diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 8d4d4da82e..50eaf9c9d7 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -5209,9 +5209,7 @@ declare module "bun" { /** * Parse a time string and return milliseconds, or format milliseconds as a string. * - * Compatible with the `ms` npm package. - * - * When used with a string literal in bundled code, this is inlined at compile-time. + * Drop-in replacement for the `ms` npm package with compile-time inlining support. * * @example * ```ts @@ -5220,17 +5218,98 @@ declare module "bun" { * 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 * ``` * - * @param value - A string like "2d", "1h", "5m" or a number of milliseconds - * @param options - Options for formatting (only applies when value is a number) - * @returns milliseconds (if string input) or formatted string (if number input), or undefined if invalid string + * 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 | undefined; + function ms( + value: + | `${number}` + | `${number}${ + | "ms" + | "s" + | "m" + | "h" + | "d" + | "w" + | "y" + | "millisecond" + | "milliseconds" + | "msec" + | "msecs" + | "second" + | "seconds" + | "sec" + | "secs" + | "minute" + | "minutes" + | "min" + | "mins" + | "hour" + | "hours" + | "hr" + | "hrs" + | "day" + | "days" + | "week" + | "weeks" + | "month" + | "months" + | "mo" + | "year" + | "years" + | "yr" + | "yrs"}` + | `${number} ${ + | "ms" + | "millisecond" + | "milliseconds" + | "msec" + | "msecs" + | "second" + | "seconds" + | "sec" + | "secs" + | "minute" + | "minutes" + | "min" + | "mins" + | "hour" + | "hours" + | "hr" + | "hrs" + | "day" + | "days" + | "week" + | "weeks" + | "month" + | "months" + | "mo" + | "year" + | "years" + | "yr" + | "yrs"}` + | string, + options?: never, + ): number; function ms(value: number, options?: { long?: boolean }): string; /** diff --git a/src/bun.js/api/bun/ms.zig b/src/bun.js/api/bun/ms.zig index f4ae3a39cc..c80c3c3210 100644 --- a/src/bun.js/api/bun/ms.zig +++ b/src/bun.js/api/bun/ms.zig @@ -72,6 +72,13 @@ fn getMultiplier(unit: []const u8) ?f64 { return std.time.ms_per_day * 365.25; } + // Months (30.4375 days average) + if (std.ascii.eqlIgnoreCase(unit, "months") or std.ascii.eqlIgnoreCase(unit, "month") or + std.ascii.eqlIgnoreCase(unit, "mo")) + { + return std.time.ms_per_day * 30.4375; + } + // Weeks if (std.ascii.eqlIgnoreCase(unit, "weeks") or std.ascii.eqlIgnoreCase(unit, "week") or std.ascii.eqlIgnoreCase(unit, "w")) @@ -125,7 +132,43 @@ fn getMultiplier(unit: []const u8) ?f64 { /// Format milliseconds to a human-readable string pub fn format(allocator: std.mem.Allocator, ms: f64, long: bool) ![]const u8 { const abs_ms = @abs(ms); + const ms_per_year = std.time.ms_per_day * 365.25; + const ms_per_month = std.time.ms_per_day * 30.4375; + // Years + if (abs_ms >= ms_per_year) { + const years = @round(ms / ms_per_year); + const years_int = @as(i64, @intFromFloat(years)); + if (long) { + const plural = abs_ms >= ms_per_year * 1.5; + return std.fmt.allocPrint(allocator, "{d} year{s}", .{ years_int, if (plural) "s" else "" }); + } + return std.fmt.allocPrint(allocator, "{d}y", .{years_int}); + } + + // Months + if (abs_ms >= ms_per_month) { + const months = @round(ms / ms_per_month); + const months_int = @as(i64, @intFromFloat(months)); + if (long) { + const plural = abs_ms >= ms_per_month * 1.5; + return std.fmt.allocPrint(allocator, "{d} month{s}", .{ months_int, if (plural) "s" else "" }); + } + return std.fmt.allocPrint(allocator, "{d}mo", .{months_int}); + } + + // Weeks + if (abs_ms >= std.time.ms_per_week) { + const weeks = @round(ms / std.time.ms_per_week); + const weeks_int = @as(i64, @intFromFloat(weeks)); + if (long) { + const plural = abs_ms >= std.time.ms_per_week * 1.5; + return std.fmt.allocPrint(allocator, "{d} week{s}", .{ weeks_int, if (plural) "s" else "" }); + } + return std.fmt.allocPrint(allocator, "{d}w", .{weeks_int}); + } + + // Days if (abs_ms >= std.time.ms_per_day) { const days = @round(ms / std.time.ms_per_day); const days_int = @as(i64, @intFromFloat(days)); @@ -136,6 +179,7 @@ pub fn format(allocator: std.mem.Allocator, ms: f64, long: bool) ![]const u8 { return std.fmt.allocPrint(allocator, "{d}d", .{days_int}); } + // Hours if (abs_ms >= std.time.ms_per_hour) { const hours = @round(ms / std.time.ms_per_hour); const hours_int = @as(i64, @intFromFloat(hours)); @@ -146,6 +190,7 @@ pub fn format(allocator: std.mem.Allocator, ms: f64, long: bool) ![]const u8 { return std.fmt.allocPrint(allocator, "{d}h", .{hours_int}); } + // Minutes if (abs_ms >= std.time.ms_per_min) { const minutes = @round(ms / std.time.ms_per_min); const minutes_int = @as(i64, @intFromFloat(minutes)); @@ -156,6 +201,7 @@ pub fn format(allocator: std.mem.Allocator, ms: f64, long: bool) ![]const u8 { return std.fmt.allocPrint(allocator, "{d}m", .{minutes_int}); } + // Seconds if (abs_ms >= std.time.ms_per_s) { const seconds = @round(ms / std.time.ms_per_s); const seconds_int = @as(i64, @intFromFloat(seconds)); @@ -166,6 +212,7 @@ pub fn format(allocator: std.mem.Allocator, ms: f64, long: bool) ![]const u8 { return std.fmt.allocPrint(allocator, "{d}s", .{seconds_int}); } + // Milliseconds const ms_int = @as(i64, @intFromFloat(ms)); if (long) { return std.fmt.allocPrint(allocator, "{d} ms", .{ms_int}); @@ -214,10 +261,10 @@ pub fn jsFunction( const slice = str.toSlice(bun.default_allocator); defer slice.deinit(); - const result = parse(slice.slice()) orelse return .js_undefined; + const result = parse(slice.slice()) orelse return JSValue.jsNumber(std.math.nan(f64)); return JSValue.jsNumber(result); } - // Invalid input type - return .js_undefined; + // Invalid input type returns NaN + return JSValue.jsNumber(std.math.nan(f64)); } diff --git a/test/js/bun/util/ms-bundler.test.ts b/test/js/bun/util/ms-bundler.test.ts index ae3d26eb8d..387fe688f0 100644 --- a/test/js/bun/util/ms-bundler.test.ts +++ b/test/js/bun/util/ms-bundler.test.ts @@ -34,7 +34,7 @@ export const values = { const output = await result.outputs[0].text(); expect(output).toMatchInlineSnapshot(` - "// ../../tmp/ms-bundler_2IefkN/entry.ts + "// ../../tmp/ms-bundler_R3U5oN/entry.ts var values = { oneSecond: Bun.ms("1s"), oneMinute: Bun.ms("1m"), diff --git a/test/js/bun/util/ms.test.ts b/test/js/bun/util/ms.test.ts index d5e6bf657f..7a9398f71a 100644 --- a/test/js/bun/util/ms.test.ts +++ b/test/js/bun/util/ms.test.ts @@ -1,308 +1,222 @@ -import { test, expect } from "bun:test"; +import { test, expect, describe } from "bun:test"; -const ms = Bun.ms; +describe("Bun.ms - parse (string to number)", () => { + describe("short strings", () => { + test.each([ + ["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], + ])('Bun.ms("%s") should return %d', (input, expected) => { + expect(Bun.ms(input)).toBe(expected); + }); + }); -// Comprehensive test cases matching the ms library behavior -// Format: [input, expected output] -const parseTests: [string, number | undefined][] = [ - // Milliseconds - all variations - ["1ms", 1], - ["1 ms", 1], - ["100ms", 100], - ["100 ms", 100], - ["1.5ms", 1.5], - ["1millisecond", 1], - ["1 millisecond", 1], - ["100milliseconds", 100], - ["100 milliseconds", 100], - ["1msec", 1], - ["1 msec", 1], - ["100msecs", 100], - ["100 msecs", 100], + describe("long strings", () => { + test.each([ + ["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], + ])('Bun.ms("%s") should return %d', (input, expected) => { + expect(Bun.ms(input)).toBe(expected); + }); + }); - // Seconds - all variations - ["1s", 1000], - ["1 s", 1000], - ["1sec", 1000], - ["1 sec", 1000], - ["1secs", 1000], - ["1 secs", 1000], - ["1second", 1000], - ["1 second", 1000], - ["1seconds", 1000], - ["1 seconds", 1000], - ["2s", 2000], - ["5s", 5000], - ["10s", 10000], - ["1.5s", 1500], - ["2.5s", 2500], - ["0.5s", 500], - [".5s", 500], + describe("case insensitive", () => { + test.each([ + ["1M", 60000], + ["1H", 3600000], + ["2D", 172800000], + ["3W", 1814400000], + ["1S", 1000], + ["1MS", 1], + ["1Y", 31557600000], + ["1 HOUR", 3600000], + ["1 DAY", 86400000], + ["1 WEEK", 604800000], + ])('Bun.ms("%s") should return %d', (input, expected) => { + expect(Bun.ms(input)).toBe(expected); + }); + }); - // Minutes - all variations - ["1m", 60000], - ["1 m", 60000], - ["1min", 60000], - ["1 min", 60000], - ["1mins", 60000], - ["1 mins", 60000], - ["1minute", 60000], - ["1 minute", 60000], - ["1minutes", 60000], - ["1 minutes", 60000], - ["2m", 120000], - ["5m", 300000], - ["10m", 600000], - ["0.5m", 30000], - ["1.5m", 90000], - [".5m", 30000], - - // Hours - all variations - ["1h", 3600000], - ["1 h", 3600000], - ["1hr", 3600000], - ["1 hr", 3600000], - ["1hrs", 3600000], - ["1 hrs", 3600000], - ["1hour", 3600000], - ["1 hour", 3600000], - ["1hours", 3600000], - ["1 hours", 3600000], - ["2h", 7200000], - ["10h", 36000000], - ["24h", 86400000], - ["0.5h", 1800000], - ["1.5h", 5400000], - ["2.5h", 9000000], - [".5h", 1800000], - - // Days - all variations - ["1d", 86400000], - ["1 d", 86400000], - ["1day", 86400000], - ["1 day", 86400000], - ["1days", 86400000], - ["1 days", 86400000], - ["2d", 172800000], - ["7d", 604800000], - ["0.5d", 43200000], - ["1.5d", 129600000], - [".5d", 43200000], - - // Weeks - all variations - ["1w", 604800000], - ["1 w", 604800000], - ["1week", 604800000], - ["1 week", 604800000], - ["1weeks", 604800000], - ["1 weeks", 604800000], - ["2w", 1209600000], - ["4w", 2419200000], - ["0.5w", 302400000], - ["1.5w", 907200000], - - // Years - all variations - ["1y", 31557600000], - ["1 y", 31557600000], - ["1yr", 31557600000], - ["1 yr", 31557600000], - ["1yrs", 31557600000], - ["1 yrs", 31557600000], - ["1year", 31557600000], - ["1 year", 31557600000], - ["1years", 31557600000], - ["1 years", 31557600000], - ["2y", 63115200000], - ["0.5y", 15778800000], - ["1.5y", 47336400000], - - // Numbers without units (treated as ms) - ["1", 1], - ["100", 100], - ["1000", 1000], - ["0", 0], - ["53", 53], - - // Negative values - all units - ["-1ms", -1], - ["-100ms", -100], - ["-1s", -1000], - ["-10s", -10000], - ["-1m", -60000], - ["-5m", -300000], - ["-1h", -3600000], - ["-2h", -7200000], - ["-1d", -86400000], - ["-2d", -172800000], - ["-1w", -604800000], - ["-1y", -31557600000], - ["-0.5s", -500], - ["-0.5m", -30000], - ["-1.5h", -5400000], - ["-100", -100], - - // Case insensitive - all units - ["1MS", 1], - ["1Ms", 1], - ["1mS", 1], - ["1S", 1000], - ["1Sec", 1000], - ["1SECOND", 1000], - ["1M", 60000], - ["1Min", 60000], - ["1MINUTE", 60000], - ["1H", 3600000], - ["1Hr", 3600000], - ["1HOUR", 3600000], - ["1D", 86400000], - ["1Day", 86400000], - ["1DAY", 86400000], - ["1W", 604800000], - ["1Week", 604800000], - ["1WEEK", 604800000], - ["1Y", 31557600000], - ["1Yr", 31557600000], - ["1YEAR", 31557600000], - - // Edge cases with whitespace - [" 1s", 1000], - ["1s ", 1000], - [" 1s ", 1000], - ["1 s", 1000], - ["1 s", 1000], - - // Invalid inputs - should return undefined - ["", undefined], - [" ", undefined], - [" ", undefined], - ["invalid", undefined], - ["hello world", undefined], - ["s", undefined], - ["m", undefined], - ["h", undefined], - ["ms", undefined], - ["1x", undefined], - ["1xs", undefined], - ["1sm", undefined], - ["1s2m", undefined], - ["1 s 2 m", undefined], - ["s1", undefined], - ["1.2.3s", undefined], - ["NaN", undefined], - ["Infinity", undefined], - ["-", undefined], - [".", undefined], - ["-.5s", -500], // This should work - ["abc123", undefined], - ["123abc", undefined], - ["1.s", 1000], // Should parse as "1" with unit "s" -]; - -test.each(parseTests)("parse: ms(%p) === %p", (input, expected) => { - expect(ms(input)).toBe(expected); + describe("invalid inputs", () => { + test.each([ + ["", "empty string"], + [" ", "whitespace only"], + ["foo", "invalid unit"], + ["1x", "unknown unit"], + ["1.2.3s", "multiple dots"], + ])('Bun.ms("%s") should return NaN (%s)', (input) => { + expect(Bun.ms(input)).toBeNaN(); + }); + }); }); -// Test format (number -> string) -const formatTests: [number, { long?: boolean } | undefined, string][] = [ - // Milliseconds - [0, undefined, "0ms"], - [1, undefined, "1ms"], - [100, undefined, "100ms"], - [999, undefined, "999ms"], +describe("Bun.ms - format (number to string)", () => { + describe("short format", () => { + test.each([ + [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"], + ])("Bun.ms(%d) should return %s", (input, expected) => { + expect(Bun.ms(input)).toBe(expected); + }); + }); - // Seconds - [1000, undefined, "1s"], - [1500, undefined, "2s"], - [2000, undefined, "2s"], - [10000, undefined, "10s"], - [59000, undefined, "59s"], - [59999, undefined, "60s"], + describe("long format", () => { + test.each([ + [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"], + ])("Bun.ms(%d, { long: true }) should return %s", (input, expected) => { + expect(Bun.ms(input, { long: true })).toBe(expected); + }); + }); - // Minutes - [60000, undefined, "1m"], - [90000, undefined, "2m"], - [120000, undefined, "2m"], - [300000, undefined, "5m"], - [3540000, undefined, "59m"], - [3599999, undefined, "60m"], + describe("invalid number inputs", () => { + test("NaN should throw", () => { + expect(() => Bun.ms(NaN)).toThrow(); + }); - // Hours - [3600000, undefined, "1h"], - [5400000, undefined, "2h"], - [7200000, undefined, "2h"], - [36000000, undefined, "10h"], - [82800000, undefined, "23h"], - [86399999, undefined, "24h"], + test("Infinity should throw", () => { + expect(() => Bun.ms(Infinity)).toThrow(); + }); - // Days - [86400000, undefined, "1d"], - [129600000, undefined, "2d"], - [172800000, undefined, "2d"], - [604800000, undefined, "7d"], - - // Negative values - [-1, undefined, "-1ms"], - [-1000, undefined, "-1s"], - [-60000, undefined, "-1m"], - [-3600000, undefined, "-1h"], - [-86400000, undefined, "-1d"], - - // Long format - milliseconds - [0, { long: true }, "0 ms"], - [1, { long: true }, "1 ms"], - [100, { long: true }, "100 ms"], - [999, { long: true }, "999 ms"], - - // Long format - seconds - [1000, { long: true }, "1 second"], - [1500, { long: true }, "2 seconds"], - [2000, { long: true }, "2 seconds"], - [10000, { long: true }, "10 seconds"], - - // Long format - minutes - [60000, { long: true }, "1 minute"], - [90000, { long: true }, "2 minutes"], - [120000, { long: true }, "2 minutes"], - [300000, { long: true }, "5 minutes"], - - // Long format - hours - [3600000, { long: true }, "1 hour"], - [5400000, { long: true }, "2 hours"], - [7200000, { long: true }, "2 hours"], - [36000000, { long: true }, "10 hours"], - - // Long format - days - [86400000, { long: true }, "1 day"], - [129600000, { long: true }, "2 days"], - [172800000, { long: true }, "2 days"], - - // Long format - negative - [-1000, { long: true }, "-1 second"], - [-60000, { long: true }, "-1 minute"], - [-3600000, { long: true }, "-1 hour"], - [-86400000, { long: true }, "-1 day"], -]; - -test.each(formatTests)("format: ms(%p, %p) === %p", (input, options, expected) => { - expect(ms(input, options)).toBe(expected); + test("-Infinity should throw", () => { + expect(() => Bun.ms(-Infinity)).toThrow(); + }); + }); }); -// Test errors -test("throws on NaN", () => { - expect(() => ms(NaN)).toThrow("Value must be a finite number"); -}); +describe("Bun.ms - comprehensive coverage", () => { + describe("all time units", () => { + test.each([ + // 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], + ])('Bun.ms("%s") should parse correctly', (input, expected) => { + expect(Bun.ms(input)).toBe(expected); + }); + }); -test("throws on Infinity", () => { - expect(() => ms(Infinity)).toThrow("Value must be a finite number"); -}); + describe("decimals and negatives", () => { + test.each([ + ["1.5s", 1500], + ["1.5h", 5400000], + ["0.5d", 43200000], + ["-1s", -1000], + ["-1.5h", -5400000], + ["-0.5d", -43200000], + [".5s", 500], + ["-.5s", -500], + ])('Bun.ms("%s") should handle decimals/negatives', (input, expected) => { + expect(Bun.ms(input)).toBe(expected); + }); + }); -test("throws on -Infinity", () => { - expect(() => ms(-Infinity)).toThrow("Value must be a finite number"); -}); - -// Test that it's available on Bun global -test("Bun.ms is available", () => { - expect(typeof Bun.ms).toBe("function"); - expect(Bun.ms("1s")).toBe(1000); - expect(Bun.ms(1000)).toBe("1s"); + describe("whitespace handling", () => { + test.each([ + ["1 s", 1000], + ["1 s", 1000], + ["1 s", 1000], + [" 1s", 1000], + ["1s ", 1000], + [" 1s ", 1000], + ["1 second", 1000], + ["1 seconds", 1000], + [" 1 second ", 1000], + ])('Bun.ms("%s") should handle whitespace', (input, expected) => { + expect(Bun.ms(input)).toBe(expected); + }); + }); });