use std::fmt::Write as _;
use rlsp_fmt::{Doc, concat, hard_line, indent, text};
use rlsp_yaml_parser::{Chomp, ScalarStyle};
use crate::server::YamlVersion;
use super::options::YamlFormatOptions;
pub(super) fn is_core_schema_tag(tag: &str) -> bool {
tag.starts_with("tag:yaml.org,2002:")
}
pub(super) fn format_tag(tag: &str) -> String {
if tag.starts_with('!') {
tag.to_string()
} else {
format!("!<{tag}>")
}
}
pub(super) fn string_to_doc(s: &str, options: &YamlFormatOptions, in_key: bool) -> Doc {
if needs_quoting(s, options.yaml_version) {
if options.single_quote && !s.contains('\'') {
text(format!("'{s}'"))
} else {
text(format!("\"{}\"", escape_double_quoted(s)))
}
} else if options.single_quote && !in_key {
text(format!("'{s}'"))
} else {
text(s.to_string())
}
}
pub(super) fn needs_flow_quoting(s: &str) -> bool {
s.contains([',', '[', ']', '{', '}'])
}
pub(super) fn needs_quoting(s: &str, version: YamlVersion) -> bool {
if s.is_empty() {
return true;
}
if s.chars().all(char::is_whitespace) {
return true;
}
if s.starts_with(char::is_whitespace) || s.ends_with(char::is_whitespace) {
return true;
}
let always_reserved = matches!(
s,
"null" | "~" | "true" | "false" | "Null" | "NULL" | "True" | "TRUE" | "False" | "FALSE"
);
let v1_1_reserved = version == YamlVersion::V1_1
&& matches!(
s,
"yes" | "no" | "on" | "off" | "Yes" | "No" | "On" | "Off" | "YES" | "NO" | "ON" | "OFF"
);
if s.contains('\n') {
return true;
}
always_reserved
|| v1_1_reserved
|| looks_like_number(s)
|| s.starts_with(|c: char| {
matches!(
c,
':' | '#'
| '&'
| '*'
| '?'
| '|'
| '-'
| '<'
| '>'
| '='
| '!'
| '%'
| '@'
| '`'
| '{'
| '}'
| '['
| ']'
| '"'
| '\''
)
})
|| s.contains(": ")
|| s.contains(" #")
|| s.starts_with("- ")
|| s.starts_with("--- ")
|| s == "---"
|| s == "..."
}
pub(super) fn looks_like_number(s: &str) -> bool {
s.parse::<i64>().is_ok()
|| s.parse::<f64>().is_ok()
|| matches!(
s,
".inf" | ".Inf" | ".INF" | "+.inf" | "-.inf" | ".nan" | ".NaN" | ".NAN"
)
}
pub(super) fn requires_double_quoting(s: &str) -> bool {
s.chars().any(|c| {
matches!(c, '\\')
|| (c as u32) <= 0x1F
|| c == '\u{0085}' || c == '\u{2028}' || c == '\u{2029}' })
}
pub(super) fn escape_double_quoted(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\x00' => out.push_str("\\0"),
'\x07' => out.push_str("\\a"),
'\x08' => out.push_str("\\b"),
'\t' => out.push_str("\\t"),
'\n' => out.push_str("\\n"),
'\x0B' => out.push_str("\\v"),
'\x0C' => out.push_str("\\f"),
'\r' => out.push_str("\\r"),
'\x1B' => out.push_str("\\e"),
'\u{0085}' => out.push_str("\\N"),
'\u{00A0}' => out.push_str("\\_"),
'\u{2028}' => out.push_str("\\L"),
'\u{2029}' => out.push_str("\\P"),
c if (c as u32) <= 0x1F => {
let _ = write!(out, "\\x{:02X}", c as u32);
}
c => out.push(c),
}
}
out
}
pub(super) fn repr_block_to_doc(s: &str, style: ScalarStyle, tab_width: usize) -> Doc {
let needs_indent_indicator = s
.lines()
.find(|l| !l.is_empty())
.is_some_and(|l| l.starts_with(' ') || l.chars().all(char::is_whitespace));
let base_header = match style {
ScalarStyle::Literal(Chomp::Clip) => "|",
ScalarStyle::Literal(Chomp::Strip) => "|-",
ScalarStyle::Literal(Chomp::Keep) => "|+",
ScalarStyle::Folded(Chomp::Clip) => ">",
ScalarStyle::Folded(Chomp::Strip) => ">-",
ScalarStyle::Folded(Chomp::Keep) => ">+",
ScalarStyle::Plain | ScalarStyle::SingleQuoted | ScalarStyle::DoubleQuoted => "",
};
let header = if needs_indent_indicator && !base_header.is_empty() {
let (block_char, chomp_char) = base_header.split_at(1);
format!("{block_char}{tab_width}{chomp_char}")
} else {
base_header.to_string()
};
let mut parts = vec![text(header)];
if matches!(style, ScalarStyle::Folded(_)) {
let mut segments: Vec<&str> = s.split('\n').collect();
if segments.last() == Some(&"") {
segments.pop();
}
let mut pending_empty: usize = 0;
let mut prev_content: Option<&str> = None;
for seg in &segments {
if seg.is_empty() {
pending_empty += 1;
} else {
if let Some(prev) = prev_content {
let prev_more = prev.starts_with([' ', '\t']);
let curr_more = seg.starts_with([' ', '\t']);
let either_more = prev_more || curr_more;
let blank_count = if either_more {
pending_empty
} else {
pending_empty + 1
};
for _ in 0..blank_count {
parts.push(hard_line());
}
}
pending_empty = 0;
parts.push(indent(concat(vec![hard_line(), text(seg.to_string())])));
prev_content = Some(seg);
}
}
} else {
for line_str in s.lines() {
if !line_str.is_empty() {
parts.push(indent(concat(vec![
hard_line(),
text(line_str.to_string()),
])));
}
}
}
concat(parts)
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
#[case::newline_escaped("a\nb", "a\\nb")]
#[case::carriage_return_escaped("a\rb", "a\\rb")]
#[case::tab_escaped("a\tb", "a\\tb")]
#[case::double_quote_escaped("say \"hi\"", "say \\\"hi\\\"")]
#[case::backslash_escaped("a\\b", "a\\\\b")]
fn escape_double_quoted_escapes(#[case] input: &str, #[case] expected: &str) {
assert_eq!(escape_double_quoted(input), expected);
}
#[rstest]
#[case::on_v1_1("on", YamlVersion::V1_1)]
#[case::yes_v1_1("yes", YamlVersion::V1_1)]
#[case::off_v1_1("off", YamlVersion::V1_1)]
#[case::no_v1_1("no", YamlVersion::V1_1)]
#[case::true_v1_1("true", YamlVersion::V1_1)]
#[case::true_v1_2("true", YamlVersion::V1_2)]
#[case::null_v1_1("null", YamlVersion::V1_1)]
#[case::null_v1_2("null", YamlVersion::V1_2)]
#[case::uppercase_yes_v1_1("YES", YamlVersion::V1_1)]
#[case::empty_string_v1_1("", YamlVersion::V1_1)]
#[case::empty_string_v1_2("", YamlVersion::V1_2)]
#[case::numeric_123_v1_1("123", YamlVersion::V1_1)]
#[case::numeric_123_v1_2("123", YamlVersion::V1_2)]
#[case::numeric_3_14_v1_2("3.14", YamlVersion::V1_2)]
fn needs_quoting_returns_true(#[case] word: &str, #[case] version: YamlVersion) {
assert!(
needs_quoting(word, version),
"{word:?} should require quoting in {version:?}"
);
}
#[rstest]
#[case::on_v1_2("on", YamlVersion::V1_2)]
#[case::yes_v1_2("yes", YamlVersion::V1_2)]
#[case::off_v1_2("off", YamlVersion::V1_2)]
#[case::no_v1_2("no", YamlVersion::V1_2)]
#[case::uppercase_yes_v1_2("YES", YamlVersion::V1_2)]
fn needs_quoting_returns_false(#[case] word: &str, #[case] version: YamlVersion) {
assert!(
!needs_quoting(word, version),
"{word:?} should not require quoting in {version:?}"
);
}
}