Skip to content
Open
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
103 changes: 95 additions & 8 deletions src/runtime/shell/builtin/seq.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::io::Write as _;

use crate::shell::builtin::{Builtin, BuiltinState, IoKind, Kind};
use crate::shell::interpreter::{Interpreter, NodeId};
use crate::shell::interpreter::{Interpreter, NodeId, ParseError, unsupported_flag};
use crate::shell::io_writer::{ChildPtr, WriterTag};
use crate::shell::yield_::Yield;

Expand All @@ -13,11 +13,21 @@ enum State {
Done,
}

/// `core::fmt` panics on a precision or width above `u16::MAX`, which an
/// operand like `1e-70000` would request; no float has more than 1074
/// fractional digits (the smallest f64 is 2^-1074), so the rest would be zeros.
Comment thread
robobun marked this conversation as resolved.
const MAX_FIXED_WIDTH_DECIMALS: u32 = 1074;

pub struct Seq {
state: State,
start: f32,
end: f32,
increment: f32,
/// Most decimal places any positional argument was written with
/// (`seq 0 0.25 1` → 2); `-w` prints every value with this many.
Comment thread
robobun marked this conversation as resolved.
decimals: u32,
/// `-w` / `--fixed-width`: zero-pad every value to the same width.
fixed_width: bool,
/// Borrowed from argv (NUL-terminated arena strings) or `'static` literals;
/// argv outlives the builtin — `RawSlice` invariant.
separator: bun_ptr::RawSlice<u8>,
Expand All @@ -31,6 +41,8 @@ impl Default for Seq {
start: 1.0,
end: 1.0,
increment: 1.0,
decimals: 0,
fixed_width: false,
separator: bun_ptr::RawSlice::new(b"\n"),
terminator: bun_ptr::RawSlice::EMPTY,
}
Expand Down Expand Up @@ -81,20 +93,39 @@ impl Seq {
continue;
}
if arg == b"-w" || arg == b"--fixed-width" {
Self::state_mut(interp, cmd).fixed_width = true;
idx += 1;
continue;
}
if arg.starts_with(b"-f") || arg == b"--format" {
let flag: &'static [u8] = if arg == b"--format" {
b"--format"
} else {
b"-f"
};
return Builtin::fail_parse(
interp,
cmd,
Kind::Seq,
&ParseError::Unsupported(unsupported_flag(flag)),
|| Self::state_mut(interp, cmd).state = State::Err,
);
}
break;
}

// Positional args.
macro_rules! parse_num {
($i:expr) => {{
let s = Builtin::of(interp, cmd).arg_bytes($i);
match parse_f32(s) {
let n = match parse_f32(s) {
Some(n) if n.is_finite() => n,
_ => return Self::fail(interp, cmd, b"seq: invalid argument\n"),
}
};
let decimals = decimal_places(s);
let me = Self::state_mut(interp, cmd);
me.decimals = me.decimals.max(decimals);
n
}};
}

Expand Down Expand Up @@ -159,9 +190,19 @@ impl Seq {
let needs_io = Builtin::of(interp, cmd).stdout.needs_io().is_some();
// Render entirely into a local Vec, then either enqueue it or
// write_no_io it; we buffer once for simplicity.
let (start, end, incr, sep, term) = {
let (start, end, incr, sep, term, fixed_width) = {
let me = Self::state_mut(interp, cmd);
(me.start, me.end, me.increment, me.separator, me.terminator)
let fixed_width = me
.fixed_width
.then(|| FixedWidth::new(me.start, me.end, me.decimals));
(
me.start,
me.end,
me.increment,
me.separator,
me.terminator,
fixed_width,
)
};
let mut out = Vec::new();
let mut current = start;
Expand All @@ -170,9 +211,14 @@ impl Seq {
} else {
current >= end
} {
// Rust `{}` for f32 prints the shortest decimal that round-trips
// (no exponent, no trailing ".0").
let _ = write!(&mut out, "{}", current);
let _ = match fixed_width {
Some(FixedWidth { width, decimals }) => {
write!(&mut out, "{current:0width$.decimals$}")
}
// Rust `{}` for f32 prints the shortest decimal that round-trips
// (no exponent, no trailing ".0").
Comment thread
robobun marked this conversation as resolved.
None => write!(&mut out, "{}", current),
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
out.extend_from_slice(sep.slice());
let next = current + incr;
if next == current {
Expand Down Expand Up @@ -218,7 +264,48 @@ impl Seq {
}
}

/// `-w` layout: `decimals` fraction digits, zero-padded after the sign
/// (`-05`) to `width`, as in BSD and GNU seq.
Comment thread
robobun marked this conversation as resolved.
#[derive(Clone, Copy)]
struct FixedWidth {
width: usize,
decimals: usize,
}

impl FixedWidth {
/// Every value lies between `start` and `end`, and at a fixed number of
/// decimals it prints no wider than the bound on its side of zero.
Comment thread
robobun marked this conversation as resolved.
fn new(start: f32, end: f32, decimals: u32) -> Self {
let decimals = decimals.min(MAX_FIXED_WIDTH_DECIMALS) as usize;
let len = |n: f32| bun_core::fmt::count(format_args!("{n:.decimals$}"));
Self {
width: len(start).max(len(end)),
decimals,
}
}
}

#[inline]
fn parse_f32(bytes: &[u8]) -> Option<f32> {
bun_core::fmt::parse_f32(bytes)
}

/// Decimal places a positional argument was written with: `0.25` → 2,
/// `1e-3` → 3, `2.50e1` → 1. `arg` has already been accepted by `parse_f32`,
/// so it has the shape `[sign]digits[.digits][e[sign]digits]`.
Comment thread
robobun marked this conversation as resolved.
fn decimal_places(arg: &[u8]) -> u32 {
let (mantissa, exponent) = match bun_core::strings::index_of_any(arg, b"eE") {
Some(e) => {
// An exponent outside i32 either overflowed to inf (rejected by
// the caller) or underflowed to 0, which needs no decimal places.
Comment thread
robobun marked this conversation as resolved.
let exponent = bun_core::fmt::parse_decimal::<i32>(&arg[e + 1..]).unwrap_or(0);
(&arg[..e], i64::from(exponent))
}
None => (arg, 0),
};
let fraction = match bun_core::strings::index_of_char_usize(mantissa, b'.') {
Some(dot) => (mantissa.len() - dot - 1) as i64,
None => 0,
};
(fraction - exponent).clamp(0, i64::from(u32::MAX)) as u32
}
155 changes: 155 additions & 0 deletions test/js/bun/shell/commands/seq.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,161 @@ describe("seq", async () => {
.stdout("1\n")
.stderr("")
.runAsTest("terminates when the increment is too small to advance the accumulator");

TestBuilder.command`seq 1 0.5 3`
.exitCode(0)
.stdout("1\n1.5\n2\n2.5\n3\n")
.stderr("")
.runAsTest("without -w, values print in their shortest form");

TestBuilder.command`seq -f %g 1 3`
.exitCode(1)
.stdout("")
.stderr("seq: unsupported option, please open a GitHub issue -- -f\n")
.runAsTest("-f is reported as unsupported");

TestBuilder.command`seq -f%03g 1 3`
.exitCode(1)
.stdout("")
.stderr("seq: unsupported option, please open a GitHub issue -- -f\n")
.runAsTest("-f with the format attached is reported as unsupported");

TestBuilder.command`seq -f`
.exitCode(1)
.stdout("")
.stderr("seq: unsupported option, please open a GitHub issue -- -f\n")
.runAsTest("-f without a format is reported as unsupported");

TestBuilder.command`seq --format %g 1 3`
.exitCode(1)
.stdout("")
.stderr("seq: unsupported option, please open a GitHub issue -- --format\n")
.runAsTest("--format is reported as unsupported");

TestBuilder.command`seq -s , -f %g 1 3`
.exitCode(1)
.stdout("")
.stderr("seq: unsupported option, please open a GitHub issue -- -f\n")
.runAsTest("-f after other flags is reported as unsupported");
});

describe("seq -w", async () => {
TestBuilder.command`seq -w 8 11`
.exitCode(0)
.stdout("08\n09\n10\n11\n")
.stderr("")
.runAsTest("pads to the width of the widest value");

TestBuilder.command`seq --fixed-width 8 11`
.exitCode(0)
.stdout("08\n09\n10\n11\n")
.stderr("")
.runAsTest("--fixed-width is the long form of -w");

TestBuilder.command`seq -w 11 8`
.exitCode(0)
.stdout("11\n10\n09\n08\n")
.stderr("")
.runAsTest("pads when counting down");

TestBuilder.command`seq -w 10`
.exitCode(0)
.stdout("01\n02\n03\n04\n05\n06\n07\n08\n09\n10\n")
.stderr("")
.runAsTest("pads with a single operand");

TestBuilder.command`seq -w 1 3`
.exitCode(0)
.stdout("1\n2\n3\n")
.stderr("")
.runAsTest("adds nothing when the values already share a width");

TestBuilder.command`seq -w 99 1 101`
.exitCode(0)
.stdout("099\n100\n101\n")
.stderr("")
.runAsTest("width comes from the widest bound");

TestBuilder.command`seq -w 1 4 10`
.exitCode(0)
.stdout("01\n05\n09\n")
.stderr("")
.runAsTest("width comes from the end bound even when it is not reached");

TestBuilder.command`seq -w -1 1`
.exitCode(0)
.stdout("-1\n00\n01\n")
.stderr("")
.runAsTest("the sign counts towards the width");

TestBuilder.command`seq -w -10 5 10`
.exitCode(0)
.stdout("-10\n-05\n000\n005\n010\n")
.stderr("")
.runAsTest("zeros go between the sign and the digits");

TestBuilder.command`seq -w 1 0.5 3`
.exitCode(0)
.stdout("1.0\n1.5\n2.0\n2.5\n3.0\n")
.stderr("")
.runAsTest("every value gets the increment's decimals");

TestBuilder.command`seq -w 0 0.25 1`
.exitCode(0)
.stdout("0.00\n0.25\n0.50\n0.75\n1.00\n")
.stderr("")
.runAsTest("every value gets the most decimals any operand has");

TestBuilder.command`seq -w -0.5 0.25 0.5`
.exitCode(0)
.stdout("-0.50\n-0.25\n00.00\n00.25\n00.50\n")
.stderr("")
.runAsTest("fractional values pad after the sign");

TestBuilder.command`seq -w 1.50 2`.exitCode(0).stdout("1.50\n").stderr("").runAsTest("keeps decimals as written");

TestBuilder.command`seq -w 0 2.5e-1 1`
.exitCode(0)
.stdout("0.00\n0.25\n0.50\n0.75\n1.00\n")
.stderr("")
.runAsTest("a negative exponent adds decimals");

TestBuilder.command`seq -w 1.5e1 16`
.exitCode(0)
.stdout("15\n16\n")
.stderr("")
.runAsTest("a positive exponent removes decimals");

TestBuilder.command`seq -w -s , 8 11`.exitCode(0).stdout("08,09,10,11,").stderr("").runAsTest("works with -s");

TestBuilder.command`seq -s, -t. -w 8 11`
.exitCode(0)
.stdout("08,09,10,11,.")
.stderr("")
.runAsTest("works after -s and -t");

TestBuilder.command`echo $(seq -w 8 11)`
.exitCode(0)
.stdout("08 09 10 11\n")
.stderr("")
.runAsTest("pads when stdout is captured");

TestBuilder.command`seq -w 8 11 > out.txt`
.exitCode(0)
.stdout("")
.stderr("")
.fileEquals("out.txt", "08\n09\n10\n11\n")
.runAsTest("pads when stdout is a file");

// 1e-70000 parses as 0 but was written with 70000 implied decimals; more
// than core::fmt can print, so the precision is capped at 1074 (the most
// fractional digits any double has) instead of panicking.
const zeros = Buffer.alloc(1074, "0").toString();
TestBuilder.command`seq -w 1e-70000 1`
.exitCode(0)
.stdout(`0.${zeros}\n1.${zeros}\n`)
.stderr("")
.runAsTest("caps the number of decimals");
});

describe("seq without stdout", async () => {
Expand Down