// 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 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 pragmas.'; @override bool get hasLongOutput => false; @override Future runForPackage(RepositoryPackage package) async { final List changedFiles = []; final List errors = []; final List markdownFiles = package.directory .listSync(recursive: true) .where((FileSystemEntity entity) { return entity is File && entity.basename != 'CHANGELOG.md' && entity.basename.toLowerCase().endsWith('.md'); }) .cast() .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 = '$'); static final RegExp _injectPattern = RegExp( r'^ *<\?code-excerpt "(?[^ ]+) \((?
[^)]+)\)"(?: plaster="(?[^"]*)")?\?>$', ); _UpdateResult _updateExcerptsIn(File file) { bool detectedChange = false; int snippetCount = 0; final List errors = []; 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'; case '.kt': language = 'kotlin'; case '.cc': case '.cpp': language = 'c++'; case '.m': language = 'objectivec'; case '.gradle': language = 'groovy'; 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); 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 errors) { final List buffer = []; 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 'java': case 'groovy': case 'objectivec': case 'swift': prefix = '// '; case 'css': prefix = '/* '; suffix = ' */'; case 'html': case 'xml': prefix = ''; padding = ' '; case 'yaml': prefix = '# '; case 'sh': prefix = '# '; } 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(); } }