blob: 9678d6e0ca930d2c2c85ee33b9375d98f32afec2 [file] [log] [blame]
// 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 List<SourceLine> lines = <SourceLine>[];
int lineNumber = startLine ?? 0;
final List<String> 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 String 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 SourceElement 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").
bool inPreamble = false;
final List<SourceLine> preamble = <SourceLine>[];
int lineNumber = 0;
int 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,
}) {
int dartpadCount = 0;
int sampleCount = 0;
int snippetCount = 0;
for (final SourceElement 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.
bool inSnippet = false;
// Whether or not we're in a '```dart' segment.
bool inDart = false;
bool foundSourceLink = false;
bool foundDartSection = false;
File? linkedFile;
List<SourceLine> block = <SourceLine>[];
List<String> snippetArgs = <String>[];
final List<CodeSample> samples = <CodeSample>[];
final Directory flutterRoot = FlutterInformation.instance.getFlutterRoot();
int 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 CodeSample 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 RegExp 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) {
String 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]}';
});
}
}