Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use tsserver geterr command & events. reloadProjects for file renames #132

Merged
merged 1 commit into from
Jul 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 68 additions & 46 deletions packages/api/server/ws.mts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
import tsservers from '../tsservers.mjs';
import { TsServer } from '../tsserver/tsserver.mjs';
import WebSocketServer from './ws-client.mjs';
import { pathToCodeFile } from '../srcbook/path.mjs';
import { filenameFromPath, pathToCodeFile } from '../srcbook/path.mjs';
import { normalizeDiagnostic } from '../tsserver/utils.mjs';
import { removeCodeCellFromDisk } from '../srcbook/index.mjs';

Expand Down Expand Up @@ -312,12 +312,29 @@ async function cellUpdate(payload: CellUpdatePayloadType) {
tsserver.close({ file: oldFilePath });
tsserver.open({ file: newFilePath, fileContent: cellAfterUpdate.source });

// Send all diagnostics so that other cells that import this updated cell
// are informed about any updates (changes to exports, file renames).
//
// TODO: Can we be smarter and only do this for cells that import
// this cell, rather than all cells?
sendAllTypeScriptDiagnostics(tsserver, session);
// TODO: Given the amount of differences here and elsewhere when renaming cells,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, I was thinking the same thing

// it's probably worth it at this point to make those separate websocket events.
if (oldFilePath !== newFilePath) {
// Tsserver can get into a bad state if we don't reload the project after renaming a file.
// This consistently happens under the following condition:
//
// 1. Rename a `a.ts` that is imported by `b.ts` to `c.ts`
// 2. Semantic diagnostics report an error in `b.ts` that `a.ts` doesn't exist
// 3. Great, all works so far.
// 4. Rename `c.ts` back to `a.ts`.
// 5. Semantic diagnostics still report an error in `b.ts` that `a.ts` doesn't exist.
// 6. This is wrong, `a.ts` does exist.
//
// If we reload the project, this issue resolves itself.
//
// NOTE: reloading the project sends diagnostic events without calling `geterr`.
// However, it seems to take a while for the diagnostics to be sent, so we still
// request it below.
//
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nanonit: extra line here

tsserver.reloadProjects();
}

requestAllDiagnostics(tsserver, session);
}
}

Expand Down Expand Up @@ -351,44 +368,60 @@ async function cellDelete(payload: CellDeletePayloadType) {
const file = pathToCodeFile(updatedSession.dir, cell.filename);
const tsserver = tsservers.get(updatedSession.id);
tsserver.close({ file });
sendAllTypeScriptDiagnostics(tsserver, updatedSession);
requestAllDiagnostics(tsserver, updatedSession);
}
}
}

/**
* Send semantic diagnostics for a TypeScript cell to the client.
* Request async diagnostics for all files in the project.
*/
async function sendTypeScriptDiagnostics(
tsserver: TsServer,
session: SessionType,
cell: CodeCellType,
) {
const response = await tsserver.semanticDiagnosticsSync({
file: pathToCodeFile(session.dir, cell.filename),
});
function requestAllDiagnostics(tsserver: TsServer, session: SessionType, delay = 0) {
const codeCells = session.cells.filter((cell) => cell.type === 'code') as CodeCellType[];
const files = codeCells.map((cell) => pathToCodeFile(session.dir, cell.filename));
tsserver.geterr({ files, delay });
}

if (!response.success) {
console.warn(`Failed to get diagnostics for cell ${cell.id}: ${response.message}`);
return;
}
function createTsServer(session: SessionType) {
const tsserver = tsservers.create(session.id, { cwd: session.dir });

const sessionId = session.id;

tsserver.onSemanticDiag(async (event) => {
const eventBody = event.body;

// Get most recent session state
const session = await findSession(sessionId);

if (!eventBody || !session) {
return;
}

// The client will always reset diagnostics when the server sends them.
// Therefore, it is important to send diagnostics even when the list is
// empty because the client will not clear stale diagnostics otherwise.
const diagnostics = response.body || [];
wss.broadcast(`session:${session.id}`, 'tsserver:cell:diagnostics', {
cellId: cell.id,
diagnostics: diagnostics.map(normalizeDiagnostic),
const filename = filenameFromPath(eventBody.file);
const cells = session.cells.filter((cell) => cell.type === 'code') as CodeCellType[];
const cell = cells.find((c) => c.filename === filename);

if (!cell) {
return;
}

wss.broadcast(`session:${session.id}`, 'tsserver:cell:diagnostics', {
cellId: cell.id,
diagnostics: eventBody.diagnostics.map(normalizeDiagnostic),
});
});
}

function sendAllTypeScriptDiagnostics(tsserver: TsServer, session: SessionType) {
// Open all code cells in tsserver
for (const cell of session.cells) {
if (cell.type === 'code') {
sendTypeScriptDiagnostics(tsserver, session, cell);
tsserver.open({
file: pathToCodeFile(session.dir, cell.filename),
fileContent: cell.source,
});
}
}

return tsserver;
}

async function tsserverStart(payload: TsServerStartPayloadType) {
Expand All @@ -402,21 +435,10 @@ async function tsserverStart(payload: TsServerStartPayloadType) {
throw new Error(`tsserver can only be used with TypeScript Srcbooks.`);
}

if (!tsservers.has(session.id)) {
const tsserver = tsservers.create(session.id, { cwd: session.dir });

// Open all code cells in tsserver
for (const cell of session.cells) {
if (cell.type === 'code') {
tsserver.open({
file: pathToCodeFile(session.dir, cell.filename),
fileContent: cell.source,
});
}
}
}

sendAllTypeScriptDiagnostics(tsservers.get(session.id), session);
requestAllDiagnostics(
tsservers.has(session.id) ? tsservers.get(session.id) : createTsServer(session),
session,
);
}

async function tsserverStop(payload: TsServerStopPayloadType) {
Expand Down
4 changes: 4 additions & 0 deletions packages/api/srcbook/path.mts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ export function pathToTsconfigJson(baseDir: string) {
export function pathToCodeFile(baseDir: string, filename: string) {
return Path.join(baseDir, 'src', filename);
}

export function filenameFromPath(filePath: string) {
return Path.basename(filePath);
}
109 changes: 82 additions & 27 deletions packages/api/tsserver/tsserver.mts
Original file line number Diff line number Diff line change
@@ -1,14 +1,44 @@
import { parseTsServerMessages } from './utils.mjs';
import type { ChildProcess } from 'child_process';
import EventEmitter from 'node:events';
import type { ChildProcess } from 'node:child_process';
import type { server as tsserver } from 'typescript';
import { parseTsServerMessages } from './utils.mjs';

export class TsServer {
/**
* This class provides a wrapper around a process running tsserver and is used to communicate
* with the server, mainly to support diagnostics for user code (type errors, sytnax errors,
* type definitions, etc).
*
* tsserver is not documented. Here is a brief overview.
*
* tsserver is a process which listens for messages over stdin and
* sends messages over stdout. tsserver has three types of messages:
*
* 1. Request: A request from the client to the server.
* 2. Response: A response from the server to a specific client request.
* 3. Event: An event from the server to the client.
*
* Request and responses are identified a unique number called `seq`. `seq` is incremented
* for each request the client sends. The client will send a `seq` field with its request
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

identified by* a unique

* and the server will provide a `request_seq` in its response which is used to tie a message
* from the server to a specific request from the client.
*
* Events can arrive at any time but are often used as an asynchronous response from the server.
* For example, syntax and semantic diagnostics are sent as events when using the `geterr` command.
*
* Most of this is learned by reading through the source (protocol.ts) as well as trial
* and error. They also have an introduction, but it's hardly useful. See links below.
*
* - https://github.com/microsoft/TypeScript/blob/v5.5.3/src/server/protocol.ts
* - https://github.com/microsoft/TypeScript/wiki/Standalone-Server-(tsserver)
*/
export class TsServer extends EventEmitter {
private _seq: number = 0;
private buffered: Buffer = Buffer.from('');
private readonly process: ChildProcess;
private readonly resolvers: Record<number, (value: any) => void> = {};

constructor(process: ChildProcess) {
super();
this.process = process;
this.process.stdout?.on('data', (chunk) => {
const { messages, buffered } = parseTsServerMessages(chunk, this.buffered);
Expand All @@ -32,7 +62,7 @@ export class TsServer {

if (!resolve) {
console.warn(
`Received a response for command '${response.command}' and request_seq '${response.request_seq}' but no resolver was found. This may be a bug in the code.`,
`Received a response for command '${response.command}' and request_seq '${response.request_seq}' but no resolver was found. This may be a bug in the code.\n\nResponse:\n${JSON.stringify(response, null, 2)}\n`,
);

return;
Expand All @@ -43,8 +73,8 @@ export class TsServer {
resolve(response);
}

private handleEvent(_event: tsserver.protocol.Event) {
// Ignoring telemetry events for now
private handleEvent(event: tsserver.protocol.Event) {
this.emit(event.event, event);
}

private send(request: tsserver.protocol.Request) {
Expand All @@ -58,10 +88,32 @@ export class TsServer {
});
}

/**
* Wrapper around the `semanticDiag` event for convenience and type safety.
*/
onSemanticDiag(callback: (event: tsserver.protocol.DiagnosticEvent) => void) {
this.on('semanticDiag', callback);
}

/**
* Wrapper around the `syntaxDiag` event for convenience and type safety.
*/
onSyntaxDiag(callback: (event: tsserver.protocol.DiagnosticEvent) => void) {
this.on('syntaxDiag', callback);
}

/**
* Wrapper around the `suggestionDiag` event for convenience and type safety.
*/
onSuggestionDiag(callback: (event: tsserver.protocol.DiagnosticEvent) => void) {
this.on('suggestionDiag', callback);
}

/**
* Shutdown the underlying tsserver process.
*/
shutdown() {
this.removeAllListeners();
return this.process.kill('SIGTERM');
}

Expand Down Expand Up @@ -94,57 +146,60 @@ export class TsServer {
}

/**
* Get info about the project.
* Ask tsserver to send diagnostics for a set of files.
*
* This can be useful during development to inspect the tsserver integration.
* This is used to get the errors for a set of files in a project.
*
* Note that the diagnostics are sent as asynchronous events instead of responding to this request.
*/
projectInfo(args: tsserver.protocol.ProjectInfoRequestArgs) {
return this.sendWithResponsePromise<tsserver.protocol.ProjectInfoResponse>({
geterr(args: tsserver.protocol.GeterrRequestArgs) {
this.send({
seq: this.seq,
type: 'request',
command: 'projectInfo',
command: 'geterr',
arguments: args,
});
}

/**
* Get info about a term at a specific location in a file.
* Reload the project in tsserver.
*
* This is used for type definitions and documentation lookups on hover.
* This is used to tell tsserver to reload the project configuration
* which helps ensure that the project is up-to-date. This helps resolve
* errors that can occur when renaming files.
*/
quickinfo(args: tsserver.protocol.FileLocationRequestArgs) {
return this.sendWithResponsePromise<tsserver.protocol.QuickInfoResponse>({
reloadProjects() {
this.send({
seq: this.seq,
type: 'request',
command: 'quickinfo',
arguments: args,
command: 'reloadProjects',
});
}

/**
* Get semantic information about a file.
* Get info about the project.
*
* This is used to report type errors in a file.
* This can be useful during development to inspect the tsserver integration.
*/
semanticDiagnosticsSync(args: tsserver.protocol.SemanticDiagnosticsSyncRequestArgs) {
return this.sendWithResponsePromise<tsserver.protocol.SemanticDiagnosticsSyncResponse>({
projectInfo(args: tsserver.protocol.ProjectInfoRequestArgs) {
return this.sendWithResponsePromise<tsserver.protocol.ProjectInfoResponse>({
seq: this.seq,
type: 'request',
command: 'semanticDiagnosticsSync',
command: 'projectInfo',
arguments: args,
});
}

/**
* Get syntactic information about a file.
* Get info about a term at a specific location in a file.
*
* This is used to report syntax errors in a file.
* This is used for type definitions and documentation lookups on hover.
*/
syntacticDiagnosticsSync(args: tsserver.protocol.SyntacticDiagnosticsSyncRequestArgs) {
return this.sendWithResponsePromise<tsserver.protocol.SyntacticDiagnosticsSyncResponse>({
quickinfo(args: tsserver.protocol.FileLocationRequestArgs) {
return this.sendWithResponsePromise<tsserver.protocol.QuickInfoResponse>({
seq: this.seq,
type: 'request',
command: 'syntacticDiagnosticsSync',
command: 'quickinfo',
arguments: args,
});
}
Expand Down
2 changes: 1 addition & 1 deletion packages/api/tsserver/utils.mts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export function normalizeDiagnostic(
};
} else {
return {
// From what I can tell, code should always be present depsite the type.
// From what I can tell, code should always be present despite the type.
// If it's not, we use 1000 as the 'unknown' error code, which is not a
// code defined in diagnosticMessages.json in TypeScript's source.
code: diagnostic.code || 1000,
Expand Down
Loading