Skip to content

Commit

Permalink
Refactor completion data: data classes. (#8122)
Browse files Browse the repository at this point in the history
  • Loading branch information
isoos authored Oct 8, 2024
1 parent d4c325e commit 7eb7684
Show file tree
Hide file tree
Showing 5 changed files with 142 additions and 116 deletions.
89 changes: 46 additions & 43 deletions app/lib/frontend/templates/views/shared/search_banner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import 'dart:convert';

import 'package:_pub_shared/data/completion.dart';
import 'package:pub_dev/frontend/request_context.dart';

import '../../../dom/dom.dart' as d;
Expand Down Expand Up @@ -93,15 +94,15 @@ d.Node searchBannerNode({
String completionDataJson({
List<String> topics = const [],
List<String> licenses = const [],
}) =>
json.encode({
// TODO: Write a shared type for this in `pkg/_pub_shared/lib/data/`
'completions': [
{
'match': ['', '-'],
'terminal': false,
'forcedOnly': true,
'options': [
}) {
return json.encode(
CompletionData(
completions: [
CompletionRule(
match: {'', '-'},
terminal: false,
forcedOnly: true,
options: [
'has:',
'is:',
'license:',
Expand All @@ -114,11 +115,11 @@ String completionDataJson({
'dependency*:',
'publisher:',
],
},
),
// TODO: Consider completion support for dependency:, dependency*: and publisher:
{
'match': ['is:', '-is:'],
'options': [
CompletionRule(
match: {'is:', '-is:'},
options: [
'dart3-compatible',
'flutter-favorite',
'legacy',
Expand All @@ -127,58 +128,60 @@ String completionDataJson({
'unlisted',
'wasm-ready',
],
},
{
'match': ['has:', '-has:'],
'options': [
),
CompletionRule(
match: {'has:', '-has:'},
options: [
'executable',
'screenshot',
],
},
{
'match': ['license:', '-license:'],
'options': [
),
CompletionRule(
match: {'license:', '-license:'},
options: [
'osi-approved',
...licenses,
],
},
{
'match': ['show:', '-show:'],
'options': [
),
CompletionRule(
match: {'show:', '-show:'},
options: [
'unlisted',
],
},
{
'match': ['sdk:', '-sdk:'],
'options': [
),
CompletionRule(
match: {'sdk:', '-sdk:'},
options: [
'dart',
'flutter',
],
},
{
'match': ['platform:', '-platform:'],
'options': [
),
CompletionRule(
match: {'platform:', '-platform:'},
options: [
'android',
'ios',
'linux',
'macos',
'web',
'windows',
],
},
{
'match': ['runtime:', '-runtime:'],
'options': [
),
CompletionRule(
match: {'runtime:', '-runtime:'},
options: [
'native-aot',
'native-jit',
'web',
],
},
{
'match': ['topic:', '-topic:'],
'options': [
),
CompletionRule(
match: {'topic:', '-topic:'},
options: [
...topics,
],
},
),
],
});
).toJson(),
);
}
1 change: 1 addition & 0 deletions pkg/_pub_shared/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ targets:
- 'lib/data/account_api.dart'
- 'lib/data/admin_api.dart'
- 'lib/data/advisories_api.dart'
- 'lib/data/completion.dart'
- 'lib/data/package_api.dart'
- 'lib/data/page_data.dart'
- 'lib/data/publisher_api.dart'
Expand Down
47 changes: 47 additions & 0 deletions pkg/_pub_shared/lib/data/completion.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) 2024, 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:json_annotation/json_annotation.dart';

part 'completion.g.dart';

@JsonSerializable()
class CompletionData {
final List<CompletionRule> completions;

CompletionData({
required this.completions,
});

factory CompletionData.fromJson(Map<String, dynamic> json) =>
_$CompletionDataFromJson(json);
Map<String, dynamic> toJson() => _$CompletionDataToJson(this);
}

/// The match trigger automatic completion (except empty match).
/// Example: `platform:` or `platform:win`
/// Match and an option must be combined to form a keyword.
/// Example: `platform:windows`
@JsonSerializable()
class CompletionRule {
final Set<String> match;
final List<String> options;

/// Add whitespace when completing.
final bool terminal;

/// Only display this when forced to match.
final bool forcedOnly;

CompletionRule({
this.match = const <String>{},
this.options = const <String>[],
this.terminal = true,
this.forcedOnly = false,
});

factory CompletionRule.fromJson(Map<String, dynamic> json) =>
_$CompletionRuleFromJson(json);
Map<String, dynamic> toJson() => _$CompletionRuleToJson(this);
}
40 changes: 40 additions & 0 deletions pkg/_pub_shared/lib/data/completion.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

81 changes: 8 additions & 73 deletions pkg/web_app/lib/src/widget/completion/widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'dart:convert';
import 'dart:js_interop';
import 'dart:math' as math;

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

final _CompletionData data;
final CompletionData data;
try {
data = await _CompletionWidget._completionDataFromUri(srcUri);
} on Exception catch (e) {
Expand Down Expand Up @@ -91,13 +92,6 @@ void create(HTMLElement element, Map<String, String> options) {
});
}

typedef _CompletionData = List<
({
Set<String> match,
List<String> options,
bool terminal,
bool forcedOnly,
})>;
typedef _Suggestions = List<
({
String value,
Expand Down Expand Up @@ -178,7 +172,7 @@ final class _CompletionWidget {

final HTMLInputElement input;
final HTMLDivElement dropdown;
final _CompletionData data;
final CompletionData data;
var state = _State();

_CompletionWidget._({
Expand Down Expand Up @@ -451,73 +445,14 @@ final class _CompletionWidget {
/// Ideally, an end-point serving this kind of completion data should have
/// `Cache-Control` headers that allow caching for a decent period of time.
/// Compression with `gzip` (or similar) would probably also be wise.
static Future<_CompletionData> _completionDataFromUri(Uri src) async {
static Future<CompletionData> _completionDataFromUri(Uri src) async {
await http.loadLibrary();
final root = jsonDecode(
await http.read(src, headers: {
'Accept': 'application/json',
}).timeout(Duration(seconds: 30)),
);
return _completionDataFromJson(root);
}

/// Load completion data from [json].
///
/// Completion data must be JSON on the form:
/// ```js
/// {
/// "completions": [
/// {
/// // The match trigger automatic completion (except empty match).
/// // Example: `platform:` or `platform:win`
/// // Match and an option must be combined to form a keyword.
/// // Example: `platform:windows`
/// "match": ["platform:", "-platform:"],
/// "forcedOnly": false, // Only display this when forced to match
/// "terminal": true, // Add whitespace when completing
/// "options": [
/// "linux",
/// "windows",
/// "android",
/// "ios",
/// ...
/// ],
/// },
/// ...
/// ],
/// }
/// ```
static _CompletionData _completionDataFromJson(Object? json) {
if (json is! Map) throw FormatException('root must be a object');
final completions = json['completions'];
if (completions is! List) {
throw FormatException('completions must be a list');
}
return completions.map((e) {
if (e is! Map) throw FormatException('completion entries must be object');
final terminal = e['terminal'] ?? true;
if (terminal is! bool) throw FormatException('termianl must be bool');
final forcedOnly = e['forcedOnly'] ?? false;
if (forcedOnly is! bool) throw FormatException('forcedOnly must be bool');
final match = e['match'];
if (match is! List) throw FormatException('match must be a list');
final options = e['options'];
if (options is! List) throw FormatException('options must be a list');
return (
match: match
.map((m) => m is String
? m
: throw FormatException('match must be strings'))
.toSet(),
forcedOnly: forcedOnly,
terminal: terminal,
options: options
.map((option) => option is String
? option
: throw FormatException('options must be strings'))
.toList(),
);
}).toList();
return CompletionData.fromJson(root as Map<String, dynamic>);
}

static late final _canvas = HTMLCanvasElement();
Expand All @@ -535,7 +470,7 @@ final class _CompletionWidget {
/// Given [data] and [caret] position inside [text] what suggestions do we
/// want to offer and should completion be automatically triggered?
static ({bool trigger, _Suggestions suggestions}) suggest(
_CompletionData data,
CompletionData data,
String text,
int caret,
) {
Expand All @@ -556,7 +491,7 @@ final class _CompletionWidget {
} else {
// If the part before the caret is matched, then we can auto trigger
final wordBeforeCaret = text.substring(start, caret);
trigger = data.any(
trigger = data.completions.any(
(c) => !c.forcedOnly && c.match.any(wordBeforeCaret.startsWith),
);
}
Expand All @@ -565,7 +500,7 @@ final class _CompletionWidget {
final word = text.substring(start, end);

// Find the longest match for each completion entry
final completionWithBestMatch = data.map((c) => (
final completionWithBestMatch = data.completions.map((c) => (
completion: c,
match: maxBy(c.match.where(word.startsWith), (m) => m.length),
));
Expand Down

0 comments on commit 7eb7684

Please sign in to comment.