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
45#[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 #[must_use]
57 pub fn new() -> Self {
58 Self::default()
59 }
60
61 #[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 crate::core::nc_compress::register_into(&mut reg);
72 reg.register_chunker(Arc::new(LineChunker::default()));
73 reg.register_chunker(Arc::new(ParagraphChunker));
74 crate::core::extractors::register_into(&mut reg);
77 #[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 pub fn register_read_mode(&mut self, handler: Arc<dyn ReadMode>) {
89 self.read_modes.insert(handler.name().to_string(), handler);
90 }
91
92 pub fn register_compressor(&mut self, handler: Arc<dyn Compressor>) {
94 self.compressors.insert(handler.name().to_string(), handler);
95 }
96
97 pub fn register_chunker(&mut self, handler: Arc<dyn Chunker>) {
99 self.chunkers.insert(handler.name().to_string(), handler);
100 }
101
102 #[must_use]
104 pub fn read_mode(&self, name: &str) -> Option<Arc<dyn ReadMode>> {
105 self.read_modes.get(name).cloned()
106 }
107
108 #[must_use]
110 pub fn compressor(&self, name: &str) -> Option<Arc<dyn Compressor>> {
111 self.compressors.get(name).cloned()
112 }
113
114 #[must_use]
116 pub fn chunker(&self, name: &str) -> Option<Arc<dyn Chunker>> {
117 self.chunkers.get(name).cloned()
118 }
119
120 #[must_use]
122 pub fn read_mode_names(&self) -> Vec<String> {
123 self.read_modes.keys().cloned().collect()
124 }
125
126 #[must_use]
128 pub fn compressor_names(&self) -> Vec<String> {
129 self.compressors.keys().cloned().collect()
130 }
131
132 #[must_use]
134 pub fn chunker_names(&self) -> Vec<String> {
135 self.chunkers.keys().cloned().collect()
136 }
137}
138
139pub 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
145struct 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
160struct 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
171struct 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
197struct 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
222struct 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
238pub(crate) fn truncate_to_budget(mut s: String, budget: Option<usize>) -> String {
240 if let Some(b) = budget
241 && s.len() > b
242 {
243 let mut end = b;
244 while end > 0 && !s.is_char_boundary(end) {
245 end -= 1;
246 }
247 s.truncate(end);
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 assert_eq!(
263 reg.compressor_names(),
264 vec!["identity", "markdown", "prose", "whitespace"]
265 );
266 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 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}