Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/language-server-session-typescript-7.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@0no-co/graphqlsp': minor
---

Add `createLanguageServerSession` to `@0no-co/graphqlsp/check`, which reports GraphQLSP's diagnostics for files in a TypeScript 7.1 language server's projects, including editors' unsaved changes, through an API session of the language server
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,6 @@ jobs:
run: pnpm --filter ./test/e2e/fixture-project-ts7 add --save-dev ${{ matrix.package }}

- name: Test
run: pnpm exec vitest run test/e2e/ts7.test.ts
run: pnpm exec vitest run test/e2e/ts7.test.ts test/e2e/ts7-session.test.ts
env:
GRAPHQLSP_TS7_COMPILER: ${{ matrix.compiler }}
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,25 @@ files can be resolved, e.g. by `graphql.persisted()`. Mapped files themselves ar
checked, since their mappers report their diagnostics. tsrs's API doesn't run
content mappers yet.

Editor integrations can use `createLanguageServerSession` instead, which checks
files in a running TypeScript 7.1 language server's projects, including unsaved
changes, through an API session of the language server (e.g. from the
TypeScript 7 VS Code extension's `TypeScriptSDK.initializeAPIConnection()`, or
its `custom/initializeAPISession` request). Its API is synchronous, so it
should run in its own process or worker. The GraphQLSP VS Code extension uses
it to report diagnostics while TypeScript 7's language server runs.

```ts
import { createLanguageServerSession } from '@0no-co/graphqlsp/check';

const session = await createLanguageServerSession({
packageJsonPath: sdk.packageJsonPath,
pipe: await sdk.initializeAPIConnection(),
});
// `text` is the file's text the diagnostics were computed for
const { text, diagnostics } = (await session.check(fileName)) ?? {};
```

## Tracking unused fields

Currently the tracking unused fields feature has a few caveats with regards to tracking, first and foremost
Expand Down
95 changes: 12 additions & 83 deletions packages/graphqlsp/src/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,28 +18,17 @@ import { createTypeScriptNamespace } from './ts7/namespace';
import { createChecker } from './ts7/checker';
import { readPluginConfig } from './ts7/config';
import type { ReadConfigFile } from './ts7/config';
import { toCheckDiagnostics } from './ts7/report';
import type { CheckDiagnostic } from './ts7/report';

export type { CompilerName };

export type CheckSeverity = 'error' | 'warning' | 'suggestion' | 'message';

/** A GraphQLSP diagnostic. Plain data, so it can be serialized as-is. */
export interface CheckDiagnostic {
/** Absolute path of the file the diagnostic is reported in. */
file: string;
/** Start offset (in UTF-16 code units) into the file's text. */
start: number;
/** Length (in UTF-16 code units) of the reported span. */
length: number;
/** 1-based line of `start`. */
line: number;
/** 1-based column (in UTF-16 code units) of `start`. */
column: number;
message: string;
/** GraphQLSP's diagnostic code, e.g. `52001` for schema validation errors. */
code: number;
severity: CheckSeverity;
}
export type { CheckDiagnostic, CheckSeverity } from './ts7/report';
export { createLanguageServerSession } from './session';
export type {
LanguageServerCheckResult,
LanguageServerSession,
LanguageServerSessionOptions,
} from './session';

export interface CheckProjectOptions {
/** Path of the `tsconfig.json` to check. */
Expand Down Expand Up @@ -72,13 +61,6 @@ export interface CheckProjectResult {
diagnostics: CheckDiagnostic[];
}

const severities: Record<number, CheckSeverity> = {
0: 'warning',
1: 'error',
2: 'suggestion',
3: 'message',
};

// Content-mapped files (e.g. `.graphql`) are part of the program as virtual
// TypeScript, but their mappers report their diagnostics, so only TypeScript
// and JavaScript files are checked
Expand All @@ -89,43 +71,6 @@ const isCheckedFile = (fileName: string) =>
!/\.d\.[cm]?ts$/.test(fileName) &&
!/[\\/]node_modules[\\/]/.test(fileName);

const flattenMessage = (
message: string | ts.DiagnosticMessageChain
): string => {
if (typeof message === 'string') return message;
let text = message.messageText;
for (const next of message.next || [])
text += '\n ' + flattenMessage(next).replace(/\n/g, '\n ');
return text;
};

const getLineStarts = (text: string) => {
const lineStarts = [0];
for (let idx = 0; idx < text.length; idx++) {
const char = text.charCodeAt(idx);
if (char === 13 /*'\r'*/ && text.charCodeAt(idx + 1) === 10 /*'\n'*/) {
continue;
} else if (char === 10 || char === 13) {
lineStarts.push(idx + 1);
}
}
return lineStarts;
};

const toLineAndColumn = (lineStarts: number[], position: number) => {
let low = 0;
let high = lineStarts.length - 1;
while (low < high) {
const mid = (low + high + 1) >> 1;
if (lineStarts[mid]! <= position) {
low = mid;
} else {
high = mid - 1;
}
}
return { line: low + 1, column: position - lineStarts[low]! + 1 };
};

// GraphQLSP's modules share a single `ts` namespace, so checks run one at a time
let queue: Promise<unknown> = Promise.resolve();

Expand Down Expand Up @@ -310,25 +255,9 @@ async function runCheck(
});
}

for (const diagnostic of fileDiagnostics || []) {
const source = diagnostic.file;
const fileName = source ? source.fileName : file;
const start = diagnostic.start || 0;
let lineStarts = lineStartsByFile.get(fileName);
if (!lineStarts) {
lineStarts = getLineStarts(source ? source.text : '');
lineStartsByFile.set(fileName, lineStarts);
}
diagnostics.push({
file: fileName,
start,
length: diagnostic.length || 0,
...toLineAndColumn(lineStarts, start),
message: flattenMessage(diagnostic.messageText),
code: diagnostic.code,
severity: severities[diagnostic.category] || 'error',
});
}
diagnostics.push(
...toCheckDiagnostics(file, fileDiagnostics || [], lineStartsByFile)
);
}

return {
Expand Down
7 changes: 6 additions & 1 deletion packages/graphqlsp/src/graphql/getSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ export interface LoadSchemaOptions {
/** Whether to write gql.tada typings (`tadaOutputLocation`) files.
* @defaultValue `true` */
persist?: boolean;
/** Called after schemas were reloaded, e.g. once their files changed. */
onChange?: () => void;
}

/** Falls back to the `current` schema for single-schema setups or unnamed documents. */
Expand Down Expand Up @@ -378,6 +380,7 @@ export const loadSchema = (
logger('Schema files changed without watcher events; reloading...');
await revalidate(true);
persistAll();
if (options.onChange) options.onChange();
})().catch(error => {
logger(`Failed to check schemas for staleness: ${error}`);
});
Expand All @@ -400,7 +403,9 @@ export const loadSchema = (
(inner.autoupdate as AutoupdateWithErrors)(
{ rootPath },
(schemaRef, value) => {
revalidate();
revalidate().then(() => {
if (options.onChange) options.onChange();
});
if (!value) return;

const found = value.name
Expand Down
202 changes: 202 additions & 0 deletions packages/graphqlsp/src/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import path from 'node:path';

import { ts, init } from './ts';
import { templates } from './ast/templates';
import { loadSchema } from './graphql/getSchema';
import type { SchemaRef } from './graphql/getSchema';
import { getGraphQLDiagnostics } from './diagnostics';
import type { GraphQLSPHost, HostProgram } from './host';
import { loadCompiler } from './ts7/modules';
import type { CompilerName } from './ts7/modules';
import { createTypeScriptNamespace } from './ts7/namespace';
import { createChecker } from './ts7/checker';
import { readPluginConfig } from './ts7/config';
import { createAPIRelay } from './ts7/relay';
import type { APIRelay } from './ts7/relay';
import { toCheckDiagnostics } from './ts7/report';
import type { CheckDiagnostic } from './ts7/report';

export interface LanguageServerSessionOptions {
/** Which compiler the language server runs: `typescript` (7.1+) or `tsrs`.
* @defaultValue `'typescript'` */
compiler?: CompilerName;
/** The `package.json` of the TypeScript installation the language server runs,
* e.g. the TypeScript 7 VS Code extension's `TypeScriptSDK.packageJsonPath`. */
packageJsonPath: string;
/** An API pipe of the language server, e.g. from the TypeScript 7 VS Code
* extension's `TypeScriptSDK.initializeAPIConnection()`, or from its
* `custom/initializeAPISession` request. */
pipe: string;
/** Receives GraphQLSP's log messages. */
logger?: (message: string) => void;
/** Whether to write gql.tada typings (`tadaOutputLocation`), as the
* TypeScript 5/6 plugin does in editors.
* @defaultValue `true` */
persist?: boolean;
/** Called after a project's schemas were reloaded, since diagnostics of
* unchanged files may then change too. */
onSchemaChange?: () => void;
}

export interface LanguageServerCheckResult {
/** The text of the file the diagnostics were computed for. When it doesn't
* match an editor's text, the language server hasn't caught up with the
* editor's changes yet, and the diagnostics are outdated. */
text: string;
diagnostics: CheckDiagnostic[];
}

export interface LanguageServerSession {
/** Reports GraphQLSP's diagnostics for a file in the language server's
* current state, including unsaved changes it received from editors.
* Returns `null` for files outside of projects configuring GraphQLSP. */
check(fileName: string): Promise<LanguageServerCheckResult | null>;
dispose(): void;
}

interface ProjectState {
host: GraphQLSPHost;
schema: SchemaRef;
/** Swapped for the program of the snapshot a file is checked in. */
program: HostProgram | undefined;
}

/** Runs GraphQLSP's diagnostics on a TypeScript 7.1 language server's
* projects, through an API session connected to the language server.
*
* @remarks
* TypeScript 7's language server doesn't load language service plugins, so
* editor integrations can use this to report GraphQLSP's diagnostics next to
* TypeScript's. Its API is synchronous, so this blocks while checking, and
* should run in a separate process or worker, rather than e.g. in VS Code's
* extension host.
*/
export async function createLanguageServerSession(
options: LanguageServerSessionOptions
): Promise<LanguageServerSession> {
const logger = options.logger || (() => {});
const compiler = await loadCompiler(options.compiler || 'typescript', {
from: options.packageJsonPath,
packagePath: options.packageJsonPath,
});
if (compiler.kind !== 'api') {
throw new Error(
`TypeScript ${compiler.version} doesn't provide the TypeScript 7.1 API.`
);
}

const { API } = compiler.sync;
let relay: APIRelay | undefined;
let api: any;
try {
api = API.fromLSPConnection({ pipe: options.pipe });
} catch (error) {
// TypeScript 7.1's sync client only spawns API servers, and doesn't
// connect to pipes yet, so it's connected to the pipe through a relay
if (!/not yet supported/i.test(String(error))) throw error;
relay = createAPIRelay(options.pipe);
try {
api = API.fromLSPConnection({ tsserverPath: relay.tsserverPath });
} catch (error) {
relay.dispose();
throw error;
}
}
const typescript = createTypeScriptNamespace(compiler);
const projects = new Map<string, ProjectState | null>();

const getProjectState = (configFileName: string): ProjectState | null => {
let state = projects.get(configFileName);
if (state !== undefined) return state;

const plugin = readPluginConfig(
configPath => api.readConfigFile(configPath),
configFileName
);
if (!plugin) {
projects.set(configFileName, null);
return null;
}

if (plugin.config.template) templates.add(plugin.config.template);
const host: GraphQLSPHost = {
config: plugin.config,
languageService: {
getProgram: () => (state ? state.program : undefined),
},
project: {
getProjectName: () => configFileName,
projectService: { logger: { info: logger } },
},
};
state = {
host,
program: undefined,
schema: loadSchema(host, logger, {
rootPath: path.dirname(plugin.configPath),
// Schemas are checked for changes on every check instead
watch: false,
persist: options.persist !== false,
onChange: options.onSchemaChange,
}),
};
projects.set(configFileName, state);
return state;
};

return {
async check(fileName) {
let snapshot = api.getCurrentLanguageServerSnapshot();
try {
let project = snapshot.getDefaultProjectForFile(fileName);
if (!project || !project.configFileName) return null;
const state = getProjectState(project.configFileName);
if (!state) return null;

if (!state.schema.current && !Object.keys(state.schema.multi).length) {
// While the schema loads, the language server may receive changes
await state.schema.ready;
snapshot.dispose();
snapshot = api.getCurrentLanguageServerSnapshot();
project = snapshot.getDefaultProjectForFile(fileName);
if (!project) return null;
}
state.schema.checkStale();

const source = project.program.getSourceFile(fileName);
if (!source) return null;
const program = project.program;
const checker = createChecker(project.checker, project);
state.program = {
getSourceFile: (fileName: string) => program.getSourceFile(fileName),
getTypeChecker: () => checker,
};

const previousTypeScript = ts;
init({ typescript });
try {
const diagnostics =
getGraphQLDiagnostics(fileName, state.schema, state.host) || [];
return {
text: source.text,
diagnostics: toCheckDiagnostics(fileName, diagnostics),
};
} finally {
init({ typescript: previousTypeScript });
state.program = undefined;
}
} finally {
snapshot.dispose();
}
},

dispose() {
projects.clear();
try {
api.close();
} finally {
if (relay) relay.dispose();
}
},
};
}
Loading
Loading