From 54328a54ca086ca783d1d07b7d32d5b6d7b9c587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 10:03:53 -0400 Subject: [PATCH] =?UTF-8?q?DB=20&=20Dev=20Tools=20=C2=B7=20Data=20formats:?= =?UTF-8?q?=20quoted=20YAML=20reads=20its=20escapes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A double-quoted scalar reads \" \\ \/ \n \t \r and \0, a single-quoted one reads '' as a quote, and a key loses either kind of quotes. An escape YAML does not know fails with its line, and so does block text after | or >, which the reader does not take. --- TASKS.md | 1 + src/devtools/datum.rs | 120 +++++++++++++++++++++++++++++++++++------- 2 files changed, 102 insertions(+), 19 deletions(-) diff --git a/TASKS.md b/TASKS.md index 28127ed..742261c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -388,3 +388,4 @@ Per-component lines live in `tasks/`. Tags there: | T33a | 2 | FAIL | the structure editor's cells kept the narrow box's first measure, and its Tab stops past the sideways fold stayed out of view | | T33a | 3 | PASS | | | T33b | 1 | FAIL | YAML lists at their key's indent and quoted `#` misread; a long note hid the key at 280px; XML text paths counted from 0 and entities stayed raw; odd-key paths and Format at rest untested | +| T33b | 2 | FAIL | quoted YAML scalars kept their escapes and single-quoted keys their quotes; the comment scanner's escape and after-a-space rules untested | diff --git a/src/devtools/datum.rs b/src/devtools/datum.rs index 71d4283..75cafc3 100644 --- a/src/devtools/datum.rs +++ b/src/devtools/datum.rs @@ -51,22 +51,64 @@ pub fn read_json(text: &str) -> Result { }) } -fn scalar(text: &str) -> Datum { - let text = text.trim(); - let unquoted = |quote: char| { - text.strip_prefix(quote) - .and_then(|rest| rest.strip_suffix(quote)) +/// The inside of a quoted scalar with its escapes read: `\"`, `\\`, `\/`, `\n`, `\t`, `\r` and `\0` in double quotes, `''` in single ones. Plain text is `None`. +fn unquoted(text: &str) -> Result, String> { + if let Some(inner) = text + .strip_prefix('\'') + .and_then(|rest| rest.strip_suffix('\'')) + { + return Ok(Some(inner.replace("''", "'"))); + } + let Some(inner) = text + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + else { + return Ok(None); }; - if let Some(inner) = unquoted('"').or_else(|| unquoted('\'')) { - return Datum::Text(inner.to_string().into()); + let mut out = String::with_capacity(inner.len()); + let mut chars = inner.chars(); + while let Some(ch) = chars.next() { + if ch != '\\' { + out.push(ch); + continue; + } + out.push(match chars.next() { + Some('"') => '"', + Some('\\') => '\\', + Some('/') => '/', + Some('n') => '\n', + Some('t') => '\t', + Some('r') => '\r', + Some('0') => '\0', + Some(other) => return Err(format!("\\{other} is no escape YAML knows")), + None => return Err("a quoted text ends in a lone \\".into()), + }); } - match text { - "" | "~" | "null" => Datum::Null, - "true" => Datum::Bool(true), - "false" => Datum::Bool(false), - _ if text.parse::().is_ok() => Datum::Number(text.to_string().into()), - _ => Datum::Text(text.to_string().into()), + Ok(Some(out)) +} + +/// A scalar's value, or why its quotes do not read. +fn scalar(text: &str, line: usize) -> Result { + let text = text.trim(); + if text.starts_with(['|', '>']) + && text[1..] + .chars() + .all(|ch| matches!(ch, '-' | '+' | '0'..='9')) + { + return Err(Unread { + line, + why: "block text after | or > is not read".into(), + }); } + let quoted = unquoted(text).map_err(|why| Unread { line, why })?; + Ok(match (quoted, text) { + (Some(inner), _) => Datum::Text(inner.into()), + (None, "" | "~" | "null") => Datum::Null, + (None, "true") => Datum::Bool(true), + (None, "false") => Datum::Bool(false), + (None, _) if text.parse::().is_ok() => Datum::Number(text.to_string().into()), + (None, _) => Datum::Text(text.to_string().into()), + }) } /// Where a line's comment starts: a `#` at its start or after a space, outside a quoted scalar. @@ -125,7 +167,7 @@ fn block(lines: &[(usize, usize, &str)], at: &mut usize, indent: usize) -> Resul let inner = indent + words.trim_start().len() - rest.len(); entries(lines, at, inner, Some((number, rest)))? } else { - scalar(rest) + scalar(rest, number)? }); } Ok(Datum::List(items)) @@ -163,9 +205,12 @@ fn entries( } }, }; - let key = SharedString::from(key.trim().trim_matches('"').to_string()); + let key = match unquoted(key.trim()).map_err(|why| Unread { line: number, why })? { + Some(inner) => SharedString::from(inner), + None => SharedString::from(key.trim().to_string()), + }; let value = match lines.get(*at) { - _ if !value.trim().is_empty() => scalar(value), + _ if !value.trim().is_empty() => scalar(value, number)?, Some((_, same, next)) if *same == indent && dashed(next) => block(lines, at, indent)?, _ => nested(lines, at, indent)?, }; @@ -290,19 +335,56 @@ mod tests { #[test] fn a_hash_inside_quotes_is_no_comment() { assert_eq!( - read_yaml("title: \"Issue #12\" # a note\nmessage: 'Fix #42'\nnote: it's #1\n") - .expect("yaml"), + read_yaml(concat!( + "title: \"Issue #12\" # a note\n", + "message: 'Fix #42'\n", + "note: it's #1\n", + "url: http://x/#frag\n", + "v: a#b\n", + "a: \"x \\\" # y\" # c\n", + "b: 'it''s #1' # c\n", + )) + .expect("yaml"), Datum::Map(vec![ ("title".into(), text("Issue #12")), ("message".into(), text("Fix #42")), - ("note".into(), text("it's")) + ("note".into(), text("it's")), + ("url".into(), text("http://x/#frag")), + ("v".into(), text("a#b")), + ("a".into(), text("x \" # y")), + ("b".into(), text("it's #1")), ]) ); } + #[test] + fn quoted_text_reads_its_escapes_and_keys_lose_their_quotes() { + assert_eq!( + read_yaml("a: \"say \\\"hi\\\"\"\nb: 'it''s'\nc: \"tab\\tx\"\n'q': 2\n\"k\": 1\n") + .expect("yaml"), + Datum::Map(vec![ + ("a".into(), text("say \"hi\"")), + ("b".into(), text("it's")), + ("c".into(), text("tab\tx")), + ("q".into(), Datum::Number("2".into())), + ("k".into(), Datum::Number("1".into())), + ]) + ); + let unread = read_yaml("a: 1\nb: \"\\q\"\n").expect_err("an unknown escape"); + assert_eq!( + (unread.line, unread.why.as_str()), + (2, "\\q is no escape YAML knows") + ); + } + #[test] fn yaml_says_which_line_it_could_not_read() { assert_eq!(read_yaml("a: 1\njust words\n").expect_err("no key").line, 2); + let block = read_yaml("a: 1\nrun: |-\n make\n").expect_err("block text"); + assert_eq!( + (block.line, block.why.as_str()), + (2, "block text after | or > is not read") + ); assert_eq!( read_yaml("a: 1\n b: 2\n") .expect_err("stray indent")