diff --git a/.gitignore b/.gitignore index 735f4af..c977c85 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +*.d.ts *.log coverage/ node_modules/ diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index 46f7ace..0000000 --- a/index.d.ts +++ /dev/null @@ -1,69 +0,0 @@ -// TypeScript Version: 3.5 - -import {Node, Parent} from 'unist' - -/** - * Check that type property matches expectation for a node - * - * @typeParam T type of node that passes test - */ -export type TestType = T['type'] - -/** - * Check that some attributes on a node are matched - * - * @typeParam T type of node that passes test - */ -export type TestObject = Partial - -/** - * Check if a node passes a test - * - * @param node node to check - * @param index index of node in parent - * @param parent parent of node - * @typeParam T type of node that passes test - * @returns true if type T is found, false otherwise - */ -export type TestFunction = ( - node: unknown, - index?: number, - parent?: Parent -) => node is T - -/** - * Union of all the types of tests - * - * @typeParam T type of node that passes test - */ -export type Test = - | TestType - | TestObject - | TestFunction - | null - | undefined - -/** - * Unist utility to check if a node passes a test. - * - * @param node Node to check. - * @param test When nullish, checks if `node` is a `Node`. - * When `string`, works like passing `function (node) {return node.type === test}`. - * When `function` checks if function passed the node is true. - * When `object`, checks that all keys in test are in node, and that they have (strictly) equal values. - * When `array`, checks any one of the subtests pass. - * @param index Position of `node` in `parent` - * @param parent Parent of `node` - * @param context Context object to invoke `test` with - * @typeParam T type that node is compared with - * @returns Whether test passed and `node` is a `Node` (object with `type` set to non-empty `string`). - */ -export declare function is( - node: unknown, - test?: Test | Array>, - index?: number, - parent?: Parent, - context?: any -): node is T - -export declare function convert(test: Test): TestFunction diff --git a/index.js b/index.js index 1f6bf26..33d18bc 100644 --- a/index.js +++ b/index.js @@ -1,76 +1,159 @@ -// Assert if `test` passes for `node`. -// When a `parent` node is known the `index` of node should also be given. -// eslint-disable-next-line max-params -export function is(node, test, index, parent, context) { - var check = convert(test) - - if ( - index !== undefined && - index !== null && - (typeof index !== 'number' || - index < 0 || - index === Number.POSITIVE_INFINITY) - ) { - throw new Error('Expected positive finite index') - } - - if ( - parent !== undefined && - parent !== null && - (!is(parent) || !parent.children) - ) { - throw new Error('Expected parent node') - } - - if ( - (parent === undefined || parent === null) !== - (index === undefined || index === null) - ) { - throw new Error('Expected both parent and index') - } - - return node && node.type && typeof node.type === 'string' - ? Boolean(check.call(context, node, index, parent)) - : false -} - -export function convert(test) { - if (test === undefined || test === null) { - return ok - } +/** + * @typedef {import('unist').Node} Node + * @typedef {import('unist').Parent} Parent + * + * @typedef {string} Type + * @typedef {Object} Props + */ + +/** + * Check if a node passes a test + * + * @callback TestFunctionAnything + * @param {Node} node + * @param {number} [index] + * @param {Parent} [parent] + * @returns {boolean|void} + */ + +/** + * Check if a node passes a certain node test + * + * @template {Node} X + * @callback TestFunctionPredicate + * @param {Node} node + * @param {number} [index] + * @param {Parent} [parent] + * @returns {node is X} + */ + +/** + * @callback AssertAnything + * @param {unknown} [node] + * @param {number} [index] + * @param {Parent} [parent] + * @returns {boolean} + */ + +/** + * Check if a node passes a certain node test + * + * @template {Node} Y + * @callback AssertPredicate + * @param {unknown} [node] + * @param {number} [index] + * @param {Parent} [parent] + * @returns {node is Y} + */ + +export var is = + /** + * Check if a node passes a test. + * When a `parent` node is known the `index` of node should also be given. + * + * @type {( + * ((node: unknown, test: T['type']|Partial|TestFunctionPredicate|Array.|TestFunctionPredicate>, index?: number, parent?: Parent, context?: unknown) => node is T) & + * ((node?: unknown, test?: null|undefined|Type|Props|TestFunctionAnything|Array., index?: number, parent?: Parent, context?: unknown) => boolean) + * )} + */ + ( + /** + * Check if a node passes a test. + * When a `parent` node is known the `index` of node should also be given. + * + * @param {unknown} [node] Node to check + * @param {null|undefined|Type|Props|TestFunctionAnything|Array.} [test] + * When nullish, checks if `node` is a `Node`. + * When `string`, works like passing `function (node) {return node.type === test}`. + * When `function` checks if function passed the node is true. + * When `object`, checks that all keys in test are in node, and that they have (strictly) equal values. + * When `array`, checks any one of the subtests pass. + * @param {number} [index] Position of `node` in `parent` + * @param {Parent} [parent] Parent of `node` + * @param {unknown} [context] Context object to invoke `test` with + * @returns {boolean} Whether test passed and `node` is a `Node` (object with `type` set to non-empty `string`). + */ + // eslint-disable-next-line max-params + function is(node, test, index, parent, context) { + var check = convert(test) + + if ( + index !== undefined && + index !== null && + (typeof index !== 'number' || + index < 0 || + index === Number.POSITIVE_INFINITY) + ) { + throw new Error('Expected positive finite index') + } - if (typeof test === 'string') { - return typeFactory(test) - } + if ( + parent !== undefined && + parent !== null && + (!is(parent) || !parent.children) + ) { + throw new Error('Expected parent node') + } - if (typeof test === 'object') { - return 'length' in test ? anyFactory(test) : allFactory(test) - } + if ( + (parent === undefined || parent === null) !== + (index === undefined || index === null) + ) { + throw new Error('Expected both parent and index') + } - if (typeof test === 'function') { - return test - } + // @ts-ignore Looks like a node. + return node && node.type && typeof node.type === 'string' + ? Boolean(check.call(context, node, index, parent)) + : false + } + ) + +export var convert = + /** + * @type {( + * ((test: T['type']|Partial|TestFunctionPredicate) => AssertPredicate) & + * ((test?: null|undefined|Type|Props|TestFunctionAnything|Array.) => AssertAnything) + * )} + */ + ( + /** + * Generate an assertion from a check. + * @param {null|undefined|Type|Props|TestFunctionAnything|Array.} [test] + * When nullish, checks if `node` is a `Node`. + * When `string`, works like passing `function (node) {return node.type === test}`. + * When `function` checks if function passed the node is true. + * When `object`, checks that all keys in test are in node, and that they have (strictly) equal values. + * When `array`, checks any one of the subtests pass. + * @returns {AssertAnything} + */ + function (test) { + if (test === undefined || test === null) { + return ok + } - throw new Error('Expected function, string, or object as test') -} + if (typeof test === 'string') { + return typeFactory(test) + } -// Utility to assert each property in `test` is represented in `node`, and each -// values are strictly equal. -function allFactory(test) { - return all + if (typeof test === 'object') { + // @ts-ignore looks like a list of tests / partial test object. + return 'length' in test ? anyFactory(test) : propsFactory(test) + } - function all(node) { - var key + if (typeof test === 'function') { + return castFactory(test) + } - for (key in test) { - if (node[key] !== test[key]) return false + throw new Error('Expected function, string, or object as test') } - - return true - } -} - + ) +/** + * @param {Array.} tests + * @returns {AssertAnything} + */ function anyFactory(tests) { + /** @type {Array.} */ var checks = [] var index = -1 @@ -78,28 +161,82 @@ function anyFactory(tests) { checks[index] = convert(tests[index]) } - return any + return castFactory(any) + /** + * @this {unknown} + * @param {unknown[]} parameters + * @returns {boolean} + */ function any(...parameters) { var index = -1 while (++index < checks.length) { - if (checks[index].call(this, ...parameters)) { - return true - } + if (checks[index].call(this, ...parameters)) return true } - - return false } } -// Utility to convert a string into a function which checks a given node’s type -// for said string. -function typeFactory(test) { - return type +/** + * Utility to assert each property in `test` is represented in `node`, and each + * values are strictly equal. + * + * @param {Props} check + * @returns {AssertAnything} + */ +function propsFactory(check) { + return castFactory(all) + + /** + * @param {Node} node + * @returns {boolean} + */ + function all(node) { + /** @type {string} */ + var key + + for (key in check) { + if (node[key] !== check[key]) return + } + + return true + } +} +/** + * Utility to convert a string into a function which checks a given node’s type + * for said string. + * + * @param {Type} check + * @returns {AssertAnything} + */ +function typeFactory(check) { + return castFactory(type) + + /** + * @param {Node} node + */ function type(node) { - return Boolean(node && node.type === test) + return node && node.type === check + } +} + +/** + * Utility to convert a string into a function which checks a given node’s type + * for said string. + * @param {TestFunctionAnything} check + * @returns {AssertAnything} + */ +function castFactory(check) { + return assertion + + /** + * @this {unknown} + * @param {Array.} parameters + * @returns {boolean} + */ + function assertion(...parameters) { + return Boolean(check.call(this, ...parameters)) } } diff --git a/index.test-d.ts b/index.test-d.ts new file mode 100644 index 0000000..5f90f84 --- /dev/null +++ b/index.test-d.ts @@ -0,0 +1,192 @@ +import {Node, Parent} from 'unist' +import {expectType, expectNotType, expectError} from 'tsd' +import {Heading} from 'mdast' +import * as unified from 'unified' +import {is, convert} from './index.js' + +/* Setup. */ +interface Element extends Parent { + type: 'element' + tagName: string + properties: Record + content: Node + children: Node[] +} + +interface Paragraph extends Parent { + type: 'ParagraphNode' +} + +const heading: Node = { + type: 'heading', + depth: 2, + children: [] +} + +const element: Node = { + type: 'element', + tagName: 'section', + properties: {}, + content: {type: 'text'}, + children: [] +} + +const isHeading = (node: unknown): node is Heading => + typeof node === 'object' && node !== null && (node as Node).type === 'heading' +const isElement = (node: unknown): node is Element => + typeof node === 'object' && node !== null && (node as Node).type === 'element' + +is() + +/* Missing parameters. */ +expectError(is()) + +/* Types cannot be narrowed without predicate. */ +expectType(is(heading)) + +/* Incorrect generic. */ +expectError(is(heading, 'heading')) +expectError(is(heading, 'heading')) +expectError(is>(heading, 'heading')) + +/* Should be assignable to boolean. */ +expectType(is(heading, 'heading')) + +/* Test is optional */ +expectType(is(heading)) +expectType(is(heading, null)) +expectType(is(heading, undefined)) +/* But not with a type predicate */ +expectError(is(heading)) // But not with a type predicate +expectError(is(heading, null)) +expectError(is(heading, undefined)) + +/* Should support string tests. */ +expectType(is(heading, 'heading')) +expectType(is(element, 'heading')) +expectError(is(heading, 'element')) + +if (is(heading, 'heading')) { + expectType(heading) + expectNotType(heading) +} + +expectType(is(element, 'element')) +expectType(is(heading, 'element')) +expectError(is(element, 'heading')) + +if (is(element, 'element')) { + expectType(element) + expectNotType(element) +} + +/* Should support function tests. */ +expectType(is(heading, isHeading)) +expectType(is(element, isHeading)) +expectError(is(heading, isElement)) + +if (is(heading, isHeading)) { + expectType(heading) + expectNotType(heading) +} + +expectType(is(element, isElement)) +expectType(is(heading, isElement)) +expectError(is(element, isHeading)) + +if (is(element, isElement)) { + expectType(element) +} + +/* Should support object tests. */ +expectType( + is(heading, {type: 'heading', depth: 2}) +) +expectType( + is(element, {type: 'heading', depth: 2}) +) +expectError( + is(heading, {type: 'heading', depth: '2'}) +) + +if (is(heading, {type: 'heading', depth: 2})) { + expectType(heading) + expectNotType(heading) +} + +expectType( + is(element, {type: 'element', tagName: 'section'}) +) +expectType( + is(heading, {type: 'element', tagName: 'section'}) +) +expectError( + is(element, {type: 'element', tagName: true}) +) + +if (is(element, {type: 'element', tagName: 'section'})) { + expectType(element) + expectNotType(element) +} + +/* Should support array tests. */ +expectType( + is(heading, [ + 'heading', + isElement, + {type: 'ParagraphNode'} + ]) +) + +if ( + is(heading, [ + 'heading', + isElement, + {type: 'ParagraphNode'} + ]) +) { + switch (heading.type) { + case 'heading': { + expectType(heading) + break + } + + case 'element': { + expectType(heading) + break + } + + case 'ParagraphNode': { + expectType(heading) + break + } + + default: { + break + } + } +} + +/* Should support being used in a unified transform. */ +unified().use(() => (tree) => { + if (is(tree, 'heading')) { + expectType(tree) + // Do something + } + + return tree +}) + +/* Should support `convert`. */ +convert('heading') +expectError(convert('element')) +convert({type: 'heading', depth: 2}) +expectError( + convert({type: 'heading', depth: 2}) +) +convert(isHeading) +expectError(convert(isHeading)) +convert() +convert(null) +convert(undefined) +expectError(convert()) diff --git a/package.json b/package.json index a1d4af2..5276b90 100644 --- a/package.json +++ b/package.json @@ -37,23 +37,30 @@ "index.js" ], "devDependencies": { + "@types/lodash": "^4.0.0", "@types/mdast": "^3.0.0", + "@types/tape": "^4.0.0", "c8": "^7.0.0", - "dtslint": "^4.0.0", "fast-check": "^2.0.0", "lodash": "^4.0.0", "prettier": "^2.0.0", "remark-cli": "^9.0.0", "remark-preset-wooorm": "^8.0.0", + "rimraf": "^3.0.0", "tape": "^5.0.0", + "tsd": "^0.14.0", + "type-coverage": "^2.0.0", + "typescript": "^4.0.0", "unified": "^9.0.0", "xo": "^0.38.0" }, "scripts": { + "prepack": "npm run build && npm run format", + "build": "rimraf \"{test/**,}*.d.ts\" && tsc && tsd && type-coverage", "format": "remark . -qfo && prettier . -w --loglevel warn && xo --fix", "test-api": "node test/index.js", "test-coverage": "c8 --check-coverage --branches 100 --functions 100 --lines 100 --statements 100 --reporter lcov node test/index.js", - "test": "npm run format && npm run test-coverage" + "test": "npm run build && npm run format && npm run test-coverage" }, "prettier": { "tabWidth": 2, @@ -66,16 +73,19 @@ "xo": { "prettier": true, "rules": { + "import/no-mutable-exports": "off", "no-var": "off", "prefer-arrow-callback": "off" - }, - "ignore": [ - "*.ts" - ] + } }, "remarkConfig": { "plugins": [ "preset-wooorm" ] + }, + "typeCoverage": { + "atLeast": 100, + "detail": true, + "strict": true } } diff --git a/test/main.js b/test/main.js index 5bb57f4..b6eb10c 100644 --- a/test/main.js +++ b/test/main.js @@ -1,12 +1,18 @@ import test from 'tape' import {is} from '../index.js' +/** + * @typedef {import('unist').Node} Node + * @typedef {import('unist').Parent} Parent + */ + test('unist-util-is', function (t) { var node = {type: 'strong'} var parent = {type: 'paragraph', children: []} t.throws( function () { + // @ts-ignore runtime. is(null, false) }, /Expected function, string, or object as test/, @@ -31,6 +37,7 @@ test('unist-util-is', function (t) { t.throws( function () { + // @ts-ignore runtime. is(node, null, false, parent) }, /Expected positive finite index/, @@ -39,6 +46,7 @@ test('unist-util-is', function (t) { t.throws( function () { + // @ts-ignore runtime. is(node, null, 0, {}) }, /Expected parent node/, @@ -47,6 +55,7 @@ test('unist-util-is', function (t) { t.throws( function () { + // @ts-ignore runtime. is(node, null, 0, {type: 'paragraph'}) }, /Expected parent node/, @@ -68,7 +77,6 @@ test('unist-util-is', function (t) { /Expected both parent and index/, 'should throw `parent` xor `index` are given (#2)' ) - t.notok(is(), 'should not fail without node') t.ok(is(node), 'should check if given a node (#1)') t.notok(is({children: []}, null), 'should check if given a node (#2)') @@ -82,7 +90,12 @@ test('unist-util-is', function (t) { t.notok(is(node, {type: 'paragraph'}), 'should match partially (#4)') t.test('should accept a test', function (t) { - function test(node, n) { + /** + * @param {unknown} _ + * @param {number} n + * @returns {boolean} + */ + function test(_, n) { return n === 5 } @@ -98,6 +111,12 @@ test('unist-util-is', function (t) { t.plan(4) + /** + * @this {context} + * @param {Node} a + * @param {number} b + * @param {Parent} c + */ function test(a, b, c) { t.equal(this, context) t.equal(a, node) @@ -118,6 +137,13 @@ test('unist-util-is', function (t) { t.ok(is(node, [test, 'strong'], 5, parent, context)) + /** + * @this {context} + * @param {Node} a + * @param {number} b + * @param {Parent} c + * @returns {boolean} + */ function test(a, b, c) { t.equal(this, context) t.equal(a, node) diff --git a/test/property.js b/test/property.js index 536ee64..57e3103 100644 --- a/test/property.js +++ b/test/property.js @@ -21,11 +21,10 @@ test('unist-util-is properties', (t) => { () => fc.assert( fc.property( - fc - .unicodeJsonObject() - .filter( - (node) => !(isPlainObject(node) && typeof node.type === 'string') - ), + fc.unicodeJsonObject().filter( + // @ts-ignore Looks like a node. + (node) => !(isPlainObject(node) && typeof node.type === 'string') + ), (node) => !is(node) ) ), @@ -65,7 +64,10 @@ test('unist-util-is properties', (t) => { ) ), fc.string({minLength: 1}), - (nodeAndKeys, type) => { + ( + /** @type {[Object., Array.]} */ nodeAndKeys, + /** @type {string} */ type + ) => { const nodeProperties = nodeAndKeys[0] const keys = nodeAndKeys[1] diff --git a/tsconfig.json b/tsconfig.json index 9884d5a..1cb61bf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,5 @@ { - "files": ["index.js"], - "include": ["*.js"], + "include": ["*.js", "test/**/*.js"], "compilerOptions": { "target": "ES2020", "lib": ["ES2020"], @@ -8,16 +7,9 @@ "moduleResolution": "node", "allowJs": true, "checkJs": true, - "noEmit": true, + "declaration": true, + "emitDeclarationOnly": true, "allowSyntheticDefaultImports": true, - "skipLibCheck": true, - "noImplicitAny": false, - "noImplicitThis": false, - "strictNullChecks": false, - "strictFunctionTypes": false, - "baseUrl": ".", - "paths": { - "unist-util-is": ["./index.d.ts"] - } + "skipLibCheck": true } }