From 7ae1a0fde17e65ea9744b4a8435643cd3f22fa9c Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 15 May 2026 17:28:11 +0000 Subject: [PATCH] ADR-0024 ranker hook scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `Ranker` plug-in point ADR-0024 §ranker-layer specified. The grammar tree declares *what's valid*; the ranker decides *what's likely useful first*. Default `identity_ranker` preserves declaration order from the grammar. API: - `pub type Ranker = fn(Vec) -> Vec;` - `pub const fn identity_ranker(c) -> Vec` returns its input unchanged. - `candidates_at_cursor_with(input, cursor, cache, ranker)` applies a custom ranker; the default `candidates_at_cursor` delegates with `identity_ranker`. Three new tests cover identity preservation, custom reordering, and the empty-list-collapses-to-None edge. This is a future-work hook — no production caller passes a non-identity ranker yet. Hooks for frequency-based ranking, content-aware priors, or recency plug in here without touching the grammar declarations. Tests: 809 passing, 0 failing, 1 ignored. Clippy clean. --- src/completion.rs | 89 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/completion.rs b/src/completion.rs index d156ee9..f960ee0 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -73,6 +73,25 @@ pub struct Candidate { pub kind: CandidateKind, } +/// Re-ranker for a freshly-computed candidate list (ADR-0024 +/// §ranker-layer). +/// +/// The grammar tree declares *what's valid*; the ranker decides +/// *what's likely useful first*. Lives outside the trie so +/// frequency-based ranking, content-aware priors (e.g. `Email` +/// → text first), and recency hooks can plug in without +/// touching grammar declarations. +/// +/// Default is `identity_ranker` — declaration order from the +/// grammar tree is preserved. +pub type Ranker = fn(Vec) -> Vec; + +/// Identity ranker: returns its input unchanged. +#[must_use] +pub const fn identity_ranker(candidates: Vec) -> Vec { + candidates +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CandidateKind { /// One of the parser's expected keywords. @@ -120,6 +139,20 @@ pub fn candidates_at_cursor( input: &str, cursor: usize, cache: &SchemaCache, +) -> Option { + candidates_at_cursor_with(input, cursor, cache, identity_ranker) +} + +/// Variant of [`candidates_at_cursor`] that applies a custom +/// `Ranker` to the final candidate list (ADR-0024 §ranker-layer). +/// The default `candidates_at_cursor` calls this with +/// `identity_ranker`. +#[must_use] +pub fn candidates_at_cursor_with( + input: &str, + cursor: usize, + cache: &SchemaCache, + ranker: Ranker, ) -> Option { let cursor = cursor.min(input.len()); @@ -305,6 +338,11 @@ pub fn candidates_at_cursor( return None; } + let candidates = ranker(candidates); + if candidates.is_empty() { + return None; + } + Some(Completion { replaced_range: (start, cursor), partial_prefix, @@ -1227,4 +1265,55 @@ mod tests { memo.selection_idx = 1; assert_eq!(memo.prev_idx(), 0); } + + // ---- Ranker hook (ADR-0024 §ranker-layer) ---- + + #[test] + fn identity_ranker_preserves_input_order() { + let input = vec![ + Candidate { + text: "b".to_string(), + kind: CandidateKind::Keyword, + }, + Candidate { + text: "a".to_string(), + kind: CandidateKind::Keyword, + }, + Candidate { + text: "c".to_string(), + kind: CandidateKind::Identifier, + }, + ]; + let out = identity_ranker(input.clone()); + assert_eq!(out, input); + } + + #[test] + fn ranker_can_reorder_candidates() { + // Hooks like frequency-based ranking or content-aware + // priors plug in through `Ranker` without touching the + // grammar. Smoke-test the call site with a sorter. + fn alphabetic_ranker(mut c: Vec) -> Vec { + c.sort_by(|a, b| a.text.cmp(&b.text)); + c + } + // `add ` exposes `column` and `1:n` — alphabetic ranker + // flips them. + let cache = SchemaCache::default(); + let comp = candidates_at_cursor_with("add ", 4, &cache, alphabetic_ranker) + .expect("some completion"); + let texts: Vec = comp.candidates.into_iter().map(|c| c.text).collect(); + assert_eq!(texts, vec!["1:n".to_string(), "column".to_string()]); + } + + #[test] + fn ranker_can_filter_to_empty() { + // A ranker that returns an empty list collapses the + // completion to `None`. + fn empty_ranker(_: Vec) -> Vec { + Vec::new() + } + let cache = SchemaCache::default(); + assert!(candidates_at_cursor_with("create ", 7, &cache, empty_ranker).is_none()); + } }