#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WhitespaceError {
pub message: String,
pub range: std::ops::Range<usize>,
pub category: WhitespaceErrorCategory,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WhitespaceErrorCategory {
TabIndentation,
LineTooLong,
MixedLineEndings,
InvalidIndentation,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u16)]
#[allow(non_camel_case_types, clippy::upper_case_acronyms)]
pub enum SyntaxKind {
ROOT = 0,
DOCUMENT,
SEQUENCE,
MAPPING,
SCALAR,
ALIAS,
TAGGED_NODE,
ERROR,
DASH,
PLUS,
COLON,
QUESTION,
LEFT_BRACKET,
RIGHT_BRACKET,
LEFT_BRACE,
RIGHT_BRACE,
COMMA,
PIPE,
GREATER,
AMPERSAND,
ASTERISK,
EXCLAMATION,
PERCENT,
AT,
BACKTICK,
QUOTE,
SINGLE_QUOTE,
DOC_START,
DOC_END,
KEY,
VALUE,
MAPPING_ENTRY,
SEQUENCE_ENTRY,
STRING,
UNTERMINATED_STRING,
INT,
FLOAT,
BOOL,
NULL,
TAG,
ANCHOR,
REFERENCE,
MERGE_KEY,
DIRECTIVE,
WHITESPACE,
NEWLINE,
INDENT,
COMMENT,
BOM,
EOF,
}
impl From<SyntaxKind> for rowan::SyntaxKind {
fn from(kind: SyntaxKind) -> Self {
Self(kind as u16)
}
}
fn plain_scalar_continues_past_whitespace(input: &str, ws_idx: usize, flow_depth: u32) -> bool {
let rest = &input[ws_idx..];
let (offset, next) = match rest.char_indices().find(|(_, c)| *c != ' ' && *c != '\t') {
Some(v) => v,
None => return false,
};
match next {
'\n' | '\r' | '#' => false,
',' | '[' | ']' | '{' | '}' if flow_depth > 0 => false,
':' => !is_colon_a_mapping_indicator(input, ws_idx + offset, flow_depth),
'-' | '+' | '?' | '|' | '>' | '&' | '*' | '!' | '%' | '\'' | '"' | ',' | '[' | ']'
| '{' | '}' => true,
c if is_yaml_special(c) => false,
_ => true,
}
}
fn read_anchor_name_from<'a>(
chars: &mut std::iter::Peekable<std::str::CharIndices<'a>>,
input: &'a str,
start_idx: usize,
) -> &'a str {
let mut end_idx = start_idx;
while let Some((idx, ch)) = chars.peek().copied() {
if ch.is_whitespace() || matches!(ch, ',' | '[' | ']' | '{' | '}') {
break;
}
end_idx = idx + ch.len_utf8();
chars.next();
}
&input[start_idx..end_idx]
}
fn is_colon_a_mapping_indicator(input: &str, colon_idx: usize, flow_depth: u32) -> bool {
if flow_depth > 0
&& input[..colon_idx]
.chars()
.next_back()
.is_some_and(|ch| matches!(ch, '}' | ']' | '"' | '\''))
{
return true;
}
let after = input[colon_idx + 1..].chars().next();
match after {
None => true,
Some(ch) if ch.is_whitespace() => true,
Some(ch) if flow_depth > 0 && matches!(ch, ',' | ']' | '}') => true,
_ => false,
}
}
fn is_hash_a_comment_start(input: &str, hash_idx: usize) -> bool {
hash_idx == 0
|| input[..hash_idx]
.chars()
.next_back()
.is_some_and(|c| c.is_whitespace())
}
fn scan_double_quoted(
chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>,
start_idx: usize,
) -> (usize, bool) {
let mut end_idx = start_idx + 1;
let mut escaped = false;
while let Some((idx, ch)) = chars.peek() {
let (current_idx, current_ch) = (*idx, *ch);
end_idx = current_idx + current_ch.len_utf8();
chars.next();
if escaped {
escaped = false;
} else if current_ch == '\\' {
escaped = true;
} else if current_ch == '"' {
return (end_idx, true);
}
}
(end_idx, false)
}
fn scan_single_quoted(
chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>,
start_idx: usize,
) -> (usize, bool) {
let mut end_idx = start_idx + 1;
while let Some((idx, ch)) = chars.peek() {
let (current_idx, current_ch) = (*idx, *ch);
end_idx = current_idx + current_ch.len_utf8();
chars.next();
if current_ch == '\'' {
if let Some((next_idx, '\'')) = chars.peek() {
end_idx = *next_idx + 1;
chars.next();
} else {
return (end_idx, true);
}
}
}
(end_idx, false)
}
fn push_quoted_string<'a>(
tokens: &mut Vec<(SyntaxKind, &'a str)>,
input: &'a str,
token_start: usize,
end_idx: usize,
found_closing: bool,
) {
let kind = if found_closing {
SyntaxKind::STRING
} else {
SyntaxKind::UNTERMINATED_STRING
};
tokens.push((kind, &input[token_start..end_idx]));
}
fn push_plain_scalar_from<'a>(
tokens: &mut Vec<(SyntaxKind, &'a str)>,
chars: &mut std::iter::Peekable<std::str::CharIndices<'a>>,
input: &'a str,
token_start: usize,
body_start: usize,
flow_depth: u32,
absorb_spaces: bool,
) {
let body = read_plain_scalar_body_from(chars, input, body_start, flow_depth, absorb_spaces);
let text = &input[token_start..body_start + body.len()];
tokens.push((classify_scalar(text), text));
}
fn read_plain_scalar_body_from<'a>(
chars: &mut std::iter::Peekable<std::str::CharIndices<'a>>,
input: &'a str,
start_idx: usize,
flow_depth: u32,
absorb_spaces: bool,
) -> &'a str {
let mut end_idx = start_idx;
while let Some((idx, ch)) = chars.peek().copied() {
if absorb_spaces && (ch == ' ' || ch == '\t') {
if !plain_scalar_continues_past_whitespace(input, idx, flow_depth) {
break;
}
while let Some((wi, wc)) = chars.peek().copied() {
if wc != ' ' && wc != '\t' {
break;
}
end_idx = wi + wc.len_utf8();
chars.next();
}
continue;
}
if ch.is_whitespace() {
break;
}
if ch == ':' && is_colon_a_mapping_indicator(input, idx, flow_depth) {
break;
}
if ch == '#' && is_hash_a_comment_start(input, idx) {
break;
}
if is_yaml_special_except(ch, "-+:#'\"?|>&*!%")
&& !(flow_depth == 0 && matches!(ch, ',' | '[' | ']' | '{' | '}'))
{
break;
}
end_idx = idx + ch.len_utf8();
chars.next();
}
&input[start_idx..end_idx]
}
pub fn lex(input: &str) -> Vec<(SyntaxKind, &str)> {
let (tokens, _) = lex_with_validation(input);
tokens
}
pub struct ValidationConfig {
pub max_line_length: Option<usize>,
pub enforce_consistent_line_endings: bool,
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
max_line_length: Some(120), enforce_consistent_line_endings: true,
}
}
}
pub fn lex_with_validation(input: &str) -> (Vec<(SyntaxKind, &str)>, Vec<WhitespaceError>) {
lex_with_validation_config(input, &ValidationConfig::default())
}
fn check_line_length(
errors: &mut Vec<WhitespaceError>,
config: &ValidationConfig,
line_start: usize,
end: usize,
) {
let Some(max_len) = config.max_line_length else {
return;
};
let line_length = end - line_start;
if line_length > max_len {
errors.push(WhitespaceError {
message: format!("Line too long ({line_length} > {max_len} characters)"),
range: line_start..end,
category: WhitespaceErrorCategory::LineTooLong,
});
}
}
fn note_line_ending<'a>(
errors: &mut Vec<WhitespaceError>,
config: &ValidationConfig,
detected: &mut Option<&'a str>,
line_ending: &'a str,
range: std::ops::Range<usize>,
) {
if !config.enforce_consistent_line_endings {
return;
}
match *detected {
Some(seen) if seen != line_ending => errors.push(WhitespaceError {
message: "Inconsistent line endings detected".to_string(),
range,
category: WhitespaceErrorCategory::MixedLineEndings,
}),
Some(_) => {}
None => *detected = Some(line_ending),
}
}
pub fn lex_with_validation_config<'a>(
input: &'a str,
config: &ValidationConfig,
) -> (Vec<(SyntaxKind, &'a str)>, Vec<WhitespaceError>) {
use SyntaxKind::*;
let mut tokens = Vec::with_capacity(input.len() / 8); let mut chars = input.char_indices().peekable();
let mut whitespace_errors = Vec::new();
let bytes = input.as_bytes();
let mut current_line_start = 0;
let mut detected_line_ending: Option<&str> = None;
let mut flow_depth: u32 = 0;
let mut block_scalar_header_indent: Option<usize> = None;
let mut block_scalar_body_indent: Option<usize> = None;
if let Some((0, '\u{FEFF}')) = chars.peek() {
chars.next(); tokens.push((BOM, "\u{FEFF}"));
current_line_start = '\u{FEFF}'.len_utf8();
}
while let Some((start_idx, ch)) = chars.next() {
let token_start = start_idx;
match ch {
'-' => {
if flow_depth == 0
&& start_idx == current_line_start
&& input[start_idx..].starts_with("---")
&& input[start_idx + 3..]
.chars()
.next()
.map_or(true, ends_document_marker)
{
chars.next();
chars.next();
tokens.push((DOC_START, &input[token_start..start_idx + 3]));
} else {
let line_start_pos = current_line_start;
let before_dash = &input[line_start_pos..token_start];
let only_whitespace_before = before_dash.chars().all(|c| c == ' ' || c == '\t');
let after_value_indicator = tokens
.iter()
.rev()
.find(|(kind, _)| !matches!(kind, WHITESPACE | INDENT))
.is_some_and(|(kind, _)| matches!(kind, QUESTION | COLON | DASH));
let followed_by_whitespace_or_end = chars
.peek()
.map_or(true, |(_, next_ch)| next_ch.is_whitespace());
let is_sequence_marker = (only_whitespace_before || after_value_indicator)
&& followed_by_whitespace_or_end;
if is_sequence_marker {
tokens.push((DASH, &input[token_start..start_idx + 1]));
} else {
let is_chomping_indicator = input[current_line_start..start_idx]
.bytes()
.rev()
.find(|b| !b.is_ascii_digit())
.is_some_and(|b| b == b'|' || b == b'>');
push_plain_scalar_from(
&mut tokens,
&mut chars,
input,
token_start,
start_idx + 1,
flow_depth,
!is_chomping_indicator,
);
}
}
}
'+' => {
let line_start = current_line_start;
let is_chomping_indicator = input[line_start..start_idx]
.bytes()
.rev()
.find(|b| !b.is_ascii_digit())
.is_some_and(|b| b == b'|' || b == b'>');
let next_starts_scalar = chars.peek().is_some_and(|(idx, c)| {
if c.is_whitespace() {
return *c != '\n'
&& *c != '\r'
&& plain_scalar_continues_past_whitespace(input, *idx, flow_depth);
}
!c.is_whitespace()
&& (!is_yaml_special(*c)
|| matches!(
c,
'\'' | '"' | '+' | '-' | '?' | '|' | '>' | '&' | '*' | '!' | '%'
)
|| (*c == '#' && !is_hash_a_comment_start(input, *idx))
|| (flow_depth == 0 && matches!(c, '[' | ']' | '{' | '}' | ','))
|| (*c == ':'
&& !is_colon_a_mapping_indicator(input, *idx, flow_depth)))
});
if !is_chomping_indicator && next_starts_scalar {
push_plain_scalar_from(
&mut tokens,
&mut chars,
input,
token_start,
start_idx + 1,
flow_depth,
true,
);
} else if !is_chomping_indicator && {
let rest = &input[start_idx + 1..];
let gap = rest.len() - rest.trim_start_matches([' ', '\t']).len();
rest[gap..].starts_with(':')
&& is_colon_a_mapping_indicator(input, start_idx + 1 + gap, flow_depth)
} {
let text = &input[token_start..start_idx + 1];
tokens.push((classify_scalar(text), text));
} else if is_chomping_indicator {
tokens.push((PLUS, &input[token_start..start_idx + 1]));
} else {
let text = &input[token_start..start_idx + 1];
tokens.push((classify_scalar(text), text));
}
}
':' => {
let preceded_by_scalar = flow_depth > 0
&& tokens
.iter()
.rev()
.find(|(k, _)| !matches!(k, WHITESPACE | INDENT | NEWLINE | COMMENT))
.is_some_and(|(k, _)| matches!(k, STRING | INT | FLOAT | BOOL | NULL));
if preceded_by_scalar || is_colon_a_mapping_indicator(input, start_idx, flow_depth)
{
tokens.push((COLON, &input[token_start..start_idx + 1]));
} else {
let rest = read_plain_scalar_body_from(
&mut chars,
input,
start_idx + 1,
flow_depth,
false,
);
let text = &input[token_start..start_idx + 1 + rest.len()];
tokens.push((classify_scalar(text), text));
}
}
'?' if node_property_can_start(&tokens)
&& chars.peek().map_or(true, |(_, next)| next.is_whitespace()) =>
{
tokens.push((QUESTION, &input[token_start..start_idx + 1]))
}
'[' | ']' | '{' | '}' | ','
if at_block_scalar_body_indent(
block_scalar_body_indent,
input,
current_line_start,
start_idx,
) || in_block_scalar_body(
block_scalar_header_indent,
input,
current_line_start,
start_idx,
) =>
{
let rest =
read_plain_scalar_body_from(&mut chars, input, start_idx + 1, flow_depth, true);
let text = &input[token_start..start_idx + 1 + rest.len()];
tokens.push((classify_scalar(text), text));
}
'[' => {
flow_depth += 1;
tokens.push((LEFT_BRACKET, &input[token_start..start_idx + 1]));
}
']' => {
flow_depth = flow_depth.saturating_sub(1);
tokens.push((RIGHT_BRACKET, &input[token_start..start_idx + 1]));
}
'{' => {
flow_depth += 1;
tokens.push((LEFT_BRACE, &input[token_start..start_idx + 1]));
}
'}' => {
flow_depth = flow_depth.saturating_sub(1);
tokens.push((RIGHT_BRACE, &input[token_start..start_idx + 1]));
}
',' if flow_depth > 0 => tokens.push((COMMA, &input[token_start..start_idx + 1])),
'|' if flow_depth == 0 && node_property_can_start(&tokens) => {
block_scalar_header_indent = Some(line_indent(input, current_line_start));
block_scalar_body_indent = None;
tokens.push((PIPE, &input[token_start..start_idx + 1]))
}
'>' if flow_depth == 0 && node_property_can_start(&tokens) => {
block_scalar_header_indent = Some(line_indent(input, current_line_start));
block_scalar_body_indent = None;
tokens.push((GREATER, &input[token_start..start_idx + 1]))
}
'<' if is_merge_key_at(input, start_idx) => {
chars.next(); tokens.push((MERGE_KEY, &input[token_start..start_idx + 2]));
}
'&' if node_property_can_start(&tokens) => {
let name = read_anchor_name_from(&mut chars, input, start_idx + 1);
if !name.is_empty() {
tokens.push((ANCHOR, &input[token_start..start_idx + 1 + name.len()]));
} else {
tokens.push((AMPERSAND, &input[token_start..start_idx + 1]));
}
}
'*' if node_property_can_start(&tokens) => {
let name = read_anchor_name_from(&mut chars, input, start_idx + 1);
if !name.is_empty() {
tokens.push((REFERENCE, &input[token_start..start_idx + 1 + name.len()]));
} else {
tokens.push((ASTERISK, &input[token_start..start_idx + 1]));
}
}
'"' => {
let (end_idx, found_closing) = scan_double_quoted(&mut chars, start_idx);
push_quoted_string(&mut tokens, input, token_start, end_idx, found_closing);
}
'\'' => {
let (end_idx, found_closing) = scan_single_quoted(&mut chars, start_idx);
push_quoted_string(&mut tokens, input, token_start, end_idx, found_closing);
}
'.' => {
let is_doc_end = flow_depth == 0
&& start_idx == current_line_start
&& input[start_idx..].starts_with("...")
&& input[start_idx + 3..]
.chars()
.next()
.map_or(true, ends_document_marker);
if is_doc_end && chars.peek() == Some(&(start_idx + 1, '.')) {
chars.next(); if chars.peek() == Some(&(start_idx + 2, '.')) {
chars.next(); tokens.push((DOC_END, &input[token_start..start_idx + 3]));
} else {
push_plain_scalar_from(
&mut tokens,
&mut chars,
input,
token_start,
start_idx + 2,
flow_depth,
true,
);
}
} else {
push_plain_scalar_from(
&mut tokens,
&mut chars,
input,
token_start,
start_idx + 1,
flow_depth,
true,
);
}
}
'#' if at_block_scalar_body_indent(
block_scalar_body_indent,
input,
current_line_start,
start_idx,
) =>
{
let rest =
read_plain_scalar_body_from(&mut chars, input, start_idx + 1, flow_depth, true);
let text = &input[token_start..start_idx + 1 + rest.len()];
tokens.push((classify_scalar(text), text));
}
'#' if !is_hash_a_comment_start(input, start_idx)
&& {
let prev = input[..start_idx].chars().next_back();
prev.is_some_and(|c| !is_yaml_special(c) || matches!(c, '!' | '"' | '\''))
}
&& !tokens.last().is_some_and(|(k, text)| {
matches!(k, STRING | UNTERMINATED_STRING)
&& text.starts_with(['"', '\''])
&& input[..start_idx].ends_with(['"', '\''])
}) =>
{
let rest =
read_plain_scalar_body_from(&mut chars, input, start_idx + 1, flow_depth, true);
let text = &input[token_start..start_idx + 1 + rest.len()];
tokens.push((classify_scalar(text), text));
}
'#' => {
let mut end_idx = start_idx + 1;
while let Some((idx, ch)) = chars.peek() {
if *ch == '\n' || *ch == '\r' {
break;
}
end_idx = *idx + ch.len_utf8();
chars.next();
}
tokens.push((COMMENT, &input[token_start..end_idx]));
}
'!' if node_property_can_start(&tokens) => {
let mut end_idx = start_idx + 1;
if let Some((_, '<')) = chars.peek() {
chars.next(); end_idx = start_idx + 2;
while let Some((idx, ch)) = chars.peek() {
if *ch == '\n' {
break;
}
end_idx = *idx + ch.len_utf8();
let closing = *ch == '>';
chars.next();
if closing {
break;
}
}
tokens.push((TAG, &input[token_start..end_idx]));
continue;
}
if let Some((_, '!')) = chars.peek() {
chars.next(); end_idx = start_idx + 2;
}
while let Some((idx, ch)) = chars.peek() {
if !is_tag_char(*ch) {
break;
}
end_idx = *idx + ch.len_utf8();
chars.next();
}
tokens.push((TAG, &input[token_start..end_idx]));
}
'%' => {
if flow_depth > 0 || start_idx != current_line_start {
let rest = read_plain_scalar_body_from(
&mut chars,
input,
start_idx + 1,
flow_depth,
true,
);
let text = &input[token_start..start_idx + 1 + rest.len()];
tokens.push((classify_scalar(text), text));
} else {
let mut end_idx = start_idx + 1;
while let Some((idx, ch)) = chars.peek() {
if *ch == '\n' || *ch == '\r' {
break;
}
end_idx = *idx + ch.len_utf8();
chars.next();
}
tokens.push((DIRECTIVE, &input[token_start..end_idx]));
}
}
'\n' => {
check_line_length(
&mut whitespace_errors,
config,
current_line_start,
start_idx,
);
note_line_ending(
&mut whitespace_errors,
config,
&mut detected_line_ending,
"\n",
token_start..start_idx + 1,
);
tokens.push((NEWLINE, &input[token_start..start_idx + 1]));
current_line_start = start_idx + 1;
track_block_scalar_body(
input,
current_line_start,
&mut block_scalar_header_indent,
&mut block_scalar_body_indent,
);
}
'\r' => {
check_line_length(
&mut whitespace_errors,
config,
current_line_start,
start_idx,
);
let (line_ending, end_pos) = if let Some((_, '\n')) = chars.peek() {
chars.next();
("\r\n", start_idx + 2)
} else {
("\r", start_idx + 1)
};
note_line_ending(
&mut whitespace_errors,
config,
&mut detected_line_ending,
line_ending,
token_start..end_pos,
);
tokens.push((NEWLINE, &input[token_start..end_pos]));
current_line_start = end_pos;
track_block_scalar_body(
input,
current_line_start,
&mut block_scalar_header_indent,
&mut block_scalar_body_indent,
);
}
' ' | '\t' => {
let mut end_idx = start_idx + 1;
let mut has_tabs = ch == '\t';
while let Some((idx, ch)) = chars.peek() {
if *ch != ' ' && *ch != '\t' {
break;
}
if *ch == '\t' {
has_tabs = true;
}
end_idx = *idx + 1;
chars.next();
}
let is_indentation = token_start == 0
|| (token_start > 0
&& (bytes[token_start - 1] == b'\n' || bytes[token_start - 1] == b'\r'));
if is_indentation {
if has_tabs {
whitespace_errors.push(WhitespaceError {
message: "Tab character used for indentation (forbidden in YAML)"
.to_string(),
range: token_start..end_idx,
category: WhitespaceErrorCategory::TabIndentation,
});
}
tokens.push((INDENT, &input[token_start..end_idx]));
} else {
tokens.push((WHITESPACE, &input[token_start..end_idx]));
}
}
_ => {
let mut end_idx = start_idx + ch.len_utf8();
while let Some((idx, next_ch)) = chars.peek().copied() {
if next_ch == '\n' || next_ch == '\r' {
break;
}
if next_ch == ' ' || next_ch == '\t' {
if !plain_scalar_continues_past_whitespace(input, idx, flow_depth) {
break;
}
while let Some((wi, wc)) = chars.peek().copied() {
if wc != ' ' && wc != '\t' {
break;
}
end_idx = wi + wc.len_utf8();
chars.next();
}
continue;
}
if next_ch == ':' {
if is_colon_a_mapping_indicator(input, idx, flow_depth) {
break;
}
end_idx = idx + next_ch.len_utf8();
chars.next();
continue;
}
let plus_is_chomping = next_ch == '+' && {
let head = input[current_line_start..idx]
.trim_end_matches(|c: char| c.is_ascii_digit());
match head.strip_suffix(['|', '>']) {
Some(before) => {
let before = before.trim_end();
before.is_empty() || before.ends_with(':') || before.ends_with('-')
}
None => false,
}
};
if plus_is_chomping {
break;
}
if is_yaml_special_except(next_ch, "-:#&*!?'\"|>%+") {
if flow_depth == 0 && matches!(next_ch, '[' | ']' | '{' | '}' | ',') {
} else {
break;
}
}
if next_ch == '#' {
if is_hash_a_comment_start(input, idx) {
break;
}
end_idx = idx + next_ch.len_utf8();
chars.next();
continue;
}
if next_ch == '-' {
let before_hyphen = &input[current_line_start..idx];
if before_hyphen.chars().all(|c| c == ' ' || c == '\t') && idx == end_idx {
break;
}
}
end_idx = idx + next_ch.len_utf8();
chars.next();
}
let text = &input[token_start..end_idx];
tokens.push((classify_scalar(text), text));
}
}
}
if let Some(max_len) = config.max_line_length {
let final_line_length = input.len() - current_line_start;
if final_line_length > max_len && final_line_length > 0 {
whitespace_errors.push(WhitespaceError {
message: format!("Line too long ({final_line_length} > {max_len} characters)"),
range: current_line_start..input.len(),
category: WhitespaceErrorCategory::LineTooLong,
});
}
}
(tokens, whitespace_errors)
}
fn classify_scalar(text: &str) -> SyntaxKind {
use crate::CoreScalarType;
match crate::scalar::ScalarValue::classify_plain(text) {
CoreScalarType::Null => SyntaxKind::NULL,
CoreScalarType::Boolean => SyntaxKind::BOOL,
CoreScalarType::Integer => SyntaxKind::INT,
CoreScalarType::Float => SyntaxKind::FLOAT,
CoreScalarType::String => SyntaxKind::STRING,
}
}
const YAML_SPECIAL_CHARS: &str = ":+-?[]{},'|>&*!%\"#";
fn is_merge_key_at(input: &str, idx: usize) -> bool {
let rest = &input[idx..];
if !rest.starts_with("<<") {
return false;
}
match rest[2..].chars().next() {
None => true,
Some(ch) => ch.is_whitespace() || matches!(ch, ':' | ',' | ']' | '}' | '#'),
}
}
fn ends_document_marker(ch: char) -> bool {
matches!(ch, ' ' | '\t' | '\n' | '\r')
}
fn node_property_can_start(tokens: &[(SyntaxKind, &str)]) -> bool {
for (kind, _) in tokens.iter().rev() {
match kind {
SyntaxKind::WHITESPACE | SyntaxKind::INDENT => continue,
SyntaxKind::NEWLINE
| SyntaxKind::COLON
| SyntaxKind::DASH
| SyntaxKind::QUESTION
| SyntaxKind::COMMA
| SyntaxKind::LEFT_BRACKET
| SyntaxKind::LEFT_BRACE
| SyntaxKind::DOC_START
| SyntaxKind::TAG
| SyntaxKind::ANCHOR
| SyntaxKind::AMPERSAND
| SyntaxKind::ASTERISK => return true,
_ => return false,
}
}
true
}
fn line_is_blank(input: &str, line_start: usize) -> bool {
input[line_start..]
.split(['\n', '\r'])
.next()
.unwrap_or_default()
.trim()
.is_empty()
}
fn line_indent(input: &str, line_start: usize) -> usize {
let line = &input[line_start..];
line.len() - line.trim_start_matches([' ', '\t']).len()
}
fn line_starts_content_at_or_before(input: &str, line_start: usize, indent: usize) -> bool {
let line = input[line_start..]
.split(['\n', '\r'])
.next()
.unwrap_or_default();
if line.trim().is_empty() {
return false;
}
line.len() - line.trim_start_matches([' ', '\t']).len() <= indent
}
fn track_block_scalar_body(
input: &str,
line_start: usize,
header_indent: &mut Option<usize>,
body_indent: &mut Option<usize>,
) {
let Some(header) = *header_indent else {
return;
};
let bound = body_indent.unwrap_or(header);
if line_starts_content_at_or_before(input, line_start, bound) {
*header_indent = None;
*body_indent = None;
} else if body_indent.is_none() && !line_is_blank(input, line_start) {
*body_indent = Some(line_indent(input, line_start));
}
}
fn in_block_scalar_body(
header_indent: Option<usize>,
input: &str,
line_start: usize,
idx: usize,
) -> bool {
let Some(header_indent) = header_indent else {
return false;
};
idx > line_start && line_indent(input, line_start) > header_indent
}
fn at_block_scalar_body_indent(
body_indent: Option<usize>,
input: &str,
line_start: usize,
idx: usize,
) -> bool {
let Some(body_indent) = body_indent else {
return false;
};
idx > line_start && line_indent(input, line_start) >= body_indent
}
fn is_tag_char(ch: char) -> bool {
if ch.is_whitespace() {
return false;
}
!matches!(ch, ',' | '[' | ']' | '{' | '}' | '!' | '#')
}
fn is_yaml_special(ch: char) -> bool {
YAML_SPECIAL_CHARS.contains(ch)
}
fn is_yaml_special_except(ch: char, exclude: &str) -> bool {
YAML_SPECIAL_CHARS.contains(ch) && !exclude.contains(ch)
}
pub(crate) fn is_valid_anchor_name(name: &str) -> bool {
!name.is_empty()
&& !name
.chars()
.any(|c| c.is_whitespace() || is_yaml_special(c))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_valid_anchor_name() {
assert!(is_valid_anchor_name("shared"));
assert!(is_valid_anchor_name("shared_1"));
assert!(is_valid_anchor_name("anchor.dotted"));
assert!(is_valid_anchor_name("naïve"));
assert!(!is_valid_anchor_name(""));
assert!(!is_valid_anchor_name("has space"));
assert!(!is_valid_anchor_name("has\ttab"));
assert!(!is_valid_anchor_name("has\nnewline"));
assert!(!is_valid_anchor_name("*star"));
assert!(!is_valid_anchor_name("&"));
assert!(!is_valid_anchor_name("comma,name"));
assert!(!is_valid_anchor_name("bracket]name"));
assert!(!is_valid_anchor_name("colon:name"));
}
#[test]
fn test_embedded_hyphens_stay_in_one_plain_scalar() {
let input = format!("k: {}\n", "a-".repeat(200));
let kinds: Vec<_> = lex(&input).into_iter().map(|(k, _)| k).collect();
assert!(kinds.contains(&SyntaxKind::STRING) || kinds.contains(&SyntaxKind::SCALAR));
let dash_count = kinds.iter().filter(|k| **k == SyntaxKind::DASH).count();
assert_eq!(dash_count, 0);
let seq = lex("- a\n- b\n");
let seq_dashes = seq.iter().filter(|(k, _)| *k == SyntaxKind::DASH).count();
assert_eq!(seq_dashes, 2);
}
#[test]
fn test_valid_anchor_names_round_trip_through_lexer() {
for name in ["shared", "shared_1", "anchor.dotted", "naïve"] {
let input = format!("*{name}");
let tokens = lex(&input);
assert_eq!(
tokens,
vec![(SyntaxKind::REFERENCE, input.as_str())],
"name {name:?} did not round-trip as a single REFERENCE token"
);
}
}
#[test]
fn test_simple_mapping() {
let input = "key: value";
let tokens = lex(input);
assert_eq!(tokens.len(), 4);
assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
assert_eq!(tokens[3], (SyntaxKind::STRING, "value"));
}
#[test]
fn test_scalar_types() {
let tokens = lex("age: 42");
assert_eq!(tokens[0], (SyntaxKind::STRING, "age"));
assert_eq!(tokens[3], (SyntaxKind::INT, "42"));
let tokens = lex("pi: 3.14");
assert_eq!(tokens[0], (SyntaxKind::STRING, "pi"));
assert_eq!(tokens[3], (SyntaxKind::FLOAT, "3.14"));
let tokens = lex("enabled: true");
assert_eq!(tokens[0], (SyntaxKind::STRING, "enabled"));
assert_eq!(tokens[3], (SyntaxKind::BOOL, "true"));
let tokens = lex("disabled: false");
assert_eq!(tokens[3], (SyntaxKind::BOOL, "false"));
let tokens = lex("value: null");
assert_eq!(tokens[3], (SyntaxKind::NULL, "null"));
let tokens = lex("value: ~");
assert_eq!(tokens[3], (SyntaxKind::NULL, "~"));
}
#[test]
fn test_sequences() {
let input = "- item1\n- item2";
let tokens = lex(input);
assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
assert_eq!(tokens[1], (SyntaxKind::WHITESPACE, " "));
assert_eq!(tokens[2], (SyntaxKind::STRING, "item1"));
assert_eq!(tokens[3], (SyntaxKind::NEWLINE, "\n"));
assert_eq!(tokens[4], (SyntaxKind::DASH, "-"));
assert_eq!(tokens[5], (SyntaxKind::WHITESPACE, " "));
assert_eq!(tokens[6], (SyntaxKind::STRING, "item2"));
}
#[test]
fn test_hyphen_in_scalars() {
let input = "Name: example-project";
let tokens = lex(input);
println!("Hyphen test tokens:");
for (i, (kind, text)) in tokens.iter().enumerate() {
println!(" {}: {:?} = {:?}", i, kind, text);
}
assert_eq!(tokens.len(), 4);
assert_eq!(tokens[0], (SyntaxKind::STRING, "Name"));
assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
assert_eq!(tokens[3], (SyntaxKind::STRING, "example-project"));
}
#[test]
fn test_hyphen_sequence_vs_scalar() {
let sequence_input = "- example-item";
let tokens = lex(sequence_input);
println!("Sequence hyphen tokens:");
for (i, (kind, text)) in tokens.iter().enumerate() {
println!(" {}: {:?} = {:?}", i, kind, text);
}
assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
assert_eq!(tokens[1], (SyntaxKind::WHITESPACE, " "));
assert_eq!(tokens[2], (SyntaxKind::STRING, "example-item"));
let scalar_input = "package-name: my-awesome-package";
let tokens = lex(scalar_input);
println!("Package hyphen tokens:");
for (i, (kind, text)) in tokens.iter().enumerate() {
println!(" {}: {:?} = {:?}", i, kind, text);
}
assert_eq!(tokens.len(), 4);
assert_eq!(tokens[0], (SyntaxKind::STRING, "package-name"));
assert_eq!(tokens[3], (SyntaxKind::STRING, "my-awesome-package"));
}
#[test]
fn test_flow_style() {
let tokens = lex("[1, 2, 3]");
assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACKET, "["));
assert_eq!(tokens[1], (SyntaxKind::INT, "1"));
assert_eq!(tokens[2], (SyntaxKind::COMMA, ","));
assert_eq!(tokens[3], (SyntaxKind::WHITESPACE, " "));
assert_eq!(tokens[4], (SyntaxKind::INT, "2"));
assert_eq!(tokens[5], (SyntaxKind::COMMA, ","));
assert_eq!(tokens[6], (SyntaxKind::WHITESPACE, " "));
assert_eq!(tokens[7], (SyntaxKind::INT, "3"));
assert_eq!(tokens[8], (SyntaxKind::RIGHT_BRACKET, "]"));
let tokens = lex("{a: 1, b: 2}");
assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACE, "{"));
assert_eq!(tokens[1], (SyntaxKind::STRING, "a"));
assert_eq!(tokens[2], (SyntaxKind::COLON, ":"));
assert_eq!(tokens[3], (SyntaxKind::WHITESPACE, " "));
assert_eq!(tokens[4], (SyntaxKind::INT, "1"));
}
#[test]
fn test_comments() {
let input = "key: value # this is a comment\n# full line comment";
let tokens = lex(input);
let comments: Vec<_> = tokens
.iter()
.filter(|(kind, _)| *kind == SyntaxKind::COMMENT)
.collect();
assert_eq!(comments.len(), 2);
assert_eq!(comments[0].1, "# this is a comment");
assert_eq!(comments[1].1, "# full line comment");
}
#[test]
fn test_multiline_scalar() {
let input = "key: value\n continued";
let tokens = lex(input);
let indents: Vec<_> = tokens
.iter()
.filter(|(kind, _)| *kind == SyntaxKind::INDENT)
.collect();
assert_eq!(indents.len(), 1);
assert_eq!(indents[0].1, " ");
}
#[test]
fn test_quoted_strings() {
let input = r#"single: 'quoted'
double: "quoted""#;
let tokens = lex(input);
let quoted_strings: Vec<_> = tokens
.iter()
.filter(|(kind, text)| {
*kind == SyntaxKind::STRING && (text.starts_with('\'') || text.starts_with('"'))
})
.collect();
assert_eq!(quoted_strings.len(), 2);
let quoted_texts: Vec<&str> = {
let mut v: Vec<&str> = quoted_strings.iter().map(|(_, t)| *t).collect();
v.sort();
v
};
assert_eq!(quoted_texts, ["\"quoted\"", "'quoted'"]);
}
#[test]
fn test_document_markers() {
let input = "---\nkey: value\n...";
let tokens = lex(input);
println!("Document tokens:");
for (i, (kind, text)) in tokens.iter().enumerate() {
println!(" {}: {:?} = {:?}", i, kind, text);
}
let doc_start_count = tokens
.iter()
.filter(|(kind, _)| *kind == SyntaxKind::DOC_START)
.count();
let doc_end_count = tokens
.iter()
.filter(|(kind, _)| *kind == SyntaxKind::DOC_END)
.count();
assert_eq!(doc_start_count, 1);
assert_eq!(doc_end_count, 1);
}
#[test]
fn test_empty_input() {
let input = "";
let tokens = lex(input);
println!("Empty input tokens: {:?}", tokens);
assert_eq!(tokens.len(), 0);
}
#[test]
fn test_anchors_and_aliases() {
let input = "key: &anchor_name value";
let tokens = lex(input);
println!("Anchor tokens: {:?}", tokens);
let anchors: Vec<_> = tokens
.iter()
.filter(|(kind, _)| *kind == SyntaxKind::ANCHOR)
.collect();
assert_eq!(anchors.len(), 1);
assert_eq!(anchors[0].1, "&anchor_name");
let input = "key: *reference_name";
let tokens = lex(input);
println!("Reference tokens: {:?}", tokens);
let references: Vec<_> = tokens
.iter()
.filter(|(kind, _)| *kind == SyntaxKind::REFERENCE)
.collect();
assert_eq!(references.len(), 1);
assert_eq!(references[0].1, "*reference_name");
let input = "key: & *";
let tokens = lex(input);
let ampersands: Vec<_> = tokens
.iter()
.filter(|(kind, _)| *kind == SyntaxKind::AMPERSAND)
.collect();
assert_eq!(ampersands.len(), 1);
let asterisks: Vec<_> = tokens
.iter()
.filter(|(kind, _)| *kind == SyntaxKind::ASTERISK)
.collect();
assert_eq!(asterisks.len(), 1);
}
#[test]
fn test_indicators_mid_plain_scalar_are_scalar_content() {
for input in [
"k: 3.1&1", "k: a&b", "k: a*b", "k: a!b", "k: a?b", "k: a !b", "k: a !!b", "k: a &b",
"k: a *b", "- a !b",
] {
let tokens = lex(input);
let bad: Vec<_> = tokens
.iter()
.filter(|(kind, _)| {
matches!(
kind,
SyntaxKind::ANCHOR
| SyntaxKind::REFERENCE
| SyntaxKind::TAG
| SyntaxKind::QUESTION
)
})
.collect();
assert!(
bad.is_empty(),
"{:?} produced structural tokens mid-scalar: {:?}",
input,
bad
);
}
let tokens = lex("a: &real 1");
assert!(
tokens.iter().any(|(k, _)| *k == SyntaxKind::ANCHOR),
"real anchor should still be recognized: {:?}",
tokens
);
}
#[test]
fn test_merge_key_token() {
let input = "<<: *defaults";
let tokens = lex(input);
let merge_keys: Vec<_> = tokens
.iter()
.filter(|(kind, _)| *kind == SyntaxKind::MERGE_KEY)
.collect();
assert_eq!(merge_keys.len(), 1);
assert_eq!(merge_keys[0].1, "<<");
let input2 = "key: < value";
let tokens2 = lex(input2);
let merge_keys2: Vec<_> = tokens2
.iter()
.filter(|(kind, _)| *kind == SyntaxKind::MERGE_KEY)
.collect();
assert_eq!(merge_keys2.len(), 0, "Single < should not be a merge key");
}
#[test]
fn test_angle_prefix_is_plain_scalar_not_merge_key() {
let tokens = lex("<<foo: 1");
let merge_keys: Vec<_> = tokens
.iter()
.filter(|(kind, _)| *kind == SyntaxKind::MERGE_KEY)
.collect();
assert_eq!(
merge_keys.len(),
0,
"<<foo should be a plain key, got {:?}",
tokens
);
assert!(
tokens
.iter()
.any(|(kind, text)| *kind != SyntaxKind::MERGE_KEY && *text == "<<foo"),
"expected <<foo scalar, got {:?}",
tokens
);
let tokens = lex("{<<foo: 1, more: x}");
assert_eq!(
tokens
.iter()
.filter(|(kind, _)| *kind == SyntaxKind::MERGE_KEY)
.count(),
0,
"flow <<foo should not be MERGE_KEY: {:?}",
tokens
);
let tokens = lex("<<: *defaults");
assert_eq!(
tokens
.iter()
.filter(|(kind, text)| *kind == SyntaxKind::MERGE_KEY && *text == "<<")
.count(),
1
);
use crate::YamlFile;
use std::str::FromStr;
let file = YamlFile::from_str("<<foo: 1\nbar: 2\n").unwrap();
let mapping = file.document().unwrap().as_mapping().unwrap();
assert_eq!(mapping.keys().count(), 2);
assert_eq!(
mapping
.get("<<foo")
.unwrap()
.as_scalar()
.unwrap()
.as_string(),
"1"
);
}
#[test]
fn test_angle_prefixed_scalar_uses_plain_scalar_rules() {
use crate::YamlFile;
use std::str::FromStr;
for key in [
"<foo-bar",
"<<foo-bar",
"<foo!x",
"<<foo!x",
"<foo&x",
"<<foo&x",
"<foo:x",
"<<foo:x",
] {
let src = format!("{}: 1\n", key);
let tokens = lex(&src);
assert_eq!(
tokens
.iter()
.filter(|(kind, _)| *kind != SyntaxKind::WHITESPACE)
.map(|(_, text)| *text)
.collect::<Vec<_>>(),
vec![key, ":", "1", "\n"],
"unexpected tokens for {:?}",
src
);
let file = YamlFile::from_str(&src).unwrap();
assert_eq!(file.to_string(), src);
let mapping = file.document().unwrap().as_mapping().unwrap();
assert_eq!(
mapping.get(key).unwrap().as_scalar().unwrap().as_string(),
"1"
);
}
}
#[test]
fn test_plus_token() {
let input = "key: |+ value";
let tokens = lex(input);
let plus_tokens: Vec<_> = tokens
.iter()
.filter(|(kind, _)| *kind == SyntaxKind::PLUS)
.collect();
assert_eq!(plus_tokens.len(), 1);
assert_eq!(plus_tokens[0].1, "+");
}
#[test]
fn test_block_scalar_indicators() {
let input1 = "key: |+ content";
let tokens1 = lex(input1);
assert!(tokens1
.iter()
.any(|(kind, text)| *kind == SyntaxKind::PIPE && *text == "|"));
assert!(tokens1
.iter()
.any(|(kind, text)| *kind == SyntaxKind::PLUS && *text == "+"));
let input2 = "key: >- content";
let tokens2 = lex(input2);
assert!(tokens2
.iter()
.any(|(kind, text)| *kind == SyntaxKind::GREATER && *text == ">"));
assert!(tokens2
.iter()
.any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "-"));
let input3 = "key: |2+ content";
let tokens3 = lex(input3);
assert!(tokens3
.iter()
.any(|(kind, text)| *kind == SyntaxKind::PIPE && *text == "|"));
assert!(tokens3
.iter()
.any(|(kind, text)| *kind == SyntaxKind::INT && *text == "2"));
assert!(tokens3
.iter()
.any(|(kind, text)| *kind == SyntaxKind::PLUS && *text == "+"));
}
#[test]
fn test_special_characters_in_block_content() {
let input = "line with - and + and : characters";
let tokens = lex(input);
assert!(tokens
.iter()
.any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "line with - and + and"));
assert!(!tokens.iter().any(|(kind, _)| *kind == SyntaxKind::PLUS));
assert!(tokens
.iter()
.any(|(kind, text)| *kind == SyntaxKind::COLON && *text == ":"));
let joined: String = tokens.iter().map(|(_, t)| *t).collect();
assert_eq!(joined, input);
}
#[test]
fn test_token_recognition() {
let input = "key: |2+ \n content with - and : and > chars\n more content";
let tokens = lex(input);
println!("Tokens:");
for (i, (kind, text)) in tokens.iter().enumerate() {
println!(" {}: {:?} = {:?}", i, kind, text);
}
let count = |k: SyntaxKind| tokens.iter().filter(|(kind, _)| *kind == k).count();
assert_eq!(count(SyntaxKind::COLON), 2);
assert_eq!(count(SyntaxKind::PIPE), 1); assert_eq!(count(SyntaxKind::INT), 1); assert_eq!(count(SyntaxKind::PLUS), 1); assert_eq!(count(SyntaxKind::GREATER), 0);
assert_eq!(count(SyntaxKind::NEWLINE), 2); assert_eq!(count(SyntaxKind::INDENT), 2); assert!(count(SyntaxKind::STRING) >= 1, "expected STRING tokens");
assert_eq!(
tokens
.iter()
.filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "content with - and")
.count(),
1
);
}
#[test]
fn test_dash_handling() {
let input = "---\nkey: value";
let tokens = lex(input);
assert_eq!(tokens[0], (SyntaxKind::DOC_START, "---"));
let input = "---";
let tokens = lex(input);
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0], (SyntaxKind::DOC_START, "---"));
let input = "--";
let tokens = lex(input);
assert_eq!(tokens, vec![(SyntaxKind::STRING, "--")]);
let input = "----";
let tokens = lex(input);
assert_eq!(tokens, vec![(SyntaxKind::STRING, "----")]);
}
#[test]
fn test_dash_in_different_scalar_contexts() {
let input = "package-name: my-awesome-package-v2";
let tokens = lex(input);
assert_eq!(tokens[0], (SyntaxKind::STRING, "package-name"));
assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
assert_eq!(tokens[3], (SyntaxKind::STRING, "my-awesome-package-v2"));
let input = "id: 123e4567-e89b-12d3-a456-426614174000";
let tokens = lex(input);
assert_eq!(tokens[0], (SyntaxKind::STRING, "id"));
assert_eq!(
tokens[3],
(SyntaxKind::STRING, "123e4567-e89b-12d3-a456-426614174000")
);
let input = "args: --verbose --log-level=debug";
let tokens = lex(input);
assert_eq!(
tokens[3],
(SyntaxKind::STRING, "--verbose --log-level=debug")
);
let input = "temperature: -40";
let tokens = lex(input);
assert_eq!(
tokens
.iter()
.filter(|(kind, text)| *kind == SyntaxKind::INT && *text == "-40")
.count(),
1
);
let input = "range: 1-10";
let tokens = lex(input);
assert_eq!(
tokens
.iter()
.filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "1-10")
.count(),
1
);
}
#[test]
fn test_sequence_markers_with_indentation() {
let input = "- item1\n- item2";
let tokens = lex(input);
assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
assert_eq!(tokens[1], (SyntaxKind::WHITESPACE, " "));
assert_eq!(tokens[2], (SyntaxKind::STRING, "item1"));
let input = " - item1\n - item2";
let tokens = lex(input);
assert_eq!(tokens[0], (SyntaxKind::INDENT, " "));
assert_eq!(tokens[1], (SyntaxKind::DASH, "-"));
let input = "- item1\n - nested1\n - nested2\n- item2";
let tokens = lex(input);
let dash_tokens: Vec<_> = tokens
.iter()
.filter(|(kind, _)| *kind == SyntaxKind::DASH)
.collect();
assert_eq!(dash_tokens.len(), 4);
let input = "- first-item\n- second-item";
let tokens = lex(input);
assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
assert_eq!(tokens[2], (SyntaxKind::STRING, "first-item"));
assert_eq!(tokens[4], (SyntaxKind::DASH, "-"));
assert_eq!(tokens[6], (SyntaxKind::STRING, "second-item"));
}
#[test]
fn test_dash_after_colon() {
let input = "key:-value";
let tokens = lex(input);
assert_eq!(tokens[0], (SyntaxKind::STRING, "key:-value"));
let input = "key: -value";
let tokens = lex(input);
assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
assert_eq!(tokens[3], (SyntaxKind::STRING, "-value"));
}
#[test]
fn test_yaml_spec_compliant_colon_handling() {
let input = "http://example.com:8080/path";
let tokens = lex(input);
assert_eq!(tokens.len(), 1);
assert_eq!(
tokens[0],
(SyntaxKind::STRING, "http://example.com:8080/path")
);
let input = "2024:12:31:23:59:59";
let tokens = lex(input);
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0], (SyntaxKind::STRING, "2024:12:31:23:59:59"));
let input = "key: value";
let tokens = lex(input);
assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
assert_eq!(tokens[3], (SyntaxKind::STRING, "value"));
let input = "key:value";
let tokens = lex(input);
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0], (SyntaxKind::STRING, "key:value"));
let input = "a:b:c:d";
let tokens = lex(input);
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0], (SyntaxKind::STRING, "a:b:c:d"));
}
#[test]
fn test_block_scalar_with_chomping() {
let count_kind = |toks: &[(SyntaxKind, &str)], k: SyntaxKind| {
toks.iter().filter(|(kind, _)| *kind == k).count()
};
let input = "text: |-\n content";
let tokens = lex(input);
assert_eq!(count_kind(&tokens, SyntaxKind::PIPE), 1);
assert_eq!(
tokens
.iter()
.filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "-")
.count(),
1
);
let input = "text: |+\n content";
let tokens = lex(input);
assert_eq!(count_kind(&tokens, SyntaxKind::PIPE), 1);
assert_eq!(count_kind(&tokens, SyntaxKind::PLUS), 1);
let input = "text: >-\n content";
let tokens = lex(input);
assert_eq!(count_kind(&tokens, SyntaxKind::GREATER), 1);
assert_eq!(
tokens
.iter()
.filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "-")
.count(),
1
);
let input = "text: |2-\n content";
let tokens = lex(input);
assert_eq!(count_kind(&tokens, SyntaxKind::PIPE), 1);
let has_2_token = tokens.iter().any(|(kind, text)| {
(*kind == SyntaxKind::STRING || *kind == SyntaxKind::INT) && text.contains("2")
});
assert!(has_2_token, "expected a token containing '2'");
}
#[test]
fn test_dash_edge_cases() {
let input = "value-";
let tokens = lex(input);
assert_eq!(tokens[0], (SyntaxKind::STRING, "value-"));
let input = "-value";
let tokens = lex(input);
assert_eq!(tokens[0], (SyntaxKind::STRING, "-value"));
let input = "key: a---b";
let tokens = lex(input);
assert_eq!(
tokens
.iter()
.filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "a---b")
.count(),
1
);
let input = "key: value-\nnext: item";
let tokens = lex(input);
assert!(tokens
.iter()
.any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "value-"));
let input = "snake_case-with-dash_mix";
let tokens = lex(input);
assert_eq!(tokens[0], (SyntaxKind::STRING, "snake_case-with-dash_mix"));
}
#[test]
fn test_whitespace_validation_tab_indentation() {
let input_with_tabs = "key: value\n\tindented_key: indented_value";
let (tokens, errors) = lex_with_validation(input_with_tabs);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].category, WhitespaceErrorCategory::TabIndentation);
assert_eq!(
errors[0].message,
"Tab character used for indentation (forbidden in YAML)"
);
assert!(tokens
.iter()
.any(|(kind, text)| *kind == SyntaxKind::INDENT && text.contains('\t')));
}
#[test]
fn test_whitespace_validation_line_endings() {
let input_mixed = "line1\nline2\r\nline3\rline4";
let config = ValidationConfig {
enforce_consistent_line_endings: true,
max_line_length: None,
};
let (tokens, errors) = lex_with_validation_config(input_mixed, &config);
assert!(errors
.iter()
.any(|e| e.category == WhitespaceErrorCategory::MixedLineEndings));
let newlines: Vec<_> = tokens
.iter()
.filter(|(kind, _)| *kind == SyntaxKind::NEWLINE)
.collect();
assert_eq!(newlines.len(), 3); assert_eq!(newlines[0].1, "\n");
assert_eq!(newlines[1].1, "\r\n");
assert_eq!(newlines[2].1, "\r");
}
#[test]
fn test_whitespace_validation_line_length() {
let long_line = format!("key: {}", "a".repeat(150));
let config = ValidationConfig {
enforce_consistent_line_endings: false,
max_line_length: Some(120),
};
let (_, errors) = lex_with_validation_config(&long_line, &config);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].category, WhitespaceErrorCategory::LineTooLong);
assert_eq!(errors[0].message, "Line too long (155 > 120 characters)");
}
#[test]
fn test_whitespace_validation_disabled() {
let input_with_issues = "key: value\n\tindented: with_tabs\n";
let config = ValidationConfig {
enforce_consistent_line_endings: false,
max_line_length: None,
};
let (tokens, errors) = lex_with_validation_config(input_with_issues, &config);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].category, WhitespaceErrorCategory::TabIndentation);
assert!(!tokens.is_empty());
}
#[test]
fn test_dash_in_flow_collections() {
let input = "[item-one, item-two]";
let tokens = lex(input);
assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACKET, "["));
assert_eq!(tokens[1], (SyntaxKind::STRING, "item-one"));
assert_eq!(tokens[2], (SyntaxKind::COMMA, ","));
assert_eq!(tokens[4], (SyntaxKind::STRING, "item-two"));
assert_eq!(tokens[5], (SyntaxKind::RIGHT_BRACKET, "]"));
let input = "{kebab-key: kebab-value}";
let tokens = lex(input);
assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACE, "{"));
assert_eq!(tokens[1], (SyntaxKind::STRING, "kebab-key"));
assert_eq!(tokens[2], (SyntaxKind::COLON, ":"));
assert_eq!(tokens[4], (SyntaxKind::STRING, "kebab-value"));
assert_eq!(tokens[5], (SyntaxKind::RIGHT_BRACE, "}"));
}
#[test]
fn test_dash_with_quotes() {
let input = r#"key: "- not a sequence marker""#;
let tokens = lex(input);
assert_eq!(
tokens
.iter()
.filter(|(kind, text)| {
*kind == SyntaxKind::STRING && *text == "\"- not a sequence marker\""
})
.count(),
1
);
let input = r#"key: '- also not a sequence marker'"#;
let tokens = lex(input);
assert_eq!(
tokens
.iter()
.filter(|(kind, text)| {
*kind == SyntaxKind::STRING && *text == "'- also not a sequence marker'"
})
.count(),
1
);
}
#[test]
fn test_dash_in_multiline_values() {
let input = "description: This is a multi-\n line value with dashes";
let tokens = lex(input);
assert!(tokens
.iter()
.any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "This is a multi-"));
let input = "text: value\n - but this is not a sequence";
let tokens = lex(input);
let indent_dash: Vec<_> = tokens
.windows(2)
.filter(|w| w[0].0 == SyntaxKind::INDENT && w[1].0 == SyntaxKind::DASH)
.collect();
assert_eq!(indent_dash.len(), 1);
}
#[test]
fn test_dash_special_yaml_values() {
let input = "date: 2024-01-15";
let tokens = lex(input);
assert!(tokens
.iter()
.any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "2024-01-15"));
let input = "timestamp: 2024-01-15T10:30:00-05:00";
let tokens = lex(input);
assert!(tokens.iter().any(
|(kind, text)| *kind == SyntaxKind::STRING && *text == "2024-01-15T10:30:00-05:00"
));
let input = "version: 1.0.0-beta.1";
let tokens = lex(input);
assert!(tokens
.iter()
.any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "1.0.0-beta.1"));
}
#[test]
fn test_flow_indicators_in_block_scalar() {
let input = "key: unix:///Users/${metadata.username}/path";
let tokens = lex(input);
assert_eq!(tokens.len(), 4);
assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
assert_eq!(
tokens[3],
(
SyntaxKind::STRING,
"unix:///Users/${metadata.username}/path"
)
);
}
}