1use async_trait::async_trait;
31use regex::Regex;
32use std::path::Path;
33
34use crate::config::{ApiSurfaceConfig, LanguagePack, SymbolRule};
35use crate::memory::{MemMessage, MemoryProvider, SessionContext};
36use crate::metrics::TurnMetrics;
37
38fn rule(pattern: &str, kind: &str) -> SymbolRule {
39 SymbolRule {
40 pattern: pattern.to_string(),
41 kind: kind.to_string(),
42 }
43}
44
45#[must_use]
50pub fn builtin_packs() -> Vec<LanguagePack> {
51 vec![
52 LanguagePack {
53 name: "rust".into(),
54 extensions: vec!["rs".into()],
55 entry_points: vec!["lib.rs".into(), "mod.rs".into(), "main.rs".into()],
56 symbols: vec![
57 rule(
58 r"^\s*pub\s+(?:async\s+|const\s+|unsafe\s+|default\s+)*fn\s+(\w+)",
59 "fn",
60 ),
61 rule(r"^\s*pub\s+struct\s+(\w+)", "struct"),
62 rule(r"^\s*pub\s+enum\s+(\w+)", "enum"),
63 rule(r"^\s*pub\s+(?:unsafe\s+)?trait\s+(\w+)", "trait"),
64 rule(r"^\s*pub\s+mod\s+(\w+)", "mod"),
65 ],
66 },
67 LanguagePack {
68 name: "python".into(),
69 extensions: vec!["py".into()],
70 entry_points: vec!["__init__.py".into(), "__main__.py".into()],
71 symbols: vec![
73 rule(r"^def\s+([a-zA-Z]\w*)", "fn"),
74 rule(r"^class\s+([a-zA-Z]\w*)", "class"),
75 ],
76 },
77 LanguagePack {
78 name: "bash".into(),
79 extensions: vec!["sh".into(), "bash".into()],
80 entry_points: vec![],
81 symbols: vec![
82 rule(r"^function\s+([a-zA-Z_]\w*)", "fn"),
83 rule(r"^([a-zA-Z_]\w*)\s*\(\s*\)", "fn"),
84 ],
85 },
86 LanguagePack {
87 name: "c_cpp".into(),
88 extensions: vec![
89 "c".into(),
90 "cc".into(),
91 "cpp".into(),
92 "cxx".into(),
93 "h".into(),
94 "hpp".into(),
95 "hh".into(),
96 "hxx".into(),
97 ],
98 entry_points: vec!["*.h".into(), "*.hpp".into(), "*.hh".into(), "*.hxx".into()],
100 symbols: vec![
101 rule(r"^\s*(?:typedef\s+)?struct\s+(\w+)", "struct"),
102 rule(r"^\s*class\s+(\w+)", "class"),
103 rule(r"^\s*enum\s+(?:class\s+)?(\w+)", "enum"),
104 rule(r"^[A-Za-z_][\w\s\*:<>]*\s+(\w+)\s*\(", "fn"),
106 ],
107 },
108 LanguagePack {
109 name: "go".into(),
110 extensions: vec!["go".into()],
111 entry_points: vec!["doc.go".into()],
112 symbols: vec![
114 rule(r"^func\s+(?:\([^)]*\)\s*)?([A-Z]\w*)", "func"),
115 rule(r"^type\s+([A-Z]\w*)\s+struct", "struct"),
116 rule(r"^type\s+([A-Z]\w*)\s+interface", "interface"),
117 rule(r"^type\s+([A-Z]\w*)", "type"),
118 ],
119 },
120 LanguagePack {
121 name: "java".into(),
122 extensions: vec!["java".into()],
123 entry_points: vec!["package-info.java".into()],
124 symbols: vec![
125 rule(
126 r"^\s*public\s+(?:final\s+|abstract\s+)*class\s+(\w+)",
127 "class",
128 ),
129 rule(r"^\s*public\s+(?:final\s+)?interface\s+(\w+)", "interface"),
130 rule(
131 r"^\s*public\s+(?:static\s+)?(?:final\s+)?enum\s+(\w+)",
132 "enum",
133 ),
134 rule(
135 r"^\s*public\s+(?:static\s+|final\s+|abstract\s+|synchronized\s+)*[\w<>\[\],\s]+\s+(\w+)\s*\(",
136 "method",
137 ),
138 ],
139 },
140 ]
141}
142
143#[must_use]
148pub fn load_packs_from_dir(dir: &Path) -> Vec<LanguagePack> {
149 let Ok(entries) = std::fs::read_dir(dir) else {
150 return Vec::new();
151 };
152 let mut paths: Vec<std::path::PathBuf> = entries
153 .flatten()
154 .map(|e| e.path())
155 .filter(|p| p.extension().is_some_and(|x| x == "toml"))
156 .collect();
157 paths.sort();
158 let mut out = Vec::new();
159 for path in paths {
160 match std::fs::read_to_string(&path).map(|s| toml::from_str::<LanguagePack>(&s)) {
161 Ok(Ok(pack)) => out.push(pack),
162 Ok(Err(e)) => {
163 tracing::warn!(path = %path.display(), error = %e, "skipping malformed language pack");
164 }
165 Err(_) => {}
166 }
167 }
168 out
169}
170
171#[must_use]
176pub fn merge_packs(layers: Vec<Vec<LanguagePack>>) -> Vec<LanguagePack> {
177 let mut order: Vec<String> = Vec::new();
179 let mut by_name: std::collections::HashMap<String, LanguagePack> =
180 std::collections::HashMap::new();
181 for layer in layers {
182 for pack in layer {
183 if !by_name.contains_key(&pack.name) {
184 order.push(pack.name.clone());
185 }
186 by_name.insert(pack.name.clone(), pack);
187 }
188 }
189 order
190 .into_iter()
191 .filter_map(|n| by_name.remove(&n))
192 .collect()
193}
194
195struct CompiledPack {
198 extensions: Vec<String>,
199 entry_points: Vec<String>,
200 rules: Vec<(Regex, String)>,
201}
202
203fn compile(packs: Vec<LanguagePack>) -> Vec<CompiledPack> {
204 packs
205 .into_iter()
206 .map(|p| {
207 let rules = p
208 .symbols
209 .into_iter()
210 .filter_map(|r| match Regex::new(&r.pattern) {
211 Ok(re) => Some((re, r.kind)),
212 Err(e) => {
213 tracing::warn!(pack = %p.name, pattern = %r.pattern, error = %e, "skipping invalid symbol rule");
214 None
215 }
216 })
217 .collect();
218 CompiledPack {
219 extensions: p.extensions,
220 entry_points: p.entry_points,
221 rules,
222 }
223 })
224 .collect()
225}
226
227fn glob_match(pattern: &str, filename: &str) -> bool {
229 if pattern == "*" {
230 true
231 } else if let Some(suffix) = pattern.strip_prefix('*') {
232 filename.ends_with(suffix)
233 } else {
234 pattern == filename
235 }
236}
237
238pub struct ApiSurfaceProvider {
241 packs: Vec<CompiledPack>,
242 max_block_chars: usize,
243 max_symbols_per_file: usize,
244 block: Option<String>,
245}
246
247impl ApiSurfaceProvider {
248 #[must_use]
251 pub fn new(packs: Vec<LanguagePack>, cfg: &ApiSurfaceConfig) -> Self {
252 Self {
253 packs: compile(packs),
254 max_block_chars: cfg.max_block_chars,
255 max_symbols_per_file: cfg.max_symbols_per_file,
256 block: None,
257 }
258 }
259
260 #[must_use]
263 pub fn from_config(cfg: &ApiSurfaceConfig) -> Self {
264 let packs = merge_packs(vec![builtin_packs(), cfg.language_packs.clone()]);
265 Self::new(packs, cfg)
266 }
267
268 fn pack_for(&self, path: &str) -> Option<&CompiledPack> {
269 let ext = path.rsplit('.').next().unwrap_or("");
270 self.packs
271 .iter()
272 .find(|p| p.extensions.iter().any(|e| e == ext))
273 }
274
275 fn public_symbols(&self, path: &str, content: &str) -> Vec<(String, String)> {
276 let Some(pack) = self.pack_for(path) else {
277 return Vec::new();
278 };
279 let mut out = Vec::new();
280 for line in content.lines() {
281 for (re, kind) in &pack.rules {
282 if let Some(name) = re.captures(line).and_then(|c| c.get(1)) {
283 out.push((name.as_str().to_string(), kind.clone()));
284 break; }
286 }
287 }
288 out
289 }
290
291 fn is_entry_point(&self, path: &str) -> bool {
292 let Some(pack) = self.pack_for(path) else {
293 return false;
294 };
295 let filename = path.rsplit('/').next().unwrap_or(path);
296 pack.entry_points.iter().any(|g| glob_match(g, filename))
297 }
298
299 fn render(&self, files: &[(String, String)]) -> Option<String> {
302 let mut entries: Vec<(&str, Vec<(String, String)>)> = Vec::new();
303 for (path, content) in files {
304 let mut syms = self.public_symbols(path, content);
305 if syms.is_empty() {
306 continue;
307 }
308 syms.truncate(self.max_symbols_per_file);
309 entries.push((path.as_str(), syms));
310 }
311 if entries.is_empty() {
312 return None;
313 }
314 entries.sort_by(|(a, _), (b, _)| {
316 self.is_entry_point(b)
317 .cmp(&self.is_entry_point(a))
318 .then_with(|| a.cmp(b))
319 });
320 let mut out = String::from(
321 "[WORKSPACE API SURFACE — authoritative public symbols defined in this \
322 workspace. Use these EXACT names; do not invent APIs. read_file a path \
323 for signatures.]\n",
324 );
325 for (path, syms) in entries {
326 if out.len() >= self.max_block_chars {
327 out.push_str("- … (surface truncated to fit the budget)\n");
328 break;
329 }
330 let rendered: Vec<String> = syms
331 .iter()
332 .map(|(name, kind)| format!("{name} ({kind})"))
333 .collect();
334 out.push_str("- ");
335 out.push_str(path);
336 out.push_str(": ");
337 out.push_str(&rendered.join(", "));
338 out.push('\n');
339 }
340 Some(out)
341 }
342}
343
344#[async_trait]
345impl MemoryProvider for ApiSurfaceProvider {
346 fn name(&self) -> &str {
347 "api_surface"
348 }
349
350 async fn initialize(&mut self, ctx: &SessionContext) -> anyhow::Result<()> {
351 let extensions: Vec<String> = self
355 .packs
356 .iter()
357 .flat_map(|p| p.extensions.iter().cloned())
358 .collect();
359 let files: Vec<(String, String)> = crate::gather_code_files(&ctx.workspace, &extensions)
360 .into_iter()
361 .map(|(path, content)| {
362 let rel = path
363 .strip_prefix(&ctx.workspace)
364 .unwrap_or(&path)
365 .trim_start_matches('/')
366 .to_string();
367 (rel, content)
368 })
369 .collect();
370 self.block = self.render(&files);
371 if self.block.is_some() {
372 tracing::info!("knowledge_base: workspace API surface injected");
373 }
374 Ok(())
375 }
376
377 fn system_prompt_block(&self) -> Option<String> {
378 self.block.clone()
379 }
380
381 fn build_messages(&self, _system_prompt: &str, _new_task: &str) -> Vec<MemMessage> {
382 Vec::new()
383 }
384
385 async fn sync_turn(&mut self, _user: &str, _assistant: &str, _metrics: &TurnMetrics) {}
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 fn provider(packs: Vec<LanguagePack>) -> ApiSurfaceProvider {
393 ApiSurfaceProvider::new(packs, &ApiSurfaceConfig::default())
394 }
395
396 #[test]
397 fn builtins_cover_the_first_class_languages() {
398 let names: Vec<String> = builtin_packs().into_iter().map(|p| p.name).collect();
399 for lang in ["rust", "python", "bash", "c_cpp", "go", "java"] {
400 assert!(
401 names.contains(&lang.to_string()),
402 "missing built-in: {lang}"
403 );
404 }
405 }
406
407 #[test]
408 fn rust_pack_extracts_public_symbols_only() {
409 let p = provider(builtin_packs());
410 let syms = p.public_symbols(
411 "x.rs",
412 "pub fn open() {}\npub struct Router;\npub(crate) fn hidden() {}\nfn private() {}",
413 );
414 let names: Vec<&str> = syms.iter().map(|(n, _)| n.as_str()).collect();
415 assert!(names.contains(&"open") && names.contains(&"Router"));
416 assert!(!names.contains(&"hidden") && !names.contains(&"private"));
417 }
418
419 #[test]
420 fn free_form_kinds_are_not_locked_to_rust() {
421 let p = provider(builtin_packs());
422 let go = p.public_symbols(
424 "m.go",
425 "func Serve() {}\ntype Server struct {}\nfunc unexported() {}",
426 );
427 assert!(go.contains(&("Serve".into(), "func".into())));
428 assert!(go.contains(&("Server".into(), "struct".into())));
429 assert!(
430 !go.iter().any(|(n, _)| n == "unexported"),
431 "lowercase = unexported"
432 );
433 let java = p.public_symbols("M.java", " public static int run(String a) {");
435 assert!(java.iter().any(|(n, k)| n == "run" && k == "method"));
436 }
437
438 #[test]
439 fn c_headers_are_entry_points_via_glob() {
440 let p = provider(builtin_packs());
441 assert!(p.is_entry_point("src/foo.h"));
442 assert!(p.is_entry_point("lib/api.hpp"));
443 assert!(
444 !p.is_entry_point("src/foo.c"),
445 "a .c source is not an entry point"
446 );
447 }
448
449 #[test]
450 fn a_custom_pack_adds_a_language_with_no_code_change() {
451 let ruby = LanguagePack {
453 name: "ruby".into(),
454 extensions: vec!["rb".into()],
455 entry_points: vec!["*.rb".into()],
456 symbols: vec![
457 rule(r"^\s*def\s+([a-z_]\w*)", "method"),
458 rule(r"^\s*class\s+(\w+)", "class"),
459 ],
460 };
461 let p = provider(merge_packs(vec![builtin_packs(), vec![ruby]]));
462 let syms = p.public_symbols("app.rb", "class Widget\n def render\n end\nend");
463 assert!(syms.contains(&("Widget".into(), "class".into())));
464 assert!(syms.contains(&("render".into(), "method".into())));
465 }
466
467 #[test]
468 fn a_custom_pack_overrides_a_builtin_by_name() {
469 let custom_rust = LanguagePack {
471 name: "rust".into(),
472 extensions: vec!["rs".into()],
473 entry_points: vec![],
474 symbols: vec![rule(r"^\s*fn\s+(\w+)", "fn")],
475 };
476 let merged = merge_packs(vec![builtin_packs(), vec![custom_rust]]);
477 let rust = merged.iter().find(|p| p.name == "rust").unwrap();
478 assert_eq!(
479 rust.symbols.len(),
480 1,
481 "the custom pack replaced the built-in rust"
482 );
483 }
484
485 #[test]
486 fn render_orders_entry_points_first() {
487 let p = provider(builtin_packs());
488 let files = vec![
489 (
490 "src/router.rs".to_string(),
491 "pub struct Router;".to_string(),
492 ),
493 ("src/lib.rs".to_string(), "pub fn boot() {}".to_string()),
494 ];
495 let block = p.render(&files).expect("a surface");
496 assert!(block.find("lib.rs").unwrap() < block.find("router.rs").unwrap());
498 assert!(block.contains("Router (struct)") && block.contains("boot (fn)"));
499 }
500
501 #[test]
502 fn render_is_bounded() {
503 let p = provider(builtin_packs());
504 let files: Vec<(String, String)> = (0..500)
505 .map(|i| (format!("m{i:03}.rs"), format!("pub fn f{i}() {{}}")))
506 .collect();
507 let block = p.render(&files).expect("a surface");
508 assert!(
509 block.len() <= ApiSurfaceConfig::default().max_block_chars + 100,
510 "bounded: {} chars",
511 block.len()
512 );
513 assert!(block.contains("surface truncated"), "names the truncation");
514 }
515
516 #[test]
517 fn render_is_a_noop_when_nothing_public() {
518 let p = provider(builtin_packs());
519 assert!(p
520 .render(&[("a.rs".into(), "fn private() {}".into())])
521 .is_none());
522 assert!(p.render(&[("README.md".into(), "# docs".into())]).is_none());
523 }
524
525 #[test]
526 fn invalid_regex_in_a_rule_is_skipped_not_fatal() {
527 let bad = LanguagePack {
528 name: "bad".into(),
529 extensions: vec!["zz".into()],
530 entry_points: vec![],
531 symbols: vec![rule(r"(unclosed", "x"), rule(r"^ok\s+(\w+)", "fn")],
532 };
533 let p = provider(vec![bad]);
534 assert_eq!(
536 p.public_symbols("f.zz", "ok thing"),
537 vec![("thing".to_string(), "fn".to_string())]
538 );
539 }
540
541 #[test]
542 fn shipped_example_packs_stay_valid() {
543 for src in [
546 include_str!("../../examples/language-packs/ruby.toml"),
547 include_str!("../../examples/language-packs/TEMPLATE.toml"),
548 ] {
549 let pack: LanguagePack = toml::from_str(src).expect("example pack must parse");
550 assert!(!pack.name.is_empty());
551 assert!(!pack.extensions.is_empty());
552 assert!(!pack.symbols.is_empty());
553 for r in &pack.symbols {
555 assert!(
556 Regex::new(&r.pattern).is_ok(),
557 "bad regex in {}: {}",
558 pack.name,
559 r.pattern
560 );
561 }
562 }
563 }
564}