// 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 (?sample|snippet|dartpad)(?:| (?[^}]*))\}'); /// 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 (?[^\]]+) \*\*'); /// 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 elements, File assumptionsFile, { bool silent = true, }) { final List assumptions = parseAssumptions(assumptionsFile); for (final CodeSample sample in elements .expand((SourceElement element) => element.samples)) { if (sample is SnippetSample) { sample.assumptions = assumptions; } sample.metadata.addAll({ '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 lines = []; int lineNumber = startLine ?? 0; final List inputStrings = [ // 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 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([newElement], silent: silent); for (final CodeSample sample in newElement.samples) { sample.metadata.addAll({ '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 parseAssumptions(File file) { // Whether or not we're in the file-wide preamble section ("Examples can assume"). bool inPreamble = false; final List preamble = []; 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 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 block = []; List snippetArgs = []; final List samples = []; 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 = []; block = []; 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 = []; } 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 = [ sampleMatch.namedGroup('type')!, ..._splitUpQuotedArgs(sampleMatch.namedGroup('args')!) ]; } else { snippetArgs = [ sampleMatch.namedGroup('type')!, ]; } } } } } for (final CodeSample sample in samples) { sample.metadata.addAll({ '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 _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'(?