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