reverse search bangs

This commit is contained in:
Fabian Freund
2026-05-27 10:06:24 +02:00
parent 7275ac9400
commit 5a7b0aa6cb
6 changed files with 800 additions and 1 deletions
@@ -128,6 +128,17 @@ class BangDao extends DatabaseAccessor<BangDatabase> with $BangDaoMixin {
);
}
Selectable<BangData> getBangDataByTemplateHost(String host) {
final httpsPrefix = 'https://$host/%';
final httpPrefix = 'http://$host/%';
return select(db.bangDataView)
..where(
(t) =>
t.urlTemplate.like(httpsPrefix) | t.urlTemplate.like(httpPrefix),
);
}
Selectable<BangData> queryBangs(String searchString) {
final ftsQuery = db.buildFtsQuery(searchString);
@@ -0,0 +1,434 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/bangs/data/providers.dart';
import 'package:weblibre/utils/lru_cache.dart';
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
part 'reverse_match.g.dart';
const String _placeholder = '{{{s}}}';
/// A URL-safe sentinel inserted in place of the bang's `{{{s}}}` placeholder
/// during template parsing. Picked so it survives URI parsing without
/// re-encoding and is extremely unlikely to appear in real templates.
const String _sentinel = 'wlbangrevxq7p2zsentinel';
/// Where the placeholder sits inside a template.
enum _Slot { mainQuery, mainPath, fragmentQuery, fragmentPath }
/// Path + query pieces of a URL (or fragment) reduced to the parts we care
/// about for reverse matching.
class _Components {
final List<String> pathSegments;
final Map<String, String> queryParams;
const _Components({required this.pathSegments, required this.queryParams});
}
/// Parsed form of a bang URL template suitable for reverse matching.
///
/// The placeholder can sit in one of four slots: main URL query/path or
/// fragment query/path. Everything else in the template becomes a required
/// constant during matching. Hash-router and #key=value fragments are both
/// supported via [_componentsFromFragment].
class BangUrlPattern {
final String host;
// Main-URL constraints (always enforced).
final List<String> mainPathSegments;
final Map<String, String> mainQueryParams;
// Fragment constraints (only when the template uses a fragment).
final List<String>? fragmentPathSegments;
final Map<String, String>? fragmentQueryParams;
// Placeholder location + capture descriptors.
final _Slot _slot;
final String prefix;
final String suffix;
final String? paramName; // for *Query slots
final int? pathIndex; // for *Path slots
const BangUrlPattern._({
required this.host,
required this.mainPathSegments,
required this.mainQueryParams,
required this.fragmentPathSegments,
required this.fragmentQueryParams,
required _Slot slot,
required this.prefix,
required this.suffix,
this.paramName,
this.pathIndex,
}) : _slot = slot;
/// Specificity score used to break ties between bangs sharing a host:
/// more constants matched = more specific template.
int get constraintCount =>
mainPathSegments.length +
mainQueryParams.length +
(fragmentPathSegments?.length ?? 0) +
(fragmentQueryParams?.length ?? 0);
/// Extracts the user query from [input] if it matches this pattern. Returns
/// null otherwise.
String? match(Uri input) {
if (input.host.toLowerCase() != host) return null;
final mainInput = _componentsFromUri(input);
if (mainInput == null) return null;
if (!_pathSegmentsMatchAround(
mainPathSegments,
mainInput.pathSegments,
_slot == _Slot.mainPath ? pathIndex : null,
)) {
return null;
}
if (!_requiredQueryParamsMatch(mainQueryParams, mainInput.queryParams)) {
return null;
}
_Components? fragmentInput;
if (fragmentPathSegments != null || fragmentQueryParams != null) {
fragmentInput = _componentsFromFragment(input.fragment);
if (fragmentInput == null) return null;
if (!_pathSegmentsMatchAround(
fragmentPathSegments ?? const [],
fragmentInput.pathSegments,
_slot == _Slot.fragmentPath ? pathIndex : null,
)) {
return null;
}
if (!_requiredQueryParamsMatch(
fragmentQueryParams ?? const {},
fragmentInput.queryParams,
)) {
return null;
}
}
switch (_slot) {
case _Slot.mainQuery:
return _extractFromQuery(mainInput.queryParams);
case _Slot.mainPath:
return _extractFromPath(mainInput.pathSegments);
case _Slot.fragmentQuery:
return _extractFromQuery(fragmentInput!.queryParams);
case _Slot.fragmentPath:
return _extractFromPath(fragmentInput!.pathSegments);
}
}
String? _extractFromQuery(Map<String, String> params) {
final value = params[paramName];
if (value == null) return null;
if (!value.startsWith(prefix)) return null;
if (!value.endsWith(suffix)) return null;
final captured =
value.substring(prefix.length, value.length - suffix.length);
return captured.isEmpty ? null : captured;
}
String? _extractFromPath(List<String> segments) {
if (pathIndex == null || pathIndex! >= segments.length) return null;
final segment = segments[pathIndex!];
if (!segment.startsWith(prefix)) return null;
if (!segment.endsWith(suffix)) return null;
final captured =
segment.substring(prefix.length, segment.length - suffix.length);
return captured.isEmpty ? null : captured;
}
static BangUrlPattern? parse(String urlTemplate) {
final placeholderCount = _placeholder.allMatches(urlTemplate).length;
if (placeholderCount != 1) {
// Multi-placeholder templates are out of scope.
return null;
}
final substituted = urlTemplate.replaceAll(_placeholder, _sentinel);
final Uri parsed;
try {
parsed = Uri.parse(substituted);
} on FormatException {
return null;
}
if (!parsed.hasScheme || parsed.host.isEmpty) return null;
if (parsed.host.contains(_sentinel)) return null;
final host = parsed.host.toLowerCase();
final fragmentSlot = parsed.fragment.contains(_sentinel);
final templateMain = _componentsFromUri(parsed);
if (templateMain == null) return null;
if (fragmentSlot) {
final fragmentComponents = _componentsFromFragment(parsed.fragment);
if (fragmentComponents == null) return null;
return _withPlaceholderInScope(
host: host,
mainPathSegments: templateMain.pathSegments,
mainQueryParams: templateMain.queryParams,
scopeComponents: fragmentComponents,
queryslot: _Slot.fragmentQuery,
pathSlot: _Slot.fragmentPath,
isFragment: true,
);
}
// Placeholder lives in the main URL.
return _withPlaceholderInScope(
host: host,
mainPathSegments: templateMain.pathSegments,
mainQueryParams: templateMain.queryParams,
scopeComponents: templateMain,
queryslot: _Slot.mainQuery,
pathSlot: _Slot.mainPath,
isFragment: false,
);
}
/// Locates the sentinel inside [scopeComponents] (which may be the main URL
/// components or the fragment's components) and assembles a [BangUrlPattern]
/// with all required constants extracted.
static BangUrlPattern? _withPlaceholderInScope({
required String host,
required List<String> mainPathSegments,
required Map<String, String> mainQueryParams,
required _Components scopeComponents,
required _Slot queryslot,
required _Slot pathSlot,
required bool isFragment,
}) {
// Try query slot first.
String? sentinelParam;
for (final entry in scopeComponents.queryParams.entries) {
if (entry.value.contains(_sentinel)) {
if (sentinelParam != null) return null; // ambiguous
sentinelParam = entry.key;
}
}
if (sentinelParam != null) {
final placeholderValue = scopeComponents.queryParams[sentinelParam]!;
final sentinelStart = placeholderValue.indexOf(_sentinel);
final prefix = placeholderValue.substring(0, sentinelStart);
final suffix =
placeholderValue.substring(sentinelStart + _sentinel.length);
// The other params in the same scope become required constants.
final required = <String, String>{
for (final e in scopeComponents.queryParams.entries)
if (e.key != sentinelParam) e.key: e.value,
};
// We already required exactly one sentinel occurrence in [parse], so
// if it lives in this scope's queries no path segment can also carry it.
// The guard remains as defensive insurance.
if (scopeComponents.pathSegments.any((s) => s.contains(_sentinel))) {
return null;
}
return BangUrlPattern._(
host: host,
mainPathSegments: mainPathSegments,
mainQueryParams: isFragment ? mainQueryParams : required,
fragmentPathSegments: isFragment ? scopeComponents.pathSegments : null,
fragmentQueryParams: isFragment ? required : null,
slot: queryslot,
prefix: prefix,
suffix: suffix,
paramName: sentinelParam,
);
}
// Otherwise the placeholder must be inside a single path segment.
final segments = scopeComponents.pathSegments;
final pathIndex = segments.indexWhere((s) => s.contains(_sentinel));
if (pathIndex < 0) return null;
final segment = segments[pathIndex];
final sentinelStart = segment.indexOf(_sentinel);
final prefix = segment.substring(0, sentinelStart);
final suffix = segment.substring(sentinelStart + _sentinel.length);
final pathSegments = List<String>.from(segments);
final required = scopeComponents.queryParams;
if (required.values.any((v) => v.contains(_sentinel))) return null;
return BangUrlPattern._(
host: host,
mainPathSegments: isFragment ? mainPathSegments : pathSegments,
mainQueryParams: isFragment ? mainQueryParams : required,
fragmentPathSegments: isFragment ? pathSegments : null,
fragmentQueryParams: isFragment ? required : null,
slot: pathSlot,
prefix: prefix,
suffix: suffix,
pathIndex: pathIndex,
);
}
}
/// Compares path segments. When [placeholderIndex] is non-null, the segment
/// at that index is the placeholder and is skipped here (the caller verifies
/// it against the stored prefix/suffix).
bool _pathSegmentsMatchAround(
List<String> required,
List<String> actual,
int? placeholderIndex,
) {
if (actual.length != required.length) return false;
for (var i = 0; i < required.length; i++) {
if (i == placeholderIndex) continue;
if (actual[i] != required[i]) return false;
}
return true;
}
bool _requiredQueryParamsMatch(
Map<String, String> required,
Map<String, String> actual,
) {
if (required.isEmpty) return true;
for (final entry in required.entries) {
if (actual[entry.key] != entry.value) return false;
}
return true;
}
/// Reduces a [Uri] to the constraint set we care about. Bails on
/// duplicate-keyed query params (multi-map semantics aren't worth the
/// complexity for our use case).
_Components? _componentsFromUri(Uri uri) {
final segments =
uri.pathSegments.where((s) => s.isNotEmpty).toList(growable: false);
final all = uri.queryParametersAll;
final params = <String, String>{};
for (final entry in all.entries) {
if (entry.value.length != 1) return null;
params[entry.key] = entry.value.first;
}
return _Components(pathSegments: segments, queryParams: params);
}
/// Parses a fragment string into pseudo-URI components.
///
/// Two common shapes are handled:
/// - hash-router style: starts with `/` (e.g. `/s/search/foo`)
/// - hash-query style: contains `=` without leading `/` (e.g. `s=foo&t=bar`)
/// Plain fragments without `/` or `=` are treated as a single path segment.
_Components? _componentsFromFragment(String fragment) {
if (fragment.isEmpty) return null;
final String synthetic;
if (fragment.startsWith('/')) {
synthetic = 'http://x$fragment';
} else if (fragment.contains('=')) {
synthetic = 'http://x/?$fragment';
} else {
synthetic = 'http://x/$fragment';
}
try {
return _componentsFromUri(Uri.parse(synthetic));
} on FormatException {
return null;
}
}
/// Cached parsed patterns. `null` entries are cached too, so unsupported
/// templates aren't re-parsed.
final LRUCache<String, BangUrlPattern?> _patternCache = LRUCache(256);
BangUrlPattern? _patternFor(String urlTemplate) {
if (_patternCache.contains(urlTemplate)) {
return _patternCache.get(urlTemplate);
}
final parsed = BangUrlPattern.parse(urlTemplate);
_patternCache.set(urlTemplate, parsed);
return parsed;
}
/// Reverse-match result: the bang whose template best matches [url] together
/// with the extracted user query.
class ReverseBangMatch {
final BangData bang;
final String query;
const ReverseBangMatch({required this.bang, required this.query});
}
@Riverpod(keepAlive: true)
class ReverseBangMatcher extends _$ReverseBangMatcher {
@override
void build() {}
/// Looks up a bang whose template produces [url] and extracts the query.
/// Returns null when no candidate matches.
Future<ReverseBangMatch?> match(Uri url) async {
final host = url.host;
if (host.isEmpty) return null;
final scheme = url.scheme.toLowerCase();
if (scheme != 'http' && scheme != 'https') return null;
final candidates = await ref
.read(bangDatabaseProvider)
.bangDao
.getBangDataByTemplateHost(host)
.get();
if (candidates.isEmpty) return null;
ReverseBangMatch? best;
int bestScore = -1;
int bestFrequency = -1;
for (final bang in candidates) {
final pattern = _patternFor(bang.urlTemplate);
if (pattern == null) continue;
final captured = pattern.match(url);
if (captured == null) continue;
// Don't auto-select when the captured text is itself a URL — usually
// means we matched a redirector/shortener and the user wouldn't expect
// the address bar to "edit" that.
if (uri_parser.tryParseUrl(captured)?.hasScheme == true) continue;
final score = pattern.constraintCount;
final frequency = bang.frequency;
if (score > bestScore ||
(score == bestScore && frequency > bestFrequency)) {
best = ReverseBangMatch(bang: bang, query: captured);
bestScore = score;
bestFrequency = frequency;
}
}
return best;
}
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'reverse_match.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ReverseBangMatcher)
final reverseBangMatcherProvider = ReverseBangMatcherProvider._();
final class ReverseBangMatcherProvider
extends $NotifierProvider<ReverseBangMatcher, void> {
ReverseBangMatcherProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'reverseBangMatcherProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$reverseBangMatcherHash();
@$internal
@override
ReverseBangMatcher create() => ReverseBangMatcher();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$reverseBangMatcherHash() =>
r'52ce24b94a780612baa085ab07b7b3d88306b934';
abstract class _$ReverseBangMatcher extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -29,6 +29,7 @@ import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/bangs/data/models/web_search_bang.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/domain/providers/search.dart';
import 'package:weblibre/features/bangs/domain/services/reverse_match.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
@@ -188,6 +189,12 @@ class SearchScreen extends HookConsumerWidget {
final isUrlInput = useState(false);
final isEditingAfterSearch = useState(false);
// Holds the original URL when reverse bang-matching has swapped the
// address-bar text for an extracted query. First tap of the clear button
// restores this URL; a subsequent tap clears the field normally.
final revertUrl = useState<String?>(null);
final reverseMatchedQuery = useState<String?>(null);
useOnListenableChangeSelector(
searchTextController,
() => searchTextController.text,
@@ -257,6 +264,49 @@ class SearchScreen extends HookConsumerWidget {
return null;
}, []);
// Try to recognise the current URL as a bang search. If a bang matches,
// swap the URL for its extracted query and pre-select the bang so the
// user can refine the search instead of editing the raw URL.
useEffect(() {
if (!startedWithUrl || initialSearchText == null) return null;
final uri = Uri.tryParse(initialSearchText!);
if (uri == null) return null;
unawaited(() async {
final match = await ref
.read(reverseBangMatcherProvider.notifier)
.match(uri);
if (!context.mounted) return;
if (match == null) return;
// Bail out if the user has already started editing while we matched.
if (searchTextController.text != initialSearchText) return;
revertUrl.value = initialSearchText;
reverseMatchedQuery.value = match.query;
searchTextController.value = TextEditingValue(
text: match.query,
selection: TextSelection(
baseOffset: 0,
extentOffset: match.query.length,
),
);
// Mutual exclusion: clear any site-scoped selection so the global
// auto-match isn't hidden behind a stale site bang (mirrors the
// SmartBangSelector selection logic).
final tabHost = existingTabState?.url.host;
if (tabHost != null && tabHost.isNotEmpty) {
ref
.read(selectedBangTriggerProvider(domain: tabHost).notifier)
.clearTrigger();
}
ref
.read(selectedBangTriggerProvider().notifier)
.setTrigger(match.bang.toKey());
}());
return null;
}, []);
//Request initial focus in a way our useOnListenableChangeSelector is triggered
useEffect(() {
if (ref.read(searchAutofocusSuppressionProvider)) {
@@ -671,6 +721,32 @@ class SearchScreen extends HookConsumerWidget {
maxLines: isEditMode ? 3 : 1,
label: const Text('Search or enter URL'),
unfocusOnTapOutside: false,
onClearPressed: () {
final url = revertUrl.value;
if (url != null &&
searchTextController.text ==
reverseMatchedQuery.value) {
// First press after a reverse-match swap: restore the
// original URL and drop the auto-selected bang. The
// user can press again to actually clear.
searchTextController.value = TextEditingValue(
text: url,
selection: TextSelection(
baseOffset: 0,
extentOffset: url.length,
),
);
revertUrl.value = null;
reverseMatchedQuery.value = null;
ref
.read(selectedBangTriggerProvider().notifier)
.clearTrigger();
} else {
revertUrl.value = null;
reverseMatchedQuery.value = null;
searchTextController.clear();
}
},
onSubmitted: (value) async {
if (value.isEmpty) return;
@@ -49,6 +49,11 @@ class SearchField extends HookConsumerWidget {
final BangData? activeBang;
final bool showBangIcon;
/// Overrides the clear (`x`) button behaviour. When null, the button just
/// clears the text. When provided, the callback decides what to do (e.g.
/// restore a previous value first, then clear on the next press).
final VoidCallback? onClearPressed;
const SearchField({
super.key,
required this.textEditingController,
@@ -64,6 +69,7 @@ class SearchField extends HookConsumerWidget {
this.autofocus = false,
this.textFieldKey,
this.hint,
this.onClearPressed,
});
@override
@@ -160,7 +166,11 @@ class SearchField extends HookConsumerWidget {
padding: const EdgeInsetsDirectional.only(end: 8.0),
child: IconButton(
onPressed: () {
textEditingController.clear();
if (onClearPressed != null) {
onClearPressed!();
} else {
textEditingController.clear();
}
},
icon: const Icon(Icons.clear),
),
@@ -0,0 +1,205 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/bangs/domain/services/reverse_match.dart';
void main() {
group('BangUrlPattern - main URL placeholder', () {
test('query param: captures simple search query', () {
final p = BangUrlPattern.parse('https://www.google.com/search?q={{{s}}}');
expect(p, isNotNull);
expect(
p!.match(Uri.parse('https://www.google.com/search?q=hello+world')),
'hello world',
);
});
test('query param: tolerates extra tracking params', () {
final p = BangUrlPattern.parse('https://www.google.com/search?q={{{s}}}');
expect(
p!.match(
Uri.parse(
'https://www.google.com/search?q=cats&hl=de&utm_source=foo',
),
),
'cats',
);
});
test('query param: enforces other required constants', () {
final p = BangUrlPattern.parse(
'https://www.google.com/search?q={{{s}}}&tbm=isch',
);
expect(
p!.match(Uri.parse('https://www.google.com/search?q=cats&tbm=isch')),
'cats',
);
expect(
p.match(Uri.parse('https://www.google.com/search?q=cats')),
isNull,
);
});
test('path segment: captures search term', () {
final p = BangUrlPattern.parse(
'https://en.wikipedia.org/wiki/{{{s}}}',
);
expect(
p!.match(Uri.parse('https://en.wikipedia.org/wiki/Flutter')),
'Flutter',
);
});
test('path segment: enforces non-placeholder segments', () {
final p = BangUrlPattern.parse(
'https://www.ebay.com/sch/{{{s}}}/m.html',
);
expect(
p!.match(Uri.parse('https://www.ebay.com/sch/toys/m.html')),
'toys',
);
expect(
p.match(Uri.parse('https://www.ebay.com/sch/toys/wrong.html')),
isNull,
);
});
test('path segment: supports prefix/suffix in same segment', () {
final p = BangUrlPattern.parse(
'https://site.com/find/prefix-{{{s}}}-end',
);
expect(
p!.match(Uri.parse('https://site.com/find/prefix-Foo-end')),
'Foo',
);
expect(
p.match(Uri.parse('https://site.com/find/Foo-end')),
isNull,
);
});
test('host mismatch rejects', () {
final p = BangUrlPattern.parse('https://google.com/search?q={{{s}}}');
expect(
p!.match(Uri.parse('https://bing.com/search?q=foo')),
isNull,
);
});
test('path length mismatch rejects', () {
final p = BangUrlPattern.parse('https://x.com/a?q={{{s}}}');
expect(
p!.match(Uri.parse('https://x.com/a/b?q=foo')),
isNull,
);
});
test('empty captured query is rejected', () {
final p = BangUrlPattern.parse('https://google.com/?q={{{s}}}');
expect(p!.match(Uri.parse('https://google.com/?q=')), isNull);
});
});
group('BangUrlPattern - fragment placeholder', () {
test('hash query: 4chan-style #s={{{s}}}', () {
final p = BangUrlPattern.parse(
'https://boards.4chan.org/g/catalog#s={{{s}}}',
);
expect(p, isNotNull);
expect(
p!.match(Uri.parse('https://boards.4chan.org/g/catalog#s=keyword')),
'keyword',
);
});
test('hash path: SPA router /#/foo/{{{s}}}', () {
final p = BangUrlPattern.parse(
'https://research.lensai.eu/#/s/search/{{{s}}}',
);
expect(p, isNotNull);
expect(
p!.match(
Uri.parse('https://research.lensai.eu/#/s/search/quantum'),
),
'quantum',
);
});
test('hash path: rejects when fragment path constants do not match', () {
final p = BangUrlPattern.parse(
'https://research.lensai.eu/#/s/search/{{{s}}}',
);
expect(
p!.match(
Uri.parse('https://research.lensai.eu/#/s/answer/quantum'),
),
isNull,
);
});
test('hash query: tolerates extra fragment params', () {
final p = BangUrlPattern.parse(
'https://site.example/#s={{{s}}}',
);
expect(
p!.match(Uri.parse('https://site.example/#s=foo&page=2')),
'foo',
);
});
test('fragment-required template rejects URLs without fragment', () {
final p = BangUrlPattern.parse(
'https://boards.4chan.org/g/catalog#s={{{s}}}',
);
expect(
p!.match(Uri.parse('https://boards.4chan.org/g/catalog')),
isNull,
);
});
});
group('BangUrlPattern - unsupported templates', () {
test('multi-placeholder returns null', () {
expect(
BangUrlPattern.parse('https://x.com/?a={{{s}}}&b={{{s}}}'),
isNull,
);
});
test('placeholder in host returns null', () {
expect(BangUrlPattern.parse('https://{{{s}}}.example.com/'), isNull);
});
test('scheme-less template returns null', () {
expect(BangUrlPattern.parse('//example.com/?q={{{s}}}'), isNull);
});
});
group('BangUrlPattern - tie-break helpers', () {
test('more required constants score higher', () {
final plain = BangUrlPattern.parse('https://x.com/?q={{{s}}}')!;
final imgs = BangUrlPattern.parse(
'https://x.com/?q={{{s}}}&tbm=isch',
)!;
expect(imgs.constraintCount, greaterThan(plain.constraintCount));
});
});
}