Skip to content

Commit 3c653b4

Browse files
astashovdevoncarew
authored andcommitted
Resolve non-imported symbols in comments [#1153] (#1298)
* Resolve non-imported symbols in comments [#1153] @Hixie noticed, that sometimes, a library, like the Flutter rendering library, wants to refer to another library, like the Flutter widgets library, in the documentation, but doesn't want to introduce a dependency in the code. Currently, there’s no mechanisms in dartdoc, which allows that. This commit adds that. You can use either a short name (e.g. [icon]) or a fully qualified name (like, [material.Icon.icon]), in the HTML docs, it always will be shown as a short name though (is that a desired behavior?). Dartdoc will go over all the libraries, classes, methods, properties, constants of the package that is being documented, and will try to resolve the symbol. If it’s successful, it will add the link to it, same way as when the symbol is in scope. Some outstanding questions: * What to do in case of ambiguity here? Show a warning, crash with an error message, ignore? * Do we want to show short name in case a fully qualified name is provided? I saw in the comments in the ticket (#1153 (comment)) that @Hixie would want that. * Anything else? Testing: Unit tests, of course, also tried to generate Flutter docs with that, and observed that the links for previously non-resolved symbols work. * Address @devoncarew feedback * Add types to declarations * Add filename/line number to the warnings * Move the code for gathering all the model elements of a package to the package's accessor and cache it. * Address another round of feedback From now on, if the reference was, for example, `[key]`, we won't match `material.Widget.key`, we will only match `material.key` (i.e. top-level `key` symbol). If you still want to match `Widget.key`, you should use either `[Widget.key]` or `[material.Widget.key]`.
1 parent 61797bc commit 3c653b4

File tree

7 files changed

+151
-69
lines changed

7 files changed

+151
-69
lines changed

lib/src/markdown_processor.dart

Lines changed: 68 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,7 @@ import 'dart:convert';
99

1010
import 'package:analyzer/dart/ast/ast.dart';
1111
import 'package:analyzer/dart/element/element.dart'
12-
show
13-
LibraryElement,
14-
Element,
15-
ConstructorElement,
16-
ClassElement,
17-
ParameterElement,
18-
PropertyAccessorElement;
12+
show LibraryElement, Element, ConstructorElement, ClassElement, ParameterElement, PropertyAccessorElement;
1913
import 'package:html/parser.dart' show parse;
2014
import 'package:markdown/markdown.dart' as md;
2115

@@ -28,32 +22,31 @@ const List<String> _oneLinerSkipTags = const ["code", "pre"];
2822

2923
final List<md.InlineSyntax> _markdown_syntaxes = [new _InlineCodeSyntax()];
3024

25+
class MatchingLinkResult {
26+
final ModelElement element;
27+
final String label;
28+
MatchingLinkResult(this.element, this.label);
29+
}
30+
3131
// TODO: this is in the wrong place
3232
NodeList<CommentReference> _getCommentRefs(ModelElement modelElement) {
3333
if (modelElement == null) return null;
3434
if (modelElement.documentation == null && modelElement.canOverride()) {
3535
var melement = modelElement.overriddenElement;
36-
if (melement != null &&
37-
melement.element.computeNode() != null &&
38-
melement.element.computeNode() is AnnotatedNode) {
39-
var docComment = (melement.element.computeNode() as AnnotatedNode)
40-
.documentationComment;
36+
if (melement != null && melement.element.computeNode() != null && melement.element.computeNode() is AnnotatedNode) {
37+
var docComment = (melement.element.computeNode() as AnnotatedNode).documentationComment;
4138
if (docComment != null) return docComment.references;
4239
return null;
4340
}
4441
}
4542
if (modelElement.element.computeNode() is AnnotatedNode) {
46-
if ((modelElement.element.computeNode() as AnnotatedNode)
47-
.documentationComment !=
48-
null) {
49-
return (modelElement.element.computeNode() as AnnotatedNode)
50-
.documentationComment
51-
.references;
43+
final AnnotatedNode annotatedNode = modelElement.element.computeNode();
44+
if (annotatedNode.documentationComment != null) {
45+
return annotatedNode.documentationComment.references;
5246
}
5347
} else if (modelElement.element is LibraryElement) {
5448
// handle anonymous libraries
55-
if (modelElement.element.computeNode() == null ||
56-
modelElement.element.computeNode().parent == null) {
49+
if (modelElement.element.computeNode() == null || modelElement.element.computeNode().parent == null) {
5750
return null;
5851
}
5952
var node = modelElement.element.computeNode().parent.parent;
@@ -67,84 +60,113 @@ NodeList<CommentReference> _getCommentRefs(ModelElement modelElement) {
6760
}
6861

6962
/// Returns null if element is a parameter.
70-
ModelElement _getMatchingLinkElement(
71-
String codeRef, ModelElement element, List<CommentReference> commentRefs,
63+
MatchingLinkResult _getMatchingLinkElement(String codeRef, ModelElement element, List<CommentReference> commentRefs,
7264
{bool isConstructor: false}) {
73-
if (commentRefs == null) return null;
65+
if (commentRefs == null) return new MatchingLinkResult(null, null);
7466

7567
Element refElement;
7668
bool isEnum = false;
7769

7870
for (CommentReference ref in commentRefs) {
7971
if (ref.identifier.name == codeRef) {
8072
bool isConstrElement = ref.identifier.staticElement is ConstructorElement;
81-
if (isConstructor && isConstrElement ||
82-
!isConstructor && !isConstrElement) {
73+
if (isConstructor && isConstrElement || !isConstructor && !isConstrElement) {
8374
refElement = ref.identifier.staticElement;
8475
break;
8576
}
8677
}
8778
}
8879

8980
// Did not find an element in scope
90-
if (refElement == null) return null;
81+
if (refElement == null) {
82+
return _findRefElementInLibrary(codeRef, element, commentRefs);
83+
}
9184

9285
if (refElement is PropertyAccessorElement) {
9386
// yay we found an accessor that wraps a const, but we really
9487
// want the top-level field itself
9588
refElement = (refElement as PropertyAccessorElement).variable;
96-
if (refElement.enclosingElement is ClassElement &&
97-
(refElement.enclosingElement as ClassElement).isEnum) {
89+
if (refElement.enclosingElement is ClassElement && (refElement.enclosingElement as ClassElement).isEnum) {
9890
isEnum = true;
9991
}
10092
}
10193

102-
if (refElement is ParameterElement) return null;
94+
if (refElement is ParameterElement) return new MatchingLinkResult(null, null);
10395

10496
// bug! this can fail to find the right library name if the element's name
10597
// we're looking for is the same as a name that comes in from an imported
10698
// library.
10799
//
108100
// Don't search through all libraries in the package, actually search
109101
// in the current scope.
110-
Library refLibrary =
111-
element.package.findLibraryFor(refElement, scopedTo: element);
102+
Library refLibrary = element.package.findLibraryFor(refElement, scopedTo: element);
112103

113104
if (refLibrary != null) {
114105
// Is there a way to pull this from a registry of known elements?
115106
// Seems like we're creating too many objects this way.
116107
if (isEnum) {
117-
return new EnumField(refElement, refLibrary);
108+
return new MatchingLinkResult(new EnumField(refElement, refLibrary), null);
118109
}
119-
return new ModelElement.from(refElement, refLibrary);
110+
return new MatchingLinkResult(new ModelElement.from(refElement, refLibrary), null);
111+
}
112+
return new MatchingLinkResult(null, null);
113+
}
114+
115+
MatchingLinkResult _findRefElementInLibrary(String codeRef, ModelElement element, List<CommentReference> commentRefs) {
116+
final Library library = element.library;
117+
final Package package = library.package;
118+
final Map<String, ModelElement> result = {};
119+
120+
for (final modelElement in package.allModelElements) {
121+
if (codeRef == modelElement.fullyQualifiedName) {
122+
result[modelElement.fullyQualifiedName] = modelElement;
123+
}
124+
}
125+
126+
for (final modelElement in library.allModelElements) {
127+
if (codeRef == modelElement.fullyQualifiedNameWithoutLibrary) {
128+
result[modelElement.fullyQualifiedName] = modelElement;
129+
}
130+
}
131+
132+
if (result.isEmpty) {
133+
return new MatchingLinkResult(null, null);
134+
} else if (result.length == 1) {
135+
return new MatchingLinkResult(result.values.first, result.values.first.name);
136+
} else {
137+
// TODO: add --fatal-warning, which would make the app crash in case of ambiguous references
138+
print(
139+
"Ambiguous reference to [${codeRef}] in '${element.fullyQualifiedName}' (${element.sourceFileName}:${element.lineNumber}). " +
140+
"We found matches to the following elements: ${result.keys.map((k) => "'${k}'").join(", ")}");
141+
return new MatchingLinkResult(null, null);
120142
}
121-
return null;
122143
}
123144

124-
String _linkDocReference(String reference, ModelElement element,
125-
NodeList<CommentReference> commentRefs) {
126-
ModelElement linkedElement;
145+
String _linkDocReference(String reference, ModelElement element, NodeList<CommentReference> commentRefs) {
127146
// support for [new Constructor] and [new Class.namedCtr]
128147
var refs = reference.split(' ');
148+
MatchingLinkResult result;
129149
if (refs.length == 2 && refs.first == 'new') {
130-
linkedElement = _getMatchingLinkElement(refs[1], element, commentRefs,
131-
isConstructor: true);
150+
result = _getMatchingLinkElement(refs[1], element, commentRefs, isConstructor: true);
132151
} else {
133-
linkedElement = _getMatchingLinkElement(reference, element, commentRefs);
152+
result = _getMatchingLinkElement(reference, element, commentRefs);
134153
}
154+
final ModelElement linkedElement = result.element;
155+
final String label = result.label ?? reference;
135156
if (linkedElement != null) {
136157
var classContent = '';
137158
if (linkedElement.isDeprecated) {
138159
classContent = 'class="deprecated" ';
139160
}
140161
// this would be linkedElement.linkedName, but link bodies are slightly
141162
// different for doc references. sigh.
142-
return '<a ${classContent}href="${linkedElement.href}">$reference</a>';
163+
return '<a ${classContent}href="${linkedElement.href}">$label</a>';
143164
} else {
144165
if (_emitWarning) {
166+
// TODO: add --fatal-warning, which would make the app crash in case of ambiguous references
145167
print(" warning: unresolved doc reference '$reference' (in $element)");
146168
}
147-
return '<code>${HTML_ESCAPE.convert(reference)}</code>';
169+
return '<code>${HTML_ESCAPE.convert(label)}</code>';
148170
}
149171
}
150172

@@ -154,8 +176,7 @@ String _renderMarkdownToHtml(String text, [ModelElement element]) {
154176
return new md.Text(_linkDocReference(name, element, commentRefs));
155177
}
156178

157-
return md.markdownToHtml(text,
158-
inlineSyntaxes: _markdown_syntaxes, linkResolver: _linkResolver);
179+
return md.markdownToHtml(text, inlineSyntaxes: _markdown_syntaxes, linkResolver: _linkResolver);
159180
}
160181

161182
class Documentation {
@@ -181,16 +202,13 @@ class Documentation {
181202
s.remove();
182203
}
183204
for (var pre in asHtmlDocument.querySelectorAll('pre')) {
184-
if (pre.children.isNotEmpty &&
185-
pre.children.length != 1 &&
186-
pre.children.first.localName != 'code') {
205+
if (pre.children.isNotEmpty && pre.children.length != 1 && pre.children.first.localName != 'code') {
187206
continue;
188207
}
189208

190209
if (pre.children.isNotEmpty && pre.children.first.localName == 'code') {
191210
var code = pre.children.first;
192-
pre.classes
193-
.addAll(code.classes.where((name) => name.startsWith('language-')));
211+
pre.classes.addAll(code.classes.where((name) => name.startsWith('language-')));
194212
}
195213

196214
bool specifiesLanguage = pre.classes.isNotEmpty;
@@ -201,9 +219,7 @@ class Documentation {
201219

202220
// `trim` fixes issue with line ending differences between mac and windows.
203221
var asHtml = asHtmlDocument.body.innerHtml?.trim();
204-
var asOneLiner = asHtmlDocument.body.children.isEmpty
205-
? ''
206-
: asHtmlDocument.body.children.first.innerHtml;
222+
var asOneLiner = asHtmlDocument.body.children.isEmpty ? '' : asHtmlDocument.body.children.first.innerHtml;
207223
if (!asOneLiner.startsWith('<p>')) {
208224
asOneLiner = '<p>$asOneLiner</p>';
209225
}

lib/src/model.dart

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,6 +1231,30 @@ class Library extends ModelElement {
12311231
return _getPackageName(dir.parent);
12321232
}
12331233
}
1234+
1235+
List<ModelElement> _allModelElements;
1236+
List<ModelElement> get allModelElements {
1237+
if (_allModelElements == null) {
1238+
final List<ModelElement> result = [];
1239+
result
1240+
..addAll(library.allClasses)
1241+
..addAll(library.constants)
1242+
..addAll(library.enums)
1243+
..addAll(library.functions)
1244+
..addAll(library.properties)
1245+
..addAll(library.typedefs);
1246+
1247+
library.allClasses.forEach((c) {
1248+
result.addAll(c.allInstanceMethods);
1249+
result.addAll(c.allInstanceProperties);
1250+
result.addAll(c.allOperators);
1251+
result.addAll(c.staticMethods);
1252+
result.addAll(c.staticProperties);
1253+
});
1254+
_allModelElements = result;
1255+
}
1256+
return _allModelElements;
1257+
}
12341258
}
12351259

12361260
class Method extends ModelElement
@@ -1328,6 +1352,7 @@ abstract class ModelElement implements Comparable, Nameable, Documentable {
13281352
String _linkedName;
13291353

13301354
String _fullyQualifiedName;
1355+
String _fullyQualifiedNameWithoutLibrary;
13311356

13321357
// WARNING: putting anything into the body of this seems
13331358
// to lead to stack overflows. Need to make a registry of ModelElements
@@ -1441,6 +1466,28 @@ abstract class ModelElement implements Comparable, Nameable, Documentable {
14411466
return (_fullyQualifiedName ??= _buildFullyQualifiedName());
14421467
}
14431468

1469+
String get fullyQualifiedNameWithoutLibrary {
1470+
return (_fullyQualifiedNameWithoutLibrary ??= fullyQualifiedName.split(".").skip(1).join("."));
1471+
}
1472+
1473+
String get sourceFileName => element.source.fullName;
1474+
1475+
int _lineNumber;
1476+
bool _isLineNumberComputed = false;
1477+
int get lineNumber {
1478+
if (!_isLineNumberComputed) {
1479+
var node = element.computeNode();
1480+
if (node is Declaration && node.element != null) {
1481+
var element = node.element;
1482+
var lineNumber = lineNumberCache.lineNumber(
1483+
element.source.fullName, element.nameOffset);
1484+
_lineNumber = lineNumber + 1;
1485+
}
1486+
_isLineNumberComputed = true;
1487+
}
1488+
return _lineNumber;
1489+
}
1490+
14441491
bool get hasAnnotations => annotations.isNotEmpty;
14451492

14461493
@override
@@ -2077,6 +2124,17 @@ class Package implements Nameable, Documentable {
20772124
}
20782125
return new Library(e.library, this);
20792126
}
2127+
2128+
List<ModelElement> _allModelElements;
2129+
List<ModelElement> get allModelElements {
2130+
if (_allModelElements == null) {
2131+
_allModelElements = [];
2132+
this.libraries.forEach((library) {
2133+
_allModelElements.addAll(library.allModelElements);
2134+
});
2135+
}
2136+
return _allModelElements;
2137+
}
20802138
}
20812139

20822140
class PackageCategory implements Comparable<PackageCategory> {
@@ -2158,6 +2216,8 @@ abstract class SourceCodeMixin {
21582216
}
21592217
}
21602218

2219+
int get lineNumber;
2220+
21612221
Element get element;
21622222

21632223
bool get hasSourceCode => config.includeSource && sourceCode.isNotEmpty;
@@ -2240,21 +2300,9 @@ abstract class SourceCodeMixin {
22402300
}
22412301

22422302
String get _crossdartUrl {
2243-
if (_lineNumber != null && _crossdartPath != null) {
2303+
if (lineNumber != null && _crossdartPath != null) {
22442304
String url = "//www.crossdart.info/p/${_crossdartPath}.html";
2245-
return "${url}#line-${_lineNumber}";
2246-
} else {
2247-
return null;
2248-
}
2249-
}
2250-
2251-
int get _lineNumber {
2252-
var node = element.computeNode();
2253-
if (node is Declaration && node.element != null) {
2254-
var element = node.element;
2255-
var lineNumber = lineNumberCache.lineNumber(
2256-
element.source.fullName, element.nameOffset);
2257-
return lineNumber + 1;
2305+
return "${url}#line-${lineNumber}";
22582306
} else {
22592307
return null;
22602308
}

test/model_test.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,14 @@ void main() {
279279
expect(docsAsHtml, contains('<code>doesStuff</code>'));
280280
});
281281

282+
test(
283+
'link to unresolved name in the library in this package still should be linked',
284+
() {
285+
final Class helperClass = exLibrary.classes.firstWhere((c) => c.name == 'Helper');
286+
expect(helperClass.documentationAsHtml, contains('<a href="ex/Apple-class.html">Apple</a>'));
287+
expect(helperClass.documentationAsHtml, contains('<a href="ex/B-class.html">B</a>'));
288+
});
289+
282290
test(
283291
'link to a name of a class from an imported library that exports the name',
284292
() {

testing/test_package/lib/src/mylib.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ library src.mylib;
22

33
void helperFunction(String message) => print(message);
44

5+
/// Even unresolved references in the same library should be resolved
6+
/// [Apple]
7+
/// [ex.B]
58
class Helper {
69
Helper();
710

testing/test_package_docs/ex/Helper-class.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,11 @@ <h5><a href="ex/ex-library.html">ex</a></h5>
136136

137137
<div class="col-xs-12 col-sm-9 col-md-8 main-content">
138138

139+
<section class="desc markdown">
140+
<p>Even unresolved references in the same library should be resolved
141+
<a href="ex/Apple-class.html">Apple</a>
142+
<a href="ex/B-class.html">B</a></p>
143+
</section>
139144

140145

141146

0 commit comments

Comments
 (0)