Skip to main content

lex_analysis/
document_symbols.rs

1use lex_core::lex::ast::{
2    Annotation, AstNode, ContentItem, Definition, Document, List, ListItem, Paragraph, Range,
3    Session, Table, TableRow, 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::STRUCT, // § sections/scopes
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::Table(table) => symbols.push(table_symbol(table)),
62            ContentItem::Paragraph(paragraph) => symbols.push(paragraph_symbol(paragraph)),
63            ContentItem::ListItem(list_item) => symbols.push(list_item_symbol(list_item)),
64            ContentItem::TextLine(_)
65            | ContentItem::VerbatimLine(_)
66            | ContentItem::BlankLineGroup(_) => {}
67        }
68    }
69    symbols
70}
71
72fn definition_symbol(definition: &Definition) -> LexDocumentSymbol {
73    let mut children = annotation_symbol_list(definition.annotations());
74    children.extend(collect_symbols_from_items(definition.children.iter()));
75    let selection_range = definition
76        .header_location()
77        .cloned()
78        .unwrap_or_else(|| definition.range().clone());
79    LexDocumentSymbol {
80        name: summarize_text(&definition.subject, "Definition"),
81        detail: Some("definition".to_string()),
82        kind: SymbolKind::PROPERTY,
83        range: definition.range().clone(),
84        selection_range,
85        children,
86    }
87}
88
89fn list_symbol(list: &List) -> LexDocumentSymbol {
90    let mut children = annotation_symbol_list(list.annotations());
91    children.extend(collect_symbols_from_items(list.items.iter()));
92
93    LexDocumentSymbol {
94        name: format!("List ({} items)", list.items.len()),
95        detail: None,
96        kind: SymbolKind::ENUM,
97        range: list.range().clone(),
98        selection_range: list.range().clone(),
99        children,
100    }
101}
102
103fn verbatim_symbol(verbatim: &Verbatim) -> LexDocumentSymbol {
104    let children = annotation_symbol_list(verbatim.annotations());
105    LexDocumentSymbol {
106        name: format!(
107            "Verbatim: {}",
108            summarize_text(&verbatim.subject, "Verbatim block")
109        ),
110        detail: Some(verbatim.closing_data.label.value.clone()),
111        kind: SymbolKind::CONSTANT,
112        range: verbatim.range().clone(),
113        selection_range: verbatim
114            .subject
115            .location
116            .clone()
117            .unwrap_or_else(|| verbatim.range().clone()),
118        children,
119    }
120}
121
122fn table_symbol(table: &Table) -> LexDocumentSymbol {
123    let mut children = annotation_symbol_list(table.annotations());
124
125    // Include rows as intermediate children so the outline reflects the table structure
126    let mut row_index = 0;
127    for row in table.all_rows() {
128        row_index += 1;
129        children.push(row_symbol(row, row_index));
130    }
131
132    LexDocumentSymbol {
133        name: format!("Table: {}", summarize_text(&table.subject, "Table")),
134        detail: Some(format!(
135            "{} row(s), {} col(s)",
136            table.row_count(),
137            table.column_count()
138        )),
139        kind: SymbolKind::CONSTANT,
140        range: table.range().clone(),
141        selection_range: table
142            .subject
143            .location
144            .clone()
145            .unwrap_or_else(|| table.range().clone()),
146        children,
147    }
148}
149
150fn row_symbol(row: &TableRow, index: usize) -> LexDocumentSymbol {
151    let mut children = Vec::new();
152
153    for cell in &row.cells {
154        let cell_text = cell.content.as_string().trim().to_string();
155        let cell_name = if cell_text.is_empty() {
156            "(empty)".to_string()
157        } else {
158            cell_text
159        };
160
161        // Collect block-level content inside this cell
162        let cell_children = if cell.has_block_content() {
163            collect_symbols_from_items(cell.children.iter())
164        } else {
165            Vec::new()
166        };
167
168        children.push(LexDocumentSymbol {
169            name: cell_name,
170            detail: if cell.colspan > 1 || cell.rowspan > 1 {
171                Some(format!("{}×{}", cell.colspan, cell.rowspan))
172            } else {
173                None
174            },
175            kind: SymbolKind::FIELD,
176            range: cell.location.clone(),
177            selection_range: cell.location.clone(),
178            children: cell_children,
179        });
180    }
181
182    LexDocumentSymbol {
183        name: format!("Row {index}"),
184        detail: Some(format!("{} cell(s)", row.cells.len())),
185        kind: SymbolKind::ENUM,
186        range: row.location.clone(),
187        selection_range: row.location.clone(),
188        children,
189    }
190}
191
192fn paragraph_symbol(paragraph: &Paragraph) -> LexDocumentSymbol {
193    let children = annotation_symbol_list(paragraph.annotations());
194    // Use the first line of text as the name, truncated if necessary
195    let name = if let Some(ContentItem::TextLine(first_line)) = paragraph.lines.first() {
196        truncate_to_words(&first_line.content, 4, "Paragraph")
197    } else {
198        "Paragraph".to_string()
199    };
200
201    LexDocumentSymbol {
202        name,
203        detail: None,
204        kind: SymbolKind::STRING,
205        range: paragraph.range().clone(),
206        selection_range: paragraph.range().clone(),
207        children,
208    }
209}
210
211fn list_item_symbol(list_item: &ListItem) -> LexDocumentSymbol {
212    let mut children = annotation_symbol_list(list_item.annotations());
213    children.extend(collect_symbols_from_items(list_item.children.iter()));
214
215    let name = if let Some(first_text) = list_item.text.first() {
216        summarize_text(first_text, "List Item")
217    } else {
218        "List Item".to_string()
219    };
220
221    LexDocumentSymbol {
222        name: format!("{} {}", list_item.marker.as_string(), name),
223        detail: None,
224        kind: SymbolKind::ENUM_MEMBER,
225        range: list_item.range().clone(),
226        selection_range: list_item.range().clone(),
227        children,
228    }
229}
230
231fn annotation_symbol(annotation: &Annotation) -> LexDocumentSymbol {
232    let children = collect_symbols_from_items(annotation.children.iter());
233    LexDocumentSymbol {
234        name: format!(":: {} ::", annotation.data.label.value),
235        detail: if annotation.data.parameters.is_empty() {
236            None
237        } else {
238            Some(
239                annotation
240                    .data
241                    .parameters
242                    .iter()
243                    .map(|param| format!("{}={}", param.key, param.value))
244                    .collect::<Vec<_>>()
245                    .join(", "),
246            )
247        },
248        kind: SymbolKind::INTERFACE,
249        range: annotation.range().clone(),
250        selection_range: annotation.header_location().clone(),
251        children,
252    }
253}
254
255fn annotation_symbol_list<'a>(
256    annotations: impl IntoIterator<Item = &'a Annotation>,
257) -> Vec<LexDocumentSymbol> {
258    annotations.into_iter().map(annotation_symbol).collect()
259}
260
261fn summarize_text(text: &TextContent, fallback: &str) -> String {
262    summarize_text_str(text.as_string().trim(), fallback)
263}
264
265fn summarize_text_str(text: &str, fallback: &str) -> String {
266    if text.is_empty() {
267        fallback.to_string()
268    } else {
269        text.to_string()
270    }
271}
272
273fn truncate_to_words(text: &TextContent, max_words: usize, fallback: &str) -> String {
274    let trimmed = text.as_string().trim();
275    if trimmed.is_empty() {
276        return fallback.to_string();
277    }
278    let words: Vec<&str> = trimmed.split_whitespace().collect();
279    if words.len() <= max_words {
280        trimmed.to_string()
281    } else {
282        format!("{}…", words[..max_words].join(" "))
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::test_support::sample_document;
290
291    fn find_symbol<'a>(symbols: &'a [LexDocumentSymbol], name: &str) -> &'a LexDocumentSymbol {
292        symbols
293            .iter()
294            .find(|symbol| symbol.name == name)
295            .unwrap_or_else(|| panic!("symbol {name} not found"))
296    }
297
298    #[test]
299    fn builds_session_tree() {
300        let document = sample_document();
301        let symbols = collect_document_symbols(&document);
302        assert!(symbols.iter().any(|s| s.name == ":: test.note ::"));
303        let session = find_symbol(&symbols, "1. Intro");
304        let child_names: Vec<_> = session
305            .children
306            .iter()
307            .map(|child| child.name.clone())
308            .collect();
309        assert!(child_names.iter().any(|name| name.contains("Cache")));
310        assert!(child_names.iter().any(|name| name.contains("List")));
311        assert!(child_names.iter().any(|name| name.contains("Verbatim")));
312
313        // Cache is parsed as a Verbatim block because it's followed by a container and an annotation marker
314        let _verbatim_symbol = session
315            .children
316            .iter()
317            .find(|child| child.name.contains("Cache") && child.kind == SymbolKind::CONSTANT)
318            .expect("verbatim symbol not found");
319    }
320
321    #[test]
322    fn includes_paragraphs_and_list_items() {
323        use lex_core::lex::ast::elements::paragraph::TextLine;
324        use lex_core::lex::ast::{ContentItem, List, ListItem, Paragraph, TextContent};
325
326        // Create a document with a paragraph and a list
327        let paragraph = Paragraph::new(vec![ContentItem::TextLine(TextLine::new(
328            TextContent::from_string("Hello World".to_string(), None),
329        ))]);
330
331        let list_item = ListItem::new("-".to_string(), "Item 1".to_string());
332        let list = List::new(vec![list_item]);
333
334        let document = Document::with_content(vec![
335            ContentItem::Paragraph(paragraph),
336            ContentItem::List(list),
337        ]);
338
339        let symbols = collect_document_symbols(&document);
340
341        // Check for paragraph
342        let paragraph_symbol = symbols
343            .iter()
344            .find(|s| s.name.contains("Hello"))
345            .expect("Paragraph symbol not found");
346        assert_eq!(paragraph_symbol.kind, SymbolKind::STRING);
347
348        // Check for list
349        let list_symbol = symbols
350            .iter()
351            .find(|s| s.name.contains("List"))
352            .expect("List symbol not found");
353
354        // Check for list item
355        let item_symbol = list_symbol
356            .children
357            .iter()
358            .find(|s| s.name.contains("Item 1"));
359        if item_symbol.is_none() {
360            println!("List symbol children: {:#?}", list_symbol.children);
361        }
362        let item_symbol = item_symbol.expect("List item symbol not found");
363        assert!(item_symbol.name.contains("-"));
364    }
365
366    #[test]
367    fn table_symbol_includes_rows_and_cells() {
368        use lex_core::lex::ast::elements::table::{TableCell, TableRow};
369        use lex_core::lex::ast::elements::verbatim::VerbatimBlockMode;
370        use lex_core::lex::ast::{Table, TextContent};
371
372        let row1 = TableRow::new(vec![
373            TableCell::new(TextContent::from_string("Header A".to_string(), None)),
374            TableCell::new(TextContent::from_string("Header B".to_string(), None)),
375        ]);
376        let row2 = TableRow::new(vec![
377            TableCell::new(TextContent::from_string("Value 1".to_string(), None)),
378            TableCell::new(TextContent::from_string("Value 2".to_string(), None)),
379        ]);
380        let table = Table::new(
381            TextContent::from_string("My Table".to_string(), None),
382            vec![row1],
383            vec![row2],
384            VerbatimBlockMode::Inflow,
385        );
386
387        let document = Document::with_content(vec![ContentItem::Table(Box::new(table))]);
388        let symbols = collect_document_symbols(&document);
389
390        // Find the table symbol
391        let table_sym = symbols
392            .iter()
393            .find(|s| s.name.contains("My Table"))
394            .expect("Table symbol not found");
395        assert_eq!(table_sym.kind, SymbolKind::CONSTANT);
396        assert!(table_sym.detail.as_ref().unwrap().contains("2 row(s)"));
397
398        // Table should have row children
399        assert_eq!(
400            table_sym.children.len(),
401            2,
402            "Table should have 2 row children"
403        );
404
405        let row1_sym = &table_sym.children[0];
406        assert_eq!(row1_sym.name, "Row 1");
407        assert_eq!(row1_sym.kind, SymbolKind::ENUM);
408        assert_eq!(
409            row1_sym.children.len(),
410            2,
411            "Row 1 should have 2 cell children"
412        );
413
414        // Check cell names
415        assert_eq!(row1_sym.children[0].name, "Header A");
416        assert_eq!(row1_sym.children[1].name, "Header B");
417        assert_eq!(row1_sym.children[0].kind, SymbolKind::FIELD);
418
419        let row2_sym = &table_sym.children[1];
420        assert_eq!(row2_sym.children[0].name, "Value 1");
421        assert_eq!(row2_sym.children[1].name, "Value 2");
422    }
423
424    #[test]
425    fn includes_document_level_annotations() {
426        let document = sample_document();
427        let symbols = collect_document_symbols(&document);
428        assert!(symbols
429            .iter()
430            .any(|symbol| symbol.name == ":: test.note ::"));
431        // callout is consumed as the footer of the Cache verbatim block
432    }
433}