use std::collections::{HashMap, HashSet};
use rlsp_yaml_parser::node::{Document, Node};
use rlsp_yaml_parser::pos::Span;
use tower_lsp::lsp_types::{
Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString, Position, Range,
};
#[derive(Debug, Clone)]
struct Token {
name: String,
line: u32,
start_col: u32,
end_col: u32,
is_anchor: bool,
}
#[must_use]
pub fn validate_unused_anchors(text: &str) -> Vec<Diagnostic> {
let lines: Vec<&str> = text.lines().collect();
let mut diagnostics = Vec::new();
let mut doc_ranges = Vec::new();
let mut current_start = 0;
for (line_idx, line) in lines.iter().enumerate() {
if line.trim() == "---" {
if line_idx > current_start {
doc_ranges.push((current_start, line_idx));
}
current_start = line_idx + 1;
}
}
if current_start < lines.len() {
doc_ranges.push((current_start, lines.len()));
}
if doc_ranges.is_empty() && !lines.is_empty() {
doc_ranges.push((0, lines.len()));
}
for (start_line, end_line) in doc_ranges {
let tokens = scan_tokens(&lines, start_line, end_line);
let mut anchors: HashMap<String, &Token> = HashMap::new();
let mut aliases: Vec<&Token> = Vec::new();
for token in &tokens {
if token.is_anchor {
anchors.insert(token.name.clone(), token);
} else {
aliases.push(token);
}
}
let mut used_anchors: HashSet<String> = HashSet::new();
for alias in &aliases {
if anchors.contains_key(&alias.name) {
used_anchors.insert(alias.name.clone());
} else {
diagnostics.push(Diagnostic {
range: Range::new(
Position::new(alias.line, alias.start_col),
Position::new(alias.line, alias.end_col),
),
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("unresolvedAlias".to_string())),
message: format!("Alias '{}' has no matching anchor", alias.name),
source: Some("rlsp-yaml".to_string()),
..Diagnostic::default()
});
}
}
diagnostics.extend(
anchors
.iter()
.filter(|(name, _)| !used_anchors.contains(*name))
.map(|(name, anchor)| {
let truncated_name = if name.len() > 100 {
format!("{}...", &name[..100])
} else {
name.clone()
};
Diagnostic {
range: Range::new(
Position::new(anchor.line, anchor.start_col),
Position::new(anchor.line, anchor.end_col),
),
severity: Some(DiagnosticSeverity::WARNING),
code: Some(NumberOrString::String("unusedAnchor".to_string())),
message: format!("Anchor '{truncated_name}' is never used"),
source: Some("rlsp-yaml".to_string()),
tags: Some(vec![DiagnosticTag::UNNECESSARY]),
..Diagnostic::default()
}
}),
);
}
diagnostics
}
fn scan_tokens(lines: &[&str], start_line: usize, end_line: usize) -> Vec<Token> {
let mut tokens = Vec::new();
for line_idx in start_line..end_line {
let Some(line) = lines.get(line_idx) else {
continue;
};
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
#[allow(clippy::cast_possible_truncation)]
let line_num = line_idx as u32;
let mut chars = line.char_indices().peekable();
while let Some((i, ch)) = chars.next() {
if ch == '&' || ch == '*' {
let is_anchor = ch == '&';
let name_start = i + 1;
let mut name_end = name_start;
while let Some(&(j, next_ch)) = chars.peek() {
if is_anchor_name_char(next_ch) {
name_end = j + next_ch.len_utf8();
chars.next();
} else {
break;
}
}
if name_end > name_start {
#[allow(clippy::cast_possible_truncation)]
tokens.push(Token {
name: line[name_start..name_end].to_string(),
line: line_num,
start_col: i as u32,
end_col: name_end as u32,
is_anchor,
});
}
}
}
}
tokens
}
const fn is_anchor_name_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.'
}
#[must_use]
pub fn validate_flow_style(text: &str) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let lines: Vec<&str> = text.lines().collect();
for (line_idx, line) in lines.iter().enumerate() {
#[allow(clippy::cast_possible_truncation)]
let line_num = line_idx as u32;
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 => {
if let Some(close_pos) = find_closing_char(line, i, '{', '}') {
if !line[i + 1..close_pos].trim().is_empty() {
#[allow(clippy::cast_possible_truncation)]
diagnostics.push(Diagnostic {
range: Range::new(
Position::new(line_num, i as u32),
Position::new(line_num, (close_pos + 1) as u32),
),
severity: Some(DiagnosticSeverity::WARNING),
code: Some(NumberOrString::String("flowMap".to_string())),
message: "Flow mapping style: use block style instead".to_string(),
source: Some("rlsp-yaml".to_string()),
..Diagnostic::default()
});
}
}
}
'[' if !in_single_quote && !in_double_quote => {
if let Some(close_pos) = find_closing_char(line, i, '[', ']') {
if !line[i + 1..close_pos].trim().is_empty() {
#[allow(clippy::cast_possible_truncation)]
diagnostics.push(Diagnostic {
range: Range::new(
Position::new(line_num, i as u32),
Position::new(line_num, (close_pos + 1) as u32),
),
severity: Some(DiagnosticSeverity::WARNING),
code: Some(NumberOrString::String("flowSeq".to_string())),
message: "Flow sequence style: use block style instead".to_string(),
source: Some("rlsp-yaml".to_string()),
..Diagnostic::default()
});
}
}
}
_ => {}
}
}
}
diagnostics
}
fn find_closing_char(line: &str, start: usize, open: char, close: char) -> Option<usize> {
let mut depth = 1;
let mut in_single_quote = false;
let mut in_double_quote = false;
for (i, ch) in line[start + 1..].char_indices() {
let actual_i = start + 1 + i;
match ch {
'\'' if !in_double_quote => in_single_quote = !in_single_quote,
'"' if !in_single_quote => in_double_quote = !in_double_quote,
c if c == open && !in_single_quote && !in_double_quote => depth += 1,
c if c == close && !in_single_quote && !in_double_quote => {
depth -= 1;
if depth == 0 {
return Some(actual_i);
}
}
_ => {}
}
}
None
}
#[must_use]
pub fn validate_custom_tags<S: std::hash::BuildHasher>(
text: &str,
docs: &[Document<Span>],
allowed_tags: &HashSet<String, S>,
) -> Vec<Diagnostic> {
if allowed_tags.is_empty() {
return Vec::new();
}
let lines: Vec<&str> = text.lines().collect();
let mut diagnostics = Vec::new();
let mut seen_counts: HashMap<String, usize> = HashMap::new();
for doc in docs {
collect_tag_diagnostics(
&doc.root,
&lines,
allowed_tags,
&mut seen_counts,
&mut diagnostics,
0,
);
}
diagnostics
}
fn collect_tag_diagnostics<S: std::hash::BuildHasher>(
node: &Node<Span>,
lines: &[&str],
allowed_tags: &HashSet<String, S>,
seen_counts: &mut HashMap<String, usize>,
diagnostics: &mut Vec<Diagnostic>,
depth: usize,
) {
const MAX_DEPTH: usize = 100;
if depth > MAX_DEPTH {
return;
}
let tag = match node {
Node::Scalar { tag, .. } | Node::Mapping { tag, .. } | Node::Sequence { tag, .. } => {
tag.as_deref()
}
Node::Alias { .. } => None,
};
if let Some(tag_str) = tag {
if !allowed_tags.contains(tag_str) {
let occurrence = *seen_counts.get(tag_str).unwrap_or(&0);
seen_counts.insert(tag_str.to_string(), occurrence + 1);
if let Some(range) = find_tag_occurrence(lines, tag_str, occurrence) {
diagnostics.push(Diagnostic {
range,
severity: Some(DiagnosticSeverity::WARNING),
code: Some(NumberOrString::String("unknownTag".to_string())),
message: format!("Unknown tag: {tag_str}"),
source: Some("rlsp-yaml".to_string()),
..Diagnostic::default()
});
}
}
}
match node {
Node::Mapping { entries, .. } => {
for (key, value) in entries {
collect_tag_diagnostics(
key,
lines,
allowed_tags,
seen_counts,
diagnostics,
depth + 1,
);
collect_tag_diagnostics(
value,
lines,
allowed_tags,
seen_counts,
diagnostics,
depth + 1,
);
}
}
Node::Sequence { items, .. } => {
for item in items {
collect_tag_diagnostics(
item,
lines,
allowed_tags,
seen_counts,
diagnostics,
depth + 1,
);
}
}
Node::Scalar { .. } | Node::Alias { .. } => {}
}
}
fn find_tag_occurrence(lines: &[&str], tag_str: &str, occurrence: usize) -> Option<Range> {
let mut count = 0usize;
for (line_idx, line) in lines.iter().enumerate() {
let mut search_start = 0;
while let Some(pos) = line[search_start..].find(tag_str) {
let abs_pos = search_start + pos;
let in_quotes = is_inside_quotes(line, abs_pos);
let before_ok = abs_pos == 0
|| line
.as_bytes()
.get(abs_pos - 1)
.is_some_and(|&b| b == b' ' || b == b'\t' || b == b':' || b == b'-');
let after_end = abs_pos + tag_str.len();
let after_ok = line
.as_bytes()
.get(after_end)
.is_none_or(|&b| !b.is_ascii_alphanumeric() && b != b'-' && b != b'_' && b != b'.');
if !in_quotes && before_ok && after_ok {
if count == occurrence {
#[allow(clippy::cast_possible_truncation)]
return Some(Range::new(
Position::new(line_idx as u32, abs_pos as u32),
Position::new(line_idx as u32, after_end as u32),
));
}
count += 1;
}
search_start = abs_pos + 1;
}
}
None
}
fn is_inside_quotes(line: &str, pos: usize) -> bool {
let mut in_single = false;
let mut in_double = false;
for (i, ch) in line.char_indices() {
if i >= pos {
break;
}
match ch {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
_ => {}
}
}
in_single || in_double
}
#[must_use]
pub fn validate_key_ordering(text: &str, docs: &[Document<Span>]) -> Vec<Diagnostic> {
let lines: Vec<&str> = text.lines().collect();
let mut diagnostics = Vec::new();
let key_index: HashMap<String, u32> = lines
.iter()
.enumerate()
.filter_map(|(line_idx, line)| {
let trimmed = line.trim_start();
let colon_pos = trimmed.find(':')?;
let key = trimmed[..colon_pos].trim_end();
if key.is_empty() {
return None;
}
#[allow(clippy::cast_possible_truncation)]
Some((key.to_string(), line_idx as u32))
})
.fold(HashMap::new(), |mut map, (key, line)| {
map.entry(key).or_insert(line);
map
});
for doc in docs {
check_yaml_ordering(&doc.root, &key_index, &mut diagnostics, 0);
}
diagnostics
}
fn check_yaml_ordering(
node: &Node<Span>,
key_index: &HashMap<String, u32>,
diagnostics: &mut Vec<Diagnostic>,
depth: usize,
) {
const MAX_DEPTH: usize = 100;
if depth > MAX_DEPTH {
return;
}
match node {
Node::Mapping { entries, .. } => {
let keys: Vec<String> = entries
.iter()
.filter_map(|(k, _)| match k {
Node::Scalar { value, .. } if !crate::scalar_helpers::is_null(value) => {
Some(value.clone())
}
Node::Scalar { .. }
| Node::Mapping { .. }
| Node::Sequence { .. }
| Node::Alias { .. } => None,
})
.collect();
let mut max_key: &str = keys.first().map_or("", String::as_str);
for key in keys.iter().skip(1) {
if key.as_str() < max_key {
if let Some(&line_num) = key_index.get(key.as_str()) {
#[allow(clippy::cast_possible_truncation)]
let key_len = key.len() as u32;
diagnostics.push(Diagnostic {
range: Range::new(
Position::new(line_num, 0),
Position::new(line_num, key_len),
),
severity: Some(DiagnosticSeverity::WARNING),
code: Some(NumberOrString::String("mapKeyOrder".to_string())),
message: format!("Key '{key}' is out of alphabetical order"),
source: Some("rlsp-yaml".to_string()),
..Diagnostic::default()
});
}
} else if key.as_str() > max_key {
max_key = key;
}
}
for (_, value) in entries {
check_yaml_ordering(value, key_index, diagnostics, depth + 1);
}
}
Node::Sequence { items, .. } => {
for item in items {
check_yaml_ordering(item, key_index, diagnostics, depth + 1);
}
}
Node::Scalar { .. } | Node::Alias { .. } => {}
}
}
#[must_use]
pub fn validate_duplicate_keys(docs: &[Document<Span>]) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for doc in docs {
check_node_for_duplicate_keys(&doc.root, &mut diagnostics, 0);
}
diagnostics
}
fn check_node_for_duplicate_keys(
node: &Node<Span>,
diagnostics: &mut Vec<Diagnostic>,
depth: usize,
) {
const MAX_DEPTH: usize = 100;
if depth > MAX_DEPTH {
return;
}
match node {
Node::Mapping { entries, .. } => {
let mut seen: HashSet<String> = HashSet::new();
for (key, value) in entries {
let key_str_and_loc: Option<(String, &Span)> = match key {
Node::Scalar {
value: key_str,
loc,
..
} => Some((key_str.clone(), loc)),
Node::Alias { name, loc, .. } => Some((format!("*{name}"), loc)),
Node::Mapping { .. } | Node::Sequence { .. } => None,
};
if let Some((key_str, loc)) = key_str_and_loc {
if seen.contains(&key_str) {
push_duplicate_diagnostic(diagnostics, &key_str, loc);
} else {
seen.insert(key_str);
}
}
check_node_for_duplicate_keys(key, diagnostics, depth + 1);
check_node_for_duplicate_keys(value, diagnostics, depth + 1);
}
}
Node::Sequence { items, .. } => {
for item in items {
check_node_for_duplicate_keys(item, diagnostics, depth + 1);
}
}
Node::Scalar { .. } | Node::Alias { .. } => {}
}
}
fn push_duplicate_diagnostic(diagnostics: &mut Vec<Diagnostic>, key: &str, loc: &Span) {
let display_key = if key.len() > 100 {
let end = key.char_indices().nth(100).map_or(key.len(), |(i, _)| i);
format!("{}...", &key[..end])
} else {
key.to_string()
};
#[allow(clippy::cast_possible_truncation)]
let start_line = loc.start.line.saturating_sub(1) as u32;
#[allow(clippy::cast_possible_truncation)]
let start_col = loc.start.column as u32;
#[allow(clippy::cast_possible_truncation)]
let end_col = loc.end.column as u32;
diagnostics.push(Diagnostic {
range: Range::new(
Position::new(start_line, start_col),
Position::new(start_line, end_col),
),
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("duplicateKey".to_string())),
message: format!("Duplicate key: '{display_key}'"),
source: Some("rlsp-yaml".to_string()),
..Diagnostic::default()
});
}
#[cfg(test)]
#[allow(clippy::indexing_slicing, clippy::expect_used, clippy::unwrap_used)]
mod tests {
use std::fmt::Write as _;
use super::*;
#[test]
fn should_return_empty_for_document_with_no_anchors() {
let result = validate_unused_anchors("key: value\n");
assert!(result.is_empty());
}
#[test]
fn should_return_empty_when_all_anchors_are_used() {
let text = "defaults: &defaults\n key: val\nproduction:\n <<: *defaults\n";
let result = validate_unused_anchors(text);
assert!(result.is_empty());
}
#[test]
fn should_detect_unused_anchor() {
let text = "defaults: &unused\n key: val\nproduction:\n key: other\n";
let result = validate_unused_anchors(text);
assert_eq!(result.len(), 1);
assert!(
result[0]
.tags
.as_ref()
.is_some_and(|tags| tags.contains(&DiagnosticTag::UNNECESSARY))
);
}
#[test]
fn should_detect_multiple_unused_anchors() {
let text = "a: &first\n k: v\nb: &second\n k: v\nc: value\n";
let result = validate_unused_anchors(text);
assert_eq!(result.len(), 2);
}
#[test]
fn should_return_correct_range_for_unused_anchor() {
let text = "defaults: &defaults\n key: val\n";
let result = validate_unused_anchors(text);
assert_eq!(result.len(), 1);
let diag = &result[0];
assert_eq!(diag.range.start.line, 0);
assert_eq!(diag.range.start.character, 10, "anchor starts at column 10");
assert_eq!(diag.range.end.character, 19, "anchor ends at column 19");
}
#[test]
fn should_mark_diagnostic_with_unnecessary_tag() {
let text = "defaults: &unused\n key: val\n";
let result = validate_unused_anchors(text);
assert_eq!(result.len(), 1);
assert!(
result[0]
.tags
.as_ref()
.is_some_and(|tags| tags.contains(&DiagnosticTag::UNNECESSARY))
);
}
#[test]
fn should_detect_alias_with_no_matching_anchor() {
let text = "production:\n <<: *undefined\n";
let result = validate_unused_anchors(text);
assert_eq!(result.len(), 1);
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_detect_multiple_unresolved_aliases() {
let text = "a: *missing1\nb: *missing2\n";
let result = validate_unused_anchors(text);
assert_eq!(result.len(), 2);
assert!(
result
.iter()
.all(|d| d.severity == Some(DiagnosticSeverity::ERROR))
);
}
#[test]
fn should_return_empty_for_empty_document() {
let result = validate_unused_anchors("");
assert!(result.is_empty());
}
#[test]
fn should_return_empty_for_comment_only_document() {
let result = validate_unused_anchors("# just a comment\n");
assert!(result.is_empty());
}
#[test]
fn should_not_report_anchors_in_comments() {
let text = "# &fake anchor\nkey: value\n";
let result = validate_unused_anchors(text);
assert!(result.is_empty());
}
#[test]
fn should_handle_anchor_used_multiple_times() {
let text = "defaults: &shared\n k: v\na: *shared\nb: *shared\n";
let result = validate_unused_anchors(text);
assert!(result.is_empty());
}
#[test]
fn should_handle_anchor_with_special_characters() {
let text = "data: &my-anchor_v2.0\n k: v\nref: *my-anchor_v2.0\n";
let result = validate_unused_anchors(text);
assert!(result.is_empty());
}
#[test]
fn should_report_unused_anchor_scoped_to_document() {
let text = "doc1: &shared\n k: v\n---\ndoc2:\n ref: *shared\n";
let result = validate_unused_anchors(text);
assert_eq!(result.len(), 2);
let unused = result.iter().find(|d| {
d.tags
.as_ref()
.is_some_and(|t| t.contains(&DiagnosticTag::UNNECESSARY))
});
let unresolved = result
.iter()
.find(|d| d.severity == Some(DiagnosticSeverity::ERROR));
assert!(unused.is_some());
assert!(unresolved.is_some());
}
#[test]
fn should_treat_same_anchor_name_in_different_documents_independently() {
let text = "a: &name\n k: v\n---\nb: &name\n k: v\nref: *name\n";
let result = validate_unused_anchors(text);
assert_eq!(result.len(), 1);
assert!(
result[0]
.tags
.as_ref()
.is_some_and(|t| t.contains(&DiagnosticTag::UNNECESSARY))
);
}
#[test]
fn should_handle_document_with_many_anchors() {
let mut text = String::new();
for i in 0..120 {
writeln!(text, "anchor{i}: &anchor{i}\n key: val").unwrap();
}
for i in (0..120).step_by(2) {
writeln!(text, "ref{i}: *anchor{i}").unwrap();
}
let result = validate_unused_anchors(&text);
assert_eq!(result.len(), 60);
assert!(result.iter().all(|d| {
d.tags
.as_ref()
.is_some_and(|t| t.contains(&DiagnosticTag::UNNECESSARY))
}));
}
#[test]
fn should_handle_long_anchor_name() {
let long_name = "a".repeat(200);
let text = format!("data: &{long_name}\n k: v\n");
let result = validate_unused_anchors(&text);
assert_eq!(result.len(), 1);
assert!(!result[0].message.is_empty());
}
#[test]
fn should_ignore_invalid_anchor_name_characters() {
let text = "data: &anchor!@# value\nref: *anchor\n";
let result = validate_unused_anchors(text);
assert!(result.is_empty());
}
#[test]
fn should_produce_correct_range_with_unicode_in_text() {
let text = "name: 中文\ndata: &unused\n key: val\n";
let result = validate_unused_anchors(text);
assert_eq!(result.len(), 1);
let diag = &result[0];
assert_eq!(diag.range.start.line, 1, "anchor is on line 1");
assert_eq!(diag.range.start.character, 6, "anchor starts at column 6");
}
#[test]
fn should_not_satisfy_alias_in_doc1_with_anchor_in_doc2() {
let text = "ref: *later\n---\ndata: &later\n key: val\n";
let result = validate_unused_anchors(text);
assert_eq!(result.len(), 2);
let error_diags = result
.iter()
.filter(|d| d.severity == Some(DiagnosticSeverity::ERROR))
.count();
let unnecessary_diags = result
.iter()
.filter(|d| {
d.tags
.as_ref()
.is_some_and(|t| t.contains(&DiagnosticTag::UNNECESSARY))
})
.count();
assert_eq!(error_diags, 1, "should have 1 error for unresolved alias");
assert_eq!(
unnecessary_diags, 1,
"should have 1 unnecessary for unused anchor"
);
}
#[test]
fn should_evaluate_each_document_independently_for_unused_anchors() {
let text = "a: &used\n k: v\nref: *used\n---\nb: &unused\n k: v\n";
let result = validate_unused_anchors(text);
assert_eq!(result.len(), 1);
assert_eq!(result[0].range.start.line, 4); assert!(
result[0]
.tags
.as_ref()
.is_some_and(|t| t.contains(&DiagnosticTag::UNNECESSARY))
);
}
#[test]
fn should_return_empty_for_block_style_only() {
let text = "key:\n nested: value\n";
let result = validate_flow_style(text);
assert!(result.is_empty());
}
#[test]
fn should_detect_flow_mapping() {
let text = "config: {key: value}\n";
let result = validate_flow_style(text);
assert_eq!(result.len(), 1);
assert_eq!(result[0].severity, Some(DiagnosticSeverity::WARNING));
assert!(
matches!(result[0].code.as_ref(), Some(NumberOrString::String(s)) if s == "flowMap")
);
}
#[test]
fn should_detect_flow_sequence() {
let text = "items: [one, two, three]\n";
let result = validate_flow_style(text);
assert_eq!(result.len(), 1);
assert_eq!(result[0].severity, Some(DiagnosticSeverity::WARNING));
assert!(
matches!(result[0].code.as_ref(), Some(NumberOrString::String(s)) if s == "flowSeq")
);
}
#[test]
fn should_detect_both_flow_mapping_and_sequence() {
let text = "config: {key: value}\nitems: [a, b]\n";
let result = validate_flow_style(text);
assert_eq!(result.len(), 2);
let has_flow_map = result
.iter()
.any(|d| matches!(d.code.as_ref(), Some(NumberOrString::String(s)) if s == "flowMap"));
let has_flow_seq = result
.iter()
.any(|d| matches!(d.code.as_ref(), Some(NumberOrString::String(s)) if s == "flowSeq"));
assert!(has_flow_map);
assert!(has_flow_seq);
}
#[test]
fn should_return_correct_range_for_flow_mapping() {
let text = "config: {key: value}\n";
let result = validate_flow_style(text);
assert_eq!(result.len(), 1);
assert_eq!(result[0].range.start.line, 0);
}
#[test]
fn should_return_correct_range_for_flow_sequence() {
let text = "items: [a, b]\n";
let result = validate_flow_style(text);
assert_eq!(result.len(), 1);
assert_eq!(result[0].range.start.line, 0);
}
#[test]
fn should_return_empty_for_empty_document_flow() {
let result = validate_flow_style("");
assert!(result.is_empty());
}
#[test]
fn should_not_detect_brackets_in_quoted_strings() {
let text = "message: \"array is [1,2,3]\"\n";
let result = validate_flow_style(text);
assert!(result.is_empty());
}
#[test]
fn should_not_detect_braces_in_quoted_strings() {
let text = "message: 'object is {a: 1}'\n";
let result = validate_flow_style(text);
assert!(result.is_empty());
}
#[test]
fn should_detect_nested_flow_styles() {
let text = "data: {outer: [inner]}\n";
let result = validate_flow_style(text);
assert_eq!(result.len(), 2);
}
#[test]
fn should_handle_flow_style_in_multi_document() {
let text = "doc1: {a: 1}\n---\ndoc2: [x]\n";
let result = validate_flow_style(text);
assert_eq!(result.len(), 2);
}
#[test]
fn should_not_warn_on_empty_flow_mapping() {
let result = validate_flow_style("status: {}\n");
assert!(result.is_empty());
}
#[test]
fn should_not_warn_on_empty_flow_sequence() {
let result = validate_flow_style("items: []\n");
assert!(result.is_empty());
}
#[test]
fn should_not_warn_on_flow_mapping_with_only_spaces() {
let result = validate_flow_style("status: { }\n");
assert!(result.is_empty());
}
#[test]
fn should_not_warn_on_flow_mapping_with_multiple_spaces() {
let result = validate_flow_style("status: { }\n");
assert!(result.is_empty());
}
#[test]
fn should_not_warn_on_flow_sequence_with_only_spaces() {
let result = validate_flow_style("items: [ ]\n");
assert!(result.is_empty());
}
#[test]
fn should_warn_on_outer_but_not_inner_empty_flow_mapping() {
let result = validate_flow_style("data: {a: {}}\n");
assert_eq!(result.len(), 1);
assert!(
matches!(result[0].code.as_ref(), Some(NumberOrString::String(s)) if s == "flowMap")
);
}
#[test]
fn should_not_warn_on_multiple_empty_collections_on_one_line() {
let result = validate_flow_style("a: {}\nb: []\n");
assert!(result.is_empty());
}
#[test]
fn should_warn_only_on_non_empty_when_mixed_with_empty() {
let result = validate_flow_style("a: {}\nb: {x: 1}\n");
assert_eq!(result.len(), 1);
assert_eq!(result[0].range.start.line, 1);
}
#[test]
fn should_return_empty_for_alphabetically_ordered_keys() {
let text = "apple: 1\nbanana: 2\ncherry: 3\n";
let docs = rlsp_yaml_parser::load(text).unwrap();
let result = validate_key_ordering(text, &docs);
assert!(result.is_empty());
}
#[test]
fn should_detect_out_of_order_keys() {
let text = "banana: 2\napple: 1\n";
let docs = rlsp_yaml_parser::load(text).unwrap();
let result = validate_key_ordering(text, &docs);
assert_eq!(result.len(), 1);
assert_eq!(result[0].severity, Some(DiagnosticSeverity::WARNING));
assert!(
matches!(result[0].code.as_ref(), Some(NumberOrString::String(s)) if s == "mapKeyOrder")
);
}
#[test]
fn should_return_correct_range_for_out_of_order_key() {
let text = "banana: 2\napple: 1\n";
let docs = rlsp_yaml_parser::load(text).unwrap();
let result = validate_key_ordering(text, &docs);
assert_eq!(result.len(), 1);
assert_eq!(result[0].range.start.line, 1, "apple is on line 1");
}
#[test]
fn should_detect_multiple_out_of_order_keys() {
let text = "charlie: 3\nalpha: 1\nbravo: 2\n";
let docs = rlsp_yaml_parser::load(text).unwrap();
let result = validate_key_ordering(text, &docs);
assert_eq!(result.len(), 2);
}
#[test]
fn should_check_ordering_within_nested_mappings() {
let text = "outer:\n zebra: 1\n alpha: 2\n";
let docs = rlsp_yaml_parser::load(text).unwrap();
let result = validate_key_ordering(text, &docs);
assert_eq!(result.len(), 1, "alpha is out of order within outer");
}
#[test]
fn should_check_ordering_at_each_level_independently() {
let text = "b_parent:\n a_child: 1\na_parent:\n key: val\n";
let docs = rlsp_yaml_parser::load(text).unwrap();
let result = validate_key_ordering(text, &docs);
assert_eq!(result.len(), 1, "a_parent is out of order at top level");
}
#[test]
fn should_return_empty_for_empty_document_ordering() {
let text = "";
let docs = rlsp_yaml_parser::load(text).unwrap();
let result = validate_key_ordering(text, &docs);
assert!(result.is_empty());
}
#[test]
fn should_return_empty_for_single_key() {
let text = "only: value\n";
let docs = rlsp_yaml_parser::load(text).unwrap();
let result = validate_key_ordering(text, &docs);
assert!(result.is_empty());
}
#[test]
fn should_handle_numeric_string_keys() {
let text = "2: two\n10: ten\n";
let docs = rlsp_yaml_parser::load(text).unwrap();
let result = validate_key_ordering(text, &docs);
assert_eq!(result.len(), 1, "10 should be flagged as out of order");
}
#[test]
fn should_ignore_sequence_items_for_ordering() {
let text = "items:\n - zebra\n - alpha\n";
let docs = rlsp_yaml_parser::load(text).unwrap();
let result = validate_key_ordering(text, &docs);
assert!(result.is_empty());
}
#[test]
fn should_handle_multi_document_key_ordering() {
let text = "z: 1\n---\na: 2\n";
let docs = rlsp_yaml_parser::load(text).unwrap();
let result = validate_key_ordering(text, &docs);
assert!(result.is_empty());
}
#[test]
fn should_be_case_sensitive() {
let text = "Apple: 1\napple: 2\n";
let docs = rlsp_yaml_parser::load(text).unwrap();
let result = validate_key_ordering(text, &docs);
assert!(result.is_empty());
}
fn parse_docs(text: &str) -> Vec<Document<Span>> {
rlsp_yaml_parser::load(text).unwrap()
}
fn allowed(tags: &[&str]) -> HashSet<String> {
tags.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn unknown_tag_produces_warning_with_unknown_tag_code() {
let text = "value: !include foo.yaml\n";
let docs = parse_docs(text);
let result = validate_custom_tags(text, &docs, &allowed(&["!other"]));
assert_eq!(result.len(), 1);
assert_eq!(result[0].severity, Some(DiagnosticSeverity::WARNING));
assert!(
matches!(result[0].code.as_ref(), Some(NumberOrString::String(s)) if s == "unknownTag")
);
assert!(result[0].message.contains("!include"));
assert_eq!(result[0].source.as_deref(), Some("rlsp-yaml"));
}
#[test]
fn allowed_tag_produces_no_diagnostic() {
let text = "value: !include foo.yaml\n";
let docs = parse_docs(text);
let result = validate_custom_tags(text, &docs, &allowed(&["!include"]));
assert!(result.is_empty());
}
#[test]
fn empty_allowed_tags_returns_no_diagnostics() {
let text = "value: !include foo.yaml\n";
let docs = parse_docs(text);
let result = validate_custom_tags(text, &docs, &allowed(&[]));
assert!(result.is_empty());
}
#[test]
fn multiple_tags_only_unknown_ones_flagged() {
let text = "a: !include foo.yaml\nb: !ref bar.yaml\n";
let docs = parse_docs(text);
let result = validate_custom_tags(text, &docs, &allowed(&["!include"]));
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("!ref"));
}
#[test]
fn no_tags_in_document_returns_empty_vec() {
let text = "key: value\nother: 123\n";
let docs = parse_docs(text);
let result = validate_custom_tags(text, &docs, &allowed(&["!include"]));
assert!(result.is_empty());
}
#[test]
fn tags_in_multi_document_yaml_are_all_checked() {
let text = "a: !include foo.yaml\n---\nb: !ref bar.yaml\n";
let docs = parse_docs(text);
let result = validate_custom_tags(text, &docs, &allowed(&["!other"]));
assert_eq!(result.len(), 2);
let result = validate_custom_tags(text, &docs, &allowed(&["!include", "!ref"]));
assert!(result.is_empty());
}
#[test]
fn nested_tagged_value_is_found() {
let text = "outer:\n inner: !include nested.yaml\n";
let docs = parse_docs(text);
let result = validate_custom_tags(text, &docs, &allowed(&["!other"]));
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("!include"));
}
fn parse_duplicate(text: &str) -> Vec<super::Diagnostic> {
let docs = rlsp_yaml_parser::load(text).unwrap();
validate_duplicate_keys(&docs)
}
#[test]
fn should_return_empty_for_document_with_no_duplicate_keys() {
let result = parse_duplicate("a: 1\nb: 2\nc: 3\n");
assert!(result.is_empty());
}
#[test]
fn should_detect_simple_top_level_duplicate() {
let text = "a: 1\na: 2\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
assert!(
matches!(result[0].code.as_ref(), Some(NumberOrString::String(s)) if s == "duplicateKey")
);
assert_eq!(result[0].source.as_deref(), Some("rlsp-yaml"));
assert!(result[0].message.contains("'a'"));
assert_eq!(result[0].range.start.line, 1, "duplicate is on line 1");
}
#[test]
fn should_detect_duplicate_in_nested_mapping() {
let text = "outer:\n x: 1\n x: 2\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("'x'"));
assert_eq!(result[0].range.start.line, 2);
}
#[test]
fn should_not_flag_same_key_at_different_nesting_levels() {
let text = "name: top\nnested:\n name: inner\n";
let result = parse_duplicate(text);
assert!(result.is_empty());
}
#[test]
fn should_reset_scope_on_document_boundary() {
let text = "key: 1\n---\nkey: 2\n";
let result = parse_duplicate(text);
assert!(result.is_empty());
}
#[test]
fn should_detect_duplicate_within_same_document_in_multi_doc_yaml() {
let text = "a: 1\na: 2\n---\nb: 3\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("'a'"));
}
#[test]
fn should_detect_flow_mapping_duplicate() {
let text = "cfg: {x: 1, x: 2}\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
assert!(result[0].message.contains("'x'"));
}
#[test]
fn should_return_empty_for_flow_mapping_without_duplicates() {
let text = "cfg: {a: 1, b: 2}\n";
let result = parse_duplicate(text);
assert!(result.is_empty());
}
#[test]
fn should_treat_double_quoted_and_unquoted_same_key_as_duplicate() {
let text = "\"key\": 1\nkey: 2\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("'key'"));
}
#[test]
fn should_treat_two_double_quoted_identical_keys_as_duplicate() {
let text = "\"key\": 1\n\"key\": 2\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
}
#[test]
fn should_treat_single_quoted_and_unquoted_same_key_as_duplicate() {
let text = "'key': 1\nkey: 2\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("'key'"));
}
#[test]
fn should_treat_single_and_double_quoted_same_value_as_duplicate() {
let text = "'key': 1\n\"key\": 2\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
}
#[test]
fn should_detect_duplicate_when_second_key_has_anchor() {
let text = "key: 1\n&anchor key: 2\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("'key'"));
}
#[test]
fn should_detect_duplicate_when_first_key_has_anchor() {
let text = "&anchor key: 1\nkey: 2\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("'key'"));
}
#[test]
fn should_not_flag_anchor_key_appearing_once() {
let text = "&anchor key: 1\nother: 2\n";
let result = parse_duplicate(text);
assert!(result.is_empty());
}
#[test]
fn should_skip_non_scalar_mapping_key() {
let text = "{a: 1}: foo\n{a: 1}: bar\n";
let result = parse_duplicate(text);
assert!(result.is_empty());
}
#[test]
fn should_detect_duplicate_alias_keys() {
let text = "x: &anchor foo\n? *anchor\n: 1\n? *anchor\n: 2\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
assert!(
matches!(result[0].code.as_ref(), Some(NumberOrString::String(s)) if s == "duplicateKey")
);
assert!(result[0].message.contains("*anchor"));
}
#[test]
fn should_not_flag_single_alias_key() {
let text = "x: &anchor foo\n? *anchor\n: 1\nother: 2\n";
let result = parse_duplicate(text);
assert!(result.is_empty());
}
#[test]
fn should_detect_duplicate_empty_string_keys() {
let text = "\"\": 1\n\"\": 2\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
assert!(
matches!(result[0].code.as_ref(), Some(NumberOrString::String(s)) if s == "duplicateKey")
);
}
#[test]
fn should_detect_duplicate_unicode_keys() {
let text = "café: 1\ncafé: 2\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
assert!(result[0].message.contains("café"));
}
#[test]
fn should_not_flag_same_key_in_different_sequence_items() {
let text = "items:\n - name: alice\n - name: bob\n";
let result = parse_duplicate(text);
assert!(result.is_empty());
}
#[test]
fn should_detect_duplicate_within_same_sequence_item() {
let text = "items:\n - name: alice\n name: alice2\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("'name'"));
}
#[test]
fn should_not_flag_same_key_in_sibling_mappings_under_common_parent() {
let text = "parent:\n child_a:\n cpu: 100m\n memory: 128Mi\n child_b:\n cpu: 200m\n memory: 256Mi\n";
let result = parse_duplicate(text);
assert!(result.is_empty());
}
#[test]
fn should_not_flag_kubernetes_limitrange_sibling_pattern() {
let text = "\
limits:
max:
cpu: \"2\"
memory: 1Gi
min:
cpu: 100m
memory: 128Mi
default:
cpu: 500m
memory: 512Mi
defaultRequest:
cpu: 250m
memory: 256Mi
";
let result = parse_duplicate(text);
assert!(result.is_empty());
}
#[test]
fn should_not_flag_kubernetes_limitrange_inside_sequence_item() {
let text = "\
spec:
limits:
- type: Container
max:
cpu: \"2\"
memory: 1Gi
min:
cpu: 100m
memory: 128Mi
default:
cpu: 500m
memory: 512Mi
defaultRequest:
cpu: 250m
memory: 256Mi
";
let result = parse_duplicate(text);
assert!(result.is_empty());
}
#[test]
fn should_not_flag_same_key_in_deeply_nested_sibling_mappings() {
let text =
"level1:\n level2:\n sibling_a:\n value: 1\n sibling_b:\n value: 2\n";
let result = parse_duplicate(text);
assert!(result.is_empty());
}
#[test]
fn should_still_detect_duplicate_in_same_sibling_mapping() {
let text = "parent:\n child:\n cpu: 100m\n cpu: 200m\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("'cpu'"));
assert_eq!(result[0].range.start.line, 3);
}
#[test]
fn should_not_flag_empty_sibling_mappings_with_shared_key_in_later_sibling() {
let text = "parent:\n a: ~\n b:\n cpu: 1\n c:\n cpu: 2\n";
let result = parse_duplicate(text);
assert!(result.is_empty());
}
#[test]
fn should_not_flag_same_key_in_sibling_mappings_mixed_indent_depth() {
let text = "resources:\n requests:\n cpu: 100m\n limits:\n cpu: 500m\n";
let result = parse_duplicate(text);
assert!(result.is_empty());
}
#[test]
fn should_detect_triple_duplicate_within_single_sibling_mapping() {
let text = "parent:\n child:\n x: 1\n x: 2\n x: 3\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 2);
assert!(result.iter().all(|d| d.message.contains("'x'")));
}
#[test]
fn should_return_empty_for_empty_document_duplicate_keys() {
let result = parse_duplicate("");
assert!(result.is_empty());
}
#[test]
fn should_return_empty_for_comment_only_document_duplicate_keys() {
let result = parse_duplicate("# just a comment\n");
assert!(result.is_empty());
}
#[test]
fn should_use_error_severity_for_duplicate_keys() {
let text = "a: 1\na: 2\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
assert_eq!(result[0].severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn should_truncate_long_key_name_in_message() {
let long_key = "k".repeat(110);
let text = format!("{long_key}: 1\n{long_key}: 2\n");
let result = parse_duplicate(&text);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("..."));
let display = &result[0].message;
assert!(display.len() < long_key.len() + 20);
}
#[test]
fn should_report_correct_column_for_indented_duplicate_key() {
let text = "outer:\n inner:\n dup: 1\n dup: 2\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
assert_eq!(
result[0].range.start.line, 3,
"duplicate is on line 3 (0-based)"
);
assert_eq!(
result[0].range.start.character, 4,
"exact column from AST loc, not indent approximation"
);
}
#[test]
fn should_report_correct_range_end_for_duplicate_key() {
let text = "abc: 1\nabc: 2\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
assert_eq!(result[0].range.start.character, 0);
assert_eq!(
result[0].range.end.character,
result[0].range.start.character + 3,
"end column = start + key length"
);
}
#[test]
fn tag_in_quoted_string_does_not_shadow_real_tag_range() {
let text = "note: \"use !include for files\"\nvalue: !include actual.yaml\n";
let docs = parse_docs(text);
let result = validate_custom_tags(text, &docs, &allowed(&["!other"]));
assert_eq!(result.len(), 1);
assert_eq!(result[0].range.start.line, 1);
}
#[test]
fn tag_in_single_quoted_string_is_skipped() {
let text = "note: 'see !ref for details'\nvalue: !ref target.yaml\n";
let docs = parse_docs(text);
let result = validate_custom_tags(text, &docs, &allowed(&["!other"]));
assert_eq!(result.len(), 1);
assert_eq!(result[0].range.start.line, 1);
}
#[test]
fn tag_boundary_check_rejects_prefix_match() {
let text = "value: !include_extras foo.yaml\n";
let docs = parse_docs(text);
let result = validate_custom_tags(text, &docs, &allowed(&["!other"]));
assert!(result.len() <= 1, "should not crash on boundary check");
}
#[test]
fn second_occurrence_of_same_tag_has_correct_range() {
let text = "a: !include file1.yaml\nb: !include file2.yaml\n";
let docs = parse_docs(text);
let result = validate_custom_tags(text, &docs, &allowed(&["!other"]));
assert_eq!(result.len(), 2);
let lines: Vec<u32> = result.iter().map(|d| d.range.start.line).collect();
assert!(lines.contains(&0), "first occurrence on line 0");
assert!(lines.contains(&1), "second occurrence on line 1");
}
#[test]
fn ellipsis_terminator_resets_scope_for_duplicate_detection() {
let text = "key: 1\n...\nkey: 2\n";
let result = parse_duplicate(text);
assert!(result.is_empty(), "ellipsis terminator should reset scope");
}
#[test]
fn duplicate_key_detected_before_ellipsis_terminator() {
let text = "a: 1\na: 2\n...\nb: 3\n";
let result = parse_duplicate(text);
assert_eq!(result.len(), 1);
assert!(result[0].message.contains("'a'"));
}
#[test]
fn flow_style_not_detected_inside_single_quoted_string() {
let text = "msg: 'value with {braces}'\n";
let result = validate_flow_style(text);
assert!(
result.is_empty(),
"braces inside single quotes must not trigger flowMap"
);
}
#[test]
fn flow_style_detected_after_single_quoted_string_ends() {
let text = "msg: 'quoted' \nreal: {a: 1}\n";
let result = validate_flow_style(text);
assert_eq!(
result.len(),
1,
"should detect flowMap after single-quoted section"
);
}
#[test]
fn validators_produce_same_diagnostics_regardless_of_yaml_version_setting() {
let text_with_v1_1_keywords = "on: push\nyes: true\n";
let text_plain = "push_trigger: push\nenabled: true\n";
assert_eq!(
parse_duplicate(text_with_v1_1_keywords).len(),
parse_duplicate(text_plain).len(),
"duplicate-key diagnostics must not differ based on v1.1 keyword presence"
);
assert_eq!(
validate_flow_style(text_with_v1_1_keywords).len(),
validate_flow_style(text_plain).len(),
"flow-style diagnostics must not differ based on v1.1 keyword presence"
);
}
}