| // Copyright 2013 The Flutter Authors | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import 'package:file/file.dart'; | |
| import 'common/output_utils.dart'; | |
| import 'common/package_looping_command.dart'; | |
| import 'common/repository_package.dart'; | |
| class _UpdateResult { | |
| const _UpdateResult(this.changed, this.snippetCount, this.errors); | |
| final bool changed; | |
| final int snippetCount; | |
| 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. | |
| UpdateExcerptsCommand(super.packagesDir, {super.processRunner, super.platform, super.gitDir}) { | |
| 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'; | |
| @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?> pragmas.'; | |
| @override | |
| bool get hasLongOutput => false; | |
| @override | |
| Future<PackageResult> runForPackage(RepositoryPackage package) async { | |
| final changedFiles = <File>[]; | |
| final 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 in markdownFiles) { | |
| final _UpdateResult result = _updateExcerptsIn(file); | |
| if (result.snippetCount > 0) { | |
| final String displayPath = getRelativePosixPath(file, from: package.directory); | |
| print( | |
| '${indentation}Checked ${result.snippetCount} snippet(s) in ' | |
| '$displayPath.', | |
| ); | |
| } | |
| if (result.changed) { | |
| changedFiles.add(file); | |
| } | |
| if (result.errors.isNotEmpty) { | |
| errors.addAll(result.errors); | |
| } | |
| } | |
| if (errors.isNotEmpty) { | |
| printError('${indentation}Injecting excerpts failed:'); | |
| printError(errors.join('\n$indentation')); | |
| return PackageResult.fail(); | |
| } | |
| if (getBoolArg(_failOnChangeFlag) && changedFiles.isNotEmpty) { | |
| printError( | |
| '${indentation}The following files have out of date excerpts:\n' | |
| '$indentation ${changedFiles.map((File file) => file.path).join("\n$indentation ")}\n' | |
| '\n' | |
| '${indentation}If you edited code in a .md file directly, you should ' | |
| 'instead edit the files that contain the sources of the excerpts.\n' | |
| '${indentation}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' | |
| '${indentation}For more information, see ' | |
| 'https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#readme-code', | |
| ); | |
| return PackageResult.fail(); | |
| } | |
| return PackageResult.success(); | |
| } | |
| 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) { | |
| var detectedChange = false; | |
| var snippetCount = 0; | |
| final errors = <String>[]; | |
| Directory pathBase = file.parent; | |
| final output = StringBuffer(); | |
| final existingBlock = StringBuffer(); | |
| String? language; | |
| String? excerpt; | |
| _ExcerptParseMode mode = _ExcerptParseMode.normal; | |
| var 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) { | |
| snippetCount++; | |
| 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'; | |
| case '.kt': | |
| case '.kts': | |
| language = 'kotlin'; | |
| case '.cc': | |
| case '.cpp': | |
| language = 'c++'; | |
| case '.m': | |
| language = 'objectivec'; | |
| case '.gradle': | |
| language = 'groovy'; | |
| default: | |
| language = extension.substring(1); | |
| } | |
| 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); | |
| 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); | |
| 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); | |
| } | |
| } | |
| } | |
| 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, snippetCount, errors); | |
| } | |
| String _extractExcerpt( | |
| File excerptSourceFile, | |
| String section, | |
| String plasterInside, | |
| String language, | |
| List<String> errors, | |
| ) { | |
| final buffer = <String>[]; | |
| var extracting = false; | |
| var lineNumber = 0; | |
| var maxLength = 0; | |
| var found = false; | |
| var prefix = ''; | |
| var suffix = ''; | |
| var padding = ''; | |
| switch (language) { | |
| case 'cc': | |
| case 'c++': | |
| case 'dart': | |
| case 'js': | |
| case 'kotlin': | |
| case 'rfwtxt': | |
| case 'java': | |
| case 'groovy': | |
| case 'objectivec': | |
| case 'swift': | |
| prefix = '// '; | |
| case 'css': | |
| prefix = '/* '; | |
| suffix = ' */'; | |
| case 'html': | |
| case 'xml': | |
| prefix = '<!--'; | |
| suffix = '-->'; | |
| padding = ' '; | |
| case 'yaml': | |
| prefix = '# '; | |
| case 'sh': | |
| prefix = '# '; | |
| } | |
| final startRegionMarker = '$prefix#docregion $section$suffix'; | |
| final endRegionMarker = '$prefix#enddocregion $section$suffix'; | |
| final 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; | |
| } | |
| } | |
| } | |
| } | |
| if (extracting) { | |
| errors.add('${excerptSourceFile.path}: missing "$endRegionMarker" pragma'); | |
| } | |
| if (!found) { | |
| errors.add('${excerptSourceFile.path}: did not find a "$startRegionMarker" pragma'); | |
| return ''; | |
| } | |
| if (buffer.isEmpty) { | |
| errors.add('${excerptSourceFile.path}: region "$section" is empty'); | |
| return ''; | |
| } | |
| var indent = maxLength; | |
| for (final line in buffer) { | |
| if (indent == 0) { | |
| break; | |
| } | |
| if (line.isEmpty) { | |
| continue; | |
| } | |
| for (var index = 0; index < line.length; index += 1) { | |
| if (line[index] != ' ') { | |
| if (index < indent) { | |
| indent = index; | |
| } | |
| } | |
| } | |
| } | |
| final excerpt = StringBuffer(); | |
| for (final line in buffer) { | |
| if (line.isEmpty) { | |
| excerpt.writeln(); | |
| } else { | |
| excerpt.writeln(line.substring(indent)); | |
| } | |
| } | |
| return excerpt.toString(); | |
| } | |
| } |