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 27 additions and 603 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"] }
-29
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
@@ -154,31 +150,6 @@ Otherwise, inline the function into the benchmarked command:
hyperfine -S 'my_function() { sleep 1; }; my_function'
```
### Environment variables
Use `--env` to set environment variables for benchmark commands and their respective setup, prepare, conclude,
and cleanup commands. Assignments are space-separated and support shell-style quoting,
but no shell expansion:
```sh
hyperfine --env 'OMP_NUM_THREADS=8 CFLAGS="-O3 -march=native"' 'my_command'
```
Specify once for all commands or once per benchmark command:
```sh
hyperfine \
--env 'OMP_NUM_THREADS=8' './benchmark-cpp' \
--env 'RAYON_NUM_THREADS=8' './benchmark-rust'
```
Use an empty `--env=''` for no overrides. Values can contain parameter placeholders.
For example, the following benchmarks `my_command` with `OMP_NUM_THREADS` ranging from 1 to 8:
```sh
hyperfine -P threads 1 8 --env 'OMP_NUM_THREADS={threads}' 'my_command'
```
### Choosing metrics and units
By default, hyperfine displays wall-clock time and memory usage
-11
View File
@@ -192,17 +192,6 @@ This performs benchmarks for 'g++ \-O2 main.cpp' and 'clang++ \-O2 main.cpp'.
The option can be specified multiple times to run benchmarks for all
possible parameter combinations.
.HP
\fB\-\-env\fR \fIASSIGNMENTS\fP
.IP
Set environment variables for benchmark commands and their respective setup, prepare, conclude,
and cleanup commands. Assignments are space-separated and support shell-style quoting,
but no shell expansion, e.g. \fB\-\-env\fR 'OMP_NUM_THREADS=8 CFLAGS="\-O3 \-march=native"'.
Specify once for all commands or once per benchmark command, like \fB\-\-prepare\fR.
Use an empty string for no overrides. Values can contain parameter placeholders.
.IP
For example, \fBhyperfine \-P threads 1 8 \-\-env\fR 'OMP_NUM_THREADS={threads}' 'my_command'
benchmarks 'my_command' with OMP_NUM_THREADS ranging from 1 to 8.
.HP
\fB\-\-shell\fR \fISHELL\fP
.IP
Set the shell to use for executing benchmarked commands, including warmup runs
+6 -8
View File
@@ -18,13 +18,15 @@ pub struct BenchmarkResult {
/// regardless of any custom name.
pub command: String,
/// The custom or automatically generated name after parameter substitution,
/// if it differs from `command`. Automatic names include environment overrides.
/// The custom name after parameter substitution, if it differs from `command`.
/// For example, `--command-name="wait {duration}s"` with `duration=1`
/// produces `Some("wait 1s")`. Without a custom name, or when the expanded
/// name equals `command`, this is `None`.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// The name, followed by parameters not used in the command or environment
/// templates, in command-line order. For example, `sleep {duration}`
/// The custom name (or `command`), followed by parameters not used in the
/// command template, in command-line order. For example, `sleep {duration}`
/// with `duration=1`, `branch=main`, and the custom name `wait {duration}s`
/// produces `wait 1s (branch = main)`. Without the custom name, this is
/// `sleep 1 (branch = main)`. Used in summaries and table exports.
@@ -38,10 +40,6 @@ pub struct BenchmarkResult {
/// Parameter values for this benchmark
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub parameters: BTreeMap<String, Parameter>,
/// Explicit environment overrides after parameter substitution.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub environment: BTreeMap<String, String>,
}
impl BenchmarkResult {
-1
View File
@@ -215,7 +215,6 @@ impl Executor for ShellExecutor<'_> {
) -> Result<Measurement> {
let on_windows_cmd = cfg!(windows) && *self.shell == Shell::Default("cmd.exe");
let mut command_builder = self.shell.command();
command_builder.envs(command.get_environment());
command_builder.arg(if on_windows_cmd { "/C" } else { "-c" });
// Windows needs special treatment for its behavior on parsing cmd arguments
+1 -7
View File
@@ -61,9 +61,8 @@ impl<'a> Benchmark<'a> {
output_policy: &CommandOutputPolicy,
iteration: executor::BenchmarkIteration,
) -> Result<Measurement> {
let command = command.clone().with_environment_from(self.command);
self.executor
.run_intermediate_command_and_measure(&command, iteration, output_policy)
.run_intermediate_command_and_measure(command, iteration, output_policy)
.map_err(|error| {
let hint = if cfg!(windows) {
"Append ' || exit /b 0' to the command if this failure can be ignored."
@@ -444,11 +443,6 @@ impl<'a> Benchmark<'a> {
Ok(BenchmarkResult {
command,
name,
environment: self
.command
.get_environment()
.map(|(name, value)| (name.to_owned(), value))
.collect(),
display_name: self.command.get_name_with_unused_parameters(),
measurements,
parameters: self
-22
View File
@@ -119,28 +119,6 @@ fn build_command() -> Command {
.help("Perform exactly NUM runs for each command. If this option is not specified, \
hyperfine automatically determines the number of runs."),
)
.arg(
Arg::new("env")
.long("env")
.action(ArgAction::Append)
.num_args(1)
.value_name("ASSIGNMENTS")
.help("Set environment variables for benchmark commands and their respective setup, \
prepare, conclude, and cleanup commands. Assignments are space-separated and \
support shell-style quoting, but no shell expansion:\n\n \
hyperfine \\\n \
--env 'OMP_NUM_THREADS=8 CFLAGS=\"-O3 -march=native\"' \\\n \
'my_command'\n\n\
Specify once for all commands or once per benchmark command:\n\n \
hyperfine \\\n \
--env 'OMP_NUM_THREADS=8' './benchmark-cpp' \\\n \
--env 'RAYON_NUM_THREADS=8' './benchmark-rust'\n\n\
Use an empty --env='' for no overrides. Values can contain parameter placeholders. \
For example, the following benchmarks 'my_command' with OMP_NUM_THREADS ranging \
from 1 to 8:\n\n \
hyperfine -P threads 1 8 \\\n \
--env 'OMP_NUM_THREADS={threads}' 'my_command'"),
)
.arg(
Arg::new("setup")
.long("setup")
+6 -91
View File
@@ -14,11 +14,9 @@ use crate::{
use clap::{parser::ValuesRef, ArgMatches};
use anyhow::{bail, ensure, Context, Result};
use anyhow::{bail, Context, Result};
use rust_decimal::Decimal;
type Environment = Vec<(String, String)>;
/// A command that should be benchmarked.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Command<'a> {
@@ -30,9 +28,6 @@ pub struct Command<'a> {
/// Zero or more parameter values.
parameters: Vec<ParameterNameAndValue<'a>>,
/// Environment overrides, before parameter substitution.
environment: Environment,
}
impl<'a> Command<'a> {
@@ -41,7 +36,6 @@ impl<'a> Command<'a> {
name,
expression,
parameters: Vec::new(),
environment: Vec::new(),
}
}
@@ -54,26 +48,14 @@ impl<'a> Command<'a> {
name,
expression,
parameters: parameters.into_iter().collect(),
environment: Vec::new(),
}
}
pub fn get_name(&self) -> String {
match self.name {
Some(name) => self.replace_parameters_in(name),
None => {
let environment = self
.get_environment()
.map(|(name, value)| format!("{name}={}", shell_words::quote(&value)))
.collect::<Vec<_>>()
.join(" ");
if environment.is_empty() {
self.get_command_line()
} else {
format!("{environment} {}", self.get_command_line())
}
}
}
self.name.map_or_else(
|| self.get_command_line(),
|name| self.replace_parameters_in(name),
)
}
pub fn get_name_with_unused_parameters(&self) -> String {
@@ -105,7 +87,6 @@ impl<'a> Command<'a> {
if let Some(program_name) = tokens.next() {
let mut command_builder = std::process::Command::new(program_name);
command_builder.args(tokens);
command_builder.envs(self.get_environment());
Ok(command_builder)
} else {
bail!("Can not execute empty command")
@@ -116,24 +97,8 @@ impl<'a> Command<'a> {
&self.parameters
}
pub fn get_environment(&self) -> impl Iterator<Item = (&str, String)> {
self.environment
.iter()
.map(move |(name, value)| (name.as_str(), self.replace_parameters_in(value)))
}
pub fn with_environment_from(mut self, command: &Command<'_>) -> Self {
self.environment = command.environment.clone();
self
}
pub fn is_parameter_unused(&self, parameter: &str) -> bool {
let placeholder = format!("{{{parameter}}}");
!self.expression.contains(&placeholder)
&& !self
.environment
.iter()
.any(|(_, value)| value.contains(&placeholder))
!self.expression.contains(&format!("{{{parameter}}}"))
}
pub fn get_unused_parameters(&self) -> impl Iterator<Item = &(&'a str, ParameterValue)> {
@@ -172,58 +137,8 @@ impl<'a> Command<'a> {
/// A collection of commands that should be benchmarked
pub struct Commands<'a>(Vec<Command<'a>>);
fn parse_environment(value: &str) -> Result<Environment> {
let mut environment = Environment::new();
for assignment in shell_words::split(value).context("Failed to parse '--env' assignments")? {
let (name, value) = assignment.split_once('=').with_context(|| {
format!(
"Invalid value '{assignment}' for '--env': expected an assignment like NAME=value or NAME=\"quoted value\"."
)
})?;
ensure!(
!name.is_empty()
&& name.bytes().enumerate().all(|(index, byte)| {
byte == b'_'
|| byte.is_ascii_alphabetic()
|| (index > 0 && byte.is_ascii_digit())
}),
"Invalid environment variable name '{name}' in '--env'"
);
// Keep only the last assignment, including case variants on Windows.
environment.retain(|(key, _)| {
if cfg!(windows) {
!key.eq_ignore_ascii_case(name)
} else {
key != name
}
});
environment.push((name.to_owned(), value.to_owned()));
}
Ok(environment)
}
impl<'a> Commands<'a> {
pub fn from_cli_arguments(matches: &'a ArgMatches) -> Result<Commands<'a>> {
let mut commands = Self::build_from_cli_arguments(matches)?;
if let Some(values) = matches.get_many::<String>("env") {
let environments = values
.map(|value| parse_environment(value))
.collect::<Result<Vec<_>>>()?;
let num_commands = commands.num_commands();
ensure!(
environments.len() == 1 || environments.len() == num_commands,
"The '--env' option has to be provided just once or N times, where N={num_commands} is the \
number of benchmark commands."
);
for (index, command) in commands.0.iter_mut().enumerate() {
command.environment =
environments[if environments.len() == 1 { 0 } else { index }].clone();
}
}
Ok(commands)
}
fn build_from_cli_arguments(matches: &'a ArgMatches) -> Result<Commands<'a>> {
let command_names = matches.get_many::<String>("command-name");
let command_strings = matches
.get_many::<String>("command")
-2
View File
@@ -88,7 +88,6 @@ fn test_csv() {
let results = vec![
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("echo command_a"),
name: Some(String::from("command_a")),
display_name: String::from("command_a"),
@@ -139,7 +138,6 @@ fn test_csv() {
},
},
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("command_b"),
name: None,
display_name: String::from("command_b"),
-8
View File
@@ -35,7 +35,6 @@ fn get_output<E: Exporter + Default>(results: &[BenchmarkResult], unit: Option<U
fn test_markup_export_auto_ms() {
let results = [
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 0.1"),
name: None,
display_name: String::from("sleep 0.1"),
@@ -71,7 +70,6 @@ fn test_markup_export_auto_ms() {
parameters: BTreeMap::new(),
},
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 2"),
name: None,
display_name: String::from("sleep 2"),
@@ -149,7 +147,6 @@ fn test_markup_export_auto_ms() {
fn test_markup_export_auto_s() {
let results = [
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 2"),
name: None,
display_name: String::from("sleep 2"),
@@ -185,7 +182,6 @@ fn test_markup_export_auto_s() {
parameters: BTreeMap::new(),
},
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 0.1"),
name: None,
display_name: String::from("sleep 0.1"),
@@ -263,7 +259,6 @@ fn test_markup_export_auto_s() {
fn test_markup_export_manual_ms() {
let timing_results = [
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 2"),
name: None,
display_name: String::from("sleep 2"),
@@ -299,7 +294,6 @@ fn test_markup_export_manual_ms() {
parameters: BTreeMap::new(),
},
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 0.1"),
name: None,
display_name: String::from("sleep 0.1"),
@@ -376,7 +370,6 @@ fn test_markup_export_manual_ms() {
fn test_markup_export_manual_s() {
let results = [
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 2"),
name: None,
display_name: String::from("sleep 2"),
@@ -412,7 +405,6 @@ fn test_markup_export_manual_s() {
parameters: BTreeMap::new(),
},
BenchmarkResult {
environment: BTreeMap::new(),
command: String::from("sleep 0.1"),
name: None,
display_name: String::from("sleep 0.1"),
+2 -32
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,35 +40,6 @@ 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 shell_syntax::starts_with_assignment(&command_line) {
bail!(
"Cannot run '{command_line}' directly because it starts with a variable assignment.\n\
Use the --env option to set environment variables, for example:\n\n \
hyperfine --env 'OMP_NUM_THREADS=8 CFLAGS=\"-O3 -march=native\"' 'my_command'\n\n\
Alternatively, use -S / --shell=default to run the command in a shell."
);
}
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[0])?;
let mut scheduler = Scheduler::new(&commands, &options, &export_manager);
+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 {
-265
View File
@@ -1,265 +0,0 @@
//! Conservative detection of shell syntax in commands run without a shell.
use std::iter::Peekable;
use std::str::CharIndices;
/// Detects the variable assignment at the start of a command like
/// `OMP_NUM_THREADS=8 my_command`.
pub fn starts_with_assignment(command: &str) -> bool {
let Some(token) = tokenize(command).next() else {
return false;
};
let mut bytes = token.bytes();
if !bytes
.next()
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
{
return false;
}
for byte in bytes {
if byte == b'=' {
return true;
}
if !byte.is_ascii_alphanumeric() && byte != b'_' {
return false;
}
}
false
}
/// 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, starts_with_assignment, tokenize};
#[test]
fn detects_leading_assignments() {
assert!(starts_with_assignment("A=1 command"));
assert!(starts_with_assignment("A=$HOME command"));
assert!(starts_with_assignment("A= command"));
assert!(starts_with_assignment("_A2='hello world' B=2 command"));
assert!(starts_with_assignment("A=1"));
assert!(starts_with_assignment(" # comment\n A=1 command"));
assert!(!starts_with_assignment(""));
assert!(!starts_with_assignment("# A=1 command"));
assert!(!starts_with_assignment("command A=1"));
assert!(!starts_with_assignment("env A=1 command"));
assert!(!starts_with_assignment("'A=1' command"));
assert!(!starts_with_assignment("\"A\"=1 command"));
assert!(!starts_with_assignment(r"A\=1 command"));
assert!(!starts_with_assignment("./A=1 command"));
assert!(!starts_with_assignment("1A=1 command"));
assert!(!starts_with_assignment("A-B=1 command"));
assert!(!starts_with_assignment("=1 command"));
assert!(!starts_with_assignment("A"));
}
#[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:?}");
}
}
}
-96
View File
@@ -53,66 +53,6 @@ fn one_run_is_supported() {
.success();
}
#[test]
fn environment_basic_usage() {
let command = if cfg!(windows) {
"echo %HYPERFINE_TEST%"
} else {
"echo \"$HYPERFINE_TEST\""
};
hyperfine()
.args(["--shell=default", "--runs=1", "--show-output"])
.arg("--command-name=env-test")
.args(["--env", r#"HYPERFINE_TEST="hello, world""#])
.arg(command)
.assert()
.success()
.stdout(predicate::str::contains("hello, world"));
}
#[test]
fn environment_parameter_expansion() {
let command = if cfg!(windows) {
"echo %HYPERFINE_TEST%"
} else {
"echo \"$HYPERFINE_TEST\""
};
hyperfine()
.args(["--shell=default", "--runs=1", "--show-output"])
.arg("--command-name=env-test")
.args(["-P", "value", "1", "2"])
.args(["--env", "HYPERFINE_TEST=hello-{value}"])
.arg(command)
.assert()
.success()
.stdout(predicate::str::contains("hello-1"))
.stdout(predicate::str::contains("hello-2"));
}
#[test]
fn rejects_invalid_environment() {
hyperfine()
.args(["--env", "OMP_NUM_THREADS"])
.arg("my_command")
.assert()
.failure()
.stderr(predicate::str::contains(
"Invalid value 'OMP_NUM_THREADS' for '--env': expected an assignment like NAME=value or NAME=\"quoted value\".",
));
}
#[test]
fn requires_explicit_shell_for_environment_assignments() {
// Without an explicit shell choice, report the leading variable assignment.
hyperfine()
.args(["--runs=1", "SOME_ENV_VAR=$HOME my_command"])
.assert()
.failure()
.stderr(predicate::str::contains(
"starts with a variable assignment",
));
}
/// Regression test: hyperfine must not panic when writing to a closed
/// stdout pipe (e.g. `hyperfine ... | head -n 1` or a downstream process
/// that terminates early). A `BrokenPipe` error should result in a quiet
@@ -326,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()