Skip to content

Commit 364954c

Browse files
committed
Complete rewrite of --greppable
1 parent 7717ed7 commit 364954c

6 files changed

Lines changed: 306 additions & 32 deletions

File tree

Cargo.lock

Lines changed: 0 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ path = "src/documentation.rs"
3535
default = ["mimalloc", "from-toml", "from-yaml", "greppable"]
3636
from-toml = ["dep:toml"]
3737
from-yaml = ["dep:serde-saphyr"]
38-
greppable = ["dep:gron", "dep:ressa", "dep:resast"]
38+
greppable = ["dep:ressa", "dep:resast"]
3939

4040
[dependencies]
4141
anyhow = "1"
@@ -48,7 +48,6 @@ mimalloc = { version = ">=0.1.40, <0.2.0", default-features = false, optional =
4848
json5 = "0.4"
4949
toml = { version = "=0.9.8", default-features = false, features = ["parse", "serde", "preserve_order"], optional = true }
5050
serde-saphyr = { version = "=0.0.14", default-features = false, optional = true }
51-
gron = { version = "=0.4.0", optional = true }
5251
ressa = { version = "=0.8.2", optional = true}
5352
resast = { version = "=0.5.0", optional = true }
5453

src/gron.rs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
use resast::prelude::*;
2+
use ressa::Parser;
3+
use serde_json::Value as JsonValue;
4+
use std::fmt::Write;
5+
6+
/// Convert a JSON value to gron format
7+
pub fn json_to_gron(value: &JsonValue) -> String {
8+
let mut output = String::new();
9+
gron_recursive(value, "json", &mut output);
10+
output
11+
}
12+
13+
fn gron_recursive(value: &JsonValue, path: &str, output: &mut String) {
14+
match value {
15+
JsonValue::Null => {
16+
writeln!(output, "{} = null;", path).unwrap();
17+
}
18+
JsonValue::Bool(b) => {
19+
writeln!(output, "{} = {};", path, b).unwrap();
20+
}
21+
JsonValue::Number(n) => {
22+
writeln!(output, "{} = {};", path, n).unwrap();
23+
}
24+
JsonValue::String(s) => {
25+
writeln!(output, "{} = {};", path, escape_string(s)).unwrap();
26+
}
27+
JsonValue::Array(arr) => {
28+
writeln!(output, "{} = [];", path).unwrap();
29+
for (i, item) in arr.iter().enumerate() {
30+
let new_path = format!("{}[{}]", path, i);
31+
gron_recursive(item, &new_path, output);
32+
}
33+
}
34+
JsonValue::Object(obj) => {
35+
writeln!(output, "{} = {{}};", path).unwrap();
36+
for (key, val) in obj.iter() {
37+
let new_path = format_path_segment(path, key);
38+
gron_recursive(val, &new_path, output);
39+
}
40+
}
41+
}
42+
}
43+
44+
/// Check if a string is a valid JavaScript identifier
45+
fn is_valid_identifier(s: &str) -> bool {
46+
if s.is_empty() {
47+
return false;
48+
}
49+
50+
// 1. Quick check: identifiers cannot contain whitespace or newlines
51+
if s.chars().any(|c| c.is_whitespace() || c.is_control()) {
52+
return false;
53+
}
54+
55+
let test_code = format!("json.{}", s);
56+
57+
// If the parser errors, we should escape in quotes
58+
if let Ok(mut parser) = Parser::new(&test_code) {
59+
// This is a good sign, but we need to ensure the entire input was consumed
60+
if let Some(Ok(ProgramPart::Stmt(Stmt::Expr(Expr::Member(_))))) = parser.next() {
61+
// Guard against tokens such as newline
62+
return parser.next().is_none();
63+
}
64+
}
65+
66+
false
67+
}
68+
69+
/// Format a path segment, using dot notation for valid identifiers,
70+
/// bracket notation with quotes for everything else
71+
fn format_path_segment(current_path: &str, key: &str) -> String {
72+
if is_valid_identifier(key) {
73+
format!("{}.{}", current_path, key)
74+
} else {
75+
format!("{}[{}]", current_path, escape_string(key))
76+
}
77+
}
78+
79+
/// Escape a string for use in gron output (both for keys and values)
80+
fn escape_string(s: &str) -> String {
81+
let mut result = String::with_capacity(s.len() + 2);
82+
result.push('"');
83+
84+
for ch in s.chars() {
85+
match ch {
86+
'"' => result.push_str(r#"\""#),
87+
'\\' => result.push_str(r"\\"),
88+
'\n' => result.push_str(r"\n"),
89+
'\r' => result.push_str(r"\r"),
90+
'\t' => result.push_str(r"\t"),
91+
'\x08' => result.push_str(r"\b"),
92+
'\x0C' => result.push_str(r"\f"),
93+
c if c.is_control() => {
94+
write!(result, "\\u{:04x}", c as u32).unwrap();
95+
}
96+
c => result.push(c),
97+
}
98+
}
99+
100+
result.push('"');
101+
result
102+
}
103+
104+
#[cfg(test)]
105+
#[path = "gron_test.rs"]
106+
mod tests;

src/gron_test.rs

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
use super::*;
2+
use serde_json::json;
3+
4+
#[test]
5+
fn test_simple_object() {
6+
let value = json!({
7+
"name": "Get Celq",
8+
"email": "get-celq@proton.me"
9+
});
10+
11+
let result = json_to_gron(&value);
12+
assert!(result.contains(r#"json = {};"#));
13+
assert!(result.contains(r#"json.name = "Get Celq";"#));
14+
assert!(result.contains(r#"json.email = "get-celq@proton.me";"#));
15+
}
16+
17+
#[test]
18+
fn test_nested_object() {
19+
let value = json!({
20+
"commit": {
21+
"author": {
22+
"name": "Get Celq",
23+
"email": "get-celq@proton.me",
24+
"date": "2026-01-01T06:00:00Z"
25+
}
26+
}
27+
});
28+
29+
let result = json_to_gron(&value);
30+
assert!(result.contains(r#"json.commit.author = {};"#));
31+
assert!(result.contains(r#"json.commit.author.name = "Get Celq";"#));
32+
assert!(result.contains(r#"json.commit.author.email = "get-celq@proton.me";"#));
33+
assert!(result.contains(r#"json.commit.author.date = "2026-01-01T06:00:00Z";"#));
34+
}
35+
36+
#[test]
37+
fn test_array() {
38+
let value = json!([
39+
{
40+
"commit": {
41+
"author": {
42+
"name": "Get Celq"
43+
}
44+
}
45+
}
46+
]);
47+
48+
let result = json_to_gron(&value);
49+
assert!(result.contains("json = [];"));
50+
assert!(result.contains(r#"json[0].commit.author.name = "Get Celq";"#));
51+
}
52+
53+
#[test]
54+
fn test_special_keys() {
55+
let value = json!({
56+
"x86_64-unknown-linux-musl": {
57+
"pkg-url": "some-value"
58+
}
59+
});
60+
61+
let result = json_to_gron(&value);
62+
// Keys with hyphens are not valid identifiers, so they need bracket notation
63+
assert!(result.contains(r#"json["x86_64-unknown-linux-musl"]["pkg-url"] = "some-value";"#));
64+
}
65+
66+
#[test]
67+
fn test_primitives() {
68+
let value = json!({
69+
"null_val": null,
70+
"bool_val": true,
71+
"num_val": 42,
72+
"float_val": 3.14
73+
});
74+
75+
let result = json_to_gron(&value);
76+
assert!(result.contains(r#"json.null_val = null;"#));
77+
assert!(result.contains(r#"json.bool_val = true;"#));
78+
assert!(result.contains(r#"json.num_val = 42;"#));
79+
assert!(result.contains(r#"json.float_val = 3.14;"#));
80+
}
81+
82+
#[test]
83+
fn test_string_escaping() {
84+
let value = json!({
85+
"with\"quote": "value with \"quote\"",
86+
"with\nnewline": "value\nwith\nnewline"
87+
});
88+
89+
let result = json_to_gron(&value);
90+
println!("DEBUG: {}", result);
91+
assert!(result.contains(r#"json["with\"quote"] = "value with \"quote\"";"#));
92+
assert!(result.contains(r#"json["with\nnewline"] = "value\nwith\nnewline";"#));
93+
}
94+
95+
#[test]
96+
fn test_your_example() {
97+
let value = json!({
98+
"package": {
99+
"metadata": {
100+
"binstall": {
101+
"overrides": {
102+
"x86_64-unknown-linux-musl": {
103+
"pkg-url": "{ repo }/releases/download/v{ version }/{ name }-linux-x86_64-musl{ archive-suffix }"
104+
}
105+
}
106+
}
107+
}
108+
}
109+
});
110+
111+
let result = json_to_gron(&value);
112+
// "pkg-url" and "x86_64-unknown-linux-musl" have hyphens, so they need brackets
113+
// but package, metadata, binstall, overrides are valid identifiers
114+
assert!(result.contains(
115+
r#"json.package.metadata.binstall.overrides["x86_64-unknown-linux-musl"]["pkg-url"] = "{ repo }/releases/download/v{ version }/{ name }-linux-x86_64-musl{ archive-suffix }";"#
116+
));
117+
}
118+
119+
#[test]
120+
fn test_mixed_notation() {
121+
let value = json!({
122+
"valid_id": {
123+
"also-valid": "value",
124+
"nested": {
125+
"deep-key": "deep-value"
126+
}
127+
},
128+
"123start": "invalid"
129+
});
130+
131+
let result = json_to_gron(&value);
132+
// valid_id is a valid identifier, uses dot
133+
assert!(result.contains(r#"json.valid_id = {};"#));
134+
// also-valid has hyphen, needs brackets
135+
assert!(result.contains(r#"json.valid_id["also-valid"] = "value";"#));
136+
// nested is valid, uses dot
137+
assert!(result.contains(r#"json.valid_id.nested = {};"#));
138+
// deep-key has hyphen, needs brackets
139+
assert!(result.contains(r#"json.valid_id.nested["deep-key"] = "deep-value";"#));
140+
// 123start starts with number but has letters, needs quoted brackets
141+
assert!(result.contains(r#"json["123start"] = "invalid";"#));
142+
}
143+
144+
#[test]
145+
fn test_array_with_objects() {
146+
let value = json!([
147+
{
148+
"123": "starts with number",
149+
"validKey": "valid"
150+
}
151+
]);
152+
153+
let result = json_to_gron(&value);
154+
// Array index is unquoted
155+
assert!(result.contains(r#"json[0] = {};"#));
156+
// Numeric string object key uses quoted bracket notation
157+
assert!(result.contains(r#"json[0]["123"] = "starts with number";"#));
158+
// Valid identifier after array index uses dot
159+
assert!(result.contains(r#"json[0].validKey = "valid";"#));
160+
}
161+
162+
#[test]
163+
fn test_serde_saphyr_example() {
164+
let value = json!({
165+
"dependencies": {
166+
"serde-saphyr": {
167+
"version": "=0.0.14"
168+
}
169+
}
170+
});
171+
172+
let result = json_to_gron(&value);
173+
// Should produce: json.dependencies["serde-saphyr"].version = "=0.0.14";
174+
assert!(result.contains(r#"json.dependencies["serde-saphyr"].version = "=0.0.14";"#));
175+
}
176+
177+
#[test]
178+
fn test_features_array_example() {
179+
let value = json!({
180+
"features": {
181+
"default": [
182+
"from-yaml",
183+
"from-toml"
184+
]
185+
}
186+
});
187+
188+
let result = json_to_gron(&value);
189+
// Array indices are unquoted
190+
assert!(result.contains(r#"json.features.default[0] = "from-yaml";"#));
191+
assert!(result.contains(r#"json.features.default[1] = "from-toml";"#));
192+
}

src/input_handler.rs

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -228,19 +228,8 @@ fn handle_json_output(result: &CelValue, input_params: &InputParameters) -> Resu
228228
if input_params.greppable {
229229
#[cfg(feature = "greppable")]
230230
{
231-
let mut output = Vec::new();
232-
gron::json_to_gron(&mut output, "json", &json_value)
233-
.context("Failed to convert to greppable format")?;
234-
let gron_output = String::from_utf8(output)
235-
.context("Failed to convert greppable output to string")?;
236-
// Add semicolons to each line.
237-
// For some reason gron crate does not add semicolons, but the original gron CLI does.
238-
let with_semicolons = gron_output
239-
.lines()
240-
.map(|line| format!("{};", line))
241-
.collect::<Vec<_>>()
242-
.join("\n");
243-
return Ok(with_semicolons + "\n");
231+
let gron_output = crate::gron::json_to_gron(&json_value);
232+
return Ok(gron_output);
244233
}
245234

246235
#[cfg(not(feature = "greppable"))]

src/main.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ mod ungron;
2727
#[cfg(feature = "greppable")]
2828
pub use ungron::gron_to_json;
2929

30+
#[cfg(feature = "greppable")]
31+
mod gron;
32+
#[cfg(feature = "greppable")]
33+
pub use gron::json_to_gron;
34+
3035
#[cfg(feature = "mimalloc")]
3136
#[global_allocator]
3237
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;

0 commit comments

Comments
 (0)