1use std::fmt;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum Node {
9 Map(Vec<(String, Node)>),
10 Text(String),
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct YamlError {
15 pub line: usize,
16 pub message: String,
17}
18
19impl fmt::Display for YamlError {
20 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21 write!(f, "line {}: {}", self.line, self.message)
22 }
23}
24
25impl std::error::Error for YamlError {}
26
27fn fail<T>(line: usize, message: impl Into<String>) -> Result<T, YamlError> {
28 Err(YamlError {
29 line,
30 message: message.into(),
31 })
32}
33
34struct Line {
35 number: usize,
36 indent: usize,
37 key: String,
38 value: Option<String>,
40}
41
42pub fn parse(text: &str) -> Result<Vec<(String, Node)>, YamlError> {
44 let mut lines = Vec::new();
45 for (n, raw) in text.lines().enumerate() {
46 let number = n + 1;
47 let raw = if number == 1 {
48 raw.trim_start_matches('\u{feff}')
49 } else {
50 raw
51 };
52 let content = strip_comment(raw, number)?;
53 if content.trim().is_empty() {
54 continue;
55 }
56 if content.starts_with('\t') || content.trim_start_matches(' ').starts_with('\t') {
57 return fail(number, "tab in the indentation (use spaces)");
58 }
59 let indent = content.len() - content.trim_start_matches(' ').len();
60 let body = content.trim();
61 if body.starts_with("- ") || body == "-" {
62 return fail(number, "lists are not supported");
63 }
64 if body == "---" || body == "..." {
65 return fail(number, "document markers are not supported");
66 }
67 let (key, rest) = split_key(body, number)?;
68 let value = match rest.trim() {
69 "" => None,
70 "{}" => Some("{}".to_owned()),
71 r if r.starts_with('{') || r.starts_with('[') => {
72 return fail(number, "flow maps and lists are not supported");
73 }
74 r if r.starts_with(['&', '*', '!']) => {
75 return fail(number, "anchors, aliases and tags are not supported");
76 }
77 r if r.starts_with(['|', '>']) => {
78 return fail(number, "multi-line blocks are not supported");
79 }
80 r => Some(r.to_owned()),
81 };
82 lines.push(Line {
83 number,
84 indent,
85 key,
86 value,
87 });
88 }
89 let mut pos = 0;
90 let map = parse_map(&lines, &mut pos, 0)?;
91 if pos < lines.len() {
92 return fail(lines[pos].number, "unexpected indentation");
93 }
94 Ok(map)
95}
96
97fn parse_map(
98 lines: &[Line],
99 pos: &mut usize,
100 indent: usize,
101) -> Result<Vec<(String, Node)>, YamlError> {
102 let mut out: Vec<(String, Node)> = Vec::new();
103 while *pos < lines.len() {
104 let line = &lines[*pos];
105 if line.indent < indent {
106 break;
107 }
108 if line.indent > indent {
109 return fail(line.number, "unexpected indentation");
110 }
111 if out.iter().any(|(k, _)| *k == line.key) {
112 return fail(line.number, format!("the key `{}` appears twice", line.key));
113 }
114 *pos += 1;
115 let node = match &line.value {
116 Some(v) if v == "{}" => Node::Map(Vec::new()),
117 Some(v) => Node::Text(scalar(v, line.number)?),
118 None => match lines.get(*pos) {
119 Some(next) if next.indent > indent => {
120 Node::Map(parse_map(lines, pos, next.indent)?)
121 }
122 _ => Node::Map(Vec::new()),
123 },
124 };
125 out.push((line.key.clone(), node));
126 }
127 Ok(out)
128}
129
130fn strip_comment(line: &str, number: usize) -> Result<String, YamlError> {
132 let mut quote: Option<char> = None;
133 let mut prev = ' ';
134 let mut chars = line.char_indices().peekable();
135 while let Some((i, c)) = chars.next() {
136 match quote {
137 Some('"') => {
138 if c == '\\' {
139 chars.next();
140 } else if c == '"' {
141 quote = None;
142 }
143 }
144 Some(_) => {
145 if c == '\'' {
146 if chars.peek().is_some_and(|(_, n)| *n == '\'') {
147 chars.next();
148 } else {
149 quote = None;
150 }
151 }
152 }
153 None => {
154 let opens = {
156 let head = line[..i].trim_end();
157 head.is_empty() || head.ends_with(':')
158 };
159 if (c == '"' || c == '\'') && opens {
160 quote = Some(c);
161 } else if c == '#' && (prev == ' ' || i == 0) {
162 return Ok(line[..i].trim_end().to_owned());
163 }
164 }
165 }
166 prev = c;
167 }
168 if quote.is_some() {
169 return fail(number, "unterminated quoted text");
170 }
171 Ok(line.trim_end().to_owned())
172}
173
174fn split_key(body: &str, number: usize) -> Result<(String, &str), YamlError> {
176 if body.starts_with('"') || body.starts_with('\'') {
177 let q = body.chars().next().unwrap_or('"');
178 let mut end = None;
179 let mut chars = body.char_indices().skip(1);
180 while let Some((i, c)) = chars.next() {
181 if q == '"' && c == '\\' {
182 chars.next();
183 } else if c == q {
184 end = Some(i);
185 break;
186 }
187 }
188 let end = end.ok_or_else(|| YamlError {
189 line: number,
190 message: "unterminated quoted key".into(),
191 })?;
192 let key = scalar(&body[..=end], number)?;
193 let rest = body[end + 1..].trim_start();
194 let rest = rest.strip_prefix(':').ok_or_else(|| YamlError {
195 line: number,
196 message: "expected `:` after the key".into(),
197 })?;
198 return Ok((key, rest));
199 }
200 let bytes = body.as_bytes();
202 for (i, b) in bytes.iter().enumerate() {
203 if *b == b':' && (i + 1 == bytes.len() || bytes[i + 1] == b' ') {
204 let key = body[..i].trim();
205 if key.is_empty() {
206 return fail(number, "empty key");
207 }
208 return Ok((key.to_owned(), &body[i + 1..]));
209 }
210 }
211 fail(number, "expected `key: value`")
212}
213
214fn scalar(text: &str, number: usize) -> Result<String, YamlError> {
215 let text = text.trim();
216 if let Some(inner) = text.strip_prefix('"') {
217 let inner = inner
218 .strip_suffix('"')
219 .filter(|_| text.len() >= 2)
220 .ok_or_else(|| YamlError {
221 line: number,
222 message: "unterminated double-quoted text".into(),
223 })?;
224 let mut out = String::new();
225 let mut chars = inner.chars();
226 while let Some(c) = chars.next() {
227 if c != '\\' {
228 out.push(c);
229 continue;
230 }
231 match chars.next() {
232 Some('n') => out.push('\n'),
233 Some('t') => out.push('\t'),
234 Some('r') => out.push('\r'),
235 Some('0') => out.push('\0'),
236 Some('"') => out.push('"'),
237 Some('\\') => out.push('\\'),
238 Some('/') => out.push('/'),
239 Some(' ') => out.push(' '),
240 Some('u') => {
241 let hex: String = chars.by_ref().take(4).collect();
242 let c = u32::from_str_radix(&hex, 16)
243 .ok()
244 .filter(|_| hex.len() == 4)
245 .and_then(char::from_u32)
246 .ok_or_else(|| YamlError {
247 line: number,
248 message: "invalid \\u escape".into(),
249 })?;
250 out.push(c);
251 }
252 _ => return fail(number, "invalid escape in double-quoted text"),
253 }
254 }
255 return Ok(out);
256 }
257 if let Some(inner) = text.strip_prefix('\'') {
258 let inner = inner
259 .strip_suffix('\'')
260 .filter(|_| text.len() >= 2)
261 .ok_or_else(|| YamlError {
262 line: number,
263 message: "unterminated single-quoted text".into(),
264 })?;
265 return Ok(inner.replace("''", "'"));
266 }
267 if text == "~" || text == "null" {
268 return Ok(String::new());
269 }
270 Ok(text.to_owned())
271}
272
273pub fn render_scalar(value: &str) -> String {
275 let lower = value.to_ascii_lowercase();
276 let needs_quotes = value.is_empty()
277 || value != value.trim()
278 || value.contains(" #")
279 || value.contains(": ")
280 || value.ends_with(':')
281 || value.chars().any(|c| c.is_control())
282 || value.starts_with([
283 '-', '?', ':', ',', '[', ']', '{', '}', '#', '&', '*', '!', '|', '>', '\'', '"', '%',
284 '@', '`',
285 ])
286 || matches!(
287 lower.as_str(),
288 "null" | "~" | "true" | "false" | "yes" | "no" | "on" | "off"
289 );
290 if !needs_quotes {
291 return value.to_owned();
292 }
293 let mut out = String::from("\"");
294 for c in value.chars() {
295 match c {
296 '"' => out.push_str("\\\""),
297 '\\' => out.push_str("\\\\"),
298 '\n' => out.push_str("\\n"),
299 '\t' => out.push_str("\\t"),
300 '\r' => out.push_str("\\r"),
301 c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
302 c => out.push(c),
303 }
304 }
305 out.push('"');
306 out
307}
308
309pub fn render(map: &[(String, Node)]) -> String {
311 let mut out = String::new();
312 render_into(map, 0, &mut out);
313 out
314}
315
316fn render_into(map: &[(String, Node)], indent: usize, out: &mut String) {
317 for (key, node) in map {
318 out.push_str(&" ".repeat(indent));
319 out.push_str(&render_scalar(key));
320 match node {
321 Node::Text(t) => {
322 out.push_str(": ");
323 out.push_str(&render_scalar(t));
324 out.push('\n');
325 }
326 Node::Map(children) if children.is_empty() => out.push_str(": {}\n"),
327 Node::Map(children) => {
328 out.push_str(":\n");
329 render_into(children, indent + 2, out);
330 }
331 }
332 }
333}
334
335#[cfg(test)]
336mod tests;