analyze_sample_code.dart 41.4 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6 7
// See ../snippets/README.md for documentation.

// To run this, from the root of the Flutter repository:
8
//   bin/cache/dart-sdk/bin/dart dev/bots/analyze_sample_code.dart
9

10 11
// @dart= 2.12

12
import 'dart:convert';
13 14
import 'dart:io';

15
import 'package:args/args.dart';
16
import 'package:path/path.dart' as path;
17
import 'package:watcher/watcher.dart';
18 19

final String _flutterRoot = path.dirname(path.dirname(path.dirname(path.fromUri(Platform.script))));
20
final String _defaultFlutterPackage = path.join(_flutterRoot, 'packages', 'flutter', 'lib');
21
final String _defaultDartUiLocation = path.join(_flutterRoot, 'bin', 'cache', 'pkg', 'sky_engine', 'lib', 'ui');
22 23
final String _flutter = path.join(_flutterRoot, 'bin', Platform.isWindows ? 'flutter.bat' : 'flutter');

24
void main(List<String> arguments) {
25 26 27 28 29
  final ArgParser argParser = ArgParser();
  argParser.addOption(
    'temp',
    defaultsTo: null,
    help: 'A location where temporary files may be written. Defaults to a '
30 31
          'directory in the system temp folder. If specified, will not be '
          'automatically removed at the end of execution.',
32
  );
33 34 35 36 37 38
  argParser.addFlag(
    'verbose',
    defaultsTo: false,
    negatable: false,
    help: 'Print verbose output for the analysis process.',
  );
39 40 41 42 43 44 45 46 47 48 49 50 51 52
  argParser.addOption(
    'dart-ui-location',
    defaultsTo: _defaultDartUiLocation,
    help: 'A location where the dart:ui dart files are to be found. Defaults to '
          'the sky_engine directory installed in this flutter repo. This '
          'is typically the engine/src/flutter/lib/ui directory in an engine dev setup. '
          'Implies --include-dart-ui.',
  );
  argParser.addFlag(
    'include-dart-ui',
    defaultsTo: true,
    negatable: true,
    help: 'Includes the dart:ui code supplied by the engine in the analysis.',
  );
53 54 55 56 57 58
  argParser.addFlag(
    'help',
    defaultsTo: false,
    negatable: false,
    help: 'Print help for this command.',
  );
59 60 61
  argParser.addOption(
    'interactive',
    abbr: 'i',
62
    help: 'Analyzes the sample code in the specified file interactively.',
63
  );
64 65 66

  final ArgResults parsedArguments = argParser.parse(arguments);

67
  if (parsedArguments['help'] as bool) {
68
    print(argParser.usage);
69
    print('See dev/snippets/README.md for documentation.');
70 71 72
    exit(0);
  }

73
  Directory flutterPackage;
74
  if (parsedArguments.rest.length == 1) {
75
    // Used for testing.
76
    flutterPackage = Directory(parsedArguments.rest.single);
77 78
  } else {
    flutterPackage = Directory(_defaultFlutterPackage);
79
  }
80

81 82 83 84 85 86 87 88 89 90 91 92 93 94
  final bool includeDartUi = parsedArguments.wasParsed('dart-ui-location') || parsedArguments['include-dart-ui'] as bool;
  late Directory dartUiLocation;
  if (((parsedArguments['dart-ui-location'] ?? '') as String).isNotEmpty) {
    dartUiLocation = Directory(
        path.absolute(parsedArguments['dart-ui-location'] as String));
  } else {
    dartUiLocation = Directory(_defaultDartUiLocation);
  }
  if (!dartUiLocation.existsSync()) {
    stderr.writeln('Unable to find dart:ui directory ${dartUiLocation.path}');
    exit(-1);
  }

  Directory? tempDirectory;
95
  if (parsedArguments.wasParsed('temp')) {
96 97 98
    final String tempArg = parsedArguments['temp'] as String;
    tempDirectory = Directory(path.join(Directory.systemTemp.absolute.path, path.basename(tempArg)));
    if (path.basename(tempArg) != tempArg) {
99 100 101 102 103 104 105 106 107 108
      stderr.writeln('Supplied temporary directory name should be a name, not a path. Using ${tempDirectory.absolute.path} instead.');
    }
    print('Leaving temporary output in ${tempDirectory.absolute.path}.');
    // Make sure that any directory left around from a previous run is cleared
    // out.
    if (tempDirectory.existsSync()) {
      tempDirectory.deleteSync(recursive: true);
    }
    tempDirectory.createSync();
  }
109 110

  if (parsedArguments['interactive'] != null) {
111 112 113 114 115 116
    _runInteractive(
      tempDir: tempDirectory,
      flutterPackage: flutterPackage,
      filePath: parsedArguments['interactive'] as String,
      dartUiLocation: includeDartUi ? dartUiLocation : null,
    );
117 118 119 120 121 122
  } else {
    try {
      exitCode = SampleChecker(
        flutterPackage,
        tempDirectory: tempDirectory,
        verbose: parsedArguments['verbose'] as bool,
123
        dartUiLocation: includeDartUi ? dartUiLocation : null,
124 125 126 127 128
      ).checkSamples();
    } on SampleCheckerException catch (e) {
      stderr.write(e);
      exit(1);
    }
129 130 131 132 133 134
  }
}

class SampleCheckerException implements Exception {
  SampleCheckerException(this.message, {this.file, this.line});
  final String message;
135 136
  final String? file;
  final int? line;
137 138 139 140 141 142 143 144 145

  @override
  String toString() {
    if (file != null || line != null) {
      final String fileStr = file == null ? '' : '$file:';
      final String lineStr = line == null ? '' : '$line:';
      return '$fileStr$lineStr Error: $message';
    } else {
      return 'Error: $message';
146 147
    }
  }
148 149
}

150 151 152 153 154 155 156 157
/// 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.
///
158 159 160 161
/// For samples, the samples are generated using the snippets tool, and they
/// are analyzed with the snippets. If errors are found in samples, then the
/// line number of the start of the sample is given instead of the actual error
/// line, since samples get reformatted when written, and the line numbers
162 163 164
/// don't necessarily match. It does, however, print the source of the
/// problematic line.
class SampleChecker {
165 166
  /// Creates a [SampleChecker].
  ///
nt4f04uNd's avatar
nt4f04uNd committed
167
  /// The positional argument is the path to the package directory for the
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188
  /// flutter package within the Flutter root dir.
  ///
  /// The optional `tempDirectory` argument supplies the location for the
  /// temporary files to be written and analyzed. If not supplied, it defaults
  /// to a system generated temp directory.
  ///
  /// The optional `verbose` argument indicates whether or not status output
  /// should be emitted while doing the check.
  ///
  /// The optional `dartUiLocation` argument indicates the location of the
  /// `dart:ui` code to be analyzed along with the framework code. If not
  /// supplied, the default location of the `dart:ui` code in the Flutter
  /// repository is used (i.e. "<flutter repo>/bin/cache/pkg/sky_engine/lib/ui").
  SampleChecker(
    this._flutterPackage, {
    Directory? tempDirectory,
    this.verbose = false,
    Directory? dartUiLocation,
  }) : _tempDirectory = tempDirectory ?? Directory.systemTemp.createTempSync('flutter_analyze_sample_code.'),
       _keepTmp = tempDirectory != null,
       _dartUiLocation = dartUiLocation;
189 190 191 192 193 194 195 196

  /// 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.
197
  static final RegExp _dartDocSampleBeginRegex = RegExp(r'{@tool (sample|snippet|dartpad)(?:| ([^}]*))}');
198 199 200 201 202

  /// 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.
203
  static final RegExp _codeBlockStartRegex = RegExp(r'///\s+```dart.*$');
204 205

  /// A RegExp that matches the end of a code block within dartdoc.
206
  static final RegExp _codeBlockEndRegex = RegExp(r'///\s+```\s*$');
207 208

  /// A RegExp that matches a Dart constructor.
209
  static final RegExp _constructorRegExp = RegExp(r'(const\s+)?_*[A-Z][a-zA-Z0-9<>._]*\(');
210

211 212 213
  /// A RegExp that matches a dart version specification in an example preamble.
  static final RegExp _dartVersionRegExp = RegExp(r'\/\/ \/\/ @dart = ([0-9]+\.[0-9]+)');

214 215 216
  /// Whether or not to print verbose output.
  final bool verbose;

217 218 219 220 221
  /// Whether or not to keep the temp directory around after running.
  ///
  /// Defaults to false.
  final bool _keepTmp;

222 223
  /// The temporary directory where all output is written. This will be deleted
  /// automatically if there are no errors.
224
  final Directory _tempDirectory;
225 226 227 228

  /// The package directory for the flutter package within the flutter root dir.
  final Directory _flutterPackage;

229 230 231 232 233 234 235
  /// The directory for the dart:ui code to be analyzed with the flutter code.
  ///
  /// If this is null, then no dart:ui code is included in the analysis.  It
  /// defaults to the location inside of the flutter bin/cache directory that
  /// contains the dart:ui code supplied by the engine.
  final Directory? _dartUiLocation;

236 237 238 239 240 241 242 243 244
  /// 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.
245
  String? _snippetsSnapshotPath;
246 247 248

  /// Finds the location of the snippets script.
  String get _snippetsExecutable {
249
    final String platformScriptPath = path.dirname(path.fromUri(Platform.script));
250 251 252
    return path.canonicalize(path.join(platformScriptPath, '..', 'snippets', 'lib', 'main.dart'));
  }

253 254 255 256 257 258
  /// Finds the location of the Dart executable.
  String get _dartExecutable {
    final File dartExecutable = File(Platform.resolvedExecutable);
    return dartExecutable.absolute.path;
  }

259
  static List<File> _listDartFiles(Directory directory, {bool recursive = false}) {
260
    return directory.listSync(recursive: recursive, followLinks: false).whereType<File>().where((File file) => path.extension(file.path) == '.dart').toList();
261 262 263 264
  }

  /// Computes the headers needed for each sample file.
  List<Line> get headers {
265 266
    return _headers ??= <String>[
      '// generated code',
267
      '// ignore_for_file: directives_ordering',
268
      '// ignore_for_file: unnecessary_import',
269
      '// ignore_for_file: unused_import',
270 271
      '// ignore_for_file: unused_element',
      '// ignore_for_file: unused_local_variable',
272 273 274 275 276 277
      "import 'dart:async';",
      "import 'dart:convert';",
      "import 'dart:math' as math;",
      "import 'dart:typed_data';",
      "import 'dart:ui' as ui;",
      "import 'package:flutter_test/flutter_test.dart';",
278
      for (final File file in _listDartFiles(Directory(_defaultFlutterPackage))) ...<String>[
279 280 281 282 283
        '',
        '// ${file.path}',
        "import 'package:flutter/${path.basename(file.path)}';",
      ],
    ].map<Line>((String code) => Line(code)).toList();
284 285
  }

286
  List<Line>? _headers;
287 288 289 290 291 292 293

  /// 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>{};
294
      final Map<String, Sample> snippets = <String, Sample>{};
295 296 297 298 299 300 301 302
      if (_dartUiLocation != null && !_dartUiLocation!.existsSync()) {
        stderr.writeln('Unable to analyze engine dart samples at ${_dartUiLocation!.path}.');
      }
      final List<File> filesToAnalyze = <File>[
        ..._listDartFiles(_flutterPackage, recursive: true),
        if (_dartUiLocation != null && _dartUiLocation!.existsSync()) ... _listDartFiles(_dartUiLocation!, recursive: true),
      ];
      _extractSamples(filesToAnalyze, sectionMap: sections, sampleMap: snippets);
303
      errors = _analyze(_tempDirectory, sections, snippets);
304 305
    } finally {
      if (errors.isNotEmpty) {
306
        for (final String filePath in errors.keys) {
307
          errors[filePath]!.forEach(stderr.writeln);
308 309 310
        }
        stderr.writeln('\nFound ${errors.length} sample code errors.');
      }
311 312 313
      if (_keepTmp) {
        print('Leaving temporary directory ${_tempDirectory.path} around for your perusal.');
      } else {
314 315 316 317 318
        try {
          _tempDirectory.deleteSync(recursive: true);
        } on FileSystemException catch (e) {
          stderr.writeln('Failed to delete ${_tempDirectory.path}: $e');
        }
319 320 321
      }
      // If we made a snapshot, remove it (so as not to clutter up the tree).
      if (_snippetsSnapshotPath != null) {
322
        final File snapshot = File(_snippetsSnapshotPath!);
323 324 325 326
        if (snapshot.existsSync()) {
          snapshot.deleteSync();
        }
      }
327
    }
328
    return _exitCode;
329
  }
330 331 332 333

  /// 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) {
334 335 336 337
    String sampleId = path.split(filename).join('.');
    sampleId = path.basenameWithoutExtension(sampleId);
    sampleId = '$prefix.$sampleId.$start';
    return sampleId;
338 339
  }

340 341 342
  // Precompiles the snippets tool if _snippetsSnapshotPath isn't set yet, and
  // runs the precompiled version if it is set.
  ProcessResult _runSnippetsScript(List<String> args) {
343
    final String workingDirectory = path.join(_flutterRoot, 'dev', 'docs');
344 345 346
    if (_snippetsSnapshotPath == null) {
      _snippetsSnapshotPath = '$_snippetsExecutable.snapshot';
      return Process.runSync(
347
        _dartExecutable,
348 349 350
        <String>[
          '--snapshot=$_snippetsSnapshotPath',
          '--snapshot-kind=app-jit',
351
          path.canonicalize(_snippetsExecutable),
352 353
          ...args,
        ],
354
        workingDirectory: workingDirectory,
355
      );
356
    } else {
357
      return Process.runSync(
358
        _dartExecutable,
359 360 361 362
        <String>[
          path.canonicalize(_snippetsSnapshotPath!),
          ...args,
        ],
363
        workingDirectory: workingDirectory,
364
      );
365
    }
366 367
  }

368
  /// Writes out the given sample to an output file in the [_tempDirectory] and
369
  /// returns the output file.
370
  File _writeSample(Sample sample) {
371
    // Generate the snippet.
372 373
    final String sampleId = _createNameFromSource('sample', sample.start.filename, sample.start.line);
    final String inputName = '$sampleId.input';
374
    // Now we have a filename like 'lib.src.material.foo_widget.123.dart' for each snippet.
375
    final File inputFile = File(path.join(_tempDirectory.path, inputName))..createSync(recursive: true);
376 377
    inputFile.writeAsStringSync(sample.input.join('\n'));
    final File outputFile = File(path.join(_tempDirectory.path, '$sampleId.dart'));
378 379 380
    final List<String> args = <String>[
      '--output=${outputFile.absolute.path}',
      '--input=${inputFile.absolute.path}',
381
      ...sample.args,
382
    ];
383
    if (verbose)
384
      print('Generating sample for ${sample.start.filename}:${sample.start.line}');
385
    final ProcessResult process = _runSnippetsScript(args);
386
    if (verbose)
387
      stderr.write('${process.stderr}');
388
    if (process.exitCode != 0) {
389
      throw SampleCheckerException(
390
        'Unable to create sample for ${sample.start.filename}:${sample.start.line} '
391
            '(using input from ${inputFile.path}):\n${process.stdout}\n${process.stderr}',
392 393
        file: sample.start.filename,
        line: sample.start.line,
394
      );
395 396 397 398
    }
    return outputFile;
  }

399
  /// Extracts the samples from the Dart files in [files], writes them
400
  /// to disk, and adds them to the appropriate [sectionMap] or [sampleMap].
401 402 403 404 405 406
  void _extractSamples(
    List<File> files, {
    required Map<String, Section> sectionMap,
    required Map<String, Sample> sampleMap,
    bool silent = false,
  }) {
407
    final List<Section> sections = <Section>[];
408
    final List<Sample> samples = <Sample>[];
409 410
    int dartpadCount = 0;
    int sampleCount = 0;
411

412
    for (final File file in files) {
413
      final String relativeFilePath = path.relative(file.path, from: _flutterRoot);
414 415
      final List<String> sampleLines = file.readAsLinesSync();
      final List<Section> preambleSections = <Section>[];
416
      // Whether or not we're in the file-wide preamble section ("Examples can assume").
417
      bool inPreamble = false;
418
      // Whether or not we're in a code sample
419
      bool inSampleSection = false;
420
      // Whether or not we're in a snippet code sample (with template) specifically.
421
      bool inSnippet = false;
422
      // Whether or not we're in a '```dart' segment.
423
      bool inDart = false;
424
      String? dartVersionOverride;
425 426 427
      int lineNumber = 0;
      final List<String> block = <String>[];
      List<String> snippetArgs = <String>[];
428
      late Line startLine;
429
      for (final String line in sampleLines) {
430 431 432 433
        lineNumber += 1;
        final String trimmedLine = line.trim();
        if (inSnippet) {
          if (!trimmedLine.startsWith(_dartDocPrefix)) {
434
            throw SampleCheckerException('Snippet section unterminated.', file: relativeFilePath, line: lineNumber);
435 436
          }
          if (_dartDocSampleEndRegex.hasMatch(trimmedLine)) {
437 438
            samples.add(
              Sample(
439 440 441
                start: startLine,
                input: block,
                args: snippetArgs,
442
                serial: samples.length,
443 444 445 446 447 448 449 450 451 452 453 454
              ),
            );
            snippetArgs = <String>[];
            block.clear();
            inSnippet = false;
            inSampleSection = false;
          } else {
            block.add(line.replaceFirst(RegExp(r'\s*/// ?'), ''));
          }
        } else if (inPreamble) {
          if (line.isEmpty) {
            inPreamble = false;
455 456 457 458 459
            // If there's only a dartVersionOverride in the preamble, don't add
            // it as a section. The dartVersionOverride was processed below.
            if (dartVersionOverride == null || block.isNotEmpty) {
              preambleSections.add(_processBlock(startLine, block));
            }
460 461
            block.clear();
          } else if (!line.startsWith('// ')) {
462
            throw SampleCheckerException('Unexpected content in sample code preamble.', file: relativeFilePath, line: lineNumber);
463 464
          } else if (_dartVersionRegExp.hasMatch(line)) {
            dartVersionOverride = line.substring(3);
465 466 467 468
          } else {
            block.add(line.substring(3));
          }
        } else if (inSampleSection) {
469
          if (_dartDocSampleEndRegex.hasMatch(trimmedLine)) {
470
            if (inDart) {
471
              throw SampleCheckerException("Dart section didn't terminate before end of sample", file: relativeFilePath, line: lineNumber);
472 473
            }
            inSampleSection = false;
474 475 476 477 478
          }
          if (inDart) {
            if (_codeBlockEndRegex.hasMatch(trimmedLine)) {
              inDart = false;
              final Section processed = _processBlock(startLine, block);
479 480
              final Section combinedSection = preambleSections.isEmpty ? processed : Section.combine(preambleSections..add(processed));
              sections.add(combinedSection.copyWith(dartVersionOverride: dartVersionOverride));
481 482 483 484 485 486 487 488 489 490 491 492 493
              block.clear();
            } else if (trimmedLine == _dartDocPrefix) {
              block.add('');
            } else {
              final int index = line.indexOf(_dartDocPrefixWithSpace);
              if (index < 0) {
                throw SampleCheckerException(
                  'Dart section inexplicably did not contain "$_dartDocPrefixWithSpace" prefix.',
                  file: relativeFilePath,
                  line: lineNumber,
                );
              }
              block.add(line.substring(index + 4));
494
            }
495 496 497 498 499 500 501 502 503
          } else if (_codeBlockStartRegex.hasMatch(trimmedLine)) {
            assert(block.isEmpty);
            startLine = Line(
              '',
              filename: relativeFilePath,
              line: lineNumber + 1,
              indent: line.indexOf(_dartDocPrefixWithSpace) + _dartDocPrefixWithSpace.length,
            );
            inDart = true;
504
          }
505 506
        }
        if (!inSampleSection) {
507
          final RegExpMatch? sampleMatch = _dartDocSampleBeginRegex.firstMatch(trimmedLine);
508 509 510 511
          if (line == '// Examples can assume:') {
            assert(block.isEmpty);
            startLine = Line('', filename: relativeFilePath, line: lineNumber + 1, indent: 3);
            inPreamble = true;
512
          } else if (sampleMatch != null) {
513
            inSnippet = sampleMatch != null && (sampleMatch[1] == 'sample' || sampleMatch[1] == 'dartpad');
514
            if (inSnippet) {
515 516 517 518 519 520
              if (sampleMatch[1] == 'sample') {
                sampleCount++;
              }
              if (sampleMatch[1] == 'dartpad') {
                dartpadCount++;
              }
521 522 523 524 525 526 527 528
              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.
529
                snippetArgs = _splitUpQuotedArgs(sampleMatch[2]!).toList();
530 531 532
              } else {
                snippetArgs = <String>[];
              }
533
            }
534
            inSampleSection = !inSnippet;
535 536
          } else if (RegExp(r'///\s*#+\s+[Ss]ample\s+[Cc]ode:?$').hasMatch(trimmedLine)) {
            throw SampleCheckerException(
537
              "Found deprecated '## Sample code' section: use {@tool snippet}...{@end-tool} instead.",
538 539 540
              file: relativeFilePath,
              line: lineNumber,
            );
541 542 543 544
          }
        }
      }
    }
545
    if (!silent)
546
      print('Found ${sections.length} snippet code blocks, $sampleCount sample code sections, and $dartpadCount dartpad sections.');
547
    for (final Section section in sections) {
548 549 550
      final String path = _writeSection(section).path;
      if (sectionMap != null)
        sectionMap[path] = section;
551
    }
552 553
    for (final Sample sample in samples) {
      final File snippetFile = _writeSample(sample);
554 555 556 557
      if (sampleMap != null) {
        sample.contents = snippetFile.readAsLinesSync();
        sampleMap[snippetFile.absolute.path] = sample;
      }
558
    }
559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588
  }

  /// 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 = '';
589
      if (match[1] != null && !match[1]!.startsWith('-')) {
590 591 592 593 594 595 596 597 598 599 600
        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
601
  /// the temporary directory a package, and sets which lint rules to enforce.
602 603
  void _createConfigurationFiles(Directory directory) {
    final File pubSpec = File(path.join(directory.path, 'pubspec.yaml'))..createSync(recursive: true);
604

605 606
    pubSpec.writeAsStringSync('''
name: analyze_sample_code
607
environment:
608
  sdk: ">=2.12.0-0 <3.0.0"
609 610 611
dependencies:
  flutter:
    sdk: flutter
612 613
  flutter_test:
    sdk: flutter
614 615 616

dev_dependencies:
  flutter_lints: ^1.0.3
617
''');
618

619

620
    // Import the analysis options from the Flutter root.
621
    final File analysisOptions = File(path.join(directory.path, 'analysis_options.yaml'));
622 623 624 625 626 627 628 629
    analysisOptions.writeAsStringSync('''
include: package:flutter_lints/flutter.yaml

linter:
  rules:
    # Samples want to print things pretty often.
    avoid_print: false
''');
630 631 632 633
  }

  /// Writes out a sample section to the disk and returns the file.
  File _writeSection(Section section) {
634
    final String sectionId = _createNameFromSource('snippet', section.start.filename, section.start.line);
635
    final File outputFile = File(path.join(_tempDirectory.path, '$sectionId.dart'))..createSync(recursive: true);
636
    final List<Line> mainContents = <Line>[
637
      if (section.dartVersionOverride != null) Line(section.dartVersionOverride!) else const Line(''),
638 639 640 641 642
      ...headers,
      const Line(''),
      Line('// From: ${section.start.filename}:${section.start.line}'),
      ...section.code,
    ];
643 644 645 646 647
    outputFile.writeAsStringSync(mainContents.map<String>((Line line) => line.code).join('\n'));
    return outputFile;
  }

  /// Invokes the analyzer on the given [directory] and returns the stdout.
648
  List<String> _runAnalyzer(Directory directory, {bool silent = true}) {
649 650
    if (!silent)
      print('Starting analysis of code samples.');
651 652
    _createConfigurationFiles(directory);
    final ProcessResult result = Process.runSync(
653
      _flutter,
654 655
      <String>['--no-wrap', 'analyze', '--no-preamble', '--no-congratulate', '.'],
      workingDirectory: directory.absolute.path,
656
    );
657 658
    final List<String> stderr = result.stderr.toString().trim().split('\n');
    final List<String> stdout = result.stdout.toString().trim().split('\n');
659 660 661 662
    // Remove output from building the flutter tool.
    stderr.removeWhere((String line) {
      return line.startsWith('Building flutter tool...');
    });
663 664 665 666 667 668 669 670 671
    // 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();
      }
    }
672
    if (stderr.isNotEmpty && stderr.any((String line) => line.isNotEmpty)) {
673 674 675 676
      throw 'Cannot analyze dartdocs; unexpected error output:\n$stderr';
    }
    if (stdout.isNotEmpty && stdout.first == 'Building flutter tool...') {
      stdout.removeAt(0);
677
    }
678
    if (stdout.isNotEmpty && stdout.first.startsWith('Running "flutter pub get" in ')) {
679 680 681 682 683 684 685 686 687 688 689
      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,
690 691 692 693
    Map<String, Sample> samples, {
    bool silent = false,
  }) {
    final List<String> errors = _runAnalyzer(directory, silent: silent);
694 695 696
    final Map<String, List<AnalysisError>> analysisErrors = <String, List<AnalysisError>>{};
    void addAnalysisError(File file, AnalysisError error) {
      if (analysisErrors.containsKey(file.path)) {
697
        analysisErrors[file.path]!.add(error);
698 699 700 701 702
      } else {
        analysisErrors[file.path] = <AnalysisError>[error];
      }
    }

703
    final String kBullet = Platform.isWindows ? ' - ' : ' • ';
704 705
    // RegExp to match an error output line of the analyzer.
    final RegExp errorPattern = RegExp(
706 707 708 709
      '^ +(?<type>[a-z]+)'
      '$kBullet(?<description>.+)'
      '$kBullet(?<file>.+):(?<line>[0-9]+):(?<column>[0-9]+)'
      '$kBullet(?<code>[-a-z_]+)\$',
710 711 712
      caseSensitive: false,
    );
    bool unknownAnalyzerErrors = false;
713
    final int headerLength = headers.length + 3;
714
    for (final String error in errors) {
715
      final RegExpMatch? match = errorPattern.firstMatch(error);
716 717 718 719 720
      if (match == null) {
        stderr.writeln('Analyzer output: $error');
        unknownAnalyzerErrors = true;
        continue;
      }
721 722
      final String type = match.namedGroup('type')!;
      final String message = match.namedGroup('description')!;
723 724 725 726
      final File file = File(path.join(_tempDirectory.path, match.namedGroup('file')));
      final List<String> fileContents = file.readAsLinesSync();
      final bool isSnippet = path.basename(file.path).startsWith('snippet.');
      final bool isSample = path.basename(file.path).startsWith('sample.');
727 728 729
      final String line = match.namedGroup('line')!;
      final String column = match.namedGroup('column')!;
      final String errorCode = match.namedGroup('code')!;
730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771
      final int lineNumber = int.parse(line, radix: 10) - (isSnippet ? headerLength : 0);
      final int columnNumber = int.parse(column, radix: 10);

      // For when errors occur outside of the things we're trying to analyze.
      if (!isSnippet && !isSample) {
        addAnalysisError(
          file,
          AnalysisError(
            type,
            lineNumber,
            columnNumber,
            message,
            errorCode,
            Line(
              '',
              filename: file.path,
              line: lineNumber,
            ),
          ),
        );
        throw SampleCheckerException(
          'Cannot analyze dartdocs; analysis errors exist: $error',
          file: file.path,
          line: lineNumber,
        );
      }

      if (isSample) {
        addAnalysisError(
          file,
          AnalysisError(
            type,
            lineNumber,
            columnNumber,
            message,
            errorCode,
            null,
            sample: samples[file.path],
          ),
        );
      } else {
        if (lineNumber < 1 || lineNumber > fileContents.length) {
772 773 774
          addAnalysisError(
            file,
            AnalysisError(
775
              type,
776 777 778 779
              lineNumber,
              columnNumber,
              message,
              errorCode,
780
              Line('', filename: file.path, line: lineNumber),
781 782
            ),
          );
783 784 785
          throw SampleCheckerException('Failed to parse error message: $error', file: file.path, line: lineNumber);
        }

786
        final Section actualSection = sections[file.path]!;
787
        if (actualSection == null) {
788
          throw SampleCheckerException(
789
            "Unknown section for ${file.path}. Maybe the temporary directory wasn't empty?",
790 791 792
            file: file.path,
            line: lineNumber,
          );
793
        }
794
        final Line actualLine = actualSection.code[lineNumber - 1];
795

796
        if (actualLine.filename == null) {
797 798
          if (errorCode == 'missing_identifier' && lineNumber > 1) {
            if (fileContents[lineNumber - 2].endsWith(',')) {
799
              final Line actualLine = sections[file.path]!.code[lineNumber - 2];
800 801 802
              addAnalysisError(
                file,
                AnalysisError(
803 804 805 806
                  type,
                  actualLine.line,
                  actualLine.indent + fileContents[lineNumber - 2].length - 1,
                  'Unexpected comma at end of sample code.',
807 808 809 810 811
                  errorCode,
                  actualLine,
                ),
              );
            }
812
          } else {
813 814 815
            addAnalysisError(
              file,
              AnalysisError(
816 817 818
                type,
                lineNumber - 1,
                columnNumber,
819 820 821 822 823
                message,
                errorCode,
                actualLine,
              ),
            );
824
          }
825 826 827 828 829 830 831 832 833 834 835 836
        } else {
          addAnalysisError(
            file,
            AnalysisError(
              type,
              actualLine.line,
              actualLine.indent + columnNumber,
              message,
              errorCode,
              actualLine,
            ),
          );
837 838 839
        }
      }
    }
840 841 842 843
    if (_exitCode == 1 && analysisErrors.isEmpty && !unknownAnalyzerErrors) {
      _exitCode = 0;
    }
    if (_exitCode == 0) {
844 845
      if (!silent)
        print('No analysis errors in samples!');
846 847 848 849 850 851 852 853 854 855 856
      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) {
857
      throw SampleCheckerException('$line: Empty ```dart block in sample code.');
858
    }
859
    if (block.first.startsWith('new ') || block.first.startsWith(_constructorRegExp)) {
860 861 862 863 864 865 866 867 868 869
      _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(), ' }');
870
    } else {
871 872
      final List<String> buffer = <String>[];
      int subblocks = 0;
873
      Line? subline;
874 875 876 877 878 879
      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)
880 881
            throw SampleCheckerException('${Line('', filename: line.filename, line: line.line + index, indent: line.indent)}: '
                'Unexpected blank line or "// ..." line near start of subblock in sample code.');
882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907
          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());
908
      }
909 910 911 912
    }
  }
}

913 914
/// A class to represent a line of input code.
class Line {
915
  const Line(this.code, {this.filename = 'unknown', this.line = -1, this.indent = 0});
916 917 918 919 920 921
  final String filename;
  final int line;
  final int indent;
  final String code;

  String toStringWithColumn(int column) {
922
    if (column != null && indent != null) {
923 924 925 926 927 928 929 930 931
      return '$filename:$line:${column + indent}: $code';
    }
    return toString();
  }

  @override
  String toString() => '$filename:$line: $code';
}

932
/// A class to represent a section of sample code, marked by "{@tool snippet}...{@end-tool}".
933
class Section {
934
  const Section(this.code, {this.dartVersionOverride});
935
  factory Section.combine(List<Section> sections) {
936 937 938
    final List<Line> code = sections
      .expand((Section section) => section.code)
      .toList();
939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968
    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,
        ),
      );
    }
969 970 971 972 973
    return Section(<Line>[
      Line(prefix),
      ...codeLines,
      Line(postfix),
    ]);
974 975 976
  }
  Line get start => code.firstWhere((Line line) => line.filename != null);
  final List<Line> code;
977
  final String? dartVersionOverride;
978

979 980
  Section copyWith({String? dartVersionOverride}) {
    return Section(code, dartVersionOverride: dartVersionOverride ?? this.dartVersionOverride);
981
  }
982 983
}

984 985 986
/// A class to represent a sample in the dartdoc comments, marked by
/// "{@tool sample ...}...{@end-tool}". Samples are processed separately from
/// regular snippets, because they must be injected into templates in order to be
987
/// analyzed.
988
class Sample {
989 990 991 992 993 994 995 996
  Sample({
    required this.start,
    required List<String> input,
    required List<String> args,
    required this.serial,
  })  : input = input.toList(),
        args = args.toList(),
        contents = <String>[];
997 998 999 1000 1001 1002 1003 1004
  final Line start;
  final int serial;
  List<String> input;
  List<String> args;
  List<String> contents;

  @override
  String toString() {
1005
    final StringBuffer buf = StringBuffer('sample ${args.join(' ')}\n');
1006
    int count = start.line;
1007
    for (final String line in input) {
1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019
      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(
1020
    this.type,
1021 1022 1023 1024 1025
    this.line,
    this.column,
    this.message,
    this.errorCode,
    this.source, {
1026
    this.sample,
1027 1028
  });

1029
  final String type;
1030 1031 1032 1033
  final int line;
  final int column;
  final String message;
  final String errorCode;
1034 1035
  final Line? source;
  final Sample? sample;
1036 1037 1038 1039

  @override
  String toString() {
    if (source != null) {
1040
      return '${source!.toStringWithColumn(column)}\n>>> $type: $message ($errorCode)';
1041 1042
    } else if (sample != null) {
      return 'In sample starting at '
1043
          '${sample!.start.filename}:${sample!.start.line}:${sample!.contents[line - 1]}\n'
1044
          '>>> $type: $message ($errorCode)';
1045
    } else {
1046
      return '<source unknown>:$line:$column\n>>> $type: $message ($errorCode)';
1047 1048 1049
    }
  }
}
1050

1051 1052 1053 1054 1055 1056
Future<void> _runInteractive({
  required Directory? tempDir,
  required Directory flutterPackage,
  required String filePath,
  required Directory? dartUiLocation,
}) async {
1057 1058 1059
  filePath = path.isAbsolute(filePath) ? filePath : path.join(path.current, filePath);
  final File file = File(filePath);
  if (!file.existsSync()) {
1060
    throw 'Path ${file.absolute.path} does not exist ($filePath).';
1061
  }
1062 1063 1064 1065
  if (!path.isWithin(_flutterRoot, file.absolute.path) &&
      (dartUiLocation == null || !path.isWithin(dartUiLocation.path, file.absolute.path))) {
    throw 'Path ${file.absolute.path} is not within the flutter root: '
        '$_flutterRoot${dartUiLocation != null ? ' or the dart:ui location: $dartUiLocation' : ''}';
1066 1067 1068 1069 1070 1071
  }

  if (tempDir == null) {
    tempDir = Directory.systemTemp.createTempSync('flutter_analyze_sample_code.');
    ProcessSignal.sigint.watch().listen((_) {
      print('Deleting temp files...');
1072
      tempDir!.deleteSync(recursive: true);
1073 1074 1075 1076
      exit(0);
    });
    print('Using temp dir ${tempDir.path}');
  }
1077
  print('Starting up in interactive mode on ${path.relative(filePath, from: _flutterRoot)} ...');
1078

1079 1080 1081 1082 1083 1084 1085 1086
  void analyze(SampleChecker checker, File file) {
    final Map<String, Section> sections = <String, Section>{};
    final Map<String, Sample> snippets = <String, Sample>{};
    checker._extractSamples(<File>[file], silent: true, sectionMap: sections, sampleMap: snippets);
    final Map<String, List<AnalysisError>> errors = checker._analyze(checker._tempDirectory, sections, snippets, silent: true);
    stderr.writeln('\u001B[2J\u001B[H'); // Clears the old results from the terminal.
    if (errors.isNotEmpty) {
      for (final String filePath in errors.keys) {
1087
        errors[filePath]!.forEach(stderr.writeln);
1088 1089 1090 1091 1092 1093
      }
      stderr.writeln('\nFound ${errors.length} errors.');
    } else {
      stderr.writeln('\nNo issues found.');
    }
  }
1094

1095 1096 1097
  final SampleChecker checker = SampleChecker(flutterPackage, tempDirectory: tempDir)
    .._createConfigurationFiles(tempDir);
  analyze(checker, file);
1098

1099 1100 1101
  print('Type "q" to quit, or "r" to delete temp dir and manually reload.');

  void rerun() {
1102
    print('\n\nRerunning...');
1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118
    try {
      analyze(checker, file);
    } on SampleCheckerException catch (e) {
      print('Caught Exception (${e.runtimeType}), press "r" to retry:\n$e');
    }
  }

  stdin.lineMode = false;
  stdin.echoMode = false;
  stdin.transform(utf8.decoder).listen((String input) {
    switch (input) {
      case 'q':
        print('Exiting...');
        exit(0);
      case 'r':
        print('Deleting temp files...');
1119
        tempDir!.deleteSync(recursive: true);
1120 1121 1122
        rerun();
        break;
    }
1123
  });
1124 1125

  Watcher(file.absolute.path).events.listen((_) => rerun());
1126
}