use std::fmt::Write as _;
use rlsp_yaml_parser::node::{Document, Node};
use rlsp_yaml_parser::{LineIndex, Pos, Span};
use tower_lsp::lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position};
use crate::schema::JsonSchema;
const MAX_EXAMPLES: usize = 3;
const MAX_DESCRIPTION_LEN: usize = 200;
const MAX_EXAMPLE_LEN: usize = 100;
#[must_use]
pub fn hover_at(
docs: &[Document<Span>],
position: Position,
schema: Option<&JsonSchema>,
) -> Option<Hover> {
if docs.is_empty() {
return None;
}
let cursor = Pos {
byte_offset: 0,
line: position.line as usize + 1,
column: position.character as usize,
};
let (path, node) = ast_walk(docs, cursor)?;
let formatted_path = format_path(&path);
let yaml_type = yaml_type_name(node);
let value = scalar_value(node);
let mut markdown = format_hover_markdown(&formatted_path, &yaml_type, value.as_deref());
if let Some(s) = schema {
let key_path = build_schema_key_path(&formatted_path);
if let Some(prop_schema) = resolve_schema_path(s, &key_path) {
let schema_section = format_schema_section(prop_schema);
if !schema_section.is_empty() {
markdown.push('\n');
markdown.push_str(&schema_section);
}
}
}
Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: markdown,
}),
range: None,
})
}
#[derive(Debug, Clone)]
enum PathSegment {
Key(String),
Index(usize),
}
fn ast_walk(docs: &[Document<Span>], cursor: Pos) -> Option<(Vec<PathSegment>, &Node<Span>)> {
for doc in docs {
let idx = doc.line_index();
if span_contains(node_loc(&doc.root), cursor, idx) {
let mut path = Vec::new();
if let Some(result) = walk_node(&doc.root, cursor, &mut path, idx) {
return Some(result);
}
}
}
None
}
fn walk_node<'a>(
node: &'a Node<Span>,
cursor: Pos,
path: &mut Vec<PathSegment>,
idx: &LineIndex,
) -> Option<(Vec<PathSegment>, &'a Node<Span>)> {
match node {
Node::Mapping { entries, .. } => {
for (key, value) in entries {
let key_name = match key {
Node::Scalar { value, .. } => value.clone(),
Node::Mapping { .. } | Node::Sequence { .. } | Node::Alias { .. } => continue,
};
if span_contains(node_loc(key), cursor, idx) {
path.push(PathSegment::Key(key_name));
return Some((path.clone(), value));
}
if span_contains(node_loc(value), cursor, idx) {
path.push(PathSegment::Key(key_name));
return walk_node(value, cursor, path, idx)
.or_else(|| Some((path.clone(), value)));
}
}
None
}
Node::Sequence { items, .. } => {
for (i, item) in items.iter().enumerate() {
if span_contains(node_loc(item), cursor, idx) {
path.push(PathSegment::Index(i));
return walk_node(item, cursor, path, idx)
.or_else(|| Some((path.clone(), item)));
}
}
None
}
Node::Scalar { .. } | Node::Alias { .. } => Some((path.clone(), node)),
}
}
fn span_contains(span: Span, cursor: Pos, idx: &LineIndex) -> bool {
let (start_line, start_col) = idx.line_column(span.start);
let (end_line, end_col) = idx.line_column(span.end);
let start = (start_line as usize, start_col as usize);
let end = (end_line as usize, end_col as usize);
let pos = (cursor.line, cursor.column);
start <= pos && pos < end
}
const fn node_loc(node: &Node<Span>) -> Span {
match node {
Node::Scalar { loc, .. }
| Node::Mapping { loc, .. }
| Node::Sequence { loc, .. }
| Node::Alias { loc, .. } => *loc,
}
}
fn format_path(path: &[PathSegment]) -> String {
let mut result = String::new();
for (i, segment) in path.iter().enumerate() {
match segment {
PathSegment::Key(key) => {
if i > 0 {
result.push('.');
}
result.push_str(key);
}
PathSegment::Index(idx) => {
result.push('[');
result.push_str(&idx.to_string());
result.push(']');
}
}
}
result
}
fn yaml_type_name(node: &Node<Span>) -> String {
match node {
Node::Mapping { .. } => "mapping".to_string(),
Node::Sequence { .. } => "sequence".to_string(),
Node::Scalar { .. } => "scalar".to_string(),
Node::Alias { .. } => "alias".to_string(),
}
}
fn scalar_value(node: &Node<Span>) -> Option<String> {
match node {
Node::Scalar { value, .. } => Some(value.clone()),
Node::Mapping { .. } | Node::Sequence { .. } | Node::Alias { .. } => None,
}
}
fn escape_for_code_span(s: &str) -> String {
s.replace('`', "\\`")
}
fn truncate_to(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let keep = max_chars - 1;
let truncated: String = s
.char_indices()
.nth(keep)
.map_or_else(|| s.to_string(), |(byte_idx, _)| s[..byte_idx].to_string());
format!("{truncated}\u{2026}")
}
fn format_hover_markdown(path: &str, yaml_type: &str, value: Option<&str>) -> String {
let mut md = String::new();
let escaped_path = escape_for_code_span(path);
let _ = write!(md, "**Path:** `{escaped_path}`\n\n");
let _ = writeln!(md, "**Type:** {yaml_type}");
if let Some(val) = value {
let escaped_val = escape_for_code_span(val);
let _ = write!(md, "\n**Value:** `{escaped_val}`\n");
}
md
}
fn build_schema_key_path(dotted_path: &str) -> Vec<String> {
dotted_path
.split('.')
.flat_map(|segment| {
segment.find('[').map_or_else(
|| vec![segment.to_string()],
|bracket_pos| {
let key = &segment[..bracket_pos];
if key.is_empty() {
vec!["[]".to_string()]
} else {
vec![key.to_string(), "[]".to_string()]
}
},
)
})
.filter(|s| !s.is_empty())
.collect()
}
fn resolve_schema_path<'a>(schema: &'a JsonSchema, path: &[String]) -> Option<&'a JsonSchema> {
let [key, rest @ ..] = path else {
return None;
};
let found = schema
.properties
.as_ref()
.and_then(|props| props.get(key.as_str()));
let found = found.or_else(|| find_in_branches(schema, key));
let child = found?;
if rest.is_empty() {
Some(child)
} else {
resolve_schema_path(child, rest)
}
}
fn find_in_branches<'a>(schema: &'a JsonSchema, key: &str) -> Option<&'a JsonSchema> {
schema
.all_of
.iter()
.flatten()
.chain(schema.any_of.iter().flatten())
.chain(schema.one_of.iter().flatten())
.find_map(|branch| branch.properties.as_ref()?.get(key))
}
fn format_schema_section(schema: &JsonSchema) -> String {
let mut md = String::new();
let text = schema
.description
.as_deref()
.filter(|d| !d.is_empty())
.or_else(|| schema.title.as_deref().filter(|t| !t.is_empty()));
if let Some(desc) = text {
let truncated = truncate_to(desc, MAX_DESCRIPTION_LEN);
let _ = writeln!(md, "\n**Description:** {truncated}");
}
if let Some(schema_type) = &schema.schema_type {
let type_str = match schema_type {
crate::schema::SchemaType::Single(t) => t.clone(),
crate::schema::SchemaType::Multiple(ts) => ts.join(" | "),
};
let _ = writeln!(md, "\n**Schema type:** {type_str}");
}
if let Some(default) = &schema.default {
let _ = writeln!(md, "\n**Default:** {default}");
}
if let Some(examples) = &schema.examples
&& !examples.is_empty()
{
let shown = examples.len().min(MAX_EXAMPLES);
let _ = write!(md, "\n**Examples:**");
examples.iter().take(shown).for_each(|ex| {
match ex {
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
if let Ok(pretty) = serde_json::to_string_pretty(ex) {
if pretty.chars().count() <= MAX_EXAMPLE_LEN {
let _ = write!(md, "\n```json\n{pretty}\n```");
} else {
let compact = json_value_to_display_string(ex);
let truncated = truncate_to(&compact, MAX_EXAMPLE_LEN);
let _ = write!(md, "\n- {truncated}");
}
} else {
let truncated =
truncate_to(&json_value_to_display_string(ex), MAX_EXAMPLE_LEN);
let _ = write!(md, "\n- {truncated}");
}
}
serde_json::Value::Null
| serde_json::Value::Bool(_)
| serde_json::Value::Number(_)
| serde_json::Value::String(_) => {
let truncated = truncate_to(&json_value_to_display_string(ex), MAX_EXAMPLE_LEN);
let _ = write!(md, "\n- {truncated}");
}
}
});
let remaining = examples.len().saturating_sub(shown);
if remaining > 0 {
let _ = write!(md, "\n- *and {remaining} more*");
}
md.push('\n');
}
md
}
fn json_value_to_display_string(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null
| serde_json::Value::Bool(_)
| serde_json::Value::Number(_)
| serde_json::Value::Array(_)
| serde_json::Value::Object(_) => value.to_string(),
}
}
#[cfg(test)]
#[expect(
clippy::expect_used,
clippy::unwrap_used,
clippy::panic,
reason = "test code"
)]
mod tests {
use std::collections::HashMap;
use rstest::rstest;
use serde_json::Value as JsonValue;
use serde_json::json;
use super::*;
use crate::schema::{JsonSchema, SchemaType};
use crate::test_utils::parse_docs;
fn pos(line: u32, character: u32) -> Position {
Position::new(line, character)
}
fn hover_content(hover: &Hover) -> &str {
match &hover.contents {
HoverContents::Markup(m) => &m.value,
HoverContents::Scalar(_) | HoverContents::Array(_) => panic!("expected MarkupContent"),
}
}
fn schema_with_description(description: &str) -> JsonSchema {
JsonSchema {
description: Some(description.to_string()),
..Default::default()
}
}
#[rstest]
#[case::simple_key("name: Alice\n", pos(0, 0), "name")]
#[case::nested_key("server:\n port: 8080\n", pos(1, 2), "server.port")]
#[case::deeply_nested_key("a:\n b:\n c: deep\n", pos(2, 4), "a.b.c")]
fn hover_contains_key_path_and_scalar_type(
#[case] text: &str,
#[case] cursor: Position,
#[case] expected_path: &str,
) {
let docs = parse_docs(text);
let hover = hover_at(&docs, cursor, None).expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains(expected_path),
"should contain key path {expected_path:?}, got: {content}"
);
assert!(
content.to_lowercase().contains("scalar"),
"should mention scalar type, got: {content}"
);
}
#[test]
fn should_return_hover_for_simple_value() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(0, 6), None);
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(content.contains("name"), "should contain key path 'name'");
assert!(
content.to_lowercase().contains("scalar"),
"should mention scalar type"
);
assert!(content.contains("Alice"), "should contain value 'Alice'");
}
#[rstest]
#[case::whitespace("key: value\n\n", pos(1, 0))]
#[case::comment("# comment\nkey: value\n", pos(0, 2))]
#[case::empty_document("", pos(0, 0))]
#[case::position_beyond_document("key: value\n", pos(5, 0))]
#[case::document_separator_line("key1: value1\n---\nkey2: value2\n", pos(1, 0))]
fn hover_returns_none_for_structural_cases(#[case] text: &str, #[case] cursor: Position) {
let docs = parse_docs(text);
let result = hover_at(&docs, cursor, None);
assert!(result.is_none(), "expected None but got Some hover");
}
#[test]
fn should_return_hover_for_sequence_item() {
let text = "items:\n - first\n - second\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(1, 4), None);
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains("items[0]") || content.contains("items.0"),
"should contain path like 'items[0]' or 'items.0'"
);
assert!(content.contains("first"), "should contain value 'first'");
}
#[rstest]
#[case::mapping_value_type("server:\n port: 8080\n", pos(0, 0), "server", "mapping")]
#[case::sequence_value_type("items:\n - one\n - two\n", pos(0, 0), "items", "sequence")]
fn hover_contains_key_path_and_compound_type(
#[case] text: &str,
#[case] cursor: Position,
#[case] expected_path: &str,
#[case] expected_type: &str,
) {
let docs = parse_docs(text);
let hover = hover_at(&docs, cursor, None).expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains(expected_path),
"should contain key path {expected_path:?}, got: {content}"
);
assert!(
content.to_lowercase().contains(expected_type),
"should mention {expected_type:?} type, got: {content}"
);
}
#[test]
fn should_return_hover_with_scalar_value() {
let text = "port: 8080\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(0, 6), None);
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(content.contains("8080"), "should contain value '8080'");
}
#[test]
fn should_format_hover_as_markdown() {
let text = "key: value\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(0, 0), None);
let hover = result.expect("should return hover");
match &hover.contents {
HoverContents::Markup(m) => {
assert_eq!(m.kind, MarkupKind::Markdown);
}
HoverContents::Scalar(_) | HoverContents::Array(_) => panic!("expected MarkupContent"),
}
}
#[test]
fn should_return_none_when_document_failed_to_parse() {
let result = hover_at(&[], pos(0, 0), None);
assert!(result.is_none());
}
#[test]
fn should_return_hover_in_multi_document_yaml() {
let text = "doc1key: value1\n---\ndoc2key: value2\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(2, 0), None);
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains("doc2key"),
"should contain key path 'doc2key'"
);
}
#[test]
fn should_return_hover_for_boolean_value() {
let text = "enabled: true\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(0, 9), None);
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.to_lowercase().contains("scalar") || content.to_lowercase().contains("boolean"),
"should mention scalar or boolean type"
);
assert!(content.contains("true"), "should contain value 'true'");
}
#[test]
fn should_return_hover_for_null_value() {
let text = "empty: ~\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(0, 7), None);
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.to_lowercase().contains("scalar") || content.to_lowercase().contains("null"),
"should mention scalar or null type"
);
}
#[test]
fn schema_description_appended_for_key_at_root() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let mut props = HashMap::new();
props.insert(
"name".to_string(),
schema_with_description("The user's display name"),
);
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(content.contains("name"), "should contain key path 'name'");
assert!(
content.contains("The user's display name"),
"should contain schema description"
);
}
#[test]
fn schema_type_shown_for_key() {
let text = "port: 8080\n";
let docs = parse_docs(text);
let mut props = HashMap::new();
props.insert(
"port".to_string(),
JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
..Default::default()
},
);
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains("integer"),
"should contain schema type 'integer'"
);
}
#[test]
fn schema_default_shown_for_key() {
let text = "timeout: 30\n";
let docs = parse_docs(text);
let mut props = HashMap::new();
props.insert(
"timeout".to_string(),
JsonSchema {
default: Some(JsonValue::Number(30.into())),
..Default::default()
},
);
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(content.contains("30"), "should contain default value '30'");
assert!(
content.to_lowercase().contains("default"),
"should mention 'default'"
);
}
#[test]
fn schema_examples_shown_for_key() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let mut props = HashMap::new();
props.insert(
"name".to_string(),
JsonSchema {
examples: Some(vec![
JsonValue::String("Alice".to_string()),
JsonValue::String("Bob".to_string()),
]),
..Default::default()
},
);
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.to_lowercase().contains("example"),
"should mention examples"
);
assert!(content.contains("Alice"), "should show first example");
assert!(content.contains("Bob"), "should show second example");
}
#[test]
fn no_schema_info_for_unknown_key() {
let text = "unknown: value\n";
let docs = parse_docs(text);
let mut props = HashMap::new();
props.insert(
"known".to_string(),
schema_with_description("A known property"),
);
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains("unknown"),
"should contain key path 'unknown'"
);
assert!(
!content.contains("A known property"),
"should not show description for unknown key"
);
}
#[test]
fn schema_description_for_nested_key() {
let text = "server:\n port: 8080\n";
let docs = parse_docs(text);
let mut port_props = HashMap::new();
port_props.insert(
"port".to_string(),
JsonSchema {
description: Some("HTTP port number".to_string()),
schema_type: Some(SchemaType::Single("integer".to_string())),
..Default::default()
},
);
let mut root_props = HashMap::new();
root_props.insert(
"server".to_string(),
JsonSchema {
properties: Some(port_props),
..Default::default()
},
);
let schema = JsonSchema {
properties: Some(root_props),
..Default::default()
};
let result = hover_at(&docs, pos(1, 2), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains("server.port"),
"should contain nested path"
);
assert!(
content.contains("HTTP port number"),
"should contain nested schema description"
);
}
#[test]
fn schema_description_shown_for_value_position() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let mut props = HashMap::new();
props.insert(
"name".to_string(),
schema_with_description("The user's display name"),
);
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 6), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains("The user's display name"),
"should show schema description when hovering on value"
);
}
#[test]
fn schema_type_shown_for_value_position() {
let text = "port: 8080\n";
let docs = parse_docs(text);
let mut props = HashMap::new();
props.insert(
"port".to_string(),
JsonSchema {
schema_type: Some(SchemaType::Single("integer".to_string())),
..Default::default()
},
);
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 6), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains("integer"),
"should show schema type when hovering on value"
);
}
#[test]
fn schema_info_appended_below_structural_hover() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let mut props = HashMap::new();
props.insert(
"name".to_string(),
schema_with_description("The user's display name"),
);
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
let path_pos = content.find("Path").expect("should contain 'Path'");
let schema_pos = content
.find("The user's display name")
.expect("should contain description");
assert!(
path_pos < schema_pos,
"structural hover (Path) should appear before schema info"
);
}
#[test]
fn long_description_truncated() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let long_desc = "A".repeat(500);
let mut props = HashMap::new();
props.insert("name".to_string(), schema_with_description(&long_desc));
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
!content.contains(&long_desc),
"full 500-char description must not appear"
);
assert!(
content.contains('\u{2026}'),
"truncated description must end with ellipsis"
);
let a_run: String = content
.chars()
.skip_while(|&c| c != 'A')
.take_while(|&c| c == 'A')
.collect();
assert!(
a_run.chars().count() <= 199,
"truncated description body must be ≤199 chars (plus ellipsis = 200), got {}",
a_run.chars().count()
);
}
#[rstest]
#[case::ascii_200("a".repeat(200), 'a')]
#[case::unicode_200("é".repeat(200), 'é')]
fn long_example_value_truncated(#[case] long_example: String, #[case] marker_char: char) {
let text = "key: v\n";
let docs = parse_docs(text);
let mut props = HashMap::new();
props.insert(
"key".to_string(),
JsonSchema {
examples: Some(vec![JsonValue::String(long_example.clone())]),
..Default::default()
},
);
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
!content.contains(&long_example),
"full 200-char example must not appear verbatim"
);
assert!(
content.contains('\u{2026}'),
"truncated example must end with ellipsis"
);
let char_run: String = content
.chars()
.skip_while(|&c| c != marker_char)
.take_while(|&c| c == marker_char)
.collect();
assert!(
char_run.chars().count() <= 100,
"displayed example must be at most 100 chars (got {})",
char_run.chars().count()
);
}
#[test]
fn should_show_at_most_3_examples_with_overflow_note() {
let text = "key: v\n";
let docs = parse_docs(text);
let mut props = HashMap::new();
props.insert(
"key".to_string(),
JsonSchema {
examples: Some(vec![
JsonValue::String("a".to_string()),
JsonValue::String("b".to_string()),
JsonValue::String("c".to_string()),
JsonValue::String("d".to_string()),
JsonValue::String("e".to_string()),
]),
..Default::default()
},
);
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains("and 2 more") || content.contains("2 more"),
"should show 'and 2 more' note for 5 examples capped at 3, got: {content}"
);
let lines_with_d = content
.lines()
.filter(|l| l.trim() == "- d" || l.trim() == "d")
.count();
let lines_with_e = content
.lines()
.filter(|l| l.trim() == "- e" || l.trim() == "e")
.count();
assert_eq!(
lines_with_d, 0,
"example 'd' (4th) must not appear as a list item"
);
assert_eq!(
lines_with_e, 0,
"example 'e' (5th) must not appear as a list item"
);
}
#[test]
fn schema_info_for_two_level_nested_key() {
let text = "database:\n host: localhost\n";
let docs = parse_docs(text);
let mut db_props = HashMap::new();
db_props.insert(
"host".to_string(),
schema_with_description("Database host address"),
);
let mut root_props = HashMap::new();
root_props.insert(
"database".to_string(),
JsonSchema {
properties: Some(db_props),
..Default::default()
},
);
let schema = JsonSchema {
properties: Some(root_props),
..Default::default()
};
let result = hover_at(&docs, pos(1, 2), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains("database.host"),
"should contain nested path"
);
assert!(
content.contains("Database host address"),
"should contain nested schema description"
);
}
#[test]
fn no_schema_info_for_deeper_than_schema_provides() {
let text = "a:\n b:\n c: deep\n";
let docs = parse_docs(text);
let mut root_props = HashMap::new();
root_props.insert("a".to_string(), schema_with_description("Top level A"));
let schema = JsonSchema {
properties: Some(root_props),
..Default::default()
};
let result = hover_at(&docs, pos(2, 4), Some(&schema));
let hover = result.expect("structural hover should work");
let content = hover_content(&hover);
assert!(content.contains("a.b.c"), "should contain path a.b.c");
assert!(
!content.contains("Top level A"),
"should not show parent description when on nested key"
);
}
#[test]
fn schema_info_from_all_of_branch() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let mut branch_props = HashMap::new();
branch_props.insert(
"name".to_string(),
schema_with_description("Name from allOf branch"),
);
let schema = JsonSchema {
all_of: Some(vec![JsonSchema {
properties: Some(branch_props),
..Default::default()
}]),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains("Name from allOf branch"),
"should find property description from allOf branch"
);
}
#[test]
fn schema_info_from_any_of_branch() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let mut branch_props = HashMap::new();
branch_props.insert(
"name".to_string(),
schema_with_description("Name from anyOf branch"),
);
let schema = JsonSchema {
any_of: Some(vec![JsonSchema {
properties: Some(branch_props),
..Default::default()
}]),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains("Name from anyOf branch"),
"should find property description from anyOf branch"
);
}
#[test]
fn no_schema_hover_unchanged() {
let text = "port: 8080\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(0, 0), None);
let hover = result.expect("should return hover with None schema");
let content = hover_content(&hover);
assert!(
content.contains("port"),
"structural hover path must be present"
);
assert!(
!content.contains("---"),
"no schema section must be appended when schema is None"
);
}
#[test]
fn schema_without_matching_property_shows_structural_only() {
let text = "port: 8080\n";
let docs = parse_docs(text);
let schema = schema_with_description("Root schema description");
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(content.contains("port"), "structural hover path present");
assert!(
!content.contains("Root schema description"),
"root description should not appear for a specific key"
);
}
#[test]
fn schema_present_but_parse_fails_returns_none() {
let schema = schema_with_description("some desc");
let result = hover_at(&[], pos(0, 0), Some(&schema));
assert!(result.is_none(), "should return None when no parsed docs");
}
#[test]
fn empty_description_not_shown() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let mut props = HashMap::new();
props.insert(
"name".to_string(),
JsonSchema {
description: Some(String::new()),
..Default::default()
},
);
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(content.contains("name"), "structural hover present");
assert!(
!content.contains("**Description:**"),
"should not show description section for empty description"
);
}
#[test]
fn title_shown_when_description_absent() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let mut props = HashMap::new();
props.insert(
"name".to_string(),
JsonSchema {
title: Some("User Name".to_string()),
description: None,
..Default::default()
},
);
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains("User Name"),
"should show title when description is absent"
);
}
#[test]
fn null_default_shown() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let mut props = HashMap::new();
props.insert(
"name".to_string(),
JsonSchema {
default: Some(JsonValue::Null),
..Default::default()
},
);
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.to_lowercase().contains("null"),
"should show null default"
);
assert!(
content.to_lowercase().contains("default"),
"should label the default"
);
}
#[test]
fn description_takes_priority_over_title() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let mut props = HashMap::new();
props.insert(
"name".to_string(),
JsonSchema {
title: Some("User Name".to_string()),
description: Some("The full display name of the user".to_string()),
..Default::default()
},
);
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains("The full display name of the user"),
"should show description"
);
assert!(
!content.contains("User Name"),
"should not show title when description is present"
);
}
#[test]
fn should_show_only_first_3_examples_when_10_provided() {
let text = "key: v\n";
let docs = parse_docs(text);
let examples: Vec<JsonValue> = (0..10)
.map(|i| JsonValue::String(format!("ex{i}")))
.collect();
let mut props = HashMap::new();
props.insert(
"key".to_string(),
JsonSchema {
examples: Some(examples),
..Default::default()
},
);
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(content.contains("ex0"), "ex0 must appear");
for i in 3..10 {
let item = format!("ex{i}");
let lines_with_item = content
.lines()
.filter(|l| l.trim() == format!("- {item}") || l.trim() == item)
.count();
assert_eq!(
lines_with_item, 0,
"example '{item}' must not appear as a list item"
);
}
assert!(
content.contains("7 more"),
"should contain 'and 7 more' note, got: {content}"
);
}
#[test]
fn should_truncate_long_description_in_hover_at_200_chars() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let long_desc: String = "é".repeat(300);
let mut props = HashMap::new();
props.insert("name".to_string(), schema_with_description(&long_desc));
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
let e_run: String = content
.chars()
.skip_while(|&c| c != 'é')
.take_while(|&c| c == 'é')
.collect();
assert!(
e_run.chars().count() <= 199,
"truncation must use chars not bytes; body must be ≤199 chars (got {})",
e_run.chars().count()
);
assert!(
content.contains('\u{2026}'),
"truncated description must end with ellipsis"
);
}
#[test]
fn should_escape_backtick_in_schema_default_value() {
let text = "key: value\n";
let docs = parse_docs(text);
let mut props = HashMap::new();
props.insert(
"key".to_string(),
JsonSchema {
default: Some(JsonValue::String("foo`bar".to_string())),
..Default::default()
},
);
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
!content.contains("`foo`bar`"),
"must not contain broken code span '`foo`bar`', got: {content}"
);
assert!(
content.contains("foo"),
"default value 'foo' must appear somewhere"
);
}
#[test]
fn should_escape_backtick_in_yaml_value_display() {
let text = "foo: bar`baz\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(0, 5), None);
assert!(result.is_some(), "should return hover");
let hover = result.unwrap();
let content = hover_content(&hover);
assert!(
!content.contains("`bar`baz`"),
"must not contain broken code span '`bar`baz`', got: {content}"
);
assert!(content.contains("bar"), "value 'bar' must appear in hover");
}
#[test]
fn should_show_structural_only_when_schema_has_no_info_for_hovered_key() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let mut props = HashMap::new();
props.insert("other".to_string(), schema_with_description("Other"));
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(&docs, pos(0, 0), Some(&schema));
assert!(result.is_some(), "should return hover");
let hover = result.unwrap();
let content = hover_content(&hover);
assert!(
!content.contains("---"),
"should not contain schema section separator"
);
assert!(
!content.contains("Other"),
"should not show 'Other' description"
);
}
#[test]
fn hover_returns_sequence_value_when_cursor_on_item() {
let text = "items:\n - first\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(1, 4), None);
let hover = result.expect("should return hover for sequence item");
let content = hover_content(&hover);
assert!(
content.contains("items"),
"should contain parent key 'items'"
);
}
#[test]
fn hover_on_plain_scalar_line_returns_value_token() {
let text = "- plainvalue\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(0, 4), None);
let hover = result.expect("should return hover for plain scalar in sequence");
let content = hover_content(&hover);
assert!(
content.contains('0') || content.contains("plainvalue"),
"should contain sequence index or value"
);
}
#[test]
fn hover_on_sequence_item_key_with_no_value_returns_hover() {
let text = "items:\n - name:\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(1, 6), None);
let hover = result.expect("should return hover for key with null value");
let content = hover_content(&hover);
assert!(
content.contains("name") || content.contains("items"),
"path should reference the key"
);
}
#[test]
fn hover_on_value_side_of_mapping_returns_value_token() {
let text = "status: active\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(0, 8), None);
let hover = result.expect("should return hover for value side of mapping");
let content = hover_content(&hover);
assert!(
content.contains("status"),
"path should contain key 'status'"
);
assert!(content.contains("active"), "should contain value 'active'");
}
#[rstest]
#[case::ellipsis_terminator("key: value\n...\n", pos(1, 0))]
fn hover_returns_none_for_degenerate_input(#[case] text: &str, #[case] cursor: Position) {
let docs = parse_docs(text);
let result = hover_at(&docs, cursor, None);
assert!(result.is_none(), "expected None but got Some hover");
}
#[test]
fn hover_does_not_panic_for_line_starting_with_colon() {
let text = ": orphan\n";
let docs = parse_docs(text);
let _result = hover_at(&docs, pos(0, 0), None);
}
#[rstest]
#[case::key_before_colon("people:\n - name: Alice\n", pos(1, 5))]
#[case::value_after_colon("people:\n - name: Alice\n", pos(1, 12))]
fn hover_does_not_panic_on_sequence_item_positions(
#[case] text: &str,
#[case] cursor: Position,
) {
let docs = parse_docs(text);
let _result = hover_at(&docs, cursor, None);
}
fn schema_with_examples(examples: Vec<JsonValue>) -> JsonSchema {
let mut props = HashMap::new();
props.insert(
"key".to_string(),
JsonSchema {
examples: Some(examples),
..Default::default()
},
);
JsonSchema {
properties: Some(props),
..Default::default()
}
}
#[test]
fn object_example_renders_as_fenced_code_block() {
let schema = schema_with_examples(vec![json!({"name": "Alice", "age": 30})]);
let text = "key: v\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains("```json"),
"object example should be in a fenced json code block, got: {content}"
);
assert!(
content.contains("\"name\": \"Alice\"") || content.contains("\"age\": 30"),
"object example should be pretty-printed, got: {content}"
);
}
#[test]
fn array_example_renders_as_fenced_code_block() {
let schema = schema_with_examples(vec![json!(["a", "b", "c"])]);
let text = "key: v\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains("```json"),
"array example should be in a fenced json code block, got: {content}"
);
}
#[test]
fn simple_value_examples_use_list_item_format() {
let schema = schema_with_examples(vec![json!("hello"), json!(42), json!(true)]);
let text = "key: v\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
!content.contains("```json"),
"simple examples must not use code blocks, got: {content}"
);
assert!(
content.contains("- hello"),
"string example should appear as list item '- hello', got: {content}"
);
assert!(
content.contains("- 42"),
"number example should appear as list item '- 42', got: {content}"
);
assert!(
content.contains("- true"),
"bool example should appear as list item '- true', got: {content}"
);
}
#[test]
fn mixed_examples_dispatch_correctly_per_type() {
let schema = schema_with_examples(vec![json!({"host": "localhost"}), json!("simple")]);
let text = "key: v\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains("```json"),
"object example should use code block, got: {content}"
);
assert!(
content.contains("- simple"),
"string example should use list item format, got: {content}"
);
}
#[test]
fn long_object_example_falls_back_to_compact_inline() {
let big_value: String = "x".repeat(50);
let schema = schema_with_examples(vec![json!({
"field_a": big_value,
"field_b": "another long value that pushes past the limit"
})]);
let text = "key: v\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(0, 0), Some(&schema));
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
!content.contains("```json"),
"long object should fall back to compact inline (no code block), got: {content}"
);
}
#[test]
fn hover_uses_span_containment_not_line_counting() {
let text = "a: 1\n---\nb: 2\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(2, 0), None);
let hover = result.expect("should return hover for second document");
let content = hover_content(&hover);
assert!(
content.contains('b'),
"should resolve to second document key 'b', got: {content}"
);
assert!(
!content.contains("**Path:** `a`"),
"should not resolve to first document key 'a'"
);
}
#[test]
fn hover_on_empty_line_between_nodes_returns_none() {
let text = "a: 1\n\nb: 2\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(1, 0), None);
assert!(result.is_none(), "empty line should return None");
}
#[test]
fn hover_on_trailing_comment_returns_none() {
let text = "key: value # comment\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(0, 13), None);
assert!(
result.is_none(),
"cursor in comment region should return None"
);
}
#[test]
fn hover_on_sequence_item_path_uses_zero_based_index() {
let text = "items:\n - first\n - second\n - third\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(3, 4), None);
let hover = result.expect("should return hover for third item");
let content = hover_content(&hover);
assert!(
content.contains("items[2]") || content.contains("items.2"),
"third item should have 0-based index 2, got: {content}"
);
}
#[test]
fn hover_on_second_sequence_item_has_correct_index() {
let text = "list:\n - a\n - b\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(2, 4), None);
let hover = result.expect("should return hover for second item");
let content = hover_content(&hover);
assert!(
content.contains("list[1]") || content.contains("list.1"),
"second item should have index 1, got: {content}"
);
}
#[test]
fn hover_on_nested_mapping_value_returns_correct_path() {
let text = "outer:\n inner: hello\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(1, 9), None);
let hover = result.expect("should return hover for nested value");
let content = hover_content(&hover);
assert!(
content.contains("outer.inner"),
"should contain nested path 'outer.inner', got: {content}"
);
assert!(content.contains("hello"), "should contain value 'hello'");
}
#[test]
fn hover_on_flow_mapping_key_does_not_panic() {
let text = "meta: {name: Alice}\n";
let docs = parse_docs(text);
let _result = hover_at(&docs, pos(0, 0), None);
}
#[test]
fn hover_returns_correct_document_in_three_doc_stream() {
let text = "a: 1\n---\nb: 2\n---\nc: 3\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(4, 0), None);
let hover = result.expect("should return hover for third document");
let content = hover_content(&hover);
assert!(
content.contains('c'),
"should resolve to third document key 'c', got: {content}"
);
}
#[test]
fn hover_at_span_start_is_inclusive() {
let text = "key: val\n";
let docs = parse_docs(text);
let result = hover_at(&docs, pos(0, 5), None);
assert!(
result.is_some(),
"cursor at span start should be included (start-inclusive)"
);
}
}