#[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 is_white_or_break(ch) || 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;
}
is_colon_followed_by_separator(input, colon_idx, flow_depth)
}
fn is_colon_followed_by_separator(input: &str, colon_idx: usize, flow_depth: u32) -> bool {
let after = input[colon_idx + 1..].chars().next();
match after {
None => true,
Some(ch) if is_white_or_break(ch) => 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(is_white_or_break)
}
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 flow_collection_is_implicit_key(input: &str, open_idx: usize) -> bool {
let line = input[open_idx..]
.split(['\n', '\r'])
.next()
.unwrap_or_default();
let mut chars = line.char_indices().peekable();
let mut closers = Vec::new();
let mut prev = ' ';
while let Some((idx, ch)) = chars.next() {
match ch {
'[' => closers.push(']'),
'{' => closers.push('}'),
']' | '}' => {
if closers.pop() != Some(ch) {
return false;
}
if closers.is_empty() {
let rest = &line[idx + 1..];
let comment = [" #", "\t#"].iter().filter_map(|c| rest.find(c)).min();
return rest[..comment.unwrap_or(rest.len())]
.match_indices(':')
.any(|(i, _)| is_colon_a_mapping_indicator(line, idx + 1 + i, 0));
}
}
'"' | '\'' if matches!(prev, ' ' | '\t' | '[' | '{' | ',' | ':') => {
let (_, closed) = if ch == '"' {
scan_double_quoted(&mut chars, idx)
} else {
scan_single_quoted(&mut chars, idx)
};
if !closed {
return false;
}
}
'#' if matches!(prev, ' ' | '\t') => return false,
_ => {}
}
prev = ch;
}
false
}
fn is_chomping_indicator(line_prefix: &str) -> bool {
line_prefix
.bytes()
.rev()
.find(|b| !b.is_ascii_digit())
.is_some_and(|b| b == b'|' || b == b'>')
}
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>) {
let mut state = LexState::new(input, config);
while let Some((start_idx, ch)) = state.chars.next() {
state.lex_char(start_idx, ch);
}
state.finish()
}
struct LexState<'a, 'c> {
input: &'a str,
config: &'c ValidationConfig,
chars: std::iter::Peekable<std::str::CharIndices<'a>>,
tokens: Vec<(SyntaxKind, &'a str)>,
errors: Vec<WhitespaceError>,
line_start: usize,
detected_line_ending: Option<&'a str>,
flow_depth: u32,
block_scalar: Option<BlockScalarBody>,
block_scalar_end: usize,
}
impl<'a, 'c> LexState<'a, 'c> {
fn new(input: &'a str, config: &'c ValidationConfig) -> Self {
Self {
input,
config,
chars: input.char_indices().peekable(),
tokens: Vec::with_capacity(input.len() / 8), errors: Vec::new(),
line_start: 0,
detected_line_ending: None,
flow_depth: 0,
block_scalar: None,
block_scalar_end: 0,
}
}
fn push(&mut self, kind: SyntaxKind, start: usize, end: usize) {
self.tokens.push((kind, &self.input[start..end]));
}
fn push_scalar(&mut self, start: usize, end: usize) {
let text = &self.input[start..end];
self.tokens.push((classify_scalar(text), text));
}
fn push_quoted_string(&mut self, start: usize, end: usize, found_closing: bool) {
let kind = if found_closing {
SyntaxKind::STRING
} else {
SyntaxKind::UNTERMINATED_STRING
};
self.push(kind, start, end);
}
fn push_plain_scalar(&mut self, start: usize, body_start: usize, absorb_spaces: bool) {
let end = self.read_plain_scalar_body(body_start, absorb_spaces, false);
self.push_scalar(start, end);
}
fn read_plain_scalar_body(
&mut self,
start_idx: usize,
absorb_spaces: bool,
stop_at_chomping_plus: bool,
) -> usize {
let input = self.input;
let flow_depth = self.flow_depth;
let mut end_idx = start_idx;
while let Some((idx, ch)) = self.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)) = self.chars.peek().copied() {
if wc != ' ' && wc != '\t' {
break;
}
end_idx = wi + wc.len_utf8();
self.chars.next();
}
continue;
}
if is_white_or_break(ch) {
break;
}
if ch == ':' && is_colon_followed_by_separator(input, idx, flow_depth) {
break;
}
if ch == '#' && is_hash_a_comment_start(input, idx) {
break;
}
if stop_at_chomping_plus
&& ch == '+'
&& input[start_idx..idx].bytes().all(|b| b.is_ascii_digit())
{
break;
}
if is_yaml_special_except(ch, "-+:#'\"?|>&*!%")
&& !(flow_depth == 0 && matches!(ch, ',' | '[' | ']' | '{' | '}'))
{
break;
}
end_idx = idx + ch.len_utf8();
self.chars.next();
}
end_idx
}
fn node_can_start(&self) -> bool {
!in_block_scalar_body(self.block_scalar, self.line_start)
&& node_property_can_start(&self.tokens)
}
fn property_can_start(&self, idx: usize) -> bool {
self.node_can_start() && !self.opens_plain_scalar_continuation_line(idx)
}
fn block_scalar_header_can_start(&self, idx: usize) -> bool {
self.flow_depth == 0
&& self.node_can_start()
&& !self.opens_plain_scalar_continuation_line(idx)
}
fn opens_plain_scalar_continuation_line(&self, idx: usize) -> bool {
use SyntaxKind::*;
self.input[self.line_start..idx]
.chars()
.all(|c| c == ' ' || c == '\t')
&& (self.flow_depth > 0
|| parent_column(&self.tokens)
.map_or(true, |column| idx - self.line_start > column))
&& self
.tokens
.iter()
.enumerate()
.rev()
.find(|(_, (kind, _))| !matches!(kind, WHITESPACE | INDENT | NEWLINE))
.is_some_and(|(i, (kind, _))| {
i >= self.block_scalar_end
&& matches!(kind, STRING | INT | FLOAT | BOOL | NULL | MERGE_KEY)
})
}
fn lex_char(&mut self, start_idx: usize, ch: char) {
use SyntaxKind::*;
match ch {
'\u{FEFF}'
if self.flow_depth == 0
&& start_idx == self.line_start
&& bom_starts_document(&self.tokens, self.input, start_idx) =>
{
self.push(BOM, start_idx, start_idx + ch.len_utf8());
self.line_start = start_idx + ch.len_utf8();
}
'-' => self.lex_dash(start_idx),
'+' => self.lex_plus(start_idx),
':' => self.lex_colon(start_idx),
'?' if self.property_can_start(start_idx)
&& self.chars.peek().map_or(true, |(_, next)| {
is_white_or_break(*next)
|| (self.flow_depth > 0 && matches!(next, ',' | '[' | ']' | '{' | '}'))
}) =>
{
self.push(QUESTION, start_idx, start_idx + 1)
}
'[' | ']' | '{' | '}' | ',' | '#'
if in_block_scalar_body(self.block_scalar, self.line_start) =>
{
self.push_plain_scalar(start_idx, start_idx + 1, true);
}
'[' | '{' | ']' | '}'
if self.flow_depth == 0
&& self.opens_plain_scalar_continuation_line(start_idx)
&& !(matches!(ch, '[' | '{')
&& flow_collection_is_implicit_key(self.input, start_idx)) =>
{
self.push_plain_scalar(start_idx, start_idx + 1, true);
}
'[' => {
self.flow_depth += 1;
self.push(LEFT_BRACKET, start_idx, start_idx + 1);
}
']' => {
self.flow_depth = self.flow_depth.saturating_sub(1);
self.push(RIGHT_BRACKET, start_idx, start_idx + 1);
}
'{' => {
self.flow_depth += 1;
self.push(LEFT_BRACE, start_idx, start_idx + 1);
}
'}' => {
self.flow_depth = self.flow_depth.saturating_sub(1);
self.push(RIGHT_BRACE, start_idx, start_idx + 1);
}
',' if self.flow_depth > 0 => self
.tokens
.push((COMMA, &self.input[start_idx..start_idx + 1])),
'|' if self.block_scalar_header_can_start(start_idx) => {
self.block_scalar = Some(BlockScalarBody::new(
&self.tokens,
self.input,
self.line_start,
));
self.push(PIPE, start_idx, start_idx + 1)
}
'>' if self.block_scalar_header_can_start(start_idx) => {
self.block_scalar = Some(BlockScalarBody::new(
&self.tokens,
self.input,
self.line_start,
));
self.push(GREATER, start_idx, start_idx + 1)
}
'<' => {
self.lex_plain_scalar(start_idx, ch);
if let Some(token) = self.tokens.last_mut().filter(|t| t.1 == "<<") {
token.0 = MERGE_KEY;
}
}
'&' if self.property_can_start(start_idx) => {
let name = read_anchor_name_from(&mut self.chars, self.input, start_idx + 1);
if !name.is_empty() {
self.push(ANCHOR, start_idx, start_idx + 1 + name.len());
} else {
self.push(AMPERSAND, start_idx, start_idx + 1);
}
}
'*' if self.property_can_start(start_idx) => {
let name = read_anchor_name_from(&mut self.chars, self.input, start_idx + 1);
if !name.is_empty() {
self.push(REFERENCE, start_idx, start_idx + 1 + name.len());
} else {
self.push(ASTERISK, start_idx, start_idx + 1);
}
}
'"' | '\''
if in_block_scalar_body(self.block_scalar, self.line_start)
|| self.opens_plain_scalar_continuation_line(start_idx) =>
{
self.push_plain_scalar(start_idx, start_idx + 1, true);
}
'"' => {
let (end_idx, found_closing) = scan_double_quoted(&mut self.chars, start_idx);
self.push_quoted_string(start_idx, end_idx, found_closing);
}
'\'' => {
let (end_idx, found_closing) = scan_single_quoted(&mut self.chars, start_idx);
self.push_quoted_string(start_idx, end_idx, found_closing);
}
'.' => self.lex_dot(start_idx),
'#' => self.lex_comment(start_idx),
'!' if self.property_can_start(start_idx) => self.lex_tag(start_idx),
'%' => self.lex_percent(start_idx),
'\n' | '\r' => self.lex_line_break(start_idx, ch),
' ' | '\t' => self.lex_whitespace(start_idx, ch),
_ => self.lex_plain_scalar(start_idx, ch),
}
}
fn lex_dash(&mut self, start_idx: usize) {
use SyntaxKind::*;
if self.flow_depth == 0
&& start_idx == self.line_start
&& self.input[start_idx..].starts_with("---")
&& self.input[start_idx + 3..]
.chars()
.next()
.map_or(true, ends_document_marker)
{
self.chars.next();
self.chars.next();
self.push(DOC_START, start_idx, start_idx + 3);
} else {
let line_start_pos = self.line_start;
let before_dash = &self.input[line_start_pos..start_idx];
let only_whitespace_before = before_dash.chars().all(|c| c == ' ' || c == '\t');
let after_value_indicator = self
.tokens
.iter()
.rev()
.find(|(kind, _)| !matches!(kind, WHITESPACE | INDENT))
.is_some_and(|(kind, _)| matches!(kind, QUESTION | COLON | DASH | DOC_START));
let followed_by_whitespace_or_end = self
.chars
.peek()
.map_or(true, |(_, next_ch)| is_white_or_break(*next_ch));
let is_sequence_marker = ((only_whitespace_before
&& !self.opens_plain_scalar_continuation_line(start_idx))
|| after_value_indicator)
&& followed_by_whitespace_or_end;
if is_sequence_marker {
self.push(DASH, start_idx, start_idx + 1);
} else {
self.push_plain_scalar(
start_idx,
start_idx + 1,
!is_chomping_indicator(&self.input[self.line_start..start_idx]),
);
}
}
}
fn lex_plus(&mut self, start_idx: usize) {
if is_chomping_indicator(&self.input[self.line_start..start_idx]) {
self.push(SyntaxKind::PLUS, start_idx, start_idx + 1);
} else {
self.push_plain_scalar(start_idx, start_idx + 1, true);
}
}
fn lex_colon(&mut self, start_idx: usize) {
use SyntaxKind::*;
let preceded_by_json_key = self.flow_depth > 0
&& self
.tokens
.iter()
.rev()
.find(|(k, _)| !matches!(k, WHITESPACE | INDENT | NEWLINE | COMMENT))
.is_some_and(|(k, text)| {
matches!(k, RIGHT_BRACKET | RIGHT_BRACE)
|| (*k == STRING && text.starts_with(['"', '\'']))
});
if preceded_by_json_key
|| is_colon_a_mapping_indicator(self.input, start_idx, self.flow_depth)
{
self.push(COLON, start_idx, start_idx + 1);
} else {
self.push_plain_scalar(start_idx, start_idx + 1, true);
}
}
fn lex_dot(&mut self, start_idx: usize) {
use SyntaxKind::*;
let is_doc_end = self.flow_depth == 0
&& start_idx == self.line_start
&& self.input[start_idx..].starts_with("...")
&& self.input[start_idx + 3..]
.chars()
.next()
.map_or(true, ends_document_marker);
if is_doc_end && self.chars.peek() == Some(&(start_idx + 1, '.')) {
self.chars.next(); if self.chars.peek() == Some(&(start_idx + 2, '.')) {
self.chars.next(); self.push(DOC_END, start_idx, start_idx + 3);
} else {
self.push_plain_scalar(start_idx, start_idx + 2, true);
}
} else {
self.push_plain_scalar(start_idx, start_idx + 1, true);
}
}
fn lex_comment(&mut self, start_idx: usize) {
use SyntaxKind::*;
let mut end_idx = start_idx + 1;
while let Some((idx, ch)) = self.chars.peek() {
if *ch == '\n' || *ch == '\r' {
break;
}
end_idx = *idx + ch.len_utf8();
self.chars.next();
}
self.push(COMMENT, start_idx, end_idx);
}
fn lex_tag(&mut self, start_idx: usize) {
use SyntaxKind::*;
let mut end_idx = start_idx + 1;
if let Some((_, '<')) = self.chars.peek() {
self.chars.next(); end_idx = start_idx + 2;
while let Some((idx, ch)) = self.chars.peek() {
if matches!(ch, '\n' | '\r') {
break;
}
end_idx = *idx + ch.len_utf8();
let closing = *ch == '>';
self.chars.next();
if closing {
break;
}
}
self.push(TAG, start_idx, end_idx);
return;
}
if let Some((_, '!')) = self.chars.peek() {
self.chars.next(); end_idx = start_idx + 2;
} else {
let word = self.input[start_idx + 1..]
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
.map_or(self.input.len(), |i| start_idx + 1 + i);
if word > start_idx + 1 && self.input[word..].starts_with('!') {
while self.chars.peek().is_some_and(|(idx, _)| *idx <= word) {
self.chars.next();
}
end_idx = word + 1;
}
}
while let Some((idx, ch)) = self.chars.peek() {
if !is_tag_char(*ch) {
break;
}
end_idx = *idx + ch.len_utf8();
self.chars.next();
}
self.push(TAG, start_idx, end_idx);
}
fn lex_percent(&mut self, start_idx: usize) {
use SyntaxKind::*;
if self.flow_depth > 0
|| start_idx != self.line_start
|| in_block_scalar_body(self.block_scalar, self.line_start)
{
self.push_plain_scalar(start_idx, start_idx + 1, true);
} else {
let line_end = self.input[start_idx..]
.find(['\n', '\r'])
.map_or(self.input.len(), |i| start_idx + i);
let end_idx = self.input[start_idx..line_end]
.match_indices('#')
.map(|(i, _)| start_idx + i)
.find(|&i| is_hash_a_comment_start(self.input, i))
.map_or(line_end, |i| {
start_idx + self.input[start_idx..i].trim_end_matches([' ', '\t']).len()
});
while self.chars.next_if(|(idx, _)| *idx < end_idx).is_some() {}
self.push(DIRECTIVE, start_idx, end_idx);
}
}
fn lex_line_break(&mut self, start_idx: usize, ch: char) {
check_line_length(&mut self.errors, self.config, self.line_start, start_idx);
let (line_ending, end_pos) = if ch == '\n' {
("\n", start_idx + 1)
} else if let Some((_, '\n')) = self.chars.peek() {
self.chars.next();
("\r\n", start_idx + 2)
} else {
("\r", start_idx + 1)
};
note_line_ending(
&mut self.errors,
self.config,
&mut self.detected_line_ending,
line_ending,
start_idx..end_pos,
);
self.push(SyntaxKind::NEWLINE, start_idx, end_pos);
self.line_start = end_pos;
let in_body = self.block_scalar.is_some();
track_block_scalar_body(
&self.tokens,
self.input,
self.line_start,
&mut self.block_scalar,
);
if in_body && self.block_scalar.is_none() {
self.block_scalar_end = self.tokens.len();
}
}
fn lex_whitespace(&mut self, start_idx: usize, ch: char) {
use SyntaxKind::*;
let mut end_idx = start_idx + 1;
let mut has_tabs = ch == '\t';
while let Some((idx, ch)) = self.chars.peek() {
if *ch != ' ' && *ch != '\t' {
break;
}
if *ch == '\t' {
has_tabs = true;
}
end_idx = *idx + 1;
self.chars.next();
}
let is_indentation = start_idx == self.line_start;
if is_indentation {
if has_tabs {
self.errors.push(WhitespaceError {
message: "Tab character used for indentation (forbidden in YAML)".to_string(),
range: start_idx..end_idx,
category: WhitespaceErrorCategory::TabIndentation,
});
}
self.push(INDENT, start_idx, end_idx);
} else {
self.push(WHITESPACE, start_idx, end_idx);
}
}
fn lex_plain_scalar(&mut self, start_idx: usize, ch: char) {
let after_header = ch.is_ascii_digit()
&& matches!(
self.tokens.last(),
Some((SyntaxKind::PIPE | SyntaxKind::GREATER, _))
);
let end = self.read_plain_scalar_body(start_idx + ch.len_utf8(), true, after_header);
self.push_scalar(start_idx, end);
}
fn finish(self) -> (Vec<(SyntaxKind, &'a str)>, Vec<WhitespaceError>) {
let mut whitespace_errors = self.errors;
if let Some(max_len) = self.config.max_line_length {
let final_line_length = self.input.len() - self.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: self.line_start..self.input.len(),
category: WhitespaceErrorCategory::LineTooLong,
});
}
}
(self.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 ends_document_marker(ch: char) -> bool {
matches!(ch, ' ' | '\t' | '\n' | '\r')
}
fn bom_starts_document(tokens: &[(SyntaxKind, &str)], input: &str, bom_idx: usize) -> bool {
use SyntaxKind::*;
let after_marker = tokens
.iter()
.rev()
.find(|(kind, _)| !matches!(kind, WHITESPACE | INDENT | NEWLINE | COMMENT))
.map_or(true, |(kind, _)| matches!(kind, DOC_START | DOC_END));
let rest = &input[bom_idx + '\u{FEFF}'.len_utf8()..];
let before_marker =
rest.starts_with("---") && rest[3..].chars().next().map_or(true, ends_document_marker);
after_marker || before_marker
}
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::BOM
| 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_matches([' ', '\t'])
.is_empty()
}
fn line_indent(input: &str, line_start: usize) -> usize {
let line = &input[line_start..];
line.len() - line.trim_start_matches([' ', '\t']).len()
}
#[derive(Clone, Copy)]
struct BlockScalarBody {
header_line_start: usize,
header_token: usize,
parent_column: usize,
min_indent: usize,
indent: Option<usize>,
}
impl BlockScalarBody {
fn new(tokens: &[(SyntaxKind, &str)], input: &str, line_start: usize) -> Self {
let is_root = tokens
.iter()
.rev()
.take_while(|(kind, _)| !matches!(kind, SyntaxKind::NEWLINE | SyntaxKind::BOM))
.all(|(kind, _)| {
matches!(
kind,
SyntaxKind::DOC_START
| SyntaxKind::WHITESPACE
| SyntaxKind::TAG
| SyntaxKind::ANCHOR
)
});
let parent_column = parent_column(tokens).unwrap_or(0);
let min_indent = if is_root {
0
} else {
line_indent(input, line_start).max(parent_column) + 1
};
Self {
header_line_start: line_start,
header_token: tokens.len(),
parent_column,
min_indent,
indent: None,
}
}
}
fn parent_column(tokens: &[(SyntaxKind, &str)]) -> Option<usize> {
use SyntaxKind::*;
for line in tokens.rsplit(|(kind, _)| matches!(kind, NEWLINE | BOM)) {
let Some(last) = line.iter().rposition(|(kind, _)| {
!matches!(
kind,
WHITESPACE
| INDENT
| COMMENT
| TAG
| ANCHOR
| STRING
| INT
| FLOAT
| BOOL
| NULL
| MERGE_KEY
)
}) else {
continue;
};
let column = |index: usize| -> usize {
line[..index]
.iter()
.map(|(_, text)| text.chars().count())
.sum()
};
return match line[last].0 {
DASH | QUESTION => Some(column(last)),
COLON => {
let key = line[..last]
.iter()
.position(|(kind, _)| {
!matches!(kind, WHITESPACE | INDENT | DASH | QUESTION | COLON)
})
.unwrap_or(last);
Some(column(key))
}
DOC_START | DOC_END => None,
_ => Some(0),
};
}
None
}
pub(crate) fn block_scalar_header<'a>(
tokens: impl IntoIterator<Item = (SyntaxKind, &'a str)>,
) -> (Option<usize>, usize) {
let mut explicit_indent = None;
let mut len = 0;
for (kind, text) in tokens {
match kind {
SyntaxKind::INT => {
let digits = text.strip_prefix(['+', '-']).unwrap_or(text);
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
break;
}
explicit_indent = digits
.chars()
.next()
.and_then(|c| c.to_digit(10))
.filter(|d| *d > 0)
.map(|d| d as usize);
}
SyntaxKind::STRING => {
let mut chars = text.chars();
let is_header = match (chars.next(), chars.next(), chars.next()) {
(Some(a), None, _) => a == '+' || a == '-',
(Some(a), Some(b), None) => {
let digit = |c: char| c.is_ascii_digit() && c != '0';
let chomp = |c: char| c == '+' || c == '-';
(digit(a) && chomp(b)) || (chomp(a) && digit(b))
}
_ => false,
};
if !is_header {
break;
}
explicit_indent = text
.chars()
.find_map(|c| c.to_digit(10))
.filter(|d| *d > 0)
.map(|d| d as usize);
}
SyntaxKind::PLUS | SyntaxKind::WHITESPACE => {}
_ => break,
}
len += 1;
}
(explicit_indent, len)
}
fn line_is_document_marker(input: &str, line_start: usize) -> bool {
let line = &input[line_start..];
(line.starts_with("---") || line.starts_with("..."))
&& line[3..].chars().next().map_or(true, ends_document_marker)
}
fn track_block_scalar_body(
tokens: &[(SyntaxKind, &str)],
input: &str,
line_start: usize,
body: &mut Option<BlockScalarBody>,
) {
let Some(current) = body else {
return;
};
if current.indent.is_none() {
if let (Some(indicator), _) =
block_scalar_header(tokens[current.header_token + 1..].iter().copied())
{
current.indent = Some(current.parent_column + indicator);
}
}
if line_is_blank(input, line_start) {
return;
}
let indent = line_indent(input, line_start);
if indent < current.indent.unwrap_or(current.min_indent)
|| line_is_document_marker(input, line_start)
{
*body = None;
} else if current.indent.is_none() {
let spaces = crate::column::space_indent(&input[line_start..]);
current.indent = Some(if spaces >= current.min_indent {
spaces
} else {
indent
});
}
}
fn in_block_scalar_body(body: Option<BlockScalarBody>, line_start: usize) -> bool {
body.is_some_and(|body| body.header_line_start != line_start)
}
fn is_tag_char(ch: char) -> bool {
if ch.is_whitespace() {
return false;
}
!matches!(ch, ',' | '[' | ']' | '{' | '}' | '!')
}
fn is_white_or_break(ch: char) -> bool {
matches!(ch, ' ' | '\t' | '\n' | '\r')
}
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_printable(c: char) -> bool {
matches!(c,
'\t' | '\n' | '\r' | '\u{20}'..='\u{7e}' | '\u{85}' | '\u{a0}'..='\u{d7ff}'
| '\u{e000}'..='\u{fffd}' | '\u{10000}'..)
&& c != '\u{feff}'
}
pub(crate) fn is_valid_anchor_name(name: &str) -> bool {
!name.is_empty() && name.chars().all(is_anchor_char)
}
fn is_anchor_char(ch: char) -> bool {
let ns_char = matches!(ch,
'\u{21}'..='\u{7E}'
| '\u{85}'
| '\u{A0}'..='\u{D7FF}'
| '\u{E000}'..='\u{FFFD}'
| '\u{10000}'..='\u{10FFFF}')
&& ch != '\u{FEFF}';
ns_char && !matches!(ch, ',' | '[' | ']' | '{' | '}')
}
#[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"));
for name in [
"a:",
"colon:name",
"*star",
"&",
"-",
"x?y",
"#",
"!t",
"|>",
"%'\"",
"@`",
"no\u{A0}break",
"nel\u{85}",
] {
assert!(is_valid_anchor_name(name), "{name:?}");
}
for name in [
"",
"has space",
"has\ttab",
"has\nnewline",
"has\rreturn",
"comma,name",
"bracket]name",
"[",
"{",
"}",
"bom\u{FEFF}",
"bell\u{7}",
"del\u{7F}",
"c1\u{80}",
] {
assert!(!is_valid_anchor_name(name), "{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",
"a:",
"*star",
"x#y",
"no\u{A0}break",
] {
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_merge_key_is_a_whole_plain_scalar() {
use SyntaxKind::*;
assert_eq!(
lex("<<#: 1"),
[(STRING, "<<#"), (COLON, ":"), (WHITESPACE, " "), (INT, "1")]
);
assert_eq!(
lex("<<:x: 1"),
[
(STRING, "<<:x"),
(COLON, ":"),
(WHITESPACE, " "),
(INT, "1")
]
);
assert_eq!(
lex("<< x: 1"),
[
(STRING, "<< x"),
(COLON, ":"),
(WHITESPACE, " "),
(INT, "1")
]
);
assert_eq!(
lex("<<]: 1"),
[(STRING, "<<]"), (COLON, ":"), (WHITESPACE, " "), (INT, "1")]
);
assert_eq!(
lex("<< #c\n"),
[
(MERGE_KEY, "<<"),
(WHITESPACE, " "),
(COMMENT, "#c"),
(NEWLINE, "\n")
]
);
assert_eq!(
lex("{<<,<<: 1}"),
[
(LEFT_BRACE, "{"),
(MERGE_KEY, "<<"),
(COMMA, ","),
(MERGE_KEY, "<<"),
(COLON, ":"),
(WHITESPACE, " "),
(INT, "1"),
(RIGHT_BRACE, "}")
]
);
}
#[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_plus_tokens() {
use SyntaxKind::*;
let cases: Vec<(&str, Vec<(SyntaxKind, &str)>)> = vec![
(
"+ x: v\n",
vec![
(STRING, "+ x"),
(COLON, ":"),
(WHITESPACE, " "),
(STRING, "v"),
(NEWLINE, "\n"),
],
),
(
"+\tx: v\n",
vec![
(STRING, "+\tx"),
(COLON, ":"),
(WHITESPACE, " "),
(STRING, "v"),
(NEWLINE, "\n"),
],
),
(
"+ \n",
vec![(STRING, "+"), (WHITESPACE, " "), (NEWLINE, "\n")],
),
(
"+ #c\n",
vec![
(STRING, "+"),
(WHITESPACE, " "),
(COMMENT, "#c"),
(NEWLINE, "\n"),
],
),
("+[b]\n", vec![(STRING, "+[b]"), (NEWLINE, "\n")]),
(
"[+[b]]\n",
vec![
(LEFT_BRACKET, "["),
(STRING, "+"),
(LEFT_BRACKET, "["),
(STRING, "b"),
(RIGHT_BRACKET, "]"),
(RIGHT_BRACKET, "]"),
(NEWLINE, "\n"),
],
),
("+'a'\n", vec![(STRING, "+'a'"), (NEWLINE, "\n")]),
("+?\n", vec![(STRING, "+?"), (NEWLINE, "\n")]),
("+|\n", vec![(STRING, "+|"), (NEWLINE, "\n")]),
(
"+#: v\n",
vec![
(STRING, "+#"),
(COLON, ":"),
(WHITESPACE, " "),
(STRING, "v"),
(NEWLINE, "\n"),
],
),
(
"+ #: v\n",
vec![
(STRING, "+"),
(WHITESPACE, " "),
(COMMENT, "#: v"),
(NEWLINE, "\n"),
],
),
(
"+: v\n",
vec![
(STRING, "+"),
(COLON, ":"),
(WHITESPACE, " "),
(STRING, "v"),
(NEWLINE, "\n"),
],
),
(
"+ : v\n",
vec![
(STRING, "+"),
(WHITESPACE, " "),
(COLON, ":"),
(WHITESPACE, " "),
(STRING, "v"),
(NEWLINE, "\n"),
],
),
("+:x\n", vec![(STRING, "+:x"), (NEWLINE, "\n")]),
(
"+\nx\n",
vec![
(STRING, "+"),
(NEWLINE, "\n"),
(STRING, "x"),
(NEWLINE, "\n"),
],
),
(
"+\r\nx\n",
vec![
(STRING, "+"),
(NEWLINE, "\r\n"),
(STRING, "x"),
(NEWLINE, "\n"),
],
),
(
"- |+\n x\n",
vec![
(DASH, "-"),
(WHITESPACE, " "),
(PIPE, "|"),
(PLUS, "+"),
(NEWLINE, "\n"),
(INDENT, " "),
(STRING, "x"),
(NEWLINE, "\n"),
],
),
(
"/|+: v\n",
vec![
(STRING, "/|+"),
(COLON, ":"),
(WHITESPACE, " "),
(STRING, "v"),
(NEWLINE, "\n"),
],
),
("i:|+\n", vec![(STRING, "i:|+"), (NEWLINE, "\n")]),
("+.INF\n", vec![(FLOAT, "+.INF"), (NEWLINE, "\n")]),
(
"{+, a}\n",
vec![
(LEFT_BRACE, "{"),
(STRING, "+"),
(COMMA, ","),
(WHITESPACE, " "),
(STRING, "a"),
(RIGHT_BRACE, "}"),
(NEWLINE, "\n"),
],
),
("+,\n", vec![(STRING, "+,"), (NEWLINE, "\n")]),
(
"a: 1+2\n",
vec![
(STRING, "a"),
(COLON, ":"),
(WHITESPACE, " "),
(STRING, "1+2"),
(NEWLINE, "\n"),
],
),
("i:|2+\n", vec![(STRING, "i:|2+"), (NEWLINE, "\n")]),
(
"x: a |2+\n",
vec![
(STRING, "x"),
(COLON, ":"),
(WHITESPACE, " "),
(STRING, "a |2+"),
(NEWLINE, "\n"),
],
),
(
"k: |2+\n",
vec![
(STRING, "k"),
(COLON, ":"),
(WHITESPACE, " "),
(PIPE, "|"),
(INT, "2"),
(PLUS, "+"),
(NEWLINE, "\n"),
],
),
(
"- |2+\n",
vec![
(DASH, "-"),
(WHITESPACE, " "),
(PIPE, "|"),
(INT, "2"),
(PLUS, "+"),
(NEWLINE, "\n"),
],
),
];
for (input, expected) in cases {
assert_eq!(lex(input), expected, "lexing {input:?}");
}
}
#[test]
fn test_chomping_plus_after_properties() {
use SyntaxKind::*;
for (input, before) in [
("? |2+\n", vec![(QUESTION, "?")]),
("!t |2+\n", vec![(TAG, "!t")]),
("&a |2+\n", vec![(ANCHOR, "&a")]),
] {
let mut expected = before;
expected.extend([
(WHITESPACE, " "),
(PIPE, "|"),
(INT, "2"),
(PLUS, "+"),
(NEWLINE, "\n"),
]);
assert_eq!(lex(input), expected, "lexing {input:?}");
}
}
#[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(), 2);
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);
assert_eq!(
&tokens[tokens.len() - 2..],
[
(SyntaxKind::INDENT, " "),
(SyntaxKind::STRING, "- but this is not a sequence"),
]
);
}
#[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_block_scalar_indentation_indicator_sets_body_column() {
use SyntaxKind::*;
assert_eq!(
lex("- |2\n x\n y # z\n [a\n"),
vec![
(DASH, "-"),
(WHITESPACE, " "),
(PIPE, "|"),
(INT, "2"),
(NEWLINE, "\n"),
(INDENT, " "),
(STRING, "x"),
(NEWLINE, "\n"),
(INDENT, " "),
(STRING, "y"),
(WHITESPACE, " "),
(STRING, "# z"),
(NEWLINE, "\n"),
(INDENT, " "),
(STRING, "[a"),
(NEWLINE, "\n"),
]
);
assert_eq!(
lex("- |2\r\n x\r\n y # z\r\n [a\r\n"),
vec![
(DASH, "-"),
(WHITESPACE, " "),
(PIPE, "|"),
(INT, "2"),
(NEWLINE, "\r\n"),
(INDENT, " "),
(STRING, "x"),
(NEWLINE, "\r\n"),
(INDENT, " "),
(STRING, "y"),
(WHITESPACE, " "),
(STRING, "# z"),
(NEWLINE, "\r\n"),
(INDENT, " "),
(STRING, "[a"),
(NEWLINE, "\r\n"),
]
);
}
#[test]
fn test_block_scalar_indentation_indicator_counts_from_parent() {
use SyntaxKind::*;
assert_eq!(
lex("- k: >-2\n x\n [a\n"),
vec![
(DASH, "-"),
(WHITESPACE, " "),
(STRING, "k"),
(COLON, ":"),
(WHITESPACE, " "),
(GREATER, ">"),
(INT, "-2"),
(NEWLINE, "\n"),
(INDENT, " "),
(STRING, "x"),
(NEWLINE, "\n"),
(INDENT, " "),
(STRING, "[a"),
(NEWLINE, "\n"),
]
);
assert_eq!(
lex("k:\n |1\n x\n # z\n"),
vec![
(STRING, "k"),
(COLON, ":"),
(NEWLINE, "\n"),
(INDENT, " "),
(PIPE, "|"),
(INT, "1"),
(NEWLINE, "\n"),
(INDENT, " "),
(STRING, "x"),
(NEWLINE, "\n"),
(INDENT, " "),
(STRING, "# z"),
(NEWLINE, "\n"),
]
);
assert_eq!(
lex("- k: |1\n x\n j: [a]\n"),
vec![
(DASH, "-"),
(WHITESPACE, " "),
(STRING, "k"),
(COLON, ":"),
(WHITESPACE, " "),
(PIPE, "|"),
(INT, "1"),
(NEWLINE, "\n"),
(INDENT, " "),
(STRING, "x"),
(NEWLINE, "\n"),
(INDENT, " "),
(STRING, "j"),
(COLON, ":"),
(WHITESPACE, " "),
(LEFT_BRACKET, "["),
(STRING, "a"),
(RIGHT_BRACKET, "]"),
(NEWLINE, "\n"),
]
);
}
#[test]
fn test_block_scalar_indentation_indicator_in_explicit_value() {
use SyntaxKind::*;
let head = [
(STRING, "x"),
(COLON, ":"),
(NEWLINE, "\n"),
(INDENT, " "),
(QUESTION, "?"),
(WHITESPACE, " "),
(STRING, "a"),
(NEWLINE, "\n"),
(INDENT, " "),
(COLON, ":"),
];
let tail = [
(INDENT, " "),
(STRING, "x"),
(NEWLINE, "\n"),
(INDENT, " "),
(LEFT_BRACKET, "["),
(STRING, "a"),
(RIGHT_BRACKET, "]"),
(NEWLINE, "\n"),
];
assert_eq!(
lex("x:\n ? a\n : |1\n x\n [a]\n"),
[
&head[..],
&[(WHITESPACE, " "), (PIPE, "|"), (INT, "1"), (NEWLINE, "\n")],
&tail,
]
.concat()
);
assert_eq!(
lex("x:\n ? a\n :\n |1\n x\n [a]\n"),
[
&head[..],
&[
(NEWLINE, "\n"),
(INDENT, " "),
(PIPE, "|"),
(INT, "1"),
(NEWLINE, "\n"),
],
&tail,
]
.concat()
);
}
#[test]
fn test_block_scalar_indentation_indicator_in_compact_explicit_value() {
use SyntaxKind::*;
let head = [
(QUESTION, "?"),
(WHITESPACE, " "),
(STRING, "a"),
(NEWLINE, "\n"),
(COLON, ":"),
(WHITESPACE, " "),
(STRING, "b"),
(COLON, ":"),
];
let tail = [
(INDENT, " "),
(STRING, "x"),
(NEWLINE, "\n"),
(INDENT, " "),
(STRING, "c"),
(COLON, ":"),
(WHITESPACE, " "),
(LEFT_BRACKET, "["),
(STRING, "d"),
(RIGHT_BRACKET, "]"),
(NEWLINE, "\n"),
];
assert_eq!(
lex("? a\n: b: |1\n x\n c: [d]\n"),
[
&head[..],
&[(WHITESPACE, " "), (PIPE, "|"), (INT, "1"), (NEWLINE, "\n")],
&tail,
]
.concat()
);
assert_eq!(
lex("? a\n: b:\n |1\n x\n c: [d]\n"),
[
&head[..],
&[
(NEWLINE, "\n"),
(INDENT, " "),
(PIPE, "|"),
(INT, "1"),
(NEWLINE, "\n"),
],
&tail,
]
.concat()
);
assert_eq!(
lex("- ? a\n : b: |1\n x\n c: [d]\n"),
vec![
(DASH, "-"),
(WHITESPACE, " "),
(QUESTION, "?"),
(WHITESPACE, " "),
(STRING, "a"),
(NEWLINE, "\n"),
(INDENT, " "),
(COLON, ":"),
(WHITESPACE, " "),
(STRING, "b"),
(COLON, ":"),
(WHITESPACE, " "),
(PIPE, "|"),
(INT, "1"),
(NEWLINE, "\n"),
(INDENT, " "),
(STRING, "x"),
(NEWLINE, "\n"),
(INDENT, " "),
(STRING, "c"),
(COLON, ":"),
(WHITESPACE, " "),
(LEFT_BRACKET, "["),
(STRING, "d"),
(RIGHT_BRACKET, "]"),
(NEWLINE, "\n"),
]
);
}
#[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"
)
);
}
#[test]
fn test_block_scalar_body_tab_is_not_indentation() {
use SyntaxKind::*;
assert_eq!(
lex("- |-\n \t[\n ]\"\n- b\n"),
vec![
(DASH, "-"),
(WHITESPACE, " "),
(PIPE, "|"),
(STRING, "-"),
(NEWLINE, "\n"),
(INDENT, " \t"),
(STRING, "["),
(NEWLINE, "\n"),
(INDENT, " "),
(STRING, "]\""),
(NEWLINE, "\n"),
(DASH, "-"),
(WHITESPACE, " "),
(STRING, "b"),
(NEWLINE, "\n"),
]
);
}
#[test]
fn test_unterminated_verbatim_tag_ends_at_line_break() {
use SyntaxKind::*;
for line_break in ["\n", "\r\n", "\r"] {
let input = format!("!<x{line_break}a");
assert_eq!(
lex(&input),
vec![(TAG, "!<x"), (NEWLINE, line_break), (STRING, "a")]
);
}
}
}