Skip to content

Commit 36e7106

Browse files
authored
Refactor completion suggestion + test + sort same-score items alphabetically (#8125)
1 parent d7a7e42 commit 36e7106

File tree

3 files changed

+286
-140
lines changed

3 files changed

+286
-140
lines changed
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Copyright (c) 2024, 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:math' as math;
6+
7+
import 'package:_pub_shared/data/completion.dart';
8+
import 'package:collection/collection.dart';
9+
10+
typedef Suggestions = List<Suggestion>;
11+
12+
class Suggestion {
13+
final int start;
14+
final int end;
15+
final String value;
16+
// TODO: Don't create HTML manually!
17+
final String html;
18+
final double score;
19+
20+
Suggestion({
21+
required this.start,
22+
required this.end,
23+
required this.value,
24+
required this.html,
25+
required this.score,
26+
});
27+
28+
Map<String, dynamic> toJson() => {
29+
'start': start,
30+
'end': end,
31+
'value': value,
32+
'html': html,
33+
'score': score,
34+
};
35+
}
36+
37+
/// Given [data] and [caret] position inside [text] what suggestions do we
38+
/// want to offer and should completion be automatically triggered?
39+
({bool trigger, Suggestions suggestions}) suggest(
40+
CompletionData data,
41+
String text,
42+
int caret,
43+
) {
44+
// Get position before caret
45+
final beforeCaret = caret > 0 ? caret - 1 : 0;
46+
// Get position of space after the caret
47+
final spaceAfterCaret = text.indexOf(' ', caret);
48+
49+
// Start and end of word we are completing
50+
final start = text.lastIndexOf(' ', beforeCaret) + 1;
51+
final end = spaceAfterCaret != -1 ? spaceAfterCaret : text.length;
52+
53+
// If caret is not at the end, and the next character isn't space then we
54+
// do not automatically trigger completion.
55+
bool trigger;
56+
if (caret < text.length && text[caret] != ' ') {
57+
trigger = false;
58+
} else {
59+
// If the part before the caret is matched, then we can auto trigger
60+
final wordBeforeCaret = text.substring(start, caret);
61+
trigger = data.completions.any(
62+
(c) => !c.forcedOnly && c.match.any(wordBeforeCaret.startsWith),
63+
);
64+
}
65+
66+
// Get the word that we are completing
67+
final word = text.substring(start, end);
68+
69+
// Find the longest match for each completion entry
70+
final completionWithBestMatch = data.completions.map((c) => (
71+
completion: c,
72+
match: maxBy(c.match.where(word.startsWith), (m) => m.length),
73+
));
74+
// Find the best completion entry
75+
final (:completion, :match) = maxBy(completionWithBestMatch, (c) {
76+
final m = c.match;
77+
return m != null ? m.length : -1;
78+
}) ??
79+
(completion: null, match: null);
80+
if (completion == null || match == null) {
81+
return (
82+
trigger: false,
83+
suggestions: [],
84+
);
85+
}
86+
87+
// prefix to be used for completion of options
88+
final prefix = word.substring(match.length);
89+
90+
if (completion.options.contains(prefix)) {
91+
// If prefix is an option, and there is no other options we don't have
92+
// anything to suggest.
93+
if (completion.options.length == 1) {
94+
return (
95+
trigger: false,
96+
suggestions: [],
97+
);
98+
}
99+
// We don't to auto trigger completion unless there is an option that is
100+
// also a prefix and longer than what prefix currently matches.
101+
trigger &= completion.options.any(
102+
(opt) => opt.startsWith(prefix) && opt != prefix,
103+
);
104+
}
105+
106+
// Terminate suggestion with a ' ' suffix, if this is a terminal completion
107+
final suffix = completion.terminal ? ' ' : '';
108+
109+
final suggestions = completion.options.map((option) {
110+
final overlap = _lcs(prefix, option);
111+
var html = option;
112+
if (overlap.isNotEmpty) {
113+
html = html.replaceAll(overlap, '<strong>$overlap</strong>');
114+
}
115+
final score = (option.startsWith(word) ? math.pow(overlap.length, 3) : 0) +
116+
math.pow(overlap.length, 2) +
117+
(option.startsWith(overlap) ? overlap.length : 0) +
118+
overlap.length / option.length;
119+
return Suggestion(
120+
value: match + option + suffix,
121+
start: start,
122+
end: end,
123+
html: html,
124+
score: score,
125+
);
126+
}).sorted((a, b) {
127+
final x = -a.score.compareTo(b.score);
128+
if (x != 0) return x;
129+
return a.value.compareTo(b.value);
130+
});
131+
132+
return (
133+
trigger: trigger,
134+
suggestions: suggestions,
135+
);
136+
}
137+
138+
/// The longest common substring
139+
String _lcs(String S, String T) {
140+
final r = S.length;
141+
final n = T.length;
142+
var Lp = List.filled(n, 0); // ignore: non_constant_identifier_names
143+
var Li = List.filled(n, 0); // ignore: non_constant_identifier_names
144+
var z = 0;
145+
var [start, end] = [0, 0];
146+
for (var i = 0; i < r; i++) {
147+
for (var j = 0; j < n; j++) {
148+
if (S[i] == T[j]) {
149+
if (i == 0 || j == 0) {
150+
Li[j] = 1;
151+
} else {
152+
Li[j] = Lp[j - 1] + 1;
153+
}
154+
if (Li[j] > z) {
155+
z = Li[j];
156+
[start, end] = [i - z + 1, i + 1];
157+
}
158+
}
159+
}
160+
[Lp, Li] = [Li, Lp..fillRange(0, Lp.length, 0)];
161+
}
162+
return S.substring(start, end);
163+
}

pkg/web_app/lib/src/widget/completion/widget.dart

Lines changed: 4 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,13 @@
55
import 'dart:async';
66
import 'dart:convert';
77
import 'dart:js_interop';
8-
import 'dart:math' as math;
98

109
import 'package:_pub_shared/data/completion.dart';
11-
import 'package:collection/collection.dart';
1210
import 'package:http/http.dart' deferred as http show read;
1311
import 'package:web/web.dart';
1412

1513
import '../../web_util.dart';
14+
import 'suggest.dart';
1615

1716
/// Create a [_CompletionWidget] on [element].
1817
///
@@ -92,15 +91,6 @@ void create(HTMLElement element, Map<String, String> options) {
9291
});
9392
}
9493

95-
typedef _Suggestions = List<
96-
({
97-
String value,
98-
String html, // TODO: Don't create HTML manually!
99-
int start,
100-
int end,
101-
double score,
102-
})>;
103-
10494
final class _State {
10595
/// Completion is not active, happens whens:
10696
/// * The input element doesn't have focus, or,
@@ -123,7 +113,7 @@ final class _State {
123113
final int caret;
124114

125115
/// Suggestions on the form: {value, html, start, end}
126-
final _Suggestions suggestions;
116+
final Suggestions suggestions;
127117

128118
/// Selected suggestion
129119
final int selectedIndex;
@@ -146,7 +136,7 @@ final class _State {
146136
bool? triggered,
147137
String? text,
148138
int? caret,
149-
_Suggestions? suggestions,
139+
Suggestions? suggestions,
150140
int? selectedIndex,
151141
}) =>
152142
_State(
@@ -245,7 +235,7 @@ final class _CompletionWidget {
245235
state.suggestions.isNotEmpty;
246236
}
247237

248-
var _renderedSuggestions = _Suggestions.empty();
238+
var _renderedSuggestions = Suggestions.empty();
249239

250240
void update() {
251241
if (!displayDropdown) {
@@ -466,130 +456,4 @@ final class _CompletionWidget {
466456
].join(' ');
467457
return ctx.measureText(text).width.floor();
468458
}
469-
470-
/// Given [data] and [caret] position inside [text] what suggestions do we
471-
/// want to offer and should completion be automatically triggered?
472-
static ({bool trigger, _Suggestions suggestions}) suggest(
473-
CompletionData data,
474-
String text,
475-
int caret,
476-
) {
477-
// Get position before caret
478-
final beforeCaret = caret > 0 ? caret - 1 : 0;
479-
// Get position of space after the caret
480-
final spaceAfterCaret = text.indexOf(' ', caret);
481-
482-
// Start and end of word we are completing
483-
final start = text.lastIndexOf(' ', beforeCaret) + 1;
484-
final end = spaceAfterCaret != -1 ? spaceAfterCaret : text.length;
485-
486-
// If caret is not at the end, and the next character isn't space then we
487-
// do not automatically trigger completion.
488-
bool trigger;
489-
if (caret < text.length && text[caret] != ' ') {
490-
trigger = false;
491-
} else {
492-
// If the part before the caret is matched, then we can auto trigger
493-
final wordBeforeCaret = text.substring(start, caret);
494-
trigger = data.completions.any(
495-
(c) => !c.forcedOnly && c.match.any(wordBeforeCaret.startsWith),
496-
);
497-
}
498-
499-
// Get the word that we are completing
500-
final word = text.substring(start, end);
501-
502-
// Find the longest match for each completion entry
503-
final completionWithBestMatch = data.completions.map((c) => (
504-
completion: c,
505-
match: maxBy(c.match.where(word.startsWith), (m) => m.length),
506-
));
507-
// Find the best completion entry
508-
final (:completion, :match) = maxBy(completionWithBestMatch, (c) {
509-
final m = c.match;
510-
return m != null ? m.length : -1;
511-
}) ??
512-
(completion: null, match: null);
513-
if (completion == null || match == null) {
514-
return (
515-
trigger: false,
516-
suggestions: [],
517-
);
518-
}
519-
520-
// prefix to be used for completion of options
521-
final prefix = word.substring(match.length);
522-
523-
if (completion.options.contains(prefix)) {
524-
// If prefix is an option, and there is no other options we don't have
525-
// anything to suggest.
526-
if (completion.options.length == 1) {
527-
return (
528-
trigger: false,
529-
suggestions: [],
530-
);
531-
}
532-
// We don't to auto trigger completion unless there is an option that is
533-
// also a prefix and longer than what prefix currently matches.
534-
trigger &= completion.options.any(
535-
(opt) => opt.startsWith(prefix) && opt != prefix,
536-
);
537-
}
538-
539-
// Terminate suggestion with a ' ' suffix, if this is a terminal completion
540-
final suffix = completion.terminal ? ' ' : '';
541-
542-
return (
543-
trigger: trigger,
544-
suggestions: completion.options
545-
.map((option) {
546-
final overlap = _lcs(prefix, option);
547-
var html = option;
548-
if (overlap.isNotEmpty) {
549-
html = html.replaceAll(overlap, '<strong>$overlap</strong>');
550-
}
551-
return (
552-
value: match + option + suffix,
553-
start: start,
554-
end: end,
555-
html: html,
556-
score:
557-
(option.startsWith(word) ? math.pow(overlap.length, 3) : 0) +
558-
math.pow(overlap.length, 2) +
559-
(option.startsWith(overlap) ? overlap.length : 0) +
560-
overlap.length / option.length,
561-
);
562-
})
563-
.sortedBy<num>((s) => s.score)
564-
.reversed
565-
.toList(),
566-
);
567-
}
568-
}
569-
570-
/// The longest common substring
571-
String _lcs(String S, String T) {
572-
final r = S.length;
573-
final n = T.length;
574-
var Lp = List.filled(n, 0); // ignore: non_constant_identifier_names
575-
var Li = List.filled(n, 0); // ignore: non_constant_identifier_names
576-
var z = 0;
577-
var [start, end] = [0, 0];
578-
for (var i = 0; i < r; i++) {
579-
for (var j = 0; j < n; j++) {
580-
if (S[i] == T[j]) {
581-
if (i == 0 || j == 0) {
582-
Li[j] = 1;
583-
} else {
584-
Li[j] = Lp[j - 1] + 1;
585-
}
586-
if (Li[j] > z) {
587-
z = Li[j];
588-
[start, end] = [i - z + 1, i + 1];
589-
}
590-
}
591-
}
592-
[Lp, Li] = [Li, Lp..fillRange(0, Lp.length, 0)];
593-
}
594-
return S.substring(start, end);
595459
}

0 commit comments

Comments
 (0)