From 075893fc854fac2cb1b893c9e72040c86b634e3a Mon Sep 17 00:00:00 2001 From: Tony Garnock-Jones Date: Fri, 28 Apr 2023 10:32:49 +0200 Subject: [PATCH] Fix up a few of the more egregious problems with the plugin. TypeScript has been moving on while the plugin has been staying still, and that has caused the plugin to stop working well. This patch gets things back into somewhat workable state, but I'm sure more will be required. Changes include: - `hookHost` is no longer required: instead, we hook ts.createSourceFile. - `shouldExpand` abstracts away details of whether a file is considered Syndicateish or not. - The code that does the Syndicate expansion has been abstracted out of the detail of how expansion is invoked by the language server. - I've had to monkey-patch ScriptInfo in order to get access to the `editContent` method. - As a consequence, at plugin startup, we invalidate existing ScriptInfo instances so they are rebuilt with our SyndicateScriptInfo constructor. --- packages/ts-plugin/src/index.ts | 237 +++++++++++++++++++------------- 1 file changed, 143 insertions(+), 94 deletions(-) diff --git a/packages/ts-plugin/src/index.ts b/packages/ts-plugin/src/index.ts index 92f4853..56f8631 100644 --- a/packages/ts-plugin/src/index.ts +++ b/packages/ts-plugin/src/index.ts @@ -20,6 +20,16 @@ const boot: tslib.server.PluginModuleFactory = ({ typescript: ts }) => { const syndicateRootDirs: Set = new Set(); + function shouldExpand(fileName: string): boolean { + let result = false; + syndicateRootDirs.forEach(d => { + if (fileName.startsWith(d)) { + result = true; + } + }); + return result; + } + function getInfo(fileName: string): SyndicateInfo | undefined { return syndicateInfo.get(fileName); } @@ -132,111 +142,125 @@ const boot: tslib.server.PluginModuleFactory = ({ typescript: ts }) => { return withPositions(fileName, [position], kNoInfo, kNoPosition, ([f]) => k(f)); } - function hookHost(host0: ts.CompilerHost | undefined, - options: ts.CompilerOptions) - { - const host = (host0 === void 0) ? ts.createCompilerHost(options, true) : host0; + function compileWithDiagnostics( + name: string, + source: string, + makeSourceFile: (expandedText: string) => ts.SourceFile, + ): { + expandedText: string, + targetToSourceMap: Syntax.SpanIndex, + sourceToTargetMap: Syntax.SpanIndex, + sourceFile: ts.SourceFile, + diagnostics: ts.DiagnosticWithLocation[], + } { + const diagnostics: Array<{ message: string, start?: Syntax.Pos, end?: Syntax.Pos }> = []; + // console.log(`Syndicate expanding ${name}: ${JSON.stringify(source)}`); + console.log(`Syndicate expanding ${name}`); + const { text: expandedText, targetToSourceMap, sourceToTargetMap } = compile({ + source, + name, + typescript: true, + emitError: (message, start, end) => { + console.error(`${Syntax.formatPos(start)}-${Syntax.formatPos(end)}: ${message}`); + diagnostics.push({ message, start, end }); + }, + }); + const sourceFile = makeSourceFile(expandedText); + return { + expandedText, + targetToSourceMap, + sourceToTargetMap, + sourceFile, + diagnostics: diagnostics.map(({ message, start, end }) => ({ + category: ts.DiagnosticCategory.Error, + code: 99999, + file: sourceFile, + start: start?.pos ?? 0, + length: Math.max(0, (end?.pos ?? 0) - (start?.pos ?? 0)), + messageText: message, + })), + }; + } - if ('Syndicate_hooked' in host) { - console.warn('Syndicate plugin refusing to hook CompilerHost twice'); - } else { - (host as any).Syndicate_hooked = true; - - const oldGetSourceFile = host.getSourceFile; - host.getSourceFile = getSourceFile; - - function getSourceFile(fileName: string, - languageVersion: ts.ScriptTarget, - onError?: ((message: string) => void), - shouldCreateNewSourceFile?: boolean): ts.SourceFile | undefined - { - let shouldExpand = false; - syndicateRootDirs.forEach(d => { - if (fileName.startsWith(d)) { - shouldExpand = true; - } - }); - if (shouldExpand) { - try { - const inputText = host.readFile(fileName); - if (inputText === void 0) { - onError?.(`Could not read input file ${fileName}`); - return undefined; - } - const diagnostics: - Array<{ message: string, start?: Syntax.Pos, end?: Syntax.Pos }> = []; - console.log('Syndicate compiling', fileName); - const { text: expandedText, targetToSourceMap, sourceToTargetMap } = compile({ - source: inputText, - name: fileName, - typescript: true, - emitError: (message, start, end) => { - console.error(`${Syntax.formatPos(start)}-${Syntax.formatPos(end)}: ${message}`); - diagnostics.push({ message, start, end }); - }, - }); - const sf = ts.createSourceFile(fileName, expandedText, languageVersion, true); - syndicateInfo.set(fileName, { - sourceFile: sf, - originalSource: inputText, - diagnostics: diagnostics.map(({ message, start, end }) => ({ - category: ts.DiagnosticCategory.Error, - code: 99999, - file: sf, - start: start?.pos ?? 0, - length: Math.max(0, (end?.pos ?? 0) - (start?.pos ?? 0)), - messageText: message, - })), - targetToSourceMap, - sourceToTargetMap, - }); - (sf as any).version = crypto.createHash('sha256').update(expandedText).digest('hex'); - return sf; - } catch (e) { - console.error(e); - onError?.((e as any).message ?? ''); - return undefined; - } - } else { - return oldGetSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile); - } - } - } - - return host; + function expandFile( + fileName: string, + inputText: string, + makeSourceFile: (expandedText: string) => ts.SourceFile, + ): ts.SourceFile { + const { expandedText, sourceFile, targetToSourceMap, sourceToTargetMap, diagnostics } = + compileWithDiagnostics(fileName, inputText, makeSourceFile); + const info = { + sourceFile, + originalSource: inputText, + diagnostics, + targetToSourceMap, + sourceToTargetMap, + }; + syndicateInfo.set(fileName, info); + (sourceFile as any).version = + crypto.createHash('sha256').update(expandedText).digest('hex'); + return sourceFile; } { - const oldCreateProgram = ts.createProgram; + const oldCreateSourceFile = ts.createSourceFile; - function createProgram(createProgramOptions: ts.CreateProgramOptions): ts.Program; - function createProgram(rootNames: readonly string[], - options: ts.CompilerOptions, - host?: ts.CompilerHost, - oldProgram?: ts.Program, - configFileParsingDiagnostics?: readonly ts.Diagnostic[]): ts.Program; - function createProgram(rootNamesOrOptions: readonly string[] | ts.CreateProgramOptions, - options?: ts.CompilerOptions, - host?: ts.CompilerHost, - oldProgram?: ts.Program, - configFileParsingDiagnostics?: readonly ts.Diagnostic[]) - : ts.Program - { - if (Array.isArray(rootNamesOrOptions)) { - const rootNames = rootNamesOrOptions; - host = hookHost(host, options!); - return oldCreateProgram(rootNames, options!, host, oldProgram, configFileParsingDiagnostics); + function createSourceFile( + fileName: string, + sourceText: string, + languageVersionOrOptions: ts.ScriptTarget | ts.CreateSourceFileOptions, + setParentNodes?: boolean, + scriptKind?: ts.ScriptKind, + ): ts.SourceFile { + const delegate = (text: string) => oldCreateSourceFile( + fileName, + text, + languageVersionOrOptions, + setParentNodes, + scriptKind); + if (shouldExpand(fileName)) { + // console.log('createSourceFile', fileName, sourceText); + return expandFile(fileName, sourceText, delegate); } else { - const createProgramOptions = rootNamesOrOptions as ts.CreateProgramOptions; - createProgramOptions.host = - hookHost(createProgramOptions.host, createProgramOptions.options); - return oldCreateProgram(createProgramOptions); + return delegate(sourceText); } } - ts.createProgram = createProgram; + ts.createSourceFile = createSourceFile; } + class SyndicateScriptInfo extends ts.server.ScriptInfo { + constructor( + host: ts.server.ServerHost, + fileName: ts.server.NormalizedPath, + scriptKind: ts.ScriptKind, + hasMixedContent: boolean, + path: ts.Path, + initialVersion?: ts.server.ScriptInfoVersion, + ) { + if (shouldExpand(fileName)) { + console.log('SyndicateScriptInfo constructor', fileName); + } + super(host, fileName, scriptKind, hasMixedContent, path, initialVersion); + } + + editContent(start: number, end: number, newText: string): void { + // console.log('SyndicateScriptInfo.editContent', this.fileName, start, end, newText); + withFileName(this.fileName, + () => super.editContent(start, end, newText), + (fx) => { + const info = fx.info; + const revised = info.originalSource.substring(0, start) + + newText + + info.originalSource.substring(end); + info.originalSource = revised; + this.open(revised); + }); + } + } + + ts.server.ScriptInfo = SyndicateScriptInfo; + class SyndicateLanguageService implements ts.LanguageService { readonly inner: ts.LanguageService; @@ -747,6 +771,31 @@ const boot: tslib.server.PluginModuleFactory = ({ typescript: ts }) => { if (options.rootDir === void 0 && options.rootDirs === void 0) { syndicateRootDirs.add(finalSlash(path.resolve('.'))); } + // This next little block is required to flush out previously-created ScriptInfo + // instances for our project, since they were created before our plugin was even + // loaded and our plugin monkey-patches ScriptInfo to SyndicateScriptInfo. + { + const infos: Map = + (createInfo.project.projectService as any).filenameToScriptInfo; + const oldInfos = Array.from(infos.values()); + infos.clear(); + for (const info of oldInfos) { + // console.log('---', info.fileName); + // console.log(JSON.stringify({ + // oldText: (info as any).textStorage.text, + // }, void 0, 2)); + const newInfo = new SyndicateScriptInfo( + (info as any).host, + info.fileName, + info.scriptKind, + info.hasMixedContent, + info.path, + (info as any).textStorage.version); + (newInfo as any).textStorage = (info as any).textStorage; + infos.set(info.fileName, newInfo); + createInfo.project.getSourceFile(info.fileName as any); + } + } return new SyndicateLanguageService(createInfo.languageService); } }