| // Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| import 'package:analyzer/dart/analysis/results.dart'; |
| import 'package:analyzer/dart/ast/ast.dart'; |
| import 'package:analyzer/dart/ast/token.dart'; |
| import 'package:analyzer/dart/ast/visitor.dart'; |
| import 'package:analyzer/dart/constant/value.dart'; |
| import 'package:analyzer/dart/element/element.dart'; |
| import 'package:analyzer/dart/element/type.dart'; |
| import 'package:analyzer/diagnostic/diagnostic.dart'; |
| import 'package:analyzer/source/line_info.dart'; |
| import 'package:analyzer/src/dart/ast/ast.dart'; |
| import 'package:collection/collection.dart'; |
| import 'package:language_server_protocol/protocol_custom_generated.dart' |
| hide Element; |
| import 'package:language_server_protocol/protocol_generated.dart' show Position; |
| |
| class FlutterWidgetPreviewDetector { |
| final _namespaceAllocator = NamespaceAllocator(); |
| |
| Map<String, String> get namespaces => _namespaceAllocator.namespaces; |
| |
| /// Search for functions annotated with `@Preview` in the current project. |
| void findPreviews( |
| ResolvedUnitResult resolvedUnit, { |
| Map<Uri, LibraryPreviewNode>? graph, |
| }) { |
| var lib = resolvedUnit.libraryElement; |
| |
| var previewsForLibrary = graph != null |
| ? graph.putIfAbsent( |
| lib.uri, |
| () => LibraryPreviewNode( |
| library: lib, |
| namespaceAllocator: _namespaceAllocator, |
| ), |
| ) |
| : LibraryPreviewNode( |
| library: lib, |
| namespaceAllocator: _namespaceAllocator, |
| ); |
| |
| // Track errors in the current file. |
| previewsForLibrary.populateErrorsForFile( |
| uri: resolvedUnit.uri, |
| diagnostics: resolvedUnit.diagnostics, |
| ); |
| |
| // If we have a graph, update dependencies for propagation. |
| if (graph != null) { |
| previewsForLibrary.updateDependencyGraph( |
| graph: graph, |
| unit: resolvedUnit, |
| ); |
| } |
| |
| // Iterate over the library's AST to find previews. |
| previewsForLibrary.addPreviews(unit: resolvedUnit); |
| } |
| |
| /// Propagates errors through the dependency graph. |
| void propagateErrors(Map<Uri, LibraryPreviewNode> graph) { |
| // Reset the error state for all dependencies. |
| for (var libraryDetails in graph.values) { |
| libraryDetails.dependencyHasErrors = false; |
| } |
| |
| void propagateErrorsHelper(LibraryPreviewNode errorContainingNode) { |
| for (var importer in errorContainingNode.dependedOnBy) { |
| if (importer.dependencyHasErrors) { |
| // This dependency path has already been processed. |
| continue; |
| } |
| importer.dependencyHasErrors = true; |
| propagateErrorsHelper(importer); |
| } |
| } |
| |
| // Find the libraries that have errors and mark each of their downstream |
| // dependencies as having a dependency containing errors. |
| for (var nodeDetails in graph.values) { |
| if (nodeDetails.hasErrors) { |
| propagateErrorsHelper(nodeDetails); |
| } |
| } |
| |
| // Update the error flags on all previews based on the propagated state. |
| for (var node in graph.values) { |
| var hasError = node.hasErrors; |
| var dependencyHasErrors = node.dependencyHasErrors; |
| for (var i = 0; i < node.previews.length; i++) { |
| var preview = node.previews[i]; |
| if (preview.hasError != hasError || |
| preview.dependencyHasErrors != dependencyHasErrors) { |
| node.previews[i] = FlutterWidgetPreviewDetails( |
| scriptUri: preview.scriptUri, |
| libraryUri: preview.libraryUri, |
| position: preview.position, |
| packageName: preview.packageName, |
| functionName: preview.functionName, |
| isBuilder: preview.isBuilder, |
| previewAnnotation: preview.previewAnnotation, |
| isMultiPreview: preview.isMultiPreview, |
| hasError: hasError, |
| dependencyHasErrors: dependencyHasErrors, |
| ); |
| } |
| } |
| } |
| } |
| } |
| |
| /// Contains information related to a library being scanned for previews. |
| final class LibraryPreviewNode { |
| final NamespaceAllocator namespaceAllocator; |
| |
| /// The URI pointing to the library. |
| final Uri uri; |
| |
| /// The absolute path to the library's defining unit. |
| final String path; |
| |
| /// The list of previews contained within the file. |
| final previews = <FlutterWidgetPreviewDetails>[]; |
| |
| /// Files that import this file. |
| final dependedOnBy = <LibraryPreviewNode>{}; |
| |
| /// Files this file imports. |
| final dependsOn = <LibraryPreviewNode>{}; |
| |
| /// `true` if a transitive dependency has compile time errors. |
| bool dependencyHasErrors = false; |
| |
| /// The set of errors found in this library. |
| final errors = <Diagnostic>[]; |
| |
| LibraryPreviewNode({ |
| required LibraryElement library, |
| required this.namespaceAllocator, |
| }) : uri = library.uri, |
| path = library.firstFragment.source.fullName; |
| |
| /// `true` if this library contains compile time errors. |
| bool get hasErrors => errors.isNotEmpty; |
| |
| /// Finds all previews defined in the [unit] and adds them to [previews]. |
| void addPreviews({required ResolvedUnitResult unit}) { |
| // Iterate over the compilation unit's AST to find previews. |
| var visitor = _PreviewVisitor( |
| unit: unit, |
| previewNode: this, |
| namespaceAllocator: namespaceAllocator, |
| ); |
| visitor.findPreviewsInResolvedUnitResult(unit); |
| |
| // Remove existing previews for this unit before adding new ones. |
| previews.removeWhere((p) => p.scriptUri == unit.uri); |
| previews.addAll(visitor.previewEntries); |
| } |
| |
| /// Determines the set of errors found in this file. |
| void populateErrorsForFile({ |
| required Uri uri, |
| required List<Diagnostic> diagnostics, |
| }) { |
| errors |
| ..removeWhere((e) => e.source.uri == uri) |
| ..addAll(diagnostics.where((e) => e.severity == Severity.error)); |
| } |
| |
| /// Updates the dependency graph based on changes to a compilation [unit]. |
| void updateDependencyGraph({ |
| required Map<Uri, LibraryPreviewNode> graph, |
| required ResolvedUnitResult unit, |
| }) { |
| var updatedDependencies = <LibraryPreviewNode>{}; |
| |
| for (var fragment in unit.libraryElement.fragments) { |
| for (var importedLib in fragment.libraryImports) { |
| if (importedLib.importedLibrary == null) { |
| continue; |
| } |
| var importedLibrary = importedLib.importedLibrary!; |
| var result = graph.putIfAbsent( |
| importedLibrary.uri, |
| () => LibraryPreviewNode( |
| library: importedLibrary, |
| namespaceAllocator: namespaceAllocator, |
| ), |
| ); |
| updatedDependencies.add(result); |
| } |
| } |
| |
| // Only update dependsOn for the library unit itself to avoid confusion |
| // with parts, or just use a cumulative set. |
| dependsOn.addAll(updatedDependencies); |
| |
| for (var dependency in updatedDependencies) { |
| dependency.dependedOnBy.add(this); |
| } |
| } |
| } |
| |
| /// Tracks imports and assigns namespaces to each unique library URL. |
| class NamespaceAllocator { |
| static const _doNotPrefix = ['dart:core']; |
| |
| final _imports = <String, int>{}; |
| var _keys = 1; |
| |
| /// Returns import source code for each library seen. |
| Map<String, String> get namespaces => |
| _imports.map((uri, id) => MapEntry(uri, '_i$id')); |
| |
| /// Returns the name of [symbol] with a namespace prefix assigned based on |
| /// [url]. |
| String applyNamespaceToSymbol({ |
| required String symbol, |
| required String? url, |
| }) { |
| if (url == null || _doNotPrefix.contains(url)) { |
| return symbol; |
| } |
| return '_i${_imports.putIfAbsent(url, _nextKey)}.$symbol'; |
| } |
| |
| int _nextKey() => _keys++; |
| } |
| |
| /// Visitor which detects previews and extracts [PreviewDetails] for later code |
| /// generation. |
| class _PreviewVisitor extends RecursiveAstVisitor<void> { |
| final LibraryPreviewNode previewNode; |
| |
| final NamespaceAllocator namespaceAllocator; |
| late final String? packageName; |
| final previewEntries = <FlutterWidgetPreviewDetails>[]; |
| |
| FunctionDeclaration? _currentFunction; |
| |
| ConstructorDeclaration? _currentConstructor; |
| MethodDeclaration? _currentMethod; |
| |
| late ResolvedUnitResult _currentUnit; |
| final LineInfo _lineInfo; |
| final Uri _scriptUri; |
| final Uri _libraryUri; |
| |
| _PreviewVisitor({ |
| required ResolvedUnitResult unit, |
| required this.previewNode, |
| required this.namespaceAllocator, |
| }) : packageName = unit.libraryElement.uri.scheme == 'package' |
| ? unit.libraryElement.uri.pathSegments.first |
| : null, |
| _lineInfo = unit.lineInfo, |
| _scriptUri = Uri.file(unit.path), |
| _libraryUri = unit.libraryElement.uri; |
| |
| void findPreviewsInResolvedUnitResult(ResolvedUnitResult unit) { |
| _currentUnit = unit; |
| _currentUnit.unit.visitChildren(this); |
| } |
| |
| bool hasRequiredParams(FormalParameterList? params) { |
| return params?.parameters.any((p) => p.isRequired) ?? false; |
| } |
| |
| @override |
| void visitAnnotation(Annotation node) { |
| bool isMultiPreview = node.isMultiPreview; |
| bool isPreview = node.isPreview; |
| // Skip non-preview annotations. |
| if (!isPreview && !isMultiPreview) { |
| return; |
| } |
| // The preview annotations must only have constant arguments. |
| DartObject? preview = node.elementAnnotation!.computeConstantValue(); |
| if (preview == null) { |
| return; |
| } |
| CharacterLocation location = _lineInfo.getLocation(node.offset); |
| int line = location.lineNumber; |
| int column = location.columnNumber; |
| var hasError = previewNode.hasErrors; |
| var dependencyHasErrors = previewNode.dependencyHasErrors; |
| |
| FlutterWidgetPreviewDetails buildPreviewDetails({ |
| required String functionName, |
| required bool isWidgetBuilder, |
| }) { |
| return FlutterWidgetPreviewDetails( |
| scriptUri: _scriptUri, |
| libraryUri: _libraryUri, |
| position: Position(character: column, line: line), |
| packageName: packageName, |
| functionName: functionName, |
| isBuilder: isWidgetBuilder, |
| previewAnnotation: preview.toSource(namespaceAllocator), |
| isMultiPreview: isMultiPreview, |
| hasError: hasError, |
| dependencyHasErrors: dependencyHasErrors, |
| ); |
| } |
| |
| if (_currentFunction != null && |
| !hasRequiredParams(_currentFunction!.functionExpression.parameters)) { |
| TypeAnnotation? returnTypeAnnotation = _currentFunction!.returnType; |
| if (returnTypeAnnotation is NamedType) { |
| Token returnType = returnTypeAnnotation.name; |
| if (returnType.isWidget || returnType.isWidgetBuilder) { |
| previewEntries.add( |
| buildPreviewDetails( |
| functionName: _currentFunction!.name.toString(), |
| isWidgetBuilder: returnType.isWidgetBuilder, |
| ), |
| ); |
| } |
| } |
| } else if (_currentConstructor != null && |
| !hasRequiredParams(_currentConstructor!.parameters)) { |
| var returnType = _currentConstructor!.typeName!; |
| Token? name = _currentConstructor!.name; |
| previewEntries.add( |
| buildPreviewDetails( |
| functionName: '$returnType${name == null ? '' : '.$name'}', |
| isWidgetBuilder: false, |
| ), |
| ); |
| } else if (_currentMethod != null && |
| !hasRequiredParams(_currentMethod!.parameters)) { |
| TypeAnnotation? returnTypeAnnotation = _currentMethod!.returnType; |
| if (returnTypeAnnotation is NamedType) { |
| Token returnType = returnTypeAnnotation.name; |
| if (returnType.isWidget || returnType.isWidgetBuilder) { |
| var parentClass = _currentMethod!.parent!.parent! as ClassDeclaration; |
| previewEntries.add( |
| buildPreviewDetails( |
| functionName: |
| '${parentClass.namePart.typeName}.${_currentMethod!.name}', |
| isWidgetBuilder: returnType.isWidgetBuilder, |
| ), |
| ); |
| } |
| } |
| } |
| } |
| |
| /// Handles previews defined on constructors. |
| @override |
| void visitConstructorDeclaration(ConstructorDeclaration node) { |
| _scopedVisitChildren( |
| node, |
| (ConstructorDeclaration? node) => _currentConstructor = node, |
| ); |
| } |
| |
| /// Handles previews defined on top-level functions. |
| @override |
| void visitFunctionDeclaration(FunctionDeclaration node) { |
| assert(_currentFunction == null); |
| if (node.name.isPrivate) { |
| return; |
| } |
| |
| TypeAnnotation? returnType = node.returnType; |
| if (returnType == null || returnType.question != null) { |
| return; |
| } |
| _scopedVisitChildren( |
| node, |
| (FunctionDeclaration? node) => _currentFunction = node, |
| ); |
| } |
| |
| /// Handles previews defined on static methods within classes. |
| @override |
| void visitMethodDeclaration(MethodDeclaration node) { |
| if (!node.isStatic) { |
| return; |
| } |
| _scopedVisitChildren( |
| node, |
| (MethodDeclaration? node) => _currentMethod = node, |
| ); |
| } |
| |
| void _scopedVisitChildren<T extends AstNode>( |
| T node, |
| void Function(T?) setter, |
| ) { |
| setter(node); |
| node.visitChildren(this); |
| setter(null); |
| } |
| } |
| |
| extension on Annotation { |
| static final widgetPreviewsLibraryUri = Uri.parse( |
| 'package:flutter/src/widget_previews/widget_previews.dart', |
| ); |
| |
| /// Convenience getter to identify `@MultiPreview` annotations |
| bool get isMultiPreview => _isPreviewType('MultiPreview'); |
| |
| /// Convenience getter to identify `@Preview` annotations |
| bool get isPreview => _isPreviewType('Preview'); |
| |
| bool _isPreviewType(String typeName) { |
| Element? element = elementAnnotation!.element; |
| if (element is ConstructorElement) { |
| InterfaceType type = element.enclosingElement.thisType; |
| return type.isType(typeName: typeName, uri: widgetPreviewsLibraryUri); |
| } |
| return false; |
| } |
| } |
| |
| extension on DartObject { |
| /// Generates an equivalent source code representation of this constant |
| /// object using [prefixAllocator] to apply namespaces to types. |
| String toSource(NamespaceAllocator prefixAllocator) { |
| DartType type = this.type!; |
| return switch (type) { |
| DartType(isDartCoreBool: true) => toBoolValue()!.toString(), |
| DartType(isDartCoreDouble: true) => toDoubleValue()!.toString(), |
| DartType(isDartCoreInt: true) => toIntValue()!.toString(), |
| DartType(isDartCoreString: true) => "'${toStringValue()!}'", |
| DartType(isDartCoreNull: true) => 'null', |
| DartType(isDartCoreList: true) => _buildListSource(prefixAllocator), |
| DartType(isDartCoreMap: true) => _buildMapSource(prefixAllocator), |
| DartType(isDartCoreSet: true) => _buildSetSource(prefixAllocator), |
| RecordType() => _buildRecordSource(prefixAllocator), |
| InterfaceType(element: EnumElement()) => _buildEnumInstanceSource( |
| prefixAllocator, |
| ), |
| InterfaceType() => _buildInstanceSource(prefixAllocator), |
| FunctionType() => _createTearoffSource(prefixAllocator), |
| _ => throw UnsupportedError('Unexpected DartObject type: $runtimeType'), |
| }; |
| } |
| |
| String _buildEnumInstanceSource(NamespaceAllocator prefixAllocator) { |
| VariableElement variable = this.variable!; |
| var url = variable.library!.uri.toString(); |
| return switch (variable) { |
| FieldElement( |
| isEnumConstant: true, |
| displayName: var enumValue, |
| enclosingElement: EnumElement(displayName: var enumName), |
| ) => |
| prefixAllocator.applyNamespaceToSymbol( |
| symbol: '$enumName.$enumValue', |
| url: url, |
| ), |
| |
| PropertyInducingElement(:var displayName) => |
| prefixAllocator.applyNamespaceToSymbol(symbol: displayName, url: url), |
| _ => throw UnsupportedError( |
| 'Unexpected enum variable type: ${variable.runtimeType}', |
| ), |
| }; |
| } |
| |
| String _buildInstanceSource(NamespaceAllocator prefixAllocator) { |
| var dartType = type! as InterfaceType; |
| var invocation = constructorInvocation; |
| if (invocation == null) { |
| return prefixAllocator.applyNamespaceToSymbol( |
| symbol: dartType.element.name!, |
| url: dartType.element.library.uri.toString(), |
| ); |
| } |
| |
| ConstructorElement? constructor = invocation.constructor; |
| String? constructorName = constructor.name == 'new' |
| ? null |
| : constructor.name; |
| |
| List<String> positionalArguments = invocation.positionalArguments |
| .map((e) => e.toSource(prefixAllocator)) |
| .toList(); |
| var namedArguments = <String, String>{ |
| for (final MapEntry(key: name, :value) |
| in invocation.namedArguments.entries) |
| name: value.toSource(prefixAllocator), |
| }; |
| var typeArguments = <String>[ |
| for (var typeArgument in dartType.typeArguments) |
| typeArgument.toSource(prefixAllocator), |
| ]; |
| |
| var buffer = StringBuffer(); |
| buffer.write( |
| prefixAllocator.applyNamespaceToSymbol( |
| symbol: dartType.element.name!, |
| url: dartType.element.library.uri.toString(), |
| ), |
| ); |
| if (typeArguments.isNotEmpty) { |
| buffer |
| ..write('<') |
| ..writeAll(typeArguments, ', ') |
| ..write('>'); |
| } |
| if (constructorName != null) { |
| buffer.write('.$constructorName'); |
| } |
| buffer |
| ..write('(') |
| ..writeAll([ |
| ...positionalArguments, |
| ...namedArguments.entries.map<String>((e) => '${e.key}: ${e.value}'), |
| ], ', ') |
| ..write(')'); |
| return buffer.toString(); |
| } |
| |
| String _buildListSource(NamespaceAllocator prefixAllocator) { |
| var list = toListValue()!; |
| var buffer = StringBuffer(); |
| buffer.write('['); |
| buffer.writeAll(list.map((e) => e.toSource(prefixAllocator)), ', '); |
| buffer.write(']'); |
| return buffer.toString(); |
| } |
| |
| String _buildMapSource(NamespaceAllocator prefixAllocator) { |
| var map = toMapValue()!; |
| var buffer = StringBuffer(); |
| buffer.write('{'); |
| buffer.writeAll( |
| map.entries.map( |
| (e) => |
| '${e.key!.toSource(prefixAllocator)}: ${e.value!.toSource(prefixAllocator)}', |
| ), |
| ', ', |
| ); |
| buffer.write('}'); |
| return buffer.toString(); |
| } |
| |
| String _buildRecordSource(NamespaceAllocator prefixAllocator) { |
| var record = toRecordValue()!; |
| var buffer = StringBuffer() |
| ..write('(') |
| ..writeAll([ |
| ...record.positional.map((e) => e.toSource(prefixAllocator)), |
| ...record.named.entries.map( |
| (e) => '${e.key}: ${e.value.toSource(prefixAllocator)}', |
| ), |
| ], ', ') |
| ..write(')'); |
| return buffer.toString(); |
| } |
| |
| String _buildSetSource(NamespaceAllocator prefixAllocator) { |
| var set = toSetValue()!; |
| var buffer = StringBuffer(); |
| buffer.write('{'); |
| buffer.writeAll(set.map((e) => e.toSource(prefixAllocator)), ', '); |
| buffer.write('}'); |
| return buffer.toString(); |
| } |
| |
| String _createTearoffSource(NamespaceAllocator prefixAllocator) { |
| var function = toFunctionValue()!; |
| return prefixAllocator.applyNamespaceToSymbol( |
| symbol: function.displayName, |
| url: function.library.uri.toString(), |
| ); |
| } |
| } |
| |
| extension on DartType { |
| /// Generates an equivalent source code representation of this type using |
| /// [prefixAllocator] to apply namespaces to all referenced types. |
| String toSource(NamespaceAllocator prefixAllocator) { |
| if (this is RecordType) { |
| return _recordToSource(this as RecordType, prefixAllocator); |
| } |
| var typeArguments = switch (this) { |
| InterfaceType(:var typeArguments) => [ |
| for (var typeArgument in typeArguments) |
| typeArgument.toSource(prefixAllocator), |
| ], |
| _ => <String>[], |
| }; |
| var element = this.element!; |
| var buffer = StringBuffer(); |
| buffer.write( |
| prefixAllocator.applyNamespaceToSymbol( |
| symbol: element.name!, |
| url: element.library!.uri.toString(), |
| ), |
| ); |
| if (typeArguments.isNotEmpty) { |
| buffer |
| ..write('<') |
| ..writeAll(typeArguments, ', ') |
| ..write('>'); |
| } |
| return buffer.toString(); |
| } |
| |
| String _recordToSource(RecordType type, NamespaceAllocator prefixAllocator) { |
| var positionalFields = type.positionalFields |
| .map((e) => e.type.toSource(prefixAllocator)) |
| .join(', '); |
| |
| var namedFields = type.namedFields |
| .map((e) => '${e.type.toSource(prefixAllocator)} ${e.name}') |
| .join(', '); |
| |
| var buffer = StringBuffer(); |
| buffer |
| ..write('(') |
| ..writeAll([ |
| if (positionalFields.isNotEmpty) positionalFields, |
| if (namedFields.isNotEmpty) '{$namedFields}', |
| ], ', ') |
| ..write(')'); |
| return buffer.toString(); |
| } |
| } |
| |
| extension on InterfaceType { |
| bool isType({required String typeName, required Uri uri}) { |
| if (getDisplayString() == typeName && element.library.uri == uri) { |
| return true; |
| } |
| return allSupertypes.firstWhereOrNull((e) { |
| return e.getDisplayString() == typeName && |
| e.element.library.uri == uri; |
| }) != |
| null; |
| } |
| } |
| |
| extension on Token { |
| /// Convenience getter to identify tokens for private fields and functions. |
| bool get isPrivate => toString().startsWith('_'); |
| |
| /// Convenience getter to identify Widget types. |
| bool get isWidget => toString() == 'Widget'; |
| |
| /// Convenience getter to identify WidgetBuilder types. |
| bool get isWidgetBuilder => toString() == 'WidgetBuilder'; |
| } |