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>
This commit is contained in:
Claude Bot
2025-10-01 12:43:13 +00:00
parent b00bf53cb4
commit 33eac619ef
4 changed files with 344 additions and 304 deletions

View File

@@ -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;
/**

View File

@@ -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));
}

View File

@@ -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"),

View File

@@ -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);
});
});
});