flutter_flutter/dev/snippets/lib/src/snippet_parser.dart
Kate Lovett 9d96df2364
Modernize framework lints (#179089)
WIP

Commits separated as follows:
- Update lints in analysis_options files
- Run `dart fix --apply`
- Clean up leftover analysis issues 
- Run `dart format .` in the right places.

Local analysis and testing passes. Checking CI now.

Part of https://github.com/flutter/flutter/issues/178827
- Adoption of flutter_lints in examples/api coming in a separate change
(cc @loic-sharma)

## Pre-launch Checklist

- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [ ] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
2025-11-26 01:10:39 +00:00

413 lines
15 KiB
Dart

// Copyright 2014 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 'package:path/path.dart' as path;
import 'data_types.dart';
import 'util.dart';
/// Parses [CodeSample]s from the source file given to one of the parsing routines.
///
/// - [parseFromDartdocToolFile] parses the output of the dartdoc `@tool`
/// directive, which contains the dartdoc comment lines (with comment markers
/// stripped) contained between the tool markers.
///
/// - [parseAndAddAssumptions] parses the assumptions in the "Examples can
/// assume:" block at the top of the file and adds them to the code samples
/// contained in the given [SourceElement] iterable.
class SnippetDartdocParser {
SnippetDartdocParser(this.filesystem);
final FileSystem filesystem;
/// The prefix of each comment line
static const String _dartDocPrefix = '///';
/// The prefix of each comment line with a space appended.
static const String _dartDocPrefixWithSpace = '$_dartDocPrefix ';
/// A RegExp that matches the beginning of a dartdoc snippet or sample.
static final RegExp _dartDocSampleBeginRegex = RegExp(
r'\{@tool (?<type>sample|snippet|dartpad)(?:| (?<args>[^}]*))\}',
);
/// A RegExp that matches the end of a dartdoc snippet or sample.
static final RegExp _dartDocSampleEndRegex = RegExp(r'\{@end-tool\}');
/// A RegExp that matches the start of a code block within dartdoc.
static final RegExp _codeBlockStartRegex = RegExp(r'///\s+```dart.*$');
/// A RegExp that matches the end of a code block within dartdoc.
static final RegExp _codeBlockEndRegex = RegExp(r'///\s+```\s*$');
/// A RegExp that matches a linked sample pointer.
static final RegExp _filePointerRegex = RegExp(r'\*\* See code in (?<file>[^\]]+) \*\*');
/// Parses the assumptions in the "Examples can assume:" block at the top of
/// the `assumptionsFile` and adds them to the code samples contained in the
/// given `elements` iterable.
void parseAndAddAssumptions(
Iterable<SourceElement> elements,
File assumptionsFile, {
bool silent = true,
}) {
final List<SourceLine> assumptions = parseAssumptions(assumptionsFile);
for (final CodeSample sample in elements.expand<CodeSample>(
(SourceElement element) => element.samples,
)) {
if (sample is SnippetSample) {
sample.assumptions = assumptions;
}
sample.metadata.addAll(<String, Object?>{
'id': '${sample.element}.${sample.index}',
'element': sample.element,
'sourcePath': assumptionsFile.path,
'sourceLine': sample.start.line,
});
}
}
/// Parses a file containing the output of the dartdoc `@tool` directive,
/// which contains the dartdoc comment lines (with comment markers stripped)
/// between the tool markers.
///
/// This is meant to be run as part of a dartdoc tool that handles snippets.
SourceElement parseFromDartdocToolFile(
File input, {
int? startLine,
String? element,
required File sourceFile,
String type = '',
bool silent = true,
}) {
final lines = <SourceLine>[];
int lineNumber = startLine ?? 0;
final inputStrings = <String>[
// The parser wants to read the arguments from the input, so we create a new
// tool line to match the given arguments, so that we can use the same parser for
// editing and docs generation.
'/// {@tool $type}',
// Snippet input comes in with the comment markers stripped, so we add them
// back to make it conform to the source format, so we can use the same
// parser for editing samples as we do for processing docs.
...input.readAsLinesSync().map<String>((String line) => '/// $line'.trimRight()),
'/// {@end-tool}',
];
for (final line in inputStrings) {
lines.add(SourceLine(line, element: element ?? '', line: lineNumber, file: sourceFile));
lineNumber++;
}
// No need to get assumptions: dartdoc won't give that to us.
final newElement = SourceElement(
SourceElementType.unknownType,
element!,
-1,
file: input,
comment: lines,
);
parseFromComments(<SourceElement>[newElement], silent: silent);
for (final CodeSample sample in newElement.samples) {
sample.metadata.addAll(<String, Object?>{
'id': '${sample.element}.${sample.index}',
'element': sample.element,
'sourcePath': sourceFile.path,
'sourceLine': sample.start.line,
});
}
return newElement;
}
/// This parses the assumptions in the "Examples can assume:" block from the
/// given `file`.
List<SourceLine> parseAssumptions(File file) {
// Whether or not we're in the file-wide preamble section ("Examples can assume").
var inPreamble = false;
final preamble = <SourceLine>[];
var lineNumber = 0;
var charPosition = 0;
for (final String line in file.readAsLinesSync()) {
if (inPreamble && line.trim().isEmpty) {
// Reached the end of the preamble.
break;
}
if (!line.startsWith('// ')) {
lineNumber++;
charPosition += line.length + 1;
continue;
}
if (line == '// Examples can assume:') {
inPreamble = true;
lineNumber++;
charPosition += line.length + 1;
continue;
}
if (inPreamble) {
preamble.add(
SourceLine(
line.substring(3),
startChar: charPosition,
endChar: charPosition + line.length + 1,
element: '#assumptions',
file: file,
line: lineNumber,
),
);
}
lineNumber++;
charPosition += line.length + 1;
}
return preamble;
}
/// This parses the code snippets from the documentation comments in the given
/// `elements`, and sets the resulting samples as the `samples` member of
/// each element in the supplied iterable.
void parseFromComments(Iterable<SourceElement> elements, {bool silent = true}) {
var dartpadCount = 0;
var sampleCount = 0;
var snippetCount = 0;
for (final element in elements) {
if (element.comment.isEmpty) {
continue;
}
parseComment(element);
for (final CodeSample sample in element.samples) {
switch (sample) {
case DartpadSample _:
dartpadCount++;
case ApplicationSample _:
sampleCount++;
case SnippetSample _:
snippetCount++;
}
}
}
if (!silent) {
print(
'Found:\n'
' $snippetCount snippet code blocks,\n'
' $sampleCount non-dartpad sample code sections, and\n'
' $dartpadCount dartpad sections.\n',
);
}
}
/// This parses the documentation comment on a single [SourceElement] and
/// assigns the resulting samples to the `samples` member of the given
/// `element`.
void parseComment(SourceElement element) {
// Whether or not we're in a snippet code sample.
var inSnippet = false;
// Whether or not we're in a '```dart' segment.
var inDart = false;
var foundSourceLink = false;
var foundDartSection = false;
File? linkedFile;
var block = <SourceLine>[];
var snippetArgs = <String>[];
final samples = <CodeSample>[];
final Directory flutterRoot = FlutterInformation.instance.getFlutterRoot();
var index = 0;
for (final SourceLine line in element.comment) {
final String trimmedLine = line.text.trim();
if (inSnippet) {
if (!trimmedLine.startsWith(_dartDocPrefix)) {
throw SnippetException(
'Snippet section unterminated.',
file: line.file?.path,
line: line.line,
);
}
if (_dartDocSampleEndRegex.hasMatch(trimmedLine)) {
switch (snippetArgs.first) {
case 'snippet':
samples.add(SnippetSample(block, index: index++, lineProto: line));
case 'sample':
if (linkedFile != null) {
samples.add(
ApplicationSample.fromFile(
input: block,
args: snippetArgs,
sourceFile: linkedFile,
index: index++,
lineProto: line,
),
);
break;
}
samples.add(
ApplicationSample(input: block, args: snippetArgs, index: index++, lineProto: line),
);
case 'dartpad':
if (linkedFile != null) {
samples.add(
DartpadSample.fromFile(
input: block,
args: snippetArgs,
sourceFile: linkedFile,
index: index++,
lineProto: line,
),
);
break;
}
samples.add(
DartpadSample(input: block, args: snippetArgs, index: index++, lineProto: line),
);
default:
throw SnippetException('Unknown snippet type ${snippetArgs.first}');
}
snippetArgs = <String>[];
block = <SourceLine>[];
inSnippet = false;
foundSourceLink = false;
foundDartSection = false;
linkedFile = null;
} else if (_filePointerRegex.hasMatch(trimmedLine)) {
foundSourceLink = true;
if (foundDartSection) {
throw SnippetException(
'Snippet contains a source link and a dart section. Cannot contain both.',
file: line.file?.path,
line: line.line,
);
}
if (linkedFile != null) {
throw SnippetException(
'Found more than one linked sample. Only one linked file per sample is allowed.',
file: line.file?.path,
line: line.line,
);
}
final RegExpMatch match = _filePointerRegex.firstMatch(trimmedLine)!;
linkedFile = filesystem.file(
path.join(flutterRoot.absolute.path, match.namedGroup('file')),
);
} else {
block.add(line.copyWith(text: line.text.replaceFirst(RegExp(r'\s*/// ?'), '')));
}
} else {
if (_dartDocSampleEndRegex.hasMatch(trimmedLine)) {
if (inDart) {
throw SnippetException(
"Dart section didn't terminate before end of sample",
file: line.file?.path,
line: line.line,
);
}
}
if (inDart) {
if (_codeBlockEndRegex.hasMatch(trimmedLine)) {
inDart = false;
block = <SourceLine>[];
} else if (trimmedLine == _dartDocPrefix) {
block.add(line.copyWith(text: ''));
} else {
final int index = line.text.indexOf(_dartDocPrefixWithSpace);
if (index < 0) {
throw SnippetException(
'Dart section inexplicably did not contain "$_dartDocPrefixWithSpace" prefix.',
file: line.file?.path,
line: line.line,
);
}
block.add(line.copyWith(text: line.text.substring(index + 4)));
}
} else if (_codeBlockStartRegex.hasMatch(trimmedLine)) {
if (foundSourceLink) {
throw SnippetException(
'Snippet contains a source link and a dart section. Cannot contain both.',
file: line.file?.path,
line: line.line,
);
}
assert(block.isEmpty);
inDart = true;
foundDartSection = true;
}
}
if (!inSnippet && !inDart) {
final RegExpMatch? sampleMatch = _dartDocSampleBeginRegex.firstMatch(trimmedLine);
if (sampleMatch != null) {
inSnippet =
sampleMatch.namedGroup('type') == 'snippet' ||
sampleMatch.namedGroup('type') == 'sample' ||
sampleMatch.namedGroup('type') == 'dartpad';
if (inSnippet) {
if (sampleMatch.namedGroup('args') != null) {
// There are arguments to the snippet tool to keep track of.
snippetArgs = <String>[
sampleMatch.namedGroup('type')!,
..._splitUpQuotedArgs(sampleMatch.namedGroup('args')!),
];
} else {
snippetArgs = <String>[sampleMatch.namedGroup('type')!];
}
}
}
}
}
for (final sample in samples) {
sample.metadata.addAll(<String, Object?>{
'id': '${sample.element}.${sample.index}',
'element': sample.element,
'sourcePath': sample.start.file?.path ?? '',
'sourceLine': sample.start.line,
});
}
element.replaceSamples(samples);
}
// Helper to process arguments given as a (possibly quoted) string.
//
// First, this will split the given [argsAsString] into separate arguments,
// taking any quoting (either ' or " are accepted) into account, including
// handling backslash-escaped quotes.
//
// Then, it will prepend "--" to any args that start with an identifier
// followed by an equals sign, allowing the argument parser to treat any
// "foo=bar" argument as "--foo=bar" (which is a dartdoc-ism).
Iterable<String> _splitUpQuotedArgs(String argsAsString) {
// This function is used because the arg parser package doesn't handle
// quoted args.
// Regexp to take care of splitting arguments, and handling the quotes
// around arguments, if any.
//
// Match group 1 (option) is the "foo=" (or "--foo=") part of the option, if any.
// Match group 2 (quote) contains the quote character used (which is discarded).
// Match group 3 (value) is a quoted arg, if any, without the quotes.
// Match group 4 (unquoted) is the unquoted arg, if any.
final argMatcher = RegExp(
r'(?<option>[-_a-zA-Z0-9]+=)?' // option name
r'(?:' // Start a new non-capture group for the two possibilities.
r'''(?<quote>["'])(?<value>(?:\\{2})*|(?:.*?[^\\](?:\\{2})*))\2|''' // value with quotes.
r'(?<unquoted>[^ ]+))',
); // without quotes.
final Iterable<RegExpMatch> matches = argMatcher.allMatches(argsAsString);
// Remove quotes around args, then for any args that look like assignments
// (start with valid option names followed by an equals sign), add a "--" in
// front so that they parse as options to support legacy dartdoc
// functionality of "option=value".
return matches.map<String>((RegExpMatch match) {
var option = '';
if (match.namedGroup('option') != null && !match.namedGroup('option')!.startsWith('-')) {
option = '--';
}
if (match.namedGroup('quote') != null) {
// This arg has quotes, so strip them.
return '$option'
'${match.namedGroup('value') ?? ''}'
'${match.namedGroup('unquoted') ?? ''}';
}
return '$option${match[0]}';
});
}
}