mirror of
https://github.com/sharkdp/hyperfine.git
synced 2026-11-15 08:51:26 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27c98c6f87 |
No files matched your search
@@ -1,12 +1,3 @@
|
||||
# v2.0.0-alpha.2
|
||||
|
||||
## Changes
|
||||
|
||||
- Always run `--setup`, `--prepare`, `--conclude`, and `--cleanup` commands in a shell, even when benchmarked commands run directly. Improve error messages for these commands, see #990 (@sharkdp).
|
||||
- Require an explicit shell choice for commands containing unquoted, standalone shell operators such as `&&` or `>`: use `-S` to interpret shell syntax or `-N` to pass operators as literal arguments (@sharkdp).
|
||||
- Use a compact output layout on narrow terminals (@sharkdp).
|
||||
- Make plotting and analysis scripts runnable directly from GitHub with `uvx --from 'git+https://github.com/sharkdp/hyperfine' <script>` (@sharkdp).
|
||||
|
||||
# v2.0.0-alpha.1
|
||||
|
||||
This is the first alpha release of hyperfine 2.0. Command-line options and export formats may change before the stable release. See the breaking changes below when migrating from hyperfine 1.x.
|
||||
|
||||
+2
-2
@@ -13,5 +13,5 @@ authors:
|
||||
repository-code: 'https://github.com/sharkdp/hyperfine'
|
||||
abstract: A command-line benchmarking tool.
|
||||
license: MIT
|
||||
version: 2.0.0-alpha.2
|
||||
date-released: '2026-10-07'
|
||||
version: 2.0.0-alpha.1
|
||||
date-released: '2026-10-06'
|
||||
Generated
+1
-2
@@ -377,7 +377,7 @@ checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
|
||||
|
||||
[[package]]
|
||||
name = "hyperfine"
|
||||
version = "2.0.0-alpha.2"
|
||||
version = "2.0.0-alpha.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"approx",
|
||||
@@ -402,7 +402,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"shell-words",
|
||||
"tempfile",
|
||||
"terminal_size",
|
||||
"thiserror",
|
||||
"uom",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
+1
-2
@@ -7,7 +7,7 @@ license = "MIT OR Apache-2.0"
|
||||
name = "hyperfine"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/sharkdp/hyperfine"
|
||||
version = "2.0.0-alpha.2"
|
||||
version = "2.0.0-alpha.1"
|
||||
edition = "2018"
|
||||
build = "build.rs"
|
||||
rust-version = "1.97.0"
|
||||
@@ -26,7 +26,6 @@ serde_json = "1.0"
|
||||
rust_decimal = "1.43"
|
||||
rand = "0.10"
|
||||
shell-words = "1.1"
|
||||
terminal_size = "0.4"
|
||||
thiserror = "2.0"
|
||||
anyhow = "1.0"
|
||||
uom = { version = "0.36.0", default-features = false, features = ["std", "si", "f64"] }
|
||||
|
||||
@@ -114,10 +114,6 @@ Shell syntax such as pipes, redirections, environment-variable expansion, `*`, a
|
||||
This avoids shell startup overhead and the noise from correcting for it, especially for fast commands
|
||||
(< 5 ms).
|
||||
|
||||
Commands containing unquoted, standalone shell operators such as `&&` or `>` require an explicit
|
||||
choice: use `-S` / `--shell=default` to interpret shell syntax, or `-N` / `--shell=none` to pass
|
||||
the operators as literal arguments.
|
||||
|
||||
To enable shell syntax, use `-S` (an alias for `--shell=default`). This selects `sh`
|
||||
on Unix (resolved through `PATH`) or `cmd.exe` on Windows:
|
||||
```sh
|
||||
@@ -239,9 +235,8 @@ You can also use a preset to select a group of metrics:
|
||||
|
||||
### Exporting results
|
||||
|
||||
Hyperfine can export results to CSV, JSON, Markdown, AsciiDoc, and org-mode. CSV exports all
|
||||
selected metrics, while Markdown, AsciiDoc, and org-mode contain only the primary metric,
|
||||
which is the first metric selected by `--metrics`.
|
||||
Hyperfine can export results to CSV, JSON, Markdown, AsciiDoc, and org-mode. Non-JSON formats
|
||||
contain only the primary metric, which is the first metric selected by `--metrics`.
|
||||
|
||||
#### Markdown
|
||||
|
||||
@@ -277,14 +272,6 @@ uvx --from 'git+https://github.com/sharkdp/hyperfine' \
|
||||
|---:|---:|
|
||||
|
||||
|
||||
#### CSV
|
||||
|
||||
Use `--export-csv <file>` to export one row per command. Each metric selected by `--metrics`
|
||||
contributes a group of columns, in selection order: `<metric>_unit`, `<metric>_mean`,
|
||||
`<metric>_stddev`, `<metric>_median`, `<metric>_min`, and `<metric>_max`.
|
||||
|
||||
CSV uses explicitly selected units, or base units (seconds, bytes, or counts) when omitted.
|
||||
|
||||
### Detailed benchmark flowchart
|
||||
|
||||
The following chart explains the execution order of various benchmark runs when using options
|
||||
|
||||
+4
-6
@@ -232,7 +232,7 @@ of the tool.
|
||||
.HP
|
||||
\fB\-\-metrics\fR \fIMETRIC[:UNIT],...|default|time|all\fP
|
||||
.IP
|
||||
Select the metrics to measure, in order, with an optional unit for each.
|
||||
Select the metrics to display, in order, with an optional unit for each.
|
||||
Default: \fBtime_wall_clock,memory_peak_resident\fR, or \fBtime_wall_clock\fR
|
||||
on Windows, where peak resident memory is not yet supported.
|
||||
.IP
|
||||
@@ -266,11 +266,9 @@ Export the primary metric's summary statistics as an AsciiDoc table to the given
|
||||
.HP
|
||||
\fB\-\-export\-csv\fR \fIFILE\fP
|
||||
.IP
|
||||
Export summary statistics for all selected metrics as CSV to the given \fIFILE\fP,
|
||||
one row per command. Each metric contributes columns for unit, mean, stddev, median,
|
||||
min, and max, prefixed with the metric name, in selection order. The command column
|
||||
comes first and parameter columns come last. Use the explicitly selected units,
|
||||
or base units (seconds, bytes, or unscaled counts) when omitted.
|
||||
Export the primary metric's summary statistics as CSV to the given \fIFILE\fP,
|
||||
identifying the metric and unit explicitly. Use the explicitly selected unit,
|
||||
or the base unit (seconds, bytes, or unscaled counts) when omitted.
|
||||
.HP
|
||||
\fB\-\-export\-json\fR \fIFILE\fP
|
||||
.IP
|
||||
|
||||
@@ -69,7 +69,7 @@ fn generate_results(args: &[&'static str]) -> Result<Vec<BenchmarkResult>> {
|
||||
assert_eq!(options.executor_kind, ExecutorKind::Mock(None));
|
||||
|
||||
let commands = Commands::from_cli_arguments(&cli_arguments)?;
|
||||
let export_manager = ExportManager::from_cli_arguments(&cli_arguments, &options.metrics)?;
|
||||
let export_manager = ExportManager::from_cli_arguments(&cli_arguments, options.metrics[0])?;
|
||||
|
||||
options.validate_against_command_list(&commands)?;
|
||||
|
||||
|
||||
+3
-4
@@ -58,7 +58,7 @@ fn build_command() -> Command {
|
||||
.hide_default_value(true)
|
||||
.help("Performance metrics to measure, in order.\n\n\
|
||||
This is either a preset name, or a comma-separated list of metrics with optional units. \
|
||||
CSV exports all selected metrics; markup exports use the first metric. \
|
||||
The first metric is used for non-JSON exports, \
|
||||
JSON always includes all collected metrics.\n\n\
|
||||
Presets (use one on its own):\n \
|
||||
default Wall-clock time and peak RSS\n \
|
||||
@@ -303,10 +303,9 @@ fn build_command() -> Command {
|
||||
.action(ArgAction::Set)
|
||||
.value_name("FILE")
|
||||
.value_hint(ValueHint::FilePath)
|
||||
.help("Export summary statistics for all selected metrics as CSV to the given FILE. If you need \
|
||||
.help("Export the primary metric summary statistics as CSV to the given FILE. If you need \
|
||||
all metrics for each individual run, use the JSON export format. \
|
||||
Column names are prefixed with the metric name. \
|
||||
Use the explicitly selected units, or base units when unspecified."),
|
||||
Use the explicitly selected unit, or the base unit when unspecified."),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("export-json")
|
||||
|
||||
+49
-54
@@ -4,34 +4,23 @@ use super::Exporter;
|
||||
use crate::benchmark::benchmark_result::BenchmarkResult;
|
||||
use crate::metric::MetricSelection;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CsvExporter {}
|
||||
|
||||
impl Exporter for CsvExporter {
|
||||
fn serialize(
|
||||
&self,
|
||||
results: &[BenchmarkResult],
|
||||
metrics: &[MetricSelection],
|
||||
) -> Result<Vec<u8>> {
|
||||
// Omit metrics unavailable for all commands (possible with `--metrics all`).
|
||||
let metrics: Vec<_> = metrics
|
||||
.iter()
|
||||
.filter(|selection| {
|
||||
results
|
||||
.iter()
|
||||
.any(|result| selection.metric.summarize(&result.measurements).is_some())
|
||||
})
|
||||
.collect();
|
||||
fn serialize(&self, results: &[BenchmarkResult], primary: MetricSelection) -> Result<Vec<u8>> {
|
||||
// CSV never chooses units automatically: an omitted suffix means base units.
|
||||
let unit = primary.csv_unit();
|
||||
let mut writer = WriterBuilder::new().from_writer(vec![]);
|
||||
let mut headers = vec!["command".to_owned()];
|
||||
for selection in &metrics {
|
||||
headers.extend(
|
||||
["unit", "mean", "stddev", "median", "min", "max"]
|
||||
.map(|field| format!("{}_{field}", selection.metric.name())),
|
||||
);
|
||||
}
|
||||
let mut headers: Vec<String> = [
|
||||
"command", "metric", "unit", "mean", "stddev", "median", "min", "max",
|
||||
]
|
||||
.iter()
|
||||
.copied()
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
if let Some(result) = results.first() {
|
||||
headers.extend(
|
||||
result
|
||||
@@ -43,28 +32,36 @@ impl Exporter for CsvExporter {
|
||||
writer.write_record(headers)?;
|
||||
|
||||
for result in results {
|
||||
let mut fields = vec![result.get_name().to_owned()];
|
||||
for selection in &metrics {
|
||||
// CSV never chooses units automatically: an omitted suffix means base units.
|
||||
let unit = selection.csv_unit();
|
||||
let stats = selection.metric.summarize(&result.measurements);
|
||||
fields.push(unit.symbol.to_owned());
|
||||
// Preserve numeric precision rather than using rounded display values.
|
||||
fields.extend(
|
||||
[
|
||||
stats.map(|stats| stats.mean),
|
||||
stats.and_then(|stats| stats.stddev),
|
||||
stats.map(|stats| stats.median),
|
||||
stats.map(|stats| stats.min),
|
||||
stats.map(|stats| stats.max),
|
||||
]
|
||||
.map(|value| {
|
||||
value
|
||||
.map(|value| (value / unit.scale).to_string())
|
||||
.unwrap_or_default()
|
||||
}),
|
||||
);
|
||||
}
|
||||
let stats = primary
|
||||
.metric
|
||||
.summarize(&result.measurements)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Metric '{}' is unavailable for '{}'",
|
||||
primary.metric.name(),
|
||||
result.get_name()
|
||||
)
|
||||
})?;
|
||||
let mut fields = vec![
|
||||
result.get_name().to_owned(),
|
||||
primary.metric.name().to_owned(),
|
||||
unit.symbol.to_owned(),
|
||||
];
|
||||
// Preserve numeric precision rather than using rounded display values.
|
||||
fields.extend(
|
||||
[
|
||||
Some(stats.mean),
|
||||
stats.stddev,
|
||||
Some(stats.median),
|
||||
Some(stats.min),
|
||||
Some(stats.max),
|
||||
]
|
||||
.map(|value| {
|
||||
value
|
||||
.map(|value| (value / unit.scale).to_string())
|
||||
.unwrap_or_default()
|
||||
}),
|
||||
);
|
||||
fields.extend(
|
||||
result
|
||||
.parameters
|
||||
@@ -194,18 +191,15 @@ fn test_csv() {
|
||||
|
||||
let actual = String::from_utf8(
|
||||
exporter
|
||||
.serialize(
|
||||
&results,
|
||||
&MetricSelection::parse_list("time_wall_clock,memory_peak_resident").unwrap(),
|
||||
)
|
||||
.serialize(&results, MetricSelection::default())
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
insta::assert_snapshot!(actual, @r#"
|
||||
command,time_wall_clock_unit,time_wall_clock_mean,time_wall_clock_stddev,time_wall_clock_median,time_wall_clock_min,time_wall_clock_max,memory_peak_resident_unit,memory_peak_resident_mean,memory_peak_resident_stddev,memory_peak_resident_median,memory_peak_resident_min,memory_peak_resident_max,parameter_bar,parameter_foo
|
||||
command_a,s,9,2.6457513110645907,8,7,12,B,1024,0,1024,1024,1024,two,one
|
||||
command_b,s,18,1,18,17,19,B,1024,0,1024,1024,1024,seven,one
|
||||
command,metric,unit,mean,stddev,median,min,max,parameter_bar,parameter_foo
|
||||
command_a,time_wall_clock,s,9,2.6457513110645907,8,7,12,two,one
|
||||
command_b,time_wall_clock,s,18,1,18,17,19,seven,one
|
||||
"#);
|
||||
|
||||
for (selection, unit, mean) in [
|
||||
@@ -213,11 +207,12 @@ fn test_csv() {
|
||||
("memory_peak_resident:KiB", "KiB", "1"),
|
||||
] {
|
||||
let output = exporter
|
||||
.serialize(&results, &MetricSelection::parse_list(selection).unwrap())
|
||||
.serialize(&results, MetricSelection::parse_list(selection).unwrap()[0])
|
||||
.unwrap();
|
||||
let mut reader = csv::Reader::from_reader(output.as_slice());
|
||||
let row = reader.records().next().unwrap().unwrap();
|
||||
assert_eq!(&row[1], unit);
|
||||
assert_eq!(&row[2], mean);
|
||||
assert_eq!(&row[1], "memory_peak_resident");
|
||||
assert_eq!(&row[2], unit);
|
||||
assert_eq!(&row[3], mean);
|
||||
}
|
||||
}
|
||||
+1
-6
@@ -65,12 +65,7 @@ pub struct JsonExporter {
|
||||
}
|
||||
|
||||
impl Exporter for JsonExporter {
|
||||
fn serialize(
|
||||
&self,
|
||||
results: &[BenchmarkResult],
|
||||
metrics: &[MetricSelection],
|
||||
) -> Result<Vec<u8>> {
|
||||
let primary = metrics[0];
|
||||
fn serialize(&self, results: &[BenchmarkResult], primary: MetricSelection) -> Result<Vec<u8>> {
|
||||
let mut output = to_vec_pretty(&HyperfineSummary {
|
||||
schema_version: JSON_SCHEMA_VERSION,
|
||||
metadata: &self.metadata,
|
||||
|
||||
@@ -77,12 +77,7 @@ pub trait MarkupExporter {
|
||||
}
|
||||
|
||||
impl<T: MarkupExporter> Exporter for T {
|
||||
fn serialize(
|
||||
&self,
|
||||
results: &[BenchmarkResult],
|
||||
metrics: &[MetricSelection],
|
||||
) -> Result<Vec<u8>> {
|
||||
let primary = metrics[0];
|
||||
fn serialize(&self, results: &[BenchmarkResult], primary: MetricSelection) -> Result<Vec<u8>> {
|
||||
let stats: Vec<_> = results
|
||||
.iter()
|
||||
.map(|result| {
|
||||
|
||||
+6
-10
@@ -44,12 +44,8 @@ pub enum ExportType {
|
||||
|
||||
/// Interface for different exporters.
|
||||
trait Exporter {
|
||||
/// Export the given entries using a nonempty list of selected metrics, primary first.
|
||||
fn serialize(
|
||||
&self,
|
||||
results: &[BenchmarkResult],
|
||||
metrics: &[MetricSelection],
|
||||
) -> Result<Vec<u8>>;
|
||||
/// Export the given entries in the serialized form.
|
||||
fn serialize(&self, results: &[BenchmarkResult], primary: MetricSelection) -> Result<Vec<u8>>;
|
||||
}
|
||||
|
||||
pub enum ExportTarget {
|
||||
@@ -65,16 +61,16 @@ struct ExporterWithTarget {
|
||||
/// Handles the management of multiple file exporters.
|
||||
pub struct ExportManager {
|
||||
exporters: Vec<ExporterWithTarget>,
|
||||
metrics: Vec<MetricSelection>,
|
||||
primary: MetricSelection,
|
||||
}
|
||||
|
||||
impl ExportManager {
|
||||
/// Build the ExportManager that will export the results specified
|
||||
/// in the given ArgMatches
|
||||
pub fn from_cli_arguments(matches: &ArgMatches, metrics: &[MetricSelection]) -> Result<Self> {
|
||||
pub fn from_cli_arguments(matches: &ArgMatches, primary: MetricSelection) -> Result<Self> {
|
||||
let mut export_manager = Self {
|
||||
exporters: vec![],
|
||||
metrics: metrics.to_vec(),
|
||||
primary,
|
||||
};
|
||||
{
|
||||
let mut add_exporter = |flag, exporttype| -> Result<()> {
|
||||
@@ -124,7 +120,7 @@ impl ExportManager {
|
||||
/// order not to clutter the output of hyperfine with intermediate results).
|
||||
pub fn write_results(&self, results: &[BenchmarkResult], intermediate: bool) -> Result<()> {
|
||||
for e in &self.exporters {
|
||||
let content = || e.exporter.serialize(results, &self.metrics);
|
||||
let content = || e.exporter.serialize(results, self.primary);
|
||||
|
||||
match e.target {
|
||||
ExportTarget::File(ref filename) => {
|
||||
|
||||
+2
-2
@@ -15,10 +15,10 @@ fn get_output<E: Exporter + Default>(results: &[BenchmarkResult], unit: Option<U
|
||||
exporter
|
||||
.serialize(
|
||||
results,
|
||||
&[MetricSelection {
|
||||
MetricSelection {
|
||||
unit,
|
||||
..MetricSelection::default()
|
||||
}],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
|
||||
+3
-25
@@ -11,9 +11,9 @@ use cli::get_cli_arguments;
|
||||
use command::Commands;
|
||||
use error::ConsoleOutputError;
|
||||
use export::ExportManager;
|
||||
use options::{ExecutorKind, Options};
|
||||
use options::Options;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::Result;
|
||||
use colored::*;
|
||||
|
||||
pub mod benchmark;
|
||||
@@ -27,7 +27,6 @@ pub mod outlier_detection;
|
||||
pub mod output;
|
||||
pub mod parameter;
|
||||
pub mod quantity;
|
||||
pub mod shell_syntax;
|
||||
pub mod timer;
|
||||
pub mod util;
|
||||
|
||||
@@ -41,28 +40,7 @@ fn run() -> Result<()> {
|
||||
let commands = Commands::from_cli_arguments(&cli_arguments)?;
|
||||
options.validate_against_command_list(&commands)?;
|
||||
|
||||
if matches!(options.executor_kind, ExecutorKind::Raw)
|
||||
&& !cli_arguments.get_flag("no-shell")
|
||||
&& cli_arguments.get_one::<String>("shell").is_none()
|
||||
{
|
||||
for command in commands.iter() {
|
||||
let command_line = command.get_command_line();
|
||||
if let Some(operator) = shell_syntax::first_unquoted_operator(&command_line) {
|
||||
bail!(
|
||||
concat!(
|
||||
"Command '{command_line}' contains unquoted shell syntax ('{operator}').\n",
|
||||
"Explicitly choose how to execute it:\n",
|
||||
" -S / --shell=default Interpret shell syntax.\n",
|
||||
" -N / --shell=none Run directly, passing '{operator}' as a literal argument.",
|
||||
),
|
||||
command_line = command_line,
|
||||
operator = operator,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let export_manager = ExportManager::from_cli_arguments(&cli_arguments, &options.metrics)?;
|
||||
let export_manager = ExportManager::from_cli_arguments(&cli_arguments, options.metrics[0])?;
|
||||
|
||||
let mut scheduler = Scheduler::new(&commands, &options, &export_manager);
|
||||
scheduler.run_benchmarks()?;
|
||||
|
||||
+8
-16
@@ -1,4 +1,4 @@
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
use std::io::{self, Write};
|
||||
|
||||
use anyhow::{ensure, Result};
|
||||
use colored::Colorize;
|
||||
@@ -12,8 +12,6 @@ use crate::output::console_writeln;
|
||||
const MIN_VALUE_WIDTH: usize = 7;
|
||||
const MIN_UNIT_WIDTH: usize = 3;
|
||||
const MIN_CHANGE_WIDTH: usize = 14;
|
||||
// Leave room for long metric labels and comparison factors in the full layout.
|
||||
const COMPACT_WIDTH_THRESHOLD: u16 = 110;
|
||||
|
||||
/// Print a completed benchmark immediately, comparing every metric with benchmark 1.
|
||||
pub fn print(
|
||||
@@ -24,10 +22,6 @@ pub fn print(
|
||||
skip_unavailable_metrics: bool,
|
||||
reference: Option<&BenchmarkResult>,
|
||||
) -> Result<()> {
|
||||
// Only adapt interactive output; redirected reports keep their usual layout.
|
||||
let compact = io::stdout().is_terminal()
|
||||
&& terminal_size::terminal_size()
|
||||
.is_some_and(|(terminal_size::Width(width), _)| width < COMPACT_WIDTH_THRESHOLD);
|
||||
let comparison = reference.is_some();
|
||||
let multiple_runs = measurements.len() > 1;
|
||||
let value_columns = 1..if multiple_runs { 5 } else { 2 };
|
||||
@@ -63,12 +57,10 @@ pub fn print(
|
||||
.unwrap_or(&summary)
|
||||
.mean;
|
||||
let unit = selection.display_unit(scale_mean);
|
||||
let label = if compact && unit.symbol != "count" {
|
||||
format!("{} [{}]", selection.metric.label(), unit.symbol)
|
||||
} else {
|
||||
selection.metric.label().to_owned()
|
||||
};
|
||||
let mut row = vec![label, unit.format_value(summary.mean)];
|
||||
let mut row = vec![
|
||||
selection.metric.label().to_owned(),
|
||||
unit.format_value(summary.mean),
|
||||
];
|
||||
if let Some(stddev) = summary.stddev {
|
||||
row.push(unit.format_value(stddev));
|
||||
row.push(unit.format_value(summary.min));
|
||||
@@ -116,9 +108,9 @@ pub fn print(
|
||||
if column == 0 {
|
||||
width.max(metric_width)
|
||||
} else if value_columns.contains(&column) {
|
||||
width.max(if compact { 5 } else { MIN_VALUE_WIDTH })
|
||||
width.max(MIN_VALUE_WIDTH)
|
||||
} else {
|
||||
width.max(if compact { 7 } else { MIN_CHANGE_WIDTH })
|
||||
width.max(MIN_CHANGE_WIDTH)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -155,7 +147,7 @@ pub fn print(
|
||||
});
|
||||
}
|
||||
let padding = " ".repeat(widths[column] - value.chars().count());
|
||||
let has_unit = !compact && value_columns.contains(&column);
|
||||
let has_unit = value_columns.contains(&column);
|
||||
let text = if has_unit && !unit.is_empty() {
|
||||
format!("{value} {unit}")
|
||||
} else {
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
//! Conservative detection of shell operators in commands run without a shell.
|
||||
|
||||
use std::iter::Peekable;
|
||||
use std::str::CharIndices;
|
||||
|
||||
/// Find the first standalone operator that is neither quoted nor escaped.
|
||||
///
|
||||
/// Operators attached to another word (such as `hello>out`), expansions, and
|
||||
/// other shell syntax are deliberately outside the scope of this check.
|
||||
pub fn first_unquoted_operator(command: &str) -> Option<&'static str> {
|
||||
tokenize(command).find_map(|token| {
|
||||
["&&", "||", "|", ";", ">", ">>", "<"]
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|operator| {
|
||||
// An escaped newline joins lines without quoting either side.
|
||||
// All other quotes and escapes remain in the token, preventing
|
||||
// it from matching an operator.
|
||||
token
|
||||
.split("\\\n")
|
||||
.flat_map(str::bytes)
|
||||
.eq(operator.bytes())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Split words while preserving their original quoting and escaping.
|
||||
///
|
||||
/// Word boundaries and comments follow the same rules as `shell_words`, which
|
||||
/// parses commands for direct execution. This tokenizer neither expands shell
|
||||
/// expressions nor validates balanced quotes; command parsing handles errors.
|
||||
fn tokenize(command: &str) -> impl Iterator<Item = &str> {
|
||||
Tokens {
|
||||
command,
|
||||
chars: command.char_indices().peekable(),
|
||||
}
|
||||
}
|
||||
|
||||
struct Tokens<'a> {
|
||||
command: &'a str,
|
||||
chars: Peekable<CharIndices<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> Iterator for Tokens<'a> {
|
||||
type Item = &'a str;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
// Whitespace, comments, and line continuations cannot start a word.
|
||||
let start = loop {
|
||||
let &(index, character) = self.chars.peek()?;
|
||||
match character {
|
||||
' ' | '\t' | '\n' => {
|
||||
self.chars.next();
|
||||
}
|
||||
'#' => {
|
||||
for (_, character) in self.chars.by_ref() {
|
||||
if character == '\n' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
'\\' if self.chars.clone().nth(1).map(|(_, c)| c) == Some('\n') => {
|
||||
self.chars.next();
|
||||
self.chars.next();
|
||||
}
|
||||
_ => break index,
|
||||
}
|
||||
};
|
||||
|
||||
let mut quote = None;
|
||||
while let Some((index, character)) = self.chars.next() {
|
||||
match (quote, character) {
|
||||
(Some('\''), '\'') | (Some('"'), '"') => quote = None,
|
||||
(Some('\''), _) => {}
|
||||
(_, '\\') => {
|
||||
// Within double quotes, skipping a non-special escaped
|
||||
// character is also safe: it cannot end the quoted word.
|
||||
self.chars.next();
|
||||
}
|
||||
(Some('"'), _) => {}
|
||||
(None, '\'' | '"') => quote = Some(character),
|
||||
(None, ' ' | '\t' | '\n') => return Some(&self.command[start..index]),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Some(&self.command[start..])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{first_unquoted_operator, tokenize};
|
||||
|
||||
#[test]
|
||||
fn tokenizer_preserves_words_quotes_and_escapes() {
|
||||
for (command, expected) in [
|
||||
("", vec![]),
|
||||
(" \t\n", vec![]),
|
||||
(" echo\thello\nworld ", vec!["echo", "hello", "world"]),
|
||||
(
|
||||
r#"echo '' "" 'hello world'"#,
|
||||
vec!["echo", "''", "\"\"", "'hello world'"],
|
||||
),
|
||||
(
|
||||
r#"echo pre'quoted part'"more"post"#,
|
||||
vec!["echo", r#"pre'quoted part'"more"post"#],
|
||||
),
|
||||
(
|
||||
r"echo hello\ world \&\&",
|
||||
vec!["echo", r"hello\ world", r"\&\&"],
|
||||
),
|
||||
(r#"echo "a\" b" 'a\'"#, vec!["echo", r#""a\" b""#, r"'a\'"]),
|
||||
("echo hello>out", vec!["echo", "hello>out"]),
|
||||
(
|
||||
"echo café\t&&\n日本語",
|
||||
vec!["echo", "café", "&&", "日本語"],
|
||||
),
|
||||
("echo a\r\u{a0}b", vec!["echo", "a\r\u{a0}b"]),
|
||||
] {
|
||||
assert_eq!(
|
||||
tokenize(command).collect::<Vec<_>>(),
|
||||
expected,
|
||||
"{command:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokenizer_handles_comments_and_continuations() {
|
||||
for (command, expected) in [
|
||||
("# ignored &&", vec![]),
|
||||
(
|
||||
"echo # ignored &&\nnext | cat",
|
||||
vec!["echo", "next", "|", "cat"],
|
||||
),
|
||||
(
|
||||
r##"echo a#b \#c "#d" '#e'"##,
|
||||
vec!["echo", "a#b", r"\#c", "\"#d\"", "'#e'"],
|
||||
),
|
||||
("\\\n echo \\\n&& more", vec!["echo", "&&", "more"]),
|
||||
("echo hello\\\nworld", vec!["echo", "hello\\\nworld"]),
|
||||
("\\\n# comment\necho", vec!["echo"]),
|
||||
] {
|
||||
assert_eq!(
|
||||
tokenize(command).collect::<Vec<_>>(),
|
||||
expected,
|
||||
"{command:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_supported_operators_in_command_order() {
|
||||
for operator in ["&&", "||", "|", ";", ">", ">>", "<"] {
|
||||
let command = format!("echo café\t{operator}\n日本語");
|
||||
assert_eq!(first_unquoted_operator(&command), Some(operator));
|
||||
assert_eq!(first_unquoted_operator(operator), Some(operator));
|
||||
}
|
||||
assert_eq!(first_unquoted_operator("echo a >> out && next"), Some(">>"));
|
||||
assert_eq!(first_unquoted_operator("echo \\\n&& next"), Some("&&"));
|
||||
assert_eq!(first_unquoted_operator("echo &\\\n& next"), Some("&&"));
|
||||
assert_eq!(first_unquoted_operator("echo &&\\\n next"), Some("&&"));
|
||||
assert_eq!(first_unquoted_operator("echo # &&\nnext | cat"), Some("|"));
|
||||
assert_eq!(first_unquoted_operator(r#"echo "&&" || next"#), Some("||"));
|
||||
assert_eq!(first_unquoted_operator(r"echo \&\& | cat"), Some("|"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_quoted_escaped_and_attached_operators() {
|
||||
for command in [
|
||||
r#"echo "&&""#,
|
||||
"echo '&&'",
|
||||
r"echo \&\&",
|
||||
r"echo &\&",
|
||||
r#"echo '&'"&""#,
|
||||
r#"echo ""&&"#,
|
||||
"echo &&''",
|
||||
r#"python -c "print(1 > 0)""#,
|
||||
"sh -c 'true && false'",
|
||||
r#"sh -c "echo 'a | b' && false""#,
|
||||
r#"sh -c 'echo "a | b" && false'"#,
|
||||
r#"echo "escaped \" && still quoted""#,
|
||||
"echo hello>out",
|
||||
"true&&false",
|
||||
"echo a;",
|
||||
"echo 2> out",
|
||||
"echo >>> out",
|
||||
"echo $VARIABLE *.rs ~/path NAME=value",
|
||||
"echo # && ignored",
|
||||
"echo foo#bar",
|
||||
"echo\u{a0}&&",
|
||||
"echo &&\r",
|
||||
] {
|
||||
assert_eq!(first_unquoted_operator(command), None, "{command:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_empty_and_malformed_commands_without_panicking() {
|
||||
for (command, expected) in [
|
||||
("", vec![]),
|
||||
("\\\n", vec![]),
|
||||
("\\", vec!["\\"]),
|
||||
(r"echo \", vec!["echo", r"\"]),
|
||||
("echo 'unclosed &&", vec!["echo", "'unclosed &&"]),
|
||||
("echo \"unclosed &&", vec!["echo", "\"unclosed &&"]),
|
||||
("echo \"unclosed\\", vec!["echo", "\"unclosed\\"]),
|
||||
] {
|
||||
assert_eq!(
|
||||
tokenize(command).collect::<Vec<_>>(),
|
||||
expected,
|
||||
"{command:?}"
|
||||
);
|
||||
assert_eq!(first_unquoted_operator(command), None, "{command:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,42 +266,6 @@ fn fails_with_duplicate_parameter_names() {
|
||||
.stderr(predicate::str::contains("Duplicate parameter names: x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_explicit_shell_for_shell_syntax() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
|
||||
let assert = hyperfine()
|
||||
.current_dir(directory.path())
|
||||
.arg("rm -rf cache/ && ./my_command")
|
||||
.assert()
|
||||
.failure();
|
||||
|
||||
insta::assert_snapshot!(String::from_utf8_lossy(&assert.get_output().stderr), @"
|
||||
Error: Command 'rm -rf cache/ && ./my_command' contains unquoted shell syntax ('&&').
|
||||
Explicitly choose how to execute it:
|
||||
-S / --shell=default Interpret shell syntax.
|
||||
-N / --shell=none Run directly, passing '&&' as a literal argument.
|
||||
");
|
||||
|
||||
hyperfine()
|
||||
.arg("--show-output")
|
||||
.arg("--command-name=benchmark")
|
||||
.args(["--shell=default", "--runs=1", "echo before && echo after"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("before"))
|
||||
.stdout(predicate::str::contains("after"))
|
||||
.stdout(predicate::str::contains("&&").not());
|
||||
|
||||
hyperfine()
|
||||
.arg("--show-output")
|
||||
.arg("--command-name=benchmark")
|
||||
.args(["--shell=none", "--runs=1", "echo before && echo after"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("before && echo after"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fails_for_unknown_command() {
|
||||
hyperfine()
|
||||
|
||||
Reference in new issue
Block a user