use rlsp_yaml_parser::node::{Document, Node};
use rlsp_yaml_parser::{LineIndex, Span};
use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, NumberOrString};
use crate::lsp_util::span_to_lsp;
#[must_use]
pub fn validate_key_ordering(docs: &[Document<Span>]) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for doc in docs {
let idx = doc.line_index();
check_yaml_ordering(&doc.root, &mut diagnostics, 0, idx);
}
diagnostics
}
fn check_yaml_ordering(
node: &Node<Span>,
diagnostics: &mut Vec<Diagnostic>,
depth: usize,
idx: &LineIndex,
) {
const MAX_DEPTH: usize = 100;
if depth > MAX_DEPTH {
return;
}
match node {
Node::Mapping { entries, .. } => {
let keys: Vec<(&str, &Span)> = entries
.iter()
.filter_map(|(k, _)| match k {
Node::Scalar {
tag, value, loc, ..
} if tag.as_deref() != Some("tag:yaml.org,2002:null") => {
Some((value.as_str(), loc))
}
Node::Scalar { .. }
| Node::Mapping { .. }
| Node::Sequence { .. }
| Node::Alias { .. } => None,
})
.collect();
let mut max_key: &str = keys.first().map_or("", |&(k, _)| k);
for &(key, loc) in keys.iter().skip(1) {
if key < max_key {
let range = span_to_lsp(*loc, idx);
diagnostics.push(Diagnostic {
range,
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 > max_key {
max_key = key;
}
}
for (_, value) in entries {
check_yaml_ordering(value, diagnostics, depth + 1, idx);
}
}
Node::Sequence { items, .. } => {
for item in items {
check_yaml_ordering(item, diagnostics, depth + 1, idx);
}
}
Node::Scalar { .. } | Node::Alias { .. } => {}
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use tower_lsp::lsp_types::DiagnosticSeverity;
use super::*;
#[rstest]
#[case::ordered_keys("apple: 1\nbanana: 2\ncherry: 3\n")]
#[case::empty_document("")]
#[case::single_key("only: value\n")]
#[case::sequence_items_ignored("items:\n - zebra\n - alpha\n")]
#[case::multi_document_single_keys("z: 1\n---\na: 2\n")]
#[case::case_sensitive_uppercase_first("Apple: 1\napple: 2\n")]
fn key_ordering_returns_empty(#[case] input: &str) {
let docs = rlsp_yaml_parser::load(input).unwrap();
let result = validate_key_ordering(&docs);
assert!(result.is_empty());
}
#[rstest]
#[case::single_ooo("banana: 2\napple: 1\n", 1)]
#[case::multiple_ooo("charlie: 3\nalpha: 1\nbravo: 2\n", 2)]
#[case::nested_ooo("outer:\n zebra: 1\n alpha: 2\n", 1)]
#[case::top_level_ooo_only("b_parent:\n a_child: 1\na_parent:\n key: val\n", 1)]
#[case::numeric_string_lexicographic("2: two\n10: ten\n", 1)]
fn key_ordering_count(#[case] input: &str, #[case] expected: usize) {
let docs = rlsp_yaml_parser::load(input).unwrap();
let result = validate_key_ordering(&docs);
assert_eq!(result.len(), expected);
}
#[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(&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(&docs);
assert_eq!(result.len(), 1);
assert_eq!(result[0].range.start.line, 1, "apple is on line 1");
assert_eq!(result[0].range.start.character, 0, "apple starts at col 0");
assert_eq!(result[0].range.end.character, 5, "apple is 5 chars long");
}
#[test]
fn tag_driven_null_key_excluded_from_ordering_check() {
let text = "~: value\nzebra: 1\nalpha: 2\n";
let docs = rlsp_yaml_parser::load(text).unwrap();
let result = validate_key_ordering(&docs);
assert_eq!(
result.len(),
1,
"null key must be excluded; only alpha is out of order"
);
assert!(
matches!(result[0].code.as_ref(), Some(NumberOrString::String(s)) if s == "mapKeyOrder")
);
}
#[test]
fn tag_driven_non_null_key_included_in_ordering_check() {
let text = "banana: 2\napple: 1\n";
let docs = rlsp_yaml_parser::load(text).unwrap();
let result = validate_key_ordering(&docs);
assert_eq!(result.len(), 1, "apple is out of order and must be flagged");
}
#[test]
fn out_of_order_key_range_covers_key_span() {
let text = "banana: 2\napple: 1\n";
let docs = rlsp_yaml_parser::load(text).unwrap();
let result = validate_key_ordering(&docs);
assert_eq!(result.len(), 1);
assert_eq!(result[0].range.start.line, 1, "apple on line 1");
assert_eq!(result[0].range.start.character, 0, "apple at col 0");
assert_eq!(result[0].range.end.character, 5, "apple is 5 chars");
}
#[test]
fn out_of_order_key_in_indented_block_has_correct_column() {
let text = "outer:\n zebra: 1\n alpha: 2\n";
let docs = rlsp_yaml_parser::load(text).unwrap();
let result = validate_key_ordering(&docs);
assert_eq!(result.len(), 1);
assert_eq!(result[0].range.start.line, 2, "alpha on line 2");
assert_eq!(result[0].range.start.character, 2, "alpha indented by 2");
assert_eq!(
result[0].range.end.character, 7,
"col 2 + len('alpha') == 7"
);
}
#[test]
fn out_of_order_keys_in_flow_mapping() {
let text = "{zebra: 1, alpha: 2}";
let docs = rlsp_yaml_parser::load(text).unwrap();
let result = validate_key_ordering(&docs);
assert_eq!(result.len(), 1);
assert!(
result[0].range.start.character > 0,
"alpha's column in flow mapping is non-zero"
);
}
}