| // Copyright (c) 2025, 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/doc_comment.dart'; |
| import 'package:analyzer/dart/ast/visitor.dart'; |
| import 'package:analyzer/file_system/file_system.dart'; |
| import 'package:analyzer/src/dart/ast/ast.dart'; |
| import 'package:analyzer/src/lint/registry.dart'; |
| import 'package:collection/collection.dart'; |
| import 'package:yaml/yaml.dart'; |
| |
| /// Computes [DocumentLink]s for lint names in an 'analysis_options.yaml'. |
| class AnalysisOptionLinkComputer { |
| static final Uri _lintStatesUri = Uri.https( |
| 'github.com', |
| 'dart-lang/sdk/blob/main/pkg/linter/doc/lint-lifecycle.md', |
| ); |
| |
| final String pubHostedUrl; |
| |
| AnalysisOptionLinkComputer(this.pubHostedUrl); |
| |
| List<DocumentLink> findLinks(String content) { |
| YamlNode node; |
| try { |
| node = loadYamlNode(content); |
| } catch (exception) { |
| return []; |
| } |
| |
| if (node is! YamlMap) return []; |
| |
| var allRules = <YamlNode>[ |
| if (node.nodes['linter'] case YamlMap dependencies) |
| ...switch (dependencies.nodes['rules']) { |
| YamlMap(:var nodes) => nodes.keys.cast<YamlNode>(), |
| YamlList rules => rules.nodes, |
| _ => const <YamlNode>[], |
| }, |
| ]; |
| |
| var links = <DocumentLink>[]; |
| for (var rule in allRules) { |
| var packageLink = _computeLink(rule); |
| |
| if (packageLink != null) { |
| var offset = rule.span.start.offset; |
| var length = rule.span.length; |
| links.add(DocumentLink(offset, length, packageLink)); |
| } |
| } |
| |
| var allPlugins = <(YamlNode, YamlNode)>[ |
| if (node.nodes['analyzer'] case YamlMap analyzer) |
| if (analyzer.nodes['plugins'] case YamlNode plugins) |
| ...switch (plugins) { |
| YamlMap(:var nodes) => nodes.cast<YamlNode, YamlNode>().entries.map( |
| (e) => (e.key, e.value), |
| ), |
| YamlList plugins => plugins.nodes.map((n) => (n, n)), |
| _ => const <(YamlNode, YamlNode)>[], |
| }, |
| if (node.nodes['plugins'] case YamlNode plugins) |
| ...switch (plugins) { |
| YamlMap(:var nodes) => nodes.cast<YamlNode, YamlNode>().entries.map( |
| (e) => (e.key, e.value), |
| ), |
| YamlList plugins => plugins.nodes.map((n) => (n, n)), |
| _ => const <(YamlNode, YamlNode)>[], |
| }, |
| ]; |
| |
| for (final (pluginName, pluginSource) in allPlugins) { |
| var pluginLink = _computePluginLink(pluginName, pluginSource); |
| |
| if (pluginLink != null) { |
| var offset = pluginName.span.start.offset; |
| var length = pluginName.span.length; |
| links.add(DocumentLink(offset, length, pluginLink)); |
| } |
| } |
| |
| links.sort((a, b) => a.offset.compareTo(b.offset)); |
| |
| return links; |
| } |
| |
| /// Computes a link for the rule named [rule]. |
| Uri? _computeLink(YamlNode rule) { |
| if (rule is! YamlScalar) return null; |
| var name = rule.value; |
| if (name is! String) return null; |
| name = name.toLowerCase(); |
| |
| var lint = Registry.ruleRegistry.rules.firstWhereOrNull( |
| (rule) => rule.name.toLowerCase() == name, |
| ); |
| if (lint == null) { |
| return null; |
| } |
| var state = lint.state; |
| if (state.isInternal) return _lintStatesUri.replace(fragment: 'internal'); |
| if (state.isRemoved) return _lintStatesUri.replace(fragment: 'removed'); |
| if (state.isTesting) return _lintStatesUri.replace(fragment: 'testing'); |
| |
| return Uri.https('dart.dev', 'lints/$name'); |
| } |
| |
| /// Computes a link for the plugin named [pluginName] with source [pluginSource]. |
| Uri? _computePluginLink(YamlNode pluginName, YamlNode pluginSource) { |
| var computer = PubspecDocumentLinkComputer(pubHostedUrl); |
| var link = computer._computeLink(pluginName, pluginSource); |
| if (link != null) return link; |
| |
| // Fallback to pub.dev if it was not a git or hosted link but we have a name. |
| if (pluginName is YamlScalar) { |
| var name = pluginName.value; |
| if (name is String && name.isNotEmpty) { |
| var separator = pubHostedUrl.endsWith('/') ? '' : '/'; |
| return Uri.parse('$pubHostedUrl${separator}packages/$name'); |
| } |
| } |
| return null; |
| } |
| } |
| |
| /// A visitor to locate links to other documents in a file. |
| /// |
| /// Such paths include "See example/a/b.dart" in documentation comments. |
| class DartDocumentLinkVisitor extends RecursiveAstVisitor<void> { |
| final ParsedUnitResult unit; |
| final String filePath; |
| final ResourceProvider resourceProvider; |
| final _documentLinks = <DocumentLink>[]; |
| |
| /// The directory that contains `examples/api`, `null` if not found. |
| late final Folder? folderWithExamplesApi = () { |
| var file = resourceProvider.getFile(filePath); |
| for (var parent in file.parent.withAncestors) { |
| var apiFolder = parent.getFolder('examples').getFolder('api'); |
| if (apiFolder.exists) { |
| return parent; |
| } |
| } |
| return null; |
| }(); |
| |
| DartDocumentLinkVisitor(this.resourceProvider, this.unit) |
| : filePath = unit.path; |
| |
| List<DocumentLink> findLinks(AstNode node) { |
| _documentLinks.clear(); |
| node.accept(this); |
| return _documentLinks; |
| } |
| |
| @override |
| void visitComment(Comment node) { |
| super.visitComment(node); |
| |
| var content = unit.content; |
| |
| var toolDirectives = node.docDirectives |
| .where((directive) => directive.type == DocDirectiveType.tool) |
| .whereType<BlockDocDirective>(); |
| for (var toolDirective in toolDirectives) { |
| var contentsStart = toolDirective.openingTag.end; |
| var contentsEnd = toolDirective.closingTag?.offset; |
| |
| // Skip unclosed tags. |
| if (contentsEnd == null) { |
| continue; |
| } |
| |
| var strValue = content.substring(contentsStart, contentsEnd); |
| if (strValue.isEmpty) { |
| continue; |
| } |
| |
| var seeCodeIn = '** See code in '; |
| var startIndex = strValue.indexOf('${seeCodeIn}examples/api/'); |
| if (startIndex != -1) { |
| final folderWithExamplesApi = this.folderWithExamplesApi; |
| if (folderWithExamplesApi == null) { |
| // Examples directory doesn't exist. |
| return; |
| } |
| startIndex += seeCodeIn.length; |
| var endIndex = strValue.indexOf('.dart') + 5; |
| var pathSnippet = strValue.substring(startIndex, endIndex); |
| // Split on '/' because that's what the comment syntax uses, but |
| // re-join it using the resource provider to get the right separator |
| // for the platform. |
| var examplePath = resourceProvider.pathContext.joinAll([ |
| folderWithExamplesApi.path, |
| ...pathSnippet.split('/'), |
| ]); |
| var offset = contentsStart + startIndex; |
| var length = endIndex - startIndex; |
| var exampleFile = resourceProvider.getFile(examplePath); |
| _documentLinks.add(DocumentLink(offset, length, exampleFile.toUri())); |
| } |
| } |
| } |
| } |
| |
| /// A link to another document or URI found in a source file. |
| class DocumentLink { |
| final Uri targetUri; |
| final int offset; |
| final int length; |
| |
| DocumentLink(this.offset, this.length, this.targetUri); |
| } |
| |
| /// Computes [DocumentLink]s for package names in a 'pubspec.yaml'. |
| class PubspecDocumentLinkComputer { |
| final String pubHostedUrl; |
| |
| final _gitHubSsshUrlPrefixRegExp = RegExp(r'^git@github\.com:'); |
| |
| PubspecDocumentLinkComputer(this.pubHostedUrl); |
| |
| List<DocumentLink> findLinks(String content) { |
| YamlNode node; |
| try { |
| node = loadYamlNode(content); |
| } catch (exception) { |
| return []; |
| } |
| |
| if (node is! YamlMap) return []; |
| |
| var allDependencies = [ |
| if (node.nodes['dependencies'] case YamlMap dependencies) |
| ...dependencies.nodes.cast<YamlNode, YamlNode>().entries, |
| if (node.nodes['dev_dependencies'] case YamlMap devDependencies) |
| ...devDependencies.nodes.cast<YamlNode, YamlNode>().entries, |
| if (node.nodes['dependency_overrides'] case YamlMap dependencyOverrides) |
| ...dependencyOverrides.nodes.cast<YamlNode, YamlNode>().entries, |
| ]; |
| |
| var links = <DocumentLink>[]; |
| for (var MapEntry(key: packageName, value: packageSource) |
| in allDependencies) { |
| var packageLink = _computeLink(packageName, packageSource); |
| |
| if (packageLink != null) { |
| var offset = packageName.span.start.offset; |
| var length = packageName.span.length; |
| links.add(DocumentLink(offset, length, packageLink)); |
| } |
| } |
| |
| return links; |
| } |
| |
| /// Attempts to compute a link for a Git URL. |
| /// |
| /// If the URL is already HTTP/HTTPS it can usually be used as-is. If it's |
| /// an SSH git link, attempt to convert it to an HTTPS equivalent: |
| /// |
| /// git@github.com:dart-lang/sdk.git -> https://github.com/dart-lang/sdk.git |
| Uri? _computeGit(String gitUrl, String packageName) { |
| // Perform substitions for SSH URLs that we know can safely be converted to |
| // HTTPS. |
| gitUrl = gitUrl.replaceFirst( |
| _gitHubSsshUrlPrefixRegExp, |
| 'https://github.com/', |
| ); |
| |
| var uri = Uri.tryParse(gitUrl); |
| return uri != null && (uri.isScheme('https') || uri.isScheme('http')) |
| ? uri |
| : null; |
| } |
| |
| /// Computes a link in the form [hostedBase]/packages/[packageName] |
| /// handling an optional trailing slash from [hostedBase]. |
| Uri _computeHosted(String hostedBase, String packageName) { |
| var separator = hostedBase.endsWith('/') ? '' : '/'; |
| return Uri.parse('$hostedBase${separator}packages/$packageName'); |
| } |
| |
| /// Computes a link for the package at [packageNameNode] based on the source |
| /// defined by [packageSource]. |
| Uri? _computeLink(YamlNode packageNameNode, YamlNode packageSource) { |
| if (packageNameNode is! YamlScalar) return null; |
| var name = packageNameNode.value; |
| if (name is! String) return null; |
| |
| return switch (packageSource) { |
| // Standard Pub packages. |
| // foo: |
| // foo: ^123 |
| YamlScalar() => _computeHosted(pubHostedUrl, name), |
| |
| // Hosted |
| // |
| // foo: |
| // hosted: http://foo/ |
| YamlMap(nodes: {'hosted': YamlScalar(value: String base)}) => |
| _computeHosted(base, name), |
| |
| // Hosted 2 |
| // |
| // foo2: |
| // hosted: |
| // url: http://foo |
| YamlMap( |
| nodes: { |
| 'hosted': YamlMap(nodes: {'url': YamlScalar(value: String base)}), |
| }, |
| ) => |
| _computeHosted(base, name), |
| |
| // Git 1 |
| // |
| // foo: |
| // git: git@github.com:dart-lang/sdk.git |
| YamlMap(nodes: {'git': YamlScalar(value: String url)}) => _computeGit( |
| url, |
| name, |
| ), |
| |
| // Git 2 |
| // |
| // foo2: |
| // git: |
| // url: http://foo |
| YamlMap( |
| nodes: {'git': YamlMap(nodes: {'url': YamlScalar(value: String url)})}, |
| ) => |
| _computeGit(url, name), |
| |
| // Unknown kind (such as path or sdk) that we can't produce a link for. |
| _ => null, |
| }; |
| } |
| } |