Skip to content

Commit

Permalink
Add smarter types
Browse files Browse the repository at this point in the history
  • Loading branch information
wooorm committed Jul 7, 2023
1 parent 9039274 commit 21144ef
Show file tree
Hide file tree
Showing 4 changed files with 189 additions and 61 deletions.
42 changes: 42 additions & 0 deletions index.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {expectType} from 'tsd'
import type {
Heading,
PhrasingContent,
Root,
RootContent,
RowContent,
TableCell,
TableRow,
Text
} from 'mdast'
import {findBefore} from './index.js'

const text: Text = {type: 'text', value: 'alpha'}
const heading: Heading = {type: 'heading', depth: 1, children: [text]}
const root: Root = {type: 'root', children: [heading]}
const cell: TableCell = {type: 'tableCell', children: [text]}
const row: TableRow = {type: 'tableRow', children: [cell]}

// @ts-expect-error: parent needed.
findBefore()

// @ts-expect-error: child or index needed.
findBefore(heading)

findBefore(
// @ts-expect-error: parent needed.
text,
0
)

expectType<PhrasingContent | undefined>(findBefore(heading, text))

expectType<Text | undefined>(findBefore(heading, text, 'text'))

expectType<Text | undefined>(findBefore(heading, 0, 'text'))

expectType<RootContent | undefined>(findBefore(root, 0))

expectType<Text | undefined>(findBefore(root, 0, 'text'))

expectType<RowContent | undefined>(findBefore(row, 0))
175 changes: 124 additions & 51 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,73 @@
/**
* @typedef {import('unist').Node} Node
* @typedef {import('unist').Parent} Parent
* @typedef {import('unist-util-is').Test} Test
* @typedef {import('unist').Node} UnistNode
* @typedef {import('unist').Parent} UnistParent
*/

/**
* @typedef {Exclude<import('unist-util-is').Test, undefined> | undefined} Test
* Test from `unist-util-is`.
*
* Note: we have remove and add `undefined`, because otherwise when generating
* automatic `.d.ts` files, TS tries to flatten paths from a local perspective,
* which doesn’t work when publishing on npm.
*/

/**
* @typedef {(
* Fn extends (value: any) => value is infer Thing
* ? Thing
* : Fallback
* )} Predicate
* Get the value of a type guard `Fn`.
* @template Fn
* Value; typically function that is a type guard (such as `(x): x is Y`).
* @template Fallback
* Value to yield if `Fn` is not a type guard.
*/

/**
* @typedef {(
* Check extends null | undefined // No test.
* ? Value
* : Value extends {type: Check} // String (type) test.
* ? Value
* : Value extends Check // Partial test.
* ? Value
* : Check extends Function // Function test.
* ? Predicate<Check, Value> extends Value
* ? Predicate<Check, Value>
* : never
* : never // Some other test?
* )} MatchesOne
* Check whether a node matches a primitive check in the type system.
* @template Value
* Value; typically unist `Node`.
* @template Check
* Value; typically `unist-util-is`-compatible test, but not arrays.
*/

/**
* @typedef {(
* Check extends Array<any>
* ? MatchesOne<Value, Check[keyof Check]>
* : MatchesOne<Value, Check>
* )} Matches
* Check whether a node matches a check in the type system.
* @template Value
* Value; typically unist `Node`.
* @template Check
* Value; typically `unist-util-is`-compatible test.
*/

/**
* @typedef {(
* Kind extends {children: Array<infer Child>}
* ? Child
* : never
* )} Child
* Collect nodes that can be parents of `Child`.
* @template {UnistNode} Kind
* All node types.
*/

import {convert} from 'unist-util-is'
Expand All @@ -10,59 +76,66 @@ import {convert} from 'unist-util-is'
* Find the first node in `parent` before another `node` or before an index,
* that passes `test`.
*
* @template {Node} Kind
* Node type.
*
* @overload
* @param {Parent} parent
* @param {Node | number} index
* @param {import('unist-util-is').Test} test
* @returns {Kind | undefined}
*
* @overload
* @param {Parent} parent
* @param {Node | number} index
* @param {Test} [test]
* @returns {Node | undefined}
*
* @param {Parent} parent
* @param parent
* Parent node.
* @param {Node | number} index
* Child of `parent`, or it’s index.
* @param {Test} [test]
* `unist-util-is`-compatible test.
* @returns {Node | undefined}
* Child of `parent` or `undefined`.
* @param index
* Child node or index.
* @param [test=undefined]
* Test for child to look for (optional).
* @returns
* A child (matching `test`, if given) or `undefined`.
*/
export function findBefore(parent, index, test) {
const is = convert(test)
export const findBefore =
// Note: overloads like this are needed to support optional generics.
/**
* @type {(
* (<Kind extends UnistParent, Check extends Test>(parent: Kind, index: Child<Kind> | number, test: Check) => Matches<Child<Kind>, Check> | undefined) &
* (<Kind extends UnistParent>(parent: Kind, index: Child<Kind> | number, test?: null | undefined) => Child<Kind> | undefined)
* )}
*/
(
/**
* @param {UnistParent} parent
* Parent node.
* @param {UnistNode | number} index
* Child node or index.
* @param {Test} [test=undefined]
* Test for child to look for.
* @returns {UnistNode | undefined}
* A child (matching `test`, if given) or `undefined`.
*/
function (parent, index, test) {
const is = convert(test)

if (!parent || !parent.type || !parent.children) {
throw new Error('Expected parent node')
}
if (!parent || !parent.type || !parent.children) {
throw new Error('Expected parent node')
}

if (typeof index === 'number') {
if (index < 0 || index === Number.POSITIVE_INFINITY) {
throw new Error('Expected positive finite number as index')
}
} else {
index = parent.children.indexOf(index)
if (typeof index === 'number') {
if (index < 0 || index === Number.POSITIVE_INFINITY) {
throw new Error('Expected positive finite number as index')
}
} else {
index = parent.children.indexOf(index)

if (index < 0) {
throw new Error('Expected child node or index')
}
}
if (index < 0) {
throw new Error('Expected child node or index')
}
}

// Performance.
if (index > parent.children.length) {
index = parent.children.length
}
// Performance.
if (index > parent.children.length) {
index = parent.children.length
}

while (index--) {
if (is(parent.children[index], index, parent)) {
return parent.children[index]
}
}
while (index--) {
const child = parent.children[index]

if (is(child, index, parent)) {
return child
}
}

return undefined
}
return undefined
}
)
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,21 @@
"unist-util-is": "^6.0.0"
},
"devDependencies": {
"@types/mdast": "^4.0.0",
"@types/node": "^20.0.0",
"c8": "^8.0.0",
"mdast-util-from-markdown": "^1.0.0",
"prettier": "^2.0.0",
"remark-cli": "^11.0.0",
"remark-preset-wooorm": "^9.0.0",
"tsd": "^0.28.0",
"type-coverage": "^2.0.0",
"typescript": "^5.0.0",
"xo": "^0.54.0"
},
"scripts": {
"prepack": "npm run build && npm run format",
"build": "tsc --build --clean && tsc --build && type-coverage",
"build": "tsc --build --clean && tsc --build && tsd && type-coverage",
"format": "remark . -qfo && prettier . -w --loglevel warn && xo --fix",
"test-api": "node --conditions development test.js",
"test-coverage": "c8 --100 --reporter lcov npm run test-api",
Expand All @@ -70,6 +72,10 @@
"atLeast": 100,
"detail": true,
"ignoreCatch": true,
"#": "needed `any`s",
"ignoreFiles": [
"lib/index.d.ts"
],
"strict": true
},
"xo": {
Expand Down
25 changes: 16 additions & 9 deletions test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/**
* @typedef {import('unist').Node} Node
* @typedef {import('mdast').Emphasis} Emphasis
* @typedef {import('mdast').InlineCode} InlineCode
* @typedef {import('unist').Node} UnistNode
*/

import assert from 'node:assert/strict'
Expand All @@ -25,6 +27,11 @@ test('`findBefore`', async function (t) {
const next = paragraph.children[1]
assert(next.type === 'emphasis')

/** @type {Emphasis} */
const emphasis = {type: 'emphasis', children: []}
/** @type {InlineCode} */
const inlineCode = {type: 'inlineCode', value: 'a'}

await t.test('should fail without parent', async function () {
assert.throws(function () {
// @ts-expect-error: check that an error is thrown at runtime.
Expand All @@ -35,40 +42,40 @@ test('`findBefore`', async function (t) {
await t.test('should fail without parent node', async function () {
assert.throws(function () {
// @ts-expect-error: check that an error is thrown at runtime.
findBefore({type: 'foo'})
findBefore(inlineCode)
}, /Expected parent node/)
})

await t.test('should fail without index (#1)', async function () {
assert.throws(function () {
// @ts-expect-error: check that an error is thrown at runtime.
findBefore({type: 'foo', children: []})
findBefore(emphasis)
}, /Expected child node or index/)
})

await t.test('should fail without index (#2)', async function () {
assert.throws(function () {
findBefore({type: 'foo', children: []}, -1)
findBefore(emphasis, -1)
}, /Expected positive finite number as index/)
})

await t.test('should fail without index (#3)', async function () {
assert.throws(function () {
findBefore({type: 'foo', children: []}, {type: 'bar'})
findBefore(emphasis, inlineCode)
}, /Expected child node or index/)
})

await t.test('should fail for invalid `test` (#1)', async function () {
assert.throws(function () {
// @ts-expect-error: check that an error is thrown at runtime.
findBefore({type: 'foo', children: [{type: 'bar'}]}, 1, false)
findBefore(emphasis, 1, false)
}, /Expected function, string, or object as test/)
})

await t.test('should fail for invalid `test` (#2)', async function () {
assert.throws(function () {
// @ts-expect-error: check that an error is thrown at runtime.
findBefore({type: 'foo', children: [{type: 'bar'}]}, 1, true)
findBefore(emphasis, 1, true)
}, /Expected function, string, or object as test/)
})

Expand Down Expand Up @@ -205,8 +212,8 @@ test('`findBefore`', async function (t) {
})

/**
* @param {Node} _
* @param {number | null | undefined} n
* @param {UnistNode} _
* @param {number | undefined} n
*/
function check(_, n) {
return n === 3
Expand Down

0 comments on commit 21144ef

Please sign in to comment.