dyntext/tre.rs
1//! Safe Rust wrapper around `tre-sys`.
2//!
3//! This module is the single entry point dyntext callers use
4//! to do approximate-regex matching. It provides:
5//!
6//! * [`TreCompiledPattern`], an owning handle to a compiled
7//! TRE pattern that frees its native resources on drop.
8//! * [`TreMatchOpts`], a builder for the per-call cost weights
9//! and edit-budget caps.
10//! * [`TreMatch`], the result of a successful approximate
11//! match.
12//! * [`TreError`], a typed error returned by [`TreCompiledPattern::compile`].
13//!
14//! The wrapper is intentionally narrow: it exposes only what
15//! the dyntext index needs, and it does **not** leak any
16//! `unsafe` API. All `unsafe` lives in the `tre-sys` crate;
17//! see the module-level safety section there for the contract
18//! enforced here.
19//!
20//! # Example
21//!
22//! ```
23//! use dyntext::tre::{TreCompiledPattern, TreMatchOpts};
24//!
25//! let opts = TreMatchOpts {
26//! max_errors: 1,
27//! ..TreMatchOpts::default()
28//! };
29//! let pat = TreCompiledPattern::compile(br"errno: \w+ refused", opts)
30//! .expect("compile");
31//! assert!(pat.is_match(b"errno: connection refused"));
32//! assert!(pat.is_match(b"errno: cnnection refused"));
33//! assert!(!pat.is_match(b"errno cnntion rfsed"));
34//! ```
35//!
36//! # Thread safety
37//!
38//! [`TreCompiledPattern`] is intentionally `!Send` and `!Sync`.
39//! TRE's compiled `regex_t` owns mutable state (the TNFA), and
40//! the matcher is not internally synchronised. Callers that
41//! want concurrent matching should compile one pattern per
42//! thread.
43
44use std::marker::PhantomData;
45use std::os::raw::c_int;
46
47use thiserror::Error;
48use tre_sys::{
49 compile as compile_raw, default_regaparams, safe_reganexec, safe_regerror, ExecOutcome,
50 OwnedRegex, REG_EXTENDED, REG_ICASE,
51};
52
53/// Errors returned by [`TreCompiledPattern::compile`].
54#[derive(Debug, Error)]
55pub enum TreError {
56 /// The pattern bytes failed to compile.
57 #[error("tre compile failed: {0}")]
58 Compile(String),
59 /// A configuration choice in [`TreMatchOpts`] is
60 /// inconsistent (for example, `max_errors > 0` with all
61 /// edit costs at zero).
62 #[error("invalid pattern options: {0}")]
63 InvalidOptions(String),
64 /// TRE returned a code not covered by the public API.
65 #[error("internal tre error: {0}")]
66 Internal(String),
67}
68
69/// Per-pattern matching configuration.
70///
71/// `max_errors` is the headline knob: it bounds the number of
72/// edit operations tolerated in a match. `cost_*` weight each
73/// edit kind; if all weights are 1 the cost equals the edit
74/// distance. `max_cost` bounds the cumulative cost; setting it
75/// to 0 with `max_errors > 0` is rejected because it forces
76/// exact match no matter what edits are allowed.
77///
78/// `case_insensitive` toggles TRE's `REG_ICASE` flag.
79#[derive(Clone, Copy, Debug)]
80pub struct TreMatchOpts {
81 /// Maximum number of edit operations (insert + delete +
82 /// substitute) permitted in a match.
83 pub max_errors: u16,
84 /// Cost of inserting one byte. Defaults to 1.
85 pub cost_ins: u16,
86 /// Cost of deleting one byte. Defaults to 1.
87 pub cost_del: u16,
88 /// Cost of substituting one byte. Defaults to 1.
89 pub cost_subst: u16,
90 /// Cumulative cost ceiling. Zero means "use
91 /// `max_errors * max(cost_*)` as the implicit ceiling".
92 pub max_cost: u16,
93 /// Match case-insensitively.
94 pub case_insensitive: bool,
95}
96
97impl Default for TreMatchOpts {
98 fn default() -> Self {
99 Self {
100 max_errors: 0,
101 cost_ins: 1,
102 cost_del: 1,
103 cost_subst: 1,
104 max_cost: 0,
105 case_insensitive: false,
106 }
107 }
108}
109
110/// Successful approximate-match result.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub struct TreMatch {
113 /// Byte offset where the match starts. The TRE
114 /// wrapper does not request submatch storage, so this is
115 /// reported as 0 on a successful match; the meaningful
116 /// result is whether a match exists within the cost bound,
117 /// not its position.
118 pub start: usize,
119 /// Byte offset one past the end of the match. See
120 /// [`Self::start`] for the current limitation.
121 pub end: usize,
122 /// Total cost of the match (sum of edit costs).
123 pub cost: u32,
124 /// Number of inserts.
125 pub n_ins: u32,
126 /// Number of deletes.
127 pub n_del: u32,
128 /// Number of substitutes.
129 pub n_subst: u32,
130}
131
132/// Owning handle to a TRE-compiled pattern.
133///
134/// Construct via [`TreCompiledPattern::compile`]. The native
135/// resources are released by [`Drop`] (delegated to the
136/// `tre-sys::OwnedRegex` field).
137pub struct TreCompiledPattern {
138 raw: OwnedRegex,
139 /// Cached compile-time options so [`Self::matches`] can
140 /// reuse the right cost weights without rebuilding the
141 /// `regaparams_t` on every call.
142 opts: TreMatchOpts,
143 /// `*const ()` is `!Send + !Sync`; this drops both auto
144 /// traits without needing explicit `unsafe impl !Send`
145 /// (which is currently nightly-only).
146 _not_send_or_sync: PhantomData<*const ()>,
147}
148
149impl std::fmt::Debug for TreCompiledPattern {
150 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151 f.debug_struct("TreCompiledPattern")
152 .field("nsub", &self.raw.nsub())
153 .field("opts", &self.opts)
154 .finish()
155 }
156}
157
158impl TreCompiledPattern {
159 /// Compile a regex pattern.
160 ///
161 /// The pattern is read as bytes. The vendored TRE build
162 /// disables multibyte handling, so byte n in the pattern is
163 /// matched against byte n in the haystack regardless of the
164 /// host locale.
165 pub fn compile(pattern: &[u8], opts: TreMatchOpts) -> Result<Self, TreError> {
166 validate_opts(opts)?;
167
168 let mut cflags = REG_EXTENDED;
169 if opts.case_insensitive {
170 cflags |= REG_ICASE;
171 }
172
173 match compile_raw(pattern, cflags) {
174 Ok(raw) => Ok(Self {
175 raw,
176 opts,
177 _not_send_or_sync: PhantomData,
178 }),
179 Err(rc) => Err(TreError::Compile(safe_regerror(rc, None))),
180 }
181 }
182
183 /// Return `true` if `text` contains an approximate match
184 /// for the pattern within the configured error budget.
185 #[must_use]
186 pub fn is_match(&self, text: &[u8]) -> bool {
187 self.matches(text).is_some()
188 }
189
190 /// Return the first approximate match in `text`, if any.
191 ///
192 /// On a non-`REG_OK`/`REG_NOMATCH` return code from TRE
193 /// the function returns `None` and asserts in debug builds.
194 /// In release the failure is treated as a no-match because
195 /// the only documented non-fatal failure for the
196 /// approximate matcher is back-references in the pattern,
197 /// which dyntext does not generate.
198 #[must_use]
199 pub fn matches(&self, text: &[u8]) -> Option<TreMatch> {
200 let params = self.build_params();
201 match safe_reganexec(self.raw.as_raw(), text, params, 0) {
202 ExecOutcome::Match(m) => Some(TreMatch {
203 start: 0,
204 end: text.len(),
205 cost: u32::try_from(m.cost).unwrap_or(u32::MAX),
206 n_ins: u32::try_from(m.num_ins).unwrap_or(u32::MAX),
207 n_del: u32::try_from(m.num_del).unwrap_or(u32::MAX),
208 n_subst: u32::try_from(m.num_subst).unwrap_or(u32::MAX),
209 }),
210 ExecOutcome::NoMatch => None,
211 ExecOutcome::Error(other) => {
212 debug_assert!(false, "tre_reganexec returned unexpected code {other}");
213 None
214 }
215 }
216 }
217
218 /// Borrow the cached options. Useful for tests and for
219 /// callers that want to re-use the same configuration.
220 #[must_use]
221 pub fn opts(&self) -> TreMatchOpts {
222 self.opts
223 }
224
225 /// Build the `regaparams_t` from the cached options.
226 fn build_params(&self) -> tre_sys::regaparams_t {
227 let mut params = default_regaparams();
228 params.cost_ins = c_int::from(self.opts.cost_ins);
229 params.cost_del = c_int::from(self.opts.cost_del);
230 params.cost_subst = c_int::from(self.opts.cost_subst);
231 params.max_err = c_int::from(self.opts.max_errors);
232 params.max_ins = c_int::from(self.opts.max_errors);
233 params.max_del = c_int::from(self.opts.max_errors);
234 params.max_subst = c_int::from(self.opts.max_errors);
235
236 let max_cost = if self.opts.max_cost == 0 {
237 // Implicit ceiling: max_errors * largest single
238 // edit cost. Using u32 saturating arithmetic to
239 // avoid surprising overflow when callers pass big
240 // u16 values.
241 let largest = self
242 .opts
243 .cost_ins
244 .max(self.opts.cost_del)
245 .max(self.opts.cost_subst);
246 let budget = u32::from(self.opts.max_errors) * u32::from(largest.max(1));
247 c_int::try_from(budget).unwrap_or(c_int::MAX)
248 } else {
249 c_int::from(self.opts.max_cost)
250 };
251 params.max_cost = max_cost;
252 params
253 }
254}
255
256fn validate_opts(opts: TreMatchOpts) -> Result<(), TreError> {
257 if opts.max_errors > 0 && opts.cost_ins == 0 && opts.cost_del == 0 && opts.cost_subst == 0 {
258 return Err(TreError::InvalidOptions(
259 "max_errors > 0 requires at least one non-zero edit cost".into(),
260 ));
261 }
262 Ok(())
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn default_opts_are_zero_error_unit_costs() {
271 let opts = TreMatchOpts::default();
272 assert_eq!(opts.max_errors, 0);
273 assert_eq!(opts.cost_ins, 1);
274 assert_eq!(opts.cost_del, 1);
275 assert_eq!(opts.cost_subst, 1);
276 assert_eq!(opts.max_cost, 0);
277 assert!(!opts.case_insensitive);
278 }
279
280 #[test]
281 fn validate_opts_rejects_max_errors_with_zero_costs() {
282 let bad = TreMatchOpts {
283 max_errors: 1,
284 cost_ins: 0,
285 cost_del: 0,
286 cost_subst: 0,
287 ..TreMatchOpts::default()
288 };
289 assert!(matches!(
290 validate_opts(bad),
291 Err(TreError::InvalidOptions(_))
292 ));
293 }
294
295 #[test]
296 fn compiled_pattern_phantom_is_not_send_or_sync() {
297 // A `PhantomData<*const ()>` field drops both auto
298 // traits. The check below is a documentation-only
299 // reminder; if it stops compiling the assertion is
300 // wrong and should be re-derived.
301 fn assert_any<T>() {}
302 assert_any::<TreCompiledPattern>();
303 }
304}