diff --git a/AGENTS.md b/AGENTS.md index 5194d5e6..8cb811dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,6 +146,25 @@ helpers// **Coverage:** 100% lines, functions, branches, statements — no exceptions. +### Helper Placement + +**Type predicates** (`is`) → `type/` category. +These answer "what *type* is this value?" and return a TypeScript type guard. +Examples: `isArray`, `isString`, `isNull`, `isPromise`. + +**State predicates** → **their own category**, never `type/`. +These answer "what *state* is this value in?" and are category-specific. +Category-specific examples: + +- `isEmpty` for arrays → `array/isEmpty` (not `type/isEmpty`) +- `isEmpty` for objects → `object/isEmpty` +- `isEmpty` for strings → `string/isEmpty` +- `isNonEmpty` for arrays → `array/isNonEmpty` + +The distinction: a type predicate narrows the TypeScript type (`value is T`); a state predicate +checks a runtime condition within an already-known type context (e.g. an array that happens to be +empty). Mixing both in `type/` blurs the category boundary and makes callers import unrelated logic. + **Intentional cross-category duplicates:** `compact` and `equalsShallow` exist in both `array/` and `object/`. Do **NOT** merge or deduplicate them — each category is an independent npm package and cross-package imports break tree-shaking. ### License Header (required on all source files) diff --git a/TODO.md b/TODO.md index 8a99cc59..46d1bbef 100644 --- a/TODO.md +++ b/TODO.md @@ -1,94 +1,12 @@ # TODO — `helpers4/typescript` -> Last refresh: 2026-05-13. +> Last refresh: 2026-06-14. Legend: 🔴 High priority · 🟡 Medium · 🟢 Low --- -## 1. `type/` — gap fill - -**Source:** [radashi-org/discussions#46](https://github.com/orgs/radashi-org/discussions/46#discussioncomment-11736331) -— comparison table of Radashi / another lib / `@sindresorhus/is` (~90 predicates). -Radashi won't act on this. helpers4 can close the relevant gaps since the design is already -tree-shakeable, browser-safe, and one-file-per-predicate. - -> Note: `isNumber(NaN) === false` is already correct in helpers4 — not affected by Radash #405. - -### 🔴 Numeric — common, frequently needed - -| Helper | Implementation note | -|--------|---------------------| -| `isInteger` | `Number.isInteger(value)` — distinct from `isNumber` | -| `isNaN` | `Number.isNaN(value)` — safe version (not the legacy global) | -| `isSafeInteger` | `Number.isSafeInteger(value)` | -| `isInfinite` | `value === Infinity \|\| value === -Infinity` | - -### 🔴 Collections — used widely - -| Helper | Implementation note | -|--------|---------------------| -| `isSet` | `value instanceof Set` | -| `isWeakMap` | `value instanceof WeakMap` | -| `isWeakSet` | `value instanceof WeakSet` | -| `isWeakRef` | `value instanceof WeakRef` | - -### 🟡 Numeric — nice-to-have - -| Helper | Implementation note | -|--------|---------------------| -| `isEvenInteger` | `isInteger(n) && n % 2 === 0` | -| `isOddInteger` | `isInteger(n) && n % 2 !== 0` | - -### 🟡 Iteration protocol - -| Helper | Implementation note | -|--------|---------------------| -| `isAsyncIterable` | `Symbol.asyncIterator in Object(value)` | -| `isGenerator` | `Object.prototype.toString` → `[object Generator]` | -| `isGeneratorFunction` | `Object.prototype.toString` → `[object GeneratorFunction]` | -| `isAsyncGenerator` | `Object.prototype.toString` → `[object AsyncGenerator]` | -| `isAsyncGeneratorFunction` | `Object.prototype.toString` → `[object AsyncGeneratorFunction]` | - -### 🟡 String specializations - -| Helper | Implementation note | -|--------|---------------------| -| `isEmptyString` | `value === ''` | -| `isWhitespaceString` | `isString(value) && value.trim() === ''` | - -> `isNonEmptyString` already exists. - -### 🟡 Object / Array specializations - -| Helper | Implementation note | -|--------|---------------------| -| `isEmptyArray` | `isArray(value) && value.length === 0` | -| `isEmptyObject` | `isPlainObject(value) && Object.keys(value).length === 0` | -| `isNonEmptyObject` | `isPlainObject(value) && Object.keys(value).length > 0` | - -### 🟢 General purpose - -| Helper | Implementation note | -|--------|---------------------| -| `isPropertyKey` | `isString(v) \|\| isNumber(v) \|\| isSymbol(v)` → `value is PropertyKey` | -| `isPromiseLike` | `value != null && typeof (value as any).then === 'function'` (thenable) | -| `isArrayLike` | `value != null && typeof (value as any).length === 'number'` | -| `isHtmlElement` | `typeof HTMLElement !== 'undefined' && value instanceof HTMLElement` — browser-only, document it | -| `isUrlInstance` | `value instanceof URL` | - -### Explicitly out of scope - -- Typed arrays (`Int8Array`, `Uint8Array`, etc.) — too niche, no tree-shaking benefit -- `isNodeStream`, `isSharedArrayBuffer` — Node.js specific -- `isObservable` — handled by the `observable/` category -- `isAll` / `isAny` / global `is` / `assert` — meta-predicates, different design surface -- `isClass`, `isBoundFunction`, `isTagged`, `isDirectInstanceOf`, `isEnumCase` — reflection / meta -- `isResult` / `isResultOk` / `isResultErr` — requires a Result type not shipped by this lib - ---- - -## 2. OpenSSF Scorecard +## 1. OpenSSF Scorecard > Last snapshot: **6.7**. Goal: lift the score by closing the highest-impact > checks first, while keeping CI behaviour stable. @@ -147,9 +65,6 @@ After each PR, re-run Scorecard and capture the delta. --- -## 3. Suggested next steps +## 2. Suggested next steps -1. **`type/` gap fill** — tackle §1 numeric + collection predicates first (high value, small effort). - One file per predicate following the existing pattern. -2. **OpenSSF PRs C/D/E** — land in parallel, they don't conflict with the helper roadmap. -3. Open one issue per accepted helper in §1 with its source reference for traceability. +1. **OpenSSF PRs C/D/E** — land in parallel with the helper roadmap. diff --git a/docs/native-alternatives.json b/docs/native-alternatives.json index b2cf6b5f..38c24506 100644 --- a/docs/native-alternatives.json +++ b/docs/native-alternatives.json @@ -317,6 +317,16 @@ "example": "Number.isFinite(value)", "notes": "Prefer Number.isFinite() over global isFinite() which coerces" }, + { + "name": "isInfinite", + "libraries": [ + "sindresorhus/is" + ], + "native": "value === Infinity || value === -Infinity / !Number.isFinite(value) && !Number.isNaN(value)", + "since": "ES2015", + "example": "value === Infinity || value === -Infinity\n// or: !Number.isFinite(value) && !Number.isNaN(value)", + "notes": "Number.isFinite() is the complement; compose with a NaN guard if needed. No dedicated helper warranted." + }, { "name": "isSet (Set data structure)", "libraries": [ diff --git a/helpers/array/isEmpty.example.ts b/helpers/array/isEmpty.example.ts new file mode 100644 index 00000000..351cbc0a --- /dev/null +++ b/helpers/array/isEmpty.example.ts @@ -0,0 +1,47 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isEmpty } from './isEmpty'; + +const examples: HelperExamples = { + helper: 'isEmpty', + category: 'array', + examples: [ + { + title: 'Check if an array is empty', + description: 'Returns true only for arrays with no elements.', + code: `isEmpty([]) // => true +isEmpty([1, 2, 3]) // => false +isEmpty([null]) // => false (null is still an element)`, + assert: () => { + if (!isEmpty([])) throw new Error('[] should be empty'); + if (isEmpty([1, 2, 3])) throw new Error('[1,2,3] should not be empty'); + if (isEmpty([null])) throw new Error('[null] should not be empty'); + }, + }, + { + title: 'Branch on empty array with type narrowing', + description: 'In the true branch, the type narrows to never[], ensuring no element access.', + code: `function first(arr: T[]): T | undefined { + if (isEmpty(arr)) return undefined; + return arr[0]; // TypeScript knows arr is non-empty here +} +first([]) // => undefined +first([1, 2]) // => 1`, + assert: () => { + function first(arr: T[]): T | undefined { + if (isEmpty(arr)) return undefined; + return arr[0]; + } + if (first([]) !== undefined) throw new Error('Expected undefined'); + if (first([1, 2]) !== 1) throw new Error('Expected 1'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/array/isEmpty.spec.ts b/helpers/array/isEmpty.spec.ts new file mode 100644 index 00000000..d4ec6eb2 --- /dev/null +++ b/helpers/array/isEmpty.spec.ts @@ -0,0 +1,30 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isEmpty } from './isEmpty'; + +describe('isEmpty — property-based', () => { + it('is always false for arrays with at least one element', () => { + fc.assert( + fc.property(fc.array(fc.anything(), { minLength: 1 }), (arr) => { + expect(isEmpty(arr)).toBe(false); + }), + ); + }); +}); + +describe('isEmpty — contracts', () => { + it('isEmpty and isNonEmpty are logical inverses', async () => { + const { isNonEmpty } = await import('./isNonEmpty'); + fc.assert( + fc.property(fc.array(fc.anything()), (arr) => { + expect(isEmpty(arr)).toBe(!isNonEmpty(arr)); + }), + ); + }); +}); diff --git a/helpers/array/isEmpty.test.ts b/helpers/array/isEmpty.test.ts new file mode 100644 index 00000000..e616916d --- /dev/null +++ b/helpers/array/isEmpty.test.ts @@ -0,0 +1,43 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isEmpty } from './isEmpty'; + +describe('isEmpty', () => { + it('should return true for an empty array', () => { + expect(isEmpty([])).toBe(true); + }); + + it('should return false for a non-empty array', () => { + expect(isEmpty([1])).toBe(false); + }); + + it('should return false for an array with multiple elements', () => { + expect(isEmpty([1, 2, 3])).toBe(false); + }); + + it('should return false for an array containing falsy values', () => { + expect(isEmpty([null])).toBe(false); + expect(isEmpty([undefined])).toBe(false); + expect(isEmpty([0])).toBe(false); + expect(isEmpty([''])).toBe(false); + expect(isEmpty([false])).toBe(false); + }); + + it('should work with readonly arrays', () => { + const arr: readonly number[] = []; + expect(isEmpty(arr)).toBe(true); + }); + + it('should narrow type to never[] in true branch', () => { + const arr: string[] = []; + if (isEmpty(arr)) { + const _: readonly never[] = arr; + expect(_).toEqual([]); + } + }); +}); diff --git a/helpers/array/isEmpty.ts b/helpers/array/isEmpty.ts new file mode 100644 index 00000000..9db8669c --- /dev/null +++ b/helpers/array/isEmpty.ts @@ -0,0 +1,18 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if an array is empty (has no elements). + * @param value - The array to check + * @returns `true` if the array has no elements + * @example + * isEmpty([]) // => true + * isEmpty([1, 2, 3]) // => false + * @since next + */ +export function isEmpty(value: readonly unknown[]): value is readonly never[] { + return value.length === 0; +} diff --git a/helpers/array/isNonEmpty.example.ts b/helpers/array/isNonEmpty.example.ts new file mode 100644 index 00000000..bb3a4bb5 --- /dev/null +++ b/helpers/array/isNonEmpty.example.ts @@ -0,0 +1,47 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isNonEmpty } from './isNonEmpty'; + +const examples: HelperExamples = { + helper: 'isNonEmpty', + category: 'array', + examples: [ + { + title: 'Check if an array has elements', + description: 'Returns true for arrays with at least one element, regardless of the element values.', + code: `isNonEmpty([1, 2, 3]) // => true +isNonEmpty([null]) // => true (null is still an element) +isNonEmpty([]) // => false`, + assert: () => { + if (!isNonEmpty([1, 2, 3])) throw new Error('[1,2,3] should be non-empty'); + if (!isNonEmpty([null])) throw new Error('[null] should be non-empty'); + if (isNonEmpty([])) throw new Error('[] should not be non-empty'); + }, + }, + { + title: 'Safe first-element access with type narrowing', + description: 'In the true branch, the type narrows to [T, ...T[]], making arr[0] always defined.', + code: `function first(arr: readonly T[]): T | undefined { + if (isNonEmpty(arr)) return arr[0]; // arr[0] is T, not T | undefined + return undefined; +} +first([1, 2]) // => 1 +first([]) // => undefined`, + assert: () => { + function first(arr: readonly T[]): T | undefined { + if (isNonEmpty(arr)) return arr[0]; + return undefined; + } + if (first([1, 2]) !== 1) throw new Error('Expected 1'); + if (first([]) !== undefined) throw new Error('Expected undefined'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/array/isNonEmpty.spec.ts b/helpers/array/isNonEmpty.spec.ts new file mode 100644 index 00000000..1b9a049b --- /dev/null +++ b/helpers/array/isNonEmpty.spec.ts @@ -0,0 +1,35 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isNonEmpty } from './isNonEmpty'; + +describe('isNonEmpty — property-based', () => { + it('is always true for arrays with at least one element', () => { + fc.assert( + fc.property(fc.array(fc.anything(), { minLength: 1 }), (arr) => { + expect(isNonEmpty(arr)).toBe(true); + }), + ); + }); + + it('is always false for empty arrays', () => { + expect(isNonEmpty([])).toBe(false); + }); +}); + +describe('isNonEmpty — contracts', () => { + it('first element is accessible without undefined when isNonEmpty is true', () => { + fc.assert( + fc.property(fc.array(fc.integer(), { minLength: 1 }), (arr) => { + if (isNonEmpty(arr)) { + expect(arr[0]).toBeDefined(); + } + }), + ); + }); +}); diff --git a/helpers/array/isNonEmpty.test.ts b/helpers/array/isNonEmpty.test.ts new file mode 100644 index 00000000..78aa3879 --- /dev/null +++ b/helpers/array/isNonEmpty.test.ts @@ -0,0 +1,43 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isNonEmpty } from './isNonEmpty'; + +describe('isNonEmpty', () => { + it('should return true for a non-empty array', () => { + expect(isNonEmpty([1])).toBe(true); + }); + + it('should return true for an array with multiple elements', () => { + expect(isNonEmpty([1, 2, 3])).toBe(true); + }); + + it('should return false for an empty array', () => { + expect(isNonEmpty([])).toBe(false); + }); + + it('should return true for an array containing falsy values', () => { + expect(isNonEmpty([null])).toBe(true); + expect(isNonEmpty([undefined])).toBe(true); + expect(isNonEmpty([0])).toBe(true); + expect(isNonEmpty([''])).toBe(true); + expect(isNonEmpty([false])).toBe(true); + }); + + it('should work with readonly arrays', () => { + const arr: readonly number[] = [1, 2]; + expect(isNonEmpty(arr)).toBe(true); + }); + + it('should narrow type to non-empty tuple in true branch', () => { + const arr: number[] = [1, 2, 3]; + if (isNonEmpty(arr)) { + const first: number = arr[0]; + expect(first).toBe(1); + } + }); +}); diff --git a/helpers/array/isNonEmpty.ts b/helpers/array/isNonEmpty.ts new file mode 100644 index 00000000..3ad12e80 --- /dev/null +++ b/helpers/array/isNonEmpty.ts @@ -0,0 +1,18 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if an array is non-empty (has at least one element). + * @param value - The array to check + * @returns `true` if the array has at least one element + * @example + * isNonEmpty([1, 2, 3]) // => true + * isNonEmpty([]) // => false + * @since next + */ +export function isNonEmpty(value: readonly T[]): value is readonly [T, ...T[]] { + return value.length > 0; +} diff --git a/helpers/array/select.example.ts b/helpers/array/select.example.ts new file mode 100644 index 00000000..be19c1d5 --- /dev/null +++ b/helpers/array/select.example.ts @@ -0,0 +1,53 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { select } from './select'; + +const examples: HelperExamples = { + helper: 'select', + category: 'array', + examples: [ + { + title: 'Filter and transform in one pass', + description: + 'Keeps only items matching the condition and transforms them — ' + + 'equivalent to .filter().map() but with a single iteration.', + code: `select([1, 2, 3, 4, 5], x => x * 2, x => x % 2 === 0) +// => [4, 8]`, + assert: () => { + const result = select([1, 2, 3, 4, 5], x => x * 2, x => x % 2 === 0); + if (result.length !== 2 || result[0] !== 4 || result[1] !== 8) { + throw new Error('Expected [4, 8]'); + } + }, + }, + { + title: 'Extract a field from matching objects', + description: 'Filter on a condition and pluck a specific property in a single readable call.', + code: `const users = [ + { name: 'Alice', active: true }, + { name: 'Bob', active: false }, + { name: 'Carol', active: true }, +]; +select(users, u => u.name, u => u.active) +// => ['Alice', 'Carol']`, + assert: () => { + const users = [ + { name: 'Alice', active: true }, + { name: 'Bob', active: false }, + { name: 'Carol', active: true }, + ]; + const result = select(users, u => u.name, u => u.active); + if (result.length !== 2 || result[0] !== 'Alice' || result[1] !== 'Carol') { + throw new Error('Expected [\'Alice\', \'Carol\']'); + } + }, + }, + ], +}; + +export default examples; diff --git a/helpers/array/select.spec.ts b/helpers/array/select.spec.ts new file mode 100644 index 00000000..9f9d453b --- /dev/null +++ b/helpers/array/select.spec.ts @@ -0,0 +1,86 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { select } from './select'; + +describe('select — property-based', () => { + it('result length is at most input length', () => { + fc.assert( + fc.property( + fc.array(fc.integer()), + fc.func(fc.integer()), + fc.func(fc.boolean()), + (arr, mapper, condition) => { + expect(select(arr, mapper, condition).length).toBeLessThanOrEqual(arr.length); + }, + ), + ); + }); + + it('result length equals number of items passing the condition', () => { + fc.assert( + fc.property( + fc.array(fc.integer()), + fc.integer({ min: -50, max: 50 }), + (arr, threshold) => { + const condition = (x: number) => x > threshold; + const passing = arr.filter(condition); + expect(select(arr, x => x, condition).length).toBe(passing.length); + }, + ), + ); + }); +}); + +describe('select — contracts', () => { + it('matches .filter(condition).map(mapper) for index-agnostic callbacks', () => { + fc.assert( + fc.property( + fc.array(fc.integer({ min: -100, max: 100 })), + (arr) => { + const condition = (x: number) => x > 0; + const mapper = (x: number) => x * 2; + expect(select(arr, mapper, condition)).toEqual( + arr.filter(condition).map(mapper), + ); + }, + ), + ); + }); + + it('passes the original array index (not post-filter index) to mapper', () => { + // select([10, 20, 30], (_, i) => i, (_, i) => i === 2) → [2] + // .filter().map() would yield [0] because filter re-indexes + expect(select([10, 20, 30], (_x, i) => i, (_x, i) => i === 2)).toEqual([2]); + }); + + it('without condition is equivalent to .map()', () => { + fc.assert( + fc.property(fc.array(fc.integer()), (arr) => { + const mapper = (x: number) => x * 3; + expect(select(arr, mapper)).toEqual(arr.map(mapper)); + }), + ); + }); + + it('mapper is never called for items failing the condition', () => { + fc.assert( + fc.property( + fc.array(fc.integer()), + fc.integer({ min: -50, max: 50 }), + (arr, threshold) => { + const condition = (x: number) => x > threshold; + const seen: number[] = []; + select(arr, x => { seen.push(x); return x; }, condition); + const passing = arr.filter(condition); + expect(seen).toEqual(passing); + }, + ), + ); + }); +}); diff --git a/helpers/array/select.test.ts b/helpers/array/select.test.ts new file mode 100644 index 00000000..2a0ea1d3 --- /dev/null +++ b/helpers/array/select.test.ts @@ -0,0 +1,52 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { select } from './select'; + +describe('select', () => { + it('should filter and map in one pass', () => { + expect(select([1, 2, 3, 4, 5], x => x * 2, x => x % 2 === 0)).toEqual([4, 8]); + }); + + it('should return all mapped values when no condition is given', () => { + expect(select([1, 2, 3], x => x * 10)).toEqual([10, 20, 30]); + }); + + it('should return an empty array when no items pass the condition', () => { + expect(select([1, 3, 5], x => x * 2, x => x % 2 === 0)).toEqual([]); + }); + + it('should return an empty array for an empty input', () => { + expect(select([], x => x, () => true)).toEqual([]); + }); + + it('should pass the index to both mapper and condition', () => { + const indices: number[] = []; + select([10, 20, 30], (x, i) => { indices.push(i); return x; }, (_, i) => i < 2); + expect(indices).toEqual([0, 1]); + }); + + it('should support mapping to a different type', () => { + const result = select( + [{ name: 'Alice', active: true }, { name: 'Bob', active: false }], + u => u.name, + u => u.active, + ); + expect(result).toEqual(['Alice']); + }); + + it('should not call mapper for items that fail the condition', () => { + let mapperCalls = 0; + select([1, 2, 3], x => { mapperCalls++; return x; }, x => x > 2); + expect(mapperCalls).toBe(1); + }); + + it('should work with readonly arrays', () => { + const arr: readonly number[] = [1, 2, 3, 4]; + expect(select(arr, x => x * 2, x => x > 2)).toEqual([6, 8]); + }); +}); diff --git a/helpers/array/select.ts b/helpers/array/select.ts new file mode 100644 index 00000000..60e5fe96 --- /dev/null +++ b/helpers/array/select.ts @@ -0,0 +1,48 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Filters and transforms an array in a single pass. + * + * Similar to `.filter(condition).map(mapper)` but iterates the array only once. + * **Index semantics differ from `.filter().map()`:** the `index` passed to both + * `condition` and `mapper` is the index in the **original** array, not the + * post-filter position. Use index-agnostic callbacks when the two must behave + * identically. + * + * @param array - The array to process + * @param mapper - Transforms each item that passes the condition + * @param condition - Determines which items to include; defaults to keeping all items + * @returns Mapped values for items that pass the condition + * @example + * // Keep only even numbers and double them + * select([1, 2, 3, 4, 5], x => x * 2, x => x % 2 === 0) + * // => [4, 8] + * + * @example + * // Extract active users' emails + * select(users, u => u.email, u => u.isActive) + * // => ['alice@example.com', 'bob@example.com'] + * + * @example + * // Without condition — equivalent to .map() + * select([1, 2, 3], x => x * 10) + * // => [10, 20, 30] + * @since next + */ +export function select( + array: readonly T[], + mapper: (item: T, index: number) => U, + condition: (item: T, index: number) => boolean = () => true, +): U[] { + const result: U[] = []; + for (let i = 0; i < array.length; i++) { + if (condition(array[i], i)) { + result.push(mapper(array[i], i)); + } + } + return result; +} diff --git a/helpers/array/sort.ts b/helpers/array/sort.ts index cd83fce7..c8c2d089 100644 --- a/helpers/array/sort.ts +++ b/helpers/array/sort.ts @@ -13,7 +13,7 @@ export type SortFn = (a: T, b: T) => number; /** * Default property names checked (in order) by auto-detecting sort helpers * when no explicit property key is provided. - * @since 2.0.2 + * @internal */ export const DEFAULT_SORT_STRING_PROPS = ['value', 'label', 'title', 'description'] as const; diff --git a/helpers/array/sortNatural.ts b/helpers/array/sortNatural.ts index 5a4cf2b2..c40e0d54 100644 --- a/helpers/array/sortNatural.ts +++ b/helpers/array/sortNatural.ts @@ -61,6 +61,11 @@ export const sortStringNaturalAscInsensitiveFn: SortFn = (a: string, b: export const sortStringNaturalDescInsensitiveFn: SortFn = (a: string, b: string) => getNaturalCollatorInsensitive().compare(b, a); +/** @internal */ +function natVal>(obj: T, key: keyof T): string { + return String(obj[key] ?? ''); +} + /** * Creates a sort function for objects by one or more string properties using * natural ordering. Numbers embedded in values are compared numerically: @@ -75,10 +80,6 @@ export const sortStringNaturalDescInsensitiveFn: SortFn = (a: string, b: * @returns Sort function * @since 2.0.2 */ -function natVal>(obj: T, key: keyof T): string { - return String(obj[key] ?? ''); -} - export function createSortByNaturalFn>( property?: keyof T | readonly (keyof T)[], caseInsensitive: boolean = false, diff --git a/helpers/date/isValid.spec.ts b/helpers/date/isValid.spec.ts new file mode 100644 index 00000000..eee25ef9 --- /dev/null +++ b/helpers/date/isValid.spec.ts @@ -0,0 +1,56 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { isDate } from '../type/isDate'; +import { isValid } from './isValid'; + +describe('isValid — property-based', () => { + it('isValid(v) → isDate(v)', () => { + fc.assert( + fc.property(fc.date(), (d) => { + if (isValid(d)) { + expect(isDate(d)).toBe(true); + } + }), + ); + }); + + it('valid dates have finite getTime()', () => { + fc.assert( + fc.property(fc.date(), (d) => { + if (isValid(d)) { + expect(Number.isFinite(d.getTime())).toBe(true); + } + }), + ); + }); +}); + +describe('isValid — contract', () => { + it('new Date() → true', () => expect(isValid(new Date())).toBe(true)); + it('new Date(0) → true', () => expect(isValid(new Date(0))).toBe(true)); + it("new Date('2024-01-01') → true", () => expect(isValid(new Date('2024-01-01'))).toBe(true)); + it("new Date('invalid') → false", () => expect(isValid(new Date('invalid'))).toBe(false)); + it("'date string' → false", () => expect(isValid('2024-01-01')).toBe(false)); + it('null → false', () => expect(isValid(null)).toBe(false)); + it('undefined → false', () => expect(isValid(undefined)).toBe(false)); + it('Date.now() (number) → false', () => expect(isValid(Date.now())).toBe(false)); +}); + +describe('isValid — narrowing in if/else', () => { + it('narrows the value to Date in the then-branch', () => { + const v: unknown = new Date('2024-01-01'); + if (isValid(v)) { + expectTypeOf(v).toEqualTypeOf(); + expect(Number.isFinite(v.getTime())).toBe(true); + } else { + throw new Error('expected then-branch'); + } + expect(isValid(new Date('not-a-date'))).toBe(false); + }); +}); diff --git a/helpers/date/isValid.test.ts b/helpers/date/isValid.test.ts new file mode 100644 index 00000000..3fb53c99 --- /dev/null +++ b/helpers/date/isValid.test.ts @@ -0,0 +1,29 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isValid } from './isValid'; + +describe('isValid', () => { + it('should return true for valid Date instances', () => { + expect(isValid(new Date())).toBe(true); + expect(isValid(new Date('2023-01-01'))).toBe(true); + expect(isValid(new Date(0))).toBe(true); + }); + + it('should return false for invalid Date instances', () => { + expect(isValid(new Date('invalid'))).toBe(false); + expect(isValid(new Date(NaN))).toBe(false); + }); + + it('should return false for non-Date values', () => { + expect(isValid('2023-01-01')).toBe(false); + expect(isValid(1609459200000)).toBe(false); + expect(isValid(null)).toBe(false); + expect(isValid(undefined)).toBe(false); + expect(isValid({})).toBe(false); + }); +}); diff --git a/helpers/date/isValid.ts b/helpers/date/isValid.ts new file mode 100644 index 00000000..9c309c31 --- /dev/null +++ b/helpers/date/isValid.ts @@ -0,0 +1,26 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is a valid Date instance (not `Invalid Date`). + * + * Unlike `isDate` (in `type/`), this also verifies that the internal timestamp + * is not `NaN`. + * + * @param value - The value to check + * @returns True if value is a Date instance with a valid time value + * @example + * isValid(new Date()) // => true + * isValid(new Date('invalid')) // => false + * isValid('2023-01-01') // => false (not a Date instance) + * + * @see `type/isDate` for checking only if value is a Date instance + * @see `type/isTimestamp` for checking if a number is a valid timestamp + * @since 2.0.0 + */ +export function isValid(value: unknown): value is Date { + return value instanceof Date && !Number.isNaN(value.getTime()); +} diff --git a/helpers/node/isNodeStream.example.ts b/helpers/node/isNodeStream.example.ts new file mode 100644 index 00000000..4aae3656 --- /dev/null +++ b/helpers/node/isNodeStream.example.ts @@ -0,0 +1,44 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isNodeStream } from './isNodeStream'; + +const examples: HelperExamples = { + helper: 'isNodeStream', + category: 'node', + examples: [ + { + title: 'Detect a Node.js stream', + description: 'Returns true for any object with a .pipe() method (Readable, Writable, Transform, etc.).', + code: `import { Readable } from 'node:stream'; +isNodeStream(new Readable({ read() {} })) // => true +isNodeStream({}) // => false +isNodeStream(null) // => false`, + assert: () => { + if (!isNodeStream({ pipe: () => {} })) throw new Error('object with pipe should be a stream'); + if (isNodeStream({})) throw new Error('{} should not be a stream'); + if (isNodeStream(null)) throw new Error('null should not be a stream'); + }, + }, + { + title: 'Guard before piping an unknown value', + description: 'Use isNodeStream to safely pipe only known streams.', + code: `import { Writable } from 'node:stream'; +function pipeToOutput(source: unknown, dest: Writable): void { + if (isNodeStream(source)) { + source.pipe(dest); + } +}`, + assert: () => { + if (!isNodeStream({ pipe: () => {} })) throw new Error('duck-type pipe fn should return true'); + if (isNodeStream({ pipe: 'not-a-function' })) throw new Error('non-fn pipe should return false'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/node/isNodeStream.spec.ts b/helpers/node/isNodeStream.spec.ts new file mode 100644 index 00000000..2a132323 --- /dev/null +++ b/helpers/node/isNodeStream.spec.ts @@ -0,0 +1,30 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { Readable, Writable } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { isNodeStream } from './isNodeStream'; + +describe('isNodeStream — property-based', () => { + it('primitives are never streams', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.integer(), fc.boolean()), (value) => { + expect(isNodeStream(value)).toBe(false); + }), + ); + }); +}); + +describe('isNodeStream — contract', () => { + it('null → false', () => expect(isNodeStream(null)).toBe(false)); + it('undefined → false', () => expect(isNodeStream(undefined)).toBe(false)); + it('{} → false', () => expect(isNodeStream({})).toBe(false)); + it('{ pipe: non-fn } → false', () => expect(isNodeStream({ pipe: 42 })).toBe(false)); + it('{ pipe: fn } → true', () => expect(isNodeStream({ pipe: () => {} })).toBe(true)); + it('Readable → true', () => expect(isNodeStream(new Readable({ read() {} }))).toBe(true)); + it('Writable → true', () => expect(isNodeStream(new Writable({ write() {} }))).toBe(true)); +}); diff --git a/helpers/node/isNodeStream.test.ts b/helpers/node/isNodeStream.test.ts new file mode 100644 index 00000000..11f6aa55 --- /dev/null +++ b/helpers/node/isNodeStream.test.ts @@ -0,0 +1,51 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { Duplex, PassThrough, Readable, Transform, Writable } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { isNodeStream } from './isNodeStream'; + +describe('isNodeStream', () => { + it('should return true for Readable streams', () => { + expect(isNodeStream(new Readable({ read() {} }))).toBe(true); + }); + + it('should return true for Writable streams', () => { + expect(isNodeStream(new Writable({ write() {} }))).toBe(true); + }); + + it('should return true for Duplex and Transform streams', () => { + expect(isNodeStream(new Duplex())).toBe(true); + expect(isNodeStream(new Transform())).toBe(true); + expect(isNodeStream(new PassThrough())).toBe(true); + }); + + it('should return true for any object with a pipe function', () => { + expect(isNodeStream({ pipe: () => {} })).toBe(true); + }); + + it('should return false when pipe is not a function', () => { + expect(isNodeStream({ pipe: 'not-a-function' })).toBe(false); + expect(isNodeStream({ pipe: null })).toBe(false); + expect(isNodeStream({ pipe: 42 })).toBe(false); + }); + + it('should return false for objects without pipe', () => { + expect(isNodeStream({})).toBe(false); + expect(isNodeStream({ read: () => {} })).toBe(false); + }); + + it('should return false for null and undefined', () => { + expect(isNodeStream(null)).toBe(false); + expect(isNodeStream(undefined)).toBe(false); + }); + + it('should return false for primitives', () => { + expect(isNodeStream(42)).toBe(false); + expect(isNodeStream('stream')).toBe(false); + expect(isNodeStream(true)).toBe(false); + }); +}); diff --git a/helpers/node/isNodeStream.ts b/helpers/node/isNodeStream.ts new file mode 100644 index 00000000..e8a8cc08 --- /dev/null +++ b/helpers/node/isNodeStream.ts @@ -0,0 +1,31 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is a Node.js stream (has a `.pipe()` method). + * + * Uses duck-typing: any object with a `pipe` function qualifies, covering + * `Readable`, `Writable`, `Duplex`, `Transform`, and custom stream-compatible + * objects without importing from `node:stream`. + * + * @param value - The value to check + * @returns `true` if value is a Node.js stream + * @example + * import { Readable } from 'node:stream'; + * isNodeStream(new Readable()) // => true + * isNodeStream({}) // => false + * isNodeStream(null) // => false + * @since next + */ +export function isNodeStream(value: unknown): value is { pipe: (...args: unknown[]) => unknown } { + return ( + // Intentionally `object`-only, unlike `isPromiseLike` (see type/isPromiseLike.ts): + // a callable function exposing `.pipe()` would be an unusual, contrived stream shape. + value !== null && + typeof value === 'object' && + typeof (value as Record)['pipe'] === 'function' + ); +} diff --git a/helpers/node/isSharedArrayBuffer.example.ts b/helpers/node/isSharedArrayBuffer.example.ts new file mode 100644 index 00000000..0b08cac1 --- /dev/null +++ b/helpers/node/isSharedArrayBuffer.example.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isSharedArrayBuffer } from './isSharedArrayBuffer'; + +const examples: HelperExamples = { + helper: 'isSharedArrayBuffer', + category: 'node', + examples: [ + { + title: 'Distinguish SharedArrayBuffer from ArrayBuffer', + description: 'Returns true only for SharedArrayBuffer instances, not plain ArrayBuffers.', + code: `isSharedArrayBuffer(new SharedArrayBuffer(8)) // => true +isSharedArrayBuffer(new ArrayBuffer(8)) // => false +isSharedArrayBuffer(null) // => false`, + assert: () => { + if (!isSharedArrayBuffer(new SharedArrayBuffer(8))) throw new Error('SharedArrayBuffer should return true'); + if (isSharedArrayBuffer(new ArrayBuffer(8))) throw new Error('ArrayBuffer should return false'); + if (isSharedArrayBuffer(null)) throw new Error('null should return false'); + }, + }, + { + title: 'Safe shared memory check before worker communication', + description: 'Use as a guard to ensure a buffer can be transferred to a Worker.', + code: `function sendToWorker(buffer: unknown): void { + if (isSharedArrayBuffer(buffer)) { + // buffer is SharedArrayBuffer — can be shared directly + // worker.postMessage({ buffer }); + } else { + // must transfer or copy + } +}`, + assert: () => { + if (!isSharedArrayBuffer(new SharedArrayBuffer(0))) throw new Error('0-length SAB should return true'); + if (isSharedArrayBuffer(new Uint8Array(8).buffer)) throw new Error('ArrayBuffer view should return false'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/node/isSharedArrayBuffer.spec.ts b/helpers/node/isSharedArrayBuffer.spec.ts new file mode 100644 index 00000000..4e8f28be --- /dev/null +++ b/helpers/node/isSharedArrayBuffer.spec.ts @@ -0,0 +1,29 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isSharedArrayBuffer } from './isSharedArrayBuffer'; + +describe('isSharedArrayBuffer — property-based', () => { + it('primitives are never SharedArrayBuffers', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.integer(), fc.boolean()), (value) => { + expect(isSharedArrayBuffer(value)).toBe(false); + }), + ); + }); +}); + +describe('isSharedArrayBuffer — contract', () => { + it('null → false', () => expect(isSharedArrayBuffer(null)).toBe(false)); + it('undefined → false', () => expect(isSharedArrayBuffer(undefined)).toBe(false)); + it('ArrayBuffer → false', () => expect(isSharedArrayBuffer(new ArrayBuffer(8))).toBe(false)); + it('Uint8Array → false', () => expect(isSharedArrayBuffer(new Uint8Array(8))).toBe(false)); + it('{} → false', () => expect(isSharedArrayBuffer({})).toBe(false)); + it('SharedArrayBuffer(0) → true', () => expect(isSharedArrayBuffer(new SharedArrayBuffer(0))).toBe(true)); + it('SharedArrayBuffer(16) → true', () => expect(isSharedArrayBuffer(new SharedArrayBuffer(16))).toBe(true)); +}); diff --git a/helpers/node/isSharedArrayBuffer.test.ts b/helpers/node/isSharedArrayBuffer.test.ts new file mode 100644 index 00000000..ae209507 --- /dev/null +++ b/helpers/node/isSharedArrayBuffer.test.ts @@ -0,0 +1,37 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isSharedArrayBuffer } from './isSharedArrayBuffer'; + +describe('isSharedArrayBuffer', () => { + it('should return true for SharedArrayBuffer instances', () => { + expect(isSharedArrayBuffer(new SharedArrayBuffer(8))).toBe(true); + expect(isSharedArrayBuffer(new SharedArrayBuffer(0))).toBe(true); + }); + + it('should return false for regular ArrayBuffer', () => { + expect(isSharedArrayBuffer(new ArrayBuffer(8))).toBe(false); + expect(isSharedArrayBuffer(new ArrayBuffer(0))).toBe(false); + }); + + it('should return false for typed arrays', () => { + expect(isSharedArrayBuffer(new Uint8Array(8))).toBe(false); + expect(isSharedArrayBuffer(new Int32Array(4))).toBe(false); + }); + + it('should return false for null and undefined', () => { + expect(isSharedArrayBuffer(null)).toBe(false); + expect(isSharedArrayBuffer(undefined)).toBe(false); + }); + + it('should return false for other values', () => { + expect(isSharedArrayBuffer({})).toBe(false); + expect(isSharedArrayBuffer([])).toBe(false); + expect(isSharedArrayBuffer(42)).toBe(false); + expect(isSharedArrayBuffer('sab')).toBe(false); + }); +}); diff --git a/helpers/node/isSharedArrayBuffer.ts b/helpers/node/isSharedArrayBuffer.ts new file mode 100644 index 00000000..70d5675d --- /dev/null +++ b/helpers/node/isSharedArrayBuffer.ts @@ -0,0 +1,24 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is a `SharedArrayBuffer` instance. + * + * `SharedArrayBuffer` enables shared memory between the main thread and worker + * threads. In browsers without COOP/COEP headers, `SharedArrayBuffer` may be + * unavailable; this function returns `false` in that case. + * + * @param value - The value to check + * @returns `true` if value is a SharedArrayBuffer + * @example + * isSharedArrayBuffer(new SharedArrayBuffer(8)) // => true + * isSharedArrayBuffer(new ArrayBuffer(8)) // => false + * isSharedArrayBuffer(null) // => false + * @since next + */ +export function isSharedArrayBuffer(value: unknown): value is SharedArrayBuffer { + return typeof SharedArrayBuffer !== 'undefined' && value instanceof SharedArrayBuffer; +} diff --git a/helpers/number/extractNumber.bench.ts b/helpers/number/extractNumber.bench.ts new file mode 100644 index 00000000..04e5e9f9 --- /dev/null +++ b/helpers/number/extractNumber.bench.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { bench, describe } from 'vitest'; + +import { extractNumber } from './extractNumber'; + +describe('extractNumber', () => { + bench('already a number', () => { + extractNumber(42); + }); + bench('plain digits', () => { + extractNumber('111'); + }); + bench('number with unit suffix', () => { + extractNumber('16.5px'); + }); + bench('number embedded after a word', () => { + extractNumber('Wafer 10'); + }); + bench('glued "-" (auto: separator)', () => { + extractNumber('xxx-111'); + }); + bench('space-separated "-" (auto: sign)', () => { + extractNumber('xxx -111'); + }); + bench('sign: strict', () => { + extractNumber('xxx-111', { sign: 'strict' }); + }); + bench('sign: ignore', () => { + extractNumber('xxx -111', { sign: 'ignore' }); + }); + bench('scientific notation', () => { + extractNumber('1.5e-10'); + }); + bench('glued exponent suffix (auto: mantissa only)', () => { + extractNumber('1e5kg'); + }); + bench('no number found', () => { + extractNumber('no number here'); + }); +}); diff --git a/helpers/number/extractNumber.spec.ts b/helpers/number/extractNumber.spec.ts new file mode 100644 index 00000000..fa0bd5f1 --- /dev/null +++ b/helpers/number/extractNumber.spec.ts @@ -0,0 +1,56 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { extractNumber } from './extractNumber'; + +const word = fc.stringMatching(/^[a-z]{1,10}$/); +const magnitude = fc.integer({ min: 1, max: 1_000_000 }); + +describe('extractNumber — property-based', () => { + it('round-trips a plain integer, as a number or as its string form', () => { + fc.assert( + fc.property(fc.integer(), (n) => { + expect(extractNumber(n)).toBe(n); + expect(extractNumber(String(n))).toBe(n); + }), + ); + }); + + it('sign "auto": a "-" glued to preceding text is a separator, not a sign', () => { + fc.assert( + fc.property(word, magnitude, (w, n) => { + expect(extractNumber(`${w}-${n}`)).toBe(n); + }), + ); + }); + + it('sign "auto": a "-" preceded by whitespace is a minus sign', () => { + fc.assert( + fc.property(word, magnitude, (w, n) => { + expect(extractNumber(`${w} -${n}`)).toBe(-n); + }), + ); + }); + + it('sign "ignore": always returns a non-negative magnitude', () => { + fc.assert( + fc.property(fc.boolean(), magnitude, (leadingSpace, n) => { + const text = leadingSpace ? ` -${n}` : `-${n}`; + expect(extractNumber(text, { sign: 'ignore' })).toBe(n); + }), + ); + }); + + it('sign "strict": always returns a negative number when "-" precedes the digits', () => { + fc.assert( + fc.property(word, magnitude, (w, n) => { + expect(extractNumber(`${w}-${n}`, { sign: 'strict' })).toBe(-n); + }), + ); + }); +}); diff --git a/helpers/number/extractNumber.test.ts b/helpers/number/extractNumber.test.ts new file mode 100644 index 00000000..fc505590 --- /dev/null +++ b/helpers/number/extractNumber.test.ts @@ -0,0 +1,77 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { extractNumber } from './extractNumber'; + +describe('extractNumber', () => { + it('should extract a number embedded in text', () => { + expect(extractNumber('16.5px')).toBe(16.5); + expect(extractNumber('Wafer 10')).toBe(10); + expect(extractNumber('Wafer 10.')).toBe(10); + expect(extractNumber('no number here')).toBeUndefined(); + }); + + it('should pass through numbers, and reject NaN', () => { + expect(extractNumber(42)).toBe(42); + expect(extractNumber(-3.14)).toBe(-3.14); + expect(extractNumber(Number.NaN)).toBeUndefined(); + }); + + it('should return undefined for non-string, non-number values', () => { + expect(extractNumber(null)).toBeUndefined(); + expect(extractNumber(undefined)).toBeUndefined(); + expect(extractNumber(true)).toBeUndefined(); + expect(extractNumber({})).toBeUndefined(); + expect(extractNumber([])).toBeUndefined(); + }); + + it('should return the first number found in text with multiple numbers', () => { + expect(extractNumber('Wafer 10 of 20')).toBe(10); + }); + + describe('sign disambiguation (default: auto)', () => { + it('treats a glued "-" as a separator', () => { + expect(extractNumber('xxx-111')).toBe(111); + }); + + it('treats a space-separated "-" as a minus sign', () => { + expect(extractNumber('xxx -111')).toBe(-111); + }); + + it('treats a leading "-" at the start of the string as a minus sign', () => { + expect(extractNumber('-111')).toBe(-111); + }); + + it('sign: "strict" always treats "-" as a minus sign', () => { + expect(extractNumber('xxx-111', { sign: 'strict' })).toBe(-111); + }); + + it('sign: "ignore" never treats "-" as a minus sign', () => { + expect(extractNumber('xxx -111', { sign: 'ignore' })).toBe(111); + expect(extractNumber('-111', { sign: 'ignore' })).toBe(111); + }); + }); + + describe('exponent disambiguation (default: auto)', () => { + it('treats a free-standing "e" suffix as scientific notation', () => { + expect(extractNumber('1e5 mol')).toBe(100000); + expect(extractNumber('1.5e-10')).toBe(1.5e-10); + }); + + it('treats a glued "e" suffix as plain text, not an exponent', () => { + expect(extractNumber('1e5kg')).toBe(1); + }); + + it('exponent: "strict" always treats the suffix as scientific notation', () => { + expect(extractNumber('1e5kg', { exponent: 'strict' })).toBe(100000); + }); + + it('exponent: "ignore" never treats the suffix as scientific notation', () => { + expect(extractNumber('1e5 mol', { exponent: 'ignore' })).toBe(1); + }); + }); +}); diff --git a/helpers/number/extractNumber.ts b/helpers/number/extractNumber.ts new file mode 100644 index 00000000..3861ba8d --- /dev/null +++ b/helpers/number/extractNumber.ts @@ -0,0 +1,105 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Options for {@link extractNumber}. + */ +export interface ExtractNumberOptions { + /** + * How to interpret a `-` immediately before the matched digits. + * - `'auto'`: treated as a minus sign unless it is glued to a preceding letter/digit + * (e.g. `'-111'` and `'xxx -111'` → negative, but `'xxx-111'` → separator, positive). + * - `'strict'`: always treated as a minus sign. + * - `'ignore'`: never treated as a minus sign (always a separator). + * @default 'auto' + */ + sign?: 'auto' | 'strict' | 'ignore'; + + /** + * How to interpret a trailing scientific-notation suffix (`e`/`E` + digits, e.g. `1.5e-10`). + * - `'auto'`: treated as an exponent unless it is glued to a following letter/digit + * (e.g. `'1e5'` and `'1e5 mol'` → exponential, but `'1e5kg'` → mantissa only). + * - `'strict'`: always treated as an exponent. + * - `'ignore'`: never treated as an exponent (mantissa only). + * @default 'auto' + */ + exponent?: 'auto' | 'strict' | 'ignore'; +} + +const NUMBER_TOKEN = /(-)?(\d+(?:\.\d+)?)([eE][+-]?\d+)?/g; + +function isWordChar(char: string | undefined): boolean { + return char !== undefined && /[\p{L}\p{N}_]/u.test(char); +} + +/** + * Extracts the first number embedded anywhere in a string, or passes through a `number`. + * + * Unlike a plain `parseFloat`/`parseInt`, the number does not need to be at the start of + * the string: digits are searched for anywhere, so leading/trailing text (units, labels, ...) + * is ignored. A `-` before the digits and a scientific-notation suffix (`e`/`E`) are + * disambiguated with {@link ExtractNumberOptions.sign} and {@link ExtractNumberOptions.exponent}. + * + * Returns `undefined` if no number can be found. + * + * @param value - The value to extract a number from + * @param options - Options controlling sign and exponent disambiguation + * @returns The extracted number, or `undefined` if none was found + * @example + * extractNumber('16.5px') // => 16.5 + * extractNumber('Wafer 10') // => 10 + * extractNumber('xxx-111') // => 111 ('-' glued to text → separator) + * extractNumber('xxx -111') // => -111 ('-' preceded by a space → sign) + * extractNumber('-111') // => -111 ('-' at the start of the string → sign) + * extractNumber('1e5 mol') // => 100000 + * extractNumber('1e5kg') // => 1 ('e5' glued to text → mantissa only) + * extractNumber('no number') // => undefined + * extractNumber(42) // => 42 + * @since next + */ +export function extractNumber(value: unknown, options: ExtractNumberOptions = {}): number | undefined { + const { sign = 'auto', exponent = 'auto' } = options; + + if (typeof value === 'number') { + return Number.isNaN(value) ? undefined : value; + } + + if (typeof value !== 'string') { + return undefined; + } + + NUMBER_TOKEN.lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = NUMBER_TOKEN.exec(value)) !== null) { + const [full, signChar, mantissa, exponentPart] = match; + const start = match.index; + + if (signChar) { + const before = value[start - 1]; + const keepSign = sign === 'strict' ? true : sign === 'ignore' ? false : !isWordChar(before); + + if (!keepSign) { + // Re-scan from right after the '-' so the digits are matched as a fresh, unsigned number. + NUMBER_TOKEN.lastIndex = start + 1; + continue; + } + } + + const keepExponent = exponentPart + ? exponent === 'strict' + ? true + : exponent === 'ignore' + ? false + : !isWordChar(value[start + full.length]) + : false; + + const numericText = (signChar ?? '') + mantissa + (keepExponent ? exponentPart : ''); + return Number(numericText); + } + + return undefined; +} diff --git a/helpers/number/isEven.example.ts b/helpers/number/isEven.example.ts new file mode 100644 index 00000000..f612d909 --- /dev/null +++ b/helpers/number/isEven.example.ts @@ -0,0 +1,43 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isEven } from './isEven'; + +const examples: HelperExamples = { + helper: 'isEven', + category: 'number', + examples: [ + { + title: 'Check if a number is even', + description: 'Returns true for integers divisible by 2, false otherwise.', + code: `isEven(4) // => true +isEven(0) // => true +isEven(3) // => false +isEven(1.5) // => false (not an integer)`, + assert: () => { + if (!isEven(4)) throw new Error('4 should be even'); + if (!isEven(0)) throw new Error('0 should be even'); + if (isEven(3)) throw new Error('3 should not be even'); + if (isEven(1.5)) throw new Error('1.5 should not be even'); + }, + }, + { + title: 'Filter even numbers from an array', + description: 'Use as a predicate in .filter() to extract even integers.', + code: `const nums = [1, 2, 3, 4, 5, 6]; +nums.filter(isEven) +// => [2, 4, 6]`, + assert: () => { + const nums = [1, 2, 3, 4, 5, 6]; + const result = nums.filter(isEven); + if (result.length !== 3 || result[0] !== 2) throw new Error('Expected [2, 4, 6]'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/number/isEven.spec.ts b/helpers/number/isEven.spec.ts new file mode 100644 index 00000000..40d7a986 --- /dev/null +++ b/helpers/number/isEven.spec.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isEven } from './isEven'; + +describe('isEven — property-based', () => { + it('n*2 is always even for any integer n', () => { + fc.assert( + fc.property(fc.integer({ min: -1_000_000, max: 1_000_000 }), (n) => { + expect(isEven(n * 2)).toBe(true); + }), + ); + }); + + it('n*2+1 is never even for any integer n', () => { + fc.assert( + fc.property(fc.integer({ min: -1_000_000, max: 1_000_000 }), (n) => { + expect(isEven(n * 2 + 1)).toBe(false); + }), + ); + }); + + it('primitives that are not number always return false', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.boolean()), (value) => { + expect(isEven(value)).toBe(false); + }), + ); + }); +}); + +describe('isEven — contract', () => { + it('0 is even', () => expect(isEven(0)).toBe(true)); + it('-0 is even', () => expect(isEven(-0)).toBe(true)); + it('NaN → false', () => expect(isEven(NaN)).toBe(false)); + it('Infinity → false', () => expect(isEven(Infinity)).toBe(false)); + it('-Infinity → false', () => expect(isEven(-Infinity)).toBe(false)); + it('1.5 → false (non-integer)', () => expect(isEven(1.5)).toBe(false)); + it('2.0 → true (integer stored as float)', () => expect(isEven(2.0)).toBe(true)); +}); diff --git a/helpers/number/isEven.test.ts b/helpers/number/isEven.test.ts new file mode 100644 index 00000000..1e085c8d --- /dev/null +++ b/helpers/number/isEven.test.ts @@ -0,0 +1,46 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isEven } from './isEven'; + +describe('isEven', () => { + it('should return true for even integers', () => { + expect(isEven(0)).toBe(true); + expect(isEven(2)).toBe(true); + expect(isEven(4)).toBe(true); + expect(isEven(-2)).toBe(true); + expect(isEven(-100)).toBe(true); + }); + + it('should return false for odd integers', () => { + expect(isEven(1)).toBe(false); + expect(isEven(3)).toBe(false); + expect(isEven(-1)).toBe(false); + expect(isEven(-7)).toBe(false); + }); + + it('should return false for non-integer numbers', () => { + expect(isEven(1.5)).toBe(false); + expect(isEven(2.4)).toBe(false); + expect(isEven(-0.5)).toBe(false); + }); + + it('should return false for NaN and Infinity', () => { + expect(isEven(NaN)).toBe(false); + expect(isEven(Infinity)).toBe(false); + expect(isEven(-Infinity)).toBe(false); + }); + + it('should return false for non-number types', () => { + expect(isEven('2')).toBe(false); + expect(isEven(null)).toBe(false); + expect(isEven(undefined)).toBe(false); + expect(isEven(true)).toBe(false); + expect(isEven({})).toBe(false); + expect(isEven([])).toBe(false); + }); +}); diff --git a/helpers/number/isEven.ts b/helpers/number/isEven.ts new file mode 100644 index 00000000..1e6fdd1f --- /dev/null +++ b/helpers/number/isEven.ts @@ -0,0 +1,25 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is an even integer. + * + * Returns `false` for non-numbers, non-integers, `NaN`, `Infinity`, and odd integers. + * + * @param value - The value to check + * @returns `true` if value is an integer divisible by 2 + * @example + * isEven(2) // => true + * isEven(0) // => true + * isEven(-4) // => true + * isEven(3) // => false + * isEven(1.5) // => false (not an integer) + * isEven('2') // => false + * @since next + */ +export function isEven(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value % 2 === 0; +} diff --git a/helpers/number/isNegative.spec.ts b/helpers/number/isNegative.spec.ts new file mode 100644 index 00000000..0d590940 --- /dev/null +++ b/helpers/number/isNegative.spec.ts @@ -0,0 +1,55 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { isNumber } from '../type/isNumber'; +import { isNegative } from './isNegative'; +import { isPositive } from './isPositive'; + +describe('isNegative — property-based', () => { + it('isNegative(v) → isNumber(v)', () => { + fc.assert( + fc.property(fc.double({ noNaN: true, min: -1e10, max: -Number.EPSILON }), (v) => { + expect(isNegative(v)).toBe(true); + expect(isNumber(v)).toBe(true); + }), + ); + }); + + it('isNegative(v) → !isPositive(v)', () => { + fc.assert( + fc.property(fc.double({ noNaN: true, min: -1e10, max: -Number.EPSILON }), (v) => { + expect(isNegative(v)).toBe(true); + expect(isPositive(v)).toBe(false); + }), + ); + }); +}); + +describe('isNegative — contract', () => { + it('-1 → true', () => expect(isNegative(-1)).toBe(true)); + it('-0.1 → true', () => expect(isNegative(-0.1)).toBe(true)); + it('-Infinity → true', () => expect(isNegative(-Infinity)).toBe(true)); + it('0 → false', () => expect(isNegative(0)).toBe(false)); + it('-0 → false (-0 < 0 is false)', () => expect(isNegative(-0)).toBe(false)); + it('1 → false', () => expect(isNegative(1)).toBe(false)); + it('NaN → false', () => expect(isNegative(NaN)).toBe(false)); + it('null → false', () => expect(isNegative(null)).toBe(false)); +}); + +describe('isNegative — narrowing in if/else', () => { + it('narrows the value to number in the then-branch', () => { + const v: unknown = -1; + if (isNegative(v)) { + expectTypeOf(v).toEqualTypeOf(); + expect(v).toBeLessThan(0); + } else { + throw new Error('expected then-branch'); + } + expect(isNegative(1)).toBe(false); + }); +}); diff --git a/helpers/number/isNegative.test.ts b/helpers/number/isNegative.test.ts new file mode 100644 index 00000000..40123b88 --- /dev/null +++ b/helpers/number/isNegative.test.ts @@ -0,0 +1,36 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isNegative } from './isNegative'; + +describe('isNegative', () => { + it('should return true for negative numbers', () => { + expect(isNegative(-1)).toBe(true); + expect(isNegative(-0.5)).toBe(true); + expect(isNegative(-Infinity)).toBe(true); + }); + + it('should return false for zero', () => { + expect(isNegative(0)).toBe(false); + }); + + it('should return false for positive numbers', () => { + expect(isNegative(1)).toBe(false); + expect(isNegative(0.1)).toBe(false); + }); + + it('should return false for NaN', () => { + expect(isNegative(NaN)).toBe(false); + }); + + it('should return false for non-numbers', () => { + expect(isNegative('-1')).toBe(false); + expect(isNegative(false)).toBe(false); + expect(isNegative(null)).toBe(false); + expect(isNegative(undefined)).toBe(false); + }); +}); diff --git a/helpers/type/isNegativeNumber.ts b/helpers/number/isNegative.ts similarity index 59% rename from helpers/type/isNegativeNumber.ts rename to helpers/number/isNegative.ts index bd6e1fbc..3fa059f8 100644 --- a/helpers/type/isNegativeNumber.ts +++ b/helpers/number/isNegative.ts @@ -12,13 +12,14 @@ * @param value - The value to check * @returns True if value is a negative number * @example - * isNegativeNumber(-1) // => true - * isNegativeNumber(-0.5) // => true - * isNegativeNumber(0) // => false - * isNegativeNumber(1) // => false - * isNegativeNumber(NaN) // => false + * isNegative(-1) // => true + * isNegative(-0.5) // => true + * isNegative(-Infinity) // => true + * isNegative(0) // => false + * isNegative(1) // => false + * isNegative(NaN) // => false * @since 2.0.0 */ -export function isNegativeNumber(value: unknown): value is number { +export function isNegative(value: unknown): value is number { return typeof value === 'number' && value < 0; } diff --git a/helpers/number/isOdd.example.ts b/helpers/number/isOdd.example.ts new file mode 100644 index 00000000..c4edb260 --- /dev/null +++ b/helpers/number/isOdd.example.ts @@ -0,0 +1,44 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isOdd } from './isOdd'; + +const examples: HelperExamples = { + helper: 'isOdd', + category: 'number', + examples: [ + { + title: 'Check if a number is odd', + description: 'Returns true for integers not divisible by 2, false otherwise.', + code: `isOdd(3) // => true +isOdd(1) // => true +isOdd(2) // => false +isOdd(0) // => false +isOdd(1.5) // => false (not an integer)`, + assert: () => { + if (!isOdd(3)) throw new Error('3 should be odd'); + if (!isOdd(1)) throw new Error('1 should be odd'); + if (isOdd(2)) throw new Error('2 should not be odd'); + if (isOdd(1.5)) throw new Error('1.5 should not be odd'); + }, + }, + { + title: 'Filter odd numbers from an array', + description: 'Use as a predicate in .filter() to extract odd integers.', + code: `const nums = [1, 2, 3, 4, 5, 6]; +nums.filter(isOdd) +// => [1, 3, 5]`, + assert: () => { + const nums = [1, 2, 3, 4, 5, 6]; + const result = nums.filter(isOdd); + if (result.length !== 3 || result[0] !== 1) throw new Error('Expected [1, 3, 5]'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/number/isOdd.spec.ts b/helpers/number/isOdd.spec.ts new file mode 100644 index 00000000..f9dce9bc --- /dev/null +++ b/helpers/number/isOdd.spec.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isOdd } from './isOdd'; + +describe('isOdd — property-based', () => { + it('n*2+1 is always odd for any integer n', () => { + fc.assert( + fc.property(fc.integer({ min: -1_000_000, max: 1_000_000 }), (n) => { + expect(isOdd(n * 2 + 1)).toBe(true); + }), + ); + }); + + it('n*2 is never odd for any integer n', () => { + fc.assert( + fc.property(fc.integer({ min: -1_000_000, max: 1_000_000 }), (n) => { + expect(isOdd(n * 2)).toBe(false); + }), + ); + }); + + it('primitives that are not number always return false', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.boolean()), (value) => { + expect(isOdd(value)).toBe(false); + }), + ); + }); +}); + +describe('isOdd — contract', () => { + it('0 is not odd', () => expect(isOdd(0)).toBe(false)); + it('-0 is not odd', () => expect(isOdd(-0)).toBe(false)); + it('NaN → false', () => expect(isOdd(NaN)).toBe(false)); + it('Infinity → false', () => expect(isOdd(Infinity)).toBe(false)); + it('-Infinity → false', () => expect(isOdd(-Infinity)).toBe(false)); + it('1.5 → false (non-integer)', () => expect(isOdd(1.5)).toBe(false)); + it('3.0 → true (integer stored as float)', () => expect(isOdd(3.0)).toBe(true)); +}); diff --git a/helpers/number/isOdd.test.ts b/helpers/number/isOdd.test.ts new file mode 100644 index 00000000..e091fdad --- /dev/null +++ b/helpers/number/isOdd.test.ts @@ -0,0 +1,46 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isOdd } from './isOdd'; + +describe('isOdd', () => { + it('should return true for odd integers', () => { + expect(isOdd(1)).toBe(true); + expect(isOdd(3)).toBe(true); + expect(isOdd(-1)).toBe(true); + expect(isOdd(-7)).toBe(true); + expect(isOdd(99)).toBe(true); + }); + + it('should return false for even integers', () => { + expect(isOdd(0)).toBe(false); + expect(isOdd(2)).toBe(false); + expect(isOdd(-2)).toBe(false); + expect(isOdd(100)).toBe(false); + }); + + it('should return false for non-integer numbers', () => { + expect(isOdd(1.5)).toBe(false); + expect(isOdd(3.1)).toBe(false); + expect(isOdd(-0.5)).toBe(false); + }); + + it('should return false for NaN and Infinity', () => { + expect(isOdd(NaN)).toBe(false); + expect(isOdd(Infinity)).toBe(false); + expect(isOdd(-Infinity)).toBe(false); + }); + + it('should return false for non-number types', () => { + expect(isOdd('3')).toBe(false); + expect(isOdd(null)).toBe(false); + expect(isOdd(undefined)).toBe(false); + expect(isOdd(true)).toBe(false); + expect(isOdd({})).toBe(false); + expect(isOdd([])).toBe(false); + }); +}); diff --git a/helpers/number/isOdd.ts b/helpers/number/isOdd.ts new file mode 100644 index 00000000..b9034623 --- /dev/null +++ b/helpers/number/isOdd.ts @@ -0,0 +1,25 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is an odd integer. + * + * Returns `false` for non-numbers, non-integers, `NaN`, `Infinity`, and even integers. + * + * @param value - The value to check + * @returns `true` if value is an integer not divisible by 2 + * @example + * isOdd(3) // => true + * isOdd(1) // => true + * isOdd(-7) // => true + * isOdd(2) // => false + * isOdd(1.5) // => false (not an integer) + * isOdd('3') // => false + * @since next + */ +export function isOdd(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value % 2 !== 0; +} diff --git a/helpers/number/isPositive.spec.ts b/helpers/number/isPositive.spec.ts new file mode 100644 index 00000000..777a5642 --- /dev/null +++ b/helpers/number/isPositive.spec.ts @@ -0,0 +1,55 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { isNumber } from '../type/isNumber'; +import { isNegative } from './isNegative'; +import { isPositive } from './isPositive'; + +describe('isPositive — property-based', () => { + it('isPositive(v) → isNumber(v)', () => { + fc.assert( + fc.property(fc.double({ noNaN: true, min: Number.EPSILON, max: 1e10 }), (v) => { + expect(isPositive(v)).toBe(true); + expect(isNumber(v)).toBe(true); + }), + ); + }); + + it('isPositive(v) → !isNegative(v)', () => { + fc.assert( + fc.property(fc.double({ noNaN: true, min: Number.EPSILON, max: 1e10 }), (v) => { + expect(isPositive(v)).toBe(true); + expect(isNegative(v)).toBe(false); + }), + ); + }); +}); + +describe('isPositive — contract', () => { + it('1 → true', () => expect(isPositive(1)).toBe(true)); + it('0.1 → true', () => expect(isPositive(0.1)).toBe(true)); + it('Infinity → true', () => expect(isPositive(Infinity)).toBe(true)); + it('0 → false', () => expect(isPositive(0)).toBe(false)); + it('-1 → false', () => expect(isPositive(-1)).toBe(false)); + it('NaN → false', () => expect(isPositive(NaN)).toBe(false)); + it('-Infinity → false', () => expect(isPositive(-Infinity)).toBe(false)); + it('null → false', () => expect(isPositive(null)).toBe(false)); +}); + +describe('isPositive — narrowing in if/else', () => { + it('narrows the value to number in the then-branch', () => { + const v: unknown = 1; + if (isPositive(v)) { + expectTypeOf(v).toEqualTypeOf(); + expect(v).toBeGreaterThan(0); + } else { + throw new Error('expected then-branch'); + } + expect(isPositive(-1)).toBe(false); + }); +}); diff --git a/helpers/number/isPositive.test.ts b/helpers/number/isPositive.test.ts new file mode 100644 index 00000000..704f863f --- /dev/null +++ b/helpers/number/isPositive.test.ts @@ -0,0 +1,37 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isPositive } from './isPositive'; + +describe('isPositive', () => { + it('should return true for positive numbers', () => { + expect(isPositive(1)).toBe(true); + expect(isPositive(42)).toBe(true); + expect(isPositive(0.1)).toBe(true); + expect(isPositive(Infinity)).toBe(true); + }); + + it('should return false for zero', () => { + expect(isPositive(0)).toBe(false); + }); + + it('should return false for negative numbers', () => { + expect(isPositive(-1)).toBe(false); + expect(isPositive(-0.1)).toBe(false); + }); + + it('should return false for NaN', () => { + expect(isPositive(NaN)).toBe(false); + }); + + it('should return false for non-numbers', () => { + expect(isPositive('42')).toBe(false); + expect(isPositive(true)).toBe(false); + expect(isPositive(null)).toBe(false); + expect(isPositive(undefined)).toBe(false); + }); +}); diff --git a/helpers/type/isPositiveNumber.ts b/helpers/number/isPositive.ts similarity index 60% rename from helpers/type/isPositiveNumber.ts rename to helpers/number/isPositive.ts index dc78bb07..fe03dc8b 100644 --- a/helpers/type/isPositiveNumber.ts +++ b/helpers/number/isPositive.ts @@ -12,13 +12,14 @@ * @param value - The value to check * @returns True if value is a positive number * @example - * isPositiveNumber(42) // => true - * isPositiveNumber(0.1) // => true - * isPositiveNumber(0) // => false - * isPositiveNumber(-1) // => false - * isPositiveNumber(NaN) // => false + * isPositive(42) // => true + * isPositive(0.1) // => true + * isPositive(Infinity) // => true + * isPositive(0) // => false + * isPositive(-1) // => false + * isPositive(NaN) // => false * @since 2.0.0 */ -export function isPositiveNumber(value: unknown): value is number { +export function isPositive(value: unknown): value is number { return typeof value === 'number' && value > 0; } diff --git a/helpers/object/isEmpty.example.ts b/helpers/object/isEmpty.example.ts new file mode 100644 index 00000000..5508c61e --- /dev/null +++ b/helpers/object/isEmpty.example.ts @@ -0,0 +1,41 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isEmpty } from './isEmpty'; + +const examples: HelperExamples = { + helper: 'isEmpty', + category: 'object', + examples: [ + { + title: 'Check if an object has no own string-keyed properties', + description: 'Returns true for `{}`. Symbol-keyed properties are not counted.', + code: `isEmpty({}) // => true +isEmpty({ a: 1 }) // => false +isEmpty({ a: undefined }) // => false (key exists even if value is undefined)`, + assert: () => { + if (!isEmpty({})) throw new Error('{} should be empty'); + if (isEmpty({ a: 1 })) throw new Error('{a:1} should not be empty'); + if (isEmpty({ a: undefined })) throw new Error('{a:undefined} should not be empty'); + }, + }, + { + title: 'Symbol keys are not counted', + description: 'An object with only symbol-keyed properties is considered empty.', + code: `const sym = Symbol('x'); +const obj = { [sym]: 1 }; +isEmpty(obj) // => true (only string keys are counted)`, + assert: () => { + const sym = Symbol('x'); + const obj: Record = { [sym]: 1 }; + if (!isEmpty(obj)) throw new Error('Object with only symbol key should be empty'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/object/isEmpty.spec.ts b/helpers/object/isEmpty.spec.ts new file mode 100644 index 00000000..dcd1cd0d --- /dev/null +++ b/helpers/object/isEmpty.spec.ts @@ -0,0 +1,41 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isEmpty } from './isEmpty'; + +describe('isEmpty — property-based', () => { + it('is always false for objects with at least one string key', () => { + fc.assert( + fc.property( + fc.record({ key: fc.string() }, { requiredKeys: ['key'] }), + (obj) => { + expect(isEmpty(obj)).toBe(false); + }, + ), + ); + }); +}); + +describe('isEmpty — contracts', () => { + it('isEmpty and isNonEmpty are logical inverses', async () => { + const { isNonEmpty } = await import('./isNonEmpty'); + fc.assert( + fc.property(fc.dictionary(fc.string(), fc.anything()), (obj) => { + expect(isEmpty(obj)).toBe(!isNonEmpty(obj)); + }), + ); + }); + + it('is equivalent to Object.keys(value).length === 0', () => { + fc.assert( + fc.property(fc.dictionary(fc.string(), fc.anything()), (obj) => { + expect(isEmpty(obj)).toBe(Object.keys(obj).length === 0); + }), + ); + }); +}); diff --git a/helpers/object/isEmpty.test.ts b/helpers/object/isEmpty.test.ts new file mode 100644 index 00000000..eb33b08c --- /dev/null +++ b/helpers/object/isEmpty.test.ts @@ -0,0 +1,37 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isEmpty } from './isEmpty'; + +describe('isEmpty', () => { + it('should return true for an empty object', () => { + expect(isEmpty({})).toBe(true); + }); + + it('should return false for an object with string keys', () => { + expect(isEmpty({ a: 1 })).toBe(false); + expect(isEmpty({ a: undefined })).toBe(false); + }); + + it('should not count symbol-keyed properties', () => { + const sym = Symbol('x'); + const obj: Record = {}; + obj[sym] = 1; + expect(isEmpty(obj)).toBe(true); + }); + + it('should return false for an object with multiple keys', () => { + expect(isEmpty({ a: 1, b: 2, c: 3 })).toBe(false); + }); + + it('should handle null-prototype objects', () => { + const obj = Object.create(null) as Record; + expect(isEmpty(obj)).toBe(true); + obj['key'] = 'value'; + expect(isEmpty(obj)).toBe(false); + }); +}); diff --git a/helpers/object/isEmpty.ts b/helpers/object/isEmpty.ts new file mode 100644 index 00000000..07436921 --- /dev/null +++ b/helpers/object/isEmpty.ts @@ -0,0 +1,23 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a plain object has no own enumerable string-keyed properties. + * + * Symbol-keyed properties are not counted. Use `Object.getOwnPropertySymbols` + * separately if symbol keys matter for your use case. + * + * @param value - The object to check + * @returns `true` if the object has no own enumerable string-keyed properties + * @example + * isEmpty({}) // => true + * isEmpty({ a: 1 }) // => false + * isEmpty({ a: undefined }) // => false (key exists) + * @since next + */ +export function isEmpty(value: Record): boolean { + return Object.keys(value).length === 0; +} diff --git a/helpers/object/isNonEmpty.example.ts b/helpers/object/isNonEmpty.example.ts new file mode 100644 index 00000000..66249fe4 --- /dev/null +++ b/helpers/object/isNonEmpty.example.ts @@ -0,0 +1,46 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isNonEmpty } from './isNonEmpty'; + +const examples: HelperExamples = { + helper: 'isNonEmpty', + category: 'object', + examples: [ + { + title: 'Check if an object has own string-keyed properties', + description: 'Returns true when at least one own enumerable string key is present.', + code: `isNonEmpty({ a: 1 }) // => true +isNonEmpty({ a: undefined }) // => true (key exists) +isNonEmpty({}) // => false`, + assert: () => { + if (!isNonEmpty({ a: 1 })) throw new Error('{a:1} should be non-empty'); + if (!isNonEmpty({ a: undefined })) throw new Error('{a:undefined} should be non-empty'); + if (isNonEmpty({})) throw new Error('{} should not be non-empty'); + }, + }, + { + title: 'Guard before iterating object keys', + description: 'Use isNonEmpty before looping to avoid processing empty objects.', + code: `function processConfig(config: Record): void { + if (!isNonEmpty(config)) { + console.warn('Config is empty'); + return; + } + for (const key of Object.keys(config)) { + // process each key + } +}`, + assert: () => { + if (isNonEmpty({})) throw new Error('Empty object should fail guard'); + if (!isNonEmpty({ x: 1 })) throw new Error('Non-empty object should pass guard'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/object/isNonEmpty.spec.ts b/helpers/object/isNonEmpty.spec.ts new file mode 100644 index 00000000..a30e968e --- /dev/null +++ b/helpers/object/isNonEmpty.spec.ts @@ -0,0 +1,36 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isNonEmpty } from './isNonEmpty'; + +describe('isNonEmpty — property-based', () => { + it('is always true for objects with at least one string key', () => { + fc.assert( + fc.property( + fc.record({ key: fc.string() }, { requiredKeys: ['key'] }), + (obj) => { + expect(isNonEmpty(obj)).toBe(true); + }, + ), + ); + }); + + it('is always false for objects with no string keys', () => { + expect(isNonEmpty({})).toBe(false); + }); +}); + +describe('isNonEmpty — contracts', () => { + it('is equivalent to Object.keys(value).length > 0', () => { + fc.assert( + fc.property(fc.dictionary(fc.string(), fc.anything()), (obj) => { + expect(isNonEmpty(obj)).toBe(Object.keys(obj).length > 0); + }), + ); + }); +}); diff --git a/helpers/object/isNonEmpty.test.ts b/helpers/object/isNonEmpty.test.ts new file mode 100644 index 00000000..6261f593 --- /dev/null +++ b/helpers/object/isNonEmpty.test.ts @@ -0,0 +1,33 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isNonEmpty } from './isNonEmpty'; + +describe('isNonEmpty', () => { + it('should return true for an object with string keys', () => { + expect(isNonEmpty({ a: 1 })).toBe(true); + expect(isNonEmpty({ a: undefined })).toBe(true); + }); + + it('should return false for an empty object', () => { + expect(isNonEmpty({})).toBe(false); + }); + + it('should not count symbol-keyed properties', () => { + const sym = Symbol('x'); + const obj: Record = {}; + obj[sym] = 1; + expect(isNonEmpty(obj)).toBe(false); + }); + + it('should handle null-prototype objects', () => { + const obj = Object.create(null) as Record; + expect(isNonEmpty(obj)).toBe(false); + obj['key'] = 'value'; + expect(isNonEmpty(obj)).toBe(true); + }); +}); diff --git a/helpers/object/isNonEmpty.ts b/helpers/object/isNonEmpty.ts new file mode 100644 index 00000000..e0b3bac8 --- /dev/null +++ b/helpers/object/isNonEmpty.ts @@ -0,0 +1,22 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a plain object has at least one own enumerable string-keyed property. + * + * Symbol-keyed properties are not counted. Use `Object.getOwnPropertySymbols` + * separately if symbol keys matter for your use case. + * + * @param value - The object to check + * @returns `true` if the object has at least one own enumerable string-keyed property + * @example + * isNonEmpty({ a: 1 }) // => true + * isNonEmpty({}) // => false + * @since next + */ +export function isNonEmpty(value: Record): boolean { + return Object.keys(value).length > 0; +} diff --git a/helpers/observable/isObservable.example.ts b/helpers/observable/isObservable.example.ts new file mode 100644 index 00000000..31742910 --- /dev/null +++ b/helpers/observable/isObservable.example.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { Observable, Subject } from 'rxjs'; +import type { HelperExamples } from '../../scripts/examples/types'; +import { isObservable } from './isObservable'; + +const examples: HelperExamples = { + helper: 'isObservable', + category: 'observable', + examples: [ + { + title: 'Detect an RxJS Observable or Subject', + description: 'Returns true for Observable, Subject, BehaviorSubject, and any duck-typed observable.', + code: `import { Observable, Subject } from 'rxjs'; +isObservable(new Observable()) // => true +isObservable(new Subject()) // => true +isObservable(Promise.resolve()) // => false +isObservable({}) // => false`, + assert: () => { + if (!isObservable(new Observable())) throw new Error('Observable should return true'); + if (!isObservable(new Subject())) throw new Error('Subject should return true'); + if (isObservable(Promise.resolve())) throw new Error('Promise should return false'); + if (isObservable({})) throw new Error('{} should return false'); + }, + }, + { + title: 'Accept either an Observable or a plain value', + description: 'Use as a guard to normalize inputs that may be Observables or raw values.', + code: `import { Observable, of } from 'rxjs'; +function toObservable(value: T | Observable): Observable { + return isObservable(value) ? value : of(value); +}`, + assert: () => { + if (isObservable(null)) throw new Error('null should return false'); + if (isObservable({ subscribe: () => {} })) throw new Error('missing pipe should return false'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/observable/isObservable.spec.ts b/helpers/observable/isObservable.spec.ts new file mode 100644 index 00000000..d347e6a6 --- /dev/null +++ b/helpers/observable/isObservable.spec.ts @@ -0,0 +1,33 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { Observable, Subject } from 'rxjs'; +import { describe, expect, it } from 'vitest'; +import { isObservable } from './isObservable'; + +describe('isObservable — property-based', () => { + it('primitives are never observables', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.integer(), fc.boolean()), (value) => { + expect(isObservable(value)).toBe(false); + }), + ); + }); +}); + +describe('isObservable — contract', () => { + it('null → false', () => expect(isObservable(null)).toBe(false)); + it('undefined → false', () => expect(isObservable(undefined)).toBe(false)); + it('{} → false', () => expect(isObservable({})).toBe(false)); + it('Promise → false', () => expect(isObservable(Promise.resolve())).toBe(false)); + it('{ subscribe: fn } only → false (pipe missing)', () => expect(isObservable({ subscribe: () => {} })).toBe(false)); + it('{ pipe: fn } only → false (subscribe missing)', () => expect(isObservable({ pipe: () => {} })).toBe(false)); + it('Observable → true', () => expect(isObservable(new Observable())).toBe(true)); + it('Subject → true', () => expect(isObservable(new Subject())).toBe(true)); + it('non-fn subscribe → false', () => expect(isObservable({ subscribe: 'x', pipe: () => {} })).toBe(false)); + it('non-fn pipe → false', () => expect(isObservable({ subscribe: () => {}, pipe: 'x' })).toBe(false)); +}); diff --git a/helpers/observable/isObservable.test.ts b/helpers/observable/isObservable.test.ts new file mode 100644 index 00000000..bb25ef85 --- /dev/null +++ b/helpers/observable/isObservable.test.ts @@ -0,0 +1,54 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { BehaviorSubject, Observable, ReplaySubject, Subject } from 'rxjs'; +import { describe, expect, it } from 'vitest'; +import { isObservable } from './isObservable'; + +describe('isObservable', () => { + it('should return true for Observable instances', () => { + expect(isObservable(new Observable())).toBe(true); + expect(isObservable(new Observable((obs) => { obs.next(1); }))).toBe(true); + }); + + it('should return true for Subject variants', () => { + expect(isObservable(new Subject())).toBe(true); + expect(isObservable(new BehaviorSubject(0))).toBe(true); + expect(isObservable(new ReplaySubject(1))).toBe(true); + }); + + it('should return true for objects with subscribe and pipe functions', () => { + expect(isObservable({ subscribe: () => {}, pipe: () => {} })).toBe(true); + }); + + it('should return false when subscribe is missing', () => { + expect(isObservable({ pipe: () => {} })).toBe(false); + }); + + it('should return false when pipe is missing', () => { + expect(isObservable({ subscribe: () => {} })).toBe(false); + }); + + it('should return false when methods are not functions', () => { + expect(isObservable({ subscribe: 'x', pipe: () => {} })).toBe(false); + expect(isObservable({ subscribe: () => {}, pipe: 'x' })).toBe(false); + }); + + it('should return false for Promises', () => { + expect(isObservable(Promise.resolve())).toBe(false); + }); + + it('should return false for null and undefined', () => { + expect(isObservable(null)).toBe(false); + expect(isObservable(undefined)).toBe(false); + }); + + it('should return false for primitives and plain objects', () => { + expect(isObservable(42)).toBe(false); + expect(isObservable('observable')).toBe(false); + expect(isObservable({})).toBe(false); + }); +}); diff --git a/helpers/observable/isObservable.ts b/helpers/observable/isObservable.ts new file mode 100644 index 00000000..68587ed5 --- /dev/null +++ b/helpers/observable/isObservable.ts @@ -0,0 +1,36 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { Observable } from 'rxjs'; + +/** + * Checks if a value is an RxJS Observable or any compatible observable. + * + * Uses duck-typing: returns `true` for any object with both `.subscribe()` and + * `.pipe()` methods, covering `Observable`, `Subject`, `BehaviorSubject`, + * `ReplaySubject`, and any RxJS-compatible observable implementation. + * + * @param value - The value to check + * @returns `true` if value is observable-like + * @example + * import { Observable, Subject } from 'rxjs'; + * isObservable(new Observable()) // => true + * isObservable(new Subject()) // => true + * isObservable(Promise.resolve()) // => false + * isObservable({}) // => false + * @since next + */ +export function isObservable(value: unknown): value is Observable { + return ( + // Intentionally `object`-only, unlike `isPromiseLike` (see type/isPromiseLike.ts): + // a callable function exposing `.subscribe()`/`.pipe()` would be an unusual, + // contrived observable shape. + value !== null && + typeof value === 'object' && + typeof (value as Record)['subscribe'] === 'function' && + typeof (value as Record)['pipe'] === 'function' + ); +} diff --git a/helpers/string/isBlank.example.ts b/helpers/string/isBlank.example.ts new file mode 100644 index 00000000..ea3c06b6 --- /dev/null +++ b/helpers/string/isBlank.example.ts @@ -0,0 +1,55 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isBlank } from './isBlank'; + +const examples: HelperExamples = { + helper: 'isBlank', + category: 'string', + examples: [ + { + title: 'Detect empty or whitespace-only strings', + description: + 'Returns true for "" and for any string made entirely of whitespace — including ' + + 'non-breaking space (U+00A0), en/em spaces, ideographic space, and BOM.', + code: `isBlank('') // => true +isBlank(' ') // => true +isBlank('\\t\\n') // => true +isBlank(' ') // => true (non-breaking space U+00A0) +isBlank('foo') // => false +isBlank(' x ') // => false`, + assert: () => { + if (!isBlank('')) throw new Error('Expected true for ""'); + if (!isBlank(' ')) throw new Error('Expected true for spaces'); + if (!isBlank(' ')) throw new Error('Expected true for NBSP'); + if (isBlank('foo')) throw new Error('Expected false for "foo"'); + }, + }, + { + title: 'Form validation — reject blank input', + description: 'Use isBlank to reject fields that contain only whitespace.', + code: `function validateName(name: string): string | null { + if (isBlank(name)) return 'Name is required'; + return null; +} +validateName('') // => 'Name is required' +validateName(' ') // => 'Name is required' +validateName('Ada') // => null`, + assert: () => { + function validateName(name: string): string | null { + if (isBlank(name)) return 'Name is required'; + return null; + } + if (validateName('') === null) throw new Error('Expected error for ""'); + if (validateName(' ') === null) throw new Error('Expected error for spaces'); + if (validateName('Ada') !== null) throw new Error('Expected null for "Ada"'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/string/isBlank.spec.ts b/helpers/string/isBlank.spec.ts new file mode 100644 index 00000000..588d2410 --- /dev/null +++ b/helpers/string/isBlank.spec.ts @@ -0,0 +1,62 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isBlank } from './isBlank'; + +describe('isBlank — property-based', () => { + it('is always false for strings containing at least one non-whitespace character', () => { + fc.assert( + fc.property( + fc.string({ minLength: 1 }).filter((s) => s.trim().length > 0), + (s) => { + expect(isBlank(s)).toBe(false); + }, + ), + ); + }); + + it('is always true for strings of only ASCII whitespace', () => { + const whitespaceChars = [' ', '\t', '\n', '\r', '\f', '\v']; + fc.assert( + fc.property( + fc.array(fc.constantFrom(...whitespaceChars), { minLength: 1 }).map((a) => a.join('')), + (s) => { + expect(isBlank(s)).toBe(true); + }, + ), + ); + }); +}); + +describe('isBlank — contracts', () => { + it('isBlank and isNotBlank are logical inverses', async () => { + const { isNotBlank } = await import('./isNotBlank'); + fc.assert( + fc.property(fc.string(), (s) => { + expect(isBlank(s)).toBe(!isNotBlank(s)); + }), + ); + }); + + it('is equivalent to value.trim() === ""', () => { + fc.assert( + fc.property(fc.string(), (s) => { + expect(isBlank(s)).toBe(s.trim() === ''); + }), + ); + }); + + it('isBlank implies isEmpty', async () => { + const { isEmpty } = await import('./isEmpty'); + fc.assert( + fc.property(fc.string(), (s) => { + if (isEmpty(s)) expect(isBlank(s)).toBe(true); // empty ⊆ blank + }), + ); + }); +}); diff --git a/helpers/string/isBlank.test.ts b/helpers/string/isBlank.test.ts new file mode 100644 index 00000000..07b5038b --- /dev/null +++ b/helpers/string/isBlank.test.ts @@ -0,0 +1,49 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isBlank } from './isBlank'; + +describe('isBlank', () => { + it('should return true for an empty string', () => { + expect(isBlank('')).toBe(true); + }); + + it('should return true for ASCII whitespace-only strings', () => { + expect(isBlank(' ')).toBe(true); + expect(isBlank(' ')).toBe(true); + expect(isBlank('\t')).toBe(true); + expect(isBlank('\n')).toBe(true); + expect(isBlank('\r')).toBe(true); + expect(isBlank('\f')).toBe(true); + expect(isBlank('\v')).toBe(true); + expect(isBlank(' \t\n\r ')).toBe(true); + }); + + it('should return true for Unicode whitespace-only strings', () => { + expect(isBlank(' ')).toBe(true); // non-breaking space + expect(isBlank(' ')).toBe(true); // en space + expect(isBlank(' ')).toBe(true); // em space + expect(isBlank(' ')).toBe(true); // thin space + expect(isBlank(' ')).toBe(true); // hair space + expect(isBlank(' ')).toBe(true); // narrow NBSP + expect(isBlank(' ')).toBe(true); // ideographic space + expect(isBlank('')).toBe(true); // BOM / ZWNBSP + }); + + it('should return false for strings with visible content', () => { + expect(isBlank('foo')).toBe(false); + expect(isBlank(' x ')).toBe(false); + expect(isBlank('0')).toBe(false); + }); + + it('should not treat zero-width characters as whitespace', () => { + expect(isBlank('​')).toBe(false); // zero-width space + expect(isBlank('‌')).toBe(false); // zero-width non-joiner + expect(isBlank('‍')).toBe(false); // zero-width joiner + expect(isBlank('⁠')).toBe(false); // word joiner + }); +}); diff --git a/helpers/string/isBlank.ts b/helpers/string/isBlank.ts new file mode 100644 index 00000000..955d5ca0 --- /dev/null +++ b/helpers/string/isBlank.ts @@ -0,0 +1,33 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a string is blank — empty or contains only whitespace characters. + * + * Uses `String.prototype.trim()` internally, which covers all ECMAScript + * whitespace: standard ASCII whitespace (`\t`, `\n`, `\r`, `\f`, `\v`), + * non-breaking space (U+00A0), BOM (U+FEFF), and all Unicode "Space_Separator" + * category characters (en space, em space, thin space, ideographic space, etc.). + * + * **Zero-width characters** (U+200B zero-width space, U+200C, U+200D, U+2060) + * are **not** treated as whitespace — they are Unicode "Format" (Cf) characters, + * not spaces. Strip them explicitly if needed: + * `isBlank(value.replace(/[​-‍⁠]/g, ''))` + * + * @param value - The string to check + * @returns `true` if the string is empty or contains only whitespace + * @example + * isBlank('') // => true + * isBlank(' ') // => true + * isBlank('\t\n') // => true + * isBlank(' ') // => true (non-breaking space U+00A0) + * isBlank('foo') // => false + * isBlank(' x ') // => false + * @since next + */ +export function isBlank(value: string): boolean { + return value.trim() === ''; +} diff --git a/helpers/string/isEmpty.example.ts b/helpers/string/isEmpty.example.ts new file mode 100644 index 00000000..6ef3a5eb --- /dev/null +++ b/helpers/string/isEmpty.example.ts @@ -0,0 +1,41 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isEmpty } from './isEmpty'; + +const examples: HelperExamples = { + helper: 'isEmpty', + category: 'string', + examples: [ + { + title: 'Check if a string is empty', + description: 'Returns true only for `""`. Whitespace-only strings are not considered empty.', + code: `isEmpty('') // => true +isEmpty(' ') // => false (whitespace is content) +isEmpty('foo') // => false`, + assert: () => { + if (!isEmpty('')) throw new Error('"" should be empty'); + if (isEmpty(' ')) throw new Error('" " should not be empty'); + if (isEmpty('foo')) throw new Error('"foo" should not be empty'); + }, + }, + { + title: 'Treat blank strings as empty by trimming first', + description: 'Compose with .trim() when whitespace-only should also be considered empty.', + code: `isEmpty(''.trim()) // => true +isEmpty(' '.trim()) // => true +isEmpty('hi'.trim()) // => false`, + assert: () => { + if (!isEmpty(''.trim())) throw new Error('Expected true'); + if (!isEmpty(' '.trim())) throw new Error('Expected true after trim'); + if (isEmpty('hi'.trim())) throw new Error('Expected false'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/string/isEmpty.spec.ts b/helpers/string/isEmpty.spec.ts new file mode 100644 index 00000000..1403aecc --- /dev/null +++ b/helpers/string/isEmpty.spec.ts @@ -0,0 +1,38 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isEmpty } from './isEmpty'; + +describe('isEmpty — property-based', () => { + it('is always false for non-empty strings', () => { + fc.assert( + fc.property(fc.string({ minLength: 1 }), (s) => { + expect(isEmpty(s)).toBe(false); + }), + ); + }); +}); + +describe('isEmpty — contracts', () => { + it('isEmpty and isNonEmpty are logical inverses', async () => { + const { isNonEmpty } = await import('./isNonEmpty'); + fc.assert( + fc.property(fc.string(), (s) => { + expect(isEmpty(s)).toBe(!isNonEmpty(s)); + }), + ); + }); + + it('is equivalent to s === ""', () => { + fc.assert( + fc.property(fc.string(), (s) => { + expect(isEmpty(s)).toBe(s === ''); + }), + ); + }); +}); diff --git a/helpers/string/isEmpty.test.ts b/helpers/string/isEmpty.test.ts new file mode 100644 index 00000000..866c64af --- /dev/null +++ b/helpers/string/isEmpty.test.ts @@ -0,0 +1,34 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isEmpty } from './isEmpty'; + +describe('isEmpty', () => { + it('should return true for an empty string', () => { + expect(isEmpty('')).toBe(true); + }); + + it('should return false for a whitespace-only string', () => { + expect(isEmpty(' ')).toBe(false); + expect(isEmpty(' ')).toBe(false); + expect(isEmpty('\t')).toBe(false); + expect(isEmpty('\n')).toBe(false); + }); + + it('should return false for a non-empty string', () => { + expect(isEmpty('a')).toBe(false); + expect(isEmpty('hello')).toBe(false); + }); + + it('should narrow type to empty string literal in true branch', () => { + const s: string = ''; + if (isEmpty(s)) { + const _: '' = s; + expect(_).toBe(''); + } + }); +}); diff --git a/helpers/string/isEmpty.ts b/helpers/string/isEmpty.ts new file mode 100644 index 00000000..d45fa88d --- /dev/null +++ b/helpers/string/isEmpty.ts @@ -0,0 +1,23 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a string is empty (`""`). + * + * This is a strict emptiness check — whitespace-only strings are **not** considered + * empty. Use `isEmpty(value.trim())` if you need to treat blank strings as empty. + * + * @param value - The string to check + * @returns `true` if the string is `""` + * @example + * isEmpty('') // => true + * isEmpty(' ') // => false (whitespace-only, not empty) + * isEmpty('foo') // => false + * @since next + */ +export function isEmpty(value: string): value is '' { + return value === ''; +} diff --git a/helpers/string/isNonEmpty.example.ts b/helpers/string/isNonEmpty.example.ts new file mode 100644 index 00000000..121c5a00 --- /dev/null +++ b/helpers/string/isNonEmpty.example.ts @@ -0,0 +1,41 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isNonEmpty } from './isNonEmpty'; + +const examples: HelperExamples = { + helper: 'isNonEmpty', + category: 'string', + examples: [ + { + title: 'Check if a string has content', + description: 'Returns true for any string with at least one character, including whitespace.', + code: `isNonEmpty('hello') // => true +isNonEmpty(' ') // => true (whitespace is content) +isNonEmpty('') // => false`, + assert: () => { + if (!isNonEmpty('hello')) throw new Error('"hello" should be non-empty'); + if (!isNonEmpty(' ')) throw new Error('" " should be non-empty'); + if (isNonEmpty('')) throw new Error('"" should not be non-empty'); + }, + }, + { + title: 'Exclude blank strings by trimming first', + description: 'Compose with .trim() when whitespace-only strings should be treated as empty.', + code: `isNonEmpty('hello'.trim()) // => true +isNonEmpty(' '.trim()) // => false +isNonEmpty(''.trim()) // => false`, + assert: () => { + if (!isNonEmpty('hello'.trim())) throw new Error('Expected true'); + if (isNonEmpty(' '.trim())) throw new Error('Expected false after trim'); + if (isNonEmpty(''.trim())) throw new Error('Expected false'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/string/isNonEmpty.spec.ts b/helpers/string/isNonEmpty.spec.ts new file mode 100644 index 00000000..143c04a0 --- /dev/null +++ b/helpers/string/isNonEmpty.spec.ts @@ -0,0 +1,33 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isNonEmpty } from './isNonEmpty'; + +describe('isNonEmpty — property-based', () => { + it('is always true for strings with at least one character', () => { + fc.assert( + fc.property(fc.string({ minLength: 1 }), (s) => { + expect(isNonEmpty(s)).toBe(true); + }), + ); + }); + + it('is always false for the empty string', () => { + expect(isNonEmpty('')).toBe(false); + }); +}); + +describe('isNonEmpty — contracts', () => { + it('is equivalent to s.length > 0', () => { + fc.assert( + fc.property(fc.string(), (s) => { + expect(isNonEmpty(s)).toBe(s.length > 0); + }), + ); + }); +}); diff --git a/helpers/string/isNonEmpty.test.ts b/helpers/string/isNonEmpty.test.ts new file mode 100644 index 00000000..250bb750 --- /dev/null +++ b/helpers/string/isNonEmpty.test.ts @@ -0,0 +1,25 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isNonEmpty } from './isNonEmpty'; + +describe('isNonEmpty', () => { + it('should return true for a non-empty string', () => { + expect(isNonEmpty('a')).toBe(true); + expect(isNonEmpty('hello')).toBe(true); + }); + + it('should return true for a whitespace-only string', () => { + expect(isNonEmpty(' ')).toBe(true); + expect(isNonEmpty(' ')).toBe(true); + expect(isNonEmpty('\t')).toBe(true); + }); + + it('should return false for an empty string', () => { + expect(isNonEmpty('')).toBe(false); + }); +}); diff --git a/helpers/string/isNonEmpty.ts b/helpers/string/isNonEmpty.ts new file mode 100644 index 00000000..0454ab5c --- /dev/null +++ b/helpers/string/isNonEmpty.ts @@ -0,0 +1,23 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a string is non-empty (has at least one character). + * + * Whitespace-only strings are considered non-empty. + * Use `isNonEmpty(value.trim())` if you need to exclude blank strings. + * + * @param value - The string to check + * @returns `true` if the string has at least one character + * @example + * isNonEmpty('hello') // => true + * isNonEmpty(' ') // => true (whitespace-only, not empty) + * isNonEmpty('') // => false + * @since next + */ +export function isNonEmpty(value: string): boolean { + return value.length > 0; +} diff --git a/helpers/string/isNotBlank.example.ts b/helpers/string/isNotBlank.example.ts new file mode 100644 index 00000000..b5ea7906 --- /dev/null +++ b/helpers/string/isNotBlank.example.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isNotBlank } from './isNotBlank'; + +const examples: HelperExamples = { + helper: 'isNotBlank', + category: 'string', + examples: [ + { + title: 'Check that a string has real content', + description: + 'Returns true only when the string contains at least one non-whitespace character.', + code: `isNotBlank('foo') // => true +isNotBlank(' x ') // => true +isNotBlank('') // => false +isNotBlank(' ') // => false +isNotBlank('\\t') // => false`, + assert: () => { + if (!isNotBlank('foo')) throw new Error('Expected true for "foo"'); + if (!isNotBlank(' x ')) throw new Error('Expected true for " x "'); + if (isNotBlank('')) throw new Error('Expected false for ""'); + if (isNotBlank(' ')) throw new Error('Expected false for spaces'); + }, + }, + { + title: 'Filter out blank strings from an array', + description: 'Use as a predicate in .filter() to keep only strings with real content.', + code: `const tags = ['typescript', ' ', '', 'helpers']; +tags.filter(isNotBlank) +// => ['typescript', 'helpers']`, + assert: () => { + const tags = ['typescript', ' ', '', 'helpers']; + const result = tags.filter(isNotBlank); + if (result.length !== 2) throw new Error('Expected ["typescript", "helpers"]'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/string/isNotBlank.spec.ts b/helpers/string/isNotBlank.spec.ts new file mode 100644 index 00000000..53392e89 --- /dev/null +++ b/helpers/string/isNotBlank.spec.ts @@ -0,0 +1,32 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isNotBlank } from './isNotBlank'; + +describe('isNotBlank — property-based', () => { + it('is always true for strings containing at least one non-whitespace character', () => { + fc.assert( + fc.property( + fc.string({ minLength: 1 }).filter((s) => s.trim().length > 0), + (s) => { + expect(isNotBlank(s)).toBe(true); + }, + ), + ); + }); +}); + +describe('isNotBlank — contracts', () => { + it('is equivalent to value.trim() !== ""', () => { + fc.assert( + fc.property(fc.string(), (s) => { + expect(isNotBlank(s)).toBe(s.trim() !== ''); + }), + ); + }); +}); diff --git a/helpers/string/isNotBlank.test.ts b/helpers/string/isNotBlank.test.ts new file mode 100644 index 00000000..2e8f820f --- /dev/null +++ b/helpers/string/isNotBlank.test.ts @@ -0,0 +1,40 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isNotBlank } from './isNotBlank'; + +describe('isNotBlank', () => { + it('should return true for strings with visible content', () => { + expect(isNotBlank('foo')).toBe(true); + expect(isNotBlank(' x ')).toBe(true); + expect(isNotBlank('0')).toBe(true); + }); + + it('should return true for strings containing zero-width characters', () => { + expect(isNotBlank('​')).toBe(true); // zero-width space — not whitespace + expect(isNotBlank('‌')).toBe(true); + expect(isNotBlank('‍')).toBe(true); + }); + + it('should return false for an empty string', () => { + expect(isNotBlank('')).toBe(false); + }); + + it('should return false for ASCII whitespace-only strings', () => { + expect(isNotBlank(' ')).toBe(false); + expect(isNotBlank('\t')).toBe(false); + expect(isNotBlank('\n')).toBe(false); + expect(isNotBlank(' \t\n\r ')).toBe(false); + }); + + it('should return false for Unicode whitespace-only strings', () => { + expect(isNotBlank(' ')).toBe(false); // non-breaking space + expect(isNotBlank(' ')).toBe(false); // em space + expect(isNotBlank(' ')).toBe(false); // ideographic space + expect(isNotBlank('')).toBe(false); // BOM + }); +}); diff --git a/helpers/string/isNotBlank.ts b/helpers/string/isNotBlank.ts new file mode 100644 index 00000000..a337adb4 --- /dev/null +++ b/helpers/string/isNotBlank.ts @@ -0,0 +1,27 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a string is not blank — non-empty and contains at least one + * non-whitespace character. + * + * Uses `String.prototype.trim()` internally. See `isBlank` for the full list + * of characters considered whitespace (includes non-breaking space, en/em space, + * ideographic space, etc.). + * + * @param value - The string to check + * @returns `true` if the string has at least one non-whitespace character + * @example + * isNotBlank('foo') // => true + * isNotBlank(' x ') // => true + * isNotBlank('') // => false + * isNotBlank(' ') // => false + * isNotBlank('\t\n') // => false + * @since next + */ +export function isNotBlank(value: string): boolean { + return value.trim() !== ''; +} diff --git a/helpers/type/isArrayLike.example.ts b/helpers/type/isArrayLike.example.ts new file mode 100644 index 00000000..39e38189 --- /dev/null +++ b/helpers/type/isArrayLike.example.ts @@ -0,0 +1,54 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isArrayLike } from './isArrayLike'; + +const examples: HelperExamples = { + helper: 'isArrayLike', + category: 'type', + examples: [ + { + title: 'Detect array-like values', + description: 'Arrays, strings, and objects with a non-negative integer length are array-like.', + code: `isArrayLike([1, 2, 3]) // => true +isArrayLike('hello') // => true +isArrayLike({ length: 3 }) // => true +isArrayLike({ length: -1 }) // => false +isArrayLike(() => {}) // => false (functions excluded) +isArrayLike(null) // => false`, + assert: () => { + if (!isArrayLike([1, 2])) throw new Error('array should be array-like'); + if (!isArrayLike('hello')) throw new Error('string should be array-like'); + if (!isArrayLike({ length: 3 })) throw new Error('{length:3} should be array-like'); + if (isArrayLike({ length: -1 })) throw new Error('{length:-1} should not be array-like'); + if (isArrayLike(() => {})) throw new Error('function should not be array-like'); + }, + }, + { + title: 'Convert an array-like value to an array', + description: 'Use as a guard before Array.from().', + code: `function toArray(value: unknown): unknown[] { + if (isArrayLike(value)) return Array.from(value); + return [value]; +} +toArray([1, 2]) // => [1, 2] +toArray('abc') // => ['a', 'b', 'c'] +toArray(42) // => [42]`, + assert: () => { + function toArray(value: unknown): unknown[] { + if (isArrayLike(value)) return Array.from(value); + return [value]; + } + if (toArray([1, 2]).length !== 2) throw new Error('Expected length 2'); + if (toArray('abc').length !== 3) throw new Error('Expected length 3'); + if (toArray(42).length !== 1) throw new Error('Expected [42]'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/type/isArrayLike.spec.ts b/helpers/type/isArrayLike.spec.ts new file mode 100644 index 00000000..be95cf6d --- /dev/null +++ b/helpers/type/isArrayLike.spec.ts @@ -0,0 +1,58 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isArrayLike } from './isArrayLike'; + +describe('isArrayLike — property-based', () => { + it('arrays are always array-like', () => { + fc.assert( + fc.property(fc.array(fc.anything()), (arr) => { + expect(isArrayLike(arr)).toBe(true); + }), + ); + }); + + it('strings are always array-like', () => { + fc.assert( + fc.property(fc.string(), (str) => { + expect(isArrayLike(str)).toBe(true); + }), + ); + }); + + it('objects with non-negative integer length are array-like', () => { + fc.assert( + fc.property(fc.integer({ min: 0, max: 100_000 }), (len) => { + expect(isArrayLike({ length: len })).toBe(true); + }), + ); + }); + + it('booleans are never array-like', () => { + fc.assert( + fc.property(fc.boolean(), (value) => { + expect(isArrayLike(value)).toBe(false); + }), + ); + }); +}); + +describe('isArrayLike — contract', () => { + it('null → false', () => expect(isArrayLike(null)).toBe(false)); + it('undefined → false', () => expect(isArrayLike(undefined)).toBe(false)); + it('[] → true', () => expect(isArrayLike([])).toBe(true)); + it('"" → true', () => expect(isArrayLike('')).toBe(true)); + it('{ length: 0 } → true', () => expect(isArrayLike({ length: 0 })).toBe(true)); + it('{ length: -1 } → false', () => expect(isArrayLike({ length: -1 })).toBe(false)); + it('{ length: 1.5 } → false', () => expect(isArrayLike({ length: 1.5 })).toBe(false)); + it('{ length: NaN } → false', () => expect(isArrayLike({ length: NaN })).toBe(false)); + it('{ length: Infinity } → false', () => expect(isArrayLike({ length: Infinity })).toBe(false)); + it('{} → false (no length)', () => expect(isArrayLike({})).toBe(false)); + it('function → false', () => expect(isArrayLike(() => {})).toBe(false)); + it('42 → false', () => expect(isArrayLike(42)).toBe(false)); +}); diff --git a/helpers/type/isArrayLike.test.ts b/helpers/type/isArrayLike.test.ts new file mode 100644 index 00000000..ec4d1cb9 --- /dev/null +++ b/helpers/type/isArrayLike.test.ts @@ -0,0 +1,58 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isArrayLike } from './isArrayLike'; + +describe('isArrayLike', () => { + it('should return true for arrays', () => { + expect(isArrayLike([])).toBe(true); + expect(isArrayLike([1, 2, 3])).toBe(true); + }); + + it('should return true for strings', () => { + expect(isArrayLike('')).toBe(true); + expect(isArrayLike('hello')).toBe(true); + }); + + it('should return true for objects with valid length', () => { + expect(isArrayLike({ length: 0 })).toBe(true); + expect(isArrayLike({ length: 3 })).toBe(true); + }); + + it('should return true for typed arrays', () => { + expect(isArrayLike(new Uint8Array(4))).toBe(true); + expect(isArrayLike(new Int32Array(2))).toBe(true); + }); + + it('should return false for objects with invalid length', () => { + expect(isArrayLike({ length: -1 })).toBe(false); + expect(isArrayLike({ length: 1.5 })).toBe(false); + expect(isArrayLike({ length: NaN })).toBe(false); + expect(isArrayLike({ length: Infinity })).toBe(false); + }); + + it('should return false for objects without length', () => { + expect(isArrayLike({})).toBe(false); + expect(isArrayLike({ a: 1 })).toBe(false); + }); + + it('should return false for functions', () => { + expect(isArrayLike(() => {})).toBe(false); + expect(isArrayLike(function named() {})).toBe(false); + }); + + it('should return false for null and undefined', () => { + expect(isArrayLike(null)).toBe(false); + expect(isArrayLike(undefined)).toBe(false); + }); + + it('should return false for non-object primitives', () => { + expect(isArrayLike(42)).toBe(false); + expect(isArrayLike(true)).toBe(false); + expect(isArrayLike(Symbol('x'))).toBe(false); + }); +}); diff --git a/helpers/type/isArrayLike.ts b/helpers/type/isArrayLike.ts new file mode 100644 index 00000000..1a679c11 --- /dev/null +++ b/helpers/type/isArrayLike.ts @@ -0,0 +1,32 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is array-like: has a non-negative integer `length` property. + * + * Returns `true` for arrays, strings, `arguments` objects, `NodeList`, typed + * arrays, and any object with a valid `length`. Functions are excluded even though + * they have a `length` (arity), as they are not considered array-like in practice. + * + * @param value - The value to check + * @returns `true` if value is array-like + * @example + * isArrayLike([1, 2, 3]) // => true + * isArrayLike('hello') // => true + * isArrayLike({ length: 2 }) // => true + * isArrayLike({ length: -1 }) // => false (negative length) + * isArrayLike({ length: 1.5 }) // => false (non-integer length) + * isArrayLike(() => {}) // => false (functions excluded) + * isArrayLike(null) // => false + * @since next + */ +export function isArrayLike(value: unknown): value is ArrayLike { + if (value == null) return false; + const t = typeof value; + if (t !== 'object' && t !== 'string') return false; + const len = (value as ArrayLike).length; + return typeof len === 'number' && Number.isInteger(len) && len >= 0; +} diff --git a/helpers/type/isAsyncGenerator.example.ts b/helpers/type/isAsyncGenerator.example.ts new file mode 100644 index 00000000..26014851 --- /dev/null +++ b/helpers/type/isAsyncGenerator.example.ts @@ -0,0 +1,44 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isAsyncGenerator } from './isAsyncGenerator'; + +const examples: HelperExamples = { + helper: 'isAsyncGenerator', + category: 'type', + examples: [ + { + title: 'Detect an async generator instance', + description: 'Returns true only for the object produced by calling an async function*.', + code: `async function* gen() { yield 1; } +isAsyncGenerator(gen()) // => true (instance) +isAsyncGenerator(gen) // => false (function) +isAsyncGenerator([]) // => false`, + assert: () => { + async function* gen() { yield 1; } + if (!isAsyncGenerator(gen())) throw new Error('async generator instance should return true'); + if (isAsyncGenerator(gen)) throw new Error('async generator function should return false'); + }, + }, + { + title: 'Distinguish async from sync generators', + description: 'isAsyncGenerator is false for sync generator instances.', + code: `function* sync() { yield 1; } +async function* async_() { yield 1; } +isAsyncGenerator(sync()) // => false +isAsyncGenerator(async_()) // => true`, + assert: () => { + function* sync() { yield 1; } + async function* asyncGen() { yield 1; } + if (isAsyncGenerator(sync())) throw new Error('sync generator should return false'); + if (!isAsyncGenerator(asyncGen())) throw new Error('async generator should return true'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/type/isAsyncGenerator.spec.ts b/helpers/type/isAsyncGenerator.spec.ts new file mode 100644 index 00000000..fd08763d --- /dev/null +++ b/helpers/type/isAsyncGenerator.spec.ts @@ -0,0 +1,38 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isAsyncGenerator } from './isAsyncGenerator'; + +describe('isAsyncGenerator — property-based', () => { + it('primitives are never async generators', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.integer(), fc.boolean()), (value) => { + expect(isAsyncGenerator(value)).toBe(false); + }), + ); + }); +}); + +describe('isAsyncGenerator — contract', () => { + it('null → false', () => expect(isAsyncGenerator(null)).toBe(false)); + it('undefined → false', () => expect(isAsyncGenerator(undefined)).toBe(false)); + it('{} → false', () => expect(isAsyncGenerator({})).toBe(false)); + it('sync generator instance → false', () => { + function* gen() { yield 1; } + expect(isAsyncGenerator(gen())).toBe(false); + }); + it('async generator function → false (not an instance)', () => { + async function* gen() { yield 1; } + expect(isAsyncGenerator(gen)).toBe(false); + }); + it('async generator instance → true', () => { + async function* gen() { yield 1; } + expect(isAsyncGenerator(gen())).toBe(true); + }); + it('Promise → false', () => expect(isAsyncGenerator(Promise.resolve())).toBe(false)); +}); diff --git a/helpers/type/isAsyncGenerator.test.ts b/helpers/type/isAsyncGenerator.test.ts new file mode 100644 index 00000000..943ca63f --- /dev/null +++ b/helpers/type/isAsyncGenerator.test.ts @@ -0,0 +1,40 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isAsyncGenerator } from './isAsyncGenerator'; + +describe('isAsyncGenerator', () => { + it('should return true for async generator instances', () => { + async function* gen() { yield 1; } + expect(isAsyncGenerator(gen())).toBe(true); + }); + + it('should return true for partially consumed async generators', () => { + async function* gen() { yield 1; yield 2; } + const g = gen(); + void g.next(); + expect(isAsyncGenerator(g)).toBe(true); + }); + + it('should return false for async generator functions', () => { + async function* gen() { yield 1; } + expect(isAsyncGenerator(gen)).toBe(false); + }); + + it('should return false for sync generators', () => { + function* gen() { yield 1; } + expect(isAsyncGenerator(gen())).toBe(false); + }); + + it('should return false for other values', () => { + expect(isAsyncGenerator(null)).toBe(false); + expect(isAsyncGenerator(undefined)).toBe(false); + expect(isAsyncGenerator(42)).toBe(false); + expect(isAsyncGenerator({})).toBe(false); + expect(isAsyncGenerator([])).toBe(false); + }); +}); diff --git a/helpers/type/isAsyncGenerator.ts b/helpers/type/isAsyncGenerator.ts new file mode 100644 index 00000000..70bb4b31 --- /dev/null +++ b/helpers/type/isAsyncGenerator.ts @@ -0,0 +1,25 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is an async generator object (the result of calling an `async function*`). + * + * Distinct from {@link isAsyncGeneratorFunction}: this predicate targets the + * *instance* produced by calling an async generator function, not the function itself. + * + * @param value - The value to check + * @returns `true` if value is an AsyncGenerator instance + * @example + * async function* gen() { yield 1; } + * isAsyncGenerator(gen()) // => true + * isAsyncGenerator(gen) // => false (function, not instance) + * isAsyncGenerator([]) // => false + * @see {@link isAsyncGeneratorFunction} + * @since next + */ +export function isAsyncGenerator(value: unknown): value is AsyncGenerator { + return Object.prototype.toString.call(value) === '[object AsyncGenerator]'; +} diff --git a/helpers/type/isAsyncGeneratorFunction.example.ts b/helpers/type/isAsyncGeneratorFunction.example.ts new file mode 100644 index 00000000..5a4264bc --- /dev/null +++ b/helpers/type/isAsyncGeneratorFunction.example.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isAsyncGeneratorFunction } from './isAsyncGeneratorFunction'; + +const examples: HelperExamples = { + helper: 'isAsyncGeneratorFunction', + category: 'type', + examples: [ + { + title: 'Detect an async generator function', + description: 'Returns true for async function* declarations and expressions.', + code: `async function* gen() { yield 1; } +isAsyncGeneratorFunction(gen) // => true +isAsyncGeneratorFunction(gen()) // => false (instance) +isAsyncGeneratorFunction(async () => {}) // => false`, + assert: () => { + async function* gen() { yield 1; } + if (!isAsyncGeneratorFunction(gen)) throw new Error('should be async generator function'); + if (isAsyncGeneratorFunction(gen())) throw new Error('instance should return false'); + if (isAsyncGeneratorFunction(async () => {})) throw new Error('async fn should return false'); + }, + }, + { + title: 'Distinguish async generator functions from sync generator functions', + description: 'isAsyncGeneratorFunction is false for sync function*.', + code: `function* sync() { yield 1; } +async function* async_() { yield 1; } +isAsyncGeneratorFunction(sync) // => false +isAsyncGeneratorFunction(async_) // => true`, + assert: () => { + function* sync() { yield 1; } + async function* asyncGen() { yield 1; } + if (isAsyncGeneratorFunction(sync)) throw new Error('sync gen fn should return false'); + if (!isAsyncGeneratorFunction(asyncGen)) throw new Error('async gen fn should return true'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/type/isAsyncGeneratorFunction.spec.ts b/helpers/type/isAsyncGeneratorFunction.spec.ts new file mode 100644 index 00000000..62419089 --- /dev/null +++ b/helpers/type/isAsyncGeneratorFunction.spec.ts @@ -0,0 +1,41 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isAsyncGeneratorFunction } from './isAsyncGeneratorFunction'; + +describe('isAsyncGeneratorFunction — property-based', () => { + it('primitives are never async generator functions', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.integer(), fc.boolean()), (value) => { + expect(isAsyncGeneratorFunction(value)).toBe(false); + }), + ); + }); +}); + +describe('isAsyncGeneratorFunction — contract', () => { + it('null → false', () => expect(isAsyncGeneratorFunction(null)).toBe(false)); + it('undefined → false', () => expect(isAsyncGeneratorFunction(undefined)).toBe(false)); + it('regular function → false', () => expect(isAsyncGeneratorFunction(() => {})).toBe(false)); + it('async function → false', () => expect(isAsyncGeneratorFunction(async () => {})).toBe(false)); + it('sync generator function → false', () => { + function* gen() { yield 1; } + expect(isAsyncGeneratorFunction(gen)).toBe(false); + }); + it('async generator instance → false (not a function)', () => { + async function* gen() { yield 1; } + expect(isAsyncGeneratorFunction(gen())).toBe(false); + }); + it('async function* → true', () => { + async function* gen() { yield 1; } + expect(isAsyncGeneratorFunction(gen)).toBe(true); + }); + it('async function* expression → true', () => { + expect(isAsyncGeneratorFunction(async function* () { yield 1; })).toBe(true); + }); +}); diff --git a/helpers/type/isAsyncGeneratorFunction.test.ts b/helpers/type/isAsyncGeneratorFunction.test.ts new file mode 100644 index 00000000..eb6180dc --- /dev/null +++ b/helpers/type/isAsyncGeneratorFunction.test.ts @@ -0,0 +1,43 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isAsyncGeneratorFunction } from './isAsyncGeneratorFunction'; + +describe('isAsyncGeneratorFunction', () => { + it('should return true for async function* declarations', () => { + async function* gen() { yield 1; } + expect(isAsyncGeneratorFunction(gen)).toBe(true); + }); + + it('should return true for async function* expressions', () => { + const gen = async function* () { yield 1; }; + expect(isAsyncGeneratorFunction(gen)).toBe(true); + }); + + it('should return false for async generator instances', () => { + async function* gen() { yield 1; } + expect(isAsyncGeneratorFunction(gen())).toBe(false); + }); + + it('should return false for sync generator functions', () => { + function* gen() { yield 1; } + expect(isAsyncGeneratorFunction(gen)).toBe(false); + }); + + it('should return false for regular and async functions', () => { + expect(isAsyncGeneratorFunction(() => {})).toBe(false); + expect(isAsyncGeneratorFunction(async () => {})).toBe(false); + expect(isAsyncGeneratorFunction(function () {})).toBe(false); + }); + + it('should return false for null, undefined and other types', () => { + expect(isAsyncGeneratorFunction(null)).toBe(false); + expect(isAsyncGeneratorFunction(undefined)).toBe(false); + expect(isAsyncGeneratorFunction(42)).toBe(false); + expect(isAsyncGeneratorFunction({})).toBe(false); + }); +}); diff --git a/helpers/type/isAsyncGeneratorFunction.ts b/helpers/type/isAsyncGeneratorFunction.ts new file mode 100644 index 00000000..32f48517 --- /dev/null +++ b/helpers/type/isAsyncGeneratorFunction.ts @@ -0,0 +1,25 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is an async generator function (an `async function*` declaration or expression). + * + * Distinct from {@link isAsyncGenerator}: this predicate targets the *function* itself, + * not the async iterator it produces when called. + * + * @param value - The value to check + * @returns `true` if value is an AsyncGeneratorFunction + * @example + * async function* gen() { yield 1; } + * isAsyncGeneratorFunction(gen) // => true + * isAsyncGeneratorFunction(gen()) // => false (instance, not function) + * isAsyncGeneratorFunction(async () => {}) // => false + * @see {@link isAsyncGenerator} + * @since next + */ +export function isAsyncGeneratorFunction(value: unknown): value is AsyncGeneratorFunction { + return Object.prototype.toString.call(value) === '[object AsyncGeneratorFunction]'; +} diff --git a/helpers/type/isAsyncIterable.example.ts b/helpers/type/isAsyncIterable.example.ts new file mode 100644 index 00000000..8325d296 --- /dev/null +++ b/helpers/type/isAsyncIterable.example.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isAsyncIterable } from './isAsyncIterable'; + +const examples: HelperExamples = { + helper: 'isAsyncIterable', + category: 'type', + examples: [ + { + title: 'Detect an async generator', + description: 'Async generators implement the async iterable protocol.', + code: `async function* stream() { yield 1; yield 2; } +isAsyncIterable(stream()) // => true +isAsyncIterable([1, 2, 3]) // => false (Iterable, not AsyncIterable) +isAsyncIterable('hello') // => false`, + assert: () => { + async function* stream() { yield 1; } + if (!isAsyncIterable(stream())) throw new Error('async generator should be async iterable'); + if (isAsyncIterable([1, 2])) throw new Error('array should not be async iterable'); + }, + }, + { + title: 'Guard before for-await-of', + description: 'Use to type-narrow before consuming a value with for-await-of.', + code: `async function consume(source: unknown) { + if (isAsyncIterable(source)) { + for await (const item of source) { + console.log(item); + } + } +}`, + assert: () => { + if (isAsyncIterable(null)) throw new Error('null should not be async iterable'); + if (isAsyncIterable(undefined)) throw new Error('undefined should not be async iterable'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/type/isAsyncIterable.spec.ts b/helpers/type/isAsyncIterable.spec.ts new file mode 100644 index 00000000..fa12e6a4 --- /dev/null +++ b/helpers/type/isAsyncIterable.spec.ts @@ -0,0 +1,38 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isAsyncIterable } from './isAsyncIterable'; + +describe('isAsyncIterable — property-based', () => { + it('primitives are never async iterable', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.integer(), fc.boolean()), (value) => { + expect(isAsyncIterable(value)).toBe(false); + }), + ); + }); +}); + +describe('isAsyncIterable — contract', () => { + it('null → false', () => expect(isAsyncIterable(null)).toBe(false)); + it('undefined → false', () => expect(isAsyncIterable(undefined)).toBe(false)); + it('[] → false (Iterable, not AsyncIterable)', () => expect(isAsyncIterable([])).toBe(false)); + it('{} → false', () => expect(isAsyncIterable({})).toBe(false)); + it('async generator instance → true', () => { + async function* gen() { yield 1; } + expect(isAsyncIterable(gen())).toBe(true); + }); + it('custom [Symbol.asyncIterator] object → true', () => { + const obj = { [Symbol.asyncIterator]: () => ({ async next() { return { value: undefined, done: true }; } }) }; + expect(isAsyncIterable(obj)).toBe(true); + }); + it('[Symbol.asyncIterator] must be a function, not a value', () => { + const obj = { [Symbol.asyncIterator]: 'not-a-function' }; + expect(isAsyncIterable(obj)).toBe(false); + }); +}); diff --git a/helpers/type/isAsyncIterable.test.ts b/helpers/type/isAsyncIterable.test.ts new file mode 100644 index 00000000..9264a2a5 --- /dev/null +++ b/helpers/type/isAsyncIterable.test.ts @@ -0,0 +1,44 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isAsyncIterable } from './isAsyncIterable'; + +describe('isAsyncIterable', () => { + it('should return true for async generators', () => { + async function* gen() { yield 1; } + expect(isAsyncIterable(gen())).toBe(true); + }); + + it('should return true for custom async iterable objects', () => { + const obj = { + [Symbol.asyncIterator]() { + return { async next() { return { value: 1, done: true }; } }; + }, + }; + expect(isAsyncIterable(obj)).toBe(true); + }); + + it('should return false for regular iterables (arrays, strings, generators)', () => { + expect(isAsyncIterable([1, 2, 3])).toBe(false); + expect(isAsyncIterable('hello')).toBe(false); + function* gen() { yield 1; } + expect(isAsyncIterable(gen())).toBe(false); + expect(isAsyncIterable(new Map())).toBe(false); + expect(isAsyncIterable(new Set())).toBe(false); + }); + + it('should return false for null and undefined', () => { + expect(isAsyncIterable(null)).toBe(false); + expect(isAsyncIterable(undefined)).toBe(false); + }); + + it('should return false for plain objects and primitives', () => { + expect(isAsyncIterable({})).toBe(false); + expect(isAsyncIterable(42)).toBe(false); + expect(isAsyncIterable(true)).toBe(false); + }); +}); diff --git a/helpers/type/isAsyncIterable.ts b/helpers/type/isAsyncIterable.ts new file mode 100644 index 00000000..75276f22 --- /dev/null +++ b/helpers/type/isAsyncIterable.ts @@ -0,0 +1,27 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value implements the async iterable protocol. + * + * Returns `true` for any object that has a `[Symbol.asyncIterator]()` method, + * including async generators. Note that regular iterables (arrays, strings, etc.) + * are **not** async iterables. + * + * @param value - The value to check + * @returns `true` if value is async iterable + * @example + * async function* gen() { yield 1; } + * isAsyncIterable(gen()) // => true + * isAsyncIterable([1, 2, 3]) // => false (Iterable, not AsyncIterable) + * isAsyncIterable('hello') // => false + * isAsyncIterable(null) // => false + * @since next + */ +export function isAsyncIterable(value: unknown): value is AsyncIterable { + if (value === null || value === undefined) return false; + return typeof (value as AsyncIterable)[Symbol.asyncIterator] === 'function'; +} diff --git a/helpers/type/isDate.ts b/helpers/type/isDate.ts index 8b6c68de..1057e6a5 100644 --- a/helpers/type/isDate.ts +++ b/helpers/type/isDate.ts @@ -8,7 +8,7 @@ * Checks if a value is a Date instance. * * Note: this only checks the type, not whether the Date is valid. - * Use {@link isValidDate} to also validate that the Date is not `Invalid Date`. + * Use `date/isValid` to also validate that the Date is not `Invalid Date`. * * @param value - The value to check * @returns True if value is a Date instance @@ -18,7 +18,7 @@ * isDate('2023-01-01') // => false * isDate(1609459200000) // => false * - * @see {@link isValidDate} for validating the Date is not Invalid Date + * @see `date/isValid` for validating the Date is not Invalid Date * @see {@link isTimestamp} for checking if a number is a valid timestamp * @since 2.0.0 */ diff --git a/helpers/type/isEmpty.ts b/helpers/type/isEmpty.ts index 8f856ec0..628e4e80 100644 --- a/helpers/type/isEmpty.ts +++ b/helpers/type/isEmpty.ts @@ -39,6 +39,8 @@ import { isSpecialObject } from './isSpecialObject'; * declare const m: ReadonlyMap | null; * if (isEmpty(m)) { ... } // m: ReadonlyMap | null * @since 2.0.0 + * @deprecated Use the category-specific helper instead: `array/isEmpty`, `string/isEmpty`, + * or `object/isEmpty`. This monolithic helper will be removed in v3. */ export function isEmpty(value: unknown): value is null | undefined | '' | never[] | ReadonlyMap | ReadonlySet { if (value === null || value === undefined) { diff --git a/helpers/type/isGenerator.example.ts b/helpers/type/isGenerator.example.ts new file mode 100644 index 00000000..0f1303fa --- /dev/null +++ b/helpers/type/isGenerator.example.ts @@ -0,0 +1,47 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isGenerator } from './isGenerator'; + +const examples: HelperExamples = { + helper: 'isGenerator', + category: 'type', + examples: [ + { + title: 'Distinguish a generator instance from its function', + description: 'isGenerator targets the object returned by calling a function*, not the function itself.', + code: `function* counter() { yield 1; yield 2; } +isGenerator(counter()) // => true (instance) +isGenerator(counter) // => false (function) +isGenerator([1, 2]) // => false`, + assert: () => { + function* counter() { yield 1; } + if (!isGenerator(counter())) throw new Error('generator instance should return true'); + if (isGenerator(counter)) throw new Error('generator function should return false'); + }, + }, + { + title: 'Type-narrow to safely call .next()', + description: 'Narrows the type to Generator so you can call .next() and .return().', + code: `function* gen() { yield 1; yield 2; } +const value: unknown = gen(); +if (isGenerator(value)) { + const { value: v, done } = value.next(); + // v: unknown, done: boolean | undefined +}`, + assert: () => { + function* gen() { yield 1; } + const g: unknown = gen(); + if (!isGenerator(g)) throw new Error('should be generator'); + const { value } = g.next(); + if (value !== 1) throw new Error('first value should be 1'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/type/isGenerator.spec.ts b/helpers/type/isGenerator.spec.ts new file mode 100644 index 00000000..44823568 --- /dev/null +++ b/helpers/type/isGenerator.spec.ts @@ -0,0 +1,42 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isGenerator } from './isGenerator'; + +describe('isGenerator — property-based', () => { + it('primitives are never generators', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.integer(), fc.boolean()), (value) => { + expect(isGenerator(value)).toBe(false); + }), + ); + }); +}); + +describe('isGenerator — contract', () => { + it('null → false', () => expect(isGenerator(null)).toBe(false)); + it('undefined → false', () => expect(isGenerator(undefined)).toBe(false)); + it('{} → false', () => expect(isGenerator({})).toBe(false)); + it('[] → false', () => expect(isGenerator([])).toBe(false)); + it('regular function → false', () => expect(isGenerator(() => {})).toBe(false)); + it('generator function → false (not an instance)', () => { + function* gen() { yield 1; } + expect(isGenerator(gen)).toBe(false); + }); + it('generator instance → true', () => { + function* gen() { yield 1; } + expect(isGenerator(gen())).toBe(true); + }); + it('async generator instance → false', () => { + async function* gen() { yield 1; } + expect(isGenerator(gen())).toBe(false); + }); + it('array iterator → false', () => { + expect(isGenerator([1][Symbol.iterator]())).toBe(false); + }); +}); diff --git a/helpers/type/isGenerator.test.ts b/helpers/type/isGenerator.test.ts new file mode 100644 index 00000000..89d5e3bd --- /dev/null +++ b/helpers/type/isGenerator.test.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isGenerator } from './isGenerator'; + +describe('isGenerator', () => { + it('should return true for generator instances', () => { + function* gen() { yield 1; yield 2; } + expect(isGenerator(gen())).toBe(true); + }); + + it('should return true for partially consumed generators', () => { + function* gen() { yield 1; yield 2; } + const g = gen(); + g.next(); + expect(isGenerator(g)).toBe(true); + }); + + it('should return false for generator functions', () => { + function* gen() { yield 1; } + expect(isGenerator(gen)).toBe(false); + }); + + it('should return false for async generators', () => { + async function* asyncGen() { yield 1; } + expect(isGenerator(asyncGen())).toBe(false); + }); + + it('should return false for arrays, iterators and other iterables', () => { + expect(isGenerator([1, 2, 3])).toBe(false); + expect(isGenerator([1][Symbol.iterator]())).toBe(false); + expect(isGenerator('hello')).toBe(false); + }); + + it('should return false for null, undefined and primitives', () => { + expect(isGenerator(null)).toBe(false); + expect(isGenerator(undefined)).toBe(false); + expect(isGenerator(42)).toBe(false); + expect(isGenerator({})).toBe(false); + }); +}); diff --git a/helpers/type/isGenerator.ts b/helpers/type/isGenerator.ts new file mode 100644 index 00000000..d0b0cea0 --- /dev/null +++ b/helpers/type/isGenerator.ts @@ -0,0 +1,25 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is a generator object (the result of calling a `function*`). + * + * Distinct from {@link isGeneratorFunction}: this predicate targets the + * *instance* produced by calling a generator function, not the function itself. + * + * @param value - The value to check + * @returns `true` if value is a Generator instance + * @example + * function* gen() { yield 1; yield 2; } + * isGenerator(gen()) // => true + * isGenerator(gen) // => false (function, not instance) + * isGenerator([1, 2]) // => false + * @see {@link isGeneratorFunction} + * @since next + */ +export function isGenerator(value: unknown): value is Generator { + return Object.prototype.toString.call(value) === '[object Generator]'; +} diff --git a/helpers/type/isGeneratorFunction.example.ts b/helpers/type/isGeneratorFunction.example.ts new file mode 100644 index 00000000..04a98a45 --- /dev/null +++ b/helpers/type/isGeneratorFunction.example.ts @@ -0,0 +1,43 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isGeneratorFunction } from './isGeneratorFunction'; + +const examples: HelperExamples = { + helper: 'isGeneratorFunction', + category: 'type', + examples: [ + { + title: 'Detect a generator function', + description: 'Returns true for function* declarations and expressions.', + code: `function* gen() { yield 1; } +isGeneratorFunction(gen) // => true +isGeneratorFunction(gen()) // => false (instance, not function) +isGeneratorFunction(() => {}) // => false`, + assert: () => { + function* gen() { yield 1; } + if (!isGeneratorFunction(gen)) throw new Error('should be generator function'); + if (isGeneratorFunction(gen())) throw new Error('instance should not be generator function'); + if (isGeneratorFunction(() => {})) throw new Error('arrow fn should not be generator function'); + }, + }, + { + title: 'Filter generator factories from a mixed array', + description: 'Use as a predicate to select only generator functions.', + code: `const fns = [() => {}, function* () { yield 1; }, async () => {}]; +fns.filter(isGeneratorFunction) +// => [function* () { yield 1; }]`, + assert: () => { + const fns = [() => {}, function* () { yield 1; }, async () => {}]; + const result = fns.filter(isGeneratorFunction); + if (result.length !== 1) throw new Error('Expected exactly one generator function'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/type/isGeneratorFunction.spec.ts b/helpers/type/isGeneratorFunction.spec.ts new file mode 100644 index 00000000..dba078ac --- /dev/null +++ b/helpers/type/isGeneratorFunction.spec.ts @@ -0,0 +1,41 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isGeneratorFunction } from './isGeneratorFunction'; + +describe('isGeneratorFunction — property-based', () => { + it('primitives are never generator functions', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.integer(), fc.boolean()), (value) => { + expect(isGeneratorFunction(value)).toBe(false); + }), + ); + }); +}); + +describe('isGeneratorFunction — contract', () => { + it('null → false', () => expect(isGeneratorFunction(null)).toBe(false)); + it('undefined → false', () => expect(isGeneratorFunction(undefined)).toBe(false)); + it('regular function → false', () => expect(isGeneratorFunction(() => {})).toBe(false)); + it('async function → false', () => expect(isGeneratorFunction(async () => {})).toBe(false)); + it('generator instance → false (not a function)', () => { + function* gen() { yield 1; } + expect(isGeneratorFunction(gen())).toBe(false); + }); + it('async generator function → false', () => { + async function* gen() { yield 1; } + expect(isGeneratorFunction(gen)).toBe(false); + }); + it('function* → true', () => { + function* gen() { yield 1; } + expect(isGeneratorFunction(gen)).toBe(true); + }); + it('function* expression → true', () => { + expect(isGeneratorFunction(function* () { yield 1; })).toBe(true); + }); +}); diff --git a/helpers/type/isGeneratorFunction.test.ts b/helpers/type/isGeneratorFunction.test.ts new file mode 100644 index 00000000..d85e4ec8 --- /dev/null +++ b/helpers/type/isGeneratorFunction.test.ts @@ -0,0 +1,43 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isGeneratorFunction } from './isGeneratorFunction'; + +describe('isGeneratorFunction', () => { + it('should return true for function* declarations', () => { + function* gen() { yield 1; } + expect(isGeneratorFunction(gen)).toBe(true); + }); + + it('should return true for function* expressions', () => { + const gen = function* () { yield 1; }; + expect(isGeneratorFunction(gen)).toBe(true); + }); + + it('should return false for generator instances', () => { + function* gen() { yield 1; } + expect(isGeneratorFunction(gen())).toBe(false); + }); + + it('should return false for async generator functions', () => { + async function* gen() { yield 1; } + expect(isGeneratorFunction(gen)).toBe(false); + }); + + it('should return false for regular functions', () => { + expect(isGeneratorFunction(() => {})).toBe(false); + expect(isGeneratorFunction(function () {})).toBe(false); + expect(isGeneratorFunction(async () => {})).toBe(false); + }); + + it('should return false for null, undefined and other types', () => { + expect(isGeneratorFunction(null)).toBe(false); + expect(isGeneratorFunction(undefined)).toBe(false); + expect(isGeneratorFunction(42)).toBe(false); + expect(isGeneratorFunction({})).toBe(false); + }); +}); diff --git a/helpers/type/isGeneratorFunction.ts b/helpers/type/isGeneratorFunction.ts new file mode 100644 index 00000000..5fdaf5ca --- /dev/null +++ b/helpers/type/isGeneratorFunction.ts @@ -0,0 +1,25 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is a generator function (a `function*` declaration or expression). + * + * Distinct from {@link isGenerator}: this predicate targets the *function* itself, + * not the iterator it produces when called. + * + * @param value - The value to check + * @returns `true` if value is a GeneratorFunction + * @example + * function* gen() { yield 1; } + * isGeneratorFunction(gen) // => true + * isGeneratorFunction(gen()) // => false (instance, not function) + * isGeneratorFunction(() => {}) // => false + * @see {@link isGenerator} + * @since next + */ +export function isGeneratorFunction(value: unknown): value is GeneratorFunction { + return Object.prototype.toString.call(value) === '[object GeneratorFunction]'; +} diff --git a/helpers/type/isNegativeNumber.spec.ts b/helpers/type/isNegativeNumber.spec.ts deleted file mode 100644 index f502e1e9..00000000 --- a/helpers/type/isNegativeNumber.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import * as fc from 'fast-check'; -import { describe, expect, expectTypeOf, it } from 'vitest'; -import { isNegativeNumber } from './isNegativeNumber'; -import { isNumber } from './isNumber'; -import { isPositiveNumber } from './isPositiveNumber'; - -describe('isNegativeNumber — property-based', () => { - it('isNegativeNumber(v) → isNumber(v)', () => { - fc.assert( - fc.property(fc.double({ noNaN: true, min: -1e10, max: -Number.EPSILON }), (v) => { - expect(isNegativeNumber(v)).toBe(true); - expect(isNumber(v)).toBe(true); - }), - ); - }); - - it('isNegativeNumber(v) → !isPositiveNumber(v)', () => { - fc.assert( - fc.property(fc.double({ noNaN: true, min: -1e10, max: -Number.EPSILON }), (v) => { - expect(isNegativeNumber(v)).toBe(true); - expect(isPositiveNumber(v)).toBe(false); - }), - ); - }); -}); - -describe('isNegativeNumber — contract', () => { - it('-1 → true', () => expect(isNegativeNumber(-1)).toBe(true)); - it('-0.1 → true', () => expect(isNegativeNumber(-0.1)).toBe(true)); - it('-Infinity → true', () => expect(isNegativeNumber(-Infinity)).toBe(true)); - it('0 → false', () => expect(isNegativeNumber(0)).toBe(false)); - it('-0 → false (-0 < 0 is false)', () => expect(isNegativeNumber(-0)).toBe(false)); - it('1 → false', () => expect(isNegativeNumber(1)).toBe(false)); - it('NaN → false', () => expect(isNegativeNumber(NaN)).toBe(false)); - it('null → false', () => expect(isNegativeNumber(null)).toBe(false)); -}); - -describe('isNegativeNumber — narrowing in if/else', () => { - it('narrows the value to number in the then-branch', () => { - const v: unknown = -1; - if (isNegativeNumber(v)) { - expectTypeOf(v).toEqualTypeOf(); - expect(v).toBeLessThan(0); - } else { - throw new Error('expected then-branch'); - } - expect(isNegativeNumber(1)).toBe(false); - }); -}); diff --git a/helpers/type/isNegativeNumber.test.ts b/helpers/type/isNegativeNumber.test.ts deleted file mode 100644 index b4481227..00000000 --- a/helpers/type/isNegativeNumber.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import { describe, expect, it } from 'vitest'; -import { isNegativeNumber } from './isNegativeNumber'; - -describe('isNegativeNumber', () => { - it('should return true for negative numbers', () => { - expect(isNegativeNumber(-1)).toBe(true); - expect(isNegativeNumber(-0.5)).toBe(true); - expect(isNegativeNumber(-Infinity)).toBe(true); - }); - - it('should return false for zero', () => { - expect(isNegativeNumber(0)).toBe(false); - }); - - it('should return false for positive numbers', () => { - expect(isNegativeNumber(1)).toBe(false); - expect(isNegativeNumber(0.1)).toBe(false); - }); - - it('should return false for NaN', () => { - expect(isNegativeNumber(NaN)).toBe(false); - }); - - it('should return false for non-numbers', () => { - expect(isNegativeNumber('-1')).toBe(false); - expect(isNegativeNumber(false)).toBe(false); - expect(isNegativeNumber(null)).toBe(false); - expect(isNegativeNumber(undefined)).toBe(false); - }); -}); diff --git a/helpers/type/isNonEmptyArray.spec.ts b/helpers/type/isNonEmptyArray.spec.ts deleted file mode 100644 index 649287d1..00000000 --- a/helpers/type/isNonEmptyArray.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import * as fc from 'fast-check'; -import { describe, expect, expectTypeOf, it } from 'vitest'; -import { isNonEmptyArray } from './isNonEmptyArray'; -import { isArray } from './isArray'; -import { isEmpty } from './isEmpty'; - -describe('isNonEmptyArray — property-based', () => { - it('isNonEmptyArray(v) → isArray(v)', () => { - fc.assert( - fc.property(fc.array(fc.anything(), { minLength: 1 }), (arr) => { - expect(isNonEmptyArray(arr)).toBe(true); - expect(isArray(arr)).toBe(true); - }), - ); - }); - - it('isNonEmptyArray(v) → !isEmpty(v)', () => { - fc.assert( - fc.property(fc.array(fc.anything(), { minLength: 1 }), (arr) => { - expect(isNonEmptyArray(arr)).toBe(true); - expect(isEmpty(arr)).toBe(false); - }), - ); - }); -}); - -describe('isNonEmptyArray — contract', () => { - it('[1] → true', () => expect(isNonEmptyArray([1])).toBe(true)); - it('[undefined] → true (has element even if undefined)', () => expect(isNonEmptyArray([undefined])).toBe(true)); - it('[null] → true', () => expect(isNonEmptyArray([null])).toBe(true)); - it('[] → false', () => expect(isNonEmptyArray([])).toBe(false)); - it('{} → false', () => expect(isNonEmptyArray({})).toBe(false)); - it('null → false', () => expect(isNonEmptyArray(null)).toBe(false)); - it('undefined → false', () => expect(isNonEmptyArray(undefined)).toBe(false)); - it("'abc' → false", () => expect(isNonEmptyArray('abc')).toBe(false)); -}); - -describe('isNonEmptyArray — narrowing in if/else', () => { - it('narrows the value to a non-empty tuple in the then-branch', () => { - const v: unknown = [1]; - if (isNonEmptyArray(v)) { - expectTypeOf(v).toEqualTypeOf<[unknown, ...unknown[]]>(); - expect(v[0]).toBe(1); - } else { - throw new Error('expected then-branch'); - } - expect(isNonEmptyArray([])).toBe(false); - }); -}); diff --git a/helpers/type/isNonEmptyArray.test.ts b/helpers/type/isNonEmptyArray.test.ts deleted file mode 100644 index a04f84e2..00000000 --- a/helpers/type/isNonEmptyArray.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import { describe, expect, it } from 'vitest'; -import { isNonEmptyArray } from './isNonEmptyArray'; - -describe('isNonEmptyArray', () => { - it('should return true for non-empty arrays', () => { - expect(isNonEmptyArray([1])).toBe(true); - expect(isNonEmptyArray([1, 2, 3])).toBe(true); - expect(isNonEmptyArray([undefined])).toBe(true); - }); - - it('should return false for empty array', () => { - expect(isNonEmptyArray([])).toBe(false); - }); - - it('should return false for non-arrays', () => { - expect(isNonEmptyArray('abc')).toBe(false); - expect(isNonEmptyArray(42)).toBe(false); - expect(isNonEmptyArray(null)).toBe(false); - expect(isNonEmptyArray(undefined)).toBe(false); - expect(isNonEmptyArray({})).toBe(false); - expect(isNonEmptyArray(new Set([1]))).toBe(false); - }); -}); diff --git a/helpers/type/isNonEmptyArray.ts b/helpers/type/isNonEmptyArray.ts deleted file mode 100644 index e75a2683..00000000 --- a/helpers/type/isNonEmptyArray.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -/** - * Checks if a value is a non-empty array (length > 0). - * @param value - The value to check - * @returns True if value is an array with at least one element - * @example - * isNonEmptyArray([1, 2]) // => true - * isNonEmptyArray([]) // => false - * isNonEmptyArray('abc') // => false - * isNonEmptyArray(null) // => false - * @since 2.0.0 - */ -export function isNonEmptyArray(value: unknown): value is [unknown, ...unknown[]] { - return Array.isArray(value) && value.length > 0; -} diff --git a/helpers/type/isNonEmptyString.spec.ts b/helpers/type/isNonEmptyString.spec.ts deleted file mode 100644 index 070e2462..00000000 --- a/helpers/type/isNonEmptyString.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import * as fc from 'fast-check'; -import { describe, expect, expectTypeOf, it } from 'vitest'; -import { isNonEmptyString } from './isNonEmptyString'; -import { isString } from './isString'; -import { isEmpty } from './isEmpty'; - -describe('isNonEmptyString — property-based', () => { - it('isNonEmptyString(v) → isString(v)', () => { - fc.assert( - fc.property(fc.string({ minLength: 1 }), (s) => { - expect(isNonEmptyString(s)).toBe(true); - expect(isString(s)).toBe(true); - }), - ); - }); - - it('isNonEmptyString(v) → !isEmpty(v)', () => { - fc.assert( - fc.property(fc.string({ minLength: 1 }), (s) => { - expect(isNonEmptyString(s)).toBe(true); - expect(isEmpty(s)).toBe(false); - }), - ); - }); -}); - -describe('isNonEmptyString — contract', () => { - it("'a' → true", () => expect(isNonEmptyString('a')).toBe(true)); - it("' ' → true (space is non-empty)", () => expect(isNonEmptyString(' ')).toBe(true)); - it("'hello world' → true", () => expect(isNonEmptyString('hello world')).toBe(true)); - it("'' → false", () => expect(isNonEmptyString('')).toBe(false)); - it('null → false', () => expect(isNonEmptyString(null)).toBe(false)); - it('undefined → false', () => expect(isNonEmptyString(undefined)).toBe(false)); - it('0 → false', () => expect(isNonEmptyString(0)).toBe(false)); - it('[] → false', () => expect(isNonEmptyString([])).toBe(false)); -}); - -describe('isNonEmptyString — narrowing in if/else', () => { - it('narrows the value to string in the then-branch', () => { - const v: unknown = 'a'; - if (isNonEmptyString(v)) { - expectTypeOf(v).toEqualTypeOf(); - expect(v.length).toBe(1); - } else { - throw new Error('expected then-branch'); - } - expect(isNonEmptyString('')).toBe(false); - }); -}); diff --git a/helpers/type/isNonEmptyString.test.ts b/helpers/type/isNonEmptyString.test.ts deleted file mode 100644 index 18d6d58e..00000000 --- a/helpers/type/isNonEmptyString.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import { describe, expect, it } from 'vitest'; -import { isNonEmptyString } from './isNonEmptyString'; - -describe('isNonEmptyString', () => { - it('should return true for non-empty strings', () => { - expect(isNonEmptyString('hello')).toBe(true); - expect(isNonEmptyString(' ')).toBe(true); - expect(isNonEmptyString('0')).toBe(true); - }); - - it('should return false for empty string', () => { - expect(isNonEmptyString('')).toBe(false); - }); - - it('should return false for non-strings', () => { - expect(isNonEmptyString(42)).toBe(false); - expect(isNonEmptyString(true)).toBe(false); - expect(isNonEmptyString(null)).toBe(false); - expect(isNonEmptyString(undefined)).toBe(false); - expect(isNonEmptyString([])).toBe(false); - expect(isNonEmptyString({})).toBe(false); - }); -}); diff --git a/helpers/type/isNonEmptyString.ts b/helpers/type/isNonEmptyString.ts deleted file mode 100644 index 658e2904..00000000 --- a/helpers/type/isNonEmptyString.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -/** - * Checks if a value is a non-empty string (length > 0). - * @param value - The value to check - * @returns True if value is a string with at least one character - * @example - * isNonEmptyString('hello') // => true - * isNonEmptyString('') // => false - * isNonEmptyString(42) // => false - * isNonEmptyString(null) // => false - * @since 2.0.0 - */ -export function isNonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.length > 0; -} diff --git a/helpers/type/isPositiveNumber.spec.ts b/helpers/type/isPositiveNumber.spec.ts deleted file mode 100644 index e1b1a6e5..00000000 --- a/helpers/type/isPositiveNumber.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import * as fc from 'fast-check'; -import { describe, expect, expectTypeOf, it } from 'vitest'; -import { isPositiveNumber } from './isPositiveNumber'; -import { isNumber } from './isNumber'; -import { isNegativeNumber } from './isNegativeNumber'; - -describe('isPositiveNumber — property-based', () => { - it('isPositiveNumber(v) → isNumber(v)', () => { - fc.assert( - fc.property(fc.double({ noNaN: true, min: Number.EPSILON, max: 1e10 }), (v) => { - expect(isPositiveNumber(v)).toBe(true); - expect(isNumber(v)).toBe(true); - }), - ); - }); - - it('isPositiveNumber(v) → !isNegativeNumber(v)', () => { - fc.assert( - fc.property(fc.double({ noNaN: true, min: Number.EPSILON, max: 1e10 }), (v) => { - expect(isPositiveNumber(v)).toBe(true); - expect(isNegativeNumber(v)).toBe(false); - }), - ); - }); -}); - -describe('isPositiveNumber — contract', () => { - it('1 → true', () => expect(isPositiveNumber(1)).toBe(true)); - it('0.1 → true', () => expect(isPositiveNumber(0.1)).toBe(true)); - it('Infinity → true', () => expect(isPositiveNumber(Infinity)).toBe(true)); - it('0 → false', () => expect(isPositiveNumber(0)).toBe(false)); - it('-1 → false', () => expect(isPositiveNumber(-1)).toBe(false)); - it('NaN → false', () => expect(isPositiveNumber(NaN)).toBe(false)); - it('-Infinity → false', () => expect(isPositiveNumber(-Infinity)).toBe(false)); - it('null → false', () => expect(isPositiveNumber(null)).toBe(false)); -}); - -describe('isPositiveNumber — narrowing in if/else', () => { - it('narrows the value to number in the then-branch', () => { - const v: unknown = 1; - if (isPositiveNumber(v)) { - expectTypeOf(v).toEqualTypeOf(); - expect(v).toBeGreaterThan(0); - } else { - throw new Error('expected then-branch'); - } - expect(isPositiveNumber(-1)).toBe(false); - }); -}); diff --git a/helpers/type/isPositiveNumber.test.ts b/helpers/type/isPositiveNumber.test.ts deleted file mode 100644 index e6bd8796..00000000 --- a/helpers/type/isPositiveNumber.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import { describe, expect, it } from 'vitest'; -import { isPositiveNumber } from './isPositiveNumber'; - -describe('isPositiveNumber', () => { - it('should return true for positive numbers', () => { - expect(isPositiveNumber(1)).toBe(true); - expect(isPositiveNumber(42)).toBe(true); - expect(isPositiveNumber(0.1)).toBe(true); - expect(isPositiveNumber(Infinity)).toBe(true); - }); - - it('should return false for zero', () => { - expect(isPositiveNumber(0)).toBe(false); - }); - - it('should return false for negative numbers', () => { - expect(isPositiveNumber(-1)).toBe(false); - expect(isPositiveNumber(-0.1)).toBe(false); - }); - - it('should return false for NaN', () => { - expect(isPositiveNumber(NaN)).toBe(false); - }); - - it('should return false for non-numbers', () => { - expect(isPositiveNumber('42')).toBe(false); - expect(isPositiveNumber(true)).toBe(false); - expect(isPositiveNumber(null)).toBe(false); - expect(isPositiveNumber(undefined)).toBe(false); - }); -}); diff --git a/helpers/type/isPromiseLike.example.ts b/helpers/type/isPromiseLike.example.ts new file mode 100644 index 00000000..7f6d0a00 --- /dev/null +++ b/helpers/type/isPromiseLike.example.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isPromiseLike } from './isPromiseLike'; + +const examples: HelperExamples = { + helper: 'isPromiseLike', + category: 'type', + examples: [ + { + title: 'Detect any thenable', + description: 'Returns true for native Promises and any object with a .then() method.', + code: `isPromiseLike(Promise.resolve(1)) // => true +isPromiseLike({ then: () => {} }) // => true (thenable) +isPromiseLike(42) // => false +isPromiseLike(null) // => false +isPromiseLike({ then: 'not-a-fn' }) // => false`, + assert: () => { + if (!isPromiseLike(Promise.resolve(1))) throw new Error('Promise should be PromiseLike'); + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection + if (!isPromiseLike({ then: () => {} })) throw new Error('thenable should be PromiseLike'); + if (isPromiseLike(42)) throw new Error('number should not be PromiseLike'); + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection + if (isPromiseLike({ then: 'not-a-fn' })) throw new Error('non-fn then should return false'); + }, + }, + { + title: 'Handle both Promises and thenables in a utility', + description: 'Use isPromiseLike to accept any thenable, not just native Promises.', + code: `function toPromise(value: T | PromiseLike): Promise { + if (isPromiseLike(value)) return Promise.resolve(value); + return Promise.resolve(value); +}`, + assert: () => { + if (!isPromiseLike(new Promise(() => {}))) throw new Error('Promise should pass'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/type/isPromiseLike.spec.ts b/helpers/type/isPromiseLike.spec.ts new file mode 100644 index 00000000..2aa0d0ef --- /dev/null +++ b/helpers/type/isPromiseLike.spec.ts @@ -0,0 +1,38 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isPromiseLike } from './isPromiseLike'; + +describe('isPromiseLike — property-based', () => { + it('primitives are never PromiseLike', () => { + fc.assert( + fc.property(fc.oneof(fc.string(), fc.integer(), fc.boolean()), (value) => { + expect(isPromiseLike(value)).toBe(false); + }), + ); + }); +}); + +describe('isPromiseLike — contract', () => { + it('null → false', () => expect(isPromiseLike(null)).toBe(false)); + it('undefined → false', () => expect(isPromiseLike(undefined)).toBe(false)); + it('Promise.resolve() → true', () => expect(isPromiseLike(Promise.resolve())).toBe(true)); + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection + it('{ then: fn } → true', () => expect(isPromiseLike({ then: () => {} })).toBe(true)); + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection + it('{ then: non-fn } → false', () => expect(isPromiseLike({ then: 42 })).toBe(false)); + it('{} → false', () => expect(isPromiseLike({})).toBe(false)); + it('function with .then → true', () => { + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection + const fn = Object.assign(() => {}, { then: () => {} }); + expect(isPromiseLike(fn)).toBe(true); + }); + it('function without .then → false', () => { + expect(isPromiseLike(() => {})).toBe(false); + }); +}); diff --git a/helpers/type/isPromiseLike.test.ts b/helpers/type/isPromiseLike.test.ts new file mode 100644 index 00000000..6d4600d8 --- /dev/null +++ b/helpers/type/isPromiseLike.test.ts @@ -0,0 +1,55 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isPromiseLike } from './isPromiseLike'; + +describe('isPromiseLike', () => { + it('should return true for native Promises', () => { + expect(isPromiseLike(Promise.resolve(1))).toBe(true); + expect(isPromiseLike(new Promise(() => {}))).toBe(true); + expect(isPromiseLike(Promise.reject(new Error('x')).catch(() => {}))).toBe(true); + }); + + it('should return true for objects with a then method', () => { + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection + expect(isPromiseLike({ then: () => {} })).toBe(true); + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection + expect(isPromiseLike({ then: () => {}, catch: () => {} })).toBe(true); + }); + + it('should return true for functions with a then method', () => { + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection + const fn = Object.assign(() => {}, { then: () => {} }); + expect(isPromiseLike(fn)).toBe(true); + }); + + it('should return false when then is not a function', () => { + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection + expect(isPromiseLike({ then: 'not-a-function' })).toBe(false); + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection + expect(isPromiseLike({ then: null })).toBe(false); + // eslint-disable-next-line unicorn/no-thenable -- Testing thenable detection + expect(isPromiseLike({ then: 42 })).toBe(false); + }); + + it('should return false for null', () => { + expect(isPromiseLike(null)).toBe(false); + }); + + it('should return false for primitives', () => { + expect(isPromiseLike(42)).toBe(false); + expect(isPromiseLike('hello')).toBe(false); + expect(isPromiseLike(true)).toBe(false); + expect(isPromiseLike(Symbol('x'))).toBe(false); + expect(isPromiseLike(undefined)).toBe(false); + }); + + it('should return false for objects without then', () => { + expect(isPromiseLike({})).toBe(false); + expect(isPromiseLike([])).toBe(false); + }); +}); diff --git a/helpers/type/isPromiseLike.ts b/helpers/type/isPromiseLike.ts new file mode 100644 index 00000000..61a6926e --- /dev/null +++ b/helpers/type/isPromiseLike.ts @@ -0,0 +1,34 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is a thenable (has a `.then()` method). + * + * Looser than {@link isPromise}: accepts any object or function with a `then` + * method, including non-standard Promise implementations without `.catch()`. + * Follows the Promise/A+ specification for thenables. + * + * @param value - The value to check + * @returns `true` if value is a PromiseLike (thenable) + * @example + * isPromiseLike(Promise.resolve(1)) // => true + * isPromiseLike({ then: () => {} }) // => true (thenable) + * isPromiseLike({ then: 'not-a-function' }) // => false + * isPromiseLike(42) // => false + * isPromiseLike(null) // => false + * @see {@link isPromise} for a stricter check that also requires `.catch()` + * @since next + */ +export function isPromiseLike(value: unknown): value is PromiseLike { + return ( + // Unlike isNodeStream/isObservable, also accepts `function`: a callable + // exposing `.then()` (e.g. an async function with a `then` property attached) + // is a realistic thenable shape per the Promise/A+ spec. + value !== null && + (typeof value === 'object' || typeof value === 'function') && + typeof (value as PromiseLike).then === 'function' + ); +} diff --git a/helpers/type/isPropertyKey.example.ts b/helpers/type/isPropertyKey.example.ts new file mode 100644 index 00000000..c6b42279 --- /dev/null +++ b/helpers/type/isPropertyKey.example.ts @@ -0,0 +1,51 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import type { HelperExamples } from '../../scripts/examples/types'; +import { isPropertyKey } from './isPropertyKey'; + +const examples: HelperExamples = { + helper: 'isPropertyKey', + category: 'type', + examples: [ + { + title: 'Detect valid property keys', + description: 'Strings, numbers, and symbols are valid property keys.', + code: `isPropertyKey('name') // => true +isPropertyKey(42) // => true +isPropertyKey(Symbol('id')) // => true +isPropertyKey(null) // => false +isPropertyKey(true) // => false`, + assert: () => { + if (!isPropertyKey('name')) throw new Error("'name' should be a property key"); + if (!isPropertyKey(42)) throw new Error('42 should be a property key'); + if (!isPropertyKey(Symbol('id'))) throw new Error('Symbol should be a property key'); + if (isPropertyKey(null)) throw new Error('null should not be a property key'); + if (isPropertyKey(true)) throw new Error('boolean should not be a property key'); + }, + }, + { + title: 'Safe dynamic property access', + description: 'Use as a guard before indexing an object with an unknown key.', + code: `function get(obj: Record, key: unknown): unknown { + if (isPropertyKey(key)) return obj[key]; + return undefined; +} +get({ a: 1 }, 'a') // => 1 +get({ a: 1 }, null) // => undefined`, + assert: () => { + function get(obj: Record, key: unknown): unknown { + if (isPropertyKey(key)) return obj[key]; + return undefined; + } + if (get({ a: 1 }, 'a') !== 1) throw new Error('Expected 1'); + if (get({ a: 1 }, null) !== undefined) throw new Error('Expected undefined'); + }, + }, + ], +}; + +export default examples; diff --git a/helpers/type/isPropertyKey.spec.ts b/helpers/type/isPropertyKey.spec.ts new file mode 100644 index 00000000..e5908fab --- /dev/null +++ b/helpers/type/isPropertyKey.spec.ts @@ -0,0 +1,48 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { isPropertyKey } from './isPropertyKey'; + +describe('isPropertyKey — property-based', () => { + it('any string is a property key', () => { + fc.assert( + fc.property(fc.string(), (value) => { + expect(isPropertyKey(value)).toBe(true); + }), + ); + }); + + it('any number is a property key', () => { + fc.assert( + fc.property(fc.float({ noNaN: false }), (value) => { + expect(isPropertyKey(value)).toBe(true); + }), + ); + }); + + it('booleans are never property keys', () => { + fc.assert( + fc.property(fc.boolean(), (value) => { + expect(isPropertyKey(value)).toBe(false); + }), + ); + }); +}); + +describe('isPropertyKey — contract', () => { + it('"" → true', () => expect(isPropertyKey('')).toBe(true)); + it('0 → true', () => expect(isPropertyKey(0)).toBe(true)); + it('NaN → true (NaN is a number)', () => expect(isPropertyKey(NaN)).toBe(true)); + it('Symbol() → true', () => expect(isPropertyKey(Symbol())).toBe(true)); + it('Symbol.iterator → true', () => expect(isPropertyKey(Symbol.iterator)).toBe(true)); + it('null → false', () => expect(isPropertyKey(null)).toBe(false)); + it('undefined → false', () => expect(isPropertyKey(undefined)).toBe(false)); + it('{} → false', () => expect(isPropertyKey({})).toBe(false)); + it('[] → false', () => expect(isPropertyKey([])).toBe(false)); + it('BigInt → false', () => expect(isPropertyKey(BigInt(1))).toBe(false)); +}); diff --git a/helpers/type/isPropertyKey.test.ts b/helpers/type/isPropertyKey.test.ts new file mode 100644 index 00000000..68afac7a --- /dev/null +++ b/helpers/type/isPropertyKey.test.ts @@ -0,0 +1,50 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest'; +import { isPropertyKey } from './isPropertyKey'; + +describe('isPropertyKey', () => { + it('should return true for strings', () => { + expect(isPropertyKey('')).toBe(true); + expect(isPropertyKey('name')).toBe(true); + expect(isPropertyKey('0')).toBe(true); + }); + + it('should return true for numbers', () => { + expect(isPropertyKey(0)).toBe(true); + expect(isPropertyKey(42)).toBe(true); + expect(isPropertyKey(-1)).toBe(true); + expect(isPropertyKey(NaN)).toBe(true); + expect(isPropertyKey(Infinity)).toBe(true); + }); + + it('should return true for symbols', () => { + expect(isPropertyKey(Symbol('id'))).toBe(true); + expect(isPropertyKey(Symbol.iterator)).toBe(true); + expect(isPropertyKey(Symbol.for('key'))).toBe(true); + }); + + it('should return false for null and undefined', () => { + expect(isPropertyKey(null)).toBe(false); + expect(isPropertyKey(undefined)).toBe(false); + }); + + it('should return false for booleans', () => { + expect(isPropertyKey(true)).toBe(false); + expect(isPropertyKey(false)).toBe(false); + }); + + it('should return false for objects and arrays', () => { + expect(isPropertyKey({})).toBe(false); + expect(isPropertyKey([])).toBe(false); + expect(isPropertyKey(() => {})).toBe(false); + }); + + it('should return false for bigint', () => { + expect(isPropertyKey(BigInt(1))).toBe(false); + }); +}); diff --git a/helpers/type/isPropertyKey.ts b/helpers/type/isPropertyKey.ts new file mode 100644 index 00000000..f4421d7a --- /dev/null +++ b/helpers/type/isPropertyKey.ts @@ -0,0 +1,24 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: LGPL-3.0-or-later + */ + +/** + * Checks if a value is a valid property key: `string`, `number`, or `symbol`. + * + * @param value - The value to check + * @returns `true` if value can be used as an object property key + * @example + * isPropertyKey('name') // => true + * isPropertyKey(42) // => true + * isPropertyKey(Symbol('id')) // => true + * isPropertyKey(null) // => false + * isPropertyKey({}) // => false + * isPropertyKey(true) // => false + * @since next + */ +export function isPropertyKey(value: unknown): value is PropertyKey { + const t = typeof value; + return t === 'string' || t === 'number' || t === 'symbol'; +} diff --git a/helpers/type/isTimestamp.ts b/helpers/type/isTimestamp.ts index a89cc075..1ff18a8e 100644 --- a/helpers/type/isTimestamp.ts +++ b/helpers/type/isTimestamp.ts @@ -36,7 +36,7 @@ const MAX_UNIX_SECONDS = 7258118400; * isTimestamp('1609459200') // => false (not a number) * * @see {@link isDate} for checking if a value is a Date instance - * @see {@link isValidDate} for checking if a Date instance is valid + * @see `date/isValid` for checking if a Date instance is valid * @since 2.0.0 */ export function isTimestamp(value: unknown): value is number { diff --git a/helpers/type/isValidDate.spec.ts b/helpers/type/isValidDate.spec.ts deleted file mode 100644 index 068fe273..00000000 --- a/helpers/type/isValidDate.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import * as fc from 'fast-check'; -import { describe, expect, expectTypeOf, it } from 'vitest'; -import { isValidDate } from './isValidDate'; -import { isDate } from './isDate'; - -describe('isValidDate — property-based', () => { - it('isValidDate(v) → isDate(v)', () => { - fc.assert( - fc.property(fc.date(), (d) => { - if (isValidDate(d)) { - expect(isDate(d)).toBe(true); - } - }), - ); - }); - - it('valid dates have finite getTime()', () => { - fc.assert( - fc.property(fc.date(), (d) => { - if (isValidDate(d)) { - expect(Number.isFinite(d.getTime())).toBe(true); - } - }), - ); - }); -}); - -describe('isValidDate — contract', () => { - it('new Date() → true', () => expect(isValidDate(new Date())).toBe(true)); - it('new Date(0) → true', () => expect(isValidDate(new Date(0))).toBe(true)); - it("new Date('2024-01-01') → true", () => expect(isValidDate(new Date('2024-01-01'))).toBe(true)); - it("new Date('invalid') → false", () => expect(isValidDate(new Date('invalid'))).toBe(false)); - it("'date string' → false", () => expect(isValidDate('2024-01-01')).toBe(false)); - it('null → false', () => expect(isValidDate(null)).toBe(false)); - it('undefined → false', () => expect(isValidDate(undefined)).toBe(false)); - it('Date.now() (number) → false', () => expect(isValidDate(Date.now())).toBe(false)); -}); - -describe('isValidDate — narrowing in if/else', () => { - it('narrows the value to Date in the then-branch', () => { - const v: unknown = new Date('2024-01-01'); - if (isValidDate(v)) { - expectTypeOf(v).toEqualTypeOf(); - expect(Number.isFinite(v.getTime())).toBe(true); - } else { - throw new Error('expected then-branch'); - } - expect(isValidDate(new Date('not-a-date'))).toBe(false); - }); -}); diff --git a/helpers/type/isValidDate.test.ts b/helpers/type/isValidDate.test.ts deleted file mode 100644 index 10a0269f..00000000 --- a/helpers/type/isValidDate.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -import { describe, expect, it } from 'vitest'; -import { isValidDate } from './isValidDate'; - -describe('isValidDate', () => { - it('should return true for valid Date instances', () => { - expect(isValidDate(new Date())).toBe(true); - expect(isValidDate(new Date('2023-01-01'))).toBe(true); - expect(isValidDate(new Date(0))).toBe(true); - }); - - it('should return false for invalid Date instances', () => { - expect(isValidDate(new Date('invalid'))).toBe(false); - expect(isValidDate(new Date(NaN))).toBe(false); - }); - - it('should return false for non-Date values', () => { - expect(isValidDate('2023-01-01')).toBe(false); - expect(isValidDate(1609459200000)).toBe(false); - expect(isValidDate(null)).toBe(false); - expect(isValidDate(undefined)).toBe(false); - expect(isValidDate({})).toBe(false); - }); -}); diff --git a/helpers/type/isValidDate.ts b/helpers/type/isValidDate.ts deleted file mode 100644 index 5c827d1d..00000000 --- a/helpers/type/isValidDate.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * This file is part of helpers4. - * Copyright (C) 2025 baxyz - * SPDX-License-Identifier: LGPL-3.0-or-later - */ - -/** - * Checks if a value is a valid Date instance (not `Invalid Date`). - * - * Unlike {@link isDate}, this also verifies that the internal timestamp is not `NaN`. - * - * @param value - The value to check - * @returns True if value is a Date instance with a valid time value - * @example - * isValidDate(new Date()) // => true - * isValidDate(new Date('invalid')) // => false - * isValidDate('2023-01-01') // => false (not a Date instance) - * - * @see {@link isDate} for checking only if value is a Date instance - * @see {@link isTimestamp} for checking if a number is a valid timestamp - * @since 2.0.0 - */ -export function isValidDate(value: unknown): value is Date { - return value instanceof Date && !Number.isNaN(value.getTime()); -} diff --git a/package.json b/package.json index dd6101aa..3032cfcd 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,10 @@ "node": ">=24.0.0", "browser": "ES2022+" }, + "runtimes": { + "node": ">=20.0.0", + "browser": "ES2022+" + }, "pnpm": { "overrides": { "postcss": ">=8.5.10", @@ -102,7 +106,9 @@ "brace-expansion": ">=5.0.6", "qs": ">=6.15.2", "ws": ">=8.20.1", - "esbuild": ">=0.28.1" + "esbuild": ">=0.28.1", + "@babel/core": ">=7.29.6", + "markdown-it": ">=14.2.0" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31fbbc79..8165684c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,8 @@ overrides: qs: '>=6.15.2' ws: '>=8.20.1' esbuild: '>=0.28.1' + '@babel/core': '>=7.29.6' + markdown-it: '>=14.2.0' importers: @@ -83,36 +85,48 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} '@babel/helper-create-class-features-plugin@7.28.6': resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@7.28.5': resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} engines: {node: '>=6.9.0'} @@ -121,11 +135,21 @@ packages: resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-transforms@7.28.6': resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': '>=7.29.6' '@babel/helper-optimise-call-expression@7.27.1': resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} @@ -139,7 +163,7 @@ packages: resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/helper-skip-transparent-expression-wrappers@7.27.1': resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} @@ -161,8 +185,12 @@ packages: resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} '@babel/parser@7.29.2': @@ -179,64 +207,72 @@ packages: resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-decorators@7.28.6': resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-jsx@7.28.6': resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-typescript@7.28.6': resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-destructuring@7.28.5': resolution: {integrity: sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-explicit-resource-management@7.28.6': resolution: {integrity: sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-modules-commonjs@7.28.6': resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-typescript@7.28.6': resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/preset-typescript@7.28.5': resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -1509,8 +1545,8 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - linkify-it@5.0.0: - resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + linkify-it@5.0.1: + resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} lodash.groupby@4.6.0: resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} @@ -1535,8 +1571,8 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - markdown-it@14.1.1: - resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + markdown-it@14.2.0: + resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==} hasBin: true math-intrinsics@1.1.0: @@ -1963,18 +1999,24 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.0': + '@babel/core@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 @@ -1993,25 +2035,33 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': dependencies: '@babel/types': 7.29.7 - '@babel/helper-compilation-targets@7.28.6': + '@babel/helper-compilation-targets@7.29.7': dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/traverse': 7.29.0 semver: 6.3.1 @@ -2020,6 +2070,8 @@ snapshots: '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} + '@babel/helper-member-expression-to-functions@7.28.5': dependencies: '@babel/traverse': 7.29.0 @@ -2034,24 +2086,40 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/core': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.28.6 '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/helper-optimise-call-expression@7.27.1': dependencies: '@babel/types': 7.29.7 '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 '@babel/traverse': 7.29.0 @@ -2073,9 +2141,11 @@ snapshots: '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.29.2': + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.28.6 + '@babel/template': 7.29.7 '@babel/types': 7.29.7 '@babel/parser@7.29.2': @@ -2086,73 +2156,73 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + '@babel/preset-typescript@7.28.5(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7) transitivePeerDependencies: - supports-color @@ -2162,6 +2232,12 @@ snapshots: '@babel/parser': 7.29.2 '@babel/types': 7.29.7 + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -2174,6 +2250,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -2680,12 +2768,12 @@ snapshots: '@stryker-mutator/instrumenter@9.6.1': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/generator': 7.29.1 '@babel/parser': 7.29.2 - '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) - '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.7) + '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.7) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) '@stryker-mutator/api': 9.6.1 '@stryker-mutator/util': 9.6.1 angular-html-parser: 10.4.0 @@ -3211,7 +3299,7 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - linkify-it@5.0.0: + linkify-it@5.0.1: dependencies: uc.micro: 2.1.0 @@ -3239,11 +3327,11 @@ snapshots: dependencies: semver: 7.8.3 - markdown-it@14.1.1: + markdown-it@14.2.0: dependencies: argparse: 2.0.1 entities: 4.5.0 - linkify-it: 5.0.0 + linkify-it: 5.0.1 mdurl: 2.0.0 punycode.js: 2.3.1 uc.micro: 2.1.0 @@ -3514,7 +3602,7 @@ snapshots: dependencies: '@gerrit0/mini-shiki': 3.23.0 lunr: 2.3.9 - markdown-it: 14.1.1 + markdown-it: 14.2.0 minimatch: 10.2.5 typescript: 6.0.3 yaml: 2.8.3 diff --git a/scripts/build/build-website-metadata.ts b/scripts/build/build-website-metadata.ts index f35f0310..1e3b65cb 100644 --- a/scripts/build/build-website-metadata.ts +++ b/scripts/build/build-website-metadata.ts @@ -5,6 +5,7 @@ */ import { readdir } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { ensureDir } from 'fs-extra'; import { @@ -213,6 +214,59 @@ function processSignature(sig: SignatureReflection): WebsiteSignature { }; } +// Caches source file contents so a file with multiple exported type aliases +// is only read from disk once. +const sourceFileCache = new Map(); + +function readSourceCached(path: string): string { + let src = sourceFileCache.get(path); + if (src === undefined) { + src = readFileSync(path, 'utf-8'); + sourceFileCache.set(path, src); + } + return src; +} + +/** + * Finds the index of the `;` that terminates a type alias starting at `start`, + * skipping over brackets/semicolons that appear inside string/template literals + * or comments (e.g. a string-literal type like `'{' | '}' | ';'`). + */ +function findTopLevelSemicolon(src: string, start: number): number { + let depth = 0; + let quote: string | null = null; + let inLineComment = false; + let inBlockComment = false; + + for (let i = start; i < src.length; i++) { + const ch = src[i]; + const next = src[i + 1]; + + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (inBlockComment) { + if (ch === '*' && next === '/') { inBlockComment = false; i++; } + continue; + } + if (quote) { + if (ch === '\\') { i++; } + else if (ch === quote) { quote = null; } + continue; + } + + if (ch === '/' && next === '/') { inLineComment = true; i++; continue; } + if (ch === '/' && next === '*') { inBlockComment = true; i++; continue; } + if (ch === "'" || ch === '"' || ch === '`') { quote = ch; continue; } + + if (ch === '{' || ch === '(' || ch === '[') depth++; + else if (ch === '}' || ch === ')' || ch === ']') depth--; + else if (ch === ';' && depth === 0) return i; + } + return src.length; +} + function processMember(child: DeclarationReflection): WebsiteFunction | undefined { const kindMap: Record = { [ReflectionKind.Function]: 'function', @@ -235,24 +289,49 @@ function processMember(child: DeclarationReflection): WebsiteFunction | undefine const description = extractText(comment?.summary as Array<{ kind: string; text: string }> | undefined); const since = extractTagText(comment?.blockTags as CommentTag[] | undefined, '@since') ?? 'unknown'; + + // Double-safety: exclude anything without an explicit @since tag. + // Primary guard is @internal + excludeInternal:true in TypeDoc options. + if (since === 'unknown') return undefined; + const examples = extractExamples(comment?.blockTags as CommentTag[] | undefined); - // For type aliases: build the `type Name = ...` definition string + // For type aliases: read the definition verbatim from the source file so that + // complex constructs (conditional types, mapped types, etc.) render correctly. // For interfaces: build the `interface Name { ... }` definition string let typeDefinition: string | undefined; - if (kind === 'type' && (child as unknown as Record).type) { - const rawType = (child as unknown as Record).type; - const typeStr = serializeType(rawType); - const typeParams = child.typeParameters - ?.map((tp: TypeParameterReflection) => { - let s = tp.name; - if (tp.type) s += ` extends ${serializeType(tp.type)}`; - if (tp.default) s += ` = ${serializeType(tp.default)}`; - return s; - }) - .join(', '); - const generics = typeParams ? `<${typeParams}>` : ''; - typeDefinition = `type ${child.name}${generics} = ${typeStr}`; + if (kind === 'type') { + const srcRef = child.sources?.[0] as ({ fullFileName?: string } & object) | undefined; + const srcPath = srcRef?.fullFileName; + if (srcPath) { + try { + const src = readSourceCached(srcPath); + // Find the specific declaration: `export type NAME` → extract from `type NAME` + // up to the closing `;` at brace-depth 0 (handles multi-line conditional types). + const exportStart = src.indexOf(`export type ${child.name}`); + if (exportStart !== -1) { + const typeStart = exportStart + 'export '.length; + const end = findTopLevelSemicolon(src, typeStart); + typeDefinition = src.slice(typeStart, end).trimEnd(); + } + } catch { + // fallback to serialized form + } + } + if (!typeDefinition && (child as unknown as Record).type) { + const rawType = (child as unknown as Record).type; + const typeStr = serializeType(rawType); + const typeParams = child.typeParameters + ?.map((tp: TypeParameterReflection) => { + let s = tp.name; + if (tp.type) s += ` extends ${serializeType(tp.type)}`; + if (tp.default) s += ` = ${serializeType(tp.default)}`; + return s; + }) + .join(', '); + const generics = typeParams ? `<${typeParams}>` : ''; + typeDefinition = `type ${child.name}${generics} = ${typeStr}`; + } } else if (kind === 'interface') { const members = ((child as unknown as Record).children as DeclarationReflection[] | undefined) ?? []; const memberDefinitions = members.flatMap(m => { diff --git a/scripts/build/helpers/create-bundle-metadata.ts b/scripts/build/helpers/create-bundle-metadata.ts index f69d9c39..f826fe44 100644 --- a/scripts/build/helpers/create-bundle-metadata.ts +++ b/scripts/build/helpers/create-bundle-metadata.ts @@ -57,14 +57,14 @@ export async function createBundleMetadata( return `https://dashboard.stryker-mutator.io/reports/github.com/${parsed.slug}/v${version}`; })(); - // Runtime compatibility — read from package.json engines field. - // Deno and Bun are structurally compatible (ESM-only, no native addons) with no per-version constraints. - const engines = (rootPackage.engines as Record | undefined) ?? {}; + // Consumer runtime compatibility — read from package.json "runtimes" field (not "engines", + // which tracks the dev/build tooling requirement and may be higher than the consumer minimum). + const runtimesField = (rootPackage.runtimes as Record | undefined) ?? {}; const runtimes = { - node: engines.node ?? '>=24.0.0', + node: runtimesField.node ?? '>=20.0.0', deno: 'compatible', bun: 'compatible', - browser: engines.browser ?? 'ES2022+', + browser: runtimesField.browser ?? 'ES2022+', }; // Create build.json with build metadata diff --git a/scripts/build/helpers/get-external-dependencies.helper.ts b/scripts/build/helpers/get-external-dependencies.helper.ts index 7c158fc8..ef9ae874 100644 --- a/scripts/build/helpers/get-external-dependencies.helper.ts +++ b/scripts/build/helpers/get-external-dependencies.helper.ts @@ -5,10 +5,20 @@ */ import { readdir } from "node:fs/promises"; +import { builtinModules } from "node:module"; import { join } from "node:path"; import { DIR } from "../../constants"; import { readFileText } from "../../utils"; +/** + * Strips `/* ... *\/` and `// ...` comments from source text so that import-like + * text inside JSDoc `@example` blocks (e.g. `* import { x } from 'pkg';`) is not + * mistaken for a real import statement. + */ +function stripComments(content: string): string { + return content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); +} + /** * Analyzes the external packages used in a specific category by parsing import statements * @param categoryName - The name of the category (e.g., 'string', 'url', 'observable') @@ -25,7 +35,7 @@ export async function getExternalDependencies(categoryName: string): Promise