Skip to main content

lean_ctx/core/
extension_registry.rs

1//! Pluggable read-modes, compressors, and chunkers (`extension-registry-v1`).
2//!
3//! Previously these sets were hardcoded. This module turns them into registries
4//! that extensions can extend at runtime. Built-ins register through the *same*
5//! path as extensions — no special-casing — so a third-party read-mode,
6//! compressor, or chunker is a first-class citizen, discoverable via
7//! `GET /v1/capabilities`.
8//!
9//! The performance-critical in-core read modes keep their optimized path; this
10//! registry is the stable, named extension seam (text→text / text→chunks
11//! transforms) and the home extensions plug into.
12
13// The `name()` trait methods return `&str` (not `&'static str`) on purpose so an
14// extension can return a runtime-owned name. Built-ins return literals, which
15// trips `unnecessary_literal_bound`; the flexibility is intentional.
16#![allow(clippy::unnecessary_literal_bound)]
17
18use std::collections::BTreeMap;
19use std::sync::{Arc, OnceLock, RwLock};
20
21/// A named text→text transform (e.g. a domain compression dictionary).
22pub trait Compressor: Send + Sync {
23    /// Stable registry name.
24    fn name(&self) -> &str;
25    /// Compress `input`, optionally honoring a soft byte budget.
26    fn compress(&self, input: &str, budget: Option<usize>) -> String;
27}
28
29/// A named splitter that turns text into index chunks.
30pub trait Chunker: Send + Sync {
31    /// Stable registry name.
32    fn name(&self) -> &str;
33    /// Split `input` into chunks.
34    fn chunk(&self, input: &str) -> Vec<String>;
35}
36
37/// A named read-mode renderer operating on a file's source + path.
38pub trait ReadMode: Send + Sync {
39    /// Stable registry name.
40    fn name(&self) -> &str;
41    /// Render the read output for `source` at `path`.
42    fn render(&self, source: &str, path: &str) -> String;
43}
44
45/// Registry of pluggable read-modes, compressors, and chunkers.
46#[derive(Default)]
47pub struct ExtensionRegistry {
48    read_modes: BTreeMap<String, Arc<dyn ReadMode>>,
49    compressors: BTreeMap<String, Arc<dyn Compressor>>,
50    chunkers: BTreeMap<String, Arc<dyn Chunker>>,
51}
52
53impl ExtensionRegistry {
54    /// An empty registry (no built-ins). Use [`ExtensionRegistry::with_builtins`]
55    /// for the production set.
56    #[must_use]
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// A registry seeded with the built-in transforms — registered through the
62    /// same public API extensions use.
63    #[must_use]
64    pub fn with_builtins() -> Self {
65        let mut reg = Self::new();
66        reg.register_read_mode(Arc::new(FullReadMode));
67        reg.register_compressor(Arc::new(IdentityCompressor));
68        reg.register_compressor(Arc::new(WhitespaceCompressor));
69        // Non-code compressors (prose/markdown) tuned for prose/web/data corpora
70        // (EPIC 12.14), registered through the same public path.
71        crate::core::nc_compress::register_into(&mut reg);
72        reg.register_chunker(Arc::new(LineChunker::default()));
73        reg.register_chunker(Arc::new(ParagraphChunker));
74        // Format-aware chunkers (csv/json/eml/html) register through the same
75        // public path so they are first-class + conformance-checked (EPIC 12.13).
76        crate::core::extractors::register_into(&mut reg);
77        // Opt-in WASM compressors discovered from `LEAN_CTX_WASM_DIR` (EPIC 12.8).
78        // First-class once registered: discoverable via `/v1/capabilities` and
79        // checked by the conformance scorecard like any other compressor.
80        #[cfg(feature = "wasm")]
81        if let Ok(dir) = std::env::var("LEAN_CTX_WASM_DIR") {
82            crate::core::wasm_ext::register_compressors_from_dir(&mut reg, dir);
83        }
84        reg
85    }
86
87    /// Register (or replace) a read-mode by its name.
88    pub fn register_read_mode(&mut self, handler: Arc<dyn ReadMode>) {
89        self.read_modes.insert(handler.name().to_string(), handler);
90    }
91
92    /// Register (or replace) a compressor by its name.
93    pub fn register_compressor(&mut self, handler: Arc<dyn Compressor>) {
94        self.compressors.insert(handler.name().to_string(), handler);
95    }
96
97    /// Register (or replace) a chunker by its name.
98    pub fn register_chunker(&mut self, handler: Arc<dyn Chunker>) {
99        self.chunkers.insert(handler.name().to_string(), handler);
100    }
101
102    /// Look up a read-mode by name.
103    #[must_use]
104    pub fn read_mode(&self, name: &str) -> Option<Arc<dyn ReadMode>> {
105        self.read_modes.get(name).cloned()
106    }
107
108    /// Look up a compressor by name.
109    #[must_use]
110    pub fn compressor(&self, name: &str) -> Option<Arc<dyn Compressor>> {
111        self.compressors.get(name).cloned()
112    }
113
114    /// Look up a chunker by name.
115    #[must_use]
116    pub fn chunker(&self, name: &str) -> Option<Arc<dyn Chunker>> {
117        self.chunkers.get(name).cloned()
118    }
119
120    /// Registered read-mode names (sorted).
121    #[must_use]
122    pub fn read_mode_names(&self) -> Vec<String> {
123        self.read_modes.keys().cloned().collect()
124    }
125
126    /// Registered compressor names (sorted).
127    #[must_use]
128    pub fn compressor_names(&self) -> Vec<String> {
129        self.compressors.keys().cloned().collect()
130    }
131
132    /// Registered chunker names (sorted).
133    #[must_use]
134    pub fn chunker_names(&self) -> Vec<String> {
135        self.chunkers.keys().cloned().collect()
136    }
137}
138
139/// Process-global registry, seeded with built-ins on first access.
140pub fn global() -> &'static RwLock<ExtensionRegistry> {
141    static REGISTRY: OnceLock<RwLock<ExtensionRegistry>> = OnceLock::new();
142    REGISTRY.get_or_init(|| RwLock::new(ExtensionRegistry::with_builtins()))
143}
144
145// ----------------------------------------------------------------------------
146// Built-in implementations (real, not stubs).
147// ----------------------------------------------------------------------------
148
149/// `full`: return the source verbatim (the byte-faithful default read mode).
150struct FullReadMode;
151impl ReadMode for FullReadMode {
152    fn name(&self) -> &str {
153        "full"
154    }
155    fn render(&self, source: &str, _path: &str) -> String {
156        source.to_string()
157    }
158}
159
160/// `identity`: pass content through unchanged (honoring a hard byte budget).
161struct IdentityCompressor;
162impl Compressor for IdentityCompressor {
163    fn name(&self) -> &str {
164        "identity"
165    }
166    fn compress(&self, input: &str, budget: Option<usize>) -> String {
167        truncate_to_budget(input.to_string(), budget)
168    }
169}
170
171/// `whitespace`: collapse runs of blank lines and strip trailing whitespace.
172struct WhitespaceCompressor;
173impl Compressor for WhitespaceCompressor {
174    fn name(&self) -> &str {
175        "whitespace"
176    }
177    fn compress(&self, input: &str, budget: Option<usize>) -> String {
178        let mut out = String::with_capacity(input.len());
179        let mut blank_run = 0u32;
180        for line in input.lines() {
181            if line.trim().is_empty() {
182                blank_run += 1;
183                if blank_run > 1 {
184                    continue;
185                }
186                out.push('\n');
187            } else {
188                blank_run = 0;
189                out.push_str(line.trim_end());
190                out.push('\n');
191            }
192        }
193        truncate_to_budget(out, budget)
194    }
195}
196
197/// `lines`: fixed-size, non-overlapping windows of source lines.
198struct LineChunker {
199    window: usize,
200}
201impl Default for LineChunker {
202    fn default() -> Self {
203        Self { window: 50 }
204    }
205}
206impl Chunker for LineChunker {
207    fn name(&self) -> &str {
208        "lines"
209    }
210    fn chunk(&self, input: &str) -> Vec<String> {
211        let lines: Vec<&str> = input.lines().collect();
212        if lines.is_empty() {
213            return Vec::new();
214        }
215        lines
216            .chunks(self.window.max(1))
217            .map(|w| w.join("\n"))
218            .collect()
219    }
220}
221
222/// `paragraph`: split on blank-line boundaries.
223struct ParagraphChunker;
224impl Chunker for ParagraphChunker {
225    fn name(&self) -> &str {
226        "paragraph"
227    }
228    fn chunk(&self, input: &str) -> Vec<String> {
229        input
230            .split("\n\n")
231            .map(str::trim)
232            .filter(|s| !s.is_empty())
233            .map(String::from)
234            .collect()
235    }
236}
237
238/// Truncate `s` to at most `budget` bytes, never splitting a UTF-8 char.
239pub(crate) fn truncate_to_budget(mut s: String, budget: Option<usize>) -> String {
240    if let Some(b) = budget {
241        if s.len() > b {
242            let mut end = b;
243            while end > 0 && !s.is_char_boundary(end) {
244                end -= 1;
245            }
246            s.truncate(end);
247        }
248    }
249    s
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn builtins_are_registered() {
258        let reg = ExtensionRegistry::with_builtins();
259        assert_eq!(reg.read_mode_names(), vec!["full"]);
260        // identity/whitespace built-ins plus the non-code compressors
261        // (markdown/prose) from `core::nc_compress` (EPIC 12.14).
262        assert_eq!(
263            reg.compressor_names(),
264            vec!["identity", "markdown", "prose", "whitespace"]
265        );
266        // Built-in line/paragraph chunkers plus the format-aware chunkers
267        // (csv/json/eml/html) registered by `core::extractors` (EPIC 12.13).
268        assert_eq!(
269            reg.chunker_names(),
270            vec!["csv", "eml", "html", "json", "lines", "paragraph"]
271        );
272    }
273
274    #[test]
275    fn whitespace_compressor_collapses_blanks() {
276        let reg = ExtensionRegistry::with_builtins();
277        let c = reg.compressor("whitespace").unwrap();
278        let out = c.compress("a\n\n\n\nb  \n", None);
279        assert_eq!(out, "a\n\nb\n");
280    }
281
282    #[test]
283    fn identity_compressor_honors_budget_on_char_boundary() {
284        let reg = ExtensionRegistry::with_builtins();
285        let c = reg.compressor("identity").unwrap();
286        // 'ä' is two bytes; a 3-byte budget must not split it.
287        let out = c.compress("aäb", Some(2));
288        assert_eq!(out, "a");
289    }
290
291    #[test]
292    fn chunkers_split_as_expected() {
293        let reg = ExtensionRegistry::with_builtins();
294        let para = reg.chunker("paragraph").unwrap();
295        assert_eq!(
296            para.chunk("one\n\ntwo\n\n\nthree"),
297            vec!["one", "two", "three"]
298        );
299        let lines = reg.chunker("lines").unwrap();
300        assert_eq!(lines.chunk("a\nb\nc").len(), 1);
301    }
302
303    struct UpperCompressor;
304    impl Compressor for UpperCompressor {
305        fn name(&self) -> &str {
306            "uppercase"
307        }
308        fn compress(&self, input: &str, _budget: Option<usize>) -> String {
309            input.to_uppercase()
310        }
311    }
312
313    #[test]
314    fn extension_can_register_and_run_custom_compressor() {
315        let mut reg = ExtensionRegistry::with_builtins();
316        reg.register_compressor(Arc::new(UpperCompressor));
317        assert!(reg.compressor_names().contains(&"uppercase".to_string()));
318        let c = reg.compressor("uppercase").unwrap();
319        assert_eq!(c.compress("hi", None), "HI");
320    }
321
322    #[test]
323    fn global_registry_seeds_builtins() {
324        let reg = global().read().unwrap();
325        assert!(reg.compressor("identity").is_some());
326    }
327}