use rlsp_yaml_parser::Span;
use rlsp_yaml_parser::loader::LoaderBuilder;
use rlsp_yaml_parser::node::Document;
use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Position, Range};
const MAX_NESTING_DEPTH: usize = 256;
pub struct ParseResult {
pub documents: Vec<Document<Span>>,
pub diagnostics: Vec<Diagnostic>,
}
#[must_use]
pub fn parse_yaml(text: &str) -> ParseResult {
match LoaderBuilder::new()
.lossless()
.max_nesting_depth(MAX_NESTING_DEPTH)
.build()
.load(text)
{
Ok(documents) => ParseResult {
documents,
diagnostics: Vec::new(),
},
Err(err) => {
let (pos, message) = match &err {
rlsp_yaml_parser::loader::LoadError::Parse { pos, message } => {
(*pos, message.clone())
}
rlsp_yaml_parser::loader::LoadError::NestingDepthLimitExceeded { .. }
| rlsp_yaml_parser::loader::LoadError::AnchorCountLimitExceeded { .. }
| rlsp_yaml_parser::loader::LoadError::AliasExpansionLimitExceeded { .. }
| rlsp_yaml_parser::loader::LoadError::CircularAlias { .. }
| rlsp_yaml_parser::loader::LoadError::UndefinedAlias { .. }
| rlsp_yaml_parser::loader::LoadError::UnexpectedEndOfStream => {
(rlsp_yaml_parser::Pos::ORIGIN, err.to_string())
}
};
#[expect(
clippy::cast_possible_truncation,
reason = "LSP line/col are u32; always fits"
)]
let line = pos.line.saturating_sub(1) as u32;
#[expect(
clippy::cast_possible_truncation,
reason = "LSP line/col are u32; always fits"
)]
let col = pos.column as u32;
let start = Position::new(line, col);
let end = Position::new(line, u32::MAX);
let diagnostic = Diagnostic {
range: Range::new(start, end),
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("yamlSyntax".to_string())),
message,
source: Some("rlsp-yaml".to_string()),
..Diagnostic::default()
};
ParseResult {
documents: Vec::new(),
diagnostics: vec![diagnostic],
}
}
}
}
#[cfg(test)]
#[expect(
clippy::indexing_slicing,
clippy::expect_used,
clippy::unwrap_used,
clippy::panic,
reason = "test code"
)]
mod tests {
use std::fmt::Write as _;
use rstest::rstest;
use super::*;
#[rstest]
#[case::valid_yaml("key: value\n", 1)]
#[case::empty_document("", 0)]
#[case::comment_only("# this is a comment\n", 0)]
#[case::complex_types(
"root:\n list:\n - item1\n - item2\n nested:\n key: value\n",
1
)]
#[case::multi_document("key1: value1\n---\nkey2: value2\n", 2)]
fn parse_yaml_no_diagnostics(#[case] text: &str, #[case] expected_doc_count: usize) {
let result = parse_yaml(text);
assert!(
result.diagnostics.is_empty(),
"expected no diagnostics, got: {:?}",
result.diagnostics
);
assert_eq!(result.documents.len(), expected_doc_count);
}
#[rstest]
#[case::invalid_yaml("key: [invalid\n")]
#[case::bad_nested_flow(":\n bad: [")]
fn parse_yaml_returns_error_diagnostic(#[case] text: &str) {
let result = parse_yaml(text);
assert!(
!result.diagnostics.is_empty(),
"expected at least one diagnostic"
);
assert_eq!(
result.diagnostics[0].severity,
Some(DiagnosticSeverity::ERROR)
);
}
#[test]
fn should_return_correct_line_position_in_diagnostic() {
let input = "key1: value1\nkey2: value2\nkey3: [bad\n";
let result = parse_yaml(input);
assert!(!result.diagnostics.is_empty());
let diag = &result.diagnostics[0];
assert!(
diag.range.start.line >= 2,
"error should be reported on or after the unclosed bracket line, got line {}",
diag.range.start.line
);
}
#[test]
fn should_return_correct_column_position_in_diagnostic() {
let result = parse_yaml("a: [bad\n");
assert!(!result.diagnostics.is_empty());
let diag = &result.diagnostics[0];
assert!(diag.range.start.line <= 1);
}
#[test]
fn should_include_error_message_in_diagnostic() {
let result = parse_yaml("key: [bad\n");
assert!(!result.diagnostics.is_empty());
assert!(!result.diagnostics[0].message.is_empty());
}
#[test]
fn should_handle_yaml_with_only_document_separator() {
let result = parse_yaml("---\n");
assert!(result.diagnostics.is_empty());
assert!(!result.documents.is_empty());
}
#[test]
fn should_not_panic_on_deeply_nested_yaml() {
let mut text = String::new();
for i in 0..64usize {
let indent = " ".repeat(i);
writeln!(text, "{indent}level{i}:").unwrap();
}
let leaf_indent = " ".repeat(64);
writeln!(text, "{leaf_indent}leaf: value").unwrap();
let result = parse_yaml(&text);
assert!(
result.documents.len() + result.diagnostics.len() > 0,
"should return a result (documents or diagnostics), not both empty"
);
}
#[test]
fn should_not_panic_on_large_document() {
let mut text = String::new();
for i in 0..10_000usize {
writeln!(text, "key{i}: value{i}").unwrap();
}
let result = parse_yaml(&text);
assert!(result.diagnostics.is_empty(), "should parse without errors");
assert!(
!result.documents.is_empty(),
"should produce at least 1 document"
);
}
#[test]
fn should_handle_very_large_yaml_document() {
let text = format!("key: {}", "a".repeat(1_000_000));
let result = parse_yaml(&text);
let _ = result;
}
#[test]
fn should_parse_yaml_with_anchors_and_aliases() {
let input = "defaults: &defaults\n adapter: postgres\n host: localhost\nproduction:\n <<: *defaults\n host: production-server\n";
let result = parse_yaml(input);
assert!(result.diagnostics.is_empty());
assert!(!result.documents.is_empty());
}
#[test]
fn should_return_diagnostic_for_multi_document_with_invalid_section() {
let input = "key1: value1\n---\nkey2: [invalid\n";
let result = parse_yaml(input);
assert!(!result.diagnostics.is_empty());
assert_eq!(
result.diagnostics[0].severity,
Some(DiagnosticSeverity::ERROR)
);
assert!(result.documents.is_empty());
}
#[test]
fn should_return_documents_as_document_span_vec() {
let result = parse_yaml("key: value\n");
assert!(!result.documents.is_empty());
let _ = &result.documents[0].root;
}
#[test]
fn should_include_span_in_parsed_scalar() {
use rlsp_yaml_parser::node::Node;
let result = parse_yaml("hello\n");
assert!(!result.documents.is_empty());
match &result.documents[0].root {
Node::Scalar { loc, .. } => {
assert_eq!(loc.start.byte_offset, 0);
}
Node::Mapping { .. } | Node::Sequence { .. } | Node::Alias { .. } => {
panic!("expected Scalar")
}
}
}
#[test]
fn should_produce_error_diagnostic_with_pos_offset() {
let result = parse_yaml("key: [bad\n");
assert!(!result.diagnostics.is_empty());
let diag = &result.diagnostics[0];
assert!(diag.range.start.line <= 1);
}
#[test]
fn should_return_no_documents_on_parse_error() {
let result = parse_yaml("key: [bad\n");
assert!(result.documents.is_empty());
}
#[test]
fn parse_yaml_returns_documents_with_string_value_type() {
use rlsp_yaml_parser::node::Node;
let result = parse_yaml("key: value\n");
assert!(!result.documents.is_empty());
match &result.documents[0].root {
Node::Mapping { entries, .. } => {
assert!(!entries.is_empty());
let (k, v) = &entries[0];
match k {
Node::Scalar { value, .. } => {
let s: &str = value.as_str();
assert_eq!(s, "key");
}
Node::Mapping { .. } | Node::Sequence { .. } | Node::Alias { .. } => {
panic!("expected Scalar key")
}
}
match v {
Node::Scalar { value, .. } => {
assert_eq!(value.as_str(), "value");
}
Node::Mapping { .. } | Node::Sequence { .. } | Node::Alias { .. } => {
panic!("expected Scalar value")
}
}
}
Node::Scalar { .. } | Node::Sequence { .. } | Node::Alias { .. } => {
panic!("expected Mapping root")
}
}
}
#[test]
fn parse_yaml_nesting_depth_limit_produces_diagnostic() {
let result = std::thread::Builder::new()
.stack_size(64 * 1024 * 1024)
.spawn(|| {
let mut text = String::new();
for i in 0..260usize {
let indent = " ".repeat(i);
writeln!(text, "{indent}level{i}:").unwrap();
}
let leaf_indent = " ".repeat(260);
writeln!(text, "{leaf_indent}leaf: value").unwrap();
parse_yaml(&text)
})
.expect("thread spawn")
.join()
.expect("thread join");
assert!(
!result.diagnostics.is_empty(),
"expected diagnostic for deep nesting"
);
assert!(
result.documents.is_empty(),
"expected no documents on error"
);
}
#[test]
fn parse_yaml_undefined_alias_in_lossless_mode_produces_alias_node() {
use rlsp_yaml_parser::node::Node;
let result = parse_yaml("key: *undefined\n");
if result.documents.is_empty() {
assert!(
!result.diagnostics.is_empty(),
"either documents or diagnostics must be non-empty"
);
} else {
match &result.documents[0].root {
Node::Mapping { entries, .. } => {
let (_, v) = &entries[0];
assert!(
matches!(v, Node::Alias { .. }),
"expected Alias node for *undefined in lossless mode, got: {v:?}"
);
}
Node::Scalar { .. } | Node::Sequence { .. } | Node::Alias { .. } => {
panic!("expected Mapping root")
}
}
}
}
}