| // 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, 'packages', 'flutter', 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]}'; | |
| }); | |
| } | |
| } |