Files
TypeScript/src/services/mapCode.ts
2024-05-22 16:47:08 -07:00

324 lines
9.5 KiB
TypeScript

import {
Block,
ClassElement,
ClassLikeDeclaration,
createSourceFile,
FileTextChanges,
find,
findAncestor,
findLast,
flatten,
forEach,
formatting,
getTokenAtPosition,
isBlock,
isClassElement,
isClassLike,
isForInOrOfStatement,
isForStatement,
isIfStatement,
isInterfaceDeclaration,
isLabeledStatement,
isNamedDeclaration,
isSourceFile,
isTypeElement,
isWhileStatement,
LanguageServiceHost,
Mutable,
Node,
NodeArray,
or,
some,
SourceFile,
Statement,
SyntaxKind,
textChanges,
TextSpan,
TypeElement,
UserPreferences,
} from "./_namespaces/ts.js";
import { ChangeTracker } from "./textChanges.js";
/** @internal */
export function mapCode(
sourceFile: SourceFile,
contents: string[],
focusLocations: TextSpan[][] | undefined,
host: LanguageServiceHost,
formatContext: formatting.FormatContext,
preferences: UserPreferences,
): FileTextChanges[] {
return textChanges.ChangeTracker.with(
{ host, formatContext, preferences },
changeTracker => {
const parsed = contents.map(c => parse(sourceFile, c));
const flattenedLocations = focusLocations && flatten(focusLocations);
for (const nodes of parsed) {
placeNodeGroup(
sourceFile,
changeTracker,
nodes,
flattenedLocations,
);
}
},
);
}
/**
* Tries to parse something into either "top-level" statements, or into blocks
* of class-context definitions.
*/
function parse(sourceFile: SourceFile, content: string): NodeArray<Node> {
// We're going to speculatively parse different kinds of contexts to see
// which one makes the most sense, and grab the NodeArray from there. Do
// this as lazily as possible.
const nodeKinds = [
{
parse: () =>
createSourceFile(
"__mapcode_content_nodes.ts",
content,
sourceFile.languageVersion,
/*setParentNodes*/ true,
sourceFile.scriptKind,
),
body: (sf: SourceFile) => sf.statements,
},
{
parse: () =>
createSourceFile(
"__mapcode_class_content_nodes.ts",
`class __class {\n${content}\n}`,
sourceFile.languageVersion,
/*setParentNodes*/ true,
sourceFile.scriptKind,
),
body: (cw: SourceFile) => (cw.statements[0] as ClassLikeDeclaration).members,
},
];
const parsedNodes = [];
for (const { parse, body } of nodeKinds) {
const sourceFile = parse();
const bod = body(sourceFile);
if (bod.length && sourceFile.parseDiagnostics.length === 0) {
// If we run into a case with no parse errors, this is likely the right kind.
return bod;
}
// We only want to keep the ones that have some kind of body.
else if (bod.length) {
// Otherwise, we'll need to look at others.
parsedNodes.push({ sourceFile, body: bod });
}
}
// Heuristic: fewer errors = more likely to be the right kind.
const { body } = parsedNodes.sort(
(a, b) =>
a.sourceFile.parseDiagnostics.length -
b.sourceFile.parseDiagnostics.length,
)[0];
return body;
}
function placeNodeGroup(
originalFile: SourceFile,
changeTracker: ChangeTracker,
changes: NodeArray<Node>,
focusLocations?: TextSpan[],
) {
if (isClassElement(changes[0]) || isTypeElement(changes[0])) {
placeClassNodeGroup(
originalFile,
changeTracker,
changes as NodeArray<ClassElement>,
focusLocations,
);
}
else {
placeStatements(
originalFile,
changeTracker,
changes as NodeArray<Statement>,
focusLocations,
);
}
}
function placeClassNodeGroup(
originalFile: SourceFile,
changeTracker: ChangeTracker,
changes: NodeArray<ClassElement> | NodeArray<TypeElement>,
focusLocations?: TextSpan[],
) {
let classOrInterface;
if (!focusLocations || !focusLocations.length) {
classOrInterface = find(originalFile.statements, or(isClassLike, isInterfaceDeclaration));
}
else {
classOrInterface = forEach(focusLocations, location =>
findAncestor(
getTokenAtPosition(originalFile, location.start),
or(isClassLike, isInterfaceDeclaration),
));
}
if (!classOrInterface) {
// No class? don't insert.
return;
}
const firstMatch = classOrInterface.members.find(member => changes.some(change => matchNode(change, member)));
if (firstMatch) {
// can't be undefined here, since we know we have at least one match.
const lastMatch = findLast(
classOrInterface.members as NodeArray<ClassElement | TypeElement>,
member => changes.some(change => matchNode(change, member)),
)!;
forEach(changes, wipeNode);
changeTracker.replaceNodeRangeWithNodes(
originalFile,
firstMatch,
lastMatch,
changes,
);
return;
}
forEach(changes, wipeNode);
changeTracker.insertNodesAfter(
originalFile,
classOrInterface.members[classOrInterface.members.length - 1],
changes,
);
}
function placeStatements(
originalFile: SourceFile,
changeTracker: ChangeTracker,
changes: NodeArray<Statement>,
focusLocations?: TextSpan[],
) {
if (!focusLocations?.length) {
changeTracker.insertNodesAtEndOfFile(
originalFile,
changes,
/*blankLineBetween*/ false,
);
return;
}
for (const location of focusLocations) {
const scope = findAncestor(
getTokenAtPosition(originalFile, location.start),
(block): block is Block | SourceFile =>
or(isBlock, isSourceFile)(block) &&
some(block.statements, origStmt => changes.some(newStmt => matchNode(newStmt, origStmt))),
);
if (scope) {
const start = scope.statements.find(stmt => changes.some(node => matchNode(node, stmt)));
if (start) {
// Can't be undefined here, since we know we have at least one match.
const end = findLast(scope.statements, stmt => changes.some(node => matchNode(node, stmt)))!;
forEach(changes, wipeNode);
changeTracker.replaceNodeRangeWithNodes(
originalFile,
start,
end,
changes,
);
return;
}
}
}
let scopeStatements: NodeArray<Statement> = originalFile.statements;
for (const location of focusLocations) {
const block = findAncestor(
getTokenAtPosition(originalFile, location.start),
isBlock,
);
if (block) {
scopeStatements = block.statements;
break;
}
}
forEach(changes, wipeNode);
changeTracker.insertNodesAfter(
originalFile,
scopeStatements[scopeStatements.length - 1],
changes,
);
}
function matchNode(a: Node, b: Node): boolean {
if (a.kind !== b.kind) {
return false;
}
if (a.kind === SyntaxKind.Constructor) {
return a.kind === b.kind;
}
if (isNamedDeclaration(a) && isNamedDeclaration(b)) {
return a.name.getText() === b.name.getText();
}
if (isIfStatement(a) && isIfStatement(b)) {
return (
a.expression.getText() === b.expression.getText()
);
}
if (isWhileStatement(a) && isWhileStatement(b)) {
return (
a.expression.getText() ===
b.expression.getText()
);
}
if (isForStatement(a) && isForStatement(b)) {
return (
a.initializer?.getText() ===
b.initializer?.getText() &&
a.incrementor?.getText() ===
b.incrementor?.getText() &&
a.condition?.getText() === b.condition?.getText()
);
}
if (isForInOrOfStatement(a) && isForInOrOfStatement(b)) {
return (
a.expression.getText() ===
b.expression.getText() &&
a.initializer.getText() ===
b.initializer.getText()
);
}
if (isLabeledStatement(a) && isLabeledStatement(b)) {
// If we're actually labeling/naming something, we should be a bit
// more lenient about when we match, so we don't care what the actual
// related statement is: we just replace.
return a.label.getText() === b.label.getText();
}
if (a.getText() === b.getText()) {
return true;
}
return false;
}
function wipeNode(node: Mutable<Node>) {
resetNodePositions(node);
node.parent = undefined!;
}
function resetNodePositions(node: Mutable<Node>) {
node.pos = -1;
node.end = -1;
node.forEachChild(resetNodePositions);
}