src/typescript/ast-utils.ts (385 lines of code) (raw):
import * as ts from 'typescript';
import { AstRenderer } from '../renderer';
export function stripCommentMarkers(comment: string, multiline: boolean) {
if (multiline) {
// The text *must* start with '/*' and end with '*/'.
// Strip leading '*' from every remaining line (first line because of '**',
// other lines because of continuations.
return comment
.substring(2, comment.length - 2)
.replace(/^[ \t]+/g, '') // Strip all leading whitepace
.replace(/[ \t]+$/g, '') // Strip all trailing whitepace
.replace(/^[ \t]*\*[ \t]?/gm, ''); // Strip "* " from start of line
}
// The text *must* start with '//'
return comment.replace(/^[/]{2}[ \t]?/gm, '');
}
export function stringFromLiteral(expr: ts.Expression) {
if (ts.isStringLiteral(expr)) {
return expr.text;
}
return '???';
}
/**
* All types of nodes that can be captured using `nodeOfType`, and the type of Node they map to
*/
export type CapturableNodes = {
[ts.SyntaxKind.ImportDeclaration]: ts.ImportDeclaration;
[ts.SyntaxKind.VariableDeclaration]: ts.VariableDeclaration;
[ts.SyntaxKind.ExternalModuleReference]: ts.ExternalModuleReference;
[ts.SyntaxKind.NamespaceImport]: ts.NamespaceImport;
[ts.SyntaxKind.NamedImports]: ts.NamedImports;
[ts.SyntaxKind.ImportSpecifier]: ts.ImportSpecifier;
[ts.SyntaxKind.StringLiteral]: ts.StringLiteral;
};
export type AstMatcher<A> = (nodes?: ts.Node[]) => A | undefined;
/**
* Return AST children of the given node
*
* Difference with node.getChildren():
*
* - node.getChildren() must take a SourceFile (will fail if it doesn't get it)
* and returns a mix of abstract and concrete syntax nodes.
* - This function function will ONLY return abstract syntax nodes.
*/
export function nodeChildren(node: ts.Node): ts.Node[] {
const ret = new Array<ts.Node>();
node.forEachChild((n) => {
ret.push(n);
});
return ret;
}
/**
* Match a single node of a given type
*
* Capture name is first so that the IDE can detect eagerly that we're falling into
* that overload and properly autocomplete the recognized node types from CapturableNodes.
*
* Looks like SyntaxList nodes appear in the printed AST, but they don't actually appear
*/
export function nodeOfType<A>(syntaxKind: ts.SyntaxKind, children?: AstMatcher<A>): AstMatcher<A>;
// eslint-disable-next-line max-len
export function nodeOfType<S extends keyof CapturableNodes, N extends string, A>(
capture: N,
capturableNodeType: S,
children?: AstMatcher<A>,
): AstMatcher<Omit<A, N> & { [key in N]: CapturableNodes[S] }>;
// eslint-disable-next-line max-len
export function nodeOfType<S extends keyof CapturableNodes, N extends string, A>(
syntaxKindOrCaptureName: ts.SyntaxKind | N,
nodeTypeOrChildren?: S | AstMatcher<A>,
children?: AstMatcher<A>,
): AstMatcher<A> | AstMatcher<A & { [key in N]: CapturableNodes[S] }> {
const capturing = typeof syntaxKindOrCaptureName === 'string'; // Determine which overload we're in (SyntaxKind is a number)
const realNext = (capturing ? children : (nodeTypeOrChildren as AstMatcher<A>)) ?? DONE;
const realCapture = capturing ? syntaxKindOrCaptureName : undefined;
const realSyntaxKind = capturing ? nodeTypeOrChildren : syntaxKindOrCaptureName;
return (nodes) => {
for (const node of nodes ?? []) {
if (node.kind === realSyntaxKind) {
const ret = realNext(nodeChildren(node));
if (!ret) {
continue;
}
if (realCapture) {
return Object.assign(ret, {
[realCapture]: node as CapturableNodes[S],
}) as any;
}
return ret;
}
}
return undefined;
};
}
export function anyNode(): AstMatcher<Record<string, unknown>>;
export function anyNode<A>(children: AstMatcher<A>): AstMatcher<A>;
export function anyNode<A>(children?: AstMatcher<A>): AstMatcher<A> | AstMatcher<any> {
const realNext = children ?? DONE;
return (nodes) => {
for (const node of nodes ?? []) {
const m = realNext(nodeChildren(node));
if (m) {
return m;
}
}
return undefined;
};
}
// Does not capture deeper because how would we even represent that?
export function allOfType<S extends keyof CapturableNodes, N extends string, A>(
s: S,
name: N,
children?: AstMatcher<A>,
): AstMatcher<{ [key in N]: Array<CapturableNodes[S]> }> {
type ArrayType = Array<CapturableNodes[S]>;
type ReturnType = { [key in N]: ArrayType };
const realNext = children ?? DONE;
return (nodes) => {
let ret: ReturnType | undefined;
for (const node of nodes ?? []) {
if (node.kind === s) {
if (realNext(nodeChildren(node))) {
if (!ret) {
ret = { [name]: new Array<CapturableNodes[S]>() } as ReturnType;
}
ret[name].push(node as any);
}
}
}
return ret;
};
}
export const DONE: AstMatcher<Record<string, unknown>> = () => ({});
/**
* Run a matcher against a node and return (or invoke a callback with) the accumulated bindings
*/
export function matchAst<A>(node: ts.Node, matcher: AstMatcher<A>): A | undefined;
export function matchAst<A>(node: ts.Node, matcher: AstMatcher<A>, cb: (bindings: A) => void): boolean;
export function matchAst<A>(
node: ts.Node,
matcher: AstMatcher<A>,
cb?: (bindings: A) => void,
): boolean | A | undefined {
const matched = matcher([node]);
if (cb) {
if (matched) {
cb(matched);
}
return !!matched;
}
return matched;
}
/**
* Count the newlines in a given piece of string that aren't in comment blocks
*/
export function countNakedNewlines(str: string) {
let ret = 0;
for (const s of scanText(str, 0, str.length).filter((r) => r.type === 'other' || r.type === 'blockcomment')) {
if (s.type === 'other') {
// Count newlines in non-comments
for (let i = s.pos; i < s.end; i++) {
if (str[i] === '\n') {
ret++;
}
}
} else {
// Discount newlines at the end of block comments
if (s.hasTrailingNewLine) {
ret--;
}
}
}
return ret;
}
export function repeatNewlines(str: string) {
return '\n'.repeat(Math.min(2, countNakedNewlines(str)));
}
const WHITESPACE = [' ', '\t', '\r', '\n'];
/**
* Extract single-line and multi-line comments from the given string
*
* Rewritten because I can't get ts.getLeadingComments and ts.getTrailingComments to do what I want.
*/
export function extractComments(text: string, start: number): ts.CommentRange[] {
return scanText(text, start)
.filter((s) => s.type === 'blockcomment' || s.type === 'linecomment')
.map(commentRangeFromTextRange);
}
export function commentRangeFromTextRange(rng: TextRange): ts.CommentRange {
return {
kind: rng.type === 'blockcomment' ? ts.SyntaxKind.MultiLineCommentTrivia : ts.SyntaxKind.SingleLineCommentTrivia,
pos: rng.pos,
end: rng.end,
hasTrailingNewLine: rng.type !== 'blockcomment' && rng.hasTrailingNewLine,
};
}
interface TextRange {
pos: number;
end: number;
type: 'linecomment' | 'blockcomment' | 'other' | 'directive';
hasTrailingNewLine: boolean;
}
/**
* Extract spans of comments and non-comments out of the string
*
* Stop at 'end' when given, or the first non-whitespace character in a
* non-comment if not given.
*/
export function scanText(text: string, start: number, end?: number): TextRange[] {
const ret: TextRange[] = [];
let pos = start;
const stopAtCode = end === undefined;
if (end === undefined) {
end = text.length;
}
while (pos < end) {
const ch = text[pos];
if (WHITESPACE.includes(ch)) {
pos++;
continue;
}
if (ch === '/' && text[pos + 1] === '/') {
accumulateTextBlock();
scanSinglelineComment();
continue;
}
if (ch === '/' && text[pos + 1] === '*') {
accumulateTextBlock();
scanMultilineComment();
continue;
}
// Non-whitespace, non-comment, must be regular token. End if we're not scanning
// to a particular location, otherwise continue.
if (stopAtCode) {
break;
}
pos++;
}
accumulateTextBlock();
return ret;
function scanMultilineComment() {
const endOfComment = findNext('*/', pos + 2);
ret.push({
type: 'blockcomment',
hasTrailingNewLine: ['\n', '\r'].includes(text[endOfComment + 2]),
pos,
end: endOfComment + 2,
});
pos = endOfComment + 2;
start = pos;
}
function scanSinglelineComment() {
const nl = Math.min(findNext('\r', pos + 2), findNext('\n', pos + 2));
if (text[pos + 2] === '/') {
// Special /// comment
ret.push({
type: 'directive',
hasTrailingNewLine: true,
pos: pos + 1,
end: nl,
});
} else {
// Regular // comment
ret.push({
type: 'linecomment',
hasTrailingNewLine: true,
pos,
end: nl,
});
}
pos = nl + 1;
start = pos;
}
function accumulateTextBlock() {
if (pos - start > 0) {
ret.push({
type: 'other',
hasTrailingNewLine: false,
pos: start,
end: pos,
});
start = pos;
}
}
function findNext(sub: string, startPos: number) {
const f = text.indexOf(sub, startPos);
if (f === -1) {
return text.length;
}
return f;
}
}
const VOID_SHOW_KEYWORD = 'show';
export function extractMaskingVoidExpression(node: ts.Node): ts.VoidExpression | undefined {
const expr = extractVoidExpression(node);
if (!expr) {
return undefined;
}
if (ts.isStringLiteral(expr.expression) && expr.expression.text === VOID_SHOW_KEYWORD) {
return undefined;
}
return expr;
}
export function extractShowingVoidExpression(node: ts.Node): ts.VoidExpression | undefined {
const expr = extractVoidExpression(node);
if (!expr) {
return undefined;
}
if (ts.isStringLiteral(expr.expression) && expr.expression.text === VOID_SHOW_KEYWORD) {
return expr;
}
return undefined;
}
/**
* Return the string argument to a void expression if it exists
*/
export function voidExpressionString(node: ts.VoidExpression): string | undefined {
if (ts.isStringLiteral(node.expression)) {
return node.expression.text;
}
return undefined;
}
/**
* We use void directives as pragmas. Extract the void directives here
*/
export function extractVoidExpression(node: ts.Node): ts.VoidExpression | undefined {
if (ts.isVoidExpression(node)) {
return node;
}
if (ts.isExpressionStatement(node)) {
return extractVoidExpression(node.expression);
}
if (ts.isParenthesizedExpression(node)) {
return extractVoidExpression(node.expression);
}
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.CommaToken) {
return extractVoidExpression(node.left);
}
return undefined;
}
export function quoteStringLiteral(x: string) {
return x.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
export function visibility(
x:
| ts.AccessorDeclaration
| ts.FunctionLikeDeclaration
| ts.GetAccessorDeclaration
| ts.PropertyDeclaration
| ts.PropertySignature
| ts.SetAccessorDeclaration,
) {
const flags = ts.getCombinedModifierFlags(x);
if (flags & ts.ModifierFlags.Private) {
return 'private';
}
if (flags & ts.ModifierFlags.Protected) {
return 'protected';
}
return 'public';
}
function hasFlag<T extends ts.Declaration>(flag: ts.ModifierFlags) {
return (x: T) => {
const flags = ts.getCombinedModifierFlags(x);
return (flags & flag) !== 0;
};
}
export const isReadOnly = hasFlag<
| ts.AccessorDeclaration
| ts.FunctionLikeDeclaration
| ts.GetAccessorDeclaration
| ts.PropertyDeclaration
| ts.PropertySignature
| ts.SetAccessorDeclaration
>(ts.ModifierFlags.Readonly);
export const isExported = hasFlag(ts.ModifierFlags.Export);
export const isPrivate = hasFlag(ts.ModifierFlags.Private);
export const isProtected = hasFlag(ts.ModifierFlags.Private);
export function isPublic(x: ts.Declaration) {
// In TypeScript, anything not explicitly marked private or protected is public.
return !isPrivate(x) && !isProtected(x);
}
export const isStatic = hasFlag(ts.ModifierFlags.Static);
/**
* Return the super() call from a method body if found
*/
export function findSuperCall(
node: ts.Block | ts.Expression | undefined,
renderer: AstRenderer<any>,
): ts.SuperCall | undefined {
if (node === undefined) {
return undefined;
}
if (ts.isCallExpression(node)) {
if (renderer.textOf(node.expression) === 'super') {
return node as unknown as ts.SuperCall;
}
}
if (ts.isExpressionStatement(node)) {
return findSuperCall(node.expression, renderer);
}
if (ts.isBlock(node)) {
for (const statement of node.statements) {
if (ts.isExpressionStatement(statement)) {
const s = findSuperCall(statement.expression, renderer);
if (s) {
return s;
}
}
}
}
return undefined;
}
/**
* Return the names of all private property declarations
*/
export function privatePropertyNames(members: readonly ts.ClassElement[], renderer: AstRenderer<any>): string[] {
const props = members.filter((m) => ts.isPropertyDeclaration(m)) as ts.PropertyDeclaration[];
return props.filter((m) => visibility(m) === 'private').map((m) => renderer.textOf(m.name));
}
export function findEnclosingClassDeclaration(node: ts.Node): ts.ClassDeclaration | undefined {
while (node && !ts.isClassDeclaration(node)) {
node = node.parent;
}
return node;
}