Skip to main content

strop_grammar/query/
mod.rs

1//! The search query (0031 R5): a compiled, bounded Vim-magic regex.
2//!
3//! One `CompiledQuery` serves every consumer — the resolver, `n`/`N`,
4//! incsearch, the render highlight — so filtering (whole-word) and
5//! semantics live in exactly one place. Unsupported constructs are a
6//! typed [`QueryError`], never a silent literal reading.
7//!
8//! The dialect is Vim's default magic mode (plus `\v` very magic),
9//! pinned against real vim by `corpora/vim-query-corpus.txt` (buffer
10//! semantics: `.` and `[...]` never cross a line break, `\n` consumes
11//! one whole line break — `\r\n` counts as one in CRLF buffers).
12//! Documented extensions over vim: `\<`/`\>` and case folding follow
13//! the editor's char-classified word model (`is_alphanumeric`/`_`), so
14//! `é` is a word char for boundaries like it is for `w`/`b`/`*`.
15
16mod codec;
17pub(crate) mod exec;
18mod parse;
19use std::sync::{
20    atomic::{AtomicBool, Ordering},
21    Arc,
22};
23
24use strop_core::id::ByteOffset;
25use strop_core::Buffer;
26
27pub use exec::STEP_BUDGET;
28use parse::Program;
29
30/// An explicit match range `[start, end)` in buffer bytes. The length
31/// is whatever the pattern matched — never assume `end - start` equals
32/// the pattern's length (0031: highlighting reads this range).
33#[derive(
34    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
35)]
36pub struct SearchMatch {
37    pub start: ByteOffset,
38    pub end: ByteOffset,
39}
40
41impl SearchMatch {
42    pub fn len(&self) -> usize {
43        self.end - self.start
44    }
45    pub fn is_empty(&self) -> bool {
46        self.start == self.end
47    }
48}
49
50/// Why a query cannot compile or run. Typed end to end — the walker
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum QueryError {
53    /// A construct outside the supported dialect (lookaround, `\%V`,
54    /// `\%#`, `\%23l`-style positions, `~`, the `\M`/`\V` magic
55    /// levels, the `\%=` engine selector). `at` is the byte offset in
56    /// the pattern where the construct starts.
57    Unsupported { construct: &'static str, at: usize },
58    /// `\(` never closed, or a stray `\)`.
59    UnbalancedGroup { at: usize },
60    /// `[` never closed.
61    UnclosedClass { at: usize },
62    /// `\%[` never closed.
63    UnclosedOptional { at: usize },
64    /// Malformed `\{...}` (bad numbers, missing `\}`), a quantifier
65    /// with nothing to repeat, or a quantifier after a quantifier.
66    BadRepeat { at: usize },
67    /// Bad collection contents: reverse range, unknown POSIX class,
68    /// or `&&` intersection.
69    BadClass { at: usize },
70    /// Malformed `\%d`/`\%x`/`\%u`/`\%U` char code.
71    BadCharCode { at: usize },
72    /// The pattern ends in a lone backslash.
73    TrailingBackslash { at: usize },
74    /// The bounded engine's step budget ran out (vim's E363 family) —
75    /// the pattern is too expensive to run, not wrong.
76    TooComplex,
77    /// Superseded background work stops without publishing a partial answer.
78    Cancelled,
79}
80
81impl std::fmt::Display for QueryError {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        let (head, at): (&str, Option<usize>) = match self {
84            QueryError::Unsupported { construct, at } => {
85                return write!(f, "unsupported query syntax: {construct} (at byte {at})")
86            }
87            QueryError::UnbalancedGroup { at } => ("unbalanced \\( \\)", Some(*at)),
88            QueryError::UnclosedClass { at } => ("unclosed [...]", Some(*at)),
89            QueryError::UnclosedOptional { at } => ("unclosed \\%[", Some(*at)),
90            QueryError::BadRepeat { at } => ("malformed repetition", Some(*at)),
91            QueryError::BadClass { at } => ("malformed [...]", Some(*at)),
92            QueryError::BadCharCode { at } => ("malformed character code", Some(*at)),
93            QueryError::TrailingBackslash { at } => ("trailing backslash", Some(*at)),
94            QueryError::Cancelled => return f.write_str("query cancelled"),
95            QueryError::TooComplex => {
96                return write!(f, "query too complex (over {STEP_BUDGET} steps)")
97            }
98        };
99        match at {
100            Some(at) => write!(f, "{head} (at byte {at})"),
101            None => write!(f, "{head}"),
102        }
103    }
104}
105
106impl std::error::Error for QueryError {}
107
108/// A compiled search: everything about the query — the source text,
109/// the whole-word flag (`*`/`#` fold it in here, so no consumer
110/// re-filters), the case mode (`\c`/`\C`), and the program.
111///
112/// Deterministic compilation makes equality source-level: two queries
113/// with the same text and whole-word flag are the same query.
114#[derive(Clone)]
115pub struct CompiledQuery {
116    source: Arc<str>,
117    whole_word: bool,
118    prog: Arc<Program>,
119    cancel: Option<Arc<AtomicBool>>,
120}
121
122impl CompiledQuery {
123    /// Compile `pattern` in Vim magic syntax. `whole_word` flanks the
124    /// match with word-boundary assertions (the `*`/`#` search shape).
125    pub fn compile(pattern: &str, whole_word: bool) -> Result<Self, QueryError> {
126        let prog = parse::compile(pattern, whole_word)?;
127        Ok(Self {
128            source: Arc::from(pattern),
129            whole_word,
130            prog: Arc::new(prog),
131            cancel: None,
132        })
133    }
134
135    /// Attach only a work owner's cooperative stop flag. It changes no query
136    /// semantics and is never serialized with the source-level command.
137    pub fn cancellable(&self, cancel: Arc<AtomicBool>) -> Self {
138        let mut query = self.clone();
139        query.cancel = Some(cancel);
140        query
141    }
142    pub(crate) fn cancelled(&self) -> bool {
143        self.cancel
144            .as_ref()
145            .is_some_and(|cancel| cancel.load(Ordering::Acquire))
146    }
147
148    /// The pattern as typed (spec footers, `/` line echo, `n` replay).
149    pub fn source(&self) -> &str {
150        &self.source
151    }
152
153    /// Whether matches are flanked to whole words (render and `n`/`N`
154    /// ask this instead of re-implementing the filter).
155    pub fn whole_word(&self) -> bool {
156        self.whole_word
157    }
158
159    pub(super) fn program(&self) -> &Program {
160        &self.prog
161    }
162}
163
164impl std::fmt::Debug for CompiledQuery {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.debug_struct("CompiledQuery")
167            .field("source", &self.source)
168            .field("whole_word", &self.whole_word)
169            .finish()
170    }
171}
172
173impl PartialEq for CompiledQuery {
174    fn eq(&self, other: &Self) -> bool {
175        self.source == other.source && self.whole_word == other.whole_word
176    }
177}
178impl Eq for CompiledQuery {}
179
180/// First match starting at/after `from` (clamped up to a char
181/// boundary). Errors only from the step budget ([`QueryError::TooComplex`]).
182pub fn search_forward(
183    buf: &Buffer,
184    from: usize,
185    query: &CompiledQuery,
186) -> Result<Option<SearchMatch>, QueryError> {
187    super::resolve::search::forward(buf, from, query)
188}
189
190/// Last match whose start precedes `from`, even if its end crosses the
191/// cursor (`?pat` from inside a hit still finds it).
192pub fn search_backward(
193    buf: &Buffer,
194    from: usize,
195    query: &CompiledQuery,
196) -> Result<Option<SearchMatch>, QueryError> {
197    super::resolve::search::backward(buf, from, query)
198}
199
200/// Non-overlapping matches from the start of the buffer; an empty
201/// match advances one char (the historical `match_indices` contract,
202/// and vim's `cpo+=c` walk). The highlight range source.
203pub fn search_all(buf: &Buffer, query: &CompiledQuery) -> Result<Vec<SearchMatch>, QueryError> {
204    super::resolve::search::all(buf, query)
205}
206
207/// Visit the shared engine's matches without allocating a whole-buffer hit
208/// vector. The visitor may stop early; a work owner's cancellation is an error,
209/// never an apparently complete prefix of the answer.
210pub fn search_visit(
211    buf: &Buffer,
212    query: &CompiledQuery,
213    visitor: impl FnMut(SearchMatch) -> std::ops::ControlFlow<()>,
214) -> Result<(), QueryError> {
215    super::resolve::search::visit_all(buf, query, visitor)
216}