| // 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 const _lintsUrl = 'https://dart.dev/tools/linter-rules/'; |
| |
| AnalysisOptionLinkComputer(); |
| |
| 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.map((node) => node as 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)); |
| } |
| } |
| |
| 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; |
| |
| var lint = Registry.ruleRegistry.rules |
| .firstWhereOrNull((rule) => rule.name == name); |
| if (lint == null) { |
| return null; |
| } |
| |
| return Uri.tryParse(_lintsUrl + name); |
| } |
| } |
| |
| /// 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 |
| .getChildAssumingFolder('examples') |
| .getChildAssumingFolder('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; |
| _documentLinks.add(DocumentLink(offset, length, Uri.file(examplePath))); |
| } |
| } |
| } |
| } |
| |
| /// 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, |
| }; |
| } |
| } |