diff --git a/.github/workflows/msrv.yml b/.github/workflows/msrv.yml index b486dd89b..15fed7aad 100644 --- a/.github/workflows/msrv.yml +++ b/.github/workflows/msrv.yml @@ -26,6 +26,11 @@ jobs: run: cargo check - uses: dtolnay/rust-toolchain@1.65 + - name: Check jaq-json + working-directory: jaq-json + run: cargo check + + - uses: dtolnay/rust-toolchain@1.66 - name: Check jaq working-directory: jaq run: cargo check diff --git a/Cargo.lock b/Cargo.lock index c304149f5..d17f72583 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,6 +83,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clipboard-win" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" +dependencies = [ + "error-code", +] + [[package]] name = "codesnake" version = "0.2.1" @@ -106,6 +115,27 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + [[package]] name = "dyn-clone" version = "1.0.19" @@ -137,12 +167,29 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -158,10 +205,22 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + [[package]] name = "hashbrown" version = "0.15.2" @@ -236,6 +295,7 @@ name = "jaq" version = "2.2.0" dependencies = [ "codesnake", + "dirs", "env_logger", "hifijson", "is-terminal", @@ -245,6 +305,7 @@ dependencies = [ "log", "memmap2", "mimalloc", + "rustyline", "tempfile", "unicode-width", "yansi", @@ -282,7 +343,7 @@ dependencies = [ "aho-corasick", "codesnake", "console_log", - "getrandom", + "getrandom 0.2.16", "hifijson", "jaq-core", "jaq-json", @@ -343,11 +404,21 @@ dependencies = [ "libc", ] +[[package]] +name = "libredox" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "linux-raw-sys" -version = "0.4.15" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] name = "log" @@ -379,6 +450,17 @@ dependencies = [ "libmimalloc-sys", ] +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -394,6 +476,12 @@ version = "1.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "proc-macro2" version = "1.0.95" @@ -412,6 +500,23 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror", +] + [[package]] name = "regex-lite" version = "0.1.6" @@ -420,9 +525,9 @@ checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" [[package]] name = "rustix" -version = "0.38.44" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" dependencies = [ "bitflags", "errno", @@ -437,6 +542,26 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +[[package]] +name = "rustyline" +version = "13.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a2d683a4ac90aeef5b1013933f6d977bd37d51ff3f4dad829d4931a7e6be86" +dependencies = [ + "bitflags", + "cfg-if", + "clipboard-win", + "fd-lock", + "libc", + "log", + "memchr", + "nix", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "winapi", +] + [[package]] name = "ryu" version = "1.0.20" @@ -494,18 +619,37 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.15.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ - "cfg-if", "fastrand", - "getrandom", + "getrandom 0.3.3", "once_cell", "rustix", "windows-sys", ] +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "typed-arena" version = "2.0.2" @@ -518,6 +662,12 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.1.13" @@ -530,12 +680,27 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -604,6 +769,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.61.0" @@ -736,6 +923,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/README.md b/README.md index e2e41d74d..71359d2b3 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ![Build status](https://github.com/01mf02/jaq/actions/workflows/check.yml/badge.svg) [![Crates.io](https://img.shields.io/crates/v/jaq-core.svg)](https://crates.io/crates/jaq-core) [![Documentation](https://docs.rs/jaq-core/badge.svg)](https://docs.rs/jaq-core) -[![Rust 1.65+](https://img.shields.io/badge/rust-1.65+-orange.svg)](https://www.rust-lang.org) +[![Rust 1.66+](https://img.shields.io/badge/rust-1.66+-orange.svg)](https://www.rust-lang.org) jaq (pronounced /ʒaːk/, like *Jacques*[^jacques]) is a clone of the JSON data processing tool [jq]. jaq aims to support a large subset of jq's syntax and operations. diff --git a/jaq/Cargo.toml b/jaq/Cargo.toml index e62b2fe26..e4110f7ee 100644 --- a/jaq/Cargo.toml +++ b/jaq/Cargo.toml @@ -9,7 +9,7 @@ description = "Just another JSON query tool" repository = "https://github.com/01mf02/jaq" keywords = ["json", "query", "jq"] categories = ["command-line-utilities", "compilers", "parser-implementations"] -rust-version = "1.65" +rust-version = "1.66" [features] default = ["mimalloc"] @@ -20,12 +20,14 @@ jaq-std = { version = "2.1.0", path = "../jaq-std" } jaq-json = { version = "1.1.1", path = "../jaq-json" } codesnake = { version = "0.2" } +dirs = { version = "6.0" } env_logger = { version = "0.10.0", default-features = false } hifijson = "0.2.0" is-terminal = "0.4.13" log = { version = "0.4.17" } memmap2 = "0.9" mimalloc = { version = "0.1.29", default-features = false, optional = true } +rustyline = { version = "13.0.0", default-features = false, features = ["with-file-history"] } tempfile = "3.3.0" unicode-width = "0.1.13" yansi = "1.0.1" diff --git a/jaq/src/filter.rs b/jaq/src/filter.rs new file mode 100644 index 000000000..f172b2d50 --- /dev/null +++ b/jaq/src/filter.rs @@ -0,0 +1,236 @@ +//! Filter parsing, compilation, and execution. +use crate::{read, repl, Cli, Error, Val}; +use core::fmt::{self, Display, Formatter}; +use jaq_core::{compile, load, Ctx, Native, RcIter, ValT}; +use std::{io, path::PathBuf}; + +pub type Filter = jaq_core::Filter>; + +pub fn parse_compile( + path: &PathBuf, + code: &str, + vars: &[String], + paths: &[PathBuf], +) -> Result<(Vec, Filter), Vec> { + use compile::Compiler; + use load::{import, Arena, File, Loader}; + + let default = ["~/.jq", "$ORIGIN/../lib/jq", "$ORIGIN/../lib"].map(|x| x.into()); + let paths = if paths.is_empty() { &default } else { paths }; + + let vars: Vec<_> = vars.iter().map(|v| format!("${v}")).collect(); + let arena = Arena::default(); + let defs = jaq_std::defs().chain(jaq_json::defs()); + let loader = Loader::new(defs).with_std_read(paths); + //let loader = Loader::new([]).with_std_read(paths); + let path = path.into(); + let modules = loader + .load(&arena, File { path, code }) + .map_err(load_errors)?; + + let mut vals = Vec::new(); + import(&modules, |p| { + let path = p.find(paths, "json")?; + vals.push(read::json_array(path).map_err(|e| e.to_string())?); + Ok(()) + }) + .map_err(load_errors)?; + + let funs = jaq_std::funs().chain(jaq_json::funs()).chain([repl::fun()]); + let compiler = Compiler::default() + .with_funs(funs) + .with_global_vars(vars.iter().map(|v| &**v)); + let filter = compiler.compile(modules).map_err(compile_errors)?; + Ok((vals, filter)) +} + +/// Run a filter with given input values and run `f` for every value output. +/// +/// This function cannot return an `Iterator` because it creates an `RcIter`. +/// This is most unfortunate. We should think about how to simplify this ... +pub(crate) fn run( + cli: &Cli, + filter: &Filter, + vars: Vec, + iter: impl Iterator>, + mut f: impl FnMut(Val) -> io::Result<()>, +) -> Result, Error> { + let mut last = None; + let iter = iter.map(|r| r.map_err(|e| e.to_string())); + + let iter = Box::new(iter) as Box>; + let null = Box::new(core::iter::once(Ok(Val::Null))) as Box>; + + let iter = RcIter::new(iter); + let null = RcIter::new(null); + + let ctx = Ctx::new(vars, &iter); + + for item in if cli.null_input { &null } else { &iter } { + let input = item.map_err(Error::Parse)?; + //println!("Got {:?}", input); + for output in filter.run((ctx.clone(), input)) { + let output = output.map_err(Error::Jaq)?; + last = Some(output.as_bool()); + f(output)?; + } + } + Ok(last) +} + +#[derive(Debug)] +pub struct FileReports(load::File, Vec); + +impl Display for FileReports { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + let Self(file, reports) = self; + let idx = codesnake::LineIndex::new(&file.code); + reports.iter().try_for_each(|e| { + writeln!(f, "Error: {}", e.message)?; + let block = e.to_block(&idx); + writeln!(f, "{}[{}]", block.prologue(), file.path.display())?; + writeln!(f, "{}{}", block, block.epilogue()) + }) + } +} + +fn load_errors(errs: load::Errors<&str, PathBuf>) -> Vec { + use load::Error; + + let errs = errs.into_iter().map(|(file, err)| { + let code = file.code; + let err = match err { + Error::Io(errs) => errs.into_iter().map(|e| report_io(code, e)).collect(), + Error::Lex(errs) => errs.into_iter().map(|e| report_lex(code, e)).collect(), + Error::Parse(errs) => errs.into_iter().map(|e| report_parse(code, e)).collect(), + }; + FileReports(file.map_code(|s| s.into()), err) + }); + errs.collect() +} + +fn compile_errors(errs: compile::Errors<&str, PathBuf>) -> Vec { + let errs = errs.into_iter().map(|(file, errs)| { + let code = file.code; + let errs = errs.into_iter().map(|e| report_compile(code, e)).collect(); + FileReports(file.map_code(|s| s.into()), errs) + }); + errs.collect() +} + +type StringColors = Vec<(String, Option)>; + +#[derive(Debug)] +struct Report { + message: String, + labels: Vec<(core::ops::Range, StringColors, Color)>, +} + +#[derive(Clone, Debug)] +enum Color { + Yellow, + Red, +} + +impl Color { + fn apply(&self, d: impl Display) -> String { + use yansi::{Color, Paint}; + let color = match self { + Self::Yellow => Color::Yellow, + Self::Red => Color::Red, + }; + d.fg(color).to_string() + } +} + +fn report_io(code: &str, (path, error): (&str, String)) -> Report { + let path_range = load::span(code, path); + Report { + message: format!("could not load file {}: {}", path, error), + labels: [(path_range, [(error, None)].into(), Color::Red)].into(), + } +} + +fn report_lex(code: &str, (expected, found): load::lex::Error<&str>) -> Report { + // truncate found string to its first character + let found = &found[..found.char_indices().nth(1).map_or(found.len(), |(i, _)| i)]; + + let found_range = load::span(code, found); + let found = match found { + "" => [("unexpected end of input".to_string(), None)].into(), + c => [("unexpected character ", None), (c, Some(Color::Red))] + .map(|(s, c)| (s.into(), c)) + .into(), + }; + let label = (found_range, found, Color::Red); + + let labels = match expected { + load::lex::Expect::Delim(open) => { + let text = [("unclosed delimiter ", None), (open, Some(Color::Yellow))] + .map(|(s, c)| (s.into(), c)); + Vec::from([(load::span(code, open), text.into(), Color::Yellow), label]) + } + _ => Vec::from([label]), + }; + + Report { + message: format!("expected {}", expected.as_str()), + labels, + } +} + +fn report_parse(code: &str, (expected, found): load::parse::Error<&str>) -> Report { + let found_range = load::span(code, found); + + let found = if found.is_empty() { + "unexpected end of input" + } else { + "unexpected token" + }; + let found = [(found.to_string(), None)].into(); + + Report { + message: format!("expected {}", expected.as_str()), + labels: Vec::from([(found_range, found, Color::Red)]), + } +} + +fn report_compile(code: &str, (found, undefined): compile::Error<&str>) -> Report { + use compile::Undefined::Filter; + let found_range = load::span(code, found); + let wnoa = |exp, got| format!("wrong number of arguments (expected {exp}, found {got})"); + let message = match (found, undefined) { + ("reduce", Filter(arity)) => wnoa("2", arity), + ("foreach", Filter(arity)) => wnoa("2 or 3", arity), + (_, undefined) => format!("undefined {}", undefined.as_str()), + }; + let found = [(message.clone(), None)].into(); + + Report { + message, + labels: Vec::from([(found_range, found, Color::Red)]), + } +} + +type CodeBlock = codesnake::Block, String>; + +impl Report { + fn to_block(&self, idx: &codesnake::LineIndex) -> CodeBlock { + use codesnake::{Block, CodeWidth, Label}; + let color_maybe = |(text, color): (_, Option)| match color { + None => text, + Some(color) => color.apply(text).to_string(), + }; + let labels = self.labels.iter().cloned().map(|(range, text, color)| { + let text = text.into_iter().map(color_maybe).collect::>(); + Label::new(range) + .with_text(text.join("")) + .with_style(move |s| color.apply(s).to_string()) + }); + Block::new(idx, labels).unwrap().map_code(|c| { + let c = c.replace('\t', " "); + let w = unicode_width::UnicodeWidthStr::width(&*c); + CodeWidth::new(c, core::cmp::max(w, 1)) + }) + } +} diff --git a/jaq/src/main.rs b/jaq/src/main.rs index 865694d69..5bafed3ca 100644 --- a/jaq/src/main.rs +++ b/jaq/src/main.rs @@ -1,15 +1,19 @@ mod cli; +mod filter; +mod read; +mod repl; +mod write; use cli::Cli; use core::fmt::{self, Display, Formatter}; +use filter::{run, FileReports, Filter}; use is_terminal::IsTerminal; -use jaq_core::{compile, load, Ctx, Native, RcIter, ValT}; +use jaq_core::{load, Ctx, RcIter}; use jaq_json::Val; use std::io::{self, BufRead, Write}; use std::path::{Path, PathBuf}; use std::process::{ExitCode, Termination}; - -type Filter = jaq_core::Filter>; +use write::{print, with_stdout}; #[cfg(feature = "mimalloc")] #[global_allocator] @@ -17,7 +21,7 @@ static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; fn main() -> ExitCode { use env_logger::Env; - env_logger::Builder::from_env(Env::default().filter_or("LOG", "debug")) + env_logger::Builder::from_env(Env::default().filter_or("LOG", "jaq=debug")) .format(|buf, record| match record.level() { // format error messages (yielded by `stderr`) without newline log::Level::Error => write!(buf, "{}", record.args()), @@ -57,6 +61,7 @@ fn main() -> ExitCode { Ok(exit) => exit, Err(e) => { set_color(cli.color_if(|| std::io::stderr().is_terminal() && !no_color)); + eprint!("{e}"); e.report() } } @@ -79,22 +84,22 @@ fn real_main(cli: &Cli) -> Result { cli::Filter::FromFile(path) => (path.into(), std::fs::read_to_string(path)?), cli::Filter::Inline(filter) => ("".into(), filter.clone()), }; - parse(&path, &code, &vars, &cli.library_path).map_err(Error::Report)? + filter::parse_compile(&path, &code, &vars, &cli.library_path).map_err(Error::Report)? } }; ctx.extend(vals); //println!("Filter: {:?}", filter); let last = if cli.files.is_empty() { - let inputs = read_buffered(cli, io::stdin().lock()); + let inputs = read::buffered(cli, io::stdin().lock()); with_stdout(|out| run(cli, &filter, ctx, inputs, |v| print(out, cli, &v)))? } else { let mut last = None; for file in &cli.files { let path = Path::new(file); - let file = - load_file(path).map_err(|e| Error::Io(Some(path.display().to_string()), e))?; - let inputs = read_slice(cli, &file); + let file = read::load_file(path) + .map_err(|e| Error::Io(Some(path.display().to_string()), e))?; + let inputs = read::slice(cli, &file); if cli.in_place { // create a temporary file where output is written to let location = path.parent().unwrap(); @@ -145,7 +150,7 @@ fn binds(cli: &Cli) -> Result, Error> { Ok((k.to_owned(), Val::Str(s?.into()))) }); let slurpfile = cli.slurpfile.iter().map(|(k, path)| { - let a = json_array(path).map_err(|e| Error::Io(Some(format!("{path:?}")), e)); + let a = read::json_array(path).map_err(|e| Error::Io(Some(format!("{path:?}")), e)); Ok((k.to_owned(), a?)) }); @@ -173,146 +178,6 @@ fn args(positional: &[Val], named: &[(String, Val)]) -> Val { Val::obj(obj.into_iter().collect()) } -fn parse( - path: &PathBuf, - code: &str, - vars: &[String], - paths: &[PathBuf], -) -> Result<(Vec, Filter), Vec> { - use compile::Compiler; - use load::{import, Arena, File, Loader}; - - let default = ["~/.jq", "$ORIGIN/../lib/jq", "$ORIGIN/../lib"].map(|x| x.into()); - let paths = if paths.is_empty() { &default } else { paths }; - - let vars: Vec<_> = vars.iter().map(|v| format!("${v}")).collect(); - let arena = Arena::default(); - let loader = Loader::new(jaq_std::defs().chain(jaq_json::defs())).with_std_read(paths); - //let loader = Loader::new([]).with_std_read(paths); - let path = path.into(); - let modules = loader - .load(&arena, File { path, code }) - .map_err(load_errors)?; - - let mut vals = Vec::new(); - import(&modules, |p| { - let path = p.find(paths, "json")?; - vals.push(json_array(path).map_err(|e| e.to_string())?); - Ok(()) - }) - .map_err(load_errors)?; - - let compiler = Compiler::default() - .with_funs(jaq_std::funs().chain(jaq_json::funs())) - .with_global_vars(vars.iter().map(|v| &**v)); - let filter = compiler.compile(modules).map_err(compile_errors)?; - Ok((vals, filter)) -} - -fn load_errors(errs: load::Errors<&str, PathBuf>) -> Vec { - use load::Error; - - let errs = errs.into_iter().map(|(file, err)| { - let code = file.code; - let err = match err { - Error::Io(errs) => errs.into_iter().map(|e| report_io(code, e)).collect(), - Error::Lex(errs) => errs.into_iter().map(|e| report_lex(code, e)).collect(), - Error::Parse(errs) => errs.into_iter().map(|e| report_parse(code, e)).collect(), - }; - (file.map_code(|s| s.into()), err) - }); - errs.collect() -} - -fn compile_errors(errs: compile::Errors<&str, PathBuf>) -> Vec { - let errs = errs.into_iter().map(|(file, errs)| { - let code = file.code; - let errs = errs.into_iter().map(|e| report_compile(code, e)).collect(); - (file.map_code(|s| s.into()), errs) - }); - errs.collect() -} - -/// Try to load file by memory mapping and fall back to regular loading if it fails. -fn load_file(path: impl AsRef) -> io::Result>> { - let file = std::fs::File::open(path.as_ref())?; - match unsafe { memmap2::Mmap::map(&file) } { - Ok(mmap) => Ok(Box::new(mmap)), - Err(_) => Ok(Box::new(std::fs::read(path)?)), - } -} - -fn invalid_data(e: impl std::error::Error + Send + Sync + 'static) -> std::io::Error { - io::Error::new(io::ErrorKind::InvalidData, e) -} - -fn json_slice(slice: &[u8]) -> impl Iterator> + '_ { - let mut lexer = hifijson::SliceLexer::new(slice); - core::iter::from_fn(move || { - use hifijson::token::Lex; - Some(Val::parse(lexer.ws_token()?, &mut lexer).map_err(invalid_data)) - }) -} - -fn json_read<'a>(read: impl BufRead + 'a) -> impl Iterator> + 'a { - let mut lexer = hifijson::IterLexer::new(read.bytes()); - core::iter::from_fn(move || { - use hifijson::token::Lex; - let v = Val::parse(lexer.ws_token()?, &mut lexer); - Some(v.map_err(|e| core::mem::take(&mut lexer.error).unwrap_or_else(|| invalid_data(e)))) - }) -} - -fn json_array(path: impl AsRef) -> io::Result { - json_slice(&load_file(path.as_ref())?).collect() -} - -fn read_buffered<'a, R>(cli: &Cli, read: R) -> Box> + 'a> -where - R: BufRead + 'a, -{ - if cli.raw_input { - Box::new(raw_input(cli.slurp, read).map(|r| r.map(Val::from))) - } else { - Box::new(collect_if(cli.slurp, json_read(read))) - } -} - -fn read_slice<'a>(cli: &Cli, slice: &'a [u8]) -> Box> + 'a> { - if cli.raw_input { - let read = io::BufReader::new(slice); - Box::new(raw_input(cli.slurp, read).map(|r| r.map(Val::from))) - } else { - Box::new(collect_if(cli.slurp, json_slice(slice))) - } -} - -fn raw_input<'a, R>(slurp: bool, mut read: R) -> impl Iterator> + 'a -where - R: BufRead + 'a, -{ - if slurp { - let mut buf = String::new(); - let s = read.read_to_string(&mut buf).map(|_| buf); - Box::new(std::iter::once(s)) - } else { - Box::new(read.lines()) as Box> - } -} - -fn collect_if<'a, T: FromIterator + 'a, E: 'a>( - slurp: bool, - iter: impl Iterator> + 'a, -) -> Box> + 'a> { - if slurp { - Box::new(core::iter::once(iter.collect())) - } else { - Box::new(iter) - } -} - -type FileReports = (load::File, Vec); - #[derive(Debug)] enum Error { Io(Option, io::Error), @@ -324,328 +189,48 @@ enum Error { NoOutput, } -impl Termination for Error { - fn report(self) -> ExitCode { - let exit = match self { - Self::FalseOrNull => 1, +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self { + Self::FalseOrNull | Self::NoOutput => Ok(()), Self::Io(prefix, e) => { - eprint!("Error: "); + write!(f, "Error: ")?; if let Some(p) = prefix { - eprint!("{p}: "); + write!(f, "{p}: ")?; } - eprintln!("{e}"); - 2 + writeln!(f, "{e}") } Self::Persist(e) => { - eprintln!("Error: {e}"); - 2 + writeln!(f, "Error: {e}") } - Self::Report(file_reports) => { - for (file, reports) in file_reports { - let idx = codesnake::LineIndex::new(&file.code); - for e in reports { - eprintln!("Error: {}", e.message); - let block = e.into_block(&idx); - eprintln!("{}[{}]", block.prologue(), file.path.display()); - eprintln!("{}{}", block, block.epilogue()) - } - } - 3 - } - Self::NoOutput => 4, - Self::Parse(e) => { - eprintln!("Error: failed to parse: {e}"); - 5 - } - Self::Jaq(e) => { - eprintln!("Error: {e}"); - 5 - } - }; - ExitCode::from(exit) - } -} - -impl From for Error { - fn from(e: io::Error) -> Self { - Self::Io(None, e) - } -} - -/// Run a filter with given input values and run `f` for every value output. -/// -/// This function cannot return an `Iterator` because it creates an `RcIter`. -/// This is most unfortunate. We should think about how to simplify this ... -fn run( - cli: &Cli, - filter: &Filter, - vars: Vec, - iter: impl Iterator>, - mut f: impl FnMut(Val) -> io::Result<()>, -) -> Result, Error> { - let mut last = None; - let iter = iter.map(|r| r.map_err(|e| e.to_string())); - - let iter = Box::new(iter) as Box>; - let null = Box::new(core::iter::once(Ok(Val::Null))) as Box>; - - let iter = RcIter::new(iter); - let null = RcIter::new(null); - - let ctx = Ctx::new(vars, &iter); - - for item in if cli.null_input { &null } else { &iter } { - let input = item.map_err(Error::Parse)?; - //println!("Got {:?}", input); - for output in filter.run((ctx.clone(), input)) { - let output = output.map_err(Error::Jaq)?; - last = Some(output.as_bool()); - f(output)?; + Self::Report(reports) => reports.iter().try_for_each(|fr| write!(f, "{fr}")), + Self::Parse(e) => writeln!(f, "Error: failed to parse: {e}"), + Self::Jaq(e) => writeln!(f, "Error: {e}"), } } - Ok(last) } -struct FormatterFn(F); - -impl fmt::Result> Display for FormatterFn { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - self.0(f) - } -} - -struct PpOpts { - compact: bool, - indent: String, - sort_keys: bool, -} - -impl PpOpts { - fn indent(&self, f: &mut Formatter, level: usize) -> fmt::Result { - if !self.compact { - write!(f, "{}", self.indent.repeat(level))?; - } - Ok(()) - } - - fn newline(&self, f: &mut Formatter) -> fmt::Result { - if !self.compact { - writeln!(f)?; - } - Ok(()) - } -} - -fn fmt_seq(fmt: &mut Formatter, opts: &PpOpts, level: usize, xs: I, f: F) -> fmt::Result -where - I: IntoIterator, - F: Fn(&mut Formatter, T) -> fmt::Result, -{ - opts.newline(fmt)?; - let mut iter = xs.into_iter().peekable(); - while let Some(x) = iter.next() { - opts.indent(fmt, level + 1)?; - f(fmt, x)?; - if iter.peek().is_some() { - write!(fmt, ",")?; - } - opts.newline(fmt)?; - } - opts.indent(fmt, level) -} - -fn fmt_val(f: &mut Formatter, opts: &PpOpts, level: usize, v: &Val) -> fmt::Result { - use yansi::Paint; - match v { - Val::Null | Val::Bool(_) | Val::Int(_) | Val::Float(_) | Val::Num(_) => v.fmt(f), - Val::Str(_) => write!(f, "{}", v.green()), - Val::Arr(a) => { - '['.bold().fmt(f)?; - if !a.is_empty() { - fmt_seq(f, opts, level, &**a, |f, x| fmt_val(f, opts, level + 1, x))?; - } - ']'.bold().fmt(f) - } - Val::Obj(o) => { - '{'.bold().fmt(f)?; - let kv = |f: &mut Formatter, (k, val): (&std::rc::Rc, &Val)| { - write!(f, "{}:", Val::Str(k.clone()).bold())?; - if !opts.compact { - write!(f, " ")?; - } - fmt_val(f, opts, level + 1, val) - }; - if !o.is_empty() { - if opts.sort_keys { - let mut o: Vec<_> = o.iter().collect(); - o.sort_by_key(|(k, _v)| *k); - fmt_seq(f, opts, level, o, kv) - } else { - fmt_seq(f, opts, level, &**o, kv) - }? - } - '}'.bold().fmt(f) - } - } -} - -fn print(w: &mut (impl Write + ?Sized), cli: &Cli, val: &Val) -> io::Result<()> { - let f = |f: &mut Formatter| { - let opts = PpOpts { - compact: cli.compact_output, - indent: if cli.tab { - String::from("\t") - } else { - " ".repeat(cli.indent) - }, - sort_keys: cli.sort_keys, - }; - fmt_val(f, &opts, 0, val) - }; - - match val { - Val::Str(s) if cli.raw_output || cli.join_output => write!(w, "{s}")?, - _ => write!(w, "{}", FormatterFn(f))?, - }; - - if cli.join_output { - // when running `jaq -jn '"prompt> " | (., input)'`, - // this flush is necessary to make "prompt> " appear first - w.flush() - } else { - // this also flushes output, because stdout is line-buffered in Rust - writeln!(w) - } -} - -fn with_stdout(f: impl FnOnce(&mut dyn Write) -> T) -> T { - let stdout = io::stdout(); - if stdout.is_terminal() { - f(&mut stdout.lock()) - } else { - f(&mut io::BufWriter::new(stdout.lock())) - } -} - -type StringColors = Vec<(String, Option)>; - -#[derive(Debug)] -struct Report { - message: String, - labels: Vec<(core::ops::Range, StringColors, Color)>, -} - -#[derive(Clone, Debug)] -enum Color { - Yellow, - Red, -} - -impl Color { - fn apply(&self, d: impl Display) -> String { - use yansi::{Color, Paint}; - let color = match self { - Self::Yellow => Color::Yellow, - Self::Red => Color::Red, - }; - d.fg(color).to_string() - } -} - -fn report_io(code: &str, (path, error): (&str, String)) -> Report { - let path_range = load::span(code, path); - Report { - message: format!("could not load file {}: {}", path, error), - labels: [(path_range, [(error, None)].into(), Color::Red)].into(), - } -} - -fn report_lex(code: &str, (expected, found): load::lex::Error<&str>) -> Report { - // truncate found string to its first character - let found = &found[..found.char_indices().nth(1).map_or(found.len(), |(i, _)| i)]; - - let found_range = load::span(code, found); - let found = match found { - "" => [("unexpected end of input".to_string(), None)].into(), - c => [("unexpected character ", None), (c, Some(Color::Red))] - .map(|(s, c)| (s.into(), c)) - .into(), - }; - let label = (found_range, found, Color::Red); - - let labels = match expected { - load::lex::Expect::Delim(open) => { - let text = [("unclosed delimiter ", None), (open, Some(Color::Yellow))] - .map(|(s, c)| (s.into(), c)); - Vec::from([(load::span(code, open), text.into(), Color::Yellow), label]) - } - _ => Vec::from([label]), - }; - - Report { - message: format!("expected {}", expected.as_str()), - labels, - } -} - -fn report_parse(code: &str, (expected, found): load::parse::Error<&str>) -> Report { - let found_range = load::span(code, found); - - let found = if found.is_empty() { - "unexpected end of input" - } else { - "unexpected token" - }; - let found = [(found.to_string(), None)].into(); - - Report { - message: format!("expected {}", expected.as_str()), - labels: Vec::from([(found_range, found, Color::Red)]), - } -} - -fn report_compile(code: &str, (found, undefined): compile::Error<&str>) -> Report { - use compile::Undefined::Filter; - let found_range = load::span(code, found); - let wnoa = |exp, got| format!("wrong number of arguments (expected {exp}, found {got})"); - let message = match (found, undefined) { - ("reduce", Filter(arity)) => wnoa("2", arity), - ("foreach", Filter(arity)) => wnoa("2 or 3", arity), - (_, undefined) => format!("undefined {}", undefined.as_str()), - }; - let found = [(message.clone(), None)].into(); - - Report { - message, - labels: Vec::from([(found_range, found, Color::Red)]), +impl Termination for Error { + fn report(self) -> ExitCode { + ExitCode::from(match self { + Self::FalseOrNull => 1, + Self::Io(_, _) | Self::Persist(_) => 2, + Self::Report(_) => 3, + Self::NoOutput => 4, + Self::Parse(_) | Self::Jaq(_) => 5, + }) } } -type CodeBlock = codesnake::Block, String>; - -impl Report { - fn into_block(self, idx: &codesnake::LineIndex) -> CodeBlock { - use codesnake::{Block, CodeWidth, Label}; - let color_maybe = |(text, color): (_, Option)| match color { - None => text, - Some(color) => color.apply(text).to_string(), - }; - let labels = self.labels.into_iter().map(|(range, text, color)| { - let text = text.into_iter().map(color_maybe).collect::>(); - Label::new(range) - .with_text(text.join("")) - .with_style(move |s| color.apply(s).to_string()) - }); - Block::new(idx, labels).unwrap().map_code(|c| { - let c = c.replace('\t', " "); - let w = unicode_width::UnicodeWidthStr::width(&*c); - CodeWidth::new(c, core::cmp::max(w, 1)) - }) +impl From for Error { + fn from(e: io::Error) -> Self { + Self::Io(None, e) } } fn run_test(test: load::test::Test) -> Result<(Val, Val), Error> { - let (ctx, filter) = parse(&PathBuf::new(), &test.filter, &[], &[]).map_err(Error::Report)?; + let (ctx, filter) = + filter::parse_compile(&PathBuf::new(), &test.filter, &[], &[]).map_err(Error::Report)?; let inputs = RcIter::new(Box::new(core::iter::empty())); let ctx = Ctx::new(ctx, &inputs); @@ -654,7 +239,7 @@ fn run_test(test: load::test::Test) -> Result<(Val, Val), Error> { use hifijson::token::Lex; hifijson::SliceLexer::new(s.as_bytes()) .exactly_one(Val::parse) - .map_err(invalid_data) + .map_err(read::invalid_data) }; let input = json(test.input)?; let expect: Result = test.output.into_iter().map(json).collect(); diff --git a/jaq/src/read.rs b/jaq/src/read.rs new file mode 100644 index 000000000..4bddd4192 --- /dev/null +++ b/jaq/src/read.rs @@ -0,0 +1,81 @@ +use crate::{Cli, Val}; +use std::io::{self, BufRead}; +use std::path::Path; + +/// Try to load file by memory mapping and fall back to regular loading if it fails. +pub fn load_file(path: impl AsRef) -> io::Result>> { + let file = std::fs::File::open(path.as_ref())?; + match unsafe { memmap2::Mmap::map(&file) } { + Ok(mmap) => Ok(Box::new(mmap)), + Err(_) => Ok(Box::new(std::fs::read(path)?)), + } +} + +pub fn invalid_data(e: impl std::error::Error + Send + Sync + 'static) -> std::io::Error { + io::Error::new(io::ErrorKind::InvalidData, e) +} + +fn json_slice(slice: &[u8]) -> impl Iterator> + '_ { + let mut lexer = hifijson::SliceLexer::new(slice); + core::iter::from_fn(move || { + use hifijson::token::Lex; + Some(Val::parse(lexer.ws_token()?, &mut lexer).map_err(invalid_data)) + }) +} + +fn json_read<'a>(read: impl BufRead + 'a) -> impl Iterator> + 'a { + let mut lexer = hifijson::IterLexer::new(read.bytes()); + core::iter::from_fn(move || { + use hifijson::token::Lex; + let v = Val::parse(lexer.ws_token()?, &mut lexer); + Some(v.map_err(|e| core::mem::take(&mut lexer.error).unwrap_or_else(|| invalid_data(e)))) + }) +} + +pub fn json_array(path: impl AsRef) -> io::Result { + json_slice(&load_file(path.as_ref())?).collect() +} + +pub fn buffered<'a, R>(cli: &Cli, read: R) -> Box> + 'a> +where + R: BufRead + 'a, +{ + if cli.raw_input { + Box::new(raw_input(cli.slurp, read).map(|r| r.map(Val::from))) + } else { + Box::new(collect_if(cli.slurp, json_read(read))) + } +} + +pub fn slice<'a>(cli: &Cli, slice: &'a [u8]) -> Box> + 'a> { + if cli.raw_input { + let read = io::BufReader::new(slice); + Box::new(raw_input(cli.slurp, read).map(|r| r.map(Val::from))) + } else { + Box::new(collect_if(cli.slurp, json_slice(slice))) + } +} + +fn raw_input<'a, R>(slurp: bool, mut read: R) -> impl Iterator> + 'a +where + R: BufRead + 'a, +{ + if slurp { + let mut buf = String::new(); + let s = read.read_to_string(&mut buf).map(|_| buf); + Box::new(std::iter::once(s)) + } else { + Box::new(read.lines()) as Box> + } +} + +fn collect_if<'a, T: FromIterator + 'a, E: 'a>( + slurp: bool, + iter: impl Iterator> + 'a, +) -> Box> + 'a> { + if slurp { + Box::new(core::iter::once(iter.collect())) + } else { + Box::new(iter) + } +} diff --git a/jaq/src/repl.rs b/jaq/src/repl.rs new file mode 100644 index 000000000..8e03db4ba --- /dev/null +++ b/jaq/src/repl.rs @@ -0,0 +1,55 @@ +use crate::{filter, run, write, Cli, Error, Val}; +use jaq_core::Native; +use jaq_std::Filter; +use rustyline::error::ReadlineError; +use rustyline::DefaultEditor; +use std::sync::atomic::{AtomicUsize, Ordering}; + +// counter that increases for each nested invocation of `repl` +static DEPTH: AtomicUsize = AtomicUsize::new(0); + +pub fn fun() -> Filter> { + jaq_std::run(("repl", jaq_std::v(0), |_, cv| { + let depth = DEPTH.fetch_add(1, Ordering::Relaxed); + repl_with(depth, |s| match eval(s, cv.1.clone()) { + Ok(()) => (), + Err(e) => eprint!("{e}"), + }) + .unwrap(); + DEPTH.fetch_sub(1, Ordering::Relaxed); + + Box::new(core::iter::empty()) + })) +} + +fn eval(code: String, input: Val) -> Result<(), Error> { + let (ctx, filter) = + filter::parse_compile(&"".into(), &code, &[], &[]).map_err(Error::Report)?; + let cli = &Cli::default(); + let inputs = core::iter::once(Ok(input)); + crate::with_stdout(|out| run(cli, &filter, ctx, inputs, |v| write::print(out, cli, &v)))?; + Ok(()) +} + +fn repl_with(depth: usize, f: impl Fn(String)) -> Result<(), ReadlineError> { + use rustyline::config::{Behavior, Config}; + use yansi::Paint; + let config = Config::builder() + .behavior(Behavior::PreferTerm) + .auto_add_history(true) + .build(); + let mut rl = DefaultEditor::with_config(config)?; + let history = dirs::cache_dir().map(|dir| dir.join("jaq-history")); + let _ = history.iter().try_for_each(|h| rl.load_history(h)); + let prompt = format!("{}{} ", str::repeat(" ", depth), '>'.bold()); + loop { + match rl.readline(&prompt) { + Ok(line) => f(line), + Err(ReadlineError::Interrupted) => (), + Err(ReadlineError::Eof) => break, + Err(err) => Err(err)?, + } + } + let _ = history.iter().try_for_each(|h| rl.append_history(h)); + Ok(()) +} diff --git a/jaq/src/write.rs b/jaq/src/write.rs new file mode 100644 index 000000000..29691c476 --- /dev/null +++ b/jaq/src/write.rs @@ -0,0 +1,125 @@ +use crate::{Cli, Val}; +use core::fmt::{self, Display, Formatter}; +use is_terminal::IsTerminal; +use std::io::{self, Write}; + +struct FormatterFn(F); + +impl fmt::Result> Display for FormatterFn { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + self.0(f) + } +} + +struct PpOpts { + compact: bool, + indent: String, + sort_keys: bool, +} + +impl PpOpts { + fn indent(&self, f: &mut Formatter, level: usize) -> fmt::Result { + if !self.compact { + write!(f, "{}", self.indent.repeat(level))?; + } + Ok(()) + } + + fn newline(&self, f: &mut Formatter) -> fmt::Result { + if !self.compact { + writeln!(f)?; + } + Ok(()) + } +} + +fn fmt_seq(fmt: &mut Formatter, opts: &PpOpts, level: usize, xs: I, f: F) -> fmt::Result +where + I: IntoIterator, + F: Fn(&mut Formatter, T) -> fmt::Result, +{ + opts.newline(fmt)?; + let mut iter = xs.into_iter().peekable(); + while let Some(x) = iter.next() { + opts.indent(fmt, level + 1)?; + f(fmt, x)?; + if iter.peek().is_some() { + write!(fmt, ",")?; + } + opts.newline(fmt)?; + } + opts.indent(fmt, level) +} + +fn fmt_val(f: &mut Formatter, opts: &PpOpts, level: usize, v: &Val) -> fmt::Result { + use yansi::Paint; + match v { + Val::Null | Val::Bool(_) | Val::Int(_) | Val::Float(_) | Val::Num(_) => v.fmt(f), + Val::Str(_) => write!(f, "{}", v.green()), + Val::Arr(a) => { + '['.bold().fmt(f)?; + if !a.is_empty() { + fmt_seq(f, opts, level, &**a, |f, x| fmt_val(f, opts, level + 1, x))?; + } + ']'.bold().fmt(f) + } + Val::Obj(o) => { + '{'.bold().fmt(f)?; + let kv = |f: &mut Formatter, (k, val): (&std::rc::Rc, &Val)| { + write!(f, "{}:", Val::Str(k.clone()).bold())?; + if !opts.compact { + write!(f, " ")?; + } + fmt_val(f, opts, level + 1, val) + }; + if !o.is_empty() { + if opts.sort_keys { + let mut o: Vec<_> = o.iter().collect(); + o.sort_by_key(|(k, _v)| *k); + fmt_seq(f, opts, level, o, kv) + } else { + fmt_seq(f, opts, level, &**o, kv) + }? + } + '}'.bold().fmt(f) + } + } +} + +pub fn print(w: &mut (impl Write + ?Sized), cli: &Cli, val: &Val) -> io::Result<()> { + let f = |f: &mut Formatter| { + let opts = PpOpts { + compact: cli.compact_output, + indent: if cli.tab { + String::from("\t") + } else { + " ".repeat(cli.indent) + }, + sort_keys: cli.sort_keys, + }; + fmt_val(f, &opts, 0, val) + }; + + match val { + Val::Str(s) if cli.raw_output || cli.join_output => write!(w, "{s}")?, + _ => write!(w, "{}", FormatterFn(f))?, + }; + + if cli.join_output { + // when running `jaq -jn '"prompt> " | (., input)'`, + // this flush is necessary to make "prompt> " appear first + w.flush() + } else { + // this also flushes output, because stdout is line-buffered in Rust + writeln!(w) + } +} + +pub fn with_stdout(f: impl FnOnce(&mut dyn Write) -> T) -> T { + let stdout = io::stdout(); + if stdout.is_terminal() { + f(&mut stdout.lock()) + } else { + f(&mut io::BufWriter::new(stdout.lock())) + } +}