// Copyright 2017 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // This script analyzes all the sample code in API docs in the Flutter source. // // It uses the following conventions: // // Code is denoted by markdown ```dart / ``` markers. // // Only code in "## Sample code" or "### Sample code" sections is examined. // Subheadings can also be specified, as in "## Sample code: foo". // // Additionally, code inside of dartdoc snippet and sample blocks // ({@tool snippet ...}{@end-tool}, and {@tool sample ...}{@end-tool}) // is recognized as sample code. Snippets are processed as separate programs, // and samples are processed in the same way as "## Sample code" blocks are. // // There are several kinds of sample code you can specify: // // * Constructor calls, typically showing what might exist in a build method. // These start with "new" or "const", and will be inserted into an assignment // expression assigning to a variable of type "dynamic" and followed by a // semicolon, for the purposes of analysis. // // * Class definitions. These start with "class", and are analyzed verbatim. // // * Other code. It gets included verbatim, though any line that says "// ..." // is considered to separate the block into multiple blocks to be processed // individually. // // In addition, you can declare code that should be included in the analysis but // not shown in the API docs by adding a comment "// Examples can assume:" to // the file (usually at the top of the file, after the imports), following by // one or more commented-out lines of code. That code is included verbatim in // the analysis. // // All the sample code of every file is analyzed together. This means you can't // have two pieces of sample code that define the same example class. // // Also, the above means that it's tricky to include verbatim imperative code // (e.g. a call to a method), since it won't be valid to have such code at the // top level. Instead, wrap it in a function or even a whole class, or make it a // valid variable declaration. import 'dart:io'; import 'package:path/path.dart' as path; // To run this: bin/cache/dart-sdk/bin/dart dev/bots/analyze-sample-code.dart final String _flutterRoot = path.dirname(path.dirname(path.dirname(path.fromUri(Platform.script)))); final String _defaultFlutterPackage = path.join(_flutterRoot, 'packages', 'flutter', 'lib'); final String _flutter = path.join(_flutterRoot, 'bin', Platform.isWindows ? 'flutter.bat' : 'flutter'); void main(List<String> arguments) { Directory flutterPackage; if (arguments.length == 1) { // Used for testing. flutterPackage = Directory(arguments.single); } else { flutterPackage = Directory(_defaultFlutterPackage); } exitCode = SampleChecker(flutterPackage).checkSamples(); } /// Checks samples and code snippets for analysis errors. /// /// Extracts dartdoc content from flutter package source code, identifies code /// sections, and writes them to a temporary directory, where 'flutter analyze' /// is used to analyze the sources for problems. If problems are found, the /// error output from the analyzer is parsed for details, and the problem /// locations are translated back to the source location. /// /// For snippets, the snippets are generated using the snippets tool, and they /// are analyzed with the samples. If errors are found in snippets, then the /// line number of the start of the snippet is given instead of the actual error /// line, since snippets get reformatted when written, and the line numbers /// don't necessarily match. It does, however, print the source of the /// problematic line. class SampleChecker { SampleChecker(this._flutterPackage) { _tempDir = Directory.systemTemp.createTempSync('flutter_analyze_sample_code.'); } /// 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)(?:| ([^}]*))}'); /// 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'/// ```dart.*$'); /// A RegExp that matches the end of a code block within dartdoc. static final RegExp _codeBlockEndRegex = RegExp(r'/// ```\s*$'); /// A RegExp that matches a Dart constructor. static final RegExp _constructorRegExp = RegExp(r'[A-Z][a-zA-Z0-9<>.]*\('); /// The temporary directory where all output is written. This will be deleted /// automatically if there are no errors. Directory _tempDir; /// The package directory for the flutter package within the flutter root dir. final Directory _flutterPackage; /// A serial number so that we can create unique expression names when we /// generate them. int _expressionId = 0; /// The exit code from the analysis process. int _exitCode = 0; // Once the snippets tool has been precompiled by Dart, this contains the AOT // snapshot. String _snippetsSnapshotPath; /// Finds the location of the snippets script. String get _snippetsExecutable { final String platformScriptPath = path.dirname(path.fromUri(Platform.script)); return path.canonicalize(path.join(platformScriptPath, '..', 'snippets', 'lib', 'main.dart')); } static List<File> _listDartFiles(Directory directory, {bool recursive = false}) { return directory.listSync(recursive: recursive, followLinks: false) .whereType<File>() .where((File file) => path.extension(file.path) == '.dart').toList(); } /// Computes the headers needed for each sample file. List<Line> get headers { if (_headers == null) { final List<String> buffer = <String>[]; buffer.add('// generated code'); buffer.add('import \'dart:async\';'); buffer.add('import \'dart:convert\';'); buffer.add('import \'dart:math\' as math;'); buffer.add('import \'dart:typed_data\';'); buffer.add('import \'dart:ui\' as ui;'); buffer.add('import \'package:flutter_test/flutter_test.dart\';'); for (File file in _listDartFiles(Directory(_defaultFlutterPackage))) { buffer.add(''); buffer.add('// ${file.path}'); buffer.add('import \'package:flutter/${path.basename(file.path)}\';'); } _headers = buffer.map<Line>((String code) => Line(code)).toList(); } return _headers; } List<Line> _headers; /// Checks all the samples in the Dart files in [_flutterPackage] for errors. int checkSamples() { _exitCode = 0; Map<String, List<AnalysisError>> errors = <String, List<AnalysisError>>{}; try { final Map<String, Section> sections = <String, Section>{}; final Map<String, Snippet> snippets = <String, Snippet>{}; _extractSamples(sections, snippets); errors = _analyze(_tempDir, sections, snippets); } finally { if (errors.isNotEmpty) { for (String filePath in errors.keys) { errors[filePath].forEach(stderr.writeln); } stderr.writeln('\nFound ${errors.length} sample code errors.'); } try { _tempDir.deleteSync(recursive: true); } on FileSystemException catch (e) { stderr.writeln('Failed to delete ${_tempDir.path}: $e'); } // If we made a snapshot, remove it (so as not to clutter up the tree). if (_snippetsSnapshotPath != null) { final File snapshot = File(_snippetsSnapshotPath); if (snapshot.existsSync()) { snapshot.deleteSync(); } } } return _exitCode; } /// Creates a name for the snippets tool to use for the snippet ID from a /// filename and starting line number. String _createNameFromSource(String prefix, String filename, int start) { String snippetId = path.relative(filename, from: _flutterPackage.path); snippetId = path.split(snippetId).join('.'); snippetId = path.basenameWithoutExtension(snippetId); snippetId = '$prefix.$snippetId.$start'; return snippetId; } // Precompiles the snippets tool if _snippetsSnapshotPath isn't set yet, and // runs the precompiled version if it is set. ProcessResult _runSnippetsScript(List<String> args) { final String workingDirectory = path.join(_flutterRoot, 'dev', 'docs'); if (_snippetsSnapshotPath == null) { _snippetsSnapshotPath = '$_snippetsExecutable.snapshot'; return Process.runSync( path.canonicalize(Platform.executable), <String>[ '--snapshot=$_snippetsSnapshotPath', '--snapshot-kind=app-jit', path.canonicalize(_snippetsExecutable), ]..addAll(args), workingDirectory: workingDirectory, ); } else { return Process.runSync( path.canonicalize(Platform.executable), <String>[path.canonicalize(_snippetsSnapshotPath)]..addAll(args), workingDirectory: workingDirectory, ); } } /// Writes out the given [snippet] to an output file in the [_tempDir] and /// returns the output file. File _writeSnippet(Snippet snippet) { // Generate the snippet. final String snippetId = _createNameFromSource('snippet', snippet.start.filename, snippet.start.line); final String inputName = '$snippetId.input'; // Now we have a filename like 'lib.src.material.foo_widget.123.dart' for each snippet. final File inputFile = File(path.join(_tempDir.path, inputName))..createSync(recursive: true); inputFile.writeAsStringSync(snippet.input.join('\n')); final File outputFile = File(path.join(_tempDir.path, '$snippetId.dart')); final List<String> args = <String>[ '--output=${outputFile.absolute.path}', '--input=${inputFile.absolute.path}', ]..addAll(snippet.args); print('Generating snippet for ${snippet.start?.filename}:${snippet.start?.line}'); final ProcessResult process = _runSnippetsScript(args); if (process.exitCode != 0) { throw 'Unable to create snippet for ${snippet.start.filename}:${snippet.start.line} ' '(using input from ${inputFile.path}):\n${process.stdout}\n${process.stderr}'; } return outputFile; } /// Extracts the samples from the Dart files in [_flutterPackage], writes them /// to disk, and adds them to the appropriate [sectionMap] or [snippetMap]. void _extractSamples(Map<String, Section> sectionMap, Map<String, Snippet> snippetMap) { final List<Section> sections = <Section>[]; final List<Snippet> snippets = <Snippet>[]; for (File file in _listDartFiles(_flutterPackage, recursive: true)) { final String relativeFilePath = path.relative(file.path, from: _flutterPackage.path); final List<String> sampleLines = file.readAsLinesSync(); final List<Section> preambleSections = <Section>[]; bool inPreamble = false; bool inSampleSection = false; bool inSnippet = false; bool inDart = false; bool foundDart = false; int lineNumber = 0; final List<String> block = <String>[]; List<String> snippetArgs = <String>[]; Line startLine; for (String line in sampleLines) { lineNumber += 1; final String trimmedLine = line.trim(); if (inSnippet) { if (!trimmedLine.startsWith(_dartDocPrefix)) { throw '$relativeFilePath:$lineNumber: Snippet section unterminated.'; } if (_dartDocSampleEndRegex.hasMatch(trimmedLine)) { snippets.add( Snippet( start: startLine, input: block, args: snippetArgs, serial: snippets.length, ), ); snippetArgs = <String>[]; block.clear(); inSnippet = false; inSampleSection = false; } else { block.add(line.replaceFirst(RegExp(r'\s*/// ?'), '')); } } else if (inPreamble) { if (line.isEmpty) { inPreamble = false; preambleSections.add(_processBlock(startLine, block)); block.clear(); } else if (!line.startsWith('// ')) { throw '$relativeFilePath:$lineNumber: Unexpected content in sample code preamble.'; } else { block.add(line.substring(3)); } } else if (inSampleSection) { if (!trimmedLine.startsWith(_dartDocPrefix) || trimmedLine.startsWith('$_dartDocPrefix ## ')) { if (inDart) { throw '$relativeFilePath:$lineNumber: Dart section inexplicably unterminated.'; } if (!foundDart) { throw '$relativeFilePath:$lineNumber: No dart block found in sample code section'; } inSampleSection = false; } else { if (inDart) { if (_codeBlockEndRegex.hasMatch(trimmedLine)) { inDart = false; final Section processed = _processBlock(startLine, block); if (preambleSections.isEmpty) { sections.add(processed); } else { sections.add(Section.combine(preambleSections ..toList() ..add(processed))); } block.clear(); } else if (trimmedLine == _dartDocPrefix) { block.add(''); } else { final int index = line.indexOf(_dartDocPrefixWithSpace); if (index < 0) { throw '$relativeFilePath:$lineNumber: Dart section inexplicably did not ' 'contain "$_dartDocPrefixWithSpace" prefix.'; } block.add(line.substring(index + 4)); } } else if (_codeBlockStartRegex.hasMatch(trimmedLine)) { assert(block.isEmpty); startLine = Line( '', filename: relativeFilePath, line: lineNumber + 1, indent: line.indexOf(_dartDocPrefixWithSpace) + _dartDocPrefixWithSpace.length, ); inDart = true; foundDart = true; } } } if (!inSampleSection) { final Match sampleMatch = _dartDocSampleBeginRegex.firstMatch(trimmedLine); if (line == '// Examples can assume:') { assert(block.isEmpty); startLine = Line('', filename: relativeFilePath, line: lineNumber + 1, indent: 3); inPreamble = true; } else if (trimmedLine == '/// ## Sample code' || trimmedLine.startsWith('/// ## Sample code:') || trimmedLine == '/// ### Sample code' || trimmedLine.startsWith('/// ### Sample code:') || sampleMatch != null) { inSnippet = sampleMatch != null ? sampleMatch[1] == 'snippet' : false; if (inSnippet) { startLine = Line( '', filename: relativeFilePath, line: lineNumber + 1, indent: line.indexOf(_dartDocPrefixWithSpace) + _dartDocPrefixWithSpace.length, ); if (sampleMatch[2] != null) { // There are arguments to the snippet tool to keep track of. snippetArgs = _splitUpQuotedArgs(sampleMatch[2]).toList(); } else { snippetArgs = <String>[]; } } inSampleSection = !inSnippet; foundDart = false; } } } } print('Found ${sections.length} sample code sections.'); for (Section section in sections) { sectionMap[_writeSection(section).path] = section; } for (Snippet snippet in snippets) { final File snippetFile = _writeSnippet(snippet); snippet.contents = snippetFile.readAsLinesSync(); snippetMap[snippetFile.absolute.path] = snippet; } } /// 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) { // Regexp to take care of splitting arguments, and handling the quotes // around arguments, if any. // // Match group 1 is the "foo=" (or "--foo=") part of the option, if any. // Match group 2 contains the quote character used (which is discarded). // Match group 3 is a quoted arg, if any, without the quotes. // Match group 4 is the unquoted arg, if any. final RegExp argMatcher = RegExp(r'([a-zA-Z\-_0-9]+=)?' // option name r'(?:' // Start a new non-capture group for the two possibilities. r'''(["'])((?:\\{2})*|(?:.*?[^\\](?:\\{2})*))\2|''' // with quotes. r'([^ ]+))'); // without quotes. final Iterable<Match> matches = argMatcher.allMatches(argsAsString); // Remove quotes around args, and if convertToArgs is true, 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. return matches.map<String>((Match match) { String option = ''; if (match[1] != null && !match[1].startsWith('-')) { option = '--'; } if (match[2] != null) { // This arg has quotes, so strip them. return '$option${match[1] ?? ''}${match[3] ?? ''}${match[4] ?? ''}'; } return '$option${match[0]}'; }); } /// Creates the configuration files necessary for the analyzer to consider /// the temporary director a package, and sets which lint rules to enforce. void _createConfigurationFiles(Directory directory) { final File pubSpec = File(path.join(directory.path, 'pubspec.yaml'))..createSync(recursive: true); final File analysisOptions = File(path.join(directory.path, 'analysis_options.yaml'))..createSync(recursive: true); pubSpec.writeAsStringSync(''' name: analyze_sample_code dependencies: flutter: sdk: flutter flutter_test: sdk: flutter '''); analysisOptions.writeAsStringSync(''' linter: rules: - unnecessary_const - unnecessary_new '''); } /// Writes out a sample section to the disk and returns the file. File _writeSection(Section section) { final String sectionId = _createNameFromSource('sample', section.start.filename, section.start.line); final File outputFile = File(path.join(_tempDir.path, '$sectionId.dart'))..createSync(recursive: true); final List<Line> mainContents = headers.toList(); mainContents.add(const Line('')); mainContents.add(Line('// From: ${section.start.filename}:${section.start.line}')); mainContents.addAll(section.code); outputFile.writeAsStringSync(mainContents.map<String>((Line line) => line.code).join('\n')); return outputFile; } /// Invokes the analyzer on the given [directory] and returns the stdout. List<String> _runAnalyzer(Directory directory) { print('Starting analysis of samples.'); _createConfigurationFiles(directory); final ProcessResult result = Process.runSync( _flutter, <String>['--no-wrap', 'analyze', '--no-preamble', '--no-congratulate', '.'], workingDirectory: directory.absolute.path, ); final List<String> stderr = result.stderr.toString().trim().split('\n'); final List<String> stdout = result.stdout.toString().trim().split('\n'); // Check out the stderr to see if the analyzer had it's own issues. if (stderr.isNotEmpty && (stderr.first.contains(' issues found. (ran in ') || stderr.first.contains(' issue found. (ran in '))) { // The "23 issues found" message goes onto stderr, which is concatenated first. stderr.removeAt(0); // If there's an "issues found" message, we put a blank line on stdout before it. if (stderr.isNotEmpty && stderr.last.isEmpty) { stderr.removeLast(); } } if (stderr.isNotEmpty) { throw 'Cannot analyze dartdocs; unexpected error output:\n$stderr'; } if (stdout.isNotEmpty && stdout.first == 'Building flutter tool...') { stdout.removeAt(0); } if (stdout.isNotEmpty && stdout.first.startsWith('Running "flutter packages get" in ')) { stdout.removeAt(0); } _exitCode = result.exitCode; return stdout; } /// Starts the analysis phase of checking the samples by invoking the analyzer /// and parsing its output to create a map of filename to [AnalysisError]s. Map<String, List<AnalysisError>> _analyze( Directory directory, Map<String, Section> sections, Map<String, Snippet> snippets, ) { final List<String> errors = _runAnalyzer(directory); final Map<String, List<AnalysisError>> analysisErrors = <String, List<AnalysisError>>{}; void addAnalysisError(File file, AnalysisError error) { if (analysisErrors.containsKey(file.path)) { analysisErrors[file.path].add(error); } else { analysisErrors[file.path] = <AnalysisError>[error]; } } final String kBullet = Platform.isWindows ? ' - ' : ' • '; // RegExp to match an error output line of the analyzer. final RegExp errorPattern = RegExp( '^ +([a-z]+)$kBullet(.+)$kBullet(.+):([0-9]+):([0-9]+)$kBullet([-a-z_]+)\$', caseSensitive: false, ); bool unknownAnalyzerErrors = false; final int headerLength = headers.length + 2; for (String error in errors) { final Match parts = errorPattern.matchAsPrefix(error); if (parts != null) { final String message = parts[2]; final File file = File(path.join(_tempDir.path, parts[3])); final List<String> fileContents = file.readAsLinesSync(); final bool isSnippet = path.basename(file.path).startsWith('snippet.'); final bool isSample = path.basename(file.path).startsWith('sample.'); final String line = parts[4]; final String column = parts[5]; final String errorCode = parts[6]; final int lineNumber = int.parse(line, radix: 10) - (isSample ? headerLength : 0); final int columnNumber = int.parse(column, radix: 10); if (lineNumber < 0 && errorCode == 'unused_import') { // We don't care about unused imports. continue; } // For when errors occur outside of the things we're trying to analyze. if (!isSnippet && !isSample) { addAnalysisError( file, AnalysisError( lineNumber, columnNumber, message, errorCode, Line( '', filename: file.path, line: lineNumber, ), ), ); throw 'Cannot analyze dartdocs; analysis errors exist in ${file.path}: $error'; } if (errorCode == 'unused_element' || errorCode == 'unused_local_variable') { // We don't really care if sample code isn't used! continue; } if (isSnippet) { addAnalysisError( file, AnalysisError( lineNumber, columnNumber, message, errorCode, null, snippet: snippets[file.path], ), ); } else { if (lineNumber < 1 || lineNumber > fileContents.length) { addAnalysisError( file, AnalysisError( lineNumber, columnNumber, message, errorCode, Line('', filename: file.path, line: lineNumber), ), ); throw 'Failed to parse error message (read line number as $lineNumber; ' 'total number of lines is ${fileContents.length}): $error'; } final Section actualSection = sections[file.path]; final Line actualLine = actualSection.code[lineNumber - 1]; if (actualLine.filename == null) { if (errorCode == 'missing_identifier' && lineNumber > 1) { if (fileContents[lineNumber - 2].endsWith(',')) { final Line actualLine = sections[file.path].code[lineNumber - 2]; addAnalysisError( file, AnalysisError( actualLine.line, actualLine.indent + fileContents[lineNumber - 2].length - 1, 'Unexpected comma at end of sample code.', errorCode, actualLine, ), ); } } else { addAnalysisError( file, AnalysisError( lineNumber - 1, columnNumber, message, errorCode, actualLine, ), ); } } else { addAnalysisError( file, AnalysisError( actualLine.line, actualLine.indent + columnNumber, message, errorCode, actualLine, ), ); } } } else { stderr.writeln('Analyzer output: $error'); unknownAnalyzerErrors = true; } } if (_exitCode == 1 && analysisErrors.isEmpty && !unknownAnalyzerErrors) { _exitCode = 0; } if (_exitCode == 0) { print('No analysis errors in samples!'); assert(analysisErrors.isEmpty); } return analysisErrors; } /// Process one block of sample code (the part inside of "```" markers). /// Splits any sections denoted by "// ..." into separate blocks to be /// processed separately. Uses a primitive heuristic to make sample blocks /// into valid Dart code. Section _processBlock(Line line, List<String> block) { if (block.isEmpty) { throw '$line: Empty ```dart block in sample code.'; } if (block.first.startsWith('new ') || block.first.startsWith('const ') || block.first.startsWith(_constructorRegExp)) { _expressionId += 1; return Section.surround(line, 'dynamic expression$_expressionId = ', block.toList(), ';'); } else if (block.first.startsWith('await ')) { _expressionId += 1; return Section.surround(line, 'Future<void> expression$_expressionId() async { ', block.toList(), ' }'); } else if (block.first.startsWith('class ') || block.first.startsWith('enum ')) { return Section.fromStrings(line, block.toList()); } else if ((block.first.startsWith('_') || block.first.startsWith('final ')) && block.first.contains(' = ')) { _expressionId += 1; return Section.surround(line, 'void expression$_expressionId() { ', block.toList(), ' }'); } else { final List<String> buffer = <String>[]; int subblocks = 0; Line subline; final List<Section> subsections = <Section>[]; for (int index = 0; index < block.length; index += 1) { // Each section of the dart code that is either split by a blank line, or with '// ...' is // treated as a separate code block. if (block[index] == '' || block[index] == '// ...') { if (subline == null) throw '${Line('', filename: line.filename, line: line.line + index, indent: line.indent)}: ' 'Unexpected blank line or "// ..." line near start of subblock in sample code.'; subblocks += 1; subsections.add(_processBlock(subline, buffer)); buffer.clear(); assert(buffer.isEmpty); subline = null; } else if (block[index].startsWith('// ')) { if (buffer.length > 1) // don't include leading comments buffer.add('/${block[index]}'); // so that it doesn't start with "// " and get caught in this again } else { subline ??= Line( block[index], filename: line.filename, line: line.line + index, indent: line.indent, ); buffer.add(block[index]); } } if (subblocks > 0) { if (subline != null) { subsections.add(_processBlock(subline, buffer)); } // Combine all of the subsections into one section, now that they've been processed. return Section.combine(subsections); } else { return Section.fromStrings(line, block.toList()); } } } } /// A class to represent a line of input code. class Line { const Line(this.code, {this.filename, this.line, this.indent}); final String filename; final int line; final int indent; final String code; String toStringWithColumn(int column) { if (column != null && indent != null) { return '$filename:$line:${column + indent}: $code'; } return toString(); } @override String toString() => '$filename:$line: $code'; } /// A class to represent a section of sample code, either marked by /// "/// ## Sample code" in the comment, or by "{@tool sample}...{@end-tool}". class Section { const Section(this.code); factory Section.combine(List<Section> sections) { final List<Line> code = <Line>[]; for (Section section in sections) { code.addAll(section.code); } return Section(code); } factory Section.fromStrings(Line firstLine, List<String> code) { final List<Line> codeLines = <Line>[]; for (int i = 0; i < code.length; ++i) { codeLines.add( Line( code[i], filename: firstLine.filename, line: firstLine.line + i, indent: firstLine.indent, ), ); } return Section(codeLines); } factory Section.surround(Line firstLine, String prefix, List<String> code, String postfix) { assert(prefix != null); assert(postfix != null); final List<Line> codeLines = <Line>[]; for (int i = 0; i < code.length; ++i) { codeLines.add( Line( code[i], filename: firstLine.filename, line: firstLine.line + i, indent: firstLine.indent, ), ); } return Section(<Line>[Line(prefix)] ..addAll(codeLines) ..add(Line(postfix))); } Line get start => code.firstWhere((Line line) => line.filename != null); final List<Line> code; } /// A class to represent a snippet in the dartdoc comments, marked by /// "{@tool snippet ...}...{@end-tool}". Snippets are processed separately from /// regular samples, because they must be injected into templates in order to be /// analyzed. class Snippet { Snippet({this.start, List<String> input, List<String> args, this.serial}) { this.input = <String>[]..addAll(input); this.args = <String>[]..addAll(args); } final Line start; final int serial; List<String> input; List<String> args; List<String> contents; @override String toString() { final StringBuffer buf = StringBuffer('snippet ${args.join(' ')}\n'); int count = start.line; for (String line in input) { buf.writeln(' ${count.toString().padLeft(4, ' ')}: $line'); count++; } return buf.toString(); } } /// A class representing an analysis error along with the context of the error. /// /// Changes how it converts to a string based on the source of the error. class AnalysisError { const AnalysisError( this.line, this.column, this.message, this.errorCode, this.source, { this.snippet, }); final int line; final int column; final String message; final String errorCode; final Line source; final Snippet snippet; @override String toString() { if (source != null) { return '${source.toStringWithColumn(column)}\n>>> $message ($errorCode)'; } else if (snippet != null) { return 'In snippet starting at ' '${snippet.start.filename}:${snippet.start.line}:${snippet.contents[line - 1]}\n' '>>> $message ($errorCode)'; } else { return '<source unknown>:$line:$column\n>>> $message ($errorCode)'; } } }