From 25dd07a209e2196aaa94ab49961d18be62eecaef Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Fri, 9 Jul 2021 12:19:21 +0200 Subject: [PATCH] Add JSDoc based types --- .gitignore | 1 + index.js | 255 ++++++++++++++++++++++++++++++++------------------ package.json | 20 +++- test.js | 74 +++++++++++---- tsconfig.json | 16 ++++ 5 files changed, 253 insertions(+), 113 deletions(-) create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 33d4929..53a29e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ coverage/ node_modules/ .DS_Store +*.d.ts *.log yarn.lock diff --git a/index.js b/index.js index 0781080..c10caf3 100644 --- a/index.js +++ b/index.js @@ -1,100 +1,164 @@ -import events from 'events' -import once from 'once' - +/** + * @typedef {import('unified').Processor} Processor + * @typedef {import('unified').ProcessCallback} ProcessCallback + * @typedef {import('vfile').BufferEncoding} Encoding + * @typedef {import('vfile').VFileValue} Value + * @typedef {((error?: Error) => void)} Callback + * @typedef {Omit} MinimalDuplex + */ + +import {EventEmitter} from 'events' + +/** + * @param {Processor} processor + * @returns {MinimalDuplex} + */ export function stream(processor) { + /** @type {string[]} */ let chunks = [] - const emitter = new events.EventEmitter() + /** @type {boolean|undefined} */ let ended - emitter.processor = processor - emitter.readable = true - emitter.writable = true - emitter.write = write - emitter.end = end - emitter.pipe = pipe - - return emitter - - // Write a chunk into memory. - function write(chunk, encoding, callback) { - if (typeof encoding === 'function') { - callback = encoding - encoding = null - } - - if (ended) { - throw new Error('Did not expect `write` after `end`') - } - - chunks.push((chunk || '').toString(encoding || 'utf8')) - - if (callback) { - callback() - } - - // Signal succesful write. - return true - } - - // End the writing. - // Passes all arguments to a final `write`. - // Starts the process, which will trigger `error`, with a fatal error, if any; - // `data`, with the generated document in `string` form, if succesful. - // If messages are triggered during the process, those are triggerd as - // `warning`s. - function end() { - write(...arguments) - - ended = true - - processor.process(chunks.join(''), done) - - return true + /** + * Write a chunk into memory. + * + * @param {Value} chunk + * @param {Encoding} encoding + * @param {Callback} callback + */ + const write = + /** + * @type {( + * ((value?: Value, encoding?: Encoding, callback?: Callback) => boolean) & + * ((value: Value, callback?: Callback) => boolean) + * )} + */ + ( + /** + * @param {Value} [chunk] + * @param {Encoding} [encoding] + * @param {Callback} [callback] + */ + function (chunk, encoding, callback) { + if (typeof encoding === 'function') { + callback = encoding + encoding = undefined + } - function done(error, file) { - const messages = file ? file.messages : [] - let index = -1 + if (ended) { + throw new Error('Did not expect `write` after `end`') + } - chunks = null + // @ts-expect-error: passing `encoding` to string is fine. + chunks.push((chunk || '').toString(encoding || 'utf8')) - // Trigger messages as warnings, except for fatal error. - while (++index < messages.length) { - /* istanbul ignore else - shouldn’t happen. */ - if (messages[index] !== error) { - emitter.emit('warning', messages[index]) + if (callback) { + callback() } + + // Signal succesful write. + return true } + ) + + /** + * End the writing. + * Passes all arguments to a final `write`. + * Starts the process, which will trigger `error`, with a fatal error, if any; + * `data`, with the generated document in `string` form, if succesful. + * If messages are triggered during the process, those are triggerd as + * `warning`s. + * + * @param {Value} chunk + * @param {Encoding} encoding + * @param {Callback} callback + */ + const end = + /** + * @type {( + * ((value?: Value, encoding?: Encoding, callback?: Callback) => boolean) & + * ((value: Value, callback?: Callback) => boolean) + * )} + */ + ( + /** + * @param {Value} [chunk] + * @param {Encoding} [encoding] + * @param {Callback} [callback] + */ + function (chunk, encoding, callback) { + write(chunk, encoding, callback) + + processor.process(chunks.join(''), done) - if (error) { - // Don’t enter an infinite error throwing loop. - setTimeout(() => { - emitter.emit('error', error) - }, 4) - } else { - emitter.emit('data', file.value) emitter.emit('end') + ended = true + return true + + /** @type {ProcessCallback} */ + function done(error, file) { + const messages = file ? file.messages : [] + let index = -1 + + // @ts-expect-error: clear memory. + chunks = undefined + + // Trigger messages as warnings, except for fatal error. + while (++index < messages.length) { + /* istanbul ignore else - shouldn’t happen. */ + if (messages[index] !== error) { + emitter.emit('warning', messages[index]) + } + } + + if (error) { + // Don’t enter an infinite error throwing loop. + setTimeout(() => { + emitter.emit('error', error) + }, 4) + } else { + emitter.emit('data', file.value) + emitter.emit('end') + } + } } - } - } + ) + + /** @type {MinimalDuplex} */ + // @ts-expect-error `addListener` is fine. + const emitter = Object.assign(new EventEmitter(), { + processor, + writable: true, + readable: true, + write, + end, + pipe + }) - // Pipe the processor into a writable stream. - // Basically `Stream#pipe`, but inlined and simplified to keep the bundled - // size down. - // See: . - function pipe(dest, options) { - const settings = options || {} - const onend = once(onended) + return emitter + /** + * Pipe the processor into a writable stream. + * Basically `Stream#pipe`, but inlined and simplified to keep the bundled + * size down. + * See: . + * + * @template {NodeJS.WritableStream} T + * @param {T} dest + * @param {{end?: boolean}} [options] + * @returns {T} + */ + function pipe(dest, options) { emitter.on('data', ondata) emitter.on('error', onerror) emitter.on('end', cleanup) emitter.on('close', cleanup) - // If the `end` option is not supplied, `dest.end()` will be called when the - // `end` or `close` events are received - // Only `dest.end()` once. - if (!dest._isStdio && settings.end !== false) { - emitter.on('end', onend) + // If the `end` option is not supplied, `dest.end()` will be + // called when the `end` or `close` events are received. + // @ts-expect-error `_isStdio` is available on `std{err,out}` + if (!dest._isStdio && (!options || options.end !== false)) { + emitter.on('end', onended) } dest.on('error', onerror) @@ -104,24 +168,37 @@ export function stream(processor) { return dest - // End destination. + /** + * End destination. + * + * @returns {void} + */ function onended() { if (dest.end) { dest.end() } } - // Handle data. + /** + * Handle data. + * + * @param {Value} chunk + * @returns {void} + */ function ondata(chunk) { if (dest.writable) { dest.write(chunk) } } - // Clean listeners. + /** + * Clean listeners. + * + * @returns {void} + */ function cleanup() { emitter.removeListener('data', ondata) - emitter.removeListener('end', onend) + emitter.removeListener('end', onended) emitter.removeListener('error', onerror) emitter.removeListener('end', cleanup) emitter.removeListener('close', cleanup) @@ -130,16 +207,16 @@ export function stream(processor) { dest.removeListener('close', cleanup) } - // Close dangling pipes and handle unheard errors. + /** + * Close dangling pipes and handle unheard errors. + * + * @param {Error?} [error] + * @returns {void} + */ function onerror(error) { cleanup() - // Cannot use `listenerCount` in node <= 0.12. - if ( - !emitter._events.error || - emitter._events.error.length === 0 || - emitter._events.error === onerror - ) { + if (!emitter.listenerCount('error')) { throw error // Unhandled stream error in pipe. } } diff --git a/package.json b/package.json index 4dc5a2a..10c0491 100644 --- a/package.json +++ b/package.json @@ -20,27 +20,33 @@ "sideEffects": false, "type": "module", "main": "index.js", + "types": "index.d.ts", "files": [ + "index.d.ts", "index.js" ], "dependencies": { - "once": "^1.4.0" + "unified": "^10.0.0-beta.1", + "vfile": "^5.0.0" }, "devDependencies": { + "@types/tape": "^4.0.0", "c8": "^7.0.0", - "is-function": "^1.0.0", "prettier": "^2.0.0", "remark-cli": "^9.0.0", "remark-preset-wooorm": "^8.0.0", + "rimraf": "^3.0.2", "tape": "^5.0.0", - "unified": "^10.0.0-beta.1", + "type-coverage": "^2.0.0", + "typescript": "^4.0.0", "xo": "^0.39.0" }, "scripts": { + "build": "rimraf \"*.d.ts\" && tsc && type-coverage", "format": "remark . -qfo && prettier . -w --loglevel warn && xo --fix", "test-api": "node --conditions development test.js", "test-coverage": "c8 --check-coverage --branches 100 --functions 100 --lines 100 --statements 100 --reporter lcov node --conditions development test.js", - "test": "npm run format && npm run test-coverage" + "test": "npm run build && npm run format && npm run test-coverage" }, "prettier": { "tabWidth": 2, @@ -57,5 +63,11 @@ "plugins": [ "preset-wooorm" ] + }, + "typeCoverage": { + "atLeast": 100, + "detail": true, + "strict": true, + "ignoreCatch": true } } diff --git a/test.js b/test.js index 2a5045b..ad06ce3 100644 --- a/test.js +++ b/test.js @@ -1,19 +1,28 @@ +/** + * @typedef {import('unified').Processor} Processor + * @typedef {import('unified').Plugin} Plugin + * @typedef {import('unified').Transformer} Transformer + * @typedef {import('unified').ParserFunction} ParserFunction + * @typedef {import('unified').CompilerFunction} CompilerFunction + * @typedef {import('vfile-message').VFileMessage} VFileMessage + */ + import nodeStream from 'stream' import test from 'tape' import {unified} from 'unified' -import func from 'is-function' import {stream} from './index.js' test('stream', (t) => { + // @ts-expect-error: unified types are wrong. const proc = unified().use(parse).use(stringify) t.test('interface', (st) => { const tr = stream(proc) st.equal(tr.readable, true, 'should be readable') st.equal(tr.writable, true, 'should be writable') - st.ok(func(tr.write), 'should have a `write` method') - st.ok(func(tr.end), 'should have an `end` method') - st.ok(func(tr.pipe), 'should have a `pipe` method') + st.equal(typeof tr.write, 'function', 'should have a `write` method') + st.equal(typeof tr.end, 'function', 'should have an `end` method') + st.equal(typeof tr.pipe, 'function', 'should have a `pipe` method') st.end() }) @@ -33,19 +42,20 @@ test('stream', (t) => { ) stream(proc) - .on('data', (value) => { + .on('data', (/** @type {string} */ value) => { st.equal(value, '', 'should emit processed `data`') }) .end() stream(proc) - .on('data', (value) => { + .on('data', (/** @type {string} */ value) => { st.equal(value, 'alpha', 'should emit given `data`') }) .end('alpha') + // @ts-expect-error: TS is wrong on streams. stream(proc) - .on('data', (value) => { + .on('data', (/** @type {string} */ value) => { st.equal(value, 'brC!vo', 'should honour encoding') }) .end(Buffer.from([0x62, 0x72, 0xc3, 0xa1, 0x76, 0x6f]), 'ascii') @@ -67,12 +77,13 @@ test('stream', (t) => { stream( proc().use(() => { return transformer + /** @type {Transformer} */ function transformer() { return exception } }) ) - .on('error', (error) => { + .on('error', (/** @type {Error} */ error) => { st.equal(error, exception, 'should trigger `error` if an error occurs') }) .on( @@ -87,49 +98,60 @@ test('stream', (t) => { stream( proc().use(() => { return transformer - function transformer(tree, file) { + /** @type {Transformer} */ + function transformer(_, file) { file.message(exception) } }) ) - .on('warning', (error) => { + .on('warning', (/** @type {VFileMessage} */ error) => { st.equal( error.reason, 'alpha', 'should trigger `warning` if an messages are emitted' ) }) - .on('data', (data) => { + .on('data', (/** @type {string} */ data) => { st.equal(data, '', 'should not fail if warnings are emitted') }) .end() }) t.test('#pipe', (st) => { - st.plan(5) + st.plan(6) st.doesNotThrow(() => { // Not writable. const tr = stream(proc) + // @ts-expect-error: we handle this gracefully. tr.pipe(new nodeStream.Readable()) tr.end('foo') }, 'should not throw when piping to a non-writable stream') let tr = stream(proc) - const s = new nodeStream.PassThrough() + let s = new nodeStream.PassThrough() + // @ts-expect-error: TS is wrong about stdin and stdout. s._isStdio = true tr.pipe(s) - tr.write('alpha') - tr.write('bravo') - tr.end('charlie') + tr.end('alpha') st.doesNotThrow(() => { - s.write('delta') + s.write('bravo') }, 'should not `end` stdio streams') - tr = stream(proc).on('error', (error) => { + tr = stream(proc) + s = new nodeStream.PassThrough() + + tr.pipe(s, {end: false}) + tr.end('alpha') + + st.doesNotThrow(() => { + s.write('bravo') + }, 'should not `end` streams when piping w/ `end: false`') + + tr = stream(proc).on('error', (/** @type {Error} */ error) => { st.equal(error.message, 'Whoops!', 'should pass errors') }) @@ -150,7 +172,7 @@ test('stream', (t) => { tr = stream(proc) tr.pipe(new nodeStream.PassThrough()) - .on('data', (buf) => { + .on('data', (/** @type {Buffer} */ buf) => { st.equal( String(buf), 'alphabravocharlie', @@ -171,18 +193,30 @@ test('stream', (t) => { }) }) +/** + * @type {Plugin} + * @this {Processor} + */ function parse() { this.Parser = parser + /** @type {ParserFunction} */ function parser(doc) { - return {type: 'root', value: doc} + // @ts-expect-error: hush. + return {type: 'text', value: doc} } } +/** + * @type {Plugin} + * @this {Processor} + */ function stringify() { this.Compiler = compiler + /** @type {CompilerFunction} */ function compiler(tree) { + // @ts-expect-error: it’s a text node. return tree.value } } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e31adf8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "include": ["*.js"], + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "ES2020", + "moduleResolution": "node", + "allowJs": true, + "checkJs": true, + "declaration": true, + "emitDeclarationOnly": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "strict": true + } +}