Skip to content

Commit

Permalink
Change types to base what visitor gets on tree
Browse files Browse the repository at this point in the history
Previously, to define what `visitor` received could only be done through
a TypeScript type parameter:

```js
// This used to work but no longer!!
visitParents<Heading>(tree, 'heading', (node) => {
  expectType<Heading>(node)
})
```

This did not look at `tree` at all (even if `tree` was hast, and as `Heading` is
mdast, it would pass `Heading` to `visitor`).
It also made it impossible to narrow types in JS.

Given that more and more of unist and friends is now strongly typed, we can
expect `tree` to be some kind of implementation of `Node` rather than the
abstract `Node` interface itself.
With that, we can also find all possible node types inside `tree`.
This commit changes to perform the test (`'heading'`) in the type system
and actually narrow down which nodes that are in `tree` match `test`.

This gives us:

```js
// This now works:
const tree: Root = {/* … */}

visitParents(tree, 'heading', (node) => {
  expectType<Heading>(node)
})
```

Closes GH-10.

Reviewed-by: Remco Haszing <remcohaszing@gmail.com>
Reviewed-by: Christian Murphy <christian.murphy.42@gmail.com>
  • Loading branch information
wooorm authored Jul 29, 2021
1 parent 588695b commit ebf54db
Show file tree
Hide file tree
Showing 6 changed files with 257 additions and 84 deletions.
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
.DS_Store
*.d.ts
color.browser.d.ts
color.d.ts
index.d.ts
test.d.ts
*.log
coverage/
node_modules/
Expand Down
51 changes: 51 additions & 0 deletions complex-types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/* eslint-disable @typescript-eslint/ban-types */

import type {Node, Parent} from 'unist'
import type {Test} from 'unist-util-is'

export type InclusiveDescendant<
Tree extends Node = never,
Found = void
> = Tree extends Parent
?
| Tree
| InclusiveDescendant<
Exclude<Tree['children'][number], Found | Tree>,
Found | Tree
>
: Tree

type Predicate<Fn, Fallback = never> = Fn extends (
value: any
) => value is infer Thing
? Thing
: Fallback

type MatchesOne<Value, Check> =
// Is this a node?
Value extends Node
? // No test.
Check extends null
? Value
: // No test.
Check extends undefined
? Value
: // Function test.
Check extends Function
? Extract<Value, Predicate<Check, Value>>
: // String (type) test.
Value['type'] extends Check
? Value
: // Partial test.
Value extends Check
? Value
: never
: never

export type Matches<Value, Check> =
// Is this a list?
Check extends any[]
? MatchesOne<Value, Check[keyof Check]>
: MatchesOne<Value, Check>

/* eslint-enable @typescript-eslint/ban-types */
23 changes: 14 additions & 9 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,22 +45,27 @@ export const SKIP = 'skip'
*/
export const EXIT = false

/**
* Visit children of tree which pass a test
*
* @param tree Abstract syntax tree to walk
* @param test Test node, optional
* @param visitor Function to run for each node
* @param reverse Visit the tree in reverse order, defaults to false
*/
export const visitParents =
/**
* @type {(
* (<T extends Node>(tree: Node, test: T['type']|Partial<T>|import('unist-util-is').TestFunctionPredicate<T>|Array.<T['type']|Partial<T>|import('unist-util-is').TestFunctionPredicate<T>>, visitor: Visitor<T>, reverse?: boolean) => void) &
* ((tree: Node, test: Test, visitor: Visitor<Node>, reverse?: boolean) => void) &
* ((tree: Node, visitor: Visitor<Node>, reverse?: boolean) => void)
* (<Tree extends Node, Check extends Test>(tree: Tree, test: Check, visitor: Visitor<import('./complex-types').Matches<import('./complex-types').InclusiveDescendant<Tree>, Check>>, reverse?: boolean) => void) &
* (<Tree extends Node>(tree: Tree, visitor: Visitor<import('./complex-types').InclusiveDescendant<Tree>>, reverse?: boolean) => void)
* )}
*/
(
/**
* Visit children of tree which pass a test
*
* @param {Node} tree Abstract syntax tree to walk
* @param {Test} test test Test node
* @param {Visitor<Node>} visitor Function to run for each node
* @param {boolean} [reverse] Fisit the tree in reverse, defaults to false
* @param {Node} tree
* @param {Test} test
* @param {Visitor<Node>} visitor
* @param {boolean} [reverse]
*/
function (tree, test, visitor, reverse) {
if (typeof test === 'function' && typeof visitor !== 'function') {
Expand Down
221 changes: 159 additions & 62 deletions index.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,39 @@
/* eslint-disable @typescript-eslint/no-confusing-void-expression, @typescript-eslint/no-empty-function */

import {expectError} from 'tsd'
import {Node, Parent} from 'unist'
import {expectAssignable, expectError, expectType, expectNotType} from 'tsd'
import {Node, Literal, Parent} from 'unist'
import {is} from 'unist-util-is'
import {visitParents, SKIP, EXIT, CONTINUE} from './index.js'

/* Setup */
const sampleTree = {
const implicitTree = {
type: 'root',
children: [{type: 'heading', depth: 1, children: []}]
}

interface Heading extends Parent {
type: 'heading'
depth: number
children: Node[]
const sampleTree: Root = {
type: 'root',
children: [{type: 'heading', depth: 1, children: []}]
}

const complexTree: Root = {
type: 'root',
children: [
{
type: 'blockquote',
children: [{type: 'paragraph', children: [{type: 'text', value: 'a'}]}]
},
{
type: 'paragraph',
children: [
{
type: 'emphasis',
children: [{type: 'emphasis', children: [{type: 'text', value: 'b'}]}]
},
{type: 'text', value: 'c'}
]
}
]
}

interface Element extends Parent {
Expand All @@ -24,6 +44,43 @@ interface Element extends Parent {
children: Node[]
}

type Content = Flow | Phrasing

interface Root extends Parent {
type: 'root'
children: Flow[]
}

type Flow = Blockquote | Heading | Paragraph

interface Blockquote extends Parent {
type: 'blockquote'
children: Flow[]
}

interface Heading extends Parent {
type: 'heading'
depth: number
children: Phrasing[]
}

interface Paragraph extends Parent {
type: 'paragraph'
children: Phrasing[]
}

type Phrasing = Text | Emphasis

interface Emphasis extends Parent {
type: 'emphasis'
children: Phrasing[]
}

interface Text extends Literal {
type: 'text'
value: string
}

const isNode = (node: unknown): node is Node =>
typeof node === 'object' && node !== null && 'type' in node
const headingTest = (node: unknown): node is Heading =>
Expand All @@ -36,76 +93,116 @@ expectError(visitParents())
expectError(visitParents(sampleTree))

/* Visit without test. */
visitParents(sampleTree, (_) => {})
visitParents(sampleTree, (_: Node) => {})
expectError(visitParents(sampleTree, (_: Element) => {}))
expectError(visitParents(sampleTree, (_: Heading) => {}))
visitParents(sampleTree, (node) => {
expectType<Root | Content>(node)
})
visitParents(implicitTree, (node) => {
expectAssignable<Node>(node)
expectNotType<Node>(node) // Objects are too loose.
})

/* Visit with type test. */
visitParents(sampleTree, 'heading', (_) => {})
visitParents(sampleTree, 'heading', (_: Heading) => {})
expectError(visitParents(sampleTree, 'not-a-heading', (_: Heading) => {}))
expectError(visitParents(sampleTree, 'element', (_: Heading) => {}))

visitParents(sampleTree, 'element', (_) => {})
visitParents(sampleTree, 'element', (_: Element) => {})
expectError(visitParents(sampleTree, 'not-an-element', (_: Element) => {}))
visitParents(sampleTree, 'heading', (node) => {
expectType<Heading>(node)
})
visitParents(sampleTree, 'element', (node) => {
// Not in tree.
expectType<never>(node)
})
expectError(visitParents(sampleTree, 'heading', (_: Element) => {}))
visitParents(implicitTree, 'heading', (node) => {
expectType<never>(node) // Objects are too loose.
expectAssignable<Heading>(node)
expectNotType<Heading>(node) // Objects are too loose.
})

/* Visit with object test. */
visitParents(sampleTree, {type: 'heading'}, (_) => {})
visitParents(sampleTree, {random: 'property'}, (_) => {})

visitParents(sampleTree, {type: 'heading'}, (_: Heading) => {})
visitParents(sampleTree, {type: 'heading', depth: 2}, (_: Heading) => {})
expectError(visitParents(sampleTree, {type: 'element'}, (_: Heading) => {}))
expectError(
visitParents(sampleTree, {type: 'heading', depth: '2'}, (_: Heading) => {})
)

visitParents(sampleTree, {type: 'element'}, (_: Element) => {})
visitParents(
sampleTree,
{type: 'element', tagName: 'section'},
(_: Element) => {}
)

expectError(visitParents(sampleTree, {type: 'heading'}, (_: Element) => {}))

visitParents(sampleTree, {depth: 1}, (node) => {
expectType<Heading>(node)
})
visitParents(sampleTree, {random: 'property'} as const, (node) => {
expectType<never>(node)
})
visitParents(sampleTree, {type: 'heading', depth: '1'} as const, (node) => {
// Not in tree.
expectType<never>(node)
})
visitParents(sampleTree, {tagName: 'section'} as const, (node) => {
// Not in tree.
expectType<never>(node)
})
expectError(
visitParents(sampleTree, {type: 'element', tagName: true}, (_: Element) => {})
visitParents(sampleTree, {type: 'heading'} as const, (_: Element) => {})
)
visitParents(implicitTree, {type: 'heading'} as const, (node) => {
expectType<never>(node) // Objects are too loose.
expectAssignable<Heading>(node)
expectNotType<Heading>(node) // Objects are too loose.
})

/* Visit with function test. */
visitParents(sampleTree, headingTest, (_) => {})
visitParents(sampleTree, headingTest, (_: Heading) => {})
visitParents(sampleTree, headingTest, (node) => {
expectType<Heading>(node)
})
expectError(visitParents(sampleTree, headingTest, (_: Element) => {}))

visitParents(sampleTree, elementTest, (_) => {})
visitParents(sampleTree, elementTest, (_: Element) => {})
expectError(visitParents(sampleTree, elementTest, (_: Heading) => {}))
visitParents(sampleTree, elementTest, (node) => {
// Not in tree.
expectType<never>(node)
})
visitParents(implicitTree, headingTest, (node) => {
expectType<never>(node) // Objects are too loose.
expectAssignable<Heading>(node)
expectNotType<Heading>(node) // Objects are too loose.
})

/* Visit with array of tests. */
visitParents(
sampleTree,
['ParagraphNode', {type: 'element'}, headingTest],
(_) => {}
)
visitParents(sampleTree, ['heading', {depth: 1}, headingTest], (node) => {
// Unfortunately TS casts things in arrays too vague.
expectType<Root | Content>(node)
})

/* Visit returns action. */
visitParents(sampleTree, 'heading', (_) => CONTINUE)
visitParents(sampleTree, 'heading', (_) => EXIT)
visitParents(sampleTree, 'heading', (_) => SKIP)
expectError(visitParents(sampleTree, 'heading', (_) => 'random'))
visitParents(sampleTree, () => CONTINUE)
visitParents(sampleTree, () => EXIT)
visitParents(sampleTree, () => SKIP)
expectError(visitParents(sampleTree, () => 'random'))

/* Visit returns index. */
visitParents(sampleTree, 'heading', (_) => 0)
visitParents(sampleTree, 'heading', (_) => 1)
visitParents(sampleTree, () => 0)
visitParents(sampleTree, () => 1)

/* Visit returns tuple. */
visitParents(sampleTree, 'heading', (_) => [CONTINUE, 1])
visitParents(sampleTree, 'heading', (_) => [EXIT, 1])
visitParents(sampleTree, 'heading', (_) => [SKIP, 1])
visitParents(sampleTree, 'heading', (_) => [SKIP])
expectError(visitParents(sampleTree, 'heading', (_) => [1]))
expectError(visitParents(sampleTree, 'heading', (_) => ['random', 1]))
visitParents(sampleTree, () => [CONTINUE, 1])
visitParents(sampleTree, () => [EXIT, 1])
visitParents(sampleTree, () => [SKIP, 1])
visitParents(sampleTree, () => [SKIP])
expectError(visitParents(sampleTree, () => [1]))
expectError(visitParents(sampleTree, () => ['random', 1]))

/* Should infer children from the given tree. */
visitParents(complexTree, (node) => {
expectType<Root | Content>(node)
})

const blockquote = complexTree.children[0]
if (is<Blockquote>(blockquote, 'blockquote')) {
visitParents(blockquote, (node) => {
expectType<Content>(node)
})
}

const paragraph = complexTree.children[1]
if (is<Paragraph>(paragraph, 'paragraph')) {
visitParents(paragraph, (node) => {
expectType<Paragraph | Phrasing>(node)
})

const child = paragraph.children[1]

if (is<Emphasis>(child, 'emphasis')) {
visitParents(child, 'blockquote', (node) => {
// `blockquote` does not exist in phrasing.
expectType<never>(node)
})
}
}
Loading

0 comments on commit ebf54db

Please sign in to comment.