DB & Dev Tools · Data formats: JSON, YAML and XML as trees, a JSON editor
A document reads into one value, Datum: JSON through serde_json, its keys now kept in their written order, and YAML's block style through a small reader that says which line it could not read. JsonViewer and YamlViewer draw it as one tree: each key with its value, maps and lists with their counts; a search keeps what holds its words and opens the way; Enter on a value copies its JSONPath. XmlViewer reads elements, attributes and text and says where tags cross. JsonEditor writes JSON in the code editor with Format and Minify and says whether it reads. DocumentViewer is JsonViewer.
This commit is contained in:
1 parent
08c8ebf386
commit
86a8b4a958
11 files changed
+1099
-8
No files matched your search
+1
-1
@@ -31,7 +31,7 @@ qrcode = { version = "0.14", default-features = false }
|
||||
regex = "1"
|
||||
rust-embed = "8.12"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
similar = { version = "3.2", default-features = false, features = ["std", "text", "inline", "unicode"] }
|
||||
smallvec = "1"
|
||||
unicode-segmentation = "1"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
use ely_gpui_component::{
|
||||
devtools::{JsonEditor, JsonViewer, XmlViewer, YamlViewer},
|
||||
editor::CodeEditor,
|
||||
forms::TextInput,
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px};
|
||||
|
||||
use crate::ui::section;
|
||||
|
||||
const ORDER: &str = r#"{
|
||||
"_id": "66f1c2a9e4b0",
|
||||
"customer": { "name": "Ada Lovelace", "email": "ada@example.com" },
|
||||
"items": [
|
||||
{ "sku": "LAMP-01", "quantity": 1, "price": 89.0 },
|
||||
{ "sku": "BULB-E27", "quantity": 4, "price": 6.5 }
|
||||
],
|
||||
"paid": true,
|
||||
"shipped_at": null,
|
||||
"tags": ["gift", "express"]
|
||||
}"#;
|
||||
|
||||
const DEPLOYMENT: &str = "apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: web
|
||||
labels:
|
||||
app: web
|
||||
spec:
|
||||
replicas: 3
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: web
|
||||
image: registry.example.com/web:1.8.2
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
";
|
||||
|
||||
const FEED: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Release notes</title>
|
||||
<item id="1">
|
||||
<title>Faster queries</title>
|
||||
<pubDate>Fri, 25 Sep 2026 09:00:00 GMT</pubDate>
|
||||
</item>
|
||||
<item id="2">
|
||||
<title>Dark mode</title>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>"#;
|
||||
|
||||
pub fn documents(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let search = window.use_keyed_state("devtools-json-search", cx, |window, cx| {
|
||||
TextInput::new(window, cx).placeholder("Find a key or a value")
|
||||
});
|
||||
let editor = window.use_keyed_state("devtools-json-editor", cx, |window, cx| {
|
||||
CodeEditor::new(
|
||||
r#"{"name": "web", "replicas": 3, "ports": [8080, 8443]}"#,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.language("JSON")
|
||||
});
|
||||
let yaml_search = window.use_keyed_state("devtools-yaml-search", cx, |window, cx| {
|
||||
TextInput::new(window, cx).placeholder("Find a key or a value")
|
||||
});
|
||||
section(
|
||||
"DocumentViewer (MongoDB / JSON) → devtools::JsonViewer · JsonViewer / JsonTree · JsonEditor · YamlViewer · XmlViewer",
|
||||
"A document as a tree: each key with its value, lists and maps with their counts; a search keeps what holds it and opens the way, and Enter on a value copies its path. The editor writes JSON with Format and Minify and says whether it reads. YAML reads as the same tree; XML as elements with their attributes and text.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_6()
|
||||
.child(div().w(px(380.)).child(JsonViewer::new("devtools-json", ORDER, &search)))
|
||||
.child(div().w(px(420.)).child(JsonEditor::new("devtools-json-editor", &editor)))
|
||||
.child(div().w(px(380.)).child(YamlViewer::new("devtools-yaml", DEPLOYMENT, &yaml_search)))
|
||||
.child(div().w(px(420.)).child(XmlViewer::new("devtools-xml", FEED))),
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use gpui::{AnyElement, App, Window, div, prelude::*};
|
||||
|
||||
mod data;
|
||||
mod db;
|
||||
mod formats;
|
||||
|
||||
use super::Page;
|
||||
use crate::script::Step;
|
||||
@@ -24,5 +25,6 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
.child(data::queries(window, cx))
|
||||
.child(db::structure(window, cx))
|
||||
.child(data::redis(window, cx))
|
||||
.child(formats::documents(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use gpui::SharedString;
|
||||
|
||||
/// A value in a document: nothing, a flag, a number, a text, or a list or a map of values in their order.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Datum {
|
||||
Null,
|
||||
Bool(bool),
|
||||
Number(SharedString),
|
||||
Text(SharedString),
|
||||
List(Vec<Datum>),
|
||||
Map(Vec<(SharedString, Datum)>),
|
||||
}
|
||||
|
||||
impl From<&serde_json::Value> for Datum {
|
||||
fn from(value: &serde_json::Value) -> Self {
|
||||
match value {
|
||||
serde_json::Value::Null => Datum::Null,
|
||||
serde_json::Value::Bool(on) => Datum::Bool(*on),
|
||||
serde_json::Value::Number(number) => Datum::Number(number.to_string().into()),
|
||||
serde_json::Value::String(text) => Datum::Text(text.clone().into()),
|
||||
serde_json::Value::Array(items) => Datum::List(items.iter().map(Datum::from).collect()),
|
||||
serde_json::Value::Object(map) => Datum::Map(
|
||||
map.iter()
|
||||
.map(|(key, value)| (key.clone().into(), Datum::from(value)))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A line that failed to read, and why.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Unread {
|
||||
pub line: usize,
|
||||
pub why: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Unread {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Line {}: {}", self.line, self.why)
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON read into a value, keys in their written order.
|
||||
pub fn read_json(text: &str) -> Result<Datum, Unread> {
|
||||
serde_json::from_str::<serde_json::Value>(text)
|
||||
.map(|value| Datum::from(&value))
|
||||
.map_err(|error| Unread {
|
||||
line: error.line(),
|
||||
why: error.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn scalar(text: &str) -> Datum {
|
||||
let text = text.trim();
|
||||
let unquoted = |quote: char| {
|
||||
text.strip_prefix(quote)
|
||||
.and_then(|rest| rest.strip_suffix(quote))
|
||||
};
|
||||
if let Some(inner) = unquoted('"').or_else(|| unquoted('\'')) {
|
||||
return Datum::Text(inner.to_string().into());
|
||||
}
|
||||
match text {
|
||||
"" | "~" | "null" => Datum::Null,
|
||||
"true" => Datum::Bool(true),
|
||||
"false" => Datum::Bool(false),
|
||||
_ if text.parse::<f64>().is_ok() => Datum::Number(text.to_string().into()),
|
||||
_ => Datum::Text(text.to_string().into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 indent = words.len() - words.trim_start().len();
|
||||
(!words.trim().is_empty() && words.trim() != "---")
|
||||
.then(|| (ix + 1, indent, words.trim_end()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The block that starts at `at` and holds the lines at `indent`.
|
||||
fn block(lines: &[(usize, usize, &str)], at: &mut usize, indent: usize) -> Result<Datum, Unread> {
|
||||
let list = lines[*at].2.trim_start().starts_with("- ") || lines[*at].2.trim() == "-";
|
||||
if list {
|
||||
let mut items = Vec::new();
|
||||
while *at < lines.len() && lines[*at].1 == indent {
|
||||
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();
|
||||
*at += 1;
|
||||
items.push(if rest.is_empty() {
|
||||
nested(lines, at, indent)?
|
||||
} else if rest.contains(": ") || rest.ends_with(':') {
|
||||
let inner = indent + words.trim_start().len() - rest.len();
|
||||
entries(lines, at, inner, Some((number, rest)))?
|
||||
} else {
|
||||
scalar(rest)
|
||||
});
|
||||
}
|
||||
Ok(Datum::List(items))
|
||||
} else {
|
||||
entries(lines, at, indent, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// What lies under a key or a dash with nothing after it: a deeper block, or nothing.
|
||||
fn nested(lines: &[(usize, usize, &str)], at: &mut usize, indent: usize) -> Result<Datum, Unread> {
|
||||
match lines.get(*at) {
|
||||
Some((_, deeper, _)) if *deeper > indent => block(lines, at, *deeper),
|
||||
_ => Ok(Datum::Null),
|
||||
}
|
||||
}
|
||||
|
||||
/// A map's entries at `indent`, the first one given when it sat after a dash.
|
||||
fn entries(
|
||||
lines: &[(usize, usize, &str)],
|
||||
at: &mut usize,
|
||||
indent: usize,
|
||||
first: Option<(usize, &str)>,
|
||||
) -> Result<Datum, Unread> {
|
||||
let mut map = Vec::new();
|
||||
let mut entry = |number: usize, words: &str, at: &mut usize| -> Result<(), Unread> {
|
||||
let (key, value) = match words.split_once(": ") {
|
||||
Some((key, value)) => (key, value),
|
||||
None => match words.strip_suffix(':') {
|
||||
Some(key) => (key, ""),
|
||||
None => {
|
||||
return Err(Unread {
|
||||
line: number,
|
||||
why: format!("{words:?} is no key and value"),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
let key = SharedString::from(key.trim().trim_matches('"').to_string());
|
||||
let value = if value.trim().is_empty() {
|
||||
nested(lines, at, indent)?
|
||||
} else {
|
||||
scalar(value)
|
||||
};
|
||||
map.push((key, value));
|
||||
Ok(())
|
||||
};
|
||||
if let Some((number, words)) = first {
|
||||
entry(number, words, at)?;
|
||||
}
|
||||
while *at < lines.len() && lines[*at].1 == indent {
|
||||
let (number, _, words) = lines[*at];
|
||||
*at += 1;
|
||||
entry(number, words.trim_start(), at)?;
|
||||
}
|
||||
if let Some((number, deeper, _)) = lines.get(*at)
|
||||
&& *deeper > indent
|
||||
{
|
||||
return Err(Unread {
|
||||
line: *number,
|
||||
why: "this line is indented further than its block".into(),
|
||||
});
|
||||
}
|
||||
Ok(Datum::Map(map))
|
||||
}
|
||||
|
||||
/// YAML's block style read into a value: maps, lists and scalars, with comments dropped. Flow style reads as text.
|
||||
pub fn read_yaml(text: &str) -> Result<Datum, Unread> {
|
||||
let lines = lines(text);
|
||||
if lines.is_empty() {
|
||||
return Ok(Datum::Null);
|
||||
}
|
||||
let mut at = 0;
|
||||
let datum = block(&lines, &mut at, lines[0].1)?;
|
||||
match lines.get(at) {
|
||||
Some((number, _, _)) => Err(Unread {
|
||||
line: *number,
|
||||
why: "this line sits outside the document's first block".into(),
|
||||
}),
|
||||
None => Ok(datum),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Datum, read_json, read_yaml};
|
||||
|
||||
fn text(words: &str) -> Datum {
|
||||
Datum::Text(words.to_string().into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_keeps_its_keys_in_order_and_says_where_it_breaks() {
|
||||
let datum = read_json(r#"{"b": 1, "a": [true, null, "x"]}"#).expect("json");
|
||||
let Datum::Map(entries) = datum else {
|
||||
panic!("a map")
|
||||
};
|
||||
assert_eq!(entries[0].0, "b");
|
||||
assert_eq!(
|
||||
entries[1].1,
|
||||
Datum::List(vec![Datum::Bool(true), Datum::Null, text("x")])
|
||||
);
|
||||
assert_eq!(
|
||||
read_json("{\n \"a\": 1,\n}")
|
||||
.expect_err("a trailing comma")
|
||||
.line,
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_reads_nested_maps_lists_and_maps_in_lists() {
|
||||
let datum = read_yaml(
|
||||
"apiVersion: apps/v1 # the api\nkind: Deployment\nspec:\n replicas: 3\n ports:\n - 80\n - name: https\n port: 443\n",
|
||||
)
|
||||
.expect("yaml");
|
||||
let Datum::Map(entries) = datum else {
|
||||
panic!("a map")
|
||||
};
|
||||
assert_eq!(entries[0], ("apiVersion".into(), text("apps/v1")));
|
||||
let Datum::Map(spec) = &entries[2].1 else {
|
||||
panic!("spec is a map")
|
||||
};
|
||||
assert_eq!(spec[0], ("replicas".into(), Datum::Number("3".into())));
|
||||
let Datum::List(ports) = &spec[1].1 else {
|
||||
panic!("ports is a list")
|
||||
};
|
||||
assert_eq!(ports[0], Datum::Number("80".into()));
|
||||
assert_eq!(
|
||||
ports[1],
|
||||
Datum::Map(vec![
|
||||
("name".into(), text("https")),
|
||||
("port".into(), Datum::Number("443".into()))
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
assert_eq!(
|
||||
read_yaml("a: 1\n b: 2\n")
|
||||
.expect_err("stray indent")
|
||||
.line,
|
||||
2
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
use gpui::{
|
||||
App, ClipboardItem, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
Styled, Window, div,
|
||||
};
|
||||
|
||||
use super::datum::{Datum, Unread, read_json, read_yaml};
|
||||
use crate::{
|
||||
feedback::InlineMessage,
|
||||
forms::{Input, TextInput},
|
||||
lists::{Tree, TreeNode},
|
||||
primitives::{Icon, IconName, Severity},
|
||||
theme::{ActiveTheme, IconSize, TextSize},
|
||||
};
|
||||
|
||||
/// A scalar as a row shows it.
|
||||
fn preview(datum: &Datum) -> SharedString {
|
||||
match datum {
|
||||
Datum::Null => "null".into(),
|
||||
Datum::Bool(on) => on.to_string().into(),
|
||||
Datum::Number(number) => number.clone(),
|
||||
Datum::Text(text) => format!("\"{text}\"").into(),
|
||||
Datum::List(items) => format!("[{}]", items.len()).into(),
|
||||
Datum::Map(entries) => format!("{{{}}}", entries.len()).into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The path to a key under `path`, in JSONPath.
|
||||
fn keyed(path: &str, key: &str) -> String {
|
||||
let plain = !key.is_empty() && key.chars().all(|c| c.is_alphanumeric() || c == '_');
|
||||
if plain {
|
||||
format!("{path}.{key}")
|
||||
} else {
|
||||
format!("{path}[\"{key}\"]")
|
||||
}
|
||||
}
|
||||
|
||||
fn children<'a>(datum: &'a Datum, path: &str) -> Vec<(SharedString, String, &'a Datum)> {
|
||||
match datum {
|
||||
Datum::Map(entries) => entries
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), keyed(path, key), value))
|
||||
.collect(),
|
||||
Datum::List(items) => items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, item)| (format!("[{ix}]").into(), format!("{path}[{ix}]"), item))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a key or a value at or under `datum` holds `query`.
|
||||
fn holds(label: &str, datum: &Datum, query: &str) -> bool {
|
||||
label.to_lowercase().contains(query)
|
||||
|| match datum {
|
||||
Datum::Map(_) | Datum::List(_) => children(datum, "")
|
||||
.iter()
|
||||
.any(|(label, _, child)| holds(label, child, query)),
|
||||
scalar => preview(scalar).to_lowercase().contains(query),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows for what lies under `datum`, each keyed by its path. With a query, only rows that hold it, or lead to one that does, stay; each one kept is listed in `open`.
|
||||
pub(crate) fn nodes(
|
||||
datum: &Datum,
|
||||
path: &str,
|
||||
query: &str,
|
||||
open: &mut Vec<SharedString>,
|
||||
) -> Vec<TreeNode> {
|
||||
children(datum, path)
|
||||
.into_iter()
|
||||
.filter(|(label, _, child)| query.is_empty() || holds(label, child, query))
|
||||
.map(|(label, path, child)| {
|
||||
let node = TreeNode::new(path.clone(), label.clone()).note(preview(child));
|
||||
match child {
|
||||
Datum::Map(_) | Datum::List(_) => {
|
||||
let whole = label.to_lowercase().contains(query);
|
||||
if !query.is_empty() {
|
||||
open.push(path.clone().into());
|
||||
}
|
||||
node.children(nodes(child, &path, if whole { "" } else { query }, open))
|
||||
}
|
||||
_ => node,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The copied path, shown under the tree.
|
||||
#[derive(Default)]
|
||||
struct Copied(Option<SharedString>);
|
||||
|
||||
/// A document read into a tree, or where it failed to read.
|
||||
fn document(
|
||||
id: ElementId,
|
||||
read: Result<Datum, Unread>,
|
||||
search: &Entity<TextInput>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> gpui::AnyElement {
|
||||
let copied = window.use_keyed_state((id.clone(), "copied"), cx, |_, _| Copied::default());
|
||||
let query = search.read(cx).text().trim().to_lowercase();
|
||||
let theme = cx.theme();
|
||||
let body = match read {
|
||||
Err(unread) => InlineMessage::new(Severity::Danger, unread.to_string()).into_any_element(),
|
||||
Ok(datum) => {
|
||||
let mut open = vec![SharedString::from("$")];
|
||||
let rows = nodes(&datum, "$", &query, &mut open);
|
||||
let root = TreeNode::new("$", "$").note(preview(&datum)).children(rows);
|
||||
let copy = copied.clone();
|
||||
div()
|
||||
.h(theme.list_max_height())
|
||||
.child(
|
||||
Tree::new((id, "tree"), [root])
|
||||
.open(open)
|
||||
.on_activate(move |path, _, cx| {
|
||||
log::info!("document: copied {path}");
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(path.to_string()));
|
||||
copy.update(cx, |copied, cx| {
|
||||
copied.0 = Some(path.clone());
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.size_full(),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
let note = copied.read(cx).0.clone().map(|path| {
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child(format!("Copied {path}"))
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(Input::new(search).prefix(Icon::new(IconName::Search).size(IconSize::Sm)))
|
||||
.child(body)
|
||||
.children(note)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// JSON as a tree: each key or index with its value, maps and lists with their counts. A search keeps the rows that hold its words and opens the way to them; Enter or a double press on a value copies its path. JSON that does not read shows the line where it breaks.
|
||||
#[derive(IntoElement)]
|
||||
pub struct JsonViewer {
|
||||
id: ElementId,
|
||||
text: SharedString,
|
||||
search: Entity<TextInput>,
|
||||
}
|
||||
|
||||
impl JsonViewer {
|
||||
/// `search` is the find field's text, which the owner keeps.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
text: impl Into<SharedString>,
|
||||
search: &Entity<TextInput>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
text: text.into(),
|
||||
search: search.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for JsonViewer {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
document(self.id, read_json(&self.text), &self.search, window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// YAML as a tree, as `JsonViewer` shows JSON: block maps and lists, with comments dropped.
|
||||
#[derive(IntoElement)]
|
||||
pub struct YamlViewer {
|
||||
id: ElementId,
|
||||
text: SharedString,
|
||||
search: Entity<TextInput>,
|
||||
}
|
||||
|
||||
impl YamlViewer {
|
||||
/// `search` is the find field's text, which the owner keeps.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
text: impl Into<SharedString>,
|
||||
search: &Entity<TextInput>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
text: text.into(),
|
||||
search: search.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for YamlViewer {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
document(self.id, read_yaml(&self.text), &self.search, window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::nodes;
|
||||
use crate::devtools::datum::read_json;
|
||||
|
||||
#[test]
|
||||
fn a_search_keeps_what_holds_it_and_opens_the_way() {
|
||||
let datum = read_json(
|
||||
r#"{"spec": {"replicas": 3, "image": "web:1.2"}, "kind": "Deployment", "odd key": 1}"#,
|
||||
)
|
||||
.expect("json");
|
||||
let mut open = Vec::new();
|
||||
let rows = nodes(&datum, "$", "web", &mut open);
|
||||
assert_eq!(rows.len(), 1, "only spec holds web");
|
||||
assert_eq!(open, ["$.spec"]);
|
||||
let mut all = Vec::new();
|
||||
let every = nodes(&datum, "$", "", &mut all);
|
||||
assert_eq!(every.len(), 3);
|
||||
assert!(all.is_empty(), "without a query nothing is opened");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
use gpui::{App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, Styled, Window, div};
|
||||
|
||||
use super::datum::{Datum, read_json};
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
editor::CodeEditor,
|
||||
feedback::InlineMessage,
|
||||
primitives::Severity,
|
||||
theme::{ActiveTheme, Radius},
|
||||
};
|
||||
|
||||
/// How much a value holds: its keys and items, all the way down.
|
||||
pub(crate) fn tally(datum: &Datum) -> (usize, usize) {
|
||||
match datum {
|
||||
Datum::Map(entries) => {
|
||||
entries
|
||||
.iter()
|
||||
.fold((entries.len(), 0), |(keys, items), (_, value)| {
|
||||
let (more, most) = tally(value);
|
||||
(keys + more, items + most)
|
||||
})
|
||||
}
|
||||
Datum::List(values) => values
|
||||
.iter()
|
||||
.fold((0, values.len()), |(keys, items), value| {
|
||||
let (more, most) = tally(value);
|
||||
(keys + more, items + most)
|
||||
}),
|
||||
_ => (0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON to write in the code editor, with Format to indent it, Minify to fold it onto one line, and a line that says whether it reads: how many keys and items it holds, or the line where it breaks. Both buttons wait for JSON that reads.
|
||||
#[derive(IntoElement)]
|
||||
pub struct JsonEditor {
|
||||
id: ElementId,
|
||||
editor: Entity<CodeEditor>,
|
||||
}
|
||||
|
||||
impl JsonEditor {
|
||||
/// `editor` holds the text, which the owner keeps.
|
||||
pub fn new(id: impl Into<ElementId>, editor: &Entity<CodeEditor>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
editor: editor.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` indented, or on one line.
|
||||
pub(crate) fn reformat(text: &str, pretty: bool) -> Option<String> {
|
||||
let value: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
let out = if pretty {
|
||||
serde_json::to_string_pretty(&value)
|
||||
} else {
|
||||
serde_json::to_string(&value)
|
||||
};
|
||||
Some(out.expect("a value read from JSON writes back"))
|
||||
}
|
||||
|
||||
impl RenderOnce for JsonEditor {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let text = self.editor.read(cx).text().to_string();
|
||||
let read = read_json(&text);
|
||||
let theme = cx.theme();
|
||||
let status = match &read {
|
||||
Ok(datum) => {
|
||||
let (keys, items) = tally(datum);
|
||||
InlineMessage::new(
|
||||
Severity::Success,
|
||||
format!("Reads as JSON · {keys} keys · {items} items"),
|
||||
)
|
||||
}
|
||||
Err(unread) => InlineMessage::new(Severity::Danger, unread.to_string()),
|
||||
};
|
||||
let button = |label: &'static str, pretty: bool| {
|
||||
let editor = self.editor.clone();
|
||||
Button::new((self.id.clone(), label), label)
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.disabled(read.is_err())
|
||||
.on_click(move |_, _, cx| {
|
||||
let text = editor.read(cx).text().to_string();
|
||||
let Some(out) = reformat(&text, pretty) else {
|
||||
return;
|
||||
};
|
||||
log::info!("json editor: {label}");
|
||||
editor.update(cx, |editor, cx| editor.set_text(out, cx));
|
||||
})
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.child(button("Format", true))
|
||||
.child(button("Minify", false)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.h(theme.list_max_height())
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.overflow_hidden()
|
||||
.child(self.editor),
|
||||
)
|
||||
.child(status)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{reformat, tally};
|
||||
use crate::devtools::datum::read_json;
|
||||
|
||||
#[test]
|
||||
fn a_document_counts_its_keys_and_items_and_reformats_both_ways() {
|
||||
let text = r#"{"a": [1, {"b": 2}], "c": null}"#;
|
||||
assert_eq!(tally(&read_json(text).expect("json")), (3, 2));
|
||||
assert_eq!(
|
||||
reformat(text, false).as_deref(),
|
||||
Some(r#"{"a":[1,{"b":2}],"c":null}"#)
|
||||
);
|
||||
assert_eq!(
|
||||
reformat(r#"{"a":1}"#, true).as_deref(),
|
||||
Some("{\n \"a\": 1\n}")
|
||||
);
|
||||
assert_eq!(reformat("{", true), None);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,24 @@
|
||||
mod connection_form;
|
||||
mod connections;
|
||||
mod datum;
|
||||
mod document;
|
||||
mod json_editor;
|
||||
mod queries;
|
||||
mod redis;
|
||||
mod schema;
|
||||
mod structure;
|
||||
mod xml;
|
||||
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
|
||||
pub use connection_form::{ConnectionForm, TestState};
|
||||
pub use connections::{Connection, ConnectionManager, ConnectionState, Engine};
|
||||
pub use datum::{Datum, Unread, read_json, read_yaml};
|
||||
pub use document::{JsonViewer, YamlViewer};
|
||||
pub use json_editor::JsonEditor;
|
||||
pub use queries::{Outcome, PlanStep, QueryHistory, QueryPlanViewer, QueryRun};
|
||||
pub use redis::{RedisKey, RedisKeyBrowser, RedisValue};
|
||||
pub use schema::{DbTable, ERDiagram, Field, FieldKey, Schema, SchemaTree};
|
||||
pub use structure::{Index, IndexManager, TableStructureEditor};
|
||||
pub use xml::{XmlElement, XmlNode, XmlViewer, read_xml};
|
||||
@@ -0,0 +1,63 @@
|
||||
use gpui::{AnyElement, App, AppContext as _, Entity, IntoElement, TestAppContext, Window, div};
|
||||
|
||||
use super::{Bench, bench, settle, tab, tap};
|
||||
use crate::{
|
||||
devtools::{JsonEditor, JsonViewer},
|
||||
editor::CodeEditor,
|
||||
forms::TextInput,
|
||||
};
|
||||
|
||||
fn viewer(_: &Bench, window: &mut Window, cx: &mut App, _: Entity<Bench>) -> AnyElement {
|
||||
let search = window.use_keyed_state("search", cx, TextInput::new);
|
||||
JsonViewer::new(
|
||||
"viewer",
|
||||
r#"{"spec": {"replicas": 3}, "kind": "Deployment"}"#,
|
||||
&search,
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Stops: the find field, then the tree, whose cursor starts on the root.
|
||||
#[gpui::test]
|
||||
fn enter_on_a_value_copies_its_path(cx: &mut TestAppContext) {
|
||||
let (_, cx) = bench(viewer, cx);
|
||||
tab(2, cx);
|
||||
for key in ["down", "right", "down", "enter"] {
|
||||
tap(key, cx);
|
||||
}
|
||||
let copied = cx
|
||||
.update(|_, cx| cx.read_from_clipboard())
|
||||
.and_then(|item| item.text());
|
||||
assert_eq!(copied.as_deref(), Some("$.spec.replicas"));
|
||||
}
|
||||
|
||||
fn editor(bench: &Bench, _: &mut Window, _: &mut App, _: Entity<Bench>) -> AnyElement {
|
||||
match &bench.editor {
|
||||
Some(editor) => JsonEditor::new("json", editor).into_any_element(),
|
||||
None => div().into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops: Format, then Minify.
|
||||
#[gpui::test]
|
||||
fn format_indents_the_json_and_minify_folds_it(cx: &mut TestAppContext) {
|
||||
let (host, cx) = bench(editor, cx);
|
||||
let editor = cx.update(|window, cx| cx.new(|cx| CodeEditor::new(r#"{"a":[1,2]}"#, window, cx)));
|
||||
host.update(cx, |bench, cx| {
|
||||
bench.editor = Some(editor.clone());
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
tab(1, cx);
|
||||
tap("space", cx);
|
||||
assert_eq!(
|
||||
editor.read_with(cx, |editor, _| editor.text().to_string()),
|
||||
"{\n \"a\": [\n 1,\n 2\n ]\n}"
|
||||
);
|
||||
tab(2, cx);
|
||||
tap("space", cx);
|
||||
assert_eq!(
|
||||
editor.read_with(cx, |editor, _| editor.text().to_string()),
|
||||
r#"{"a":[1,2]}"#
|
||||
);
|
||||
}
|
||||
@@ -4,14 +4,17 @@ use gpui::{
|
||||
};
|
||||
|
||||
use super::{Connection, ConnectionForm, ConnectionManager, Engine, Field, TableStructureEditor};
|
||||
use crate::{forms, primitives::FocusNext, theme::Theme};
|
||||
use crate::{editor::CodeEditor, forms, primitives::FocusNext, theme::Theme};
|
||||
|
||||
mod formats;
|
||||
|
||||
type Part = fn(&Bench, &mut Window, &mut App, Entity<Bench>) -> AnyElement;
|
||||
|
||||
/// A view that shows one tool, keeps the words it heard, and holds a choice among its part's cases.
|
||||
/// A view that shows one tool, keeps the words it heard, and holds what a test hands its part: an editor, and a choice among the part's cases.
|
||||
struct Bench {
|
||||
part: Part,
|
||||
said: Vec<String>,
|
||||
editor: Option<Entity<CodeEditor>>,
|
||||
choice: usize,
|
||||
}
|
||||
|
||||
@@ -41,6 +44,7 @@ fn bench(part: Part, cx: &mut TestAppContext) -> (Entity<Bench>, &mut VisualTest
|
||||
let (host, cx) = cx.add_window_view(|_, _| Bench {
|
||||
part,
|
||||
said: Vec::new(),
|
||||
editor: None,
|
||||
choice: 0,
|
||||
});
|
||||
settle(cx);
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
use gpui::{
|
||||
App, ClipboardItem, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
Styled, Window, div,
|
||||
};
|
||||
|
||||
use super::datum::Unread;
|
||||
use crate::{
|
||||
feedback::InlineMessage,
|
||||
lists::{Tree, TreeNode},
|
||||
primitives::Severity,
|
||||
theme::{ActiveTheme, TextSize},
|
||||
};
|
||||
|
||||
/// A piece of an XML document: an element, or the text between tags.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum XmlNode {
|
||||
Element(XmlElement),
|
||||
Text(SharedString),
|
||||
}
|
||||
|
||||
/// An element: its name, its attributes in order, and what it holds.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct XmlElement {
|
||||
pub name: SharedString,
|
||||
pub attributes: Vec<(SharedString, SharedString)>,
|
||||
pub children: Vec<XmlNode>,
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
text: &'a str,
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
fn line(&self) -> usize {
|
||||
self.text[..self.at].matches('\n').count() + 1
|
||||
}
|
||||
|
||||
fn fail<T>(&self, why: impl Into<String>) -> Result<T, Unread> {
|
||||
Err(Unread {
|
||||
line: self.line(),
|
||||
why: why.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn rest(&self) -> &'a str {
|
||||
&self.text[self.at..]
|
||||
}
|
||||
|
||||
/// Steps past what `open` starts, through `close`.
|
||||
fn skip(&mut self, open: &str, close: &str) -> Result<(), Unread> {
|
||||
match self.rest().find(close) {
|
||||
Some(end) => {
|
||||
self.at += end + close.len();
|
||||
Ok(())
|
||||
}
|
||||
None => self.fail(format!("{open} is never closed by {close}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn name(&mut self) -> SharedString {
|
||||
let rest = self.rest();
|
||||
let end = rest
|
||||
.find(|c: char| c.is_whitespace() || matches!(c, '>' | '/' | '='))
|
||||
.unwrap_or(rest.len());
|
||||
self.at += end;
|
||||
rest[..end].to_string().into()
|
||||
}
|
||||
|
||||
fn spaces(&mut self) {
|
||||
let rest = self.rest();
|
||||
self.at += rest.len() - rest.trim_start().len();
|
||||
}
|
||||
|
||||
fn element(&mut self) -> Result<XmlElement, Unread> {
|
||||
self.at += 1;
|
||||
let name = self.name();
|
||||
if name.is_empty() {
|
||||
return self.fail("a tag has no name");
|
||||
}
|
||||
let mut attributes = Vec::new();
|
||||
loop {
|
||||
self.spaces();
|
||||
if self.rest().starts_with("/>") {
|
||||
self.at += 2;
|
||||
return Ok(XmlElement {
|
||||
name,
|
||||
attributes,
|
||||
children: Vec::new(),
|
||||
});
|
||||
}
|
||||
if self.rest().starts_with('>') {
|
||||
self.at += 1;
|
||||
break;
|
||||
}
|
||||
let key = self.name();
|
||||
self.spaces();
|
||||
if key.is_empty() || !self.rest().starts_with('=') {
|
||||
return self.fail(format!("<{name}> has an attribute without a value"));
|
||||
}
|
||||
self.at += 1;
|
||||
self.spaces();
|
||||
let Some(quote) = self
|
||||
.rest()
|
||||
.chars()
|
||||
.next()
|
||||
.filter(|c| matches!(c, '"' | '\''))
|
||||
else {
|
||||
return self.fail(format!("{key} in <{name}> is not quoted"));
|
||||
};
|
||||
self.at += 1;
|
||||
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()));
|
||||
self.at += end + 1;
|
||||
}
|
||||
let children = self.content()?;
|
||||
if !self.rest().starts_with("</") {
|
||||
return self.fail(format!("<{name}> is never closed"));
|
||||
}
|
||||
self.at += 2;
|
||||
let closing = self.name();
|
||||
self.spaces();
|
||||
if closing != name || !self.rest().starts_with('>') {
|
||||
return self.fail(format!("</{closing}> closes <{name}>"));
|
||||
}
|
||||
self.at += 1;
|
||||
Ok(XmlElement {
|
||||
name,
|
||||
attributes,
|
||||
children,
|
||||
})
|
||||
}
|
||||
|
||||
/// What lies between an element's tags, up to its closing tag or the end.
|
||||
fn content(&mut self) -> Result<Vec<XmlNode>, Unread> {
|
||||
let mut children = Vec::new();
|
||||
while !self.rest().is_empty() && !self.rest().starts_with("</") {
|
||||
let rest = self.rest();
|
||||
if rest.starts_with("<!--") {
|
||||
self.skip("<!--", "-->")?;
|
||||
} else if let Some(data) = rest.strip_prefix("<![CDATA[") {
|
||||
let Some(end) = data.find("]]>") else {
|
||||
return self.fail("a CDATA section is never closed");
|
||||
};
|
||||
children.push(XmlNode::Text(data[..end].to_string().into()));
|
||||
self.at += "<![CDATA[".len() + end + 3;
|
||||
} else if rest.starts_with("<?") || rest.starts_with("<!") {
|
||||
self.skip("<?", ">")?;
|
||||
} else if rest.starts_with('<') {
|
||||
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()));
|
||||
}
|
||||
self.at += end;
|
||||
}
|
||||
}
|
||||
Ok(children)
|
||||
}
|
||||
}
|
||||
|
||||
/// An XML document read into its root element. A tag that closes the wrong element, or is never closed, fails with its line.
|
||||
pub fn read_xml(text: &str) -> Result<XmlElement, Unread> {
|
||||
let mut reader = Reader { text, at: 0 };
|
||||
let mut roots = reader.content()?.into_iter().filter_map(|node| match node {
|
||||
XmlNode::Element(element) => Some(element),
|
||||
XmlNode::Text(_) => None,
|
||||
});
|
||||
if reader.at < text.len() {
|
||||
return reader.fail("a closing tag with nothing open");
|
||||
}
|
||||
match (roots.next(), roots.next()) {
|
||||
(Some(root), None) => Ok(root),
|
||||
(None, _) => Err(Unread {
|
||||
line: 1,
|
||||
why: "the document holds no element".into(),
|
||||
}),
|
||||
(Some(_), Some(second)) => Err(Unread {
|
||||
line: 1,
|
||||
why: format!("a second root, <{}>", second.name),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows for what `element` holds, each element's path listed in `open`.
|
||||
fn nodes(element: &XmlElement, path: &str, open: &mut Vec<SharedString>) -> Vec<TreeNode> {
|
||||
let mut counts: Vec<(SharedString, usize)> = Vec::new();
|
||||
element
|
||||
.children
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, child)| match child {
|
||||
XmlNode::Text(text) => TreeNode::new(format!("{path}/text()[{ix}]"), text.clone()),
|
||||
XmlNode::Element(child) => {
|
||||
let seen = match counts.iter_mut().find(|(name, _)| *name == child.name) {
|
||||
Some((_, count)) => {
|
||||
*count += 1;
|
||||
*count
|
||||
}
|
||||
None => {
|
||||
counts.push((child.name.clone(), 1));
|
||||
1
|
||||
}
|
||||
};
|
||||
let here = format!("{path}/{}[{seen}]", child.name);
|
||||
open.push(here.clone().into());
|
||||
let attributes: Vec<String> = child
|
||||
.attributes
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{key}=\"{value}\""))
|
||||
.collect();
|
||||
TreeNode::new(here.clone(), format!("<{}>", child.name))
|
||||
.note(attributes.join(" "))
|
||||
.children(nodes(child, &here, open))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The copied path, shown under the tree.
|
||||
#[derive(Default)]
|
||||
struct Copied(Option<SharedString>);
|
||||
|
||||
/// XML as a tree of elements, each with its attributes, and the text between tags; comments are dropped. Enter or a double press on a text copies its path. XML that does not read shows the line where it breaks.
|
||||
#[derive(IntoElement)]
|
||||
pub struct XmlViewer {
|
||||
id: ElementId,
|
||||
text: SharedString,
|
||||
}
|
||||
|
||||
impl XmlViewer {
|
||||
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
text: text.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for XmlViewer {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let copied: Entity<Copied> =
|
||||
window.use_keyed_state((self.id.clone(), "copied"), cx, |_, _| Copied::default());
|
||||
let theme = cx.theme();
|
||||
let body = match read_xml(&self.text) {
|
||||
Err(unread) => {
|
||||
InlineMessage::new(Severity::Danger, unread.to_string()).into_any_element()
|
||||
}
|
||||
Ok(root) => {
|
||||
let path = format!("/{}", root.name);
|
||||
let mut open = vec![SharedString::from(path.clone())];
|
||||
let rows = nodes(&root, &path, &mut open);
|
||||
let copy = copied.clone();
|
||||
div()
|
||||
.h(theme.list_max_height())
|
||||
.child(
|
||||
Tree::new(
|
||||
(self.id, "tree"),
|
||||
[TreeNode::new(path.clone(), format!("<{}>", root.name))
|
||||
.children(rows)],
|
||||
)
|
||||
.open(open)
|
||||
.on_activate(move |path, _, cx| {
|
||||
log::info!("xml viewer: copied {path}");
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(path.to_string()));
|
||||
copy.update(cx, |copied, cx| {
|
||||
copied.0 = Some(path.clone());
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.size_full(),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
let note = copied.read(cx).0.clone().map(|path| {
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child(format!("Copied {path}"))
|
||||
});
|
||||
div().flex().flex_col().gap_2().child(body).children(note)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{XmlNode, read_xml};
|
||||
|
||||
#[test]
|
||||
fn elements_attributes_and_text_read_in_order() {
|
||||
let root = read_xml(
|
||||
"<?xml version=\"1.0\"?>\n<!-- feed -->\n<feed lang=\"en\">\n <entry id='1'>First</entry>\n <entry id=\"2\"/>\n</feed>",
|
||||
)
|
||||
.expect("xml");
|
||||
assert_eq!(root.name, "feed");
|
||||
assert_eq!(root.attributes, [("lang".into(), "en".into())]);
|
||||
let XmlNode::Element(first) = &root.children[0] else {
|
||||
panic!("an element")
|
||||
};
|
||||
assert_eq!(first.children, [XmlNode::Text("First".into())]);
|
||||
assert_eq!(root.children.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tag_that_closes_the_wrong_element_says_where() {
|
||||
let unread = read_xml("<a>\n <b>\n </a>\n</b>").expect_err("crossed tags");
|
||||
assert_eq!((unread.line, unread.why.as_str()), (3, "</a> closes <b>"));
|
||||
assert_eq!(
|
||||
read_xml("<a><b></b>").expect_err("unclosed").why,
|
||||
"<a> is never closed"
|
||||
);
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -95,11 +95,11 @@
|
||||
|
||||
## T33b DB & Dev Tools · Data formats
|
||||
|
||||
- [ ] DocumentViewer (MongoDB / JSON) → `devtools::JsonViewer`
|
||||
- [ ] JsonViewer / JsonTree
|
||||
- [ ] JsonEditor
|
||||
- [ ] YamlViewer
|
||||
- [ ] XmlViewer
|
||||
- [x] DocumentViewer (MongoDB / JSON) → `devtools::JsonViewer`
|
||||
- [x] JsonViewer / JsonTree
|
||||
- [x] JsonEditor
|
||||
- [x] YamlViewer
|
||||
- [x] XmlViewer
|
||||
|
||||
## T33c DB & Dev Tools · APIs
|
||||
|
||||
|
||||
Reference in new issue
Block a user