diff --git a/apps/weblibre/lib/features/bangs/data/database/daos/bang.dart b/apps/weblibre/lib/features/bangs/data/database/daos/bang.dart index d6eef1ac..418a12d5 100644 --- a/apps/weblibre/lib/features/bangs/data/database/daos/bang.dart +++ b/apps/weblibre/lib/features/bangs/data/database/daos/bang.dart @@ -128,6 +128,17 @@ class BangDao extends DatabaseAccessor with $BangDaoMixin { ); } + Selectable 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 queryBangs(String searchString) { final ftsQuery = db.buildFtsQuery(searchString); diff --git a/apps/weblibre/lib/features/bangs/domain/services/reverse_match.dart b/apps/weblibre/lib/features/bangs/domain/services/reverse_match.dart new file mode 100644 index 00000000..4756db0f --- /dev/null +++ b/apps/weblibre/lib/features/bangs/domain/services/reverse_match.dart @@ -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 . + */ +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 pathSegments; + final Map 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 mainPathSegments; + final Map mainQueryParams; + + // Fragment constraints (only when the template uses a fragment). + final List? fragmentPathSegments; + final Map? 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 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 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 mainPathSegments, + required Map 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 = { + 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.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 required, + List 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 required, + Map 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 = {}; + 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 _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 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; + } +} diff --git a/apps/weblibre/lib/features/bangs/domain/services/reverse_match.g.dart b/apps/weblibre/lib/features/bangs/domain/services/reverse_match.g.dart new file mode 100644 index 00000000..7ca15d78 --- /dev/null +++ b/apps/weblibre/lib/features/bangs/domain/services/reverse_match.g.dart @@ -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 { + 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(value), + ); + } +} + +String _$reverseBangMatcherHash() => + r'52ce24b94a780612baa085ab07b7b3d88306b934'; + +abstract class _$ReverseBangMatcher extends $Notifier { + void build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + void, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/screens/search.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/screens/search.dart index 974cac62..c743735d 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/screens/search.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/screens/search.dart @@ -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(null); + final reverseMatchedQuery = useState(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; diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_field.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_field.dart index f8afd37d..d176fa42 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_field.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/search_field.dart @@ -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), ), diff --git a/apps/weblibre/test/features/bangs/reverse_match_test.dart b/apps/weblibre/test/features/bangs/reverse_match_test.dart new file mode 100644 index 00000000..34994e6a --- /dev/null +++ b/apps/weblibre/test/features/bangs/reverse_match_test.dart @@ -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 . + */ + +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)); + }); + }); +}