| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // 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 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.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/wiki/Contributing-to-Plugins-and-Packages#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) { |
| bool detectedChange = false; |
| int snippetCount = 0; |
| 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) { |
| 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'; |
| 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, snippetCount, errors); |
| } |
| |
| 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 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; |
| } |
| } |
| } |
| } |
| 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 ''; |
| } |
| 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(); |
| } |
| } |