Skip to main content

waggle_lens_code/
outline.rs

1//! The symbol outline: a flat, document-ordered arena (doc `20 §5.2`) and
2//! its struct-of-arrays wire form. ~tens of bytes per symbol, one shared
3//! name buffer, parent links by index — nesting without pointers, binary
4//! search by line, prefix-sliceable rendering under a byte budget.
5
6use serde::{Deserialize, Serialize};
7
8/// Wire version — pins the `(bytes, extractor) → outline` function so a
9/// future extractor bump mints new outlines without ambiguity (doc 20 §3).
10pub const WIRE_VERSION: u16 = 1;
11
12/// Sentinel parent index for top-level symbols.
13pub(crate) const ROOT: u32 = u32::MAX;
14
15/// One definition: name by (offset, len) into the shared buffer, kind by
16/// index into the legend, 1-based inclusive lines (the same shape contract
17/// `Region`s use), parent by arena index.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub(crate) struct Sym {
20    pub name: (u32, u16),
21    pub kind: u8,
22    pub start_line: u32,
23    pub end_line: u32,
24    pub parent: u32,
25    pub depth: u8,
26}
27
28/// The arena. Construction happens in [`crate::extract`]; consumers get
29/// lookups and the wire form.
30#[derive(Debug, Default, Clone, PartialEq, Eq)]
31pub struct SymbolOutline {
32    pub(crate) syms: Vec<Sym>,
33    pub(crate) names: String,
34    /// Kind legend, indexed by `Sym::kind` — the `tags.scm` capture
35    /// suffixes ("function", "method", "class", …), deduplicated.
36    pub(crate) kinds: Vec<String>,
37}
38
39impl SymbolOutline {
40    /// Number of definitions.
41    #[must_use]
42    pub fn len(&self) -> usize {
43        self.syms.len()
44    }
45
46    /// True when nothing was extracted (callers store no blob then).
47    #[must_use]
48    pub fn is_empty(&self) -> bool {
49        self.syms.is_empty()
50    }
51
52    pub(crate) fn name_of(&self, s: &Sym) -> &str {
53        let (off, len) = s.name;
54        &self.names[off as usize..off as usize + len as usize]
55    }
56
57    /// All definitions with this exact name, as arena indices in
58    /// document order — `symbol:` contract resolution walks this.
59    #[must_use]
60    pub fn find(&self, name: &str) -> Vec<usize> {
61        self.syms
62            .iter()
63            .enumerate()
64            .filter(|(_, s)| self.name_of(s) == name)
65            .map(|(i, _)| i)
66            .collect()
67    }
68
69    /// The name at an arena index.
70    #[must_use]
71    pub fn name_at(&self, idx: usize) -> Option<&str> {
72        self.syms.get(idx).map(|s| self.name_of(s))
73    }
74
75    /// The `(start_line, end_line, kind)` of an arena index.
76    #[must_use]
77    pub fn lines_of(&self, idx: usize) -> Option<(u32, u32, &str)> {
78        let s = self.syms.get(idx)?;
79        Some((
80            s.start_line,
81            s.end_line,
82            self.kinds.get(s.kind as usize).map_or("", String::as_str),
83        ))
84    }
85
86    /// Serialize to the wire form (struct-of-arrays JSON).
87    ///
88    /// # Panics
89    /// Never in practice: the wire is plain data with no fallible
90    /// serialization path; the `expect` documents the invariant.
91    #[must_use]
92    pub fn to_wire(&self) -> Vec<u8> {
93        let w = Wire {
94            x: WIRE_VERSION,
95            kinds: self.kinds.clone(),
96            names: self
97                .syms
98                .iter()
99                .map(|s| self.name_of(s).to_owned())
100                .collect(),
101            kind: self.syms.iter().map(|s| s.kind).collect(),
102            start: self.syms.iter().map(|s| s.start_line).collect(),
103            end: self.syms.iter().map(|s| s.end_line).collect(),
104            depth: self.syms.iter().map(|s| s.depth).collect(),
105        };
106        serde_json::to_vec(&w).expect("plain data always serializes")
107    }
108}
109
110/// The wire shape: parallel arrays so the serve side (which lives in
111/// `waggle-mcp`, wasm-safe — this crate never reaches the edge) can
112/// slice prefixes without touching entries it will drop (doc `20 §4`).
113/// Parent links are not carried — depth suffices for rendering, and
114/// contracts resolve to line ranges at mint.
115#[derive(Serialize, Deserialize)]
116struct Wire {
117    x: u16,
118    kinds: Vec<String>,
119    names: Vec<String>,
120    kind: Vec<u8>,
121    start: Vec<u32>,
122    end: Vec<u32>,
123    depth: Vec<u8>,
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    fn outline(n: usize) -> SymbolOutline {
131        let mut o = SymbolOutline {
132            kinds: vec!["function".into(), "class".into()],
133            ..SymbolOutline::default()
134        };
135        for i in 0..n {
136            let name = format!("sym_{i}");
137            let off = u32::try_from(o.names.len()).unwrap();
138            o.names.push_str(&name);
139            o.syms.push(Sym {
140                name: (off, u16::try_from(name.len()).unwrap()),
141                kind: u8::try_from(i % 2).unwrap(),
142                start_line: u32::try_from(i * 10 + 1).unwrap(),
143                end_line: u32::try_from(i * 10 + 8).unwrap(),
144                parent: ROOT,
145                depth: u8::try_from(i % 3).unwrap(),
146            });
147        }
148        o
149    }
150
151    #[test]
152    fn wire_is_parallel_arrays_with_the_version_pin() {
153        let o = outline(7);
154        let v: serde_json::Value = serde_json::from_slice(&o.to_wire()).unwrap();
155        assert_eq!(v["x"], WIRE_VERSION);
156        for field in ["names", "kind", "start", "end", "depth"] {
157            assert_eq!(v[field].as_array().unwrap().len(), 7, "{field}");
158        }
159        assert_eq!(v["names"][3], "sym_3");
160        assert_eq!(v["start"][3], 31);
161        assert_eq!(v["kinds"][0], "function");
162    }
163
164    #[test]
165    fn arena_lookups() {
166        let o = outline(7);
167        assert_eq!(o.find("sym_3"), vec![3]);
168        let (start, end, kind) = o.lines_of(3).unwrap();
169        assert_eq!((start, end), (31, 38));
170        assert_eq!(kind, "class");
171        assert!(o.find("nope").is_empty());
172    }
173}