Skip to content

Commit 7eb7684

Browse files
authored
Refactor completion data: data classes. (#8122)
1 parent d4c325e commit 7eb7684

File tree

5 files changed

+142
-116
lines changed

5 files changed

+142
-116
lines changed

app/lib/frontend/templates/views/shared/search_banner.dart

Lines changed: 46 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import 'dart:convert';
66

7+
import 'package:_pub_shared/data/completion.dart';
78
import 'package:pub_dev/frontend/request_context.dart';
89

910
import '../../../dom/dom.dart' as d;
@@ -93,15 +94,15 @@ d.Node searchBannerNode({
9394
String completionDataJson({
9495
List<String> topics = const [],
9596
List<String> licenses = const [],
96-
}) =>
97-
json.encode({
98-
// TODO: Write a shared type for this in `pkg/_pub_shared/lib/data/`
99-
'completions': [
100-
{
101-
'match': ['', '-'],
102-
'terminal': false,
103-
'forcedOnly': true,
104-
'options': [
97+
}) {
98+
return json.encode(
99+
CompletionData(
100+
completions: [
101+
CompletionRule(
102+
match: {'', '-'},
103+
terminal: false,
104+
forcedOnly: true,
105+
options: [
105106
'has:',
106107
'is:',
107108
'license:',
@@ -114,11 +115,11 @@ String completionDataJson({
114115
'dependency*:',
115116
'publisher:',
116117
],
117-
},
118+
),
118119
// TODO: Consider completion support for dependency:, dependency*: and publisher:
119-
{
120-
'match': ['is:', '-is:'],
121-
'options': [
120+
CompletionRule(
121+
match: {'is:', '-is:'},
122+
options: [
122123
'dart3-compatible',
123124
'flutter-favorite',
124125
'legacy',
@@ -127,58 +128,60 @@ String completionDataJson({
127128
'unlisted',
128129
'wasm-ready',
129130
],
130-
},
131-
{
132-
'match': ['has:', '-has:'],
133-
'options': [
131+
),
132+
CompletionRule(
133+
match: {'has:', '-has:'},
134+
options: [
134135
'executable',
135136
'screenshot',
136137
],
137-
},
138-
{
139-
'match': ['license:', '-license:'],
140-
'options': [
138+
),
139+
CompletionRule(
140+
match: {'license:', '-license:'},
141+
options: [
141142
'osi-approved',
142143
...licenses,
143144
],
144-
},
145-
{
146-
'match': ['show:', '-show:'],
147-
'options': [
145+
),
146+
CompletionRule(
147+
match: {'show:', '-show:'},
148+
options: [
148149
'unlisted',
149150
],
150-
},
151-
{
152-
'match': ['sdk:', '-sdk:'],
153-
'options': [
151+
),
152+
CompletionRule(
153+
match: {'sdk:', '-sdk:'},
154+
options: [
154155
'dart',
155156
'flutter',
156157
],
157-
},
158-
{
159-
'match': ['platform:', '-platform:'],
160-
'options': [
158+
),
159+
CompletionRule(
160+
match: {'platform:', '-platform:'},
161+
options: [
161162
'android',
162163
'ios',
163164
'linux',
164165
'macos',
165166
'web',
166167
'windows',
167168
],
168-
},
169-
{
170-
'match': ['runtime:', '-runtime:'],
171-
'options': [
169+
),
170+
CompletionRule(
171+
match: {'runtime:', '-runtime:'},
172+
options: [
172173
'native-aot',
173174
'native-jit',
174175
'web',
175176
],
176-
},
177-
{
178-
'match': ['topic:', '-topic:'],
179-
'options': [
177+
),
178+
CompletionRule(
179+
match: {'topic:', '-topic:'},
180+
options: [
180181
...topics,
181182
],
182-
},
183+
),
183184
],
184-
});
185+
).toJson(),
186+
);
187+
}

pkg/_pub_shared/build.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ targets:
99
- 'lib/data/account_api.dart'
1010
- 'lib/data/admin_api.dart'
1111
- 'lib/data/advisories_api.dart'
12+
- 'lib/data/completion.dart'
1213
- 'lib/data/package_api.dart'
1314
- 'lib/data/page_data.dart'
1415
- 'lib/data/publisher_api.dart'
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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 'package:json_annotation/json_annotation.dart';
6+
7+
part 'completion.g.dart';
8+
9+
@JsonSerializable()
10+
class CompletionData {
11+
final List<CompletionRule> completions;
12+
13+
CompletionData({
14+
required this.completions,
15+
});
16+
17+
factory CompletionData.fromJson(Map<String, dynamic> json) =>
18+
_$CompletionDataFromJson(json);
19+
Map<String, dynamic> toJson() => _$CompletionDataToJson(this);
20+
}
21+
22+
/// The match trigger automatic completion (except empty match).
23+
/// Example: `platform:` or `platform:win`
24+
/// Match and an option must be combined to form a keyword.
25+
/// Example: `platform:windows`
26+
@JsonSerializable()
27+
class CompletionRule {
28+
final Set<String> match;
29+
final List<String> options;
30+
31+
/// Add whitespace when completing.
32+
final bool terminal;
33+
34+
/// Only display this when forced to match.
35+
final bool forcedOnly;
36+
37+
CompletionRule({
38+
this.match = const <String>{},
39+
this.options = const <String>[],
40+
this.terminal = true,
41+
this.forcedOnly = false,
42+
});
43+
44+
factory CompletionRule.fromJson(Map<String, dynamic> json) =>
45+
_$CompletionRuleFromJson(json);
46+
Map<String, dynamic> toJson() => _$CompletionRuleToJson(this);
47+
}

pkg/_pub_shared/lib/data/completion.g.dart

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

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

Lines changed: 8 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'dart:convert';
77
import 'dart:js_interop';
88
import 'dart:math' as math;
99

10+
import 'package:_pub_shared/data/completion.dart';
1011
import 'package:collection/collection.dart';
1112
import 'package:http/http.dart' deferred as http show read;
1213
import 'package:web/web.dart';
@@ -63,7 +64,7 @@ void create(HTMLElement element, Map<String, String> options) {
6364
await input.onFocus.first;
6465
}
6566

66-
final _CompletionData data;
67+
final CompletionData data;
6768
try {
6869
data = await _CompletionWidget._completionDataFromUri(srcUri);
6970
} on Exception catch (e) {
@@ -91,13 +92,6 @@ void create(HTMLElement element, Map<String, String> options) {
9192
});
9293
}
9394

94-
typedef _CompletionData = List<
95-
({
96-
Set<String> match,
97-
List<String> options,
98-
bool terminal,
99-
bool forcedOnly,
100-
})>;
10195
typedef _Suggestions = List<
10296
({
10397
String value,
@@ -178,7 +172,7 @@ final class _CompletionWidget {
178172

179173
final HTMLInputElement input;
180174
final HTMLDivElement dropdown;
181-
final _CompletionData data;
175+
final CompletionData data;
182176
var state = _State();
183177

184178
_CompletionWidget._({
@@ -451,73 +445,14 @@ final class _CompletionWidget {
451445
/// Ideally, an end-point serving this kind of completion data should have
452446
/// `Cache-Control` headers that allow caching for a decent period of time.
453447
/// Compression with `gzip` (or similar) would probably also be wise.
454-
static Future<_CompletionData> _completionDataFromUri(Uri src) async {
448+
static Future<CompletionData> _completionDataFromUri(Uri src) async {
455449
await http.loadLibrary();
456450
final root = jsonDecode(
457451
await http.read(src, headers: {
458452
'Accept': 'application/json',
459453
}).timeout(Duration(seconds: 30)),
460454
);
461-
return _completionDataFromJson(root);
462-
}
463-
464-
/// Load completion data from [json].
465-
///
466-
/// Completion data must be JSON on the form:
467-
/// ```js
468-
/// {
469-
/// "completions": [
470-
/// {
471-
/// // The match trigger automatic completion (except empty match).
472-
/// // Example: `platform:` or `platform:win`
473-
/// // Match and an option must be combined to form a keyword.
474-
/// // Example: `platform:windows`
475-
/// "match": ["platform:", "-platform:"],
476-
/// "forcedOnly": false, // Only display this when forced to match
477-
/// "terminal": true, // Add whitespace when completing
478-
/// "options": [
479-
/// "linux",
480-
/// "windows",
481-
/// "android",
482-
/// "ios",
483-
/// ...
484-
/// ],
485-
/// },
486-
/// ...
487-
/// ],
488-
/// }
489-
/// ```
490-
static _CompletionData _completionDataFromJson(Object? json) {
491-
if (json is! Map) throw FormatException('root must be a object');
492-
final completions = json['completions'];
493-
if (completions is! List) {
494-
throw FormatException('completions must be a list');
495-
}
496-
return completions.map((e) {
497-
if (e is! Map) throw FormatException('completion entries must be object');
498-
final terminal = e['terminal'] ?? true;
499-
if (terminal is! bool) throw FormatException('termianl must be bool');
500-
final forcedOnly = e['forcedOnly'] ?? false;
501-
if (forcedOnly is! bool) throw FormatException('forcedOnly must be bool');
502-
final match = e['match'];
503-
if (match is! List) throw FormatException('match must be a list');
504-
final options = e['options'];
505-
if (options is! List) throw FormatException('options must be a list');
506-
return (
507-
match: match
508-
.map((m) => m is String
509-
? m
510-
: throw FormatException('match must be strings'))
511-
.toSet(),
512-
forcedOnly: forcedOnly,
513-
terminal: terminal,
514-
options: options
515-
.map((option) => option is String
516-
? option
517-
: throw FormatException('options must be strings'))
518-
.toList(),
519-
);
520-
}).toList();
455+
return CompletionData.fromJson(root as Map<String, dynamic>);
521456
}
522457

523458
static late final _canvas = HTMLCanvasElement();
@@ -535,7 +470,7 @@ final class _CompletionWidget {
535470
/// Given [data] and [caret] position inside [text] what suggestions do we
536471
/// want to offer and should completion be automatically triggered?
537472
static ({bool trigger, _Suggestions suggestions}) suggest(
538-
_CompletionData data,
473+
CompletionData data,
539474
String text,
540475
int caret,
541476
) {
@@ -556,7 +491,7 @@ final class _CompletionWidget {
556491
} else {
557492
// If the part before the caret is matched, then we can auto trigger
558493
final wordBeforeCaret = text.substring(start, caret);
559-
trigger = data.any(
494+
trigger = data.completions.any(
560495
(c) => !c.forcedOnly && c.match.any(wordBeforeCaret.startsWith),
561496
);
562497
}
@@ -565,7 +500,7 @@ final class _CompletionWidget {
565500
final word = text.substring(start, end);
566501

567502
// Find the longest match for each completion entry
568-
final completionWithBestMatch = data.map((c) => (
503+
final completionWithBestMatch = data.completions.map((c) => (
569504
completion: c,
570505
match: maxBy(c.match.where(word.startsWith), (m) => m.length),
571506
));

0 commit comments

Comments
 (0)