[flutter_plugin_tools] Reimplements the excerpt system inline in the tool, rather than relying on a separate package. (#4417)

* Allows excerpts to come from any package, not just examples.
* Fixes a bug in the excerpting logic that was causing a stray `}` to appear in one example.
* Removes the need for `build.excerpt.yaml` files.
* Remove the dependency on build_runner for excerpts.
* Reduces the time to generate the excerpts from about 10 minutes to about 5 seconds.
* Almost certainly fixes https://github.com/flutter/flutter/issues/107180 (untested).

The new logic is not quite backwards compatible; the `path-base` feature now specifies a real path to the actual source directories, rather than a path into the invisible generated `excerpts/` directory with its special structure. Also, a number of features from the previous package that were not actually used in this repository are no longer supported (such as having multiple section names per `#docregion` pragma).
This commit is contained in:
Ian Hickson
2023-07-11 14:49:24 -07:00
committed by GitHub
parent e479ca5d82
commit bd5d19113e
45 changed files with 749 additions and 1080 deletions

View File

@ -19,12 +19,6 @@ dart run script/tool/bin/flutter_plugin_tools.dart <args>
Many commands require the Flutter-bundled version of Dart to be the first `dart` in the path.
### Extra Setup
When updating sample code excerpts (`update-excerpts`) for the README.md files,
there is some [extra setup for
submodules](#update-readmemd-from-example-sources) that is necessary.
## Commands
Run with `--help` for a full list of commands and arguments, but the
@ -94,14 +88,16 @@ dart run script/tool/bin/flutter_plugin_tools.dart native-test --windows --packa
### Update README.md from Example Sources
`update-excerpts` requires sources that are in a submodule. If you didn't clone
with submodules, you will need to `git submodule update --init --recursive`
before running this command.
```sh
# Update all .md files for all packages:
dart run script/tool/bin/flutter_plugin_tools.dart update-excerpts
# Update the .md files only for one package:
dart run script/tool/bin/flutter_plugin_tools.dart update-excerpts --packages package_name
```
_See also: https://github.com/flutter/flutter/wiki/Contributing-to-Plugins-and-Packages#readme-code_
### Update CHANGELOG and Version
`update-release-info` will automatically update the version and `CHANGELOG.md`
@ -178,13 +174,3 @@ _everything_, including untracked or uncommitted files in version control.
`publish` will first check the status of the local
directory and refuse to publish if there are any mismatched files with version
control present.
## Updating the Tool
For flutter/packages, just changing the source here is all that's needed.
For changes that are relevant to flutter/packages, you will also need to:
- Update the tool's pubspec.yaml and CHANGELOG
- Publish the tool
- Update the pinned version in
[flutter/packages](https://github.com/flutter/packages/blob/main/.cirrus.yml)

View File

@ -172,20 +172,6 @@ class ReadmeCheckCommand extends PackageLoopingCommand {
errorSummary = 'Missing language identifier for code block';
}
// If any blocks use code excerpts, make sure excerpting is configured
// for the package.
if (readmeLines.any((String line) => line.startsWith(excerptTagStart))) {
const String buildRunnerConfigFile = 'build.excerpt.yaml';
if (!mainPackage.getExamples().any((RepositoryPackage example) =>
example.directory.childFile(buildRunnerConfigFile).existsSync())) {
printError('code-excerpt tag found, but the package is not configured '
'for excerpting. Follow the instructions at\n'
'$_instructionWikiUrl\n'
'for setting up a build.excerpt.yaml file.');
errorSummary ??= 'Missing code-excerpt configuration';
}
}
if (missingExcerptLines.isNotEmpty) {
for (final int lineNumber in missingExcerptLines) {
printError('${indentation}Dart code block at line $lineNumber is not '
@ -193,8 +179,7 @@ class ReadmeCheckCommand extends PackageLoopingCommand {
}
printError(
'\n${indentation}For each block listed above, add <?code-excerpt ...> '
'tag on the previous line, and ensure that a build.excerpt.yaml is '
'configured for the source example as explained at\n'
'tag on the previous line, as explained at\n'
'$_instructionWikiUrl');
errorSummary ??= 'Missing code-excerpt management for code block';
}

View File

@ -2,18 +2,20 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:io' as io;
import 'package:file/file.dart';
import 'package:meta/meta.dart';
import 'package:yaml/yaml.dart';
import 'package:yaml_edit/yaml_edit.dart';
import 'common/output_utils.dart';
import 'common/package_looping_command.dart';
import 'common/pub_utils.dart';
import 'common/repository_package.dart';
class _UpdateResult {
const _UpdateResult(this.changed, this.errors);
final bool changed;
final List<String> errors;
}
enum _ExcerptParseMode { normal, pragma, injecting }
/// A command to update .md code excerpts from code files.
class UpdateExcerptsCommand extends PackageLoopingCommand {
/// Creates a excerpt updater command instance.
@ -23,232 +25,295 @@ class UpdateExcerptsCommand extends PackageLoopingCommand {
super.platform,
super.gitDir,
}) {
argParser.addFlag(_failOnChangeFlag, hide: true);
argParser.addFlag(_noCleanupFlag,
help: 'Skips the step of cleaning up the excerpt extraction output. '
'This can be useful when debugging extraction or checking paths to '
'reference in snippets.');
argParser.addFlag(
_failOnChangeFlag,
help: 'Fail if the command does anything. '
'(Used in CI to ensure excerpts are up to date.)',
);
}
static const String _failOnChangeFlag = 'fail-on-change';
static const String _noCleanupFlag = 'no-cleanup';
static const String _buildRunnerConfigName = 'excerpt';
// The name of the build_runner configuration file that will be in an example
// directory if the package is set up to use `code-excerpt`.
static const String _buildRunnerConfigFile =
'build.$_buildRunnerConfigName.yaml';
/// The relative directory path to put the extracted excerpt yaml files.
@visibleForTesting
static const String excerptOutputDir = 'excerpts';
// The filename to store the pre-modification copy of the pubspec.
static const String _originalPubspecFilename =
'pubspec.plugin_tools_original.yaml';
@override
final String name = 'update-excerpts';
@override
final String description = 'Updates code excerpts in .md files, based '
'on code from code files, via code-excerpt';
'on code from code files, via <?code-excerpt?> pragmas.';
@override
Future<PackageResult> runForPackage(RepositoryPackage package) async {
final Iterable<RepositoryPackage> configuredExamples = package
.getExamples()
.where((RepositoryPackage example) =>
example.directory.childFile(_buildRunnerConfigFile).existsSync());
if (configuredExamples.isEmpty) {
return PackageResult.skip(
'No $_buildRunnerConfigFile found in example(s).');
}
final Directory repoRoot =
packagesDir.fileSystem.directory((await gitDir).path);
for (final RepositoryPackage example in configuredExamples) {
_addSubmoduleDependencies(example, repoRoot: repoRoot);
try {
// Ensure that dependencies are available.
if (!await runPubGet(example, processRunner, platform)) {
return PackageResult.fail(
<String>['Unable to get script dependencies']);
}
// Update the excerpts.
if (!await _extractSnippets(example)) {
return PackageResult.fail(<String>['Unable to extract excerpts']);
}
if (!await _injectSnippets(example, targetPackage: package)) {
return PackageResult.fail(<String>['Unable to inject excerpts']);
}
if (!await _injectSnippets(example, targetPackage: example)) {
return PackageResult.fail(
<String>['Unable to inject example excerpts']);
}
} finally {
// Clean up the pubspec changes and extracted excerpts directory.
_undoPubspecChanges(example);
final Directory excerptDirectory =
example.directory.childDirectory(excerptOutputDir);
if (excerptDirectory.existsSync()) {
if (getBoolArg(_noCleanupFlag)) {
final String relativeDir =
getRelativePosixPath(excerptDirectory, from: package.directory);
print(
'\n\nSKIPPING CLEANUP: Extraction output is in $relativeDir/');
} else {
excerptDirectory.deleteSync(recursive: true);
}
}
final List<File> changedFiles = <File>[];
final List<String> errors = <String>[];
final List<File> markdownFiles = package.directory
.listSync(recursive: true)
.where((FileSystemEntity entity) {
return entity is File &&
entity.basename != 'CHANGELOG.md' &&
entity.basename.toLowerCase().endsWith('.md');
})
.cast<File>()
.toList();
for (final File file in markdownFiles) {
final _UpdateResult result = _updateExcerptsIn(file);
if (result.changed) {
changedFiles.add(file);
}
if (result.errors.isNotEmpty) {
errors.addAll(result.errors);
}
}
if (getBoolArg(_failOnChangeFlag)) {
final String? stateError = await _validateRepositoryState(package);
if (stateError != null) {
printError('One or more .md files are out of sync with their source '
'excerpts.\n\n'
'If you edited code in a .md file directly, you should instead '
'edit the example source files. If you edited source files, run '
'the repository tooling\'s "$name" command on this package, and '
'update your PR with the resulting changes.\n\n'
'For more information, see '
'https://github.com/flutter/flutter/wiki/Contributing-to-Plugins-and-Packages#readme-code');
return PackageResult.fail(<String>[stateError]);
}
if (errors.isNotEmpty) {
printError('Injecting excerpts failed:');
printError(errors.join('\n'));
return PackageResult.fail();
}
if (getBoolArg(_failOnChangeFlag) && changedFiles.isNotEmpty) {
printError(
'The following files have out of date excerpts:\n'
' ${changedFiles.map((File file) => file.path).join("\n ")}\n'
'\n'
'If you edited code in a .md file directly, you should instead edit the '
'files that contain the sources of the excerpts.\n'
'If you did edit those source files, run the repository tooling\'s "$name" '
'command on this package, and update your PR with the resulting changes.\n'
'\n'
'For more information, see '
'https://github.com/flutter/flutter/wiki/Contributing-to-Plugins-and-Packages#readme-code',
);
return PackageResult.fail();
}
return PackageResult.success();
}
/// Runs the extraction step to create the excerpt files for the given
/// example, returning true on success.
Future<bool> _extractSnippets(RepositoryPackage example) async {
final int exitCode = await processRunner.runAndStream(
'dart',
<String>[
'run',
'build_runner',
'build',
'--config',
_buildRunnerConfigName,
'--output',
excerptOutputDir,
'--delete-conflicting-outputs',
],
workingDir: example.directory);
return exitCode == 0;
static const String _pragma = '<?code-excerpt';
static final RegExp _basePattern =
RegExp(r'^ *<\?code-excerpt path-base="([^"]+)"\?>$');
static final RegExp _injectPattern = RegExp(
r'^ *<\?code-excerpt "(?<path>[^ ]+) \((?<section>[^)]+)\)"(?: plaster="(?<plaster>[^"]*)")?\?>$',
);
_UpdateResult _updateExcerptsIn(File file) {
bool detectedChange = false;
final List<String> errors = <String>[];
Directory pathBase = file.parent;
final StringBuffer output = StringBuffer();
final StringBuffer existingBlock = StringBuffer();
String? language;
String? excerpt;
_ExcerptParseMode mode = _ExcerptParseMode.normal;
int lineNumber = 0;
for (final String line in file.readAsLinesSync()) {
lineNumber += 1;
switch (mode) {
case _ExcerptParseMode.normal:
if (line.contains(_pragma)) {
RegExpMatch? match = _basePattern.firstMatch(line);
if (match != null) {
pathBase =
file.parent.childDirectory(path.normalize(match.group(1)!));
} else {
match = _injectPattern.firstMatch(line);
if (match != null) {
final String excerptPath =
path.normalize(match.namedGroup('path')!);
final File excerptSourceFile = pathBase.childFile(excerptPath);
final String extension = path.extension(excerptSourceFile.path);
switch (extension) {
case '':
language = 'txt';
break;
case '.kt':
language = 'kotlin';
break;
case '.cc':
case '.cpp':
language = 'c++';
break;
default:
language = extension.substring(1);
break;
}
final String section = match.namedGroup('section')!;
final String plaster = match.namedGroup('plaster') ?? '···';
if (!excerptSourceFile.existsSync()) {
errors.add(
'${file.path}:$lineNumber: specified file "$excerptPath" (resolved to "${excerptSourceFile.path}") does not exist');
} else {
excerpt = _extractExcerpt(
excerptSourceFile, section, plaster, language, errors);
}
mode = _ExcerptParseMode.pragma;
} else {
errors.add(
'${file.path}:$lineNumber: $_pragma?> pragma does not match expected syntax or is not alone on the line');
}
}
}
output.writeln(line);
break;
case _ExcerptParseMode.pragma:
if (!line.startsWith('```')) {
errors.add(
'${file.path}:$lineNumber: expected code block but did not find one');
mode = _ExcerptParseMode.normal;
} else {
if (line.startsWith('``` ')) {
errors.add(
'${file.path}:$lineNumber: code block was followed by a space character instead of the language (expected "$language")');
mode = _ExcerptParseMode.injecting;
} else if (line != '```$language' && line != '```rfwtxt' && line != '```json') {
// We special-case rfwtxt and json because the rfw package extracts such sections from Dart files.
// If we get more special cases we should think about a more general solution.
errors.add(
'${file.path}:$lineNumber: code block has wrong language');
}
mode = _ExcerptParseMode.injecting;
}
output.writeln(line);
break;
case _ExcerptParseMode.injecting:
if (line == '```') {
if (existingBlock.toString() != excerpt) {
detectedChange = true;
}
output.write(excerpt);
output.writeln(line);
mode = _ExcerptParseMode.normal;
language = null;
excerpt = null;
existingBlock.clear();
} else {
existingBlock.writeln(line);
}
break;
}
}
if (detectedChange) {
if (errors.isNotEmpty) {
errors.add('${file.path}: skipped updating file due to errors');
} else {
try {
file.writeAsStringSync(output.toString());
} catch (e) {
errors.add(
'${file.path}: failed to update file (${e.runtimeType}: $e)');
}
}
}
return _UpdateResult(detectedChange, errors);
}
/// Runs the injection step to update [targetPackage]'s top-level .md files
/// with the latest excerpts from [example], returning true on success.
Future<bool> _injectSnippets(
RepositoryPackage example, {
required RepositoryPackage targetPackage,
}) async {
final List<String> relativeMdPaths = targetPackage.directory
.listSync()
.whereType<File>()
.where((File f) =>
f.basename.toLowerCase().endsWith('.md') &&
// Exclude CHANGELOG since it should never have excerpts.
f.basename != 'CHANGELOG.md')
.map((File f) => getRelativePosixPath(f, from: example.directory))
.toList();
if (relativeMdPaths.isEmpty) {
return true;
String _extractExcerpt(File excerptSourceFile, String section,
String plasterInside, String language, List<String> errors) {
final List<String> buffer = <String>[];
bool extracting = false;
int lineNumber = 0;
int maxLength = 0;
bool found = false;
String prefix = '';
String suffix = '';
String padding = '';
switch (language) {
case 'cc':
case 'c++':
case 'dart':
case 'js':
case 'kotlin':
case 'rfwtxt':
case 'swift':
prefix = '// ';
break;
case 'css':
prefix = '/* ';
suffix = ' */';
break;
case 'html':
case 'xml':
prefix = '<!--';
suffix = '-->';
padding = ' ';
break;
case 'yaml':
prefix = '# ';
break;
}
final int exitCode = await processRunner.runAndStream(
'dart',
<String>[
'run',
'code_excerpt_updater',
'--write-in-place',
'--yaml',
'--no-escape-ng-interpolation',
...relativeMdPaths,
],
workingDir: example.directory);
return exitCode == 0;
}
/// Adds `code_excerpter` and `code_excerpt_updater` to [package]'s
/// `dev_dependencies` using path-based references to the submodule copies.
///
/// This is done on the fly rather than being checked in so that:
/// - Just building examples don't require everyone to check out submodules.
/// - Examples can be analyzed/built even on versions of Flutter that these
/// submodules do not support.
void _addSubmoduleDependencies(RepositoryPackage package,
{required Directory repoRoot}) {
final String pubspecContents = package.pubspecFile.readAsStringSync();
// Save aside a copy of the current pubspec state. This allows restoration
// to the previous state regardless of its git status at the time the script
// ran.
package.directory
.childFile(_originalPubspecFilename)
.writeAsStringSync(pubspecContents);
// Update the actual pubspec.
final YamlEditor editablePubspec = YamlEditor(pubspecContents);
const String devDependenciesKey = 'dev_dependencies';
final YamlNode root = editablePubspec.parseAt(<String>[]);
// Ensure that there's a `dev_dependencies` entry to update.
if ((root as YamlMap)[devDependenciesKey] == null) {
editablePubspec.update(<String>['dev_dependencies'], YamlMap());
final String startRegionMarker = '$prefix#docregion $section$suffix';
final String endRegionMarker = '$prefix#enddocregion $section$suffix';
final String plaster = '$prefix$padding$plasterInside$padding$suffix';
int? indentation;
for (final String excerptLine in excerptSourceFile.readAsLinesSync()) {
final String trimmedLine = excerptLine.trimLeft();
lineNumber += 1;
if (extracting) {
if (trimmedLine == endRegionMarker) {
extracting = false;
indentation = excerptLine.length - trimmedLine.length;
} else {
if (trimmedLine == startRegionMarker) {
errors.add(
'${excerptSourceFile.path}:$lineNumber: saw "$startRegionMarker" pragma while already in a "$section" doc region');
}
if (excerptLine.length > maxLength) {
maxLength = excerptLine.length;
}
if (!excerptLine.contains('$prefix#docregion ') &&
!excerptLine.contains('$prefix#enddocregion ')) {
buffer.add(excerptLine);
}
}
} else {
if (trimmedLine == startRegionMarker) {
found = true;
extracting = true;
if (buffer.isNotEmpty && plasterInside != 'none') {
assert(indentation != null);
buffer.add('${" " * indentation!}$plaster');
indentation = null;
}
}
}
}
final Set<String> submoduleDependencies = <String>{
'code_excerpter',
'code_excerpt_updater',
};
final String relativeRootPath =
getRelativePosixPath(repoRoot, from: package.directory);
for (final String dependency in submoduleDependencies) {
editablePubspec.update(<String>[
devDependenciesKey,
dependency
], <String, String>{
'path': '$relativeRootPath/site-shared/packages/$dependency'
});
if (extracting) {
errors
.add('${excerptSourceFile.path}: missing "$endRegionMarker" pragma');
}
package.pubspecFile.writeAsStringSync(editablePubspec.toString());
}
/// Restores the version of the pubspec that was present before running
/// [_addSubmoduleDependencies].
void _undoPubspecChanges(RepositoryPackage package) {
package.directory
.childFile(_originalPubspecFilename)
.renameSync(package.pubspecFile.path);
}
/// Checks the git state, returning an error string if any .md files have
/// changed.
Future<String?> _validateRepositoryState(RepositoryPackage package) async {
final io.ProcessResult checkFiles = await processRunner.run(
'git',
<String>['ls-files', '--modified'],
workingDir: package.directory,
logOnError: true,
);
if (checkFiles.exitCode != 0) {
return 'Unable to determine local file state';
if (!found) {
errors.add(
'${excerptSourceFile.path}: did not find a "$startRegionMarker" pragma');
return '';
}
final String stdout = checkFiles.stdout as String;
final List<String> changedFiles = stdout.trim().split('\n');
final Iterable<String> changedMDFiles =
changedFiles.where((String filePath) => filePath.endsWith('.md'));
if (changedMDFiles.isNotEmpty) {
return 'Snippets are out of sync in the following files: '
'${changedMDFiles.join(', ')}';
if (buffer.isEmpty) {
errors.add('${excerptSourceFile.path}: region "$section" is empty');
return '';
}
return null;
int indent = maxLength;
for (final String line in buffer) {
if (indent == 0) {
break;
}
if (line.isEmpty) {
continue;
}
for (int index = 0; index < line.length; index += 1) {
if (line[index] != ' ') {
if (index < indent) {
indent = index;
}
}
}
}
final StringBuffer excerpt = StringBuffer();
for (final String line in buffer) {
if (line.isEmpty) {
excerpt.writeln();
} else {
excerpt.writeln(line.substring(indent));
}
}
return excerpt.toString();
}
}

View File

@ -648,7 +648,6 @@ A B C
final RepositoryPackage package = createFakePackage(
'a_package',
packagesDir,
extraFiles: <String>[kReadmeExcerptConfigPath],
);
package.readmeFile.writeAsStringSync('''
@ -672,40 +671,6 @@ A B C
);
});
test('fails when excerpts are used but the package is not configured',
() async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir);
package.readmeFile.writeAsStringSync('''
Example:
<?code-excerpt "main.dart (SomeSection)"?>
```dart
A B C
```
''');
Error? commandError;
final List<String> output = await runCapturingPrint(
runner, <String>['readme-check', '--require-excerpts'],
errorHandler: (Error e) {
commandError = e;
});
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(<Matcher>[
contains('code-excerpt tag found, but the package is not configured '
'for excerpting. Follow the instructions at\n'
'https://github.com/flutter/flutter/wiki/Contributing-to-Plugins-and-Packages\n'
'for setting up a build.excerpt.yaml file.'),
contains('Missing code-excerpt configuration'),
]),
);
});
test('fails on missing excerpt tag when requested', () async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir);

View File

@ -7,328 +7,92 @@ import 'package:file/file.dart';
import 'package:file/memory.dart';
import 'package:flutter_plugin_tools/src/common/core.dart';
import 'package:flutter_plugin_tools/src/update_excerpts_command.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
import 'common/package_command_test.mocks.dart';
import 'mocks.dart';
import 'util.dart';
void main() {
void runAllTests(MockPlatform platform) {
late FileSystem fileSystem;
late Directory packagesDir;
late RecordingProcessRunner processRunner;
late CommandRunner<void> runner;
setUp(() {
fileSystem = MemoryFileSystem();
fileSystem = MemoryFileSystem(
style: platform.isWindows
? FileSystemStyle.windows
: FileSystemStyle.posix);
packagesDir = createPackagesDirectory(fileSystem: fileSystem);
final MockGitDir gitDir = MockGitDir();
when(gitDir.path).thenReturn(packagesDir.parent.path);
processRunner = RecordingProcessRunner();
final UpdateExcerptsCommand command = UpdateExcerptsCommand(
packagesDir,
processRunner: processRunner,
platform: MockPlatform(),
gitDir: gitDir,
runner = CommandRunner<void>('', '')
..addCommand(UpdateExcerptsCommand(
packagesDir,
platform: platform,
processRunner: RecordingProcessRunner(),
gitDir: MockGitDir(),
));
});
Future<void> testInjection(String before, String source, String after,
{bool failOnChange = false}) async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir);
package.readmeFile.writeAsStringSync(before);
package.directory.childFile('main.dart').writeAsStringSync(source);
Object? errorObject;
final List<String> output = await runCapturingPrint(
runner,
<String>[
'update-excerpts',
if (failOnChange) '--fail-on-change',
],
errorHandler: (Object error) {
errorObject = error;
},
);
if (errorObject != null) {
fail('Failed: $errorObject\n\nOutput from excerpt command:\n$output');
}
expect(package.readmeFile.readAsStringSync(), after);
}
runner = CommandRunner<void>(
'update_excerpts_command', 'Test for update_excerpts_command');
runner.addCommand(command);
});
test('succeeds when nothing has changed', () async {
const String readme = '''
Example:
test('runs pub get before running scripts', () async {
final RepositoryPackage package = createFakePlugin('a_package', packagesDir,
extraFiles: <String>[kReadmeExcerptConfigPath]);
final Directory example = getExampleDir(package);
await runCapturingPrint(runner, <String>['update-excerpts']);
expect(
processRunner.recordedCalls,
containsAll(<ProcessCall>[
ProcessCall('flutter', const <String>['pub', 'get'], example.path),
ProcessCall(
'dart',
const <String>[
'run',
'build_runner',
'build',
'--config',
'excerpt',
'--output',
UpdateExcerptsCommand.excerptOutputDir,
'--delete-conflicting-outputs',
],
example.path),
]));
});
test('runs when config is present', () async {
final RepositoryPackage package = createFakePlugin('a_package', packagesDir,
extraFiles: <String>[kReadmeExcerptConfigPath]);
final Directory example = getExampleDir(package);
final List<String> output =
await runCapturingPrint(runner, <String>['update-excerpts']);
expect(
processRunner.recordedCalls,
containsAll(<ProcessCall>[
ProcessCall(
'dart',
const <String>[
'run',
'build_runner',
'build',
'--config',
'excerpt',
'--output',
UpdateExcerptsCommand.excerptOutputDir,
'--delete-conflicting-outputs',
],
example.path),
ProcessCall(
'dart',
const <String>[
'run',
'code_excerpt_updater',
'--write-in-place',
'--yaml',
'--no-escape-ng-interpolation',
'../README.md',
],
example.path),
]));
expect(
output,
containsAllInOrder(<Matcher>[
contains('Ran for 1 package(s)'),
]));
});
test('updates example readme when config is present', () async {
final RepositoryPackage package = createFakePlugin('a_package', packagesDir,
extraFiles: <String>[kReadmeExcerptConfigPath, 'example/README.md']);
final Directory example = getExampleDir(package);
final List<String> output =
await runCapturingPrint(runner, <String>['update-excerpts']);
expect(
processRunner.recordedCalls,
containsAll(<ProcessCall>[
ProcessCall(
'dart',
const <String>[
'run',
'build_runner',
'build',
'--config',
'excerpt',
'--output',
UpdateExcerptsCommand.excerptOutputDir,
'--delete-conflicting-outputs',
],
example.path),
ProcessCall(
'dart',
const <String>[
'run',
'code_excerpt_updater',
'--write-in-place',
'--yaml',
'--no-escape-ng-interpolation',
'README.md',
],
example.path),
]));
expect(
output,
containsAllInOrder(<Matcher>[
contains('Ran for 1 package(s)'),
]));
});
test('includes all top-level .md files', () async {
const String otherMdFileName = 'another_file.md';
final RepositoryPackage package = createFakePlugin('a_package', packagesDir,
extraFiles: <String>[kReadmeExcerptConfigPath, otherMdFileName]);
final Directory example = getExampleDir(package);
final List<String> output =
await runCapturingPrint(runner, <String>['update-excerpts']);
expect(
processRunner.recordedCalls,
containsAll(<ProcessCall>[
ProcessCall(
'dart',
const <String>[
'run',
'build_runner',
'build',
'--config',
'excerpt',
'--output',
UpdateExcerptsCommand.excerptOutputDir,
'--delete-conflicting-outputs',
],
example.path),
ProcessCall(
'dart',
const <String>[
'run',
'code_excerpt_updater',
'--write-in-place',
'--yaml',
'--no-escape-ng-interpolation',
'../README.md',
'../$otherMdFileName',
],
example.path),
]));
expect(
output,
containsAllInOrder(<Matcher>[
contains('Ran for 1 package(s)'),
]));
});
test('skips when no config is present', () async {
createFakePlugin('a_package', packagesDir);
final List<String> output =
await runCapturingPrint(runner, <String>['update-excerpts']);
expect(processRunner.recordedCalls, isEmpty);
expect(
output,
containsAllInOrder(<Matcher>[
contains('Skipped 1 package(s)'),
]));
});
test('restores pubspec even if running the script fails', () async {
final RepositoryPackage package = createFakePlugin('a_package', packagesDir,
extraFiles: <String>[kReadmeExcerptConfigPath]);
processRunner.mockProcessesForExecutable['flutter'] = <FakeProcessInfo>[
FakeProcessInfo(MockProcess(exitCode: 1), <String>['pub', 'get'])
];
Error? commandError;
final List<String> output = await runCapturingPrint(
runner, <String>['update-excerpts'], errorHandler: (Error e) {
commandError = e;
});
// Check that it's definitely a failure in a step between making the changes
// and restoring the original.
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(<Matcher>[
contains('The following packages had errors:'),
contains('a_package:\n'
' Unable to get script dependencies')
]));
final String examplePubspecContent =
package.getExamples().first.pubspecFile.readAsStringSync();
expect(examplePubspecContent, isNot(contains('code_excerpter')));
expect(examplePubspecContent, isNot(contains('code_excerpt_updater')));
});
test('fails if pub get fails', () async {
createFakePlugin('a_package', packagesDir,
extraFiles: <String>[kReadmeExcerptConfigPath]);
processRunner.mockProcessesForExecutable['flutter'] = <FakeProcessInfo>[
FakeProcessInfo(MockProcess(exitCode: 1), <String>['pub', 'get'])
];
Error? commandError;
final List<String> output = await runCapturingPrint(
runner, <String>['update-excerpts'], errorHandler: (Error e) {
commandError = e;
});
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(<Matcher>[
contains('The following packages had errors:'),
contains('a_package:\n'
' Unable to get script dependencies')
]));
});
test('fails if extraction fails', () async {
createFakePlugin('a_package', packagesDir,
extraFiles: <String>[kReadmeExcerptConfigPath]);
processRunner.mockProcessesForExecutable['dart'] = <FakeProcessInfo>[
FakeProcessInfo(MockProcess(exitCode: 1), <String>['run', 'build_runner'])
];
Error? commandError;
final List<String> output = await runCapturingPrint(
runner, <String>['update-excerpts'], errorHandler: (Error e) {
commandError = e;
});
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(<Matcher>[
contains('The following packages had errors:'),
contains('a_package:\n'
' Unable to extract excerpts')
]));
});
test('fails if injection fails', () async {
createFakePlugin('a_package', packagesDir,
extraFiles: <String>[kReadmeExcerptConfigPath]);
processRunner.mockProcessesForExecutable['dart'] = <FakeProcessInfo>[
FakeProcessInfo(MockProcess(), <String>['run', 'build_runner']),
FakeProcessInfo(
MockProcess(exitCode: 1), <String>['run', 'code_excerpt_updater']),
];
Error? commandError;
final List<String> output = await runCapturingPrint(
runner, <String>['update-excerpts'], errorHandler: (Error e) {
commandError = e;
});
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(<Matcher>[
contains('The following packages had errors:'),
contains('a_package:\n'
' Unable to inject excerpts')
]));
<?code-excerpt "main.dart (SomeSection)"?>
```dart
A B C
```
''';
const String source = '''
FAIL
// #docregion SomeSection
A B C
// #enddocregion SomeSection
FAIL
''';
await testInjection(readme, source, readme);
});
test('fails if example injection fails', () async {
createFakePlugin('a_package', packagesDir,
extraFiles: <String>[kReadmeExcerptConfigPath, 'example/README.md']);
final RepositoryPackage package =
createFakePackage('a_package', packagesDir);
package.readmeFile.writeAsStringSync('''
Example:
processRunner.mockProcessesForExecutable['dart'] = <FakeProcessInfo>[
FakeProcessInfo(MockProcess(), <String>['run', 'build_runner']),
FakeProcessInfo(MockProcess(), <String>['run', 'code_excerpt_updater']),
FakeProcessInfo(
MockProcess(exitCode: 1), <String>['run', 'code_excerpt_updater']),
];
<?code-excerpt "main.dart (UnknownSection)"?>
```dart
A B C
```
''');
package.directory.childFile('main.dart').writeAsStringSync('''
FAIL
// #docregion SomeSection
A B C
// #enddocregion SomeSection
FAIL
''');
Error? commandError;
final List<String> output = await runCapturingPrint(
@ -338,22 +102,61 @@ void main() {
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(<Matcher>[
contains('The following packages had errors:'),
contains('a_package:\n'
' Unable to inject example excerpts')
]));
output,
containsAllInOrder(<Matcher>[
contains('Injecting excerpts failed:'),
contains(
'main.dart: did not find a "// #docregion UnknownSection" pragma'),
]),
);
});
test('updates files', () async {
await testInjection(
'''
Example:
<?code-excerpt "main.dart (SomeSection)"?>
```dart
X Y Z
```
''',
'''
FAIL
// #docregion SomeSection
A B C
// #enddocregion SomeSection
FAIL
''',
'''
Example:
<?code-excerpt "main.dart (SomeSection)"?>
```dart
A B C
```
''',
);
});
test('fails if READMEs are changed with --fail-on-change', () async {
createFakePlugin('a_plugin', packagesDir,
extraFiles: <String>[kReadmeExcerptConfigPath]);
final RepositoryPackage package =
createFakePackage('a_package', packagesDir);
package.readmeFile.writeAsStringSync('''
Example:
const String changedFilePath = 'README.md';
processRunner.mockProcessesForExecutable['git'] = <FakeProcessInfo>[
FakeProcessInfo(MockProcess(stdout: changedFilePath)),
];
<?code-excerpt "main.dart (SomeSection)"?>
```dart
X Y Z
```
''');
package.directory.childFile('main.dart').writeAsStringSync('''
FAIL
// #docregion SomeSection
A B C
// #enddocregion SomeSection
FAIL
''');
Error? commandError;
final List<String> output = await runCapturingPrint(
@ -364,101 +167,259 @@ void main() {
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(<Matcher>[
contains(
'One or more .md files are out of sync with their source excerpts'),
contains('Snippets are out of sync in the following files: '
'$changedFilePath'),
]));
output.join('\n'),
contains('The following files have out of date excerpts:'),
);
});
test('passes if unrelated files are changed with --fail-on-change', () async {
createFakePlugin('a_plugin', packagesDir,
extraFiles: <String>[kReadmeExcerptConfigPath]);
test('does not fail if READMEs are not changed with --fail-on-change',
() async {
const String readme = '''
Example:
const String changedFilePath = 'packages/a_plugin/linux/CMakeLists.txt';
processRunner.mockProcessesForExecutable['git'] = <FakeProcessInfo>[
FakeProcessInfo(MockProcess(stdout: changedFilePath)),
];
final List<String> output = await runCapturingPrint(
runner, <String>['update-excerpts', '--fail-on-change']);
expect(
output,
containsAllInOrder(<Matcher>[
contains('Ran for 1 package(s)'),
]));
<?code-excerpt "main.dart (aa)"?>
```dart
A
```
<?code-excerpt "main.dart (bb)"?>
```dart
B
```
''';
await testInjection(
readme,
'''
// #docregion aa
A
// #enddocregion aa
// #docregion bb
B
// #enddocregion bb
''',
readme,
failOnChange: true,
);
});
test('fails if git ls-files fails', () async {
createFakePlugin('a_plugin', packagesDir,
extraFiles: <String>[kReadmeExcerptConfigPath]);
test('indents the plaster', () async {
await testInjection(
'''
Example:
processRunner.mockProcessesForExecutable['git'] = <FakeProcessInfo>[
FakeProcessInfo(MockProcess(exitCode: 1))
];
Error? commandError;
final List<String> output = await runCapturingPrint(
runner, <String>['update-excerpts', '--fail-on-change'],
errorHandler: (Error e) {
commandError = e;
});
<?code-excerpt "main.dart (SomeSection)"?>
```dart
```
''',
'''
// #docregion SomeSection
A
// #enddocregion SomeSection
// #docregion SomeSection
B
// #enddocregion SomeSection
''',
'''
Example:
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(<Matcher>[
contains('Unable to determine local file state'),
]));
<?code-excerpt "main.dart (SomeSection)"?>
```dart
A
// ···
B
```
''',
);
});
test('cleans up excerpt output by default', () async {
final RepositoryPackage package = createFakePackage(
'a_package', packagesDir,
extraFiles: <String>[kReadmeExcerptConfigPath]);
// Simulate the creation of the output directory.
final Directory excerptOutputDir = package
.getExamples()
.first
.directory
.childDirectory(UpdateExcerptsCommand.excerptOutputDir);
excerptOutputDir.createSync(recursive: true);
test('does not unindent blocks if plaster will not unindent', () async {
await testInjection(
'''
Example:
const String changedFilePath = 'packages/a_plugin/linux/CMakeLists.txt';
processRunner.mockProcessesForExecutable['git'] = <FakeProcessInfo>[
FakeProcessInfo(MockProcess(stdout: changedFilePath)),
];
<?code-excerpt "main.dart (SomeSection)"?>
```dart
```
''',
'''
// #docregion SomeSection
A
// #enddocregion SomeSection
// #docregion SomeSection
B
// #enddocregion SomeSection
''',
'''
Example:
<?code-excerpt "main.dart (SomeSection)"?>
```dart
A
// ···
B
```
''',
);
});
test('unindents blocks', () async {
await testInjection(
'''
Example:
<?code-excerpt "main.dart (SomeSection)"?>
```dart
```
''',
'''
// #docregion SomeSection
A
// #enddocregion SomeSection
// #docregion SomeSection
B
// #enddocregion SomeSection
''',
'''
Example:
<?code-excerpt "main.dart (SomeSection)"?>
```dart
A
// ···
B
```
''',
);
});
test('unindents blocks and plaster', () async {
await testInjection(
'''
Example:
<?code-excerpt "main.dart (SomeSection)"?>
```dart
```
''',
'''
// #docregion SomeSection
A
// #enddocregion SomeSection
// #docregion SomeSection
B
// #enddocregion SomeSection
''',
'''
Example:
<?code-excerpt "main.dart (SomeSection)"?>
```dart
A
// ···
B
```
''',
);
});
test('relative path bases', () async {
final RepositoryPackage package =
createFakePackage('a_package', packagesDir);
package.readmeFile.writeAsStringSync('''
<?code-excerpt "main.dart (a)"?>
```dart
```
<?code-excerpt "test/main.dart (a)"?>
```dart
```
<?code-excerpt "test/test/main.dart (a)"?>
```dart
```
<?code-excerpt path-base="test"?>
<?code-excerpt "main.dart (a)"?>
```dart
```
<?code-excerpt "../main.dart (a)"?>
```dart
```
<?code-excerpt "test/main.dart (a)"?>
```dart
```
<?code-excerpt path-base="/packages/a_package"?>
<?code-excerpt "main.dart (a)"?>
```dart
```
<?code-excerpt "test/main.dart (a)"?>
```dart
```
''');
package.directory.childFile('main.dart').writeAsStringSync('''
// #docregion a
X
// #enddocregion a
''');
package.directory.childDirectory('test').createSync();
package.directory
.childDirectory('test')
.childFile('main.dart')
.writeAsStringSync('''
// #docregion a
Y
// #enddocregion a
''');
package.directory
.childDirectory('test')
.childDirectory('test')
.createSync();
package.directory
.childDirectory('test')
.childDirectory('test')
.childFile('main.dart')
.writeAsStringSync('''
// #docregion a
Z
// #enddocregion a
''');
await runCapturingPrint(runner, <String>['update-excerpts']);
expect(excerptOutputDir.existsSync(), false);
});
test('cleans up excerpt output by default', () async {
final RepositoryPackage package = createFakePackage(
'a_package', packagesDir,
extraFiles: <String>[kReadmeExcerptConfigPath]);
// Simulate the creation of the output directory.
const String outputDirName = UpdateExcerptsCommand.excerptOutputDir;
final Directory excerptOutputDir =
package.getExamples().first.directory.childDirectory(outputDirName);
excerptOutputDir.createSync(recursive: true);
const String changedFilePath = 'packages/a_plugin/linux/CMakeLists.txt';
processRunner.mockProcessesForExecutable['git'] = <FakeProcessInfo>[
FakeProcessInfo(MockProcess(stdout: changedFilePath)),
];
final List<String> output = await runCapturingPrint(
runner, <String>['update-excerpts', '--no-cleanup']);
expect(
output,
containsAllInOrder(<Matcher>[
contains('Extraction output is in example/$outputDirName/'),
]));
expect(excerptOutputDir.existsSync(), true);
expect(package.readmeFile.readAsStringSync(), '''
<?code-excerpt "main.dart (a)"?>
```dart
X
```
<?code-excerpt "test/main.dart (a)"?>
```dart
Y
```
<?code-excerpt "test/test/main.dart (a)"?>
```dart
Z
```
<?code-excerpt path-base="test"?>
<?code-excerpt "main.dart (a)"?>
```dart
Y
```
<?code-excerpt "../main.dart (a)"?>
```dart
X
```
<?code-excerpt "test/main.dart (a)"?>
```dart
Z
```
<?code-excerpt path-base="/packages/a_package"?>
<?code-excerpt "main.dart (a)"?>
```dart
X
```
<?code-excerpt "test/main.dart (a)"?>
```dart
Y
```
''');
});
}
void main() {
runAllTests(MockPlatform());
runAllTests(MockPlatform(isWindows: true));
}

View File

@ -23,13 +23,6 @@ import 'mocks.dart';
export 'package:flutter_plugin_tools/src/common/repository_package.dart';
/// The relative path from a package to the file that is used to enable
/// README excerpting for a package.
// This is a shared constant to ensure that both readme-check and
// update-excerpt are looking for the same file, so that readme-check can't
// get out of sync with what actually drives excerpting.
const String kReadmeExcerptConfigPath = 'example/build.excerpt.yaml';
const String _defaultDartConstraint = '>=2.14.0 <4.0.0';
const String _defaultFlutterConstraint = '>=2.5.0';