From f901beca9e5073eeaeef6c332678135930e6dc10 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Sun, 5 Feb 2023 19:27:04 -0500 Subject: [PATCH] feat: forEachComment --- .eslintrc.cjs | 5 +++ README.md | 9 ++--- cspell.json | 2 + package.json | 3 ++ src/forEachComment.ts | 92 +++++++++++++++++++++++++++++++++++++++++++ src/forEachToken.ts | 36 +++++++++++++++++ src/greet.test.ts | 46 ---------------------- src/greet.ts | 15 ------- src/index.test.ts | 7 ++++ src/index.ts | 2 +- 10 files changed, 149 insertions(+), 68 deletions(-) create mode 100644 src/forEachComment.ts create mode 100644 src/forEachToken.ts delete mode 100644 src/greet.test.ts delete mode 100644 src/greet.ts create mode 100644 src/index.test.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d1e131d6..8a194750 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -29,6 +29,11 @@ module.exports = { rules: { // These off-by-default rules work well for this repo and we like them on. "deprecation/deprecation": "error", + + // TODO? + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-unnecessary-condition": "off", + "no-constant-condition": "off", }, }, { diff --git a/README.md b/README.md index acb14bc7..fedd921a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

TypeScript API Tools

-

Utility functions for working with TypeScript's API. Based on the wonderful ajafff/tsutils.

+

Utility functions for working with TypeScript's API. Based on the wonderful [ajafff/tsutils](/~https://github.com/ajafff/tsutils).

@@ -32,11 +32,8 @@ npm i ts-api-tools ``` -```ts -import { greet } from "ts-api-tools"; - -greet("Hello, world!"); -``` +> ⚠️ This package is a fork of and drop-in replacement for [`tsutils`](/~https://github.com/ajafff/tsutils) ([original license: MIT](/~https://github.com/ajafff/tsutils/blob/26b195358ec36d59f00333115aa3ffd9611ca78b/LICENSE)). +> It's very early stage - most functions are not implemented yet! ⚠️ ## Development diff --git a/cspell.json b/cspell.json index 29c45552..9d6b67ab 100644 --- a/cspell.json +++ b/cspell.json @@ -10,6 +10,7 @@ "script/*.json" ], "words": [ + "ajafff", "Codecov", "codespace", "commitlint", @@ -19,6 +20,7 @@ "knip", "lcov", "quickstart", + "tsutils", "wontfix" ] } diff --git a/package.json b/package.json index 80ee9dc8..51f5c08f 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,9 @@ "main": "./lib/index.js", "name": "ts-api-tools", "packageManager": "pnpm@7.26.3", + "peerDependencies": { + "typescript": ">=4" + }, "repository": { "type": "git", "url": "/~https://github.com/JoshuaKGoldberg/ts-api-tools" diff --git a/src/forEachComment.ts b/src/forEachComment.ts new file mode 100644 index 00000000..3849c585 --- /dev/null +++ b/src/forEachComment.ts @@ -0,0 +1,92 @@ +// Code largely based on ajafff/tsutils: +// /~https://github.com/ajafff/tsutils/blob/03b4df15d14702f9c7a128ac3fae77171778d613/util/util.ts +// Original license MIT: +// /~https://github.com/ajafff/tsutils/blob/26b195358ec36d59f00333115aa3ffd9611ca78b/LICENSE + +import * as ts from "typescript"; + +import { forEachToken } from "./forEachToken"; + +/** Exclude trailing positions that would lead to scanning for trivia inside JsxText */ +function canHaveTrailingTrivia(token: ts.Node): boolean { + switch (token.kind) { + case ts.SyntaxKind.CloseBraceToken: + // after a JsxExpression inside a JsxElement's body can only be other JsxChild, but no trivia + return ( + token.parent.kind !== ts.SyntaxKind.JsxExpression || + !isJsxElementOrFragment(token.parent.parent) + ); + case ts.SyntaxKind.GreaterThanToken: + switch (token.parent.kind) { + case ts.SyntaxKind.JsxOpeningElement: + // if end is not equal, this is part of the type arguments list. in all other cases it would be inside the element body + return token.end !== token.parent.end; + case ts.SyntaxKind.JsxOpeningFragment: + return false; // would be inside the fragment + case ts.SyntaxKind.JsxSelfClosingElement: + return ( + token.end !== token.parent.end || // if end is not equal, this is part of the type arguments list + !isJsxElementOrFragment(token.parent.parent) + ); // there's only trailing trivia if it's the end of the top element + case ts.SyntaxKind.JsxClosingElement: + case ts.SyntaxKind.JsxClosingFragment: + // there's only trailing trivia if it's the end of the top element + return !isJsxElementOrFragment(token.parent.parent.parent); + } + } + return true; +} + +function isJsxElementOrFragment( + node: ts.Node +): node is ts.JsxElement | ts.JsxFragment { + return ( + node.kind === ts.SyntaxKind.JsxElement || + node.kind === ts.SyntaxKind.JsxFragment + ); +} + +export type ForEachCommentCallback = ( + fullText: string, + comment: ts.CommentRange +) => void; + +/** Iterate over all comments owned by `node` or its children */ +export function forEachComment( + node: ts.Node, + cb: ForEachCommentCallback, + sourceFile: ts.SourceFile = node.getSourceFile() +) { + /* Visit all tokens and skip trivia. + Comment ranges between tokens are parsed without the need of a scanner. + forEachTokenWithWhitespace does intentionally not pay attention to the correct comment ownership of nodes as it always + scans all trivia before each token, which could include trailing comments of the previous token. + Comment onwership is done right in this function*/ + const fullText = sourceFile.text; + const notJsx = sourceFile.languageVariant !== ts.LanguageVariant.JSX; + return forEachToken( + node, + (token) => { + if (token.pos === token.end) return; + if (token.kind !== ts.SyntaxKind.JsxText) + ts.forEachLeadingCommentRange( + fullText, + // skip shebang at position 0 + // TODO: Investigate + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + token.pos === 0 ? (ts.getShebang(fullText) || "").length : token.pos, + commentCallback + ); + if (notJsx || canHaveTrailingTrivia(token)) + return ts.forEachTrailingCommentRange( + fullText, + token.end, + commentCallback + ); + }, + sourceFile + ); + function commentCallback(pos: number, end: number, kind: ts.CommentKind) { + cb(fullText, { pos, end, kind }); + } +} diff --git a/src/forEachToken.ts b/src/forEachToken.ts new file mode 100644 index 00000000..f75a7e4b --- /dev/null +++ b/src/forEachToken.ts @@ -0,0 +1,36 @@ +// Code largely based on ajafff/tsutils: +// /~https://github.com/ajafff/tsutils/blob/03b4df15d14702f9c7a128ac3fae77171778d613/util/util.ts +// Original license MIT: +// /~https://github.com/ajafff/tsutils/blob/26b195358ec36d59f00333115aa3ffd9611ca78b/LICENSE + +import * as ts from "typescript"; + +/** + * Iterate over all tokens of `node` + * + * @param node The node whose tokens should be visited + * @param cb Is called for every token contained in `node` + */ +export function forEachToken( + node: ts.Node, + cb: (node: ts.Node) => void, + sourceFile: ts.SourceFile = node.getSourceFile() +) { + const queue = []; + while (true) { + if (ts.isTokenKind(node.kind)) { + cb(node); + // TODO: Investigate? + // eslint-disable-next-line deprecation/deprecation + } else if (node.kind !== ts.SyntaxKind.JSDocComment) { + const children = node.getChildren(sourceFile); + if (children.length === 1) { + node = children[0]; + continue; + } + for (let i = children.length - 1; i >= 0; --i) queue.push(children[i]); // add children in reverse order, when we pop the next element from the queue, it's the first child + } + if (queue.length === 0) break; + node = queue.pop()!; + } +} diff --git a/src/greet.test.ts b/src/greet.test.ts deleted file mode 100644 index bbebd1e6..00000000 --- a/src/greet.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -const logger = vi.spyOn(console, "log"); - -import { greet } from "./greet.js"; - -const message = "Yay, testing!"; - -describe("greet", () => { - it("logs to the console once when message is provided as a string", () => { - logger.mockImplementation(() => undefined); - - greet(message); - - expect(logger).toHaveBeenCalledWith(message); - expect(logger).toHaveBeenCalledTimes(1); - }); - - it("logs to the console once when message is provided as an object", () => { - logger.mockImplementation(() => undefined); - - greet({ message }); - - expect(logger).toHaveBeenCalledWith(message); - expect(logger).toHaveBeenCalledTimes(1); - }); - - it("logs once when times is not provided in an object", () => { - const logger = vi.fn(); - - greet({ logger, message }); - - expect(logger).toHaveBeenCalledWith(message); - expect(logger).toHaveBeenCalledTimes(1); - }); - - it("logs a specified number of times when times is provided", () => { - const logger = vi.fn(); - const times = 7; - - greet({ logger, message, times }); - - expect(logger).toHaveBeenCalledWith(message); - expect(logger).toHaveBeenCalledTimes(7); - }); -}); diff --git a/src/greet.ts b/src/greet.ts deleted file mode 100644 index 4941d28a..00000000 --- a/src/greet.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { GreetOptions } from "./types.js"; - -const consoleLogBound = console.log.bind(console); - -export function greet(options: GreetOptions | string) { - const { - logger = consoleLogBound, - message, - times = 1, - } = typeof options === "string" ? { message: options } : options; - - for (let i = 0; i < times; i += 1) { - logger(message); - } -} diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 00000000..58461082 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from "vitest"; + +describe("index", () => { + it("TODO", () => { + expect(2 + 2).not.toBe("fish"); + }); +}); diff --git a/src/index.ts b/src/index.ts index a39b40fa..729ef2af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ -export * from "./greet.js"; +export * from "./forEachComment.js"; export * from "./types.js";