lean_ctx/core/
extension_registry.rs1#![allow(clippy::unnecessary_literal_bound)]
17
18use std::collections::BTreeMap;
19use std::sync::{Arc, OnceLock, RwLock};
20
21pub trait Compressor: Send + Sync {
23 fn name(&self) -> &str;
25 fn compress(&self, input: &str, budget: Option<usize>) -> String;
27}
28
29pub trait Chunker: Send + Sync {
31 fn name(&self) -> &str;
33 fn chunk(&self, input: &str) -> Vec<String>;
35}
36
37pub trait ReadMode: Send + Sync {
39 fn name(&self) -> &str;
41 fn render(&self, source: &str, path: &str) -> String;
43}
44
45pub trait RenderTransform: Send + Sync {
48 fn name(&self) -> &str;
49 fn render(&self, input: &str, hint: i32) -> String;
50}
51
52#[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 #[must_use]
65 pub fn new() -> Self {
66 Self::default()
67 }
68
69 #[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 crate::core::nc_compress::register_into(&mut reg);
80 reg.register_chunker(Arc::new(LineChunker::default()));
81 reg.register_chunker(Arc::new(ParagraphChunker));
82 crate::core::extractors::register_into(&mut reg);
85 #[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 pub fn register_read_mode(&mut self, handler: Arc<dyn ReadMode>) {
112 self.read_modes.insert(handler.name().to_string(), handler);
113 }
114
115 pub fn register_compressor(&mut self, handler: Arc<dyn Compressor>) {
117 self.compressors.insert(handler.name().to_string(), handler);
118 }
119
120 pub fn register_chunker(&mut self, handler: Arc<dyn Chunker>) {
122 self.chunkers.insert(handler.name().to_string(), handler);
123 }
124
125 #[must_use]
127 pub fn read_mode(&self, name: &str) -> Option<Arc<dyn ReadMode>> {
128 self.read_modes.get(name).cloned()
129 }
130
131 #[must_use]
133 pub fn compressor(&self, name: &str) -> Option<Arc<dyn Compressor>> {
134 self.compressors.get(name).cloned()
135 }
136
137 #[must_use]
139 pub fn chunker(&self, name: &str) -> Option<Arc<dyn Chunker>> {
140 self.chunkers.get(name).cloned()
141 }
142
143 #[must_use]
145 pub fn read_mode_names(&self) -> Vec<String> {
146 self.read_modes.keys().cloned().collect()
147 }
148
149 #[must_use]
151 pub fn compressor_names(&self) -> Vec<String> {
152 self.compressors.keys().cloned().collect()
153 }
154
155 #[must_use]
157 pub fn chunker_names(&self) -> Vec<String> {
158 self.chunkers.keys().cloned().collect()
159 }
160
161 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 #[must_use]
169 pub fn render_transform(&self, name: &str) -> Option<Arc<dyn RenderTransform>> {
170 self.render_transforms.get(name).cloned()
171 }
172
173 #[must_use]
175 pub fn render_transform_names(&self) -> Vec<String> {
176 self.render_transforms.keys().cloned().collect()
177 }
178}
179
180pub 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
186struct 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
201struct 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
212struct 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
238struct 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
263struct 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
279pub(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 assert_eq!(
304 reg.compressor_names(),
305 vec!["identity", "markdown", "prose", "whitespace"]
306 );
307 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 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}