use std::collections::HashSet;
use std::path::Path;
use anyhow::Result;
use crate::highlight::Highlighter;
const RESET: &str = "\x1b[0m";
const DIM: &str = "\x1b[2m";
pub fn prettify_line(line: &str) -> Result<String> {
let value: serde_json::Value = serde_json::from_str(line)?;
Ok(serde_json::to_string_pretty(&value)?)
}
pub fn is_jsonl_path(path: &Path) -> bool {
matches!(
path.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref(),
Some("jsonl" | "ndjson")
)
}
pub struct RenderedJson {
pub text: String,
pub map: Vec<(usize, usize)>,
}
pub fn render_with_map(
source: &str,
highlighter: &mut Highlighter,
use_color: bool,
) -> Result<RenderedJson> {
let mut rows: Vec<String> = Vec::new();
let mut map: Vec<(usize, usize)> = Vec::new();
for (idx, line) in source.lines().enumerate() {
let source_line = idx + 1;
let row_start = rows.len();
match prettify_line(line) {
Ok(pretty) => {
for pline in pretty.lines() {
let row = if use_color {
let h = highlighter.highlight_line(&format!("{pline}\n"))?;
h.trim_end_matches('\n').to_string()
} else {
pline.to_string()
};
rows.push(row);
}
}
Err(_) => {
let row = if use_color {
format!("{DIM}{line}{RESET}")
} else {
line.to_string()
};
rows.push(row);
}
}
if rows.len() == row_start {
rows.push(String::new());
}
map.push((row_start, source_line));
}
Ok(RenderedJson {
text: rows.join("\n"),
map,
})
}
pub struct RenderedJsonWithGutter {
pub text: String,
pub map: Vec<(usize, usize)>,
pub gutter_width: usize,
}
pub fn render_with_gutter(
source: &str,
line_no_width: usize,
show_numbers: bool,
show_grid: bool,
use_color: bool,
highlighter: &mut Highlighter,
) -> Result<RenderedJsonWithGutter> {
let rendered = render_with_map(source, highlighter, use_color)?;
let gutter_width = (if show_numbers { line_no_width + 1 } else { 0 })
+ (if show_grid { 2 } else { 0 });
if gutter_width == 0 {
return Ok(RenderedJsonWithGutter {
text: rendered.text,
map: rendered.map,
gutter_width: 0,
});
}
let block_starts: HashSet<usize> = rendered.map.iter().map(|(r, _)| *r).collect();
let map = rendered.map.clone();
let rows: Vec<&str> = rendered.text.split('\n').collect();
let last = rows.len().saturating_sub(1);
let mut out = String::with_capacity(rendered.text.len() + rows.len() * gutter_width);
for (idx, row) in rows.iter().enumerate() {
if show_numbers {
if block_starts.contains(&idx) {
let src_line = crate::markdown::source_line_for_rendered(&map, idx);
let label = format!("{:>width$}", src_line, width = line_no_width);
if use_color {
out.push_str(DIM);
out.push_str(&label);
out.push_str(RESET);
} else {
out.push_str(&label);
}
out.push(' ');
} else {
for _ in 0..(line_no_width + 1) {
out.push(' ');
}
}
}
if show_grid {
out.push_str("│ ");
}
out.push_str(row);
if idx < last {
out.push('\n');
}
}
Ok(RenderedJsonWithGutter {
text: out,
map,
gutter_width,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::highlight::{resolve_theme, theme_set, Highlighter};
fn json_highlighter<'a>(
ss: &'a syntect::parsing::SyntaxSet,
ts: &'a syntect::highlighting::ThemeSet,
) -> Highlighter<'a> {
let theme = resolve_theme(ts, None);
let syntax = ss.find_syntax_by_token("json").unwrap();
Highlighter::new(syntax, theme, ss)
}
#[test]
fn prettify_expands_and_preserves_key_order() {
let out = prettify_line(r#"{"b":1,"a":2}"#).unwrap();
assert!(out.contains('\n'), "expected multi-line output: {out:?}");
let b = out.find("\"b\"").unwrap();
let a = out.find("\"a\"").unwrap();
assert!(b < a, "key order not preserved: {out:?}");
assert!(out.contains(" \"b\": 1"), "expected 2-space indent: {out:?}");
}
#[test]
fn prettify_rejects_non_json() {
assert!(prettify_line("not json at all").is_err());
assert!(prettify_line("").is_err());
}
#[test]
fn render_with_map_expands_and_maps() {
let ss = crate::syntax::build_syntax_set().unwrap();
let ts = theme_set();
let mut hl = json_highlighter(&ss, &ts);
let r = render_with_map("{\"a\":1}\nnotjson\n", &mut hl, false).unwrap();
assert_eq!(r.map, vec![(0, 1), (3, 2)], "map: {:?}", r.map);
assert!(r.text.contains(" \"a\": 1"), "expanded object missing: {:?}", r.text);
assert!(r.text.contains("notjson"), "passthrough missing: {:?}", r.text);
}
#[test]
fn render_with_map_dims_malformed_lines() {
let ss = crate::syntax::build_syntax_set().unwrap();
let ts = theme_set();
let mut hl = json_highlighter(&ss, &ts);
let r = render_with_map("notjson\n", &mut hl, true).unwrap();
assert!(r.text.contains("\x1b[2m"), "malformed line should be DIM: {:?}", r.text);
}
#[test]
fn is_jsonl_path_matches() {
use std::path::PathBuf;
assert!(is_jsonl_path(&PathBuf::from("a.jsonl")));
assert!(is_jsonl_path(&PathBuf::from("a.ndjson")));
assert!(is_jsonl_path(&PathBuf::from("A.JSONL")));
assert!(!is_jsonl_path(&PathBuf::from("a.json")));
assert!(!is_jsonl_path(&PathBuf::from("a.txt")));
}
#[test]
fn render_with_gutter_numbers_block_starts() {
let ss = crate::syntax::build_syntax_set().unwrap();
let ts = theme_set();
let mut hl = json_highlighter(&ss, &ts);
let r = render_with_gutter("{\"a\":1}\n{\"b\":2}\n", 4, true, false, false, &mut hl)
.unwrap();
assert_eq!(r.gutter_width, 5); let starts: std::collections::HashSet<usize> =
r.map.iter().map(|(rr, _)| *rr).collect();
for (idx, row) in r.text.split('\n').enumerate() {
let prefix: String = row.chars().take(5).collect();
if starts.contains(&idx) {
assert!(
prefix.trim().chars().any(|c| c.is_ascii_digit()),
"block-start row {idx} missing number; prefix={prefix:?}"
);
} else {
assert!(
prefix.trim().is_empty(),
"continuation row {idx} has unexpected number; prefix={prefix:?}"
);
}
}
}
#[test]
fn render_with_gutter_zero_width_is_passthrough() {
let ss = crate::syntax::build_syntax_set().unwrap();
let ts = theme_set();
let mut hl = json_highlighter(&ss, &ts);
let plain = render_with_map("{\"a\":1}\n", &mut hl, false).unwrap().text;
let mut hl2 = json_highlighter(&ss, &ts);
let r = render_with_gutter("{\"a\":1}\n", 4, false, false, false, &mut hl2).unwrap();
assert_eq!(r.gutter_width, 0);
assert_eq!(r.text, plain);
}
}