analyze_sample_code.dart 41.8 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
    return _headers ??= <String>[
266
      '// ignore_for_file: directives_ordering',
267
      '// ignore_for_file: unnecessary_import',
268
      '// ignore_for_file: unused_import',
269 270
      '// ignore_for_file: unused_element',
      '// ignore_for_file: unused_local_variable',
271 272 273 274 275 276
      "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';",
277
      for (final File file in _listDartFiles(Directory(_defaultFlutterPackage)))
278
        "import 'package:flutter/${path.basename(file.path)}';",
279
    ].map<Line>((String code) => Line.generated(code: code, filename: 'headers')).toList();
280 281
  }

282
  List<Line>? _headers;
283 284 285 286 287 288 289

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

  /// 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) {
330 331 332 333
    String sampleId = path.split(filename).join('.');
    sampleId = path.basenameWithoutExtension(sampleId);
    sampleId = '$prefix.$sampleId.$start';
    return sampleId;
334 335
  }

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

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

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

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

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

601 602
    pubSpec.writeAsStringSync('''
name: analyze_sample_code
603
environment:
604
  sdk: ">=2.12.0-0 <3.0.0"
605 606 607
dependencies:
  flutter:
    sdk: flutter
608 609
  flutter_test:
    sdk: flutter
610 611 612

dev_dependencies:
  flutter_lints: ^1.0.3
613
''');
614

615

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

linter:
  rules:
    # Samples want to print things pretty often.
    avoid_print: false
''');
626 627 628 629
  }

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

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

697
    final String kBullet = Platform.isWindows ? ' - ' : ' • ';
698 699
    // RegExp to match an error output line of the analyzer.
    final RegExp errorPattern = RegExp(
700 701 702 703
      '^ +(?<type>[a-z]+)'
      '$kBullet(?<description>.+)'
      '$kBullet(?<file>.+):(?<line>[0-9]+):(?<column>[0-9]+)'
      '$kBullet(?<code>[-a-z_]+)\$',
704 705 706
      caseSensitive: false,
    );
    bool unknownAnalyzerErrors = false;
707
    final int headerLength = headers.length + 3;
708
    for (final String error in errors) {
709
      final RegExpMatch? match = errorPattern.firstMatch(error);
710 711 712 713 714
      if (match == null) {
        stderr.writeln('Analyzer output: $error');
        unknownAnalyzerErrors = true;
        continue;
      }
715 716
      final String type = match.namedGroup('type')!;
      final String message = match.namedGroup('description')!;
717 718 719 720
      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.');
721 722 723
      final String line = match.namedGroup('line')!;
      final String column = match.namedGroup('column')!;
      final String errorCode = match.namedGroup('code')!;
724 725 726 727 728 729 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
      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) {
765 766 767
          addAnalysisError(
            file,
            AnalysisError(
768
              type,
769 770 771 772
              lineNumber,
              columnNumber,
              message,
              errorCode,
773
              Line(filename: file.path, line: lineNumber),
774 775
            ),
          );
776 777 778
          throw SampleCheckerException('Failed to parse error message: $error', file: file.path, line: lineNumber);
        }

779
        final Section actualSection = sections[file.path]!;
780
        if (actualSection == null) {
781
          throw SampleCheckerException(
782
            "Unknown section for ${file.path}. Maybe the temporary directory wasn't empty?",
783 784 785
            file: file.path,
            line: lineNumber,
          );
786
        }
787
        final Line actualLine = actualSection.code[lineNumber - 1];
788

789 790 791 792 793 794 795 796 797
        late int line;
        late int column;
        String errorMessage = message;
        Line source = actualLine;
        if (actualLine.generated) {
          // Since generated lines don't appear in the original, we just provide the line
          // in the generated file.
          line = lineNumber - 1;
          column = columnNumber;
798
          if (errorCode == 'missing_identifier' && lineNumber > 1) {
799 800 801 802 803 804 805 806 807 808
            // For a missing identifier on a generated line, it is very often because of a
            // trailing comma on the previous line, and so we want to provide a better message
            // and the previous line as the error location, since that appears in the original
            // source, and can be more easily located.
            final Line previousCodeLine = sections[file.path]!.code[lineNumber - 2];
            if (previousCodeLine.code.contains(RegExp(r',\s*$'))) {
              line = previousCodeLine.line;
              column = previousCodeLine.indent + previousCodeLine.code.length - 1;
              errorMessage = 'Unexpected comma at end of sample code.';
              source = previousCodeLine;
809
            }
810
          }
811
        } else {
812 813
          line = actualLine.line;
          column = actualLine.indent + columnNumber;
814
        }
815 816 817 818 819 820 821 822 823 824 825
        addAnalysisError(
          file,
          AnalysisError(
            type,
            line,
            column,
            errorMessage,
            errorCode,
            source,
          ),
        );
826 827
      }
    }
828 829 830 831
    if (_exitCode == 1 && analysisErrors.isEmpty && !unknownAnalyzerErrors) {
      _exitCode = 0;
    }
    if (_exitCode == 0) {
832 833
      if (!silent)
        print('No analysis errors in samples!');
834 835 836 837 838 839 840 841 842 843 844
      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) {
845
      throw SampleCheckerException('$line: Empty ```dart block in sample code.');
846
    }
847
    if (block.first.startsWith('new ') || block.first.startsWith(_constructorRegExp)) {
848 849 850 851 852 853 854 855 856 857
      _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(), ' }');
858
    } else {
859 860
      final List<String> buffer = <String>[];
      int subblocks = 0;
861
      Line? subline;
862 863 864 865 866 867
      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)
868
            throw SampleCheckerException('${Line(filename: line.filename, line: line.line + index, indent: line.indent)}: '
869
                'Unexpected blank line or "// ..." line near start of subblock in sample code.');
870 871 872 873 874 875 876 877 878 879
          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(
880
            code: block[index],
881 882 883 884 885 886 887 888 889 890 891 892 893 894 895
            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());
896
      }
897 898 899 900
    }
  }
}

901 902
/// A class to represent a line of input code.
class Line {
903 904 905 906 907 908
  const Line({this.code = '', required this.filename, this.line = -1, this.indent = 0})
      : generated = false;
  const Line.generated({this.code = '', required this.filename, this.line = -1, this.indent = 0})
      : generated = true;

  /// The file that this line came from, or the file that the line was generated for, if [generated] is true.
909 910 911 912
  final String filename;
  final int line;
  final int indent;
  final String code;
913
  final bool generated;
914 915

  String toStringWithColumn(int column) {
916
    if (column != null && indent != null) {
917 918 919 920 921 922 923 924 925
      return '$filename:$line:${column + indent}: $code';
    }
    return toString();
  }

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

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

973 974
  Section copyWith({String? dartVersionOverride}) {
    return Section(code, dartVersionOverride: dartVersionOverride ?? this.dartVersionOverride);
975
  }
976 977
}

978 979 980
/// 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
981
/// analyzed.
982
class Sample {
983 984 985 986 987 988 989 990
  Sample({
    required this.start,
    required List<String> input,
    required List<String> args,
    required this.serial,
  })  : input = input.toList(),
        args = args.toList(),
        contents = <String>[];
991 992 993 994 995 996 997 998
  final Line start;
  final int serial;
  List<String> input;
  List<String> args;
  List<String> contents;

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

1023
  final String type;
1024 1025 1026 1027
  final int line;
  final int column;
  final String message;
  final String errorCode;
1028 1029
  final Line? source;
  final Sample? sample;
1030 1031 1032 1033

  @override
  String toString() {
    if (source != null) {
1034
      return '${source!.toStringWithColumn(column)}\n>>> $type: $message ($errorCode)';
1035 1036
    } else if (sample != null) {
      return 'In sample starting at '
1037
          '${sample!.start.filename}:${sample!.start.line}:${sample!.contents[line - 1]}\n'
1038
          '>>> $type: $message ($errorCode)';
1039
    } else {
1040
      return '<source unknown>:$line:$column\n>>> $type: $message ($errorCode)';
1041 1042 1043
    }
  }
}
1044

1045 1046 1047 1048 1049 1050
Future<void> _runInteractive({
  required Directory? tempDir,
  required Directory flutterPackage,
  required String filePath,
  required Directory? dartUiLocation,
}) async {
1051 1052 1053
  filePath = path.isAbsolute(filePath) ? filePath : path.join(path.current, filePath);
  final File file = File(filePath);
  if (!file.existsSync()) {
1054
    throw 'Path ${file.absolute.path} does not exist ($filePath).';
1055
  }
1056 1057 1058 1059
  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' : ''}';
1060 1061 1062 1063 1064 1065
  }

  if (tempDir == null) {
    tempDir = Directory.systemTemp.createTempSync('flutter_analyze_sample_code.');
    ProcessSignal.sigint.watch().listen((_) {
      print('Deleting temp files...');
1066
      tempDir!.deleteSync(recursive: true);
1067 1068 1069 1070
      exit(0);
    });
    print('Using temp dir ${tempDir.path}');
  }
1071
  print('Starting up in interactive mode on ${path.relative(filePath, from: _flutterRoot)} ...');
1072

1073 1074 1075 1076 1077 1078 1079 1080
  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) {
1081
        errors[filePath]!.forEach(stderr.writeln);
1082 1083 1084 1085 1086 1087
      }
      stderr.writeln('\nFound ${errors.length} errors.');
    } else {
      stderr.writeln('\nNo issues found.');
    }
  }
1088

1089 1090 1091
  final SampleChecker checker = SampleChecker(flutterPackage, tempDirectory: tempDir)
    .._createConfigurationFiles(tempDir);
  analyze(checker, file);
1092

1093 1094 1095
  print('Type "q" to quit, or "r" to delete temp dir and manually reload.');

  void rerun() {
1096
    print('\n\nRerunning...');
1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112
    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...');
1113
        tempDir!.deleteSync(recursive: true);
1114 1115 1116
        rerun();
        break;
    }
1117
  });
1118 1119

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