import 'dart:io'; import 'package:analyzer/dart/analysis/analysis_context.dart'; import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; import 'package:analyzer/dart/analysis/results.dart'; import 'package:analyzer/dart/analysis/session.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/diagnostic/diagnostic.dart'; import 'package:analyzer/file_system/file_system.dart' as fs; import 'package:analyzer/file_system/physical_file_system.dart'; import 'errors.dart'; import 'project.dart'; import 'sdk.dart'; /// Outcome of attempting to resolve a single Dart file. class FileOutcome { FileOutcome({ required this.path, required this.micros, this.unit, this.failure, this.skipped = false, }); final String path; final int micros; final ResolvedUnitResult? unit; /// Why resolution did not produce a usable unit, when that happened. final String? failure; /// True when the file is not part of any analysis context. final bool skipped; bool get resolved => unit != null; List get diagnostics => unit?.diagnostics ?? const []; } /// Diagnostic codes meaning a name or import did not resolve. Unlike a type /// error, these change the syntax tree pipe reads: an unresolved `Foo()` is a /// method call rather than a construction, so a dispatch or emission of `Foo` /// disappears from the analysis. const Set unresolvedReferenceCodes = { 'uri_does_not_exist', 'undefined_class', 'undefined_identifier', 'undefined_method', 'undefined_function', 'undefined_getter', 'undefined_setter', 'undefined_super_member', 'undefined_prefixed_name', 'creation_with_non_type', 'extends_non_class', 'implements_non_class', 'mixin_of_non_class', 'non_type_as_type_argument', }; /// Resolved files that contain at least one unresolved reference. class UnresolvedReferences { UnresolvedReferences({ required this.files, required this.analysed, this.first, this.names = const {}, }); /// Every identifier those files mention. pipe's evidence is name-based, so an /// unresolved file can only hide evidence about a name it mentions. final Set names; /// Paths (absolute) of resolved files with an unresolved reference. final List files; /// How many files resolved at all. final int analysed; /// The first such diagnostic as `path:line: message`, for the reader. final String? first; bool get any => files.isNotEmpty; } UnresolvedReferences findUnresolvedReferences(List outcomes) { final files = []; final names = {}; String? first; var analysed = 0; for (final outcome in outcomes) { final unit = outcome.unit; if (unit == null) continue; analysed++; for (final diagnostic in unit.diagnostics) { if (!unresolvedReferenceCodes.contains( diagnostic.diagnosticCode.lowerCaseName, )) { continue; } files.add(outcome.path); final line = unit.lineInfo.getLocation(diagnostic.offset).lineNumber; first ??= '${outcome.path}:$line: ${diagnostic.message}'; final collector = _Names(); unit.unit.accept(collector); names.addAll(collector.names); break; } } return UnresolvedReferences( files: files, analysed: analysed, first: first, names: names, ); } /// Every type, enum constant and top-level name declared in the resolved units. /// `verify` uses it to tell a finding that went away because its subject was /// deleted from one that went away because the code was fixed. Set declaredNamesOf(List outcomes) { final names = {}; for (final outcome in outcomes) { final unit = outcome.unit; if (unit == null) continue; for (final declaration in unit.unit.declarations) { switch (declaration) { case ClassDeclaration(): names.add(declaration.namePart.typeName.lexeme); case MixinDeclaration(): names.add(declaration.name.lexeme); case EnumDeclaration(): names.add(declaration.namePart.typeName.lexeme); for (final constant in declaration.body.constants) { names.add(constant.name.lexeme); } case FunctionDeclaration(): names.add(declaration.name.lexeme); case TopLevelVariableDeclaration(): for (final variable in declaration.variables.variables) { names.add(variable.name.lexeme); } default: break; } } } return names; } class _Names extends RecursiveAstVisitor { final Set names = {}; @override void visitSimpleIdentifier(SimpleIdentifier node) { names.add(node.name); super.visitSimpleIdentifier(node); } @override void visitNamedType(NamedType node) { names.add(node.name.lexeme); super.visitNamedType(node); } } /// How much of a project an analysis actually examined. /// /// Every report carries this, because a finding count means nothing without it: /// "0 findings" from 3 of 40 files is not a clean project, and a reader (or an /// agent) handed only the findings cannot tell. class ScanCoverage { const ScanCoverage({ required this.discovered, required this.selected, required this.resolved, required this.failed, required this.skipped, }); factory ScanCoverage.fromJson(Map json) => ScanCoverage( discovered: json['discovered']! as int, selected: json['selected']! as int, resolved: json['resolved']! as int, failed: json['failed']! as int, skipped: json['skipped']! as int, ); factory ScanCoverage.of({ required int discovered, required List outcomes, }) { var resolved = 0; var failed = 0; var skipped = 0; for (final outcome in outcomes) { if (outcome.resolved) { resolved++; } else if (outcome.skipped) { skipped++; } else { failed++; } } return ScanCoverage( discovered: discovered, selected: outcomes.length, resolved: resolved, failed: failed, skipped: skipped, ); } /// Dart files found under the scanned directories. final int discovered; /// Files handed to the analyzer, after --no-include-generated and --limit. final int selected; final int resolved; final int failed; /// Files the analyzer did not cover: excluded by analysis_options, or /// outside every analysis context. final int skipped; bool get complete => resolved == discovered; Map toJson() => { 'discovered': discovered, 'selected': selected, 'resolved': resolved, 'failed': failed, 'skipped': skipped, 'complete': complete, }; /// One line, for the top of a brief or plan. String describe() { final notSelected = discovered - selected; return 'Coverage: $resolved of $discovered discovered file(s) analysed ' '($failed failed, $skipped skipped by analysis_options or context' '${notSelected > 0 ? ', $notSelected not selected' : ''})' '${complete ? '.' : ' - results are PARTIAL.'}'; } } /// Owns the analyzer contexts for one target project. /// /// Mirrors the lifecycle Flutter's own tooling uses: one collection rooted at /// the project, an explicit SDK path, and a fresh session after an /// [InconsistentAnalysisException]. class Analyzer { Analyzer._(this.collection, this.sdk); final AnalysisContextCollection collection; final SdkResolution sdk; int get contextCount => collection.contexts.length; /// Build a collection for [project], resolving `dart:` libraries with the /// Flutter-bundled SDK when the target is a Flutter project. static Analyzer create(Project project, {String? sdkOverride}) { final resolution = resolveSdk( explicit: sdkOverride, flutterRootHint: project.flutterRoot, ); requireReadableSdk(resolution); final AnalysisContextCollection collection; try { collection = AnalysisContextCollection( includedPaths: [project.root], resourceProvider: PhysicalResourceProvider.INSTANCE, sdkPath: resolution.sdkPath, ); } on fs.FileSystemException catch (error) { // The analyzer throws its own FileSystemException, not dart:io's, so the // catch in bin/pipe.dart does not see it. The usual cause is a project // whose package_config still names a Flutter SDK that has moved. throw PipeException( 'cannot read the Dart SDK at ${resolution.sdkPath}: ${error.message} ' '(${error.path})', remedy: 'run `flutter pub get` in the project so its package_config ' 'names the current SDK, or pass --sdk ', ); } return Analyzer._(collection, resolution); } /// Resolve a single file, measuring wall time for that file. Future resolve(String path) async { final stopwatch = Stopwatch()..start(); final AnalysisContext context; try { context = collection.contextFor(path); } on StateError { stopwatch.stop(); return FileOutcome( path: path, micros: stopwatch.elapsedMicroseconds, skipped: true, failure: 'not covered by any analysis context', ); } // A session is bound to one metadata state; if files changed under us the // analyzer throws and re-reading currentSession gives a fresh view. for (var attempt = 0; attempt < 2; attempt++) { try { final result = await context.currentSession.getResolvedUnit(path); stopwatch.stop(); if (result is ResolvedUnitResult) { // An unreadable file is handed back as a unit with EMPTY content and no // declarations - not an error and not a failure. Treating that as a // successful resolution meant a project whose files could not be read // reported "12/12 resolved, 0 diagnostics" and `check` reported zero // findings, indistinguishable from a genuinely clean project. The // unit's content is only available here, so the check lives here. if (result.content.isEmpty) { var onDisk = 0; try { onDisk = File(path).lengthSync(); } on FileSystemException { onDisk = -1; } // A genuinely empty file is fine; a file with bytes that produced no // content was not read. if (onDisk != 0) { return FileOutcome( path: path, micros: stopwatch.elapsedMicroseconds, failure: 'no content returned for a file of $onDisk bytes ' '(unreadable or removed?)', ); } } return FileOutcome( path: path, micros: stopwatch.elapsedMicroseconds, unit: result, ); } return FileOutcome( path: path, micros: stopwatch.elapsedMicroseconds, failure: 'no unit result (${result.runtimeType})', ); } on InconsistentAnalysisException { if (attempt == 0) continue; stopwatch.stop(); return FileOutcome( path: path, micros: stopwatch.elapsedMicroseconds, failure: 'inconsistent analysis session', ); } on Object catch (error) { stopwatch.stop(); return FileOutcome( path: path, micros: stopwatch.elapsedMicroseconds, failure: error.toString(), ); } } stopwatch.stop(); return FileOutcome( path: path, micros: stopwatch.elapsedMicroseconds, failure: 'exhausted resolution attempts', ); } Future dispose() => collection.dispose(); }