codemods/analyzers/identifier.analyzer.ts
codemods/analyzers/identifier.analyzer.ts is a file in GovLab Extension Host. 149 lines of code and 36 definitions.
import type { IdentifierFinding, RenameLocation } from "../../types/analyzer.types.ts";
import { inRepo, lineOf, relPath, toPosix } from "../selectors/program.selector.ts";
import ts from "typescript";
const UPPER_A = 65;
const UPPER_Z = 90;
const DIGIT_0 = 48;
const DIGIT_9 = 57;
const UNDERSCORE = 95;
const isUpperAlpha = function isUpperAlpha(code: number): boolean {
return code >= UPPER_A && code <= UPPER_Z;
};
const isConstantCase = function isConstantCase(name: string): boolean {
for (let i = 0; i < name.length; i += 1) {
const code = name.codePointAt(i) ?? 0;
const allowed = isUpperAlpha(code) || code === UNDERSCORE || (code >= DIGIT_0 && code <= DIGIT_9);
if (!allowed) {
return false;
}
}
return true;
};
const isPascalCase = function isPascalCase(name: string): boolean {
if (name.length === 0 || !isUpperAlpha(name.codePointAt(0) ?? 0)) {
return false;
}
return !isConstantCase(name);
};
const camelized = function camelized(name: string): string {
return name.charAt(0).toLowerCase() + name.slice(1);
};
const isGenerated = function isGenerated(fileName: string): boolean {
return toPosix(fileName).includes(".generated.");
};
const isConstDeclaration = function isConstDeclaration(node: ts.VariableDeclaration): boolean {
const list = node.parent;
if (!ts.isVariableDeclarationList(list)) {
return false;
}
return (list.flags & ts.NodeFlags.Const) !== 0;
};
const declarationsOf = function declarationsOf(sourceFile: ts.SourceFile): ts.VariableDeclaration[] {
const out: ts.VariableDeclaration[] = [];
const visit = function visit(node: ts.Node): void {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && isConstDeclaration(node)) {
out.push(node);
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return out;
};
const unaliased = function unaliased(checker: ts.TypeChecker, symbol: ts.Symbol | undefined): ts.Symbol | undefined {
if (!symbol) {
return undefined;
}
return (symbol.flags & ts.SymbolFlags.Alias) === 0 ? symbol : checker.getAliasedSymbol(symbol);
};
const targetSymbol = function targetSymbol(checker: ts.TypeChecker, node: ts.Identifier): ts.Symbol | undefined {
const { parent } = node;
if (ts.isShorthandPropertyAssignment(parent)) {
return unaliased(checker, checker.getShorthandAssignmentValueSymbol(parent));
}
return unaliased(checker, checker.getSymbolAtLocation(node));
};
const shorthandBlocked = function shorthandBlocked(node: ts.Identifier): boolean {
const { parent } = node;
return ts.isShorthandPropertyAssignment(parent) || ts.isBindingElement(parent);
};
const identifiersIn = function identifiersIn(sourceFile: ts.SourceFile): ts.Identifier[] {
const out: ts.Identifier[] = [];
const visit = function visit(node: ts.Node): void {
if (ts.isIdentifier(node)) {
out.push(node);
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return out;
};
const collectReferences = function collectReferences(
program: ts.Program,
files: readonly ts.SourceFile[],
declared: ts.Symbol,
): { blocked: boolean; locations: RenameLocation[] } {
const checker = program.getTypeChecker();
const referencing = files.flatMap((sourceFile) =>
identifiersIn(sourceFile)
.filter((node) => targetSymbol(checker, node) === declared)
.map((node) => ({ node, sourceFile })),
);
const locations = referencing
.filter((hit) => !shorthandBlocked(hit.node))
.map((hit) => ({
end: hit.node.getEnd(),
fileName: hit.sourceFile.fileName,
start: hit.node.getStart(hit.sourceFile),
}));
return { blocked: referencing.some((hit) => shorthandBlocked(hit.node)), locations };
};
const nameTakenInFile = function nameTakenInFile(sourceFile: ts.SourceFile, name: string): boolean {
const locals: unknown = Reflect.get(sourceFile, "locals");
return locals instanceof Map && locals.has(name);
};
const blockedReason = function blockedReason(blocked: boolean, collides: boolean, to: string): string | null {
if (collides) {
return `the file already declares '${to}', so renaming would shadow or collide with an existing binding`;
}
if (blocked) {
return "the name is used in a shorthand property or binding pattern, where renaming would silently change the property key";
}
return null;
};
const findingsIn = function findingsIn(
program: ts.Program,
files: readonly ts.SourceFile[],
sourceFile: ts.SourceFile,
): IdentifierFinding[] {
const checker = program.getTypeChecker();
const out: IdentifierFinding[] = [];
const renameable = declarationsOf(sourceFile)
.map((declaration) => declaration.name)
.filter((name) => ts.isIdentifier(name) && isPascalCase(name.text));
for (const name of renameable) {
const declared = ts.isIdentifier(name) ? targetSymbol(checker, name) : undefined;
if (declared === undefined || !ts.isIdentifier(name)) {
continue;
}
const to = camelized(name.text);
const { blocked, locations } = collectReferences(program, files, declared);
out.push({
file: relPath(sourceFile.fileName),
fileName: sourceFile.fileName,
from: name.text,
line: lineOf(sourceFile, name),
locations: locations.filter((location) => inRepo(location.fileName) && !isGenerated(location.fileName)),
reason: blockedReason(blocked, nameTakenInFile(sourceFile, to), to),
to,
});
}
return out;
};
export const scanProgram = function scanProgram(
program: ts.Program,
files: readonly ts.SourceFile[],
): IdentifierFinding[] {
return files
.filter((sourceFile) => !isGenerated(sourceFile.fileName))
.flatMap((sourceFile) => findingsIn(program, files, sourceFile));
};