use rlsp_yaml_parser::node::{Document, Node};
use rlsp_yaml_parser::{LineIndex, Span};
use tower_lsp::lsp_types::{DocumentSymbol, Position, Range, SymbolKind};
use crate::lsp_util::{offset_to_lsp, span_to_lsp};
#[must_use]
#[expect(
deprecated,
reason = "DocumentSymbol.deprecated field is required by LSP spec"
)]
pub fn document_symbols(docs: &[Document<Span>]) -> Vec<DocumentSymbol> {
if docs.is_empty() {
return Vec::new();
}
if let [doc] = docs {
return yaml_to_symbols(&doc.root, doc.line_index());
}
docs.iter()
.enumerate()
.map(|(i, doc)| {
let idx = doc.line_index();
let root_loc = node_loc(&doc.root);
let range = span_to_lsp(root_loc, idx);
let selection_range = Range::new(range.start, range.start);
let children = yaml_to_symbols(&doc.root, idx);
DocumentSymbol {
name: format!("Document {}", i + 1),
detail: None,
kind: SymbolKind::NAMESPACE,
tags: None,
deprecated: None,
range,
selection_range,
children: if children.is_empty() {
None
} else {
Some(children)
},
}
})
.collect()
}
#[expect(
deprecated,
reason = "DocumentSymbol.deprecated field is required by LSP spec"
)]
fn yaml_to_symbols(node: &Node<Span>, idx: &LineIndex) -> Vec<DocumentSymbol> {
match node {
Node::Mapping { entries, .. } => entries
.iter()
.map(|(key, value)| make_symbol(&node_to_string(key), key, value, idx))
.collect(),
Node::Sequence { items, .. } => make_sequence_children(items, idx),
Node::Scalar { value, .. } => {
let loc = node_loc(node);
let range = span_to_lsp(loc, idx);
vec![DocumentSymbol {
name: value.clone(),
detail: None,
kind: node_symbol_kind(node),
tags: None,
deprecated: None,
range,
selection_range: range,
children: None,
}]
}
Node::Alias { .. } => Vec::new(),
}
}
fn truncate_detail(s: &str) -> String {
const LIMIT: usize = 60;
let mut chars = s.chars();
let collected: String = chars.by_ref().take(LIMIT).collect();
if chars.next().is_some() {
format!("{collected}…")
} else {
collected
}
}
fn value_detail(value: &Node<Span>) -> Option<String> {
match value {
Node::Scalar { value, .. } => Some(truncate_detail(value)),
Node::Mapping { entries, .. } => {
let n = entries.len();
if n == 1 {
Some("1 key".to_string())
} else {
Some(format!("{n} keys"))
}
}
Node::Sequence { items, .. } => {
let n = items.len();
if n == 1 {
Some("1 item".to_string())
} else {
Some(format!("{n} items"))
}
}
Node::Alias { .. } => None,
}
}
#[expect(
deprecated,
reason = "DocumentSymbol.deprecated field is required by LSP spec"
)]
fn make_symbol(
key_name: &str,
key_node: &Node<Span>,
value: &Node<Span>,
idx: &LineIndex,
) -> DocumentSymbol {
let key_loc = node_loc(key_node);
let value_loc = node_loc(value);
let selection_range = span_to_lsp(key_loc, idx);
let range = Range::new(
offset_to_lsp(key_loc.start, idx),
offset_to_lsp(value_loc.end, idx),
);
let children = match value {
Node::Mapping { entries, .. } => {
let child_symbols: Vec<DocumentSymbol> = entries
.iter()
.map(|(k, v)| make_symbol(&node_to_string(k), k, v, idx))
.collect();
if child_symbols.is_empty() {
None
} else {
Some(child_symbols)
}
}
Node::Sequence { items, .. } => {
let child_symbols = make_sequence_children(items, idx);
if child_symbols.is_empty() {
None
} else {
Some(child_symbols)
}
}
Node::Scalar { .. } | Node::Alias { .. } => None,
};
DocumentSymbol {
name: key_name.to_string(),
detail: value_detail(value),
kind: node_symbol_kind(value),
tags: None,
deprecated: None,
range,
selection_range,
children,
}
}
const LABEL_KEYS: &[&str] = &["name", "id", "key"];
fn label_from_mapping(entries: &[(Node<Span>, Node<Span>)]) -> Option<&str> {
let (first_key, first_value) = entries.first()?;
let key_str = match first_key {
Node::Scalar { value, .. } => value.as_str(),
Node::Mapping { .. } | Node::Sequence { .. } | Node::Alias { .. } => return None,
};
if !LABEL_KEYS.contains(&key_str) {
return None;
}
match first_value {
Node::Scalar { value, .. } => Some(value.as_str()),
Node::Mapping { .. } | Node::Sequence { .. } | Node::Alias { .. } => None,
}
}
#[expect(
deprecated,
reason = "DocumentSymbol.deprecated field is required by LSP spec"
)]
fn make_sequence_children(items: &[Node<Span>], idx: &LineIndex) -> Vec<DocumentSymbol> {
let mut children = Vec::new();
for (i, item) in items.iter().enumerate() {
let item_loc = node_loc(item);
let index_name = format!("[{i}]");
let (name, detail) = match item {
Node::Mapping { entries, .. } => label_from_mapping(entries).map_or_else(
|| (index_name.clone(), None),
|label| (label.to_string(), Some(index_name.clone())),
),
Node::Scalar { .. } | Node::Sequence { .. } | Node::Alias { .. } => {
(index_name.clone(), None)
}
};
let range = span_to_lsp(item_loc, idx);
let start = offset_to_lsp(item_loc.start, idx);
let sel_end = Position::new(start.line, start.character + 1);
let selection_range = Range::new(start, sel_end);
let item_children = match item {
Node::Mapping { entries, .. } => {
let cs: Vec<DocumentSymbol> = entries
.iter()
.map(|(k, v)| make_symbol(&node_to_string(k), k, v, idx))
.collect();
if cs.is_empty() { None } else { Some(cs) }
}
Node::Scalar { .. } | Node::Sequence { .. } | Node::Alias { .. } => None,
};
children.push(DocumentSymbol {
name,
detail,
kind: node_symbol_kind(item),
tags: None,
deprecated: None,
range,
selection_range,
children: item_children,
});
}
children
}
const fn node_loc(node: &Node<Span>) -> Span {
match node {
Node::Scalar { loc, .. }
| Node::Mapping { loc, .. }
| Node::Sequence { loc, .. }
| Node::Alias { loc, .. } => *loc,
}
}
fn node_symbol_kind(node: &Node<Span>) -> SymbolKind {
match node {
Node::Mapping { .. } => SymbolKind::OBJECT,
Node::Sequence { .. } => SymbolKind::ARRAY,
Node::Scalar { tag, .. } => match tag.as_deref() {
Some("tag:yaml.org,2002:null") => SymbolKind::NULL,
Some("tag:yaml.org,2002:bool") => SymbolKind::BOOLEAN,
Some("tag:yaml.org,2002:int" | "tag:yaml.org,2002:float") => SymbolKind::NUMBER,
_ => SymbolKind::STRING,
},
Node::Alias { .. } => SymbolKind::STRING,
}
}
fn node_to_string(node: &Node<Span>) -> String {
match node {
Node::Scalar { value, .. } => value.clone(),
Node::Mapping { .. } | Node::Sequence { .. } | Node::Alias { .. } => {
format!("{node:?}")
}
}
}
#[cfg(test)]
#[expect(clippy::indexing_slicing, clippy::expect_used, reason = "test code")]
mod tests {
use rstest::rstest;
use super::*;
use crate::test_utils::parse_docs as parse_docs_inner;
use tower_lsp::lsp_types::SymbolKind;
#[expect(clippy::unnecessary_wraps, reason = "callers use Option API")]
fn parse_docs(text: &str) -> Option<Vec<Document<Span>>> {
Some(parse_docs_inner(text))
}
fn find_symbol<'a>(symbols: &'a [DocumentSymbol], name: &str) -> Option<&'a DocumentSymbol> {
symbols.iter().find(|s| s.name == name)
}
#[test]
fn should_return_symbols_for_flat_mapping() {
let text = "name: Alice\nage: 30\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 2);
let name_sym = find_symbol(&symbols, "name").expect("should have 'name' symbol");
assert_eq!(name_sym.kind, SymbolKind::STRING);
let age_sym = find_symbol(&symbols, "age").expect("should have 'age' symbol");
assert_eq!(age_sym.kind, SymbolKind::NUMBER);
}
#[test]
fn should_return_symbol_with_object_kind_for_mapping() {
let text = "server:\n port: 8080\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 1);
let server = &symbols[0];
assert_eq!(server.name, "server");
assert_eq!(server.kind, SymbolKind::OBJECT);
let children = server.children.as_ref().expect("should have children");
assert!(
find_symbol(children, "port").is_some(),
"should have 'port' child"
);
}
#[test]
fn should_return_symbol_with_array_kind_for_sequence() {
let text = "items:\n - one\n - two\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].name, "items");
assert_eq!(symbols[0].kind, SymbolKind::ARRAY);
}
#[test]
fn should_return_nested_symbols() {
let text = "server:\n host: localhost\n port: 8080\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 1);
let server = &symbols[0];
assert_eq!(server.kind, SymbolKind::OBJECT);
let children = server.children.as_ref().expect("should have children");
assert_eq!(children.len(), 2);
assert!(find_symbol(children, "host").is_some());
assert!(find_symbol(children, "port").is_some());
}
#[test]
fn should_return_deeply_nested_symbols() {
let text = "a:\n b:\n c: deep\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 1);
let a = &symbols[0];
assert_eq!(a.name, "a");
let b_children = a.children.as_ref().expect("a should have children");
let b = find_symbol(b_children, "b").expect("should have 'b'");
let c_children = b.children.as_ref().expect("b should have children");
assert!(find_symbol(c_children, "c").is_some(), "should have 'c'");
}
#[test]
fn should_return_correct_symbol_kinds_for_scalar_types() {
let text = "str_val: hello\nint_val: 42\nbool_val: true\nnull_val: ~\nfloat_val: 3.14\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 5);
assert_eq!(
find_symbol(&symbols, "str_val").expect("str_val").kind,
SymbolKind::STRING
);
assert_eq!(
find_symbol(&symbols, "int_val").expect("int_val").kind,
SymbolKind::NUMBER
);
assert_eq!(
find_symbol(&symbols, "bool_val").expect("bool_val").kind,
SymbolKind::BOOLEAN
);
assert_eq!(
find_symbol(&symbols, "null_val").expect("null_val").kind,
SymbolKind::NULL
);
assert_eq!(
find_symbol(&symbols, "float_val").expect("float_val").kind,
SymbolKind::NUMBER
);
}
#[test]
fn should_return_symbols_for_mixed_types() {
let text = "name: Alice\naddress:\n city: Wonderland\nhobbies:\n - reading\n - chess\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 3);
let name = find_symbol(&symbols, "name").expect("name");
assert_eq!(name.kind, SymbolKind::STRING);
let address = find_symbol(&symbols, "address").expect("address");
assert_eq!(address.kind, SymbolKind::OBJECT);
let addr_children = address.children.as_ref().expect("address children");
assert!(find_symbol(addr_children, "city").is_some());
let hobbies = find_symbol(&symbols, "hobbies").expect("hobbies");
assert_eq!(hobbies.kind, SymbolKind::ARRAY);
}
#[test]
fn should_return_empty_for_empty_document() {
let text = "";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert!(symbols.is_empty());
}
#[test]
fn should_return_empty_for_empty_slice() {
let symbols = document_symbols(&[]);
assert!(symbols.is_empty());
}
#[test]
fn should_return_symbols_for_multi_document_yaml() {
let text = "doc1key: value1\n---\ndoc2key: value2\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let has_doc1 = symbols.iter().any(|s| {
s.children
.as_ref()
.is_some_and(|c| c.iter().any(|ch| ch.name == "doc1key"))
});
let has_doc2 = symbols.iter().any(|s| {
s.children
.as_ref()
.is_some_and(|c| c.iter().any(|ch| ch.name == "doc2key"))
});
assert!(has_doc1, "should contain doc1key inside a wrapper");
assert!(has_doc2, "should contain doc2key inside a wrapper");
}
#[test]
fn should_set_ranges_on_symbols() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 1);
let sym = &symbols[0];
assert_eq!(sym.range.start.line, 0);
assert_eq!(sym.selection_range.start.line, 0);
}
#[test]
fn should_set_selection_range_to_key_span() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 1);
let sym = &symbols[0];
assert_eq!(sym.selection_range.start.character, 0);
assert_eq!(sym.selection_range.end.character, 4); }
#[test]
fn should_handle_sequence_of_mappings() {
let text = "users:\n - name: Alice\n age: 30\n - name: Bob\n age: 25\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 1);
let users = &symbols[0];
assert_eq!(users.name, "users");
assert_eq!(users.kind, SymbolKind::ARRAY);
let children = users.children.as_ref().expect("users should have children");
assert!(
children.len() >= 2,
"should have at least 2 sequence item children"
);
let first = &children[0];
let first_children = first
.children
.as_ref()
.expect("first item should have children");
assert!(
first_children.iter().any(|c| c.name == "name"),
"first item should have 'name'"
);
assert!(
first_children.iter().any(|c| c.name == "age"),
"first item should have 'age'"
);
}
#[test]
fn should_return_empty_for_comment_only_document() {
let text = "# just a comment\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert!(symbols.is_empty());
}
#[test]
fn should_handle_document_with_only_separator() {
let text = "---\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let _ = symbols;
}
#[test]
fn sequence_root_produces_symbols() {
let text = "- one\n- two\n- three\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert!(
!symbols.is_empty(),
"sequence root should produce symbols, got: {symbols:?}"
);
}
#[test]
fn scalar_root_produces_single_symbol() {
let text = "just a scalar\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(
symbols.len(),
1,
"scalar root should produce exactly 1 symbol, got: {symbols:?}"
);
}
#[test]
fn should_produce_sequence_children_for_bare_dash_items() {
let text = "items:\n -\n - two\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let items = find_symbol(&symbols, "items");
assert!(items.is_some(), "should have 'items' symbol");
}
#[test]
fn should_handle_integer_keyed_mapping() {
let text = "1: one\n2: two\n";
let docs = parse_docs(text);
let _symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
}
#[test]
fn should_return_symbols_from_both_docs_when_content_precedes_separator() {
let text = "before: separator\n---\nafter: separator\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let has_before = symbols.iter().any(|s| {
s.children
.as_ref()
.is_some_and(|c| c.iter().any(|ch| ch.name == "before"))
});
let has_after = symbols.iter().any(|s| {
s.children
.as_ref()
.is_some_and(|c| c.iter().any(|ch| ch.name == "after"))
});
assert!(has_before, "should have 'before' symbol inside a wrapper");
assert!(has_after, "should have 'after' symbol inside a wrapper");
}
#[test]
fn should_produce_symbols_for_sequence_of_mappings_with_multiple_keys() {
let text = "list:\n - a: 1\n b: 2\n - a: 3\n b: 4\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let list = find_symbol(&symbols, "list").expect("list symbol");
assert_eq!(list.kind, SymbolKind::ARRAY);
let children = list.children.as_ref().expect("list children");
assert!(children.len() >= 2, "should have at least 2 items");
let first = &children[0];
let first_children = first.children.as_ref().expect("first item children");
assert!(first_children.iter().any(|c| c.name == "a"));
assert!(first_children.iter().any(|c| c.name == "b"));
}
#[test]
fn should_extend_symbol_range_to_last_child_line() {
let text = "root:\n child1: a\n child2: b\n child3: c\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let root = find_symbol(&symbols, "root").expect("root symbol");
assert!(
root.range.end.line >= 3,
"root range should extend to last child line, got: {:?}",
root.range.end.line
);
}
#[test]
fn should_return_empty_for_empty_documents_vec() {
let symbols = document_symbols(&[]);
assert!(
symbols.is_empty(),
"empty documents should produce no symbols"
);
}
#[test]
fn utf8_key_selection_range_covers_full_key() {
let text = "名前: Alice\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 1, "should have exactly 1 symbol");
let sym = &symbols[0];
assert_eq!(sym.selection_range.start.character, 0);
assert_eq!(
sym.selection_range.end.character, 2,
"selection_range.end.character should be 2 codepoints (名前 = 2 chars)"
);
}
#[test]
fn deeply_nested_range_enclosure() {
let text = "a:\n b:\n c:\n d: leaf\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 1);
let a = &symbols[0];
assert!(
a.range.end.line >= 3,
"a.range.end.line should reach last line, got {}",
a.range.end.line
);
let b_children = a.children.as_ref().expect("a should have children");
let b = find_symbol(b_children, "b").expect("should have 'b'");
assert!(
b.range.end.line >= 3,
"b.range.end.line should reach last line, got {}",
b.range.end.line
);
assert!(
a.range.end >= b.range.end,
"a.range.end {:?} should >= b.range.end {:?}",
a.range.end,
b.range.end
);
let c_children = b.children.as_ref().expect("b should have children");
let c = find_symbol(c_children, "c").expect("should have 'c'");
assert!(
b.range.end >= c.range.end,
"b.range.end {:?} should >= c.range.end {:?}",
b.range.end,
c.range.end
);
}
#[test]
fn sequence_of_mappings_indexed_children_with_grandchildren() {
let text = "users:\n - name: Alice\n age: 30\n - name: Bob\n age: 25\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let users = find_symbol(&symbols, "users").expect("users symbol");
assert_eq!(users.kind, SymbolKind::ARRAY);
let children = users.children.as_ref().expect("users children");
assert_eq!(children.len(), 2, "should have exactly 2 sequence items");
let item0 = &children[0];
assert_eq!(item0.name, "Alice", "first item should use label-key value");
assert_eq!(
item0.detail.as_deref(),
Some("[0]"),
"detail shows original index when label-key used"
);
let item1 = &children[1];
assert_eq!(item1.name, "Bob", "second item should use label-key value");
assert_eq!(
item1.detail.as_deref(),
Some("[1]"),
"detail shows original index when label-key used"
);
let gc0 = item0.children.as_ref().expect("Alice should have children");
assert!(gc0.iter().any(|c| c.name == "name"), "Alice missing 'name'");
assert!(gc0.iter().any(|c| c.name == "age"), "Alice missing 'age'");
let gc1 = item1.children.as_ref().expect("Bob should have children");
assert!(gc1.iter().any(|c| c.name == "name"), "Bob missing 'name'");
assert_eq!(
item0.selection_range.start.line, item0.selection_range.end.line,
"selection_range is single-line"
);
assert_eq!(
item0.selection_range.end.character,
item0.selection_range.start.character + 1,
"selection_range spans exactly 1 character"
);
}
#[test]
fn multi_document_symbols_scoped_per_doc() {
let text = "doc1: v1\n---\ndoc2: v2\n---\ndoc3: v3\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 3, "should have 3 NAMESPACE wrapper symbols");
let doc1_children = symbols[0]
.children
.as_ref()
.expect("Document 1 should have children");
let doc1_sym = find_symbol(doc1_children, "doc1").expect("should have doc1 in Document 1");
assert_eq!(doc1_sym.range.start.line, 0, "doc1 should start at line 0");
let doc2_children = symbols[1]
.children
.as_ref()
.expect("Document 2 should have children");
assert!(
find_symbol(doc2_children, "doc2").is_some(),
"should have doc2 in Document 2"
);
let doc3_children = symbols[2]
.children
.as_ref()
.expect("Document 3 should have children");
let doc3_sym = find_symbol(doc3_children, "doc3").expect("should have doc3 in Document 3");
assert_eq!(doc3_sym.range.start.line, 4, "doc3 should start at line 4");
}
#[test]
fn tag_driven_quoted_integer_gets_string_symbol_kind() {
let text = "count: \"42\"\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let sym = find_symbol(&symbols, "count").expect("should have 'count'");
assert_eq!(
sym.kind,
SymbolKind::STRING,
"quoted '42' has str tag — must be STRING, not NUMBER"
);
}
#[test]
fn tag_driven_explicit_null_tag_gives_null_symbol_kind() {
let text = "key: !!null foo\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let sym = find_symbol(&symbols, "key").expect("should have 'key'");
assert_eq!(
sym.kind,
SymbolKind::NULL,
"!!null tag must produce NULL symbol kind regardless of value content"
);
}
#[test]
fn symbols_multibyte_key_lsp_character_correct() {
let text = "日本語: val\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let sym = find_symbol(&symbols, "日本語").expect("should have '日本語' symbol");
assert_eq!(
sym.selection_range.start.character, 0,
"selection_range start.character should be 0, got {}",
sym.selection_range.start.character
);
assert_eq!(
sym.selection_range.end.character, 3,
"selection_range end.character should be 3 (codepoints), not 9 (bytes), got {}",
sym.selection_range.end.character
);
}
#[test]
fn scalar_detail_short_value_appears_verbatim() {
let text = "key: hello\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols[0].detail.as_deref(), Some("hello"));
}
#[test]
fn scalar_detail_exact_60_chars_appears_verbatim() {
let value: String = "x".repeat(60);
let text = format!("key: {value}\n");
let docs = parse_docs(&text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(
symbols[0].detail.as_deref(),
Some(value.as_str()),
"exactly 60 chars should not be truncated"
);
}
#[test]
fn scalar_detail_over_60_chars_is_truncated_with_ellipsis() {
let value: String = "x".repeat(61);
let text = format!("key: {value}\n");
let docs = parse_docs(&text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let detail = symbols[0].detail.as_deref().expect("should have detail");
assert!(
detail.ends_with('…'),
"detail should end with ellipsis, got: {detail}"
);
let without_ellipsis: String = detail.chars().filter(|&c| c != '…').collect();
assert_eq!(
without_ellipsis.chars().count(),
60,
"content before ellipsis should be exactly 60 chars"
);
}
#[test]
fn mapping_value_detail_shows_n_keys() {
let text = "server:\n host: localhost\n port: 8080\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let server = find_symbol(&symbols, "server").expect("should have 'server'");
assert_eq!(server.detail.as_deref(), Some("2 keys"));
}
#[rstest]
#[case::one_key("cfg:\n debug: true\n", "1 key")]
#[case::two_keys("cfg:\n a: 1\n b: 2\n", "2 keys")]
fn mapping_value_detail_singular_and_plural(#[case] text: &str, #[case] expected: &str) {
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let cfg = find_symbol(&symbols, "cfg").expect("should have 'cfg'");
assert_eq!(cfg.detail.as_deref(), Some(expected));
}
#[test]
fn sequence_value_detail_shows_n_items() {
let text = "tags:\n - alpha\n - beta\n - gamma\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let tags = find_symbol(&symbols, "tags").expect("should have 'tags'");
assert_eq!(tags.detail.as_deref(), Some("3 items"));
}
#[rstest]
#[case::one_item("tags:\n - only\n", "1 item")]
#[case::two_items("tags:\n - a\n - b\n", "2 items")]
fn sequence_value_detail_singular_and_plural(#[case] text: &str, #[case] expected: &str) {
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let tags = find_symbol(&symbols, "tags").expect("should have 'tags'");
assert_eq!(tags.detail.as_deref(), Some(expected));
}
#[test]
fn label_key_name_used_as_sequence_item_name() {
let text = "items:\n - name: nginx\n image: nginx:latest\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let items = find_symbol(&symbols, "items").expect("should have 'items'");
let children = items.children.as_ref().expect("items should have children");
assert_eq!(children[0].name, "nginx");
assert_eq!(children[0].detail.as_deref(), Some("[0]"));
}
#[test]
fn label_key_id_used_as_sequence_item_name() {
let text = "rules:\n - id: rule-001\n action: allow\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let rules = find_symbol(&symbols, "rules").expect("should have 'rules'");
let children = rules.children.as_ref().expect("rules should have children");
assert_eq!(children[0].name, "rule-001");
assert_eq!(children[0].detail.as_deref(), Some("[0]"));
}
#[test]
fn label_key_key_used_as_sequence_item_name() {
let text = "entries:\n - key: primary\n value: 1\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let entries = find_symbol(&symbols, "entries").expect("should have 'entries'");
let children = entries
.children
.as_ref()
.expect("entries should have children");
assert_eq!(children[0].name, "primary");
assert_eq!(children[0].detail.as_deref(), Some("[0]"));
}
#[test]
fn non_label_first_key_falls_back_to_index() {
let text = "list:\n - host: db.local\n port: 5432\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let list = find_symbol(&symbols, "list").expect("should have 'list'");
let children = list.children.as_ref().expect("list should have children");
assert_eq!(children[0].name, "[0]");
assert!(children[0].detail.is_none());
}
#[test]
fn label_key_present_but_value_not_scalar_falls_back_to_index() {
let text = "items:\n - name:\n nested: true\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let items = find_symbol(&symbols, "items").expect("should have 'items'");
let children = items.children.as_ref().expect("items should have children");
assert_eq!(children[0].name, "[0]");
assert!(children[0].detail.is_none());
}
#[test]
fn sequence_item_not_a_mapping_falls_back_to_index() {
let text = "tags:\n - alpha\n - beta\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let tags = find_symbol(&symbols, "tags").expect("should have 'tags'");
let children = tags.children.as_ref().expect("tags should have children");
assert_eq!(children[0].name, "[0]");
assert!(children[0].detail.is_none());
assert_eq!(children[1].name, "[1]");
assert!(children[1].detail.is_none());
}
#[test]
fn label_key_check_uses_first_entry_only() {
let text = "items:\n - value: first\n name: alice\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let items = find_symbol(&symbols, "items").expect("should have 'items'");
let children = items.children.as_ref().expect("items should have children");
assert_eq!(
children[0].name, "[0]",
"should fall back because 'value' (first key) is not in label list"
);
assert!(children[0].detail.is_none());
}
#[test]
fn mixed_sequence_items_some_labeled_some_fallback() {
let text = "items:\n - name: web\n port: 80\n - host: db\n port: 5432\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let items = find_symbol(&symbols, "items").expect("should have 'items'");
let children = items.children.as_ref().expect("items should have children");
assert_eq!(children[0].name, "web");
assert_eq!(children[0].detail.as_deref(), Some("[0]"));
assert_eq!(children[1].name, "[1]");
assert!(children[1].detail.is_none());
}
#[test]
fn empty_mapping_sequence_item_falls_back_to_index() {
let text = "items:\n - {}\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
let items = find_symbol(&symbols, "items").expect("should have 'items'");
let children = items.children.as_ref().expect("items should have children");
assert_eq!(children[0].name, "[0]");
assert!(children[0].detail.is_none());
}
#[test]
fn sequence_root_produces_sequence_item_symbols() {
let text = "- one\n- two\n- three\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert!(
symbols.len() >= 3,
"should produce at least 3 items, got: {symbols:?}"
);
assert_eq!(symbols[0].name, "[0]");
assert_eq!(symbols[1].name, "[1]");
assert_eq!(symbols[2].name, "[2]");
}
#[test]
fn sequence_root_item_names_follow_index_convention() {
let text = "- alpha\n- beta\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 2);
assert_eq!(symbols[0].name, "[0]");
assert!(symbols[0].children.is_none());
assert_eq!(symbols[0].kind, SymbolKind::STRING);
assert_eq!(symbols[1].name, "[1]");
assert!(symbols[1].children.is_none());
assert_eq!(symbols[1].kind, SymbolKind::STRING);
}
#[test]
fn sequence_root_with_labeled_mapping_items_uses_label_key() {
let text = "- name: nginx\n image: nginx:latest\n- name: sidecar\n image: busybox\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 2);
assert_eq!(symbols[0].name, "nginx", "first item should use label-key");
assert_eq!(symbols[0].detail.as_deref(), Some("[0]"));
assert_eq!(
symbols[1].name, "sidecar",
"second item should use label-key"
);
assert_eq!(symbols[1].detail.as_deref(), Some("[1]"));
}
#[test]
fn scalar_root_produces_single_symbol_with_scalar_value_as_name() {
let text = "just a scalar\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].name, "just a scalar");
}
#[test]
fn scalar_root_symbol_has_correct_kind() {
let text = "42\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].name, "42");
assert_eq!(symbols[0].kind, SymbolKind::NUMBER);
}
#[test]
fn scalar_root_empty_string() {
let text = "\"\"\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(
symbols.len(),
1,
"empty quoted string root should produce 1 symbol"
);
assert_eq!(symbols[0].name, "");
}
#[test]
fn two_doc_file_wraps_each_doc_in_namespace_symbol() {
let text = "doc1: v1\n---\ndoc2: v2\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(
symbols.len(),
2,
"two-doc file should produce 2 top-level NAMESPACE symbols"
);
assert_eq!(symbols[0].kind, SymbolKind::NAMESPACE);
assert_eq!(symbols[1].kind, SymbolKind::NAMESPACE);
let children0 = symbols[0]
.children
.as_ref()
.expect("Document 1 should have children");
assert!(
find_symbol(children0, "doc1").is_some(),
"Document 1 should contain 'doc1'"
);
let children1 = symbols[1]
.children
.as_ref()
.expect("Document 2 should have children");
assert!(
find_symbol(children1, "doc2").is_some(),
"Document 2 should contain 'doc2'"
);
}
#[test]
fn two_doc_wrapper_names_are_document_1_and_document_2() {
let text = "doc1: v1\n---\ndoc2: v2\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 2);
assert_eq!(symbols[0].name, "Document 1");
assert_eq!(symbols[1].name, "Document 2");
}
#[test]
fn three_doc_file_produces_three_namespace_wrappers() {
let text = "doc1: v1\n---\ndoc2: v2\n---\ndoc3: v3\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(
symbols.len(),
3,
"three-doc file should produce 3 NAMESPACE wrappers"
);
let children0 = symbols[0]
.children
.as_ref()
.expect("Document 1 should have children");
assert!(find_symbol(children0, "doc1").is_some());
let children1 = symbols[1]
.children
.as_ref()
.expect("Document 2 should have children");
assert!(find_symbol(children1, "doc2").is_some());
let children2 = symbols[2]
.children
.as_ref()
.expect("Document 3 should have children");
assert!(find_symbol(children2, "doc3").is_some());
}
#[test]
fn multi_doc_wrapper_range_spans_full_document() {
let text = "a: 1\n---\nb: 2\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 2);
assert_eq!(
symbols[0].range.start.line, 0,
"wrapper 1 should start at line 0"
);
assert_eq!(
symbols[1].range.start.line, 2,
"wrapper 2 should start at line 2"
);
assert!(
symbols[0].range.end <= symbols[1].range.start,
"wrapper ranges must not overlap: {:?} vs {:?}",
symbols[0].range.end,
symbols[1].range.start
);
}
#[test]
fn single_doc_file_has_no_wrapper_symbol() {
let text = "name: Alice\nage: 30\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 2);
assert!(find_symbol(&symbols, "name").is_some());
assert!(find_symbol(&symbols, "age").is_some());
for sym in &symbols {
assert_ne!(
sym.kind,
SymbolKind::NAMESPACE,
"single-doc file must not produce NAMESPACE wrapper symbols"
);
}
}
#[test]
fn multi_doc_where_one_doc_has_sequence_root_still_wraps() {
let text = "mapping: val\n---\n- item1\n- item2\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(symbols.len(), 2, "should produce 2 NAMESPACE wrappers");
assert_eq!(symbols[0].kind, SymbolKind::NAMESPACE);
assert_eq!(symbols[1].kind, SymbolKind::NAMESPACE);
let children0 = symbols[0]
.children
.as_ref()
.expect("Document 1 should have children");
assert!(
find_symbol(children0, "mapping").is_some(),
"Document 1 should contain 'mapping'"
);
let children1 = symbols[1]
.children
.as_ref()
.expect("Document 2 should have children");
assert!(
children1.iter().any(|c| c.name == "[0]"),
"Document 2 should contain sequence item '[0]'"
);
assert!(
children1.iter().any(|c| c.name == "[1]"),
"Document 2 should contain sequence item '[1]'"
);
}
#[test]
fn multi_doc_where_one_doc_is_empty_still_wraps() {
let text = "key: val\n---\n";
let docs = parse_docs(text);
let symbols = document_symbols(docs.as_deref().unwrap_or(&[]));
assert_eq!(
symbols.len(),
2,
"should produce 2 NAMESPACE wrappers even if one doc is empty"
);
assert_eq!(symbols[0].kind, SymbolKind::NAMESPACE);
assert_eq!(symbols[1].kind, SymbolKind::NAMESPACE);
let children0 = symbols[0]
.children
.as_ref()
.expect("Document 1 should have children");
assert!(
find_symbol(children0, "key").is_some(),
"Document 1 should contain 'key'"
);
if let Some(children1) = &symbols[1].children {
let _ = children1;
}
}
}