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, 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::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::PROPERTY,
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::ENUM,
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::CONSTANT,
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        truncate_to_words(&first_line.content, 4, "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::ENUM_MEMBER,
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::INTERFACE,
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    summarize_text_str(text.as_string().trim(), fallback)
192}
193
194fn summarize_text_str(text: &str, fallback: &str) -> String {
195    if text.is_empty() {
196        fallback.to_string()
197    } else {
198        text.to_string()
199    }
200}
201
202fn truncate_to_words(text: &TextContent, max_words: usize, fallback: &str) -> String {
203    let trimmed = text.as_string().trim();
204    if trimmed.is_empty() {
205        return fallback.to_string();
206    }
207    let words: Vec<&str> = trimmed.split_whitespace().collect();
208    if words.len() <= max_words {
209        trimmed.to_string()
210    } else {
211        format!("{}…", words[..max_words].join(" "))
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::test_support::sample_document;
219
220    fn find_symbol<'a>(symbols: &'a [LexDocumentSymbol], name: &str) -> &'a LexDocumentSymbol {
221        symbols
222            .iter()
223            .find(|symbol| symbol.name == name)
224            .unwrap_or_else(|| panic!("symbol {name} not found"))
225    }
226
227    #[test]
228    fn builds_session_tree() {
229        let document = sample_document();
230        let symbols = collect_document_symbols(&document);
231        assert!(symbols.iter().any(|s| s.name == ":: doc.note ::"));
232        let session = find_symbol(&symbols, "1. Intro");
233        let child_names: Vec<_> = session
234            .children
235            .iter()
236            .map(|child| child.name.clone())
237            .collect();
238        assert!(child_names.iter().any(|name| name.contains("Cache")));
239        assert!(child_names.iter().any(|name| name.contains("List")));
240        assert!(child_names.iter().any(|name| name.contains("Verbatim")));
241
242        // Cache is parsed as a Verbatim block because it's followed by a container and an annotation marker
243        let _verbatim_symbol = session
244            .children
245            .iter()
246            .find(|child| child.name.contains("Cache") && child.kind == SymbolKind::CONSTANT)
247            .expect("verbatim symbol not found");
248    }
249
250    #[test]
251    fn includes_paragraphs_and_list_items() {
252        use lex_core::lex::ast::elements::paragraph::TextLine;
253        use lex_core::lex::ast::{ContentItem, List, ListItem, Paragraph, TextContent};
254
255        // Create a document with a paragraph and a list
256        let paragraph = Paragraph::new(vec![ContentItem::TextLine(TextLine::new(
257            TextContent::from_string("Hello World".to_string(), None),
258        ))]);
259
260        let list_item = ListItem::new("-".to_string(), "Item 1".to_string());
261        let list = List::new(vec![list_item]);
262
263        let document = Document::with_content(vec![
264            ContentItem::Paragraph(paragraph),
265            ContentItem::List(list),
266        ]);
267
268        let symbols = collect_document_symbols(&document);
269
270        // Check for paragraph
271        let paragraph_symbol = symbols
272            .iter()
273            .find(|s| s.name.contains("Hello"))
274            .expect("Paragraph symbol not found");
275        assert_eq!(paragraph_symbol.kind, SymbolKind::STRING);
276
277        // Check for list
278        let list_symbol = symbols
279            .iter()
280            .find(|s| s.name.contains("List"))
281            .expect("List symbol not found");
282
283        // Check for list item
284        let item_symbol = list_symbol
285            .children
286            .iter()
287            .find(|s| s.name.contains("Item 1"));
288        if item_symbol.is_none() {
289            println!("List symbol children: {:#?}", list_symbol.children);
290        }
291        let item_symbol = item_symbol.expect("List item symbol not found");
292        assert!(item_symbol.name.contains("-"));
293    }
294
295    #[test]
296    fn includes_document_level_annotations() {
297        let document = sample_document();
298        let symbols = collect_document_symbols(&document);
299        assert!(symbols.iter().any(|symbol| symbol.name == ":: doc.note ::"));
300        // callout is consumed as the footer of the Cache verbatim block
301    }
302}