use rlsp_yaml_parser::Span;
use rlsp_yaml_parser::node::{Document, Node};
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(
text: &str,
documents: Option<&Vec<Document<Span>>>,
position: Position,
schema: Option<&JsonSchema>,
) -> Option<Hover> {
let documents = documents?;
if documents.is_empty() {
return None;
}
let lines: Vec<&str> = text.lines().collect();
let line_idx = position.line as usize;
let col_idx = position.character as usize;
let line = lines.get(line_idx)?;
if col_idx > line.len() {
return None;
}
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
if trimmed == "---" || trimmed == "..." {
return None;
}
let doc_index = document_index_for_line(&lines, line_idx);
let doc = &documents.get(doc_index)?.root;
let token = token_at_cursor(line, col_idx)?;
let result = find_node_info(doc, &token, line, &lines, line_idx)?;
let mut markdown =
format_hover_markdown(&result.path, &result.yaml_type, result.value.as_deref());
if let Some(s) = schema {
let key_path = build_schema_key_path(&result.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,
})
}
fn document_index_for_line(lines: &[&str], target_line: usize) -> usize {
lines
.iter()
.enumerate()
.take(target_line)
.filter(|(i, line)| line.trim() == "---" && *i > 0)
.count()
}
enum CursorToken {
Key(String),
Value(String),
SequenceValue,
}
fn token_at_cursor(line: &str, col: usize) -> Option<CursorToken> {
let trimmed = line.trim();
if let Some(after_dash) = trimmed.strip_prefix("- ") {
let dash_col = line.find("- ").map_or(0, |i| i + 2);
if col >= dash_col {
let value = after_dash.trim();
if let Some(colon_pos) = find_mapping_colon(after_dash) {
let key = after_dash[..colon_pos].trim();
let value_part = after_dash[colon_pos + 1..].trim();
let abs_colon = dash_col + colon_pos;
if col < abs_colon {
return Some(CursorToken::Key(key.to_string()));
}
if !value_part.is_empty() {
return Some(CursorToken::Value(value_part.to_string()));
}
return Some(CursorToken::Key(key.to_string()));
}
if !value.is_empty() {
return Some(CursorToken::SequenceValue);
}
return None;
}
let value = after_dash.trim();
if !value.is_empty() {
return Some(CursorToken::SequenceValue);
}
return None;
}
if trimmed == "-" {
return None;
}
if let Some(colon_pos) = find_mapping_colon(line) {
let key = line[..colon_pos].trim();
let value_part = line[colon_pos + 1..].trim();
if col <= colon_pos || value_part.is_empty() {
if !key.is_empty() {
return Some(CursorToken::Key(key.to_string()));
}
} else {
return Some(CursorToken::Value(value_part.to_string()));
}
} else if !trimmed.is_empty() {
return Some(CursorToken::Value(trimmed.to_string()));
}
None
}
fn find_mapping_colon(line: &str) -> Option<usize> {
let mut in_single_quote = false;
let mut in_double_quote = false;
for (i, ch) in line.char_indices() {
match ch {
'\'' if !in_double_quote => in_single_quote = !in_single_quote,
'"' if !in_single_quote => in_double_quote = !in_double_quote,
':' if !in_single_quote && !in_double_quote => {
let rest = &line[i + 1..];
if rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t') {
return Some(i);
}
}
_ => {}
}
}
None
}
struct NodeInfo {
path: String,
yaml_type: String,
value: Option<String>,
}
fn find_node_info(
doc: &Node<Span>,
token: &CursorToken,
line: &str,
lines: &[&str],
line_idx: usize,
) -> Option<NodeInfo> {
let path = build_key_path(token, line, lines, line_idx);
let node = resolve_path(doc, &path)?;
let yaml_type = yaml_type_name(node);
let value = scalar_value(node);
Some(NodeInfo {
path: format_path(&path),
yaml_type,
value,
})
}
#[derive(Debug)]
enum PathSegment {
Key(String),
Index(usize),
}
fn build_key_path(
token: &CursorToken,
line: &str,
lines: &[&str],
line_idx: usize,
) -> Vec<PathSegment> {
let mut path = Vec::new();
let current_indent = indentation_level(line);
let mut parents = Vec::new();
let mut target_indent = current_indent;
if target_indent > 0 {
let mut i = line_idx;
while i > 0 {
i -= 1;
let Some(prev_line) = lines.get(i).copied() else {
continue;
};
let prev_trimmed = prev_line.trim();
if prev_trimmed.is_empty()
|| prev_trimmed.starts_with('#')
|| prev_trimmed == "---"
|| prev_trimmed == "..."
{
continue;
}
let prev_indent = indentation_level(prev_line);
if prev_indent < target_indent {
if let Some(key) = extract_key(prev_trimmed) {
parents.push(key);
target_indent = prev_indent;
if target_indent == 0 {
break;
}
}
}
}
}
parents.reverse();
path.extend(parents.into_iter().map(PathSegment::Key));
match token {
CursorToken::Key(key) | CursorToken::Value(key) => {
if matches!(token, CursorToken::Value(_)) {
if let Some(key) = extract_key(line.trim()) {
path.push(PathSegment::Key(key));
}
} else {
path.push(PathSegment::Key(key.clone()));
}
}
CursorToken::SequenceValue => {
let idx = sequence_index(lines, line_idx);
path.push(PathSegment::Index(idx));
}
}
path
}
fn indentation_level(line: &str) -> usize {
line.len() - line.trim_start().len()
}
fn extract_key(trimmed_line: &str) -> Option<String> {
let line = trimmed_line.strip_prefix("- ").unwrap_or(trimmed_line);
if let Some(colon_pos) = find_mapping_colon(line) {
let key = line[..colon_pos].trim();
if !key.is_empty() {
return Some(key.to_string());
}
}
None
}
fn sequence_index(lines: &[&str], line_idx: usize) -> usize {
let current_indent = lines.get(line_idx).map_or(0, |l| indentation_level(l));
let mut idx = 0;
for i in (0..line_idx).rev() {
let Some(prev_line) = lines.get(i).copied() else {
continue;
};
let prev_trimmed = prev_line.trim();
let prev_indent = indentation_level(prev_line);
if prev_trimmed.is_empty() || prev_trimmed.starts_with('#') {
continue;
}
if prev_indent < current_indent {
break; }
if prev_indent == current_indent && prev_trimmed.starts_with("- ") {
idx += 1;
}
}
idx
}
fn resolve_path<'a>(doc: &'a Node<Span>, path: &[PathSegment]) -> Option<&'a Node<Span>> {
let mut current = doc;
for segment in path {
match segment {
PathSegment::Key(key) => match current {
Node::Mapping { entries, .. } => {
current = entries.iter().find_map(|(k, v)| match k {
Node::Scalar { value, .. } if value == key => Some(v),
Node::Scalar { .. }
| Node::Mapping { .. }
| Node::Sequence { .. }
| Node::Alias { .. } => None,
})?;
}
Node::Scalar { .. } | Node::Sequence { .. } | Node::Alias { .. } => return None,
},
PathSegment::Index(idx) => match current {
Node::Sequence { items, .. } => {
current = items.get(*idx)?;
}
Node::Scalar { .. } | Node::Mapping { .. } | Node::Alias { .. } => return None,
},
}
}
Some(current)
}
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 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 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 {
use std::fmt::Write;
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 {
use std::fmt::Write;
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)]
#[allow(clippy::indexing_slicing, clippy::expect_used, clippy::unwrap_used)]
mod tests {
use std::collections::HashMap;
use serde_json::Value as JsonValue;
use super::*;
use crate::schema::{JsonSchema, SchemaType};
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 parse_docs(text: &str) -> Option<Vec<Document<Span>>> {
rlsp_yaml_parser::load(text).ok()
}
fn schema_with_description(description: &str) -> JsonSchema {
JsonSchema {
description: Some(description.to_string()),
..Default::default()
}
}
#[test]
fn should_return_hover_for_simple_key() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), pos(0, 0), 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"
);
}
#[test]
fn should_return_hover_for_simple_value() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), 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'");
}
#[test]
fn should_return_none_for_whitespace() {
let text = "key: value\n\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), pos(1, 0), None);
assert!(result.is_none());
}
#[test]
fn should_return_none_for_comment() {
let text = "# comment\nkey: value\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), pos(0, 2), None);
assert!(result.is_none());
}
#[test]
fn should_return_hover_for_nested_key() {
let text = "server:\n port: 8080\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), pos(1, 2), None);
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains("server.port"),
"should contain key path 'server.port'"
);
assert!(
content.to_lowercase().contains("scalar"),
"should mention scalar type"
);
}
#[test]
fn should_return_hover_for_deeply_nested_key() {
let text = "a:\n b:\n c: deep\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), pos(2, 4), None);
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(content.contains("a.b.c"), "should contain key path 'a.b.c'");
assert!(
content.to_lowercase().contains("scalar"),
"should mention scalar type"
);
}
#[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(text, docs.as_ref(), 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'");
}
#[test]
fn should_return_hover_for_mapping_value_type() {
let text = "server:\n port: 8080\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), pos(0, 0), None);
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(
content.contains("server"),
"should contain key path 'server'"
);
assert!(
content.to_lowercase().contains("mapping"),
"should mention mapping type"
);
}
#[test]
fn should_return_hover_for_sequence_value_type() {
let text = "items:\n - one\n - two\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), pos(0, 0), None);
let hover = result.expect("should return hover");
let content = hover_content(&hover);
assert!(content.contains("items"), "should contain key path 'items'");
assert!(
content.to_lowercase().contains("sequence"),
"should mention sequence type"
);
}
#[test]
fn should_return_hover_with_scalar_value() {
let text = "port: 8080\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), 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(text, docs.as_ref(), 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_for_empty_document() {
let text = "";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), pos(0, 0), None);
assert!(result.is_none());
}
#[test]
fn should_return_none_when_document_failed_to_parse() {
let text = "key: [bad";
let result = hover_at(text, None, 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(text, docs.as_ref(), 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_none_for_position_beyond_document() {
let text = "key: value\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), pos(5, 0), None);
assert!(result.is_none());
}
#[test]
fn should_return_none_for_document_separator_line() {
let text = "key1: value1\n---\nkey2: value2\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), pos(1, 0), None);
assert!(result.is_none());
}
#[test]
fn should_return_hover_for_boolean_value() {
let text = "enabled: true\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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()
);
}
#[test]
fn long_example_value_truncated() {
let text = "name: Alice\n";
let docs = parse_docs(text);
let long_example = "B".repeat(200);
let mut props = HashMap::new();
props.insert(
"name".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(text, docs.as_ref(), 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 should not appear"
);
assert!(
content.contains('\u{2026}'),
"truncated example should end with ellipsis"
);
let b_run: String = content
.chars()
.skip_while(|&c| c != 'B')
.take_while(|&c| c == 'B')
.collect();
assert!(
b_run.chars().count() <= 99,
"truncated example body should be ≤99 chars (plus ellipsis = 100)"
);
}
#[test]
fn long_example_value_truncated_at_100_chars() {
let text = "key: v\n";
let docs = parse_docs(text);
let long_example = "a".repeat(200);
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(text, docs.as_ref(), 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"
);
let a_run: String = content
.chars()
.skip_while(|&c| c != 'a')
.take_while(|&c| c == 'a')
.collect();
assert!(
a_run.chars().count() <= 100,
"displayed example must be at most 100 chars (got {})",
a_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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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 text = "key: [bad";
let schema = schema_with_description("some desc");
let result = hover_at(text, None, 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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(text, docs.as_ref(), 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_show_at_most_3_examples_with_overflow_note_sec() {
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(text, docs.as_ref(), pos(0, 0), Some(&schema));
assert!(result.is_some(), "should return hover");
let hover = result.unwrap();
let content = hover_content(&hover);
let d_count = content
.lines()
.filter(|l| l.trim() == "- d" || l.trim() == "d")
.count();
let e_count = content
.lines()
.filter(|l| l.trim() == "- e" || l.trim() == "e")
.count();
assert_eq!(d_count, 0, "'d' must not appear as example item");
assert_eq!(e_count, 0, "'e' must not appear as example item");
assert!(
content.contains("more"),
"should contain overflow note indicating more examples exist, got: {content}"
);
}
#[test]
fn should_truncate_long_example_value_at_100_chars() {
let text = "key: v\n";
let docs = parse_docs(text);
let long_example = "a".repeat(200);
let mut props = HashMap::new();
props.insert(
"key".to_string(),
JsonSchema {
examples: Some(vec![JsonValue::String(long_example)]),
..Default::default()
},
);
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));
assert!(result.is_some(), "should return hover");
let hover = result.unwrap();
let content = hover_content(&hover);
let a_run: String = content
.chars()
.skip_while(|&c| c != 'a')
.take_while(|&c| c == 'a')
.collect();
assert!(
a_run.chars().count() <= 100,
"displayed example must be ≤ 100 chars (got {})",
a_run.chars().count()
);
}
#[test]
fn should_escape_backtick_in_yaml_value_display() {
let text = "foo: bar`baz\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), 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(text, docs.as_ref(), 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 should_show_title_as_fallback_when_no_description() {
let text = "key: value\n";
let docs = parse_docs(text);
let mut props = HashMap::new();
props.insert(
"key".to_string(),
JsonSchema {
title: Some("My Key Title".to_string()),
description: None,
..Default::default()
},
);
let schema = JsonSchema {
properties: Some(props),
..Default::default()
};
let result = hover_at(text, docs.as_ref(), pos(0, 0), Some(&schema));
assert!(result.is_some(), "should return hover");
let hover = result.unwrap();
let content = hover_content(&hover);
assert!(
content.contains("My Key Title"),
"should show title as fallback when description absent"
);
}
#[test]
fn hover_returns_none_when_col_beyond_line_length() {
let text = "key: value\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), pos(0, 11), None);
assert!(result.is_none());
}
#[test]
fn hover_returns_sequence_value_when_cursor_on_dash() {
let text = "items:\n - first\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), pos(1, 2), None);
let hover = result.expect("should return hover for sequence item when cursor on dash");
let content = hover_content(&hover);
assert!(
content.contains("items"),
"should contain parent key 'items'"
);
}
#[test]
fn hover_on_dash_of_sequence_with_empty_value_returns_none() {
let text = "items:\n -\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), pos(1, 2), None);
assert!(result.is_none());
}
#[test]
fn hover_on_plain_scalar_line_returns_value_token() {
let text = "- plainvalue\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), 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_key_token() {
let text = "items:\n - name:\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), pos(1, 8), None);
if let Some(hover) = result {
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(text, docs.as_ref(), 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'");
}
#[test]
fn hover_returns_none_for_line_starting_with_colon() {
let text = ": orphan\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), pos(0, 0), None);
let _ = result; }
#[test]
fn hover_on_sequence_item_key_before_colon_does_not_panic() {
let text = "people:\n - name: Alice\n";
let docs = parse_docs(text);
let _result = hover_at(text, docs.as_ref(), pos(1, 5), None);
}
#[test]
fn hover_on_sequence_item_value_after_colon_does_not_panic() {
let text = "people:\n - name: Alice\n";
let docs = parse_docs(text);
let _result = hover_at(text, docs.as_ref(), pos(1, 12), None);
}
#[test]
fn hover_returns_none_for_ellipsis_document_terminator() {
let text = "key: value\n...\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), pos(1, 0), None);
assert!(result.is_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() {
use serde_json::json;
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(text, docs.as_ref(), 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() {
use serde_json::json;
let schema = schema_with_examples(vec![json!(["a", "b", "c"])]);
let text = "key: v\n";
let docs = parse_docs(text);
let result = hover_at(text, docs.as_ref(), 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() {
use serde_json::json;
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(text, docs.as_ref(), 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() {
use serde_json::json;
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(text, docs.as_ref(), 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() {
use serde_json::json;
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(text, docs.as_ref(), 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}"
);
}
}