diff --git a/TASKS.md b/TASKS.md index 08e3152..28127ed 100644 --- a/TASKS.md +++ b/TASKS.md @@ -387,3 +387,4 @@ Per-component lines live in `tasks/`. Tags there: | T33a | 1 | FAIL | the plan, the Redis browser and the structure editor drew past a 280px box; the form's rules and a row button's press went untested | | 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 | diff --git a/src/devtools/datum.rs b/src/devtools/datum.rs index f2610ef..71d4283 100644 --- a/src/devtools/datum.rs +++ b/src/devtools/datum.rs @@ -69,16 +69,41 @@ fn scalar(text: &str) -> Datum { } } +/// Where a line's comment starts: a `#` at its start or after a space, outside a quoted scalar. +fn comment(line: &str) -> Option { + let mut chars = line.char_indices().peekable(); + let (mut quote, mut last) = (None, ' '); + while let Some((at, ch)) = chars.next() { + match quote { + Some('"') if ch == '\\' => { + chars.next(); + } + Some('\'') if ch == '\'' && chars.peek().is_some_and(|(_, next)| *next == '\'') => { + chars.next(); + } + Some(open) if ch == open => quote = None, + Some(_) => {} + None if ch == '#' && last.is_whitespace() => return Some(at), + None if matches!(ch, '"' | '\'') && last.is_whitespace() => quote = Some(ch), + None => {} + } + last = ch; + } + None +} + +/// Whether a line starts a list item. +fn dashed(words: &str) -> bool { + let words = words.trim_start(); + words.starts_with("- ") || words == "-" +} + /// Lines of a YAML block: each one's number, indent and words, without comments and blank lines. fn lines(text: &str) -> Vec<(usize, usize, &str)> { text.lines() .enumerate() .filter_map(|(ix, line)| { - let words = match line.find(" #") { - Some(at) => &line[..at], - None if line.trim_start().starts_with('#') => "", - None => line, - }; + let words = &line[..comment(line).unwrap_or(line.len())]; let indent = words.len() - words.trim_start().len(); (!words.trim().is_empty() && words.trim() != "---") .then(|| (ix + 1, indent, words.trim_end())) @@ -88,18 +113,11 @@ fn lines(text: &str) -> Vec<(usize, usize, &str)> { /// The block that starts at `at` and holds the lines at `indent`. fn block(lines: &[(usize, usize, &str)], at: &mut usize, indent: usize) -> Result { - let list = lines[*at].2.trim_start().starts_with("- ") || lines[*at].2.trim() == "-"; - if list { + if dashed(lines[*at].2) { let mut items = Vec::new(); - while *at < lines.len() && lines[*at].1 == indent { + while *at < lines.len() && lines[*at].1 == indent && dashed(lines[*at].2) { let (number, _, words) = lines[*at]; - let Some(rest) = words.trim_start().strip_prefix('-') else { - return Err(Unread { - line: number, - why: "a list holds only items starting with -".into(), - }); - }; - let rest = rest.trim_start(); + let rest = words.trim_start()[1..].trim_start(); *at += 1; items.push(if rest.is_empty() { nested(lines, at, indent)? @@ -146,10 +164,10 @@ fn entries( }, }; let key = SharedString::from(key.trim().trim_matches('"').to_string()); - let value = if value.trim().is_empty() { - nested(lines, at, indent)? - } else { - scalar(value) + let value = match lines.get(*at) { + _ if !value.trim().is_empty() => scalar(value), + Some((_, same, next)) if *same == indent && dashed(next) => block(lines, at, indent)?, + _ => nested(lines, at, indent)?, }; map.push((key, value)); Ok(()) @@ -244,6 +262,44 @@ mod tests { ); } + #[test] + fn yaml_reads_a_list_at_its_key_indent() { + let steps = |run: &str| Datum::Map(vec![("run".into(), text(run))]); + assert_eq!( + read_yaml("steps:\n- run: build\n- run: test\nname: ci\n").expect("yaml"), + Datum::Map(vec![ + ( + "steps".into(), + Datum::List(vec![steps("build"), steps("test")]) + ), + ("name".into(), text("ci")) + ]) + ); + assert_eq!( + read_yaml("ports:\n- 80\n- 443\n").expect("yaml"), + Datum::Map(vec![( + "ports".into(), + Datum::List(vec![ + Datum::Number("80".into()), + Datum::Number("443".into()) + ]) + )]) + ); + } + + #[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"), + Datum::Map(vec![ + ("title".into(), text("Issue #12")), + ("message".into(), text("Fix #42")), + ("note".into(), text("it's")) + ]) + ); + } + #[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); diff --git a/src/devtools/document.rs b/src/devtools/document.rs index 87b6c22..b298664 100644 --- a/src/devtools/document.rs +++ b/src/devtools/document.rs @@ -218,6 +218,10 @@ mod tests { let mut all = Vec::new(); let every = nodes(&datum, "$", "", &mut all); assert_eq!(every.len(), 3); + assert_eq!( + every[2].key, "$[\"odd key\"]", + "a key beyond letters takes brackets" + ); assert!(all.is_empty(), "without a query nothing is opened"); } } diff --git a/src/devtools/json_editor.rs b/src/devtools/json_editor.rs index 8fb2692..992ea02 100644 --- a/src/devtools/json_editor.rs +++ b/src/devtools/json_editor.rs @@ -80,9 +80,8 @@ impl RenderOnce for JsonEditor { .disabled(read.is_err()) .on_click(move |_, _, cx| { let text = editor.read(cx).text().to_string(); - let Some(out) = reformat(&text, pretty) else { - return; - }; + let out = reformat(&text, pretty) + .expect("Format and Minify rest until the JSON reads"); log::info!("json editor: {label}"); editor.update(cx, |editor, cx| editor.set_text(out, cx)); }) diff --git a/src/devtools/tests/formats.rs b/src/devtools/tests/formats.rs index ae96649..1e6af8a 100644 --- a/src/devtools/tests/formats.rs +++ b/src/devtools/tests/formats.rs @@ -1,4 +1,7 @@ -use gpui::{AnyElement, App, AppContext as _, Entity, IntoElement, TestAppContext, Window, div}; +use gpui::{ + AnyElement, App, AppContext as _, Entity, Focusable as _, IntoElement, TestAppContext, Window, + div, +}; use super::{Bench, bench, settle, tab, tap}; use crate::{ @@ -61,3 +64,20 @@ fn format_indents_the_json_and_minify_folds_it(cx: &mut TestAppContext) { r#"{"a":[1,2]}"# ); } + +/// Stops: the editor alone, the buttons resting while the JSON does not read. +#[gpui::test] +fn format_and_minify_rest_while_the_json_does_not_read(cx: &mut TestAppContext) { + let (host, cx) = bench(editor, cx); + let editor = cx.update(|window, cx| cx.new(|cx| CodeEditor::new("{", window, cx))); + host.update(cx, |bench, cx| { + bench.editor = Some(editor.clone()); + cx.notify(); + }); + settle(cx); + tab(1, cx); + assert!( + cx.update(|window, cx| editor.focus_handle(cx).is_focused(window)), + "Tab passes Format and Minify" + ); +} diff --git a/src/devtools/xml.rs b/src/devtools/xml.rs index 7027f24..f5e3278 100644 --- a/src/devtools/xml.rs +++ b/src/devtools/xml.rs @@ -32,15 +32,51 @@ struct Reader<'a> { } impl<'a> Reader<'a> { - fn line(&self) -> usize { - self.text[..self.at].matches('\n').count() + 1 + /// Fails on the line `ahead` bytes past where the reader stands. + fn fail_at(&self, ahead: usize, why: impl Into) -> Result { + Err(Unread { + line: self.text[..self.at + ahead].matches('\n').count() + 1, + why: why.into(), + }) } fn fail(&self, why: impl Into) -> Result { - Err(Unread { - line: self.line(), - why: why.into(), - }) + self.fail_at(0, why) + } + + /// The `len` bytes where the reader stands, their entity references read. + fn decoded(&self, len: usize) -> Result { + let raw = &self.rest()[..len]; + let mut out = String::with_capacity(len); + let mut done = 0; + while let Some(amp) = raw[done..].find('&').map(|at| done + at) { + out.push_str(&raw[done..amp]); + let Some(end) = raw[amp..].find(';').map(|at| amp + at) else { + return self.fail_at(amp, "& starts no entity"); + }; + let name = &raw[amp + 1..end]; + if name.is_empty() || name.contains(|c: char| c.is_whitespace() || c == '&') { + return self.fail_at(amp, "& starts no entity"); + } + let number = match name.strip_prefix("#x") { + Some(hex) => u32::from_str_radix(hex, 16).ok(), + None => name.strip_prefix('#').and_then(|dec| dec.parse().ok()), + }; + out.push(match name { + "amp" => '&', + "lt" => '<', + "gt" => '>', + "quot" => '"', + "apos" => '\'', + _ => match number.and_then(char::from_u32) { + Some(ch) => ch, + None => return self.fail_at(amp, format!("&{name}; is no entity")), + }, + }); + done = end + 1; + } + out.push_str(&raw[done..]); + Ok(out) } fn rest(&self) -> &'a str { @@ -112,7 +148,7 @@ impl<'a> Reader<'a> { let Some(end) = self.rest().find(quote) else { return self.fail(format!("{key} in <{name}> is never closed")); }; - attributes.push((key, self.rest()[..end].to_string().into())); + attributes.push((key, self.decoded(end)?.into())); self.at += end + 1; } let children = self.content()?; @@ -152,9 +188,9 @@ impl<'a> Reader<'a> { children.push(XmlNode::Element(self.element()?)); } else { let end = rest.find('<').unwrap_or(rest.len()); - let words = rest[..end].trim(); - if !words.is_empty() { - children.push(XmlNode::Text(words.to_string().into())); + let words = self.decoded(end)?; + if !words.trim().is_empty() { + children.push(XmlNode::Text(words.trim().to_string().into())); } self.at += end; } @@ -189,12 +225,15 @@ pub fn read_xml(text: &str) -> Result { /// Rows for what `element` holds, each element's path listed in `open`. fn nodes(element: &XmlElement, path: &str, open: &mut Vec) -> Vec { let mut counts: Vec<(SharedString, usize)> = Vec::new(); + let mut texts = 0; element .children .iter() - .enumerate() - .map(|(ix, child)| match child { - XmlNode::Text(text) => TreeNode::new(format!("{path}/text()[{ix}]"), text.clone()), + .map(|child| match child { + XmlNode::Text(text) => { + texts += 1; + TreeNode::new(format!("{path}/text()[{texts}]"), text.clone()) + } XmlNode::Element(child) => { let seen = match counts.iter_mut().find(|(name, _)| *name == child.name) { Some((_, count)) => { @@ -289,7 +328,35 @@ impl RenderOnce for XmlViewer { #[cfg(test)] mod tests { - use super::{XmlNode, read_xml}; + use super::{XmlNode, nodes, read_xml}; + + #[test] + fn entities_read_in_text_and_attributes() { + let root = + read_xml("Tom & Jerry <3 AB") + .expect("xml"); + assert_eq!(root.attributes, [("a".into(), "Tom & Jerry".into())]); + assert_eq!(root.children, [XmlNode::Text("Tom & Jerry <3 AB".into())]); + let unread = read_xml("\n &nope;").expect_err("an unknown entity"); + assert_eq!( + (unread.line, unread.why.as_str()), + (2, "&nope; is no entity") + ); + assert_eq!( + read_xml("Q&A").expect_err("a bare ampersand").why, + "& starts no entity" + ); + } + + #[test] + fn a_text_path_counts_text_nodes_from_one() { + let root = read_xml("xy").expect("xml"); + let keys: Vec<_> = nodes(&root, "/a", &mut Vec::new()) + .into_iter() + .map(|node| node.key) + .collect(); + assert_eq!(keys, ["/a/text()[1]", "/a/b[1]", "/a/text()[2]"]); + } #[test] fn elements_attributes_and_text_read_in_order() { diff --git a/src/lists/tree/row.rs b/src/lists/tree/row.rs index 2d37544..59f5ba8 100644 --- a/src/lists/tree/row.rs +++ b/src/lists/tree/row.rs @@ -3,7 +3,7 @@ use std::{ops::Range, rc::Rc}; use gpui::{ AnyElement, App, DragMoveEvent, ElementId, Entity, EntityId, FocusHandle, InteractiveElement, IntoElement, MouseButton, ParentElement, SharedString, StatefulInteractiveElement, Styled, - Window, div, prelude::*, + Window, div, prelude::*, relative, }; use super::{ @@ -210,10 +210,12 @@ fn row(rows: &Rows, ix: usize, window: &mut Window, cx: &mut App) -> AnyElement .child(body) .children(note.map(|note| { div() + .debug_selector(|| "tree-note".into()) .flex_none() + .max_w(relative(0.5)) .text_size(theme.text_size(TextSize::Xs)) .text_color(ink.unwrap_or(colors.fg_subtle)) - .child(note) + .child(Ellipsis::new(note)) })) .children(landing.filter(|at| *at != DropAt::Inside).map(line)) .on_click(move |event, window, cx| { diff --git a/src/lists/tree/tests.rs b/src/lists/tree/tests.rs index fdc1384..9a9f8be 100644 --- a/src/lists/tree/tests.rs +++ b/src/lists/tree/tests.rs @@ -34,7 +34,8 @@ impl Render for Explorer { TreeNode::new("ui", "ui").child(TreeNode::new("button", "button.rs")), ]), docs, - TreeNode::new("readme", "README.md"), + TreeNode::new("readme", "README.md") + .note("a note long enough to take the whole row and more"), ]; let (select, check, load, rename, moved) = ( view.clone(), @@ -95,6 +96,20 @@ fn read(view: &Entity, cx: &mut VisualTestContext, f: impl Fn(&Expl view.read_with(cx, |explorer, _| f(explorer)) } +#[gpui::test] +fn a_long_note_leaves_the_label_half_the_row(cx: &mut TestAppContext) { + let (_, cx) = explorer(false, cx); + let note = cx.debug_bounds("tree-note").expect("the readme's note"); + assert!( + note.size.width <= px(150.0), + "the note takes at most half the row: {note:?}" + ); + assert!( + note.size.width > px(100.0), + "the note keeps its half: {note:?}" + ); +} + #[gpui::test] fn keys_walk_open_climb_and_close(cx: &mut TestAppContext) { let (view, cx) = explorer(false, cx);