Skip to main content

bynkc_lsp/
document_symbols.rs

1//! Document-symbol tree for the LSP `textDocument/documentSymbol` request
2//! (v1.1; LSP spec ยง3.7).
3//!
4//! Walks a single file's parsed AST and emits a hierarchical
5//! [`DocumentSymbol`] tree that populates VS Code's Outline pane and
6//! powers "Go to Symbol in File" (Cmd-Shift-O). Multi-file commons /
7//! contexts each report only their own file's contents โ€” joining across
8//! files is `workspaceSymbol` territory, which is deferred.
9
10use bynk_syntax::ast::*;
11use bynk_syntax::lexer::tokenize;
12use bynk_syntax::parser::parse_unit_with_recovery;
13use tower_lsp::lsp_types::{DocumentSymbol, Range, SymbolKind};
14
15use crate::position::span_to_range;
16
17/// Build the document-symbol tree for the given source text. Returns an
18/// empty vector when the file cannot be parsed at all (no recognisable
19/// header).
20pub fn outline(source: &str) -> Vec<DocumentSymbol> {
21    let Ok(tokens) = tokenize(source) else {
22        return Vec::new();
23    };
24    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
25    let Some(unit) = unit else {
26        return Vec::new();
27    };
28    match unit {
29        SourceUnit::Commons(c) => vec![commons_symbol(source, &c)],
30        SourceUnit::Context(c) => vec![context_symbol(source, &c)],
31        SourceUnit::Suite(t) => vec![test_symbol(source, &t)],
32        SourceUnit::Integration(i) => vec![integration_symbol(source, &i)],
33        SourceUnit::Adapter(a) => vec![adapter_symbol(source, &a)],
34    }
35}
36
37fn adapter_symbol(source: &str, a: &AdapterDecl) -> DocumentSymbol {
38    let children: Vec<DocumentSymbol> = a
39        .items
40        .iter()
41        .map(|item| item_symbol(source, item))
42        .collect();
43    make_symbol(
44        a.name.joined(),
45        detail_from_doc(&a.documentation),
46        SymbolKind::MODULE,
47        span_to_range(source, a.span),
48        span_to_range(source, a.name.span),
49        children,
50    )
51}
52
53fn integration_symbol(source: &str, i: &IntegrationDecl) -> DocumentSymbol {
54    let mut children: Vec<DocumentSymbol> = Vec::new();
55    for c in &i.cases {
56        children.push(make_symbol(
57            c.name.clone(),
58            None,
59            SymbolKind::FUNCTION,
60            span_to_range(source, c.span),
61            span_to_range(source, c.name_span),
62            Vec::new(),
63        ));
64    }
65    make_symbol(
66        format!("test integration \"{}\"", i.suite),
67        detail_from_doc(&i.documentation),
68        SymbolKind::MODULE,
69        span_to_range(source, i.span),
70        span_to_range(source, i.suite_span),
71        children,
72    )
73}
74
75fn test_symbol(source: &str, t: &SuiteDecl) -> DocumentSymbol {
76    let mut children: Vec<DocumentSymbol> = Vec::new();
77    for m in &t.mocks {
78        children.push(make_symbol(
79            format!("mocks {} = {}", m.target_name.name, m.impl_name.name),
80            None,
81            SymbolKind::INTERFACE,
82            span_to_range(source, m.span),
83            span_to_range(source, m.target_name.span),
84            Vec::new(),
85        ));
86    }
87    for c in &t.cases {
88        children.push(make_symbol(
89            c.name.clone(),
90            None,
91            SymbolKind::FUNCTION,
92            span_to_range(source, c.span),
93            span_to_range(source, c.name_span),
94            Vec::new(),
95        ));
96    }
97    make_symbol(
98        format!("test {}", t.target.joined()),
99        detail_from_doc(&t.documentation),
100        SymbolKind::MODULE,
101        span_to_range(source, t.span),
102        span_to_range(source, t.target.span),
103        children,
104    )
105}
106
107fn commons_symbol(source: &str, c: &Commons) -> DocumentSymbol {
108    let children: Vec<DocumentSymbol> = c
109        .items
110        .iter()
111        .map(|item| item_symbol(source, item))
112        .collect();
113    make_symbol(
114        c.name.joined(),
115        detail_from_doc(&c.documentation),
116        SymbolKind::MODULE,
117        span_to_range(source, c.span),
118        span_to_range(source, c.name.span),
119        children,
120    )
121}
122
123fn context_symbol(source: &str, c: &Context) -> DocumentSymbol {
124    let children: Vec<DocumentSymbol> = c
125        .items
126        .iter()
127        .map(|item| item_symbol(source, item))
128        .collect();
129    make_symbol(
130        c.name.joined(),
131        detail_from_doc(&c.documentation),
132        SymbolKind::MODULE,
133        span_to_range(source, c.span),
134        span_to_range(source, c.name.span),
135        children,
136    )
137}
138
139fn item_symbol(source: &str, item: &CommonsItem) -> DocumentSymbol {
140    match item {
141        CommonsItem::Type(t) => type_symbol(source, t),
142        CommonsItem::Fn(f) => fn_symbol(source, f),
143        CommonsItem::Capability(c) => capability_symbol(source, c),
144        CommonsItem::Provider(p) => provider_symbol(source, p),
145        CommonsItem::Service(s) => service_symbol(source, s),
146        CommonsItem::Agent(a) => agent_symbol(source, a),
147        CommonsItem::Actor(a) => actor_symbol(source, a),
148    }
149}
150
151fn actor_symbol(source: &str, a: &ActorDecl) -> DocumentSymbol {
152    make_symbol(
153        a.name.name.clone(),
154        detail_from_doc(&a.documentation),
155        SymbolKind::INTERFACE,
156        span_to_range(source, a.span),
157        span_to_range(source, a.name.span),
158        Vec::new(),
159    )
160}
161
162fn type_symbol(source: &str, t: &TypeDecl) -> DocumentSymbol {
163    let (kind, children) = match &t.body {
164        TypeBody::Record(r) => (SymbolKind::STRUCT, record_field_symbols(source, &r.fields)),
165        TypeBody::Sum(s) => (SymbolKind::ENUM, variant_symbols(source, &s.variants)),
166        TypeBody::Opaque { .. } => (SymbolKind::CLASS, Vec::new()),
167        TypeBody::Refined { .. } => (SymbolKind::TYPE_PARAMETER, Vec::new()),
168    };
169    make_symbol(
170        t.name.name.clone(),
171        detail_from_doc(&t.documentation),
172        kind,
173        span_to_range(source, t.span),
174        span_to_range(source, t.name.span),
175        children,
176    )
177}
178
179fn record_field_symbols(source: &str, fields: &[RecordField]) -> Vec<DocumentSymbol> {
180    fields
181        .iter()
182        .map(|f| {
183            make_symbol(
184                f.name.name.clone(),
185                None,
186                SymbolKind::FIELD,
187                span_to_range(source, f.span),
188                span_to_range(source, f.name.span),
189                Vec::new(),
190            )
191        })
192        .collect()
193}
194
195fn variant_symbols(source: &str, variants: &[Variant]) -> Vec<DocumentSymbol> {
196    variants
197        .iter()
198        .map(|v| {
199            make_symbol(
200                v.name.name.clone(),
201                None,
202                SymbolKind::ENUM_MEMBER,
203                span_to_range(source, v.span),
204                span_to_range(source, v.name.span),
205                Vec::new(),
206            )
207        })
208        .collect()
209}
210
211fn fn_symbol(source: &str, f: &FnDecl) -> DocumentSymbol {
212    // Free functions are top-level Function symbols. Methods (whose
213    // owning type lives in the same file) would normally nest under that
214    // type, but the type-decl symbol is built independently โ€” see the
215    // commons/context walk. For v1.1, surface methods as top-level
216    // siblings with a "TypeName.method" name; nesting can be added once
217    // the walker reorders items.
218    let kind = match &f.name {
219        FnName::Free(_) => SymbolKind::FUNCTION,
220        FnName::Method { .. } => SymbolKind::METHOD,
221    };
222    make_symbol(
223        f.name.display(),
224        detail_from_doc(&f.documentation),
225        kind,
226        span_to_range(source, f.span),
227        span_to_range(source, f.name.ident().span),
228        Vec::new(),
229    )
230}
231
232fn capability_symbol(source: &str, c: &CapabilityDecl) -> DocumentSymbol {
233    let children = c
234        .ops
235        .iter()
236        .map(|op| {
237            make_symbol(
238                op.name.name.clone(),
239                detail_from_doc(&op.documentation),
240                SymbolKind::METHOD,
241                span_to_range(source, op.span),
242                span_to_range(source, op.name.span),
243                Vec::new(),
244            )
245        })
246        .collect();
247    make_symbol(
248        c.name.name.clone(),
249        detail_from_doc(&c.documentation),
250        SymbolKind::INTERFACE,
251        span_to_range(source, c.span),
252        span_to_range(source, c.name.span),
253        children,
254    )
255}
256
257fn provider_symbol(source: &str, p: &ProviderDecl) -> DocumentSymbol {
258    let children = p
259        .ops
260        .iter()
261        .map(|op| {
262            make_symbol(
263                op.name.name.clone(),
264                None,
265                SymbolKind::METHOD,
266                span_to_range(source, op.span),
267                span_to_range(source, op.name.span),
268                Vec::new(),
269            )
270        })
271        .collect();
272    // The display name shows both the capability and provider names so
273    // the outline disambiguates multiple `provides X = ...` blocks.
274    let name = format!("{} = {}", p.capability.name, p.provider_name.name);
275    make_symbol(
276        name,
277        detail_from_doc(&p.documentation),
278        SymbolKind::OBJECT,
279        span_to_range(source, p.span),
280        span_to_range(source, p.provider_name.span),
281        children,
282    )
283}
284
285fn service_symbol(source: &str, s: &ServiceDecl) -> DocumentSymbol {
286    let children = s
287        .handlers
288        .iter()
289        .map(|h| handler_symbol(source, h))
290        .collect();
291    make_symbol(
292        s.name.name.clone(),
293        detail_from_doc(&s.documentation),
294        SymbolKind::CLASS,
295        span_to_range(source, s.span),
296        span_to_range(source, s.name.span),
297        children,
298    )
299}
300
301fn agent_symbol(source: &str, a: &AgentDecl) -> DocumentSymbol {
302    let mut children = Vec::new();
303    // Key field โ€” surface as a Property.
304    children.push(make_symbol(
305        a.key_name.name.clone(),
306        Some("key".into()),
307        SymbolKind::PROPERTY,
308        span_to_range(source, a.key_name.span),
309        span_to_range(source, a.key_name.span),
310        Vec::new(),
311    ));
312    // Store fields.
313    for field in &a.store_fields {
314        children.push(make_symbol(
315            field.name.name.clone(),
316            Some("store".into()),
317            SymbolKind::PROPERTY,
318            span_to_range(source, field.span),
319            span_to_range(source, field.name.span),
320            Vec::new(),
321        ));
322    }
323    // Handlers.
324    for h in &a.handlers {
325        children.push(handler_symbol(source, h));
326    }
327    make_symbol(
328        a.name.name.clone(),
329        detail_from_doc(&a.documentation),
330        SymbolKind::CLASS,
331        span_to_range(source, a.span),
332        span_to_range(source, a.name.span),
333        children,
334    )
335}
336
337fn handler_symbol(source: &str, h: &Handler) -> DocumentSymbol {
338    let name = match &h.method_name {
339        Some(m) => format!("call {}", m.name),
340        None => "call".to_string(),
341    };
342    let selection_span = h.method_name.as_ref().map(|m| m.span).unwrap_or(h.span);
343    make_symbol(
344        name,
345        detail_from_doc(&h.documentation),
346        SymbolKind::METHOD,
347        span_to_range(source, h.span),
348        span_to_range(source, selection_span),
349        Vec::new(),
350    )
351}
352
353fn detail_from_doc(doc: &Option<String>) -> Option<String> {
354    doc.as_ref().and_then(|d| {
355        let first = d
356            .lines()
357            .map(str::trim)
358            .find(|l| !l.is_empty())?
359            .to_string();
360        Some(first)
361    })
362}
363
364#[allow(deprecated)] // `deprecated` and `tags` fields exist on DocumentSymbol.
365fn make_symbol(
366    name: String,
367    detail: Option<String>,
368    kind: SymbolKind,
369    range: Range,
370    selection_range: Range,
371    children: Vec<DocumentSymbol>,
372) -> DocumentSymbol {
373    DocumentSymbol {
374        name,
375        detail,
376        kind,
377        tags: None,
378        deprecated: None,
379        range,
380        selection_range,
381        children: if children.is_empty() {
382            None
383        } else {
384            Some(children)
385        },
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    fn outline_of(src: &str) -> Vec<DocumentSymbol> {
394        outline(src)
395    }
396
397    #[test]
398    fn returns_empty_for_empty_input() {
399        assert!(outline_of("").is_empty());
400    }
401
402    #[test]
403    fn commons_with_types_and_fns_produces_module_with_children() {
404        let src = "commons demo.x {\n\
405                   type Money = Int where NonNegative\n\
406                   fn double(n: Int) -> Int { n + n }\n\
407                   }";
408        let syms = outline_of(src);
409        assert_eq!(syms.len(), 1);
410        let module = &syms[0];
411        assert_eq!(module.kind, SymbolKind::MODULE);
412        assert_eq!(module.name, "demo.x");
413        let children = module.children.as_ref().expect("children");
414        assert_eq!(children.len(), 2);
415        assert_eq!(children[0].name, "Money");
416        assert_eq!(children[0].kind, SymbolKind::TYPE_PARAMETER);
417        assert_eq!(children[1].name, "double");
418        assert_eq!(children[1].kind, SymbolKind::FUNCTION);
419    }
420
421    #[test]
422    fn record_fields_nest_under_record_type() {
423        let src = "commons demo.x {\n\
424                   type Pt = { x: Int, y: Int }\n\
425                   }";
426        let syms = outline_of(src);
427        let module = &syms[0];
428        let children = module.children.as_ref().unwrap();
429        let pt = &children[0];
430        assert_eq!(pt.kind, SymbolKind::STRUCT);
431        let fields = pt.children.as_ref().expect("record fields");
432        assert_eq!(fields.len(), 2);
433        assert_eq!(fields[0].name, "x");
434        assert_eq!(fields[0].kind, SymbolKind::FIELD);
435        assert_eq!(fields[1].name, "y");
436    }
437
438    #[test]
439    fn sum_variants_nest_under_enum() {
440        let src = "commons demo.x {\n\
441                   type Tag = enum { Foo, Bar, Baz }\n\
442                   }";
443        let syms = outline_of(src);
444        let module = &syms[0];
445        let tag = &module.children.as_ref().unwrap()[0];
446        assert_eq!(tag.kind, SymbolKind::ENUM);
447        let variants = tag.children.as_ref().expect("variants");
448        assert_eq!(variants.len(), 3);
449        assert_eq!(variants[0].kind, SymbolKind::ENUM_MEMBER);
450        assert_eq!(variants[2].name, "Baz");
451    }
452
453    #[test]
454    fn opaque_type_uses_class_kind() {
455        let src = "commons demo.x {\n\
456                   type Id = opaque Int where NonNegative\n\
457                   }";
458        let syms = outline_of(src);
459        let id = &syms[0].children.as_ref().unwrap()[0];
460        assert_eq!(id.kind, SymbolKind::CLASS);
461    }
462
463    #[test]
464    fn context_with_service_and_agent_produces_hierarchical_tree() {
465        let src = "context demo.app {\n\
466                   capability Clock { fn now() -> Int }\n\
467                   service Api {\n\
468                   on call(amount: Int) -> Int given Clock { amount }\n\
469                   }\n\
470                   agent Counter {\n\
471                   key id: Int\n\
472                   store value: Cell[Int]\n\
473                   on call bump(amount: Int) -> Int { 0 }\n\
474                   }\n\
475                   }";
476        let syms = outline_of(src);
477        let module = &syms[0];
478        assert_eq!(module.name, "demo.app");
479        let children = module.children.as_ref().unwrap();
480        // capability + service + agent
481        let kinds: Vec<SymbolKind> = children.iter().map(|c| c.kind).collect();
482        assert!(kinds.contains(&SymbolKind::INTERFACE));
483        assert!(kinds.contains(&SymbolKind::CLASS));
484        let service = children
485            .iter()
486            .find(|c| c.name == "Api")
487            .expect("Api service");
488        let service_children = service.children.as_ref().unwrap();
489        assert_eq!(service_children.len(), 1);
490        assert_eq!(service_children[0].kind, SymbolKind::METHOD);
491        let agent = children
492            .iter()
493            .find(|c| c.name == "Counter")
494            .expect("Counter agent");
495        let agent_children = agent.children.as_ref().unwrap();
496        // key + store field + handler = 3 children
497        assert_eq!(agent_children.len(), 3);
498        assert!(
499            agent_children
500                .iter()
501                .any(|c| c.kind == SymbolKind::PROPERTY && c.name == "value")
502        );
503        assert!(
504            agent_children
505                .iter()
506                .any(|c| c.kind == SymbolKind::METHOD && c.name == "call bump")
507        );
508    }
509
510    #[test]
511    fn adapter_unit_outlines_its_items() {
512        let src = "adapter tokens {\n\
513                   binding \"./tokens.binding.ts\"\n\
514                   exports capability { Jwt }\n\
515                   capability Jwt {\n\
516                   fn sign(secret: String) -> Effect[String]\n\
517                   }\n\
518                   provides Jwt = JoseJwt\n\
519                   }";
520        let syms = outline_of(src);
521        assert_eq!(syms.len(), 1);
522        assert_eq!(syms[0].name, "tokens");
523        let children = syms[0].children.as_ref().unwrap();
524        // The capability and the external provider both appear in the outline.
525        assert!(children.iter().any(|c| c.name == "Jwt"));
526        assert!(children.iter().any(|c| c.name == "Jwt = JoseJwt"));
527    }
528
529    /// Every symbol's `selection_range` must be contained in its `range`, or
530    /// VS Code rejects the whole `documentSymbol` response
531    /// ("selectionRange must be contained in fullRange"). Verify recursively.
532    fn assert_selection_contained(sym: &DocumentSymbol, path: &str) {
533        let here = format!("{path}/{}", sym.name);
534        let outer = sym.range;
535        let inner = sym.selection_range;
536        let pos_le = |a: Position, b: Position| (a.line, a.character) <= (b.line, b.character);
537        assert!(
538            pos_le(outer.start, inner.start) && pos_le(inner.end, outer.end),
539            "selection_range not contained in range for {here}: range={outer:?} sel={inner:?}",
540        );
541        for c in sym.children.iter().flatten() {
542            assert_selection_contained(c, &here);
543        }
544    }
545
546    /// Regression: an `invariant` after a handler is a hard parse error, so
547    /// recovery drops the agent and leaves the fragment-form context with no
548    /// items. The context span must still cover its header (`context demo.a`)
549    /// so the name-span selection range stays contained. (negative fixture 238)
550    #[test]
551    fn fragment_context_with_all_items_dropped_keeps_valid_ranges() {
552        let src = "context demo.a\n\
553                   \n\
554                   agent Counter {\n\
555                   key id: String\n\
556                   store count: Cell[Int]\n\
557                   on call bump() -> Effect[()] {\n\
558                   let cur = count\n\
559                   count := cur + 1\n\
560                   ()\n\
561                   }\n\
562                   invariant bad:\n\
563                   count >= 0\n\
564                   }\n";
565        for sym in &outline(src) {
566            assert_selection_contained(sym, "");
567        }
568    }
569
570    use tower_lsp::lsp_types::Position;
571
572    #[test]
573    fn doc_block_first_line_appears_as_detail() {
574        let src = "commons demo.x {\n\
575                   ---\n\
576                   Short one-liner.\n\
577                   Second line.\n\
578                   ---\n\
579                   type T = Int where Positive\n\
580                   }";
581        let syms = outline_of(src);
582        let t = &syms[0].children.as_ref().unwrap()[0];
583        assert_eq!(t.detail.as_deref(), Some("Short one-liner."));
584    }
585}