Compare commits

..
Author SHA1 Message Date
David Peter 27c98c6f87 Make plotting scripts runnable with uv 2026-10-07 11:24:14 +02:00
17 changed files with 84 additions and 414 deletions

No files matched your search

-9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"] }
+2 -15
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+1 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
-218
View File
@@ -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:?}");
}
}
}
-36
View File
@@ -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()