Compare commits

...

24 Commits

Author SHA1 Message Date
Claude Bot
37a66dcc94 Auto-inline import { ms } from "bun" at compile time
This change enables automatic compile-time constant folding for both:
- Bun.ms("1s") → 1000
- import { ms } from "bun"; ms("1s") → 1000

Implementation:
1. When processing import statements from "bun", automatically register
   the `ms` symbol in the macro refs map (P.zig)
2. Intercept macro execution for `ms` from "bun" and use compile-time
   constant folding instead of JavaScript execution (visitExpr.zig)
3. Extract duplicate inlining logic into shared helper function

Both approaches now use the same optimized code path without duplication.
Works with --minify-syntax flag.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-02 06:38:57 +00:00
RiskyMH
bace521b69 improve perf 2025-10-02 12:57:41 +10:00
RiskyMH
f0e6a89e59 align closer to npm:ms@nightly 2025-10-02 11:01:02 +10:00
RiskyMH
086dc1c860 . 2025-10-02 10:14:41 +10:00
RiskyMH
33e8e9bd50 use canary ms to be more fair 2025-10-02 10:08:32 +10:00
RiskyMH
2d892b5eb7 fix tests 2025-10-02 10:04:00 +10:00
Dylan Conway
8b8c845708 oops 2025-10-01 16:56:18 -07:00
RiskyMH
f0be4443be , 2025-10-02 09:47:22 +10:00
RiskyMH
0abed6e575 . 2025-10-02 09:47:08 +10:00
RiskyMH
05c3a13366 coderabbit 2025-10-02 09:46:08 +10:00
Dylan Conway
1d95405da8 oops 2025-10-01 16:38:12 -07:00
Dylan Conway
548a4f7859 extern string, comptime string map 2025-10-01 16:33:08 -07:00
RiskyMH
709b8b9803 fix rounding 2025-10-02 01:45:37 +10:00
RiskyMH
30f045dd41 add bench 2025-10-02 01:09:09 +10:00
RiskyMH
6d1d14663b better 2025-10-02 00:27:07 +10:00
Claude Bot
0ba7624824 Inline number literals too! Bun.ms(1000) → "1s" at compile time
Now the bundler inlines BOTH directions:
- String → Number: Bun.ms("1s") → 1000
- Number → String: Bun.ms(1000) → "1s"
- With options: Bun.ms(60000, { long: true }) → "1 minute"

This means ZERO runtime overhead for all constant Bun.ms() calls!

Updated test comments to reflect that number inputs ARE inlined.
All 138 tests passing 

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 13:14:10 +00:00
Claude Bot
f6d326d136 Inline invalid strings to NaN and add dynamic value tests
Bundler improvements:
- Invalid string literals now inline to NaN (Bun.ms("invalid") → NaN)
- Empty strings inline to NaN (Bun.ms("") → NaN)
- Number inputs correctly preserved for runtime formatting

Added runtime tests for dynamic values:
- Dynamic string concatenation
- Template literals with function calls
- Variable strings
- Dynamic number formatting

All 138 tests passing 

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 13:05:57 +00:00
Claude Bot
d85fd983f9 Implement bundler compile-time inlining for Bun.ms() string literals
When minify.syntax is enabled, Bun.ms("literal") calls are now inlined
to their numeric values at build time:

- Bun.ms("1s") → 1000
- Bun.ms("1m") → 60000
- Bun.ms("2d") → 172800000

Implementation:
- Added compile-time folding in visitExpr.zig e_call visitor
- Reuses existing parse() function from ms.zig
- Only inlines string literals, preserves dynamic values
- Works with case-insensitive units, decimals, negatives

This optimization activates with --minify or when bundling with
syntax minification enabled.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 12:55:57 +00:00
Claude Bot
339a90ac1a Add TypeScript type integration tests for Bun.ms
Verify type checking works correctly:
- String literal autocomplete for time units
- Correct return types (number for strings, string for numbers)
- Options parameter only allowed with number input
- All unit variations type-check correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 12:45:37 +00:00
Claude Bot
33eac619ef Add months support, return NaN for invalid inputs, comprehensive tests
- Added month/months/mo unit support (30.4375 days average)
- Return NaN instead of undefined for invalid string inputs
- Throw errors for NaN/Infinity number inputs
- Complete test coverage matching vercel/ms library (133 tests)
- Updated TypeScript types with string literal autocomplete
- All test cases from vercel/ms parse, format, and index tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 12:43:13 +00:00
Claude Bot
b00bf53cb4 Use std.time constants and fix bundler test
- Replace custom constants with std.time.ms_per_* directly
- Rewrite bundler test to use Bun.build API with inline snapshot
- Remove external snapshot file

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 12:17:14 +00:00
Claude Bot
0972365627 Use std.time constants instead of hardcoded values
Replace manual time constant calculations with std.time.ns_per_*
constants from the Zig standard library.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 12:09:24 +00:00
Claude Bot
bbf04de639 Fix bundler test to snapshot output instead of running code
The bundler test now checks the actual bundled output with snapshots
rather than executing the bundled code. This verifies Bun.ms works
in bundled code and sets a baseline for future compile-time inlining.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 12:03:08 +00:00
Claude Bot
6bf431d707 Add Bun.ms for time string parsing and formatting
Implements Bun.ms as a drop-in replacement for the npm ms library.
Parses time strings like "2d", "1.5h", "5m" to milliseconds and formats
numbers back to human-readable time strings.

- Implemented in Zig for reusability with future bundler optimizations
- Supports all time units: ms, s, m, h, d, w, y with variations
- Case-insensitive parsing with whitespace handling
- Formats with short ("1m") and long ("1 minute") options
- Comprehensive test coverage with 226 runtime + 1 bundler test

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 12:01:03 +00:00
13 changed files with 900 additions and 4 deletions

View File

@@ -18,6 +18,7 @@
"fastify": "^5.0.0",
"fdir": "^6.1.0",
"mitata": "^1.0.25",
"ms": "^4.0.0-nightly.202508271359",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"string-width": "7.1.0",
@@ -363,7 +364,7 @@
"mitata": ["mitata@1.0.25", "", {}, "sha512-0v5qZtVW5vwj9FDvYfraR31BMDcRLkhSFWPTLaxx/Z3/EvScfVtAAWtMI2ArIbBcwh7P86dXh0lQWKiXQPlwYA=="],
"ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
"ms": ["ms@4.0.0-nightly.202508271359", "", {}, "sha512-WC/Eo7NzFrOV/RRrTaI0fxKVbNCzEy76j2VqNV8SxDf9D69gSE2Lh0QwYvDlhiYmheBYExAvEAxVf5NoN0cj2A=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
@@ -497,6 +498,8 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
"fastify/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],

81
bench/ms/ms.mjs Normal file
View File

@@ -0,0 +1,81 @@
import { ms } from "ms";
import { bench, group, run } from "../runner.mjs";
const stringInputs = ["1s", "1m", "1h", "1d", "1w", "1y", "2 days", "10h", "2.5 hrs", "1.5h", "100ms"];
const numberInputs = [1000, 60000, 3600000, 86400000, 604800000];
if (!process.argv.includes("--full")) {
const str = ["1.5y"];
if (typeof Bun === "undefined") {
bench("ms (npm)", () => {
ms(str[0]);
});
} else {
bench("Bun.ms", () => {
Bun.ms(str[0]);
});
bench("Bun.ms (statically inlined)", () => {
Bun.ms("1.5y")
})
}
} else {
if (typeof Bun == "undefined" || process.argv.includes("--both")) {
group("ms (npm)", () => {
bench(`${stringInputs.length + numberInputs.length} inputs`, () => {
for (const input of stringInputs) {
ms(input);
}
for (const num of numberInputs) {
ms(num);
}
});
bench("string -> num", () => {
ms(stringInputs[0]);
});
bench("num -> string", () => {
ms(numberInputs[0]);
});
});
}
if (typeof Bun != "undefined") {
group("Bun.ms", () => {
bench(`${stringInputs.length + numberInputs.length} inputs`, () => {
for (const input of stringInputs) {
Bun.ms(input);
}
for (const num of numberInputs) {
Bun.ms(num);
}
});
bench("string -> num", () => {
Bun.ms(stringInputs[0]);
});
bench("num -> string", () => {
Bun.ms(numberInputs[0]);
});
bench("statically inlined", () => {
Bun.ms("1s");
Bun.ms("1m");
Bun.ms("1h");
Bun.ms("1d");
Bun.ms("1w");
Bun.ms("1y");
Bun.ms("2 days");
Bun.ms("10h");
Bun.ms("2.5 hrs");
Bun.ms("1.5h");
Bun.ms("100ms");
Bun.ms(1000);
Bun.ms(60000);
Bun.ms(3600000);
Bun.ms(86400000);
Bun.ms(604800000);
});
});
}
}
await run();

View File

@@ -15,6 +15,7 @@
"fastify": "^5.0.0",
"fdir": "^6.1.0",
"mitata": "^1.0.25",
"ms": "^4.0.0-nightly.202508271359",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"string-width": "7.1.0",

View File

@@ -14,6 +14,4 @@ export function run(opts = {}) {
export const bench = Mitata.bench;
export function group(_name, fn) {
return Mitata.group(fn);
}
export const group = Mitata.group;

View File

@@ -5206,6 +5206,43 @@ declare module "bun" {
*/
function sleepSync(ms: number): void;
/**
* Parse a time string and return milliseconds, or format milliseconds as a string.
*
* Drop-in replacement for the `ms` npm package with compile-time inlining support.
*
* @example
* ```ts
* Bun.ms("2d") // 172800000
* Bun.ms("1.5h") // 5400000
* Bun.ms("1m") // 60000
* Bun.ms("5s") // 5000
* Bun.ms("100ms") // 100
* Bun.ms("1mo") // 2629800000
* Bun.ms("1y") // 31557600000
* Bun.ms("100") // 100
* Bun.ms(60000) // "1m"
* Bun.ms(60000, { long: true }) // "1 minute"
* Bun.ms("invalid") // NaN
* ```
*
* Supports these units:
* - `ms`, `millisecond`, `milliseconds`
* - `s`, `sec`, `second`, `seconds`
* - `m`, `min`, `minute`, `minutes`
* - `h`, `hr`, `hour`, `hours`
* - `d`, `day`, `days`
* - `w`, `week`, `weeks`
* - `mo`, `month`, `months`
* - `y`, `yr`, `year`, `years`
*
* @param value - Time string to parse or milliseconds to format
* @param options - Formatting options (when value is a number)
* @returns Milliseconds (for string) or formatted string (for number). Returns NaN for invalid strings.
*/
function ms(value: string): number;
function ms(value: number, options?: { long?: boolean }): string;
/**
* Hash `input` using [SHA-2 512/256](https://en.wikipedia.org/wiki/SHA-2#Comparison_of_SHA_functions)
*

View File

@@ -2771,6 +2771,18 @@ pub fn NewParser_(
}
}
// Auto-inline Bun.ms when imported from "bun"
// This allows: import { ms } from "bun"; ms("1s") -> 1000
// without requiring `with { type: "macro" }`
if (comptime allow_macros) {
if (strings.eqlComptime(path.text, "bun") and strings.eqlComptime(item.alias, "ms")) {
try p.macro.refs.put(ref, .{
.import_record_id = stmt.import_record_index,
.name = item.alias,
});
}
}
if (macro_remap) |*remap| {
if (remap.get(item.alias)) |remapped_path| {
const new_import_id = p.addImportRecord(.stmt, path.loc, remapped_path);

View File

@@ -1149,6 +1149,51 @@ pub fn VisitExpr(
}
return expr;
}
/// Helper function to inline Bun.ms() and import { ms } from "bun" calls
/// Returns the inlined expression if successful, null otherwise
fn inlineMsCall(p: *P, e_: *const E.Call, loc: logger.Loc) ?Expr {
const ms_module = @import("../bun.js/api/bun/ms.zig");
if (e_.args.len == 1) {
const arg = e_.args.at(0).unwrapInlined();
if (arg.data == .e_string and arg.data.e_string.isUTF8()) {
// ms("string") -> number
const str = arg.data.e_string.slice(p.allocator);
const ms_value = ms_module.parse(str) orelse std.math.nan(f64);
return p.newExpr(E.Number{ .value = ms_value }, loc);
} else if (arg.data == .e_number) {
// ms(number) -> "string"
const num = arg.data.e_number.value;
if (std.math.isNan(num) or std.math.isInf(num)) return null;
if (ms_module.format(p.allocator, num, false)) |formatted| {
return p.newExpr(E.String.init(formatted), loc);
} else |_| return null;
}
} else if (e_.args.len == 2) {
// ms(number, { long: true/false }) -> "string"
const arg = e_.args.at(0).unwrapInlined();
const opts = e_.args.at(1).unwrapInlined();
if (arg.data == .e_number and opts.data == .e_object) {
const num = arg.data.e_number.value;
if (std.math.isNan(num) or std.math.isInf(num)) return null;
var long = false;
if (opts.data.e_object.get("long")) |val| {
if (val.data == .e_boolean) {
long = val.data.e_boolean.value;
}
}
if (ms_module.format(p.allocator, num, long)) |formatted| {
return p.newExpr(E.String.init(formatted), loc);
} else |_| return null;
}
}
return null;
}
pub fn e_call(p: *P, expr: Expr, in: ExprIn) Expr {
const e_ = expr.data.e_call;
p.call_target = e_.target.data;
@@ -1387,6 +1432,16 @@ pub fn VisitExpr(
const name = macro_ref_data.name orelse e_.target.data.e_dot.name;
const record = &p.import_records.items[macro_ref_data.import_record_id];
// Special case: import { ms } from "bun" gets compile-time constant folding
// instead of macro execution. This allows ms("1s") -> 1000 inline optimization.
if (record.tag == .bun and strings.eqlComptime(name, "ms")) {
if (inlineMsCall(p, e_, expr.loc)) |result| {
return result;
}
// If we can't inline (dynamic args), fall through to normal macro execution
}
const copied = Expr{ .loc = expr.loc, .data = .{ .e_call = e_ } };
const start_error_count = p.log.msgs.items.len;
p.macro_call_count += 1;
@@ -1497,6 +1552,20 @@ pub fn VisitExpr(
}
};
// Implement constant folding for Bun.ms("1s") -> 1000 and Bun.ms(1000) -> "1s"
if (p.should_fold_typescript_constant_expressions or p.options.features.inlining) {
if (e_.target.data.as(.e_dot)) |dot| {
if (dot.target.data == .e_identifier and strings.eqlComptime(dot.name, "ms")) {
const symbol = &p.symbols.items[dot.target.data.e_identifier.ref.innerIndex()];
if (symbol.kind == .unbound and strings.eqlComptime(symbol.original_name, "Bun")) {
if (inlineMsCall(p, e_, expr.loc)) |result| {
return result;
}
}
}
}
}
return expr;
}
pub fn e_new(p: *P, expr: Expr, _: ExprIn) Expr {

View File

@@ -25,6 +25,7 @@ pub const BunObject = struct {
pub const jest = toJSCallback(@import("../test/jest.zig").Jest.call);
pub const listen = toJSCallback(host_fn.wrapStaticMethod(api.Listener, "listen", false));
pub const mmap = toJSCallback(Bun.mmapFile);
pub const ms = toJSCallback(@import("./bun/ms.zig").jsFunction);
pub const nanoseconds = toJSCallback(Bun.nanoseconds);
pub const openInEditor = toJSCallback(Bun.openInEditor);
pub const registerMacro = toJSCallback(Bun.registerMacro);
@@ -161,6 +162,7 @@ pub const BunObject = struct {
@export(&BunObject.jest, .{ .name = callbackName("jest") });
@export(&BunObject.listen, .{ .name = callbackName("listen") });
@export(&BunObject.mmap, .{ .name = callbackName("mmap") });
@export(&BunObject.ms, .{ .name = callbackName("ms") });
@export(&BunObject.nanoseconds, .{ .name = callbackName("nanoseconds") });
@export(&BunObject.openInEditor, .{ .name = callbackName("openInEditor") });
@export(&BunObject.registerMacro, .{ .name = callbackName("registerMacro") });

238
src/bun.js/api/bun/ms.zig Normal file
View File

@@ -0,0 +1,238 @@
/// Parse a time string like "2d", "1.5h", "5m" to milliseconds
pub fn parse(input: []const u8) ?f64 {
if (input.len == 0 or input.len > 100) return null;
var i: usize = 0;
next: switch (input[i]) {
'-',
'.',
'0'...'9',
=> {
i += 1;
if (i < input.len) {
continue :next input[i];
}
break :next;
},
' ',
'a'...'z',
'A'...'Z',
=> {
break :next;
},
else => {
return null;
},
}
const value = std.fmt.parseFloat(f64, input[0..i]) catch return null;
const unit = strings.trimLeadingChar(input[i..], ' ');
if (unit.len == 0) return value;
if (MultiplierMap.getASCIIICaseInsensitive(unit)) |m| {
return value * m;
}
return null;
}
// Years (365.25 days to account for leap years)
const ms_per_year = std.time.ms_per_day * 365.25;
const ms_per_month = std.time.ms_per_day * (365.25 / 12.0);
const MultiplierMap = bun.ComptimeStringMap(f64, .{
// Years (365.25 days to account for leap years)
.{ "y", ms_per_year },
.{ "yr", ms_per_year },
.{ "yrs", ms_per_year },
.{ "year", ms_per_year },
.{ "years", ms_per_year },
// Months (30.4375 days average)
.{ "mo", ms_per_month },
.{ "month", ms_per_month },
.{ "months", ms_per_month },
// Weeks
.{ "w", std.time.ms_per_week },
.{ "week", std.time.ms_per_week },
.{ "weeks", std.time.ms_per_week },
// Days
.{ "d", std.time.ms_per_day },
.{ "day", std.time.ms_per_day },
.{ "days", std.time.ms_per_day },
// Hours
.{ "h", std.time.ms_per_hour },
.{ "hr", std.time.ms_per_hour },
.{ "hrs", std.time.ms_per_hour },
.{ "hour", std.time.ms_per_hour },
.{ "hours", std.time.ms_per_hour },
// Minutes
.{ "m", std.time.ms_per_min },
.{ "min", std.time.ms_per_min },
.{ "mins", std.time.ms_per_min },
.{ "minute", std.time.ms_per_min },
.{ "minutes", std.time.ms_per_min },
// Seconds
.{ "s", std.time.ms_per_s },
.{ "sec", std.time.ms_per_s },
.{ "secs", std.time.ms_per_s },
.{ "second", std.time.ms_per_s },
.{ "seconds", std.time.ms_per_s },
// Milliseconds
.{ "ms", 1 },
.{ "msec", 1 },
.{ "msecs", 1 },
.{ "millisecond", 1 },
.{ "milliseconds", 1 },
});
// To keep the behavior consistent with JavaScript, we can't use @round
// Zig's @round uses "round half away from zero": ties round away from zero (2.5→3, -2.5→-3)
// JavaScript's Math.round uses "round half toward +∞": ties round toward positive infinity (2.5→3, -2.5→-2)
// This implementation: floor(x) + 1 if fractional part >= 0.5, else floor(x)
fn jsMathRound(x: f64) i64 {
const i: f64 = @ceil(x);
if ((i - 0.5) > x) return @intFromFloat(i - 1.0);
return @intFromFloat(i);
}
/// Format milliseconds to a human-readable string
pub fn format(allocator: std.mem.Allocator, ms: f64, long: bool) ![]u8 {
const abs_ms = @abs(ms);
// Years
if (abs_ms >= ms_per_year) {
const years = jsMathRound(ms / ms_per_year);
if (long) {
const plural = abs_ms >= ms_per_year * 1.5;
return std.fmt.allocPrint(allocator, "{d} year{s}", .{ years, if (plural) "s" else "" });
}
return std.fmt.allocPrint(allocator, "{d}y", .{years});
}
// Months
if (abs_ms >= ms_per_month) {
const months = jsMathRound(ms / ms_per_month);
if (long) {
const plural = abs_ms >= ms_per_month * 1.5;
return std.fmt.allocPrint(allocator, "{d} month{s}", .{ months, if (plural) "s" else "" });
}
return std.fmt.allocPrint(allocator, "{d}mo", .{months});
}
// Weeks
if (abs_ms >= std.time.ms_per_week) {
const weeks = jsMathRound(ms / std.time.ms_per_week);
if (long) {
const plural = abs_ms >= std.time.ms_per_week * 1.5;
return std.fmt.allocPrint(allocator, "{d} week{s}", .{ weeks, if (plural) "s" else "" });
}
return std.fmt.allocPrint(allocator, "{d}w", .{weeks});
}
// Days
if (abs_ms >= std.time.ms_per_day) {
const days = jsMathRound(ms / std.time.ms_per_day);
if (long) {
const plural = abs_ms >= std.time.ms_per_day * 1.5;
return std.fmt.allocPrint(allocator, "{d} day{s}", .{ days, if (plural) "s" else "" });
}
return std.fmt.allocPrint(allocator, "{d}d", .{days});
}
// Hours
if (abs_ms >= std.time.ms_per_hour) {
const hours = jsMathRound(ms / std.time.ms_per_hour);
if (long) {
const plural = abs_ms >= std.time.ms_per_hour * 1.5;
return std.fmt.allocPrint(allocator, "{d} hour{s}", .{ hours, if (plural) "s" else "" });
}
return std.fmt.allocPrint(allocator, "{d}h", .{hours});
}
// Minutes
if (abs_ms >= std.time.ms_per_min) {
const minutes = jsMathRound(ms / std.time.ms_per_min);
if (long) {
const plural = abs_ms >= std.time.ms_per_min * 1.5;
return std.fmt.allocPrint(allocator, "{d} minute{s}", .{ minutes, if (plural) "s" else "" });
}
return std.fmt.allocPrint(allocator, "{d}m", .{minutes});
}
// Seconds
if (abs_ms >= std.time.ms_per_s) {
const seconds = jsMathRound(ms / std.time.ms_per_s);
if (long) {
const plural = abs_ms >= std.time.ms_per_s * 1.5;
return std.fmt.allocPrint(allocator, "{d} second{s}", .{ seconds, if (plural) "s" else "" });
}
return std.fmt.allocPrint(allocator, "{d}s", .{seconds});
}
// Milliseconds
const ms_int: i64 = @intFromFloat(ms);
if (long) {
return std.fmt.allocPrint(allocator, "{d} ms", .{ms_int});
}
return std.fmt.allocPrint(allocator, "{d}ms", .{ms_int});
}
/// JavaScript function: Bun.ms(value, options?)
pub fn jsFunction(
globalThis: *JSGlobalObject,
callframe: *jsc.CallFrame,
) JSError!jsc.JSValue {
const input, const options = callframe.argumentsAsArray(2);
// If input is a number, format it to a string
if (input.isNumber()) {
const ms_value = input.asNumber();
if (std.math.isNan(ms_value) or std.math.isInf(ms_value)) {
return globalThis.throwInvalidArguments("Value must be a finite number", .{});
}
var long = false;
if (options.isObject()) {
if (try options.get(globalThis, "long")) |long_value| {
long = long_value.toBoolean();
}
}
const result = try format(bun.default_allocator, ms_value, long);
var str = String.createExternalGloballyAllocated(.latin1, result);
return str.transferToJS(globalThis);
}
// If input is a string, parse it to milliseconds
if (input.isString()) {
const str = try input.toSlice(globalThis, bun.default_allocator);
defer str.deinit();
const result = parse(str.slice()) orelse std.math.nan(f64);
return JSValue.jsNumber(result);
}
return globalThis.throwInvalidArguments("Bun.ms() expects a string or number", .{});
}
const std = @import("std");
const bun = @import("bun");
const JSError = bun.JSError;
const String = bun.String;
const strings = bun.strings;
const jsc = bun.jsc;
const JSGlobalObject = jsc.JSGlobalObject;
const JSValue = jsc.JSValue;

View File

@@ -55,6 +55,7 @@
macro(jest) \
macro(listen) \
macro(mmap) \
macro(ms) \
macro(nanoseconds) \
macro(openInEditor) \
macro(registerMacro) \

View File

@@ -759,6 +759,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
udpSocket BunObject_callback_udpSocket DontDelete|Function 1
main bunObjectMain DontDelete|CustomAccessor
mmap BunObject_callback_mmap DontDelete|Function 1
ms BunObject_callback_ms DontDelete|Function 2
nanoseconds functionBunNanoseconds DontDelete|Function 0
openInEditor BunObject_callback_openInEditor DontDelete|Function 1
origin BunObject_lazyPropCb_wrap_origin DontEnum|ReadOnly|DontDelete|PropertyCallback

View File

@@ -0,0 +1,65 @@
import { expectType } from "./utilities";
// Test string literal autocomplete and return types
expectType(Bun.ms("1s")).is<number>();
expectType(Bun.ms("1m")).is<number>();
expectType(Bun.ms("1h")).is<number>();
expectType(Bun.ms("1d")).is<number>();
expectType(Bun.ms("1w")).is<number>();
expectType(Bun.ms("1mo")).is<number>();
expectType(Bun.ms("1y")).is<number>();
// Test with all unit variations
expectType(Bun.ms("1ms")).is<number>();
expectType(Bun.ms("1millisecond")).is<number>();
expectType(Bun.ms("1milliseconds")).is<number>();
expectType(Bun.ms("1second")).is<number>();
expectType(Bun.ms("1 second")).is<number>();
expectType(Bun.ms("1 seconds")).is<number>();
expectType(Bun.ms("1minute")).is<number>();
expectType(Bun.ms("1 minute")).is<number>();
expectType(Bun.ms("1hour")).is<number>();
expectType(Bun.ms("1 hour")).is<number>();
expectType(Bun.ms("1day")).is<number>();
expectType(Bun.ms("1 day")).is<number>();
expectType(Bun.ms("1week")).is<number>();
expectType(Bun.ms("1 week")).is<number>();
expectType(Bun.ms("1month")).is<number>();
expectType(Bun.ms("1 month")).is<number>();
expectType(Bun.ms("1year")).is<number>();
expectType(Bun.ms("1 year")).is<number>();
// Test with decimals and negatives
expectType(Bun.ms("1.5h")).is<number>();
expectType(Bun.ms("-1s")).is<number>();
expectType(Bun.ms(".5m")).is<number>();
expectType(Bun.ms("-.5h")).is<number>();
// Test number input (formatting)
expectType(Bun.ms(1000)).is<string>();
expectType(Bun.ms(60000)).is<string>();
expectType(Bun.ms(3600000)).is<string>();
// Test with options
expectType(Bun.ms(1000, { long: true })).is<string>();
expectType(Bun.ms(60000, { long: false })).is<string>();
// Test generic string input (for dynamic values)
const dynamicString: string = "1s";
expectType(Bun.ms(dynamicString)).is<number>();
// Should NOT accept options with string input
// @ts-expect-error - options only valid with number input
Bun.ms("1s", { long: true });
// Number with options should work
const formatted = Bun.ms(1000, { long: true });
expectType(formatted).is<string>();
// Options should be optional
const shortFormat = Bun.ms(1000);
expectType(shortFormat).is<string>();
// Test that invalid inputs still return number (NaN is a number)
expectType(Bun.ms("invalid")).is<number>();
expectType(Bun.ms("")).is<number>();

388
test/js/bun/util/ms.test.ts Normal file
View File

@@ -0,0 +1,388 @@
import { describe, expect, test } from "bun:test";
import { tempDirWithFiles } from "harness";
import { join } from "path";
describe("Bun.ms - parse (string to number)", () => {
test("short strings", () => {
const cases = [
["100", 100],
["1m", 60000],
["1h", 3600000],
["2d", 172800000],
["3w", 1814400000],
["1s", 1000],
["100ms", 100],
["1y", 31557600000],
["1.5h", 5400000],
["1 s", 1000],
["-.5h", -1800000],
["-1h", -3600000],
["-200", -200],
[".5ms", 0.5],
] as const;
for (const [input, expected] of cases) {
expect(Bun.ms(input)).toBe(expected);
}
});
test("long strings", () => {
const cases = [
["53 milliseconds", 53],
["17 msecs", 17],
["1 sec", 1000],
["1 min", 60000],
["1 hr", 3600000],
["2 days", 172800000],
["1 week", 604800000],
["1 month", 2629800000],
["1 year", 31557600000],
["1.5 hours", 5400000],
["-100 milliseconds", -100],
["-1.5 hours", -5400000],
["-10 minutes", -600000],
] as const;
for (const [input, expected] of cases) {
expect(Bun.ms(input)).toBe(expected);
}
});
test("case insensitive", () => {
const cases = [
["1M", 60000],
["1H", 3600000],
["2D", 172800000],
["3W", 1814400000],
["1S", 1000],
["1MS", 1],
["1Y", 31557600000],
["1 HOUR", 3600000],
["1 DAY", 86400000],
["1 WEEK", 604800000],
] as const;
for (const [input, expected] of cases) {
expect(Bun.ms(input)).toBe(expected);
}
});
test("invalid inputs", () => {
const cases = [
["", "empty string"],
[" ", "whitespace only"],
["foo", "invalid unit"],
["1x", "unknown unit"],
["1.2.3s", "multiple dots"],
] as const;
for (const [input] of cases) {
expect(Bun.ms(input)).toBeNaN();
}
});
});
describe("Bun.ms - format (number to string)", () => {
test("short format", () => {
const cases = [
[0, "0ms"],
[500, "500ms"],
[-500, "-500ms"],
[1000, "1s"],
[10000, "10s"],
[60000, "1m"],
[600000, "10m"],
[3600000, "1h"],
[86400000, "1d"],
[604800000, "1w"],
[2629800000, "1mo"],
[31557600001, "1y"],
[234234234, "3d"],
[-234234234, "-3d"],
] as const;
for (const [input, expected] of cases) {
expect(Bun.ms(input)).toBe(expected);
}
});
test("long format", () => {
const cases = [
[0, "0 ms"],
[500, "500 ms"],
[-500, "-500 ms"],
[1000, "1 second"],
[1001, "1 second"],
[1499, "1 second"],
[1500, "2 seconds"],
[10000, "10 seconds"],
[60000, "1 minute"],
[600000, "10 minutes"],
[3600000, "1 hour"],
[86400000, "1 day"],
[172800000, "2 days"],
[604800000, "1 week"],
[2629800000, "1 month"],
[31557600001, "1 year"],
[234234234, "3 days"],
[-234234234, "-3 days"],
] as const;
for (const [input, expected] of cases) {
expect(Bun.ms(input, { long: true })).toBe(expected);
}
});
test("rounding behavior matches JavaScript Math.round and npm ms", () => {
// JavaScript Math.round uses "round half toward +∞"
// Positive ties (X.5) round up (away from zero): 2.5 → 3
// Negative ties (X.5) round up toward zero: -2.5 → -2
// This is different from Zig's @round which rounds away from zero
// (so we made our own jsMathRound function)
const cases = [
// Positive ties - should round up
[1000, "1s", "1 second"],
[1500, "2s", "2 seconds"],
[2500, "3s", "3 seconds"],
[3500, "4s", "4 seconds"],
[4500, "5s", "5 seconds"],
// Negative ties - should round toward zero (toward +∞)
[-1000, "-1s", "-1 second"],
[-1500, "-1s", "-1 seconds"],
[-2500, "-2s", "-2 seconds"],
[-3500, "-3s", "-3 seconds"],
[-4500, "-4s", "-4 seconds"],
[9000000, "3h", "3 hours"],
[-9000000, "-2h", "-2 hours"],
[216000000, "3d", "3 days"],
[-216000000, "-2d", "-2 days"],
] as const;
for (const [input, expectedShort, expectedLong] of cases) {
expect(Bun.ms(input)).toBe(expectedShort);
expect(Bun.ms(input, { long: true })).toBe(expectedLong);
}
});
test("invalid number inputs", () => {
expect(() => Bun.ms(NaN)).toThrow();
expect(() => Bun.ms(Infinity)).toThrow();
expect(() => Bun.ms(-Infinity)).toThrow();
});
});
describe("Bun.ms - comprehensive coverage", () => {
test("all time units", () => {
const cases = [
// Milliseconds
["1ms", 1],
["1millisecond", 1],
["1milliseconds", 1],
["1msec", 1],
["1msecs", 1],
// Seconds
["1s", 1000],
["1sec", 1000],
["1secs", 1000],
["1second", 1000],
["1seconds", 1000],
["2seconds", 2000],
// Minutes
["1m", 60000],
["1min", 60000],
["1mins", 60000],
["1minute", 60000],
["1minutes", 60000],
["2minutes", 120000],
// Hours
["1h", 3600000],
["1hr", 3600000],
["1hrs", 3600000],
["1hour", 3600000],
["1hours", 3600000],
["2hours", 7200000],
// Days
["1d", 86400000],
["1day", 86400000],
["1days", 86400000],
["2days", 172800000],
// Weeks
["1w", 604800000],
["1week", 604800000],
["1weeks", 604800000],
["2weeks", 1209600000],
// Months
["1mo", 2629800000],
["1month", 2629800000],
["1months", 2629800000],
["2months", 5259600000],
// Years
["1y", 31557600000],
["1yr", 31557600000],
["1yrs", 31557600000],
["1year", 31557600000],
["1years", 31557600000],
["2years", 63115200000],
] as const;
for (const [input, expected] of cases) {
expect(Bun.ms(input)).toBe(expected);
}
});
test("decimals and negatives", () => {
const cases = [
["1.5s", 1500],
["1.5h", 5400000],
["0.5d", 43200000],
["-1s", -1000],
["-1.5h", -5400000],
["-0.5d", -43200000],
[".5s", 500],
["-.5s", -500],
] as const;
for (const [input, expected] of cases) {
expect(Bun.ms(input)).toBe(expected);
}
});
test("whitespace handling", () => {
const cases = [
["1 s", 1000],
["1 s", 1000],
["1 s", 1000],
[" 1s", NaN],
["1s ", NaN],
[" 1s ", NaN],
["1 second", 1000],
["1 seconds", 1000],
[" 1 second ", NaN],
] as const;
for (const [input, expected] of cases) {
expect(Bun.ms(input)).toBe(expected);
}
});
});
test("Bun.ms - dynamic values at runtime", () => {
{
function getNumber() {
return Math.random() > 0.5 ? 1 : 2;
}
const days = getNumber();
const result = Bun.ms(days + "days");
// Should be either 1 day or 2 days
expect(result === 86400000 || result === 172800000).toBe(true);
}
{
function getHours() {
return 5;
}
const result = Bun.ms(String(getHours()) + "h");
expect(result).toBe(18000000); // 5 hours
}
{
const timeStr = "10m";
const result = Bun.ms(timeStr);
expect(result).toBe(600000);
}
{
function getMs() {
return 60000;
}
const result = Bun.ms(getMs());
expect(result).toBe("1m");
}
});
test("Bun.ms - static string formatting", () => {
expect(Bun.ms("5s")).toBe(5000);
expect(Bun.ms(5000, { long: true })).toBe("5 seconds");
expect(Bun.ms(5000, { long: false })).toBe("5s");
});
test("Bun.ms - bundler output", async () => {
const dir = tempDirWithFiles("ms-bundler", {
"entry.ts": `
const dynamic = () => Math.random() > 0.5 ? 1 : 2;
export const values = {
// Valid strings - should inline to numbers
oneSecond: Bun.ms("1s"),
oneMinute: Bun.ms("1m"),
oneHour: Bun.ms("1h"),
oneDay: Bun.ms("1d"),
twoWeeks: Bun.ms("2w"),
halfYear: Bun.ms("0.5y"),
withSpaces: Bun.ms("5 minutes"),
negative: Bun.ms("-10s"),
decimal: Bun.ms("1.5h"),
justNumber: Bun.ms("100"),
caseInsensitive: Bun.ms("2D"),
// Invalid strings - should inline to NaN
invalid: Bun.ms("invalid"),
empty: Bun.ms(""),
// Number inputs - should inline to strings
formatShort: Bun.ms(1000),
formatLong: Bun.ms(60000, { long: true }),
// dynamic should not inline
dynamic: Bun.ms(\`\$\{dynamic()\}s\`),
// test
dontBeWeird: abc.ms("1s"),
};
`,
});
const result = await Bun.build({
entrypoints: [join(dir, "entry.ts")],
minify: {
syntax: true,
},
});
expect(result.success).toBe(true);
expect(result.outputs).toHaveLength(1);
let output = await result.outputs[0].text();
output = output.replace(/\/\/.*?\/entry\.ts/, "// entry.ts");
expect(output).toMatchInlineSnapshot(`
"// entry.ts
var dynamic = () => Math.random() > 0.5 ? 1 : 2, values = {
oneSecond: 1000,
oneMinute: 60000,
oneHour: 3600000,
oneDay: 86400000,
twoWeeks: 1209600000,
halfYear: 15778800000,
withSpaces: 300000,
negative: -1e4,
decimal: 5400000,
justNumber: 100,
caseInsensitive: 172800000,
invalid: NaN,
empty: NaN,
formatShort: "1s",
formatLong: "1 minute",
dynamic: Bun.ms(\`\${dynamic()}s\`),
dontBeWeird: abc.ms("1s")
};
export {
values
};
"
`);
});