Skip to main content

newt_core/
api_surface.rs

1//! The `knowledge_base` workspace **API-surface** technique (#669), with a
2//! **pluggable language-pack** model.
3//!
4//! A language pack is pure DATA ([`crate::config::LanguagePack`]): file
5//! extensions, entry-point file globs, and regex symbol-extraction rules with
6//! free-form kind labels. So adding a language is *config, not code*.
7//!
8//! - **Built-in packs** (first-class): Rust, Python, Bash, C/C++, Go, Java —
9//!   see [`builtin_packs`]. They double as the canonical examples.
10//! - **External packs**: drop a `<name>.toml` into `~/.newt/language-packs/`
11//!   (global) or `.newt/language-packs/` (project-local), or inline under
12//!   `[[context.api_surface.language_packs]]`. Packs merge **by `name`**, so a
13//!   community pack (Ruby, Swift, Objective-C, …) adds or overrides without ever
14//!   touching the binary. See `docs/language-packs.md` + `examples/language-packs/`.
15//!
16//! The rendered surface rides the **frozen system prompt** — the compressor's
17//! protected head ([`super::agentic::compress`] `head_len`) — so it is a stable
18//! base that is never summarized (#661 group E).
19//!
20//! ## Extraction engine: regex is the BOOTSTRAP; AST (tree-sitter) is the target
21//!
22//! Per-line regex rules are a deliberate bootstrap — fragile for real code
23//! (multi-line declarations, macros, generics, attributes). The **right** engine
24//! is an AST parser (tree-sitter): a pack becomes a *grammar + a tree-sitter
25//! query*, and the query — like the regex rules here — is pluggable DATA. The
26//! architecture is chosen so that swap is local: `extensions` / `entry_points` /
27//! merge-by-name / drop-in loading are all engine-agnostic; only the per-pack
28//! extraction rules change shape (regex → query). Tracked as a follow-up.
29
30use 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/// The first-class built-in language packs (Rust, Python, Bash, C/C++, Go, Java).
46/// These also serve as the worked examples a contributor copies — `newt
47/// language-pack template <lang>` emits one to a drop-in file. Best-effort regexes
48/// (a surface, not a parser); override any of them by name with your own pack.
49#[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            // Module-level (column 0), public (non-`_`) names.
72            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            // Headers are the public surface — list them first.
99            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                // Rough free-function declaration: `<type> name(`.
105                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            // Go exports = capitalized identifiers.
113            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/// Load language packs from a drop-in directory (`<dir>/*.toml`, one pack per
144/// file). A malformed pack file is **skipped with a warning**, never fatal — the
145/// same "drop-in, tolerant" contract as the `[backends]` directory. A missing
146/// directory yields `[]`.
147#[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/// Merge pack layers **by name** — later layers win (built-ins < global dir <
172/// project dir < inline config). A custom pack named `rust` replaces the built-in
173/// `rust`; a new name adds a language. Output order is stable (insertion order of
174/// first appearance, then last value wins).
175#[must_use]
176pub fn merge_packs(layers: Vec<Vec<LanguagePack>>) -> Vec<LanguagePack> {
177    // Preserve first-seen order while letting later layers overwrite the value.
178    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
195/// A [`LanguagePack`] with its symbol rules compiled. Invalid regexes are dropped
196/// (with a warning) so one bad rule can't disable the surface.
197struct 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
227/// Tiny filename glob: `*` (any), `*.ext` (suffix), else exact match.
228fn 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
238/// Injects the workspace's public API surface into the system prompt, driven by a
239/// resolved set of language packs.
240pub 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    /// Construct from already-resolved packs (built-ins + drop-in dirs + inline,
249    /// merged by the caller via [`merge_packs`]) and the surface budget.
250    #[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    /// Convenience for the common case: built-in packs + inline config packs (no
261    /// drop-in dirs). The TUI uses [`new`](Self::new) with the dir layers added.
262    #[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; // first matching rule wins for a line
285                }
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    /// Render a bounded surface from already-gathered `(path, content)` files.
300    /// Pure (no filesystem) so ordering + bounding + extraction are unit-testable.
301    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        // Entry-point files first, then alphabetical — deterministic.
315        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        // `gather_code_files` is the only fs touch; rendering is pure (tested).
352        // Read files for EVERY extension the resolved packs declare (#956), not a
353        // hardcoded rs/py — so bash/c_cpp/go/java and drop-in packs are surfaced.
354        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        // Go: exported func + type → kinds "func"/"struct", not Rust's "fn".
423        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        // Java: a public method → kind "method".
434        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        // Simulate ingesting an external Ruby pack (what a drop-in file would do).
452        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        // A pack named "rust" replaces the built-in (here: also surface `fn` privates).
470        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        // lib.rs is an entry point; router.rs is not → lib.rs is listed first.
497        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        // The good rule still works; the bad one was dropped without panicking.
535        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        // include_str! reads at compile time (no runtime fs) — guards the
544        // contributor template + example against rot.
545        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            // Every rule's regex compiles (so a copy-paste starting point works).
554            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}