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, 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 symbols from cell children with block content
126    for row in table.all_rows() {
127        for cell in &row.cells {
128            if cell.has_block_content() {
129                children.extend(collect_symbols_from_items(cell.children.iter()));
130            }
131        }
132    }
133
134    LexDocumentSymbol {
135        name: format!("Table: {}", summarize_text(&table.subject, "Table")),
136        detail: Some("table".to_string()),
137        kind: SymbolKind::CONSTANT,
138        range: table.range().clone(),
139        selection_range: table
140            .subject
141            .location
142            .clone()
143            .unwrap_or_else(|| table.range().clone()),
144        children,
145    }
146}
147
148fn paragraph_symbol(paragraph: &Paragraph) -> LexDocumentSymbol {
149    let children = annotation_symbol_list(paragraph.annotations());
150    // Use the first line of text as the name, truncated if necessary
151    let name = if let Some(ContentItem::TextLine(first_line)) = paragraph.lines.first() {
152        truncate_to_words(&first_line.content, 4, "Paragraph")
153    } else {
154        "Paragraph".to_string()
155    };
156
157    LexDocumentSymbol {
158        name,
159        detail: None,
160        kind: SymbolKind::STRING,
161        range: paragraph.range().clone(),
162        selection_range: paragraph.range().clone(),
163        children,
164    }
165}
166
167fn list_item_symbol(list_item: &ListItem) -> LexDocumentSymbol {
168    let mut children = annotation_symbol_list(list_item.annotations());
169    children.extend(collect_symbols_from_items(list_item.children.iter()));
170
171    let name = if let Some(first_text) = list_item.text.first() {
172        summarize_text(first_text, "List Item")
173    } else {
174        "List Item".to_string()
175    };
176
177    LexDocumentSymbol {
178        name: format!("{} {}", list_item.marker.as_string(), name),
179        detail: None,
180        kind: SymbolKind::ENUM_MEMBER,
181        range: list_item.range().clone(),
182        selection_range: list_item.range().clone(),
183        children,
184    }
185}
186
187fn annotation_symbol(annotation: &Annotation) -> LexDocumentSymbol {
188    let children = collect_symbols_from_items(annotation.children.iter());
189    LexDocumentSymbol {
190        name: format!(":: {} ::", annotation.data.label.value),
191        detail: if annotation.data.parameters.is_empty() {
192            None
193        } else {
194            Some(
195                annotation
196                    .data
197                    .parameters
198                    .iter()
199                    .map(|param| format!("{}={}", param.key, param.value))
200                    .collect::<Vec<_>>()
201                    .join(", "),
202            )
203        },
204        kind: SymbolKind::INTERFACE,
205        range: annotation.range().clone(),
206        selection_range: annotation.header_location().clone(),
207        children,
208    }
209}
210
211fn annotation_symbol_list<'a>(
212    annotations: impl IntoIterator<Item = &'a Annotation>,
213) -> Vec<LexDocumentSymbol> {
214    annotations.into_iter().map(annotation_symbol).collect()
215}
216
217fn summarize_text(text: &TextContent, fallback: &str) -> String {
218    summarize_text_str(text.as_string().trim(), fallback)
219}
220
221fn summarize_text_str(text: &str, fallback: &str) -> String {
222    if text.is_empty() {
223        fallback.to_string()
224    } else {
225        text.to_string()
226    }
227}
228
229fn truncate_to_words(text: &TextContent, max_words: usize, fallback: &str) -> String {
230    let trimmed = text.as_string().trim();
231    if trimmed.is_empty() {
232        return fallback.to_string();
233    }
234    let words: Vec<&str> = trimmed.split_whitespace().collect();
235    if words.len() <= max_words {
236        trimmed.to_string()
237    } else {
238        format!("{}…", words[..max_words].join(" "))
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::test_support::sample_document;
246
247    fn find_symbol<'a>(symbols: &'a [LexDocumentSymbol], name: &str) -> &'a LexDocumentSymbol {
248        symbols
249            .iter()
250            .find(|symbol| symbol.name == name)
251            .unwrap_or_else(|| panic!("symbol {name} not found"))
252    }
253
254    #[test]
255    fn builds_session_tree() {
256        let document = sample_document();
257        let symbols = collect_document_symbols(&document);
258        assert!(symbols.iter().any(|s| s.name == ":: doc.note ::"));
259        let session = find_symbol(&symbols, "1. Intro");
260        let child_names: Vec<_> = session
261            .children
262            .iter()
263            .map(|child| child.name.clone())
264            .collect();
265        assert!(child_names.iter().any(|name| name.contains("Cache")));
266        assert!(child_names.iter().any(|name| name.contains("List")));
267        assert!(child_names.iter().any(|name| name.contains("Verbatim")));
268
269        // Cache is parsed as a Verbatim block because it's followed by a container and an annotation marker
270        let _verbatim_symbol = session
271            .children
272            .iter()
273            .find(|child| child.name.contains("Cache") && child.kind == SymbolKind::CONSTANT)
274            .expect("verbatim symbol not found");
275    }
276
277    #[test]
278    fn includes_paragraphs_and_list_items() {
279        use lex_core::lex::ast::elements::paragraph::TextLine;
280        use lex_core::lex::ast::{ContentItem, List, ListItem, Paragraph, TextContent};
281
282        // Create a document with a paragraph and a list
283        let paragraph = Paragraph::new(vec![ContentItem::TextLine(TextLine::new(
284            TextContent::from_string("Hello World".to_string(), None),
285        ))]);
286
287        let list_item = ListItem::new("-".to_string(), "Item 1".to_string());
288        let list = List::new(vec![list_item]);
289
290        let document = Document::with_content(vec![
291            ContentItem::Paragraph(paragraph),
292            ContentItem::List(list),
293        ]);
294
295        let symbols = collect_document_symbols(&document);
296
297        // Check for paragraph
298        let paragraph_symbol = symbols
299            .iter()
300            .find(|s| s.name.contains("Hello"))
301            .expect("Paragraph symbol not found");
302        assert_eq!(paragraph_symbol.kind, SymbolKind::STRING);
303
304        // Check for list
305        let list_symbol = symbols
306            .iter()
307            .find(|s| s.name.contains("List"))
308            .expect("List symbol not found");
309
310        // Check for list item
311        let item_symbol = list_symbol
312            .children
313            .iter()
314            .find(|s| s.name.contains("Item 1"));
315        if item_symbol.is_none() {
316            println!("List symbol children: {:#?}", list_symbol.children);
317        }
318        let item_symbol = item_symbol.expect("List item symbol not found");
319        assert!(item_symbol.name.contains("-"));
320    }
321
322    #[test]
323    fn includes_document_level_annotations() {
324        let document = sample_document();
325        let symbols = collect_document_symbols(&document);
326        assert!(symbols.iter().any(|symbol| symbol.name == ":: doc.note ::"));
327        // callout is consumed as the footer of the Cache verbatim block
328    }
329}