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")]
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 pub fn register_read_mode(&mut self, handler: Arc<dyn ReadMode>) {
97 self.read_modes.insert(handler.name().to_string(), handler);
98 }
99
100 pub fn register_compressor(&mut self, handler: Arc<dyn Compressor>) {
102 self.compressors.insert(handler.name().to_string(), handler);
103 }
104
105 pub fn register_chunker(&mut self, handler: Arc<dyn Chunker>) {
107 self.chunkers.insert(handler.name().to_string(), handler);
108 }
109
110 #[must_use]
112 pub fn read_mode(&self, name: &str) -> Option<Arc<dyn ReadMode>> {
113 self.read_modes.get(name).cloned()
114 }
115
116 #[must_use]
118 pub fn compressor(&self, name: &str) -> Option<Arc<dyn Compressor>> {
119 self.compressors.get(name).cloned()
120 }
121
122 #[must_use]
124 pub fn chunker(&self, name: &str) -> Option<Arc<dyn Chunker>> {
125 self.chunkers.get(name).cloned()
126 }
127
128 #[must_use]
130 pub fn read_mode_names(&self) -> Vec<String> {
131 self.read_modes.keys().cloned().collect()
132 }
133
134 #[must_use]
136 pub fn compressor_names(&self) -> Vec<String> {
137 self.compressors.keys().cloned().collect()
138 }
139
140 #[must_use]
142 pub fn chunker_names(&self) -> Vec<String> {
143 self.chunkers.keys().cloned().collect()
144 }
145
146 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 #[must_use]
154 pub fn render_transform(&self, name: &str) -> Option<Arc<dyn RenderTransform>> {
155 self.render_transforms.get(name).cloned()
156 }
157
158 #[must_use]
160 pub fn render_transform_names(&self) -> Vec<String> {
161 self.render_transforms.keys().cloned().collect()
162 }
163}
164
165pub 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
171struct 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
186struct 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
197struct 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
223struct 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
248struct 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
264pub(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 assert_eq!(
289 reg.compressor_names(),
290 vec!["identity", "markdown", "prose", "whitespace"]
291 );
292 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 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}