1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'package:analyzer/dart/analysis/results.dart';
6import 'package:analyzer/dart/ast/ast.dart';
7import 'package:analyzer/dart/ast/visitor.dart';
8import 'package:analyzer/dart/element/element.dart';
9import 'package:analyzer/dart/element/type.dart';
10import 'package:path/path.dart' as path;
11
12import '../utils.dart';
13import 'analyze.dart';
14
15// The comment pattern representing the "flutter_ignore" inline directive that
16// indicates the line should be exempt from the stopwatch check.
17final Pattern _ignoreStopwatch = RegExp(r'// flutter_ignore: .*stopwatch .*\(see analyze\.dart\)');
18
19/// Use of Stopwatches can introduce test flakes as the logical time of a
20/// stopwatch can fall out of sync with the mocked time of FakeAsync in testing.
21/// The Clock object provides a safe stopwatch instead, which is paired with
22/// FakeAsync as part of the test binding.
23final AnalyzeRule noStopwatches = _NoStopwatches();
24
25class _NoStopwatches implements AnalyzeRule {
26 final Map<ResolvedUnitResult, List<AstNode>> _errors = <ResolvedUnitResult, List<AstNode>>{};
27
28 @override
29 void applyTo(ResolvedUnitResult unit) {
30 final _StopwatchVisitor visitor = _StopwatchVisitor(unit);
31 unit.unit.visitChildren(visitor);
32 final List<AstNode> violationsInUnit = visitor.stopwatchAccessNodes;
33 if (violationsInUnit.isNotEmpty) {
34 _errors.putIfAbsent(unit, () => <AstNode>[]).addAll(violationsInUnit);
35 }
36 }
37
38 @override
39 void reportViolations(String workingDirectory) {
40 if (_errors.isEmpty) {
41 return;
42 }
43
44 String locationInFile(ResolvedUnitResult unit, AstNode node) {
45 return '${path.relative(path.relative(unit.path, from: workingDirectory))}:${unit.lineInfo.getLocation(node.offset).lineNumber}';
46 }
47
48 foundError(<String>[
49 for (final MapEntry<ResolvedUnitResult, List<AstNode>> entry in _errors.entries)
50 for (final AstNode node in entry.value)
51 '${locationInFile(entry.key, node)}: ${node.parent}',
52 '\n${bold}Stopwatches introduce flakes by falling out of sync with the FakeAsync used in testing.$reset',
53 'A Stopwatch that stays in sync with FakeAsync is available through the Gesture or Test bindings, through samplingClock.',
54 ]);
55 }
56
57 @override
58 String toString() => 'No "Stopwatch"';
59}
60
61// This visitor finds invocation sites of Stopwatch (and subclasses) constructors
62// and references to "external" functions that return a Stopwatch (and subclasses),
63// including constructors, and put them in the stopwatchAccessNodes list.
64class _StopwatchVisitor extends RecursiveAstVisitor<void> {
65 _StopwatchVisitor(this.compilationUnit);
66
67 final ResolvedUnitResult compilationUnit;
68
69 final List<AstNode> stopwatchAccessNodes = <AstNode>[];
70
71 final Map<ClassElement, bool> _isStopwatchClassElementCache = <ClassElement, bool>{};
72
73 bool _checkIfImplementsStopwatchRecursively(ClassElement classElement) {
74 if (classElement.library.isDartCore) {
75 return classElement.name == 'Stopwatch';
76 }
77 return classElement.allSupertypes.any((InterfaceType interface) {
78 final InterfaceElement interfaceElement = interface.element;
79 return interfaceElement is ClassElement && _implementsStopwatch(interfaceElement);
80 });
81 }
82
83 // The cached version, call this method instead of _checkIfImplementsStopwatchRecursively.
84 bool _implementsStopwatch(ClassElement classElement) {
85 return classElement.library.isDartCore
86 ? classElement.name == 'Stopwatch'
87 : _isStopwatchClassElementCache.putIfAbsent(
88 classElement,
89 () => _checkIfImplementsStopwatchRecursively(classElement),
90 );
91 }
92
93 bool _isInternal(LibraryElement libraryElement) {
94 return path.isWithin(
95 compilationUnit.session.analysisContext.contextRoot.root.path,
96 libraryElement.source.fullName,
97 );
98 }
99
100 bool _hasTrailingFlutterIgnore(AstNode node) {
101 return compilationUnit.content
102 .substring(
103 node.offset + node.length,
104 compilationUnit.lineInfo.getOffsetOfLineAfter(node.offset + node.length),
105 )
106 .contains(_ignoreStopwatch);
107 }
108
109 // We don't care about directives or comments, skip them.
110 @override
111 void visitImportDirective(ImportDirective node) {}
112
113 @override
114 void visitExportDirective(ExportDirective node) {}
115
116 @override
117 void visitComment(Comment node) {}
118
119 @override
120 void visitConstructorName(ConstructorName node) {
121 final Element? element = node.staticElement;
122 if (element is! ConstructorElement) {
123 assert(false, '$element of $node is not a ConstructorElement.');
124 return;
125 }
126 final bool isAllowed = switch (element.returnType) {
127 InterfaceType(element: final ClassElement classElement) => !_implementsStopwatch(
128 classElement,
129 ),
130 InterfaceType(element: InterfaceElement()) => true,
131 };
132 if (isAllowed || _hasTrailingFlutterIgnore(node)) {
133 return;
134 }
135 stopwatchAccessNodes.add(node);
136 }
137
138 @override
139 void visitSimpleIdentifier(SimpleIdentifier node) {
140 final bool isAllowed = switch (node.staticElement) {
141 ExecutableElement(
142 returnType: DartType(element: final ClassElement classElement),
143 library: final LibraryElement libraryElement,
144 ) =>
145 _isInternal(libraryElement) || !_implementsStopwatch(classElement),
146 Element() || null => true,
147 };
148 if (isAllowed || _hasTrailingFlutterIgnore(node)) {
149 return;
150 }
151 stopwatchAccessNodes.add(node);
152 }
153}
154