From 7cf0ad1d3acc19115e63f96947086d2588a74967 Mon Sep 17 00:00:00 2001 From: harehare Date: Sat, 8 Aug 2026 22:51:12 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(mq-run):=20add=20--diff=20to?= =?UTF-8?q?=20-U=20for=20safe=20change=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preview what `-U` would change without writing anything: prints a unified diff of the original input against the transformed output instead of the full content. Multiple files are diffed one at a time with their path in the headers; stdin is labeled ``. Exits 1 if anything would change, 0 otherwise, so it can gate CI/scripts. Requires --update via clap's `requires`, and is rejected with -F grep. Diffing always compares against plain (uncolored) text even with -C, to avoid ANSI codes baking into the rendered content and making every line look changed; the diff output colors its own +/-/@@ lines. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/mq-lang/Cargo.toml | 2 +- crates/mq-run/Cargo.toml | 1 + crates/mq-run/src/cli.rs | 201 ++++++++++++++---- crates/mq-run/tests/integration_tests.rs | 187 ++++++++++++++++ .../src/cookbook/update-text-in-place.md | 20 ++ 7 files changed, 370 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ab3cffd7f..3e1779091 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2774,6 +2774,7 @@ dependencies = [ "scopeguard", "serde_json", "serde_yaml", + "similar 3.1.1", "strum", "tabled", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 5e4b89bce..a572ff48c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,6 +96,7 @@ serde-wasm-bindgen = "0.6.5" serde_json = "1.0" serde_yaml = "0.9" sha2 = "0.11.0" +similar = "3.0.0" slotmap = "1.1.1" smallvec = "1.15.1" smol_str = "0.3.6" diff --git a/crates/mq-lang/Cargo.toml b/crates/mq-lang/Cargo.toml index 59a67cd51..f6728fa97 100644 --- a/crates/mq-lang/Cargo.toml +++ b/crates/mq-lang/Cargo.toml @@ -47,7 +47,7 @@ url = {workspace = true} toon-format = {workspace = true} quick-xml = {workspace = true} unicode-width = {workspace = true} -similar = "3.0.0" +similar = {workspace = true} tiktoken-rs = { version = "0.12", optional = true } ureq = { workspace = true, optional = true } uuid = {workspace = true, features = ["v4", "v7"]} diff --git a/crates/mq-run/Cargo.toml b/crates/mq-run/Cargo.toml index 88a5ccf68..61a5b96a9 100644 --- a/crates/mq-run/Cargo.toml +++ b/crates/mq-run/Cargo.toml @@ -38,6 +38,7 @@ mq-repl = {workspace = true} quick-xml = {workspace = true} rayon = {workspace = true} serde_json = {workspace = true} +similar = {workspace = true} serde_yaml = {workspace = true} regex = {workspace = true, optional = true} rustyline = {workspace = true, optional = true, default-features = false, features = ["custom-bindings", "with-file-history"]} diff --git a/crates/mq-run/src/cli.rs b/crates/mq-run/src/cli.rs index 7e7aaf2ad..6a1d5b31b 100644 --- a/crates/mq-run/src/cli.rs +++ b/crates/mq-run/src/cli.rs @@ -23,6 +23,9 @@ use which::which; // processing can fan out across rayon worker threads. static HAD_TRUTHY_OUTPUT: AtomicBool = AtomicBool::new(false); +// Tracks whether --diff found any changed input, for the exit-code-1 CI check below. +static HAD_DIFF: AtomicBool = AtomicBool::new(false); + use crate::grep; use mq_help as help; @@ -419,6 +422,12 @@ struct OutputArgs { #[arg(short = 'U', long = "update", default_value_t = false)] update: bool, + /// With --update, print a unified diff instead of the transformed content; + /// nothing is written. Multiple files are diffed one at a time with their path + /// in the headers; stdin is labeled ``. Exits 1 if anything would change. + #[arg(long = "diff", default_value_t = false, requires = "update")] + diff: bool, + /// Unbuffered output #[clap(long, default_value_t = false)] unbuffered: bool, @@ -1215,6 +1224,10 @@ impl Cli { return Err(miette!("The output format is not supported for the update option")); } + if self.output.diff && matches!(self.output.output_format, OutputFormat::Grep) { + return Err(miette!("--diff is not supported with -F grep")); + } + match &self.commands { Some(Commands::Repl) => { let engine = self.create_engine()?; @@ -1246,6 +1259,12 @@ impl Cli { } } + // --diff: exit 1 if anything would change, for CI gating. + if self.output.diff && HAD_DIFF.load(Ordering::Relaxed) { + result?; + std::process::exit(1); + } + result } } @@ -1579,6 +1598,10 @@ impl Cli { engine.eval(query, input.into_iter()).map_err(|e| *e)? }; + if self.output.update && self.output.diff { + return self.emit_diff(&runtime_values, file, content); + } + if let Some(separator) = &self.output.separator { let separator = engine .eval( @@ -1696,6 +1719,10 @@ impl Cli { engine.eval_compiled(program, input.into_iter()).map_err(|e| *e)? }; + if self.output.update && self.output.diff { + return self.emit_diff(&runtime_values, file, content); + } + self.emit_results(runtime_values, grep_input, file) } @@ -2014,98 +2041,188 @@ impl Cli { markdown } - fn print(&self, runtime_values: mq_lang::RuntimeValues) -> miette::Result<()> { - let stdout = io::stdout(); - let mut handle: Box = if let Some(output_file) = &self.output.output_file { - let file = fs::File::create(output_file).into_diagnostic()?; - Box::new(BufWriter::new(file)) - } else if self.output.unbuffered { - Box::new(stdout.lock()) - } else { - Box::new(BufWriter::new(stdout.lock())) - }; - let stripped_values: Option> = self.output.no_position.then(|| { - runtime_values - .values() - .iter() - .cloned() - .map(Self::strip_markdown_position) - .collect() - }); - let runtime_values: &[mq_lang::RuntimeValue] = stripped_values.as_deref().unwrap_or(runtime_values.values()); - - // Track truthy output for --exit-status. - if self.output.exit_status && runtime_values.iter().any(|v| !Self::is_falsy(v)) { - HAD_TRUTHY_OUTPUT.store(true, Ordering::Relaxed); - } + /// Renders `runtime_values` to a byte buffer without writing anywhere. + /// `emit_diff` always passes `colorize: false` — it needs plain text to diff + /// against the uncolored original, and colors the diff lines itself. + fn render(&self, runtime_values: &[mq_lang::RuntimeValue], colorize: bool) -> miette::Result> { + let mut buf = Vec::new(); match self.output.output_format { OutputFormat::Raw => { for value in runtime_values { match value { - mq_lang::RuntimeValue::Bytes(b) => Self::write_ignore_pipe(&mut handle, b)?, - _ => Self::write_ignore_pipe(&mut handle, value.to_string().as_bytes())?, + mq_lang::RuntimeValue::Bytes(b) => buf.extend_from_slice(b), + _ => buf.extend_from_slice(value.to_string().as_bytes()), } } } OutputFormat::Json => { - let theme = (self.output.color_output && !Self::is_no_color()).then(mq_markdown::ColorTheme::from_env); + let theme = colorize.then(mq_markdown::ColorTheme::from_env); let json_str = crate::output::json::runtime_values_to_json(runtime_values, theme.as_ref())?; - Self::write_ignore_pipe(&mut handle, json_str.as_bytes())?; + buf.extend_from_slice(json_str.as_bytes()); } OutputFormat::Html => { let markdown = self.build_markdown(runtime_values); - Self::write_ignore_pipe(&mut handle, markdown.to_html().as_bytes())?; + buf.extend_from_slice(markdown.to_html().as_bytes()); } OutputFormat::Text => { let markdown = self.build_markdown(runtime_values); - Self::write_ignore_pipe(&mut handle, markdown.to_text().as_bytes())?; + buf.extend_from_slice(markdown.to_text().as_bytes()); } - OutputFormat::Markdown if self.output.color_output && !Self::is_no_color() => { + OutputFormat::Markdown if colorize => { let markdown = self.build_markdown(runtime_values); let theme = mq_markdown::ColorTheme::from_env(); - Self::write_ignore_pipe(&mut handle, markdown.to_colored_string_with_theme(&theme).as_bytes())?; + buf.extend_from_slice(markdown.to_colored_string_with_theme(&theme).as_bytes()); } OutputFormat::Markdown => { let markdown = self.build_markdown(runtime_values); - Self::write_ignore_pipe(&mut handle, markdown.to_string().as_bytes())?; + buf.extend_from_slice(markdown.to_string().as_bytes()); } OutputFormat::Table => { - let theme = (self.output.color_output && !Self::is_no_color()).then(mq_markdown::ColorTheme::from_env); + let theme = colorize.then(mq_markdown::ColorTheme::from_env); let table = crate::output::table::runtime_values_to_table(runtime_values, theme.as_ref()); - Self::write_ignore_pipe(&mut handle, format!("{}\n", table).as_bytes())?; + buf.extend_from_slice(format!("{}\n", table).as_bytes()); } OutputFormat::Grep => { let markdown = self.build_markdown(runtime_values); - Self::write_ignore_pipe(&mut handle, markdown.to_string().as_bytes())?; + buf.extend_from_slice(markdown.to_string().as_bytes()); } OutputFormat::Gron => { let gron_str = crate::output::gron::runtime_values_to_gron(runtime_values); - Self::write_ignore_pipe(&mut handle, gron_str.as_bytes())?; + buf.extend_from_slice(gron_str.as_bytes()); } OutputFormat::Csv => { let csv_str = crate::output::csv::runtime_values_to_csv(runtime_values)?; - Self::write_ignore_pipe(&mut handle, csv_str.as_bytes())?; + buf.extend_from_slice(csv_str.as_bytes()); } OutputFormat::Toml => { let toml_str = crate::output::toml::runtime_values_to_toml(runtime_values)?; - Self::write_ignore_pipe(&mut handle, toml_str.as_bytes())?; + buf.extend_from_slice(toml_str.as_bytes()); } OutputFormat::Toon => { let toon_str = crate::output::toon::runtime_values_to_toon(runtime_values)?; - Self::write_ignore_pipe(&mut handle, toon_str.as_bytes())?; + buf.extend_from_slice(toon_str.as_bytes()); } OutputFormat::Xml => { let xml_str = crate::output::xml::runtime_values_to_xml(runtime_values)?; - Self::write_ignore_pipe(&mut handle, xml_str.as_bytes())?; + buf.extend_from_slice(xml_str.as_bytes()); } OutputFormat::Yaml => { let yaml_str = crate::output::yaml::runtime_values_to_yaml(runtime_values)?; - Self::write_ignore_pipe(&mut handle, yaml_str.as_bytes())?; + buf.extend_from_slice(yaml_str.as_bytes()); } OutputFormat::None => {} } + Ok(buf) + } + + fn print(&self, runtime_values: mq_lang::RuntimeValues) -> miette::Result<()> { + let stdout = io::stdout(); + let mut handle: Box = if let Some(output_file) = &self.output.output_file { + let file = fs::File::create(output_file).into_diagnostic()?; + Box::new(BufWriter::new(file)) + } else if self.output.unbuffered { + Box::new(stdout.lock()) + } else { + Box::new(BufWriter::new(stdout.lock())) + }; + let stripped_values: Option> = self.output.no_position.then(|| { + runtime_values + .values() + .iter() + .cloned() + .map(Self::strip_markdown_position) + .collect() + }); + let runtime_values: &[mq_lang::RuntimeValue] = stripped_values.as_deref().unwrap_or(runtime_values.values()); + + // Track truthy output for --exit-status. + if self.output.exit_status && runtime_values.iter().any(|v| !Self::is_falsy(v)) { + HAD_TRUTHY_OUTPUT.store(true, Ordering::Relaxed); + } + + let colorize = self.output.color_output && !Self::is_no_color(); + let buf = self.render(runtime_values, colorize)?; + Self::write_ignore_pipe(&mut handle, &buf)?; + + if !self.output.unbuffered + && let Err(e) = handle.flush() + && e.kind() != std::io::ErrorKind::BrokenPipe + { + return Err(miette!(e)); + } + + Ok(()) + } + + /// Renders what `--update` would print and diffs it against the original input. + fn emit_diff( + &self, + runtime_values: &mq_lang::RuntimeValues, + file: &Option, + content: &ContentData, + ) -> miette::Result<()> { + let original = content.as_str().unwrap_or(""); + let rendered = self.render(runtime_values.values(), false)?; + let rendered = String::from_utf8_lossy(&rendered); + + if original != rendered { + HAD_DIFF.store(true, Ordering::Relaxed); + self.print_unified_diff(original, &rendered, file)?; + } + + Ok(()) + } + + /// Prints a unified diff of `original` vs `rendered` to stdout (or `-o`), headed + /// by the file path (or `` when there is none). Colorizes `+`/`-`/`@@` + /// lines when `-C`/`--color-output` is set and `NO_COLOR` isn't. + fn print_unified_diff(&self, original: &str, rendered: &str, file: &Option) -> miette::Result<()> { + let stdout = io::stdout(); + let mut handle: Box = if let Some(output_file) = &self.output.output_file { + let file = fs::File::create(output_file).into_diagnostic()?; + Box::new(BufWriter::new(file)) + } else if self.output.unbuffered { + Box::new(stdout.lock()) + } else { + Box::new(BufWriter::new(stdout.lock())) + }; + + let label = file + .as_ref() + .map(|f| f.display().to_string()) + .unwrap_or_else(|| "".to_string()); + + let diff_text = similar::TextDiff::from_lines(original, rendered) + .unified_diff() + .header(&label, &label) + .to_string(); + + // Raw ANSI, not `colored::Colorize` — it auto-disables on non-tty stdout, but -C should force color. + let colorize = self.output.color_output && !Self::is_no_color(); + let mut out = String::with_capacity(diff_text.len()); + for line in diff_text.lines() { + if colorize && line.starts_with('+') && !line.starts_with("+++") { + out.push_str("\x1b[32m"); + out.push_str(line); + out.push_str("\x1b[0m"); + } else if colorize && line.starts_with('-') && !line.starts_with("---") { + out.push_str("\x1b[31m"); + out.push_str(line); + out.push_str("\x1b[0m"); + } else if colorize && line.starts_with("@@") { + out.push_str("\x1b[36m"); + out.push_str(line); + out.push_str("\x1b[0m"); + } else { + out.push_str(line); + } + out.push('\n'); + } + + Self::write_ignore_pipe(&mut handle, out.as_bytes())?; + if !self.output.unbuffered && let Err(e) = handle.flush() && e.kind() != std::io::ErrorKind::BrokenPipe diff --git a/crates/mq-run/tests/integration_tests.rs b/crates/mq-run/tests/integration_tests.rs index c6d3c3a34..f00fd7b81 100644 --- a/crates/mq-run/tests/integration_tests.rs +++ b/crates/mq-run/tests/integration_tests.rs @@ -1293,6 +1293,193 @@ fn test_eval_all_conflicts_with_update() -> Result<(), Box Result<(), Box> { + let mut cmd = cargo::cargo_bin_cmd!("mq"); + cmd.arg("--unbuffered") + .arg("--update") + .arg("--diff") + .arg("self") + .write_stdin("# title\n") + .assert() + .success() + .code(0) + .stdout(""); + Ok(()) +} + +#[test] +fn test_diff_shows_changes_with_stdin_label() -> Result<(), Box> { + let mut cmd = cargo::cargo_bin_cmd!("mq"); + let assert = cmd + .arg("--unbuffered") + .arg("--update") + .arg("--diff") + .arg(r#".h | select(contains("title")) | ltrimstr("titl")"#) + .write_stdin("# title\n") + .assert(); + let output = assert.failure().code(1).get_output().stdout.clone(); + let output = String::from_utf8(output)?; + + assert!(output.contains("--- "), "missing old header: {output}"); + assert!(output.contains("+++ "), "missing new header: {output}"); + assert!(output.contains("-# title"), "missing removed line: {output}"); + assert!(output.contains("+# e"), "missing added line: {output}"); + + Ok(()) +} + +#[test] +fn test_diff_multiple_files_includes_path_headers() -> Result<(), Box> { + let (_, file1) = create_file("test_diff_multi1.md", "# title\n"); + let (_, file2) = create_file("test_diff_multi2.md", "# other\n"); + let (file1_clone, file2_clone) = (file1.clone(), file2.clone()); + + defer! { + std::fs::remove_file(&file1_clone).ok(); + std::fs::remove_file(&file2_clone).ok(); + } + + let mut cmd = cargo::cargo_bin_cmd!("mq"); + let assert = cmd + .arg("--unbuffered") + .arg("--update") + .arg("--diff") + .arg(r#".h | select(contains("title")) | ltrimstr("titl")"#) + .arg(&file1) + .arg(&file2) + .assert(); + let output = assert.failure().code(1).get_output().stdout.clone(); + let output = String::from_utf8(output)?; + + assert!( + output.contains(&format!("--- {}", file1.display())), + "missing file1 header: {output}" + ); + assert!( + !output.contains(&format!("--- {}", file2.display())), + "unchanged file2 should not appear: {output}" + ); + + Ok(()) +} + +#[test] +fn test_diff_original_file_unchanged_on_query_error() -> Result<(), Box> { + let original_content = "# title\n"; + let (_, file) = create_file("test_diff_error_unchanged.md", original_content); + let file_clone = file.clone(); + + defer! { + std::fs::remove_file(&file_clone).ok(); + } + + let mut cmd = cargo::cargo_bin_cmd!("mq"); + cmd.arg("--update") + .arg("--diff") + .arg("this_function_does_not_exist()") + .arg(&file) + .assert() + .failure(); + + assert_eq!(std::fs::read_to_string(&file)?, original_content); + + Ok(()) +} + +#[test] +fn test_diff_trailing_newline_only() -> Result<(), Box> { + let mut cmd = cargo::cargo_bin_cmd!("mq"); + let assert = cmd + .arg("--unbuffered") + .arg("--update") + .arg("--diff") + .arg("self") + .write_stdin("# title") + .assert(); + let output = assert.failure().code(1).get_output().stdout.clone(); + let output = String::from_utf8(output)?; + + assert!( + output.contains("\\ No newline at end of file"), + "expected a trailing-newline-only diff: {output}" + ); + + Ok(()) +} + +#[test] +fn test_diff_no_color_env_disables_ansi_codes() -> Result<(), Box> { + let mut cmd = cargo::cargo_bin_cmd!("mq"); + let assert = cmd + .arg("--unbuffered") + .arg("--update") + .arg("--diff") + .arg("-C") + .arg(r#".h | select(contains("title")) | ltrimstr("titl")"#) + .env("NO_COLOR", "1") + .write_stdin("# title\n") + .assert(); + let output = assert.failure().code(1).get_output().stdout.clone(); + let output = String::from_utf8(output)?; + + assert!(!output.contains('\u{1b}'), "expected no ANSI escapes: {output:?}"); + + Ok(()) +} + +#[test] +fn test_diff_color_output_adds_ansi_codes() -> Result<(), Box> { + let mut cmd = cargo::cargo_bin_cmd!("mq"); + let assert = cmd + .arg("--unbuffered") + .arg("--update") + .arg("--diff") + .arg("-C") + .arg(r#".h | select(contains("title")) | ltrimstr("titl")"#) + .env_remove("NO_COLOR") + .write_stdin("# title\n\nBody text.\n") + .assert(); + let output = assert.failure().code(1).get_output().stdout.clone(); + let output = String::from_utf8(output)?; + + assert!(output.contains('\u{1b}'), "expected ANSI escapes: {output:?}"); + // Regression: -C must not bake ANSI into the text being diffed, or the unchanged line would spuriously differ too. + assert!( + output.contains("\n Body text.\n"), + "expected the unchanged line to remain a plain context line: {output:?}" + ); + assert!( + !output.contains("-Body text.") && !output.contains("+Body text."), + "unchanged line must not appear as a diff line: {output:?}" + ); + + Ok(()) +} + #[rstest] #[case::bash("bash", "_mq()")] #[case::elvish("elvish", "edit:completion:arg-completer[mq]")] diff --git a/docs/books/src/cookbook/update-text-in-place.md b/docs/books/src/cookbook/update-text-in-place.md index d39448c51..b59efa1f6 100644 --- a/docs/books/src/cookbook/update-text-in-place.md +++ b/docs/books/src/cookbook/update-text-in-place.md @@ -35,3 +35,23 @@ See the changelog for details. - **Without `-U`**, mq prints only the nodes that matched `select(...)` — here, the "See the changelog for details." paragraph would silently disappear from the output, since it never matched the `contains("1.2.0")` filter. - **With `-U`**, mq prints the whole document back out, with only the matched-and-transformed nodes changed. This is the mode you want whenever the query's job is "edit part of this file," not "extract part of this file." - `-U` writes to stdout, not the file itself. To edit a file on disk, redirect to a temp file and move it back: `mq -U '...' file.md > file.md.tmp && mv file.md.tmp file.md`. + +## Previewing changes first + +Before piping `-U` output into a file, preview what would change with `--diff`, which prints a unified diff instead of the full content: + +```bash +$ mq -U --diff 'select(contains("1.2.0")) | replace("1.2.0", "1.3.0")' CHANGELOG.md +--- CHANGELOG.md ++++ CHANGELOG.md +@@ -1,4 +1,4 @@ +-# My Project v1.2.0 ++# My Project v1.3.0 + +-Install version 1.2.0 to get started. ++Install version 1.3.0 to get started. + + See the changelog for details. +``` + +`--diff` never writes to the file — `-U` never has. It exits with code `1` if anything would change (`0` otherwise), which is handy for a CI check. With multiple files, each gets its own diff headed by its path.