lex_analysis/
document_symbols.rs

1use lex_core::lex::ast::{
2    Annotation, AstNode, ContentItem, Definition, Document, List, ListItem, Paragraph, Range,
3    Session, TextContent, Verbatim,
4};
5use lsp_types::SymbolKind;
6
7#[derive(Debug, Clone, PartialEq)]
8pub struct LexDocumentSymbol {
9    pub name: String,
10    pub detail: Option<String>,
11    pub kind: SymbolKind,
12    pub range: Range,
13    pub selection_range: Range,
14    pub children: Vec<LexDocumentSymbol>,
15}
16
17pub fn collect_document_symbols(document: &Document) -> Vec<LexDocumentSymbol> {
18    let mut symbols: Vec<LexDocumentSymbol> = document
19        .annotations()
20        .iter()
21        .map(annotation_symbol)
22        .collect();
23    symbols.extend(session_symbols(&document.root, true));
24    symbols
25}
26
27fn session_symbols(session: &Session, is_root: bool) -> Vec<LexDocumentSymbol> {
28    let mut symbols = Vec::new();
29    if !is_root {
30        let mut children = annotation_symbol_list(session.annotations());
31        children.extend(collect_symbols_from_items(session.children.iter()));
32        let selection_range = session
33            .header_location()
34            .cloned()
35            .unwrap_or_else(|| session.range().clone());
36        symbols.push(LexDocumentSymbol {
37            name: summarize_text(&session.title, "Session"),
38            detail: Some(format!("{} item(s)", session.children.len())),
39            kind: SymbolKind::NAMESPACE,
40            range: session.range().clone(),
41            selection_range,
42            children,
43        });
44    } else {
45        symbols.extend(collect_symbols_from_items(session.children.iter()));
46    }
47    symbols
48}
49
50fn collect_symbols_from_items<'a>(
51    items: impl Iterator<Item = &'a ContentItem>,
52) -> Vec<LexDocumentSymbol> {
53    let mut symbols = Vec::new();
54    for item in items {
55        match item {
56            ContentItem::Session(session) => symbols.extend(session_symbols(session, false)),
57            ContentItem::Definition(definition) => symbols.push(definition_symbol(definition)),
58            ContentItem::List(list) => symbols.push(list_symbol(list)),
59            ContentItem::Annotation(annotation) => symbols.push(annotation_symbol(annotation)),
60            ContentItem::VerbatimBlock(verbatim) => symbols.push(verbatim_symbol(verbatim)),
61            ContentItem::Paragraph(paragraph) => symbols.push(paragraph_symbol(paragraph)),
62            ContentItem::ListItem(list_item) => symbols.push(list_item_symbol(list_item)),
63            ContentItem::TextLine(_)
64            | ContentItem::VerbatimLine(_)
65            | ContentItem::BlankLineGroup(_) => {}
66        }
67    }
68    symbols
69}
70
71fn definition_symbol(definition: &Definition) -> LexDocumentSymbol {
72    let mut children = annotation_symbol_list(definition.annotations());
73    children.extend(collect_symbols_from_items(definition.children.iter()));
74    let selection_range = definition
75        .header_location()
76        .cloned()
77        .unwrap_or_else(|| definition.range().clone());
78    LexDocumentSymbol {
79        name: summarize_text(&definition.subject, "Definition"),
80        detail: Some("definition".to_string()),
81        kind: SymbolKind::STRUCT,
82        range: definition.range().clone(),
83        selection_range,
84        children,
85    }
86}
87
88fn list_symbol(list: &List) -> LexDocumentSymbol {
89    let mut children = annotation_symbol_list(list.annotations());
90    children.extend(collect_symbols_from_items(list.items.iter()));
91
92    LexDocumentSymbol {
93        name: format!("List ({} items)", list.items.len()),
94        detail: None,
95        kind: SymbolKind::ARRAY,
96        range: list.range().clone(),
97        selection_range: list.range().clone(),
98        children,
99    }
100}
101
102fn verbatim_symbol(verbatim: &Verbatim) -> LexDocumentSymbol {
103    let children = annotation_symbol_list(verbatim.annotations());
104    LexDocumentSymbol {
105        name: format!(
106            "Verbatim: {}",
107            summarize_text(&verbatim.subject, "Verbatim block")
108        ),
109        detail: Some(verbatim.closing_data.label.value.clone()),
110        kind: SymbolKind::OBJECT,
111        range: verbatim.range().clone(),
112        selection_range: verbatim
113            .subject
114            .location
115            .clone()
116            .unwrap_or_else(|| verbatim.range().clone()),
117        children,
118    }
119}
120
121fn paragraph_symbol(paragraph: &Paragraph) -> LexDocumentSymbol {
122    let children = annotation_symbol_list(paragraph.annotations());
123    // Use the first line of text as the name, truncated if necessary
124    let name = if let Some(ContentItem::TextLine(first_line)) = paragraph.lines.first() {
125        summarize_text(&first_line.content, "Paragraph")
126    } else {
127        "Paragraph".to_string()
128    };
129
130    LexDocumentSymbol {
131        name,
132        detail: None,
133        kind: SymbolKind::STRING,
134        range: paragraph.range().clone(),
135        selection_range: paragraph.range().clone(),
136        children,
137    }
138}
139
140fn list_item_symbol(list_item: &ListItem) -> LexDocumentSymbol {
141    let mut children = annotation_symbol_list(list_item.annotations());
142    children.extend(collect_symbols_from_items(list_item.children.iter()));
143
144    let name = if let Some(first_text) = list_item.text.first() {
145        summarize_text(first_text, "List Item")
146    } else {
147        "List Item".to_string()
148    };
149
150    LexDocumentSymbol {
151        name: format!("{} {}", list_item.marker.as_string(), name),
152        detail: None,
153        kind: SymbolKind::FIELD, // Or Property/Variable
154        range: list_item.range().clone(),
155        selection_range: list_item.range().clone(),
156        children,
157    }
158}
159
160fn annotation_symbol(annotation: &Annotation) -> LexDocumentSymbol {
161    let children = collect_symbols_from_items(annotation.children.iter());
162    LexDocumentSymbol {
163        name: format!(":: {} ::", annotation.data.label.value),
164        detail: if annotation.data.parameters.is_empty() {
165            None
166        } else {
167            Some(
168                annotation
169                    .data
170                    .parameters
171                    .iter()
172                    .map(|param| format!("{}={}", param.key, param.value))
173                    .collect::<Vec<_>>()
174                    .join(", "),
175            )
176        },
177        kind: SymbolKind::EVENT,
178        range: annotation.range().clone(),
179        selection_range: annotation.header_location().clone(),
180        children,
181    }
182}
183
184fn annotation_symbol_list<'a>(
185    annotations: impl IntoIterator<Item = &'a Annotation>,
186) -> Vec<LexDocumentSymbol> {
187    annotations.into_iter().map(annotation_symbol).collect()
188}
189
190fn summarize_text(text: &TextContent, fallback: &str) -> String {
191    let trimmed = text.as_string().trim();
192    if trimmed.is_empty() {
193        fallback.to_string()
194    } else {
195        trimmed.to_string()
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::test_support::sample_document;
203
204    fn find_symbol<'a>(symbols: &'a [LexDocumentSymbol], name: &str) -> &'a LexDocumentSymbol {
205        symbols
206            .iter()
207            .find(|symbol| symbol.name == name)
208            .unwrap_or_else(|| panic!("symbol {name} not found"))
209    }
210
211    #[test]
212    fn builds_session_tree() {
213        let document = sample_document();
214        let symbols = collect_document_symbols(&document);
215        assert!(symbols.iter().any(|s| s.name == ":: doc.note ::"));
216        let session = find_symbol(&symbols, "1. Intro");
217        let child_names: Vec<_> = session
218            .children
219            .iter()
220            .map(|child| child.name.clone())
221            .collect();
222        assert!(child_names.iter().any(|name| name.contains("Cache")));
223        assert!(child_names.iter().any(|name| name.contains("List")));
224        assert!(child_names.iter().any(|name| name.contains("Verbatim")));
225
226        // Cache is parsed as a Verbatim block because it's followed by a container and an annotation marker
227        let _verbatim_symbol = session
228            .children
229            .iter()
230            .find(|child| child.name.contains("Cache") && child.kind == SymbolKind::OBJECT)
231            .expect("verbatim symbol not found");
232    }
233
234    #[test]
235    fn includes_paragraphs_and_list_items() {
236        use lex_core::lex::ast::elements::paragraph::TextLine;
237        use lex_core::lex::ast::{ContentItem, List, ListItem, Paragraph, TextContent};
238
239        // Create a document with a paragraph and a list
240        let paragraph = Paragraph::new(vec![ContentItem::TextLine(TextLine::new(
241            TextContent::from_string("Hello World".to_string(), None),
242        ))]);
243
244        let list_item = ListItem::new("-".to_string(), "Item 1".to_string());
245        let list = List::new(vec![list_item]);
246
247        let document = Document::with_content(vec![
248            ContentItem::Paragraph(paragraph),
249            ContentItem::List(list),
250        ]);
251
252        let symbols = collect_document_symbols(&document);
253
254        // Check for paragraph
255        let paragraph_symbol = symbols
256            .iter()
257            .find(|s| s.name == "Hello World")
258            .expect("Paragraph symbol not found");
259        assert_eq!(paragraph_symbol.kind, SymbolKind::STRING);
260
261        // Check for list
262        let list_symbol = symbols
263            .iter()
264            .find(|s| s.name.contains("List"))
265            .expect("List symbol not found");
266
267        // Check for list item
268        let item_symbol = list_symbol
269            .children
270            .iter()
271            .find(|s| s.name.contains("Item 1"));
272        if item_symbol.is_none() {
273            println!("List symbol children: {:#?}", list_symbol.children);
274        }
275        let item_symbol = item_symbol.expect("List item symbol not found");
276        assert!(item_symbol.name.contains("-"));
277    }
278
279    #[test]
280    fn includes_document_level_annotations() {
281        let document = sample_document();
282        let symbols = collect_document_symbols(&document);
283        assert!(symbols.iter().any(|symbol| symbol.name == ":: doc.note ::"));
284        // callout is consumed as the footer of the Cache verbatim block
285    }
286}