use lex_core::lex::ast::{
Annotation, AstNode, ContentItem, Definition, Document, List, ListItem, Paragraph, Range,
Session, Table, TableRow, TextContent, Verbatim,
};
use lsp_types::SymbolKind;
#[derive(Debug, Clone, PartialEq)]
pub struct LexDocumentSymbol {
pub name: String,
pub detail: Option<String>,
pub kind: SymbolKind,
pub range: Range,
pub selection_range: Range,
pub children: Vec<LexDocumentSymbol>,
}
pub fn collect_document_symbols(document: &Document) -> Vec<LexDocumentSymbol> {
let mut symbols: Vec<LexDocumentSymbol> = document
.annotations()
.iter()
.map(annotation_symbol)
.collect();
symbols.extend(session_symbols(&document.root, true));
symbols
}
fn session_symbols(session: &Session, is_root: bool) -> Vec<LexDocumentSymbol> {
let mut symbols = Vec::new();
if !is_root {
let mut children = annotation_symbol_list(session.annotations());
children.extend(collect_symbols_from_items(session.children.iter()));
let selection_range = session
.header_location()
.cloned()
.unwrap_or_else(|| session.range().clone());
symbols.push(LexDocumentSymbol {
name: summarize_text(&session.title, "Session"),
detail: Some(format!("{} item(s)", session.children.len())),
kind: SymbolKind::STRUCT, range: session.range().clone(),
selection_range,
children,
});
} else {
symbols.extend(collect_symbols_from_items(session.children.iter()));
}
symbols
}
fn collect_symbols_from_items<'a>(
items: impl Iterator<Item = &'a ContentItem>,
) -> Vec<LexDocumentSymbol> {
let mut symbols = Vec::new();
for item in items {
match item {
ContentItem::Session(session) => symbols.extend(session_symbols(session, false)),
ContentItem::Definition(definition) => symbols.push(definition_symbol(definition)),
ContentItem::List(list) => symbols.push(list_symbol(list)),
ContentItem::Annotation(annotation) => symbols.push(annotation_symbol(annotation)),
ContentItem::VerbatimBlock(verbatim) => symbols.push(verbatim_symbol(verbatim)),
ContentItem::Table(table) => symbols.push(table_symbol(table)),
ContentItem::Paragraph(paragraph) => symbols.push(paragraph_symbol(paragraph)),
ContentItem::ListItem(list_item) => symbols.push(list_item_symbol(list_item)),
ContentItem::TextLine(_)
| ContentItem::VerbatimLine(_)
| ContentItem::BlankLineGroup(_) => {}
}
}
symbols
}
fn definition_symbol(definition: &Definition) -> LexDocumentSymbol {
let mut children = annotation_symbol_list(definition.annotations());
children.extend(collect_symbols_from_items(definition.children.iter()));
let selection_range = definition
.header_location()
.cloned()
.unwrap_or_else(|| definition.range().clone());
LexDocumentSymbol {
name: summarize_text(&definition.subject, "Definition"),
detail: Some("definition".to_string()),
kind: SymbolKind::PROPERTY,
range: definition.range().clone(),
selection_range,
children,
}
}
fn list_symbol(list: &List) -> LexDocumentSymbol {
let mut children = annotation_symbol_list(list.annotations());
children.extend(collect_symbols_from_items(list.items.iter()));
LexDocumentSymbol {
name: format!("List ({} items)", list.items.len()),
detail: None,
kind: SymbolKind::ENUM,
range: list.range().clone(),
selection_range: list.range().clone(),
children,
}
}
fn verbatim_symbol(verbatim: &Verbatim) -> LexDocumentSymbol {
let children = annotation_symbol_list(verbatim.annotations());
LexDocumentSymbol {
name: format!(
"Verbatim: {}",
summarize_text(&verbatim.subject, "Verbatim block")
),
detail: Some(verbatim.closing_data.label.value.clone()),
kind: SymbolKind::CONSTANT,
range: verbatim.range().clone(),
selection_range: verbatim
.subject
.location
.clone()
.unwrap_or_else(|| verbatim.range().clone()),
children,
}
}
fn table_symbol(table: &Table) -> LexDocumentSymbol {
let mut children = annotation_symbol_list(table.annotations());
let mut row_index = 0;
for row in table.all_rows() {
row_index += 1;
children.push(row_symbol(row, row_index));
}
LexDocumentSymbol {
name: format!("Table: {}", summarize_text(&table.subject, "Table")),
detail: Some(format!(
"{} row(s), {} col(s)",
table.row_count(),
table.column_count()
)),
kind: SymbolKind::CONSTANT,
range: table.range().clone(),
selection_range: table
.subject
.location
.clone()
.unwrap_or_else(|| table.range().clone()),
children,
}
}
fn row_symbol(row: &TableRow, index: usize) -> LexDocumentSymbol {
let mut children = Vec::new();
for cell in &row.cells {
let cell_text = cell.content.as_string().trim().to_string();
let cell_name = if cell_text.is_empty() {
"(empty)".to_string()
} else {
cell_text
};
let cell_children = if cell.has_block_content() {
collect_symbols_from_items(cell.children.iter())
} else {
Vec::new()
};
children.push(LexDocumentSymbol {
name: cell_name,
detail: if cell.colspan > 1 || cell.rowspan > 1 {
Some(format!("{}ร{}", cell.colspan, cell.rowspan))
} else {
None
},
kind: SymbolKind::FIELD,
range: cell.location.clone(),
selection_range: cell.location.clone(),
children: cell_children,
});
}
LexDocumentSymbol {
name: format!("Row {index}"),
detail: Some(format!("{} cell(s)", row.cells.len())),
kind: SymbolKind::ENUM,
range: row.location.clone(),
selection_range: row.location.clone(),
children,
}
}
fn paragraph_symbol(paragraph: &Paragraph) -> LexDocumentSymbol {
let children = annotation_symbol_list(paragraph.annotations());
let name = if let Some(ContentItem::TextLine(first_line)) = paragraph.lines.first() {
truncate_to_words(&first_line.content, 4, "Paragraph")
} else {
"Paragraph".to_string()
};
LexDocumentSymbol {
name,
detail: None,
kind: SymbolKind::STRING,
range: paragraph.range().clone(),
selection_range: paragraph.range().clone(),
children,
}
}
fn list_item_symbol(list_item: &ListItem) -> LexDocumentSymbol {
let mut children = annotation_symbol_list(list_item.annotations());
children.extend(collect_symbols_from_items(list_item.children.iter()));
let name = if let Some(first_text) = list_item.text.first() {
summarize_text(first_text, "List Item")
} else {
"List Item".to_string()
};
LexDocumentSymbol {
name: format!("{} {}", list_item.marker.as_string(), name),
detail: None,
kind: SymbolKind::ENUM_MEMBER,
range: list_item.range().clone(),
selection_range: list_item.range().clone(),
children,
}
}
fn annotation_symbol(annotation: &Annotation) -> LexDocumentSymbol {
let children = collect_symbols_from_items(annotation.children.iter());
LexDocumentSymbol {
name: format!(":: {} ::", annotation.data.label.value),
detail: if annotation.data.parameters.is_empty() {
None
} else {
Some(
annotation
.data
.parameters
.iter()
.map(|param| format!("{}={}", param.key, param.value))
.collect::<Vec<_>>()
.join(", "),
)
},
kind: SymbolKind::INTERFACE,
range: annotation.range().clone(),
selection_range: annotation.header_location().clone(),
children,
}
}
fn annotation_symbol_list<'a>(
annotations: impl IntoIterator<Item = &'a Annotation>,
) -> Vec<LexDocumentSymbol> {
annotations.into_iter().map(annotation_symbol).collect()
}
fn summarize_text(text: &TextContent, fallback: &str) -> String {
summarize_text_str(text.as_string().trim(), fallback)
}
fn summarize_text_str(text: &str, fallback: &str) -> String {
if text.is_empty() {
fallback.to_string()
} else {
text.to_string()
}
}
fn truncate_to_words(text: &TextContent, max_words: usize, fallback: &str) -> String {
let trimmed = text.as_string().trim();
if trimmed.is_empty() {
return fallback.to_string();
}
let words: Vec<&str> = trimmed.split_whitespace().collect();
if words.len() <= max_words {
trimmed.to_string()
} else {
format!("{}โฆ", words[..max_words].join(" "))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::sample_document;
fn find_symbol<'a>(symbols: &'a [LexDocumentSymbol], name: &str) -> &'a LexDocumentSymbol {
symbols
.iter()
.find(|symbol| symbol.name == name)
.unwrap_or_else(|| panic!("symbol {name} not found"))
}
#[test]
fn builds_session_tree() {
let document = sample_document();
let symbols = collect_document_symbols(&document);
assert!(symbols.iter().any(|s| s.name == ":: test.note ::"));
let session = find_symbol(&symbols, "1. Intro");
let child_names: Vec<_> = session
.children
.iter()
.map(|child| child.name.clone())
.collect();
assert!(child_names.iter().any(|name| name.contains("Cache")));
assert!(child_names.iter().any(|name| name.contains("List")));
assert!(child_names.iter().any(|name| name.contains("Verbatim")));
let _verbatim_symbol = session
.children
.iter()
.find(|child| child.name.contains("Cache") && child.kind == SymbolKind::CONSTANT)
.expect("verbatim symbol not found");
}
#[test]
fn includes_paragraphs_and_list_items() {
use lex_core::lex::ast::elements::paragraph::TextLine;
use lex_core::lex::ast::{ContentItem, List, ListItem, Paragraph, TextContent};
let paragraph = Paragraph::new(vec![ContentItem::TextLine(TextLine::new(
TextContent::from_string("Hello World".to_string(), None),
))]);
let list_item = ListItem::new("-".to_string(), "Item 1".to_string());
let list = List::new(vec![list_item]);
let document = Document::with_content(vec![
ContentItem::Paragraph(paragraph),
ContentItem::List(list),
]);
let symbols = collect_document_symbols(&document);
let paragraph_symbol = symbols
.iter()
.find(|s| s.name.contains("Hello"))
.expect("Paragraph symbol not found");
assert_eq!(paragraph_symbol.kind, SymbolKind::STRING);
let list_symbol = symbols
.iter()
.find(|s| s.name.contains("List"))
.expect("List symbol not found");
let item_symbol = list_symbol
.children
.iter()
.find(|s| s.name.contains("Item 1"));
if item_symbol.is_none() {
println!("List symbol children: {:#?}", list_symbol.children);
}
let item_symbol = item_symbol.expect("List item symbol not found");
assert!(item_symbol.name.contains("-"));
}
#[test]
fn table_symbol_includes_rows_and_cells() {
use lex_core::lex::ast::elements::table::{TableCell, TableRow};
use lex_core::lex::ast::elements::verbatim::VerbatimBlockMode;
use lex_core::lex::ast::{Table, TextContent};
let row1 = TableRow::new(vec![
TableCell::new(TextContent::from_string("Header A".to_string(), None)),
TableCell::new(TextContent::from_string("Header B".to_string(), None)),
]);
let row2 = TableRow::new(vec![
TableCell::new(TextContent::from_string("Value 1".to_string(), None)),
TableCell::new(TextContent::from_string("Value 2".to_string(), None)),
]);
let table = Table::new(
TextContent::from_string("My Table".to_string(), None),
vec![row1],
vec![row2],
VerbatimBlockMode::Inflow,
);
let document = Document::with_content(vec![ContentItem::Table(Box::new(table))]);
let symbols = collect_document_symbols(&document);
let table_sym = symbols
.iter()
.find(|s| s.name.contains("My Table"))
.expect("Table symbol not found");
assert_eq!(table_sym.kind, SymbolKind::CONSTANT);
assert!(table_sym.detail.as_ref().unwrap().contains("2 row(s)"));
assert_eq!(
table_sym.children.len(),
2,
"Table should have 2 row children"
);
let row1_sym = &table_sym.children[0];
assert_eq!(row1_sym.name, "Row 1");
assert_eq!(row1_sym.kind, SymbolKind::ENUM);
assert_eq!(
row1_sym.children.len(),
2,
"Row 1 should have 2 cell children"
);
assert_eq!(row1_sym.children[0].name, "Header A");
assert_eq!(row1_sym.children[1].name, "Header B");
assert_eq!(row1_sym.children[0].kind, SymbolKind::FIELD);
let row2_sym = &table_sym.children[1];
assert_eq!(row2_sym.children[0].name, "Value 1");
assert_eq!(row2_sym.children[1].name, "Value 2");
}
#[test]
fn includes_document_level_annotations() {
let document = sample_document();
let symbols = collect_document_symbols(&document);
assert!(symbols
.iter()
.any(|symbol| symbol.name == ":: test.note ::"));
}
}