Skip to content

Commit ef55299

Browse files
authored
Refactor search ranking (#3424)
1 parent 1d94484 commit ef55299

File tree

8 files changed

+3668
-3339
lines changed

8 files changed

+3668
-3339
lines changed

lib/resources/docs.dart.js

Lines changed: 3276 additions & 3178 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/resources/docs.dart.js.map

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/src/generator/generator_backend.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ class DartdocGeneratorBackendOptions implements TemplateOptions {
4343

4444
final String? resourcesDir;
4545

46+
final List<String> packageOrder;
47+
4648
DartdocGeneratorBackendOptions.fromContext(
4749
DartdocGeneratorOptionContext context)
4850
: relCanonicalPrefix = context.relCanonicalPrefix,
@@ -53,7 +55,8 @@ class DartdocGeneratorBackendOptions implements TemplateOptions {
5355
customHeaderContent = context.header,
5456
customFooterContent = context.footer,
5557
customInnerFooterText = context.footerText,
56-
resourcesDir = context.resourcesDir;
58+
resourcesDir = context.resourcesDir,
59+
packageOrder = context.packageOrder;
5760
}
5861

5962
/// An interface for classes which are responsible for outputing the generated
@@ -167,7 +170,7 @@ abstract class GeneratorBackendBase implements GeneratorBackend {
167170
@override
168171
void generateSearchIndex(List<Indexable> indexedElements) {
169172
var json = generator_util.generateSearchIndexJson(
170-
indexedElements, options.prettyIndexJson);
173+
indexedElements, options.prettyIndexJson, options.packageOrder);
171174
if (!options.useBaseHref) {
172175
json = json.replaceAll(htmlBasePlaceholder, '');
173176
}

lib/src/generator/generator_utils.dart

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,13 @@ String removeHtmlTags(String? input) {
4343
return parsedString;
4444
}
4545

46-
String generateSearchIndexJson(
47-
Iterable<Indexable> indexedElements, bool pretty) {
46+
String generateSearchIndexJson(Iterable<Indexable> indexedElements, bool pretty,
47+
List<String> packageOrder) {
4848
final indexItems = [
49+
{packageOrderKey: packageOrder},
4950
for (final indexable
5051
in indexedElements.sorted(_compareElementRepresentations))
51-
<String, Object?>{
52+
{
5253
'name': indexable.name,
5354
'qualifiedName': indexable.fullyQualifiedName,
5455
'href': indexable.href,
@@ -72,6 +73,9 @@ String generateSearchIndexJson(
7273
return encoder.convert(indexItems);
7374
}
7475

76+
/// The key used in the `index.json` file used to specify the package order.
77+
const packageOrderKey = '__PACKAGE_ORDER__';
78+
7579
// Compares two elements, first by fully qualified name, then by kind.
7680
int _compareElementRepresentations<T extends Indexable>(T a, T b) {
7781
final value = compareNatural(a.fullyQualifiedName, b.fullyQualifiedName);

lib/src/search.dart

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:convert';
6+
7+
import 'package:dartdoc/src/generator/generator_utils.dart';
8+
import 'package:meta/meta.dart';
9+
10+
enum _MatchPosition {
11+
isExactly,
12+
startsWith,
13+
contains;
14+
15+
int operator -(_MatchPosition other) => index - other.index;
16+
}
17+
18+
class Index {
19+
final List<String> packageOrder;
20+
final List<IndexItem> index;
21+
22+
@visibleForTesting
23+
Index(this.packageOrder, this.index);
24+
25+
factory Index.fromJson(String text) {
26+
var jsonIndex = (jsonDecode(text) as List).cast<Map<String, Object>>();
27+
var indexList = <IndexItem>[];
28+
var packageOrder = <String>[];
29+
for (var entry in jsonIndex) {
30+
if (entry.containsKey(packageOrderKey)) {
31+
packageOrder.addAll(entry[packageOrderKey] as List<String>);
32+
} else {
33+
indexList.add(IndexItem.fromMap(entry));
34+
}
35+
}
36+
return Index(packageOrder, indexList);
37+
}
38+
39+
int packageOrderPosition(String packageName) {
40+
if (packageOrder.isEmpty) return 0;
41+
var index = packageOrder.indexOf(packageName);
42+
return index == -1 ? packageOrder.length : index;
43+
}
44+
45+
List<IndexItem> find(String rawQuery) {
46+
if (rawQuery.isEmpty) {
47+
return [];
48+
}
49+
50+
var query = rawQuery.toLowerCase();
51+
var allMatches = <({IndexItem item, _MatchPosition matchPosition})>[];
52+
53+
for (var item in index) {
54+
void score(_MatchPosition matchPosition) {
55+
allMatches.add((item: item, matchPosition: matchPosition));
56+
}
57+
58+
var lowerName = item.name.toLowerCase();
59+
var lowerQualifiedName = item.qualifiedName.toLowerCase();
60+
61+
if (lowerName == query ||
62+
lowerQualifiedName == query ||
63+
lowerName == 'dart:$query') {
64+
score(_MatchPosition.isExactly);
65+
} else if (query.length > 1) {
66+
if (lowerName.startsWith(query) ||
67+
lowerQualifiedName.startsWith(query)) {
68+
score(_MatchPosition.startsWith);
69+
} else if (lowerName.contains(query) ||
70+
lowerQualifiedName.contains(query)) {
71+
score(_MatchPosition.contains);
72+
}
73+
}
74+
}
75+
76+
allMatches.sort((a, b) {
77+
// Exact match vs substring is king. If the user has typed the whole term
78+
// they are searching for, but it isn't at the top, they cannot type any
79+
// more to try and find it.
80+
var comparison = a.matchPosition - b.matchPosition;
81+
if (comparison != 0) {
82+
return comparison;
83+
}
84+
85+
// Prefer packages higher in the package order.
86+
comparison = packageOrderPosition(a.item.packageName) -
87+
packageOrderPosition(b.item.packageName);
88+
if (comparison != 0) {
89+
return comparison;
90+
}
91+
92+
// Prefer top-level elements to library members to class (etc.) members.
93+
comparison = a.item._scope - b.item._scope;
94+
if (comparison != 0) {
95+
return comparison;
96+
}
97+
98+
// Prefer non-overrides to overrides.
99+
comparison = a.item.overriddenDepth - b.item.overriddenDepth;
100+
if (comparison != 0) {
101+
return comparison;
102+
}
103+
104+
// Prefer shorter names to longer ones.
105+
return a.item.name.length - b.item.name.length;
106+
});
107+
108+
return allMatches.map((match) => match.item).toList();
109+
}
110+
}
111+
112+
class IndexItem {
113+
final String name;
114+
final String qualifiedName;
115+
116+
// TODO(srawlins): Store the index of the package in package order instead of
117+
// this String. The Strings bloat the `index.json` file and keeping duplicate
118+
// parsed Strings in memory is expensive.
119+
final String packageName;
120+
final String type;
121+
final String? href;
122+
final int overriddenDepth;
123+
final String? desc;
124+
final EnclosedBy? enclosedBy;
125+
126+
IndexItem._({
127+
required this.name,
128+
required this.qualifiedName,
129+
required this.packageName,
130+
required this.type,
131+
required this.desc,
132+
required this.href,
133+
required this.overriddenDepth,
134+
required this.enclosedBy,
135+
});
136+
137+
// Example Map structure:
138+
//
139+
// ```dart
140+
// {
141+
// "name":"dartdoc",
142+
// "qualifiedName":"dartdoc.Dartdoc",
143+
// "href":"dartdoc/Dartdoc-class.html",
144+
// "type":"class",
145+
// "overriddenDepth":0,
146+
// "packageName":"dartdoc"
147+
// ["enclosedBy":{"name":"dartdoc","type":"library"}]
148+
// }
149+
// ```
150+
factory IndexItem.fromMap(Map<String, dynamic> data) {
151+
// Note that this map also contains 'packageName', but we're not currently
152+
// using that info.
153+
154+
EnclosedBy? enclosedBy;
155+
if (data['enclosedBy'] != null) {
156+
final map = data['enclosedBy'] as Map<String, Object>;
157+
enclosedBy = EnclosedBy._(
158+
name: map['name'] as String,
159+
type: map['type'] as String,
160+
href: map['href'] as String);
161+
}
162+
163+
return IndexItem._(
164+
name: data['name'],
165+
qualifiedName: data['qualifiedName'],
166+
packageName: data['packageName'],
167+
href: data['href'],
168+
type: data['type'],
169+
overriddenDepth: (data['overriddenDepth'] as int?) ?? 0,
170+
desc: data['desc'],
171+
enclosedBy: enclosedBy,
172+
);
173+
}
174+
175+
/// The "scope" of a search item which may affect ranking.
176+
///
177+
/// This is not the lexical scope of identifiers in Dart code, but similar in a
178+
/// very loose sense. We define 4 "scopes":
179+
///
180+
/// * 0: root- and package-level items
181+
/// * 1: library members
182+
/// * 2: container members
183+
/// * 3: unknown (shouldn't be used but present for completeness)
184+
// TODO(srawlins): Test and confirm that top-level functions, variables, and
185+
// constants are ranked appropriately.
186+
int get _scope =>
187+
const {
188+
'topic': 0,
189+
'library': 0,
190+
'class': 1,
191+
'enum': 1,
192+
'mixin': 1,
193+
'extension': 1,
194+
'typedef': 1,
195+
'function': 2,
196+
'method': 2,
197+
'accessor': 2,
198+
'operator': 2,
199+
'constant': 2,
200+
'property': 2,
201+
'constructor': 2,
202+
}[type] ??
203+
3;
204+
}
205+
206+
class EnclosedBy {
207+
final String name;
208+
final String type;
209+
final String href;
210+
211+
// Built from JSON structure:
212+
// ["enclosedBy":{"name":"Accessor","type":"class","href":"link"}]
213+
EnclosedBy._({required this.name, required this.type, required this.href});
214+
}

0 commit comments

Comments
 (0)