blob: 4b4147182b103769abc4bfb044bc37fdda03b245 [file] [log] [blame]
// 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/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) {
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';
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<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 '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 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();
}
}