equicord-companion
Version:
Equicord Companion is a vscode extension to test Equicord patches & webpack finds right from the comfort of your IDE
352 lines (287 loc) • 11.7 kB
text/typescript
/* eslint-disable @stylistic/indent-binary-ops */
import { makeRange } from "@ast/util";
import { VencordAstParser } from "@ast/vencord";
import { outputChannel } from "@modules/logging";
import { sendAndGetData } from "@server/index";
import { SourcePatch } from "@type/ast";
import { PromiseProivderResult } from "@type/index";
import { ExtractModuleR } from "@type/server";
import { format } from "./format";
import { canonicalizeMatch, canonicalizeReplace, parseMatch, parseReplace } from "./patches";
import { formatModule } from "./util";
import DiffFunc, { DELETE, Diff } from "fast-diff";
import { nanoid } from "nanoid";
import {
CancellationToken,
commands,
Event,
EventEmitter,
TabChangeEvent,
TabInputText,
TextDocument,
TextDocumentChangeEvent,
TextEditor,
TextEditorRevealType,
Uri,
ViewColumn,
window,
workspace,
} from "vscode";
class LastTwo<T> {
constructor(private one: T, private two: T) {
}
push(val: T) {
this.one = this.two;
this.two = val;
return val;
}
get(): [T, T] {
return [this.one, this.two];
}
}
// uri format: equicord-patchhelper://patch/:id.js
export class PatchHelper {
private _lastPatch!: SourcePatch;
editor?: TextEditor;
private get lastPatch(): SourcePatch {
return this._lastPatch;
}
private set lastPatch(value: SourcePatch) {
this._lastPatch = value;
this.lastFindLength = this.ast
.getPatches()
.filter((e) => e.find === value.find).length;
}
// uri.path > PatchHelper[]
private static readonly activeWindows = new Map<Uri["path"], PatchHelper>();
private static readonly activeWindowsById = new Map<string, PatchHelper>();
private readonly id: string;
private ast: VencordAstParser;
private lastFindLength!: number;
private moduleData?: ExtractModuleR["data"];
private get displayUri() {
return Uri.parse(`equicord-patchhelper://patch/${this.id}.js`);
}
public static async create(doc: TextDocument, lastPatch: SourcePatch) {
if (PatchHelper.activeWindows.has(doc.uri.path)) {
const active = PatchHelper.activeWindows.get(doc.uri.path)!;
await active.setAndLoadPatch(lastPatch);
return active;
}
const newHelper = new PatchHelper(doc, lastPatch);
PatchHelper.activeWindows.set(doc.uri.path, newHelper);
PatchHelper.activeWindowsById.set(PatchHelper.idFromUri(newHelper.displayUri), newHelper);
await newHelper.setAndLoadPatch(lastPatch);
return newHelper;
}
private async setAndLoadPatch(lastPatch: SourcePatch) {
this.lastPatch = lastPatch;
try {
this.moduleData = (
await sendAndGetData<"extract">({
type: "extract",
data: {
extractType: "search",
findType: lastPatch.findType,
usePatched: false,
idOrSearch: lastPatch.find,
},
})
).data;
PatchHelper.changeEmitter.fire(this.displayUri);
} catch (e) {
const message = `PatchHelper: Could not load the new patch, exiting. Error: ${e}`;
outputChannel.error(message);
window.showErrorMessage(message);
this.end();
}
}
private constructor(private readonly doc: TextDocument, lastPatch: SourcePatch) {
this.id = nanoid();
this.ast = new VencordAstParser(doc);
this.lastPatch = lastPatch;
outputChannel.debug(`Opening new patch helper for patch ${JSON.stringify(lastPatch)}, with id ${this.id} and for document ${doc.uri.path}`);
}
public async openModuleWindow() {
if (!this.moduleData) {
await this.setAndLoadPatch(this.lastPatch);
}
workspace.openTextDocument(this.displayUri)
.then(async (doc) => {
this.editor = await window.showTextDocument(doc, ViewColumn.Beside, true);
});
}
private onChange(newAst: VencordAstParser) {
this.ast = newAst;
const patch = this.findPatch();
if (patch == null) {
const message = `Lost patch with\nfind: ${this.lastPatch.find}\nnum replacements: ${this.lastPatch.replacement.length}\nindex: ${this.lastPatch.origIndex}`;
outputChannel.info(message);
window.showErrorMessage(message);
this.end();
return;
}
this.lastPatch = patch;
PatchHelper.changeEmitter.fire(this.displayUri);
}
/**
* closes this window, disables this patch helper, and removes itself from the map
*/
private async end() {
PatchHelper.activeWindows.delete(this.doc.uri.path);
PatchHelper.activeWindowsById.delete(this.id);
const tab = window.tabGroups.all
.map((x) => x.tabs)
.flat()
.find((tab) => tab.input instanceof TabInputText
&& tab.input.uri.path === this.displayUri.path);
if (tab) {
outputChannel.debug(`closing tab with label ${tab.label}`);
await window.tabGroups.close(tab);
}
outputChannel.debug(`ending patch helper with id ${this.id}`);
}
private highlightChanges() {
const [was, is] = this.lastPatchedModule.get();
if (!was || !is || was === is)
return void outputChannel.warn("no changes to highlight");
const changes = DiffFunc(was, is);
let i = -1;
let cur: Diff;
let pos = 0;
while (++i < changes.length && !(cur = changes[i])[0])
pos += cur[1].length;
let end = pos;
while ((cur = changes[i++])[0] && i < changes.length)
cur[0] !== DELETE && (end += cur[1].length);
const rangeToShow = makeRange({
pos,
end,
}, is);
if (!this.editor)
return void outputChannel.warn("no editor to show changes");
outputChannel.debug(`highlighting changes from ${pos} to ${end}`);
this.editor.revealRange(rangeToShow, TextEditorRevealType.InCenter);
}
/**
* finds the patch in a changed document
*
* check how many times our find appears, if >1, we need take a good guess or return null of there is no good guess
*/
// TODO: make this more fault tolerant
private findPatch(): SourcePatch | null {
const patches = this.ast.getPatches();
if (this.lastFindLength === 1) {
const possibleByFind = patches.filter((e) => e.find === this.lastPatch.find);
if (possibleByFind.length === 1)
return (this.lastPatch = possibleByFind[0]);
}
const strmatch = this.lastPatch.replacement
.filter((e) => e.match.type === "string")
.map<string>((e) => e.match.value as any);
const regexmatch = this.lastPatch.replacement
.filter((e) => e.match.type === "regex")
.map<string>((e) => (e as any).match.value.pattern);
const possibleCandidates = patches.filter((maybe) => maybe.find === this.lastPatch.find
&& Math.abs(maybe.replacement.length - this.lastPatch.replacement.length) < 2
&& maybe.replacement.some((maybeReplacement) => {
if (maybeReplacement.match.type === "string") {
return strmatch.some((e2) => e2 === maybeReplacement.match.value);
}
const x = maybeReplacement.match.value.pattern;
return regexmatch.some((e2) => e2 === x);
}));
if (possibleCandidates.length === 1)
return (this.lastPatch = possibleCandidates[0]);
return null;
}
private lastPatchedModule = new LastTwo("", "");
async patch(): PromiseProivderResult<string> {
if (!this.moduleData)
return null;
let code = this.moduleData.module;
for (let i = 0; i < this.lastPatch.replacement.length; i++) {
const { match, replace } = this.lastPatch.replacement[i];
try {
const matcher = canonicalizeMatch(parseMatch(match));
const replacer = canonicalizeReplace(parseReplace(replace), this.ast.getPluginName() || "MyPlugin");
// @ts-expect-error stupid overloading
const newSrc = code.replace(matcher, replacer);
if (code === newSrc)
throw new Error(`Patch ${JSON.stringify({
match,
replace,
})} had no effect`);
Function(newSrc);
code = newSrc;
} catch (e) {
outputChannel.warn(`Error in patch ${i + 1}: ${e.message}`);
continue;
}
}
return this.lastPatchedModule.push(await format(formatModule(code, this.moduleData.moduleNumber, false)));
}
private static idFromUri(uri: Uri) {
return uri.path.replace(/^\//, "");
}
// #region static handlers for vscode
private static changeEmitter = new EventEmitter<Uri>();
public static onDidChange: Event<Uri> = PatchHelper.changeEmitter.event;
public static async provideTextDocumentContent(uri: Uri, _token: CancellationToken): PromiseProivderResult<string> {
try {
const helper = PatchHelper.activeWindowsById.get(PatchHelper.idFromUri(uri));
if (!helper)
return null;
const toRet = await helper.patch();
helper.highlightChanges();
return toRet;
} catch (e) {
outputChannel.error(e);
return null;
}
}
/**
* @param e handle closing of our source documents
*/
public static onCloseDocument(e: TextDocument) {
if (e.uri.scheme === "equicord-patchhelper") {
const helper = PatchHelper.activeWindowsById.get(PatchHelper.idFromUri(e.uri));
helper?.end();
}
const helper = PatchHelper.activeWindows.get(e.uri.path);
helper?.end();
}
public static changeDocument({ document }: TextDocumentChangeEvent) {
try {
const helper = PatchHelper.activeWindows.get(document.uri.path);
if (!helper)
return;
const newast = new VencordAstParser(document);
helper.onChange(newast);
} catch (e) {
window.showErrorMessage(String(e));
}
}
/**
* there is no way to open a document in read-only mode or set it via an URI
*
* wait for any of our editors to be opened, and set them to read only mode right away
*/
public static changeActiveEditor(_editor: TextEditor | undefined): void {
if (
window.activeTextEditor?.document.uri.scheme === "equicord-patchhelper"
) {
outputChannel.debug(`setting active editor with URI: (${window.activeTextEditor?.document.uri.toString()}) to read only`);
commands.executeCommand("workbench.action.files.setActiveEditorReadonlyInSession");
}
}
public static onTabClose({ closed }: TabChangeEvent) {
closed.flatMap(({ input }) => {
if (!(input instanceof TabInputText))
return [];
return PatchHelper.activeWindowsById.get(PatchHelper.idFromUri(input.uri)) ?? [];
})
.forEach((x) => x.end());
}
// #endregion
}