equicord-companion
Version:
Equicord Companion is a vscode extension to test Equicord patches & webpack finds right from the comfort of your IDE
447 lines (386 loc) • 13.7 kB
text/typescript
import { format } from "@modules/format";
import { outputChannel } from "@modules/logging";
import { mkStringUri } from "@modules/util";
import { Functionish } from "@type/ast";
import { Cache, CacheGetter, CharCode, isEOL } from "./util";
import { collectVariableUsage, VariableInfo } from "tsutils/util/usage";
import { getTokenAtPosition } from "tsutils/util/util";
import {
AssignmentExpression,
AssignmentOperatorToken,
CallExpression,
createSourceFile,
Expression,
Identifier,
isArrowFunction,
isBigIntLiteral,
isBinaryExpression,
isConstructorDeclaration,
isFunctionDeclaration,
isFunctionExpression,
isFunctionLike,
isGetAccessorDeclaration,
isIdentifier,
isJsxText,
isMethodDeclaration,
isNumericLiteral,
isPropertyAccessExpression,
isRegularExpressionLiteral,
isSetAccessorDeclaration,
isStringLiteralLike,
isVariableDeclaration,
LeftHandSideExpression,
LiteralToken,
MemberName,
Node,
PropertyAccessExpression,
ReadonlyTextRange,
ScriptKind,
ScriptTarget,
SourceFile,
SyntaxKind,
VariableDeclaration,
} from "typescript";
import { Position, Range, Uri } from "vscode";
export class AstParser {
public static withFormattedText(text: string) {
return new this(format(text));
}
public readonly text: string;
public get sourceFile(): SourceFile {
return this.createSourceFile();
}
/**
* All the variables in the source file
*/
public get vars(): Map<Identifier, VariableInfo> {
return collectVariableUsage(this.sourceFile);
}
public getVarInfoFromUse(ident: Identifier): VariableInfo | undefined {
const toRet = [...this.vars.values()].find((x) => x.uses.some((use) => use.location === ident));
if (!toRet) {
outputChannel.debug("getVarInfoFromUse: no variable info found for identifier");
}
return toRet;
}
// FIXME: add tests for this
/**
* @param use a use of a variable
* @param decl a declaration of a variable
* @returns true of the use is a use of the declaration, false otherwise
*/
public isUseOf(use: Identifier, decl: Identifier): boolean {
if (!decl || !use)
return false;
const varInfo = this.vars.get(decl);
if (!varInfo)
return false;
return varInfo.uses.some(({ location }) => location === use);
}
public constructor(text: string) {
this.text = text;
}
/**
* given something like this
* ```js
* const bar = "foo";
* const baz = bar;
* const qux = baz;
* ```
* if given `qux` it will return `[bar, baz]`;
*
* fails on something where a variable is reassigned
*/
public unwrapVariableDeclaration(ident: Identifier): Identifier[] | undefined {
const arr: Identifier[] = [];
let last = ident;
while (true) {
const [varDec, ...rest] = this.getVarInfoFromUse(last)?.declarations ?? [];
if (!varDec)
break;
if (rest.length) {
arr.length = 0;
break;
}
arr.push(last = varDec);
}
if (arr.length !== 0)
return arr;
outputChannel.debug("Failed finding variable declaration");
}
public isCallExpression(node: Node | undefined): node is CallExpression {
return node?.kind === SyntaxKind.CallExpression;
}
/**
* Returns an {@link Uri} for this file that can be used
* to open this file.
*/
public mkStringUri(): Uri {
return mkStringUri(this.text);
}
/**
* given the `x` of
* ```js
* const x = {
* foo: bar
* }
* ```
* NOTE: this must be the exact x, not a use of it
* @returns the expression {foo: bar}
*/
public getVariableInitializer(ident: Identifier): Expression | undefined {
const dec = ident.parent;
if (!isVariableDeclaration(dec))
return;
return dec.initializer;
}
public isVariableAssignmentLike(node: Node | undefined):
node is
| (
& Omit<VariableDeclaration, "name" | "initializer">
& {
name: Identifier;
initilizer: Exclude<VariableDeclaration["initializer"], undefined>;
}
)
| (Omit<AssignmentExpression<AssignmentOperatorToken>, "left"> & { left: Identifier; }) {
if (!node)
return false;
if (isVariableDeclaration(node)) {
return isIdentifier(node.name) && !!node.initializer;
} else if (isBinaryExpression(node)) {
return this.isAssignmentExpression(node);
}
return false;
}
public isAssignmentExpression(node: Node | undefined):
node is AssignmentExpression<AssignmentOperatorToken> {
if (!node || !isBinaryExpression(node) || !isIdentifier(node.left))
return false;
switch (node.operatorToken.kind) {
case SyntaxKind.EqualsToken:
case SyntaxKind.PlusEqualsToken:
case SyntaxKind.MinusEqualsToken:
case SyntaxKind.AsteriskAsteriskEqualsToken:
case SyntaxKind.AsteriskEqualsToken:
case SyntaxKind.SlashEqualsToken:
case SyntaxKind.PercentEqualsToken:
case SyntaxKind.AmpersandEqualsToken:
case SyntaxKind.BarEqualsToken:
case SyntaxKind.CaretEqualsToken:
case SyntaxKind.LessThanLessThanEqualsToken:
case SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken:
case SyntaxKind.GreaterThanGreaterThanEqualsToken:
case SyntaxKind.BarBarEqualsToken:
case SyntaxKind.AmpersandAmpersandEqualsToken:
case SyntaxKind.QuestionQuestionEqualsToken:
return true;
default:
return false;
}
}
// TODO: add tests for this
/**
* @param expr the property access expression to flatten
*
* given a property access expression like `foo.bar.baz.qux`
*
* @returns the identifiers [`foo`, `bar`, `baz`, `qux`]
*
* given another property access expression like `foo.bar.baz[0].qux.abc`
*
* @returns the elementAccessExpression, followed by the identifiers [`foo.bar.baz[0]`, `qux`, `abc`]
*/
public flattenPropertyAccessExpression(expr: PropertyAccessExpression | undefined):
| readonly [LeftHandSideExpression, ...MemberName[]]
| undefined {
if (!expr)
return undefined;
const toRet = [] as any as [LeftHandSideExpression, ...MemberName[]];
let cur = expr;
do {
toRet.unshift(cur.name);
if (isIdentifier(cur.expression)) {
toRet.unshift(cur.expression);
return toRet;
}
if (!isPropertyAccessExpression(cur.expression)) {
toRet.unshift(cur.expression);
return;
}
} while ((cur = cur.expression));
}
/**
* Create the source file for this parser
*
* MUST SET PARENT NODES
*/
protected createSourceFile(): SourceFile {
return createSourceFile(
"file.tsx",
this.text,
ScriptTarget.ESNext,
true,
ScriptKind.TSX,
);
}
/** Returns the token at or following the specified position or undefined if none is found inside `parent`. */
public getTokenAtOffset(pos: number): Node | undefined {
return getTokenAtPosition(this.sourceFile, pos, this.sourceFile, false);
}
public getTokenAtPosition(pos: Position): Node | undefined {
return this.getTokenAtOffset(this.offsetAt(pos));
}
/**
* convert two offsets to a range
* DO NOT USE WITH AN AST NODE, IT WILL LEAD TO INCORRECT LOCATIONS
* @see makeRangeFromAstNode
*/
public makeRange({ pos, end }: ReadonlyTextRange): Range {
return new Range(this.positionAt(pos), this.positionAt(end));
}
public makeRangeFromAstNode(node: Node) {
return new Range(this.positionAt(node.getStart(this.sourceFile)), this.positionAt(node.end));
}
public makeRangeFromAnonFunction(func: Functionish): Range {
const { pos } = func.body ?? { pos: func.getEnd() };
return this.makeRange({
pos: func.getStart(),
end: pos,
});
}
public makeRangeFromFunctionDef(ident: Identifier): Range | undefined {
const { declarations } = this.getVarInfoFromUse(ident) ?? {};
if (!declarations) {
outputChannel.debug("makeRangeFromFunctionDef: no declarations found for identifier");
return undefined;
}
if (declarations.length !== 1) {
outputChannel.debug("makeRangeFromFunctionDef: zero or multiple declarations found for identifier");
return undefined;
}
if (declarations[0].parent && !isFunctionLike(declarations[0].parent)) {
outputChannel.debug("makeRangeFromFunctionDef: dec. parent is not a function");
return undefined;
}
return this.makeRangeFromAstNode(declarations[0]);
}
public isLiteralish(node: Node): node is LiteralToken {
return isStringLiteralLike(node)
|| isNumericLiteral(node)
|| isBigIntLiteral(node)
|| isJsxText(node)
|| isRegularExpressionLiteral(node);
}
public isFunctionish(node: Node): node is Functionish {
return (
isFunctionDeclaration(node)
|| isMethodDeclaration(node)
|| isGetAccessorDeclaration(node)
|| isSetAccessorDeclaration(node)
|| isConstructorDeclaration(node)
|| isFunctionExpression(node)
|| isArrowFunction(node)
);
}
public isIdentifier(node: Node | undefined): node is Identifier {
return !!node && isIdentifier(node);
}
/**
* Converts the position to a zero-based offset.
* Invalid positions are adjusted as described in {@link Position.line}
* and {@link Position.character}.
*
* @param position A position.
* @return A valid zero-based offset.
*/
// copied from vscode-languageserver-node
public offsetAt(position: Position): number {
const { lineOffsets } = this;
if (position.line >= lineOffsets.length) {
return this.text.length;
} else if (position.line < 0) {
return 0;
}
const lineOffset = lineOffsets[position.line];
if (position.character <= 0) {
return lineOffset;
}
const nextLineOffset
= position.line + 1 < lineOffsets.length
? lineOffsets[position.line + 1]
: this.text.length;
const offset = Math.min(lineOffset + position.character, nextLineOffset);
return this.ensureBeforeEOL(offset, lineOffset);
}
// methods copied from vscode-languageserver-node
private get lineOffsets() {
return this.computeLineOffsets(true);
}
public get lineCount() {
return this.lineOffsets.length;
}
private ensureBeforeEOL(offset: number, lineOffset: number): number {
while (offset > lineOffset && isEOL(this.text.charCodeAt(offset - 1))) {
offset--;
}
return offset;
}
private computeLineOffsets(isAtLineStart: boolean, textOffset = 0): number[] {
const { text } = this;
const result: number[] = isAtLineStart ? [textOffset] : [];
for (let i = 0; i < text.length; i++) {
const ch = text.charCodeAt(i);
if (isEOL(ch)) {
if (
ch === CharCode.CarriageReturn
&& i + 1 < text.length
&& text.charCodeAt(i + 1) === CharCode.LineFeed
) {
i++;
}
result.push(textOffset + i + 1);
}
}
return result;
}
/**
* Converts a zero-based offset to a position.
*
* @param offset A zero-based offset.
* @return A valid {@link Position position}.
* @example The text document "ab\ncd" produces:
* position { line: 0, character: 0 } for `offset` 0.
* position { line: 0, character: 1 } for `offset` 1.
* position { line: 0, character: 2 } for `offset` 2.
* position { line: 1, character: 0 } for `offset` 3.
* position { line: 1, character: 1 } for `offset` 4.
*/
public positionAt(offset: number): Position {
offset = Math.max(Math.min(offset, this.text.length), 0);
const { lineOffsets } = this;
let low = 0,
high = lineOffsets.length;
if (high === 0) {
return new Position(0, offset);
}
while (low < high) {
const mid = Math.floor((low + high) / 2);
if (lineOffsets[mid] > offset) {
high = mid;
} else {
low = mid + 1;
}
}
// low is the least x for which the line offset is larger than the current offset
// or array.length if no line offset is larger than the current offset
const line = low - 1;
offset = this.ensureBeforeEOL(offset, lineOffsets[line]);
return new Position(line, offset - lineOffsets[line]);
}
}