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/// `@render type=<name>` — a WASM-backed render transform.
46/// `hint`: consumer hint (ai = 0, human = 1).
47pub trait RenderTransform: Send + Sync {
48    fn name(&self) -> &str;
49    fn render(&self, input: &str, hint: i32) -> String;
50}
51
52/// Registry of pluggable read-modes, compressors, and chunkers.
53#[derive(Default)]
54pub struct ExtensionRegistry {
55    read_modes: BTreeMap<String, Arc<dyn ReadMode>>,
56    compressors: BTreeMap<String, Arc<dyn Compressor>>,
57    chunkers: BTreeMap<String, Arc<dyn Chunker>>,
58    render_transforms: BTreeMap<String, Arc<dyn RenderTransform>>,
59}
60
61impl ExtensionRegistry {
62    /// An empty registry (no built-ins). Use [`ExtensionRegistry::with_builtins`]
63    /// for the production set.
64    #[must_use]
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// A registry seeded with the built-in transforms — registered through the
70    /// same public API extensions use.
71    #[must_use]
72    pub fn with_builtins() -> Self {
73        let mut reg = Self::new();
74        reg.register_read_mode(Arc::new(FullReadMode));
75        reg.register_compressor(Arc::new(IdentityCompressor));
76        reg.register_compressor(Arc::new(WhitespaceCompressor));
77        // Non-code compressors (prose/markdown) tuned for prose/web/data corpora
78        // (EPIC 12.14), registered through the same public path.
79        crate::core::nc_compress::register_into(&mut reg);
80        reg.register_chunker(Arc::new(LineChunker::default()));
81        reg.register_chunker(Arc::new(ParagraphChunker));
82        // Format-aware chunkers (csv/json/eml/html) register through the same
83        // public path so they are first-class + conformance-checked (EPIC 12.13).
84        crate::core::extractors::register_into(&mut reg);
85        // Extensions from installed `kind=addon` packs — signed, hash-pinned
86        // and locally re-verified on install. This is the supported channel;
87        // `LEAN_CTX_WASM_DIR` below stays as an unsigned developer override for
88        // authoring a module before it is packaged.
89        //
90        // First-class once registered: discoverable via `/v1/capabilities` and
91        // checked by the conformance scorecard like any other compressor.
92        #[cfg(feature = "wasm")]
93        {
94            if let Ok(store) = crate::core::context_package::addons::store_root() {
95                for module in crate::core::context_package::addons::installed_modules(&store) {
96                    if let Some(stem) = module.file_stem().and_then(|s| s.to_str())
97                        && let Ok(c) = crate::core::wasm_ext::WasmCompressor::load(&module, stem)
98                    {
99                        reg.register_compressor(std::sync::Arc::new(c));
100                    }
101                }
102            }
103            if let Ok(dir) = std::env::var("LEAN_CTX_WASM_DIR") {
104                crate::core::wasm_ext::register_compressors_from_dir(&mut reg, dir);
105            }
106        }
107        reg
108    }
109
110    /// Register (or replace) a read-mode by its name.
111    pub fn register_read_mode(&mut self, handler: Arc<dyn ReadMode>) {
112        self.read_modes.insert(handler.name().to_string(), handler);
113    }
114
115    /// Register (or replace) a compressor by its name.
116    pub fn register_compressor(&mut self, handler: Arc<dyn Compressor>) {
117        self.compressors.insert(handler.name().to_string(), handler);
118    }
119
120    /// Register (or replace) a chunker by its name.
121    pub fn register_chunker(&mut self, handler: Arc<dyn Chunker>) {
122        self.chunkers.insert(handler.name().to_string(), handler);
123    }
124
125    /// Look up a read-mode by name.
126    #[must_use]
127    pub fn read_mode(&self, name: &str) -> Option<Arc<dyn ReadMode>> {
128        self.read_modes.get(name).cloned()
129    }
130
131    /// Look up a compressor by name.
132    #[must_use]
133    pub fn compressor(&self, name: &str) -> Option<Arc<dyn Compressor>> {
134        self.compressors.get(name).cloned()
135    }
136
137    /// Look up a chunker by name.
138    #[must_use]
139    pub fn chunker(&self, name: &str) -> Option<Arc<dyn Chunker>> {
140        self.chunkers.get(name).cloned()
141    }
142
143    /// Registered read-mode names (sorted).
144    #[must_use]
145    pub fn read_mode_names(&self) -> Vec<String> {
146        self.read_modes.keys().cloned().collect()
147    }
148
149    /// Registered compressor names (sorted).
150    #[must_use]
151    pub fn compressor_names(&self) -> Vec<String> {
152        self.compressors.keys().cloned().collect()
153    }
154
155    /// Registered chunker names (sorted).
156    #[must_use]
157    pub fn chunker_names(&self) -> Vec<String> {
158        self.chunkers.keys().cloned().collect()
159    }
160
161    /// Register (or replace) a render transform by its name.
162    pub fn register_render_transform(&mut self, handler: Arc<dyn RenderTransform>) {
163        self.render_transforms
164            .insert(handler.name().to_string(), handler);
165    }
166
167    /// Look up a render transform by name.
168    #[must_use]
169    pub fn render_transform(&self, name: &str) -> Option<Arc<dyn RenderTransform>> {
170        self.render_transforms.get(name).cloned()
171    }
172
173    /// Registered render transform names (sorted).
174    #[must_use]
175    pub fn render_transform_names(&self) -> Vec<String> {
176        self.render_transforms.keys().cloned().collect()
177    }
178}
179
180/// Process-global registry, seeded with built-ins on first access.
181pub fn global() -> &'static RwLock<ExtensionRegistry> {
182    static REGISTRY: OnceLock<RwLock<ExtensionRegistry>> = OnceLock::new();
183    REGISTRY.get_or_init(|| RwLock::new(ExtensionRegistry::with_builtins()))
184}
185
186// ----------------------------------------------------------------------------
187// Built-in implementations (real, not stubs).
188// ----------------------------------------------------------------------------
189
190/// `full`: return the source verbatim (the byte-faithful default read mode).
191struct FullReadMode;
192impl ReadMode for FullReadMode {
193    fn name(&self) -> &str {
194        "full"
195    }
196    fn render(&self, source: &str, _path: &str) -> String {
197        source.to_string()
198    }
199}
200
201/// `identity`: pass content through unchanged (honoring a hard byte budget).
202struct IdentityCompressor;
203impl Compressor for IdentityCompressor {
204    fn name(&self) -> &str {
205        "identity"
206    }
207    fn compress(&self, input: &str, budget: Option<usize>) -> String {
208        truncate_to_budget(input.to_string(), budget)
209    }
210}
211
212/// `whitespace`: collapse runs of blank lines and strip trailing whitespace.
213struct WhitespaceCompressor;
214impl Compressor for WhitespaceCompressor {
215    fn name(&self) -> &str {
216        "whitespace"
217    }
218    fn compress(&self, input: &str, budget: Option<usize>) -> String {
219        let mut out = String::with_capacity(input.len());
220        let mut blank_run = 0u32;
221        for line in input.lines() {
222            if line.trim().is_empty() {
223                blank_run += 1;
224                if blank_run > 1 {
225                    continue;
226                }
227                out.push('\n');
228            } else {
229                blank_run = 0;
230                out.push_str(line.trim_end());
231                out.push('\n');
232            }
233        }
234        truncate_to_budget(out, budget)
235    }
236}
237
238/// `lines`: fixed-size, non-overlapping windows of source lines.
239struct LineChunker {
240    window: usize,
241}
242impl Default for LineChunker {
243    fn default() -> Self {
244        Self { window: 50 }
245    }
246}
247impl Chunker for LineChunker {
248    fn name(&self) -> &str {
249        "lines"
250    }
251    fn chunk(&self, input: &str) -> Vec<String> {
252        let lines: Vec<&str> = input.lines().collect();
253        if lines.is_empty() {
254            return Vec::new();
255        }
256        lines
257            .chunks(self.window.max(1))
258            .map(|w| w.join("\n"))
259            .collect()
260    }
261}
262
263/// `paragraph`: split on blank-line boundaries.
264struct ParagraphChunker;
265impl Chunker for ParagraphChunker {
266    fn name(&self) -> &str {
267        "paragraph"
268    }
269    fn chunk(&self, input: &str) -> Vec<String> {
270        input
271            .split("\n\n")
272            .map(str::trim)
273            .filter(|s| !s.is_empty())
274            .map(String::from)
275            .collect()
276    }
277}
278
279/// Truncate `s` to at most `budget` bytes, never splitting a UTF-8 char.
280pub(crate) fn truncate_to_budget(mut s: String, budget: Option<usize>) -> String {
281    if let Some(b) = budget
282        && s.len() > b
283    {
284        let mut end = b;
285        while end > 0 && !s.is_char_boundary(end) {
286            end -= 1;
287        }
288        s.truncate(end);
289    }
290    s
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn builtins_are_registered() {
299        let reg = ExtensionRegistry::with_builtins();
300        assert_eq!(reg.read_mode_names(), vec!["full"]);
301        // identity/whitespace built-ins plus the non-code compressors
302        // (markdown/prose) from `core::nc_compress` (EPIC 12.14).
303        assert_eq!(
304            reg.compressor_names(),
305            vec!["identity", "markdown", "prose", "whitespace"]
306        );
307        // Built-in line/paragraph chunkers plus the format-aware chunkers
308        // (csv/json/eml/html) registered by `core::extractors` (EPIC 12.13).
309        assert_eq!(
310            reg.chunker_names(),
311            vec!["csv", "eml", "html", "json", "lines", "paragraph"]
312        );
313    }
314
315    #[test]
316    fn whitespace_compressor_collapses_blanks() {
317        let reg = ExtensionRegistry::with_builtins();
318        let c = reg.compressor("whitespace").unwrap();
319        let out = c.compress("a\n\n\n\nb  \n", None);
320        assert_eq!(out, "a\n\nb\n");
321    }
322
323    #[test]
324    fn identity_compressor_honors_budget_on_char_boundary() {
325        let reg = ExtensionRegistry::with_builtins();
326        let c = reg.compressor("identity").unwrap();
327        // 'ä' is two bytes; a 3-byte budget must not split it.
328        let out = c.compress("aäb", Some(2));
329        assert_eq!(out, "a");
330    }
331
332    #[test]
333    fn chunkers_split_as_expected() {
334        let reg = ExtensionRegistry::with_builtins();
335        let para = reg.chunker("paragraph").unwrap();
336        assert_eq!(
337            para.chunk("one\n\ntwo\n\n\nthree"),
338            vec!["one", "two", "three"]
339        );
340        let lines = reg.chunker("lines").unwrap();
341        assert_eq!(lines.chunk("a\nb\nc").len(), 1);
342    }
343
344    struct UpperCompressor;
345    impl Compressor for UpperCompressor {
346        fn name(&self) -> &str {
347            "uppercase"
348        }
349        fn compress(&self, input: &str, _budget: Option<usize>) -> String {
350            input.to_uppercase()
351        }
352    }
353
354    #[test]
355    fn extension_can_register_and_run_custom_compressor() {
356        let mut reg = ExtensionRegistry::with_builtins();
357        reg.register_compressor(Arc::new(UpperCompressor));
358        assert!(reg.compressor_names().contains(&"uppercase".to_string()));
359        let c = reg.compressor("uppercase").unwrap();
360        assert_eq!(c.compress("hi", None), "HI");
361    }
362
363    struct UpperRender;
364    impl RenderTransform for UpperRender {
365        fn name(&self) -> &str {
366            "upper"
367        }
368        fn render(&self, input: &str, hint: i32) -> String {
369            format!("{}:{}", hint, input.to_uppercase())
370        }
371    }
372
373    #[test]
374    fn render_transform_registers_and_resolves_with_hint() {
375        let mut reg = ExtensionRegistry::with_builtins();
376        reg.register_render_transform(Arc::new(UpperRender));
377        let r = reg.render_transform("upper").unwrap();
378        assert_eq!(r.render("hi", 1), "1:HI");
379        assert!(reg.render_transform_names().contains(&"upper".to_string()));
380    }
381
382    #[test]
383    fn global_registry_seeds_builtins() {
384        let reg = global().read().unwrap();
385        assert!(reg.compressor("identity").is_some());
386    }
387}