Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion crates/mq-lang/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}
Expand Down
1 change: 1 addition & 0 deletions crates/mq-run/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}
Expand Down
201 changes: 159 additions & 42 deletions crates/mq-run/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 `<stdin>`. 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,
Expand Down Expand Up @@ -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()?;
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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<dyn Write> = 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<Vec<mq_lang::RuntimeValue>> = 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<Vec<u8>> {
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<dyn Write> = 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<Vec<mq_lang::RuntimeValue>> = 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<PathBuf>,
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 `<stdin>` 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<PathBuf>) -> miette::Result<()> {
let stdout = io::stdout();
let mut handle: Box<dyn Write> = 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(|| "<stdin>".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
Expand Down
Loading
Loading