diff --git a/.changeset/check-project-run-external-code.md b/.changeset/check-project-run-external-code.md new file mode 100644 index 00000000..a9924bd9 --- /dev/null +++ b/.changeset/check-project-run-external-code.md @@ -0,0 +1,5 @@ +--- +'@0no-co/graphqlsp': patch +--- + +Add a `runExternalCode` option to `checkProject`, which runs a project's content mappers (like `tsc --runExternalCode`), so documents imported from content-mapped files (e.g. `.graphql` files mapped by `@gql.tada/content-mapper`) are resolved. Content-mapped files themselves aren't checked, since their mappers report their diagnostics. Also compute persisted document hashes from string literals' values rather than their source text, which failed to parse documents written with escape sequences. diff --git a/.changeset/check-project-typescript-7.md b/.changeset/check-project-typescript-7.md new file mode 100644 index 00000000..d53377d2 --- /dev/null +++ b/.changeset/check-project-typescript-7.md @@ -0,0 +1,5 @@ +--- +'@0no-co/graphqlsp': minor +--- + +Add `checkProject` (`@0no-co/graphqlsp/check`), which reports GraphQLSP's diagnostics for a whole project without a language service. It runs on the TypeScript 7.1 API (`typescript@>=7.1` or `tsrs`), where language service plugins aren't loaded, and on the compiler API of TypeScript 5/6. To support this, the diagnostics now only depend on a small host interface rather than tsserver's plugin API, and identifiers are compared by their `text` instead of their `escapedText`. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8b3a1768..3147e2ab 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -62,3 +62,68 @@ jobs: - name: Test run: pnpm run test:e2e + + typescript-7: + name: TypeScript 7.1 API (${{ matrix.name }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + # Prereleases and tsrs may break ahead of us, which shouldn't fail CI + continue-on-error: ${{ matrix.experimental }} + strategy: + fail-fast: false + matrix: + include: + - name: typescript@beta + compiler: typescript + package: typescript-7@npm:typescript@beta + experimental: false + - name: typescript@next + compiler: typescript + package: typescript-7@npm:typescript@next + experimental: true + - name: tsrs@latest + compiler: tsrs + package: tsrs@latest + experimental: true + steps: + - name: Checkout Repo + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + + - name: Setup pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + with: + version: 11.3.0 + run_install: false + + - name: Get pnpm store directory + id: pnpm-store + run: echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Use pnpm store + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + id: pnpm-cache + with: + path: ${{ steps.pnpm-store.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + + - name: Install Dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + # The TypeScript 7.1 API implementations are installed in the fixture + # project, so they don't replace the workspace's TypeScript 5/6 + - name: Install ${{ matrix.name }} + 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 + env: + GRAPHQLSP_TS7_COMPILER: ${{ matrix.compiler }} diff --git a/.gitignore b/.gitignore index c55649b3..8ac8016c 100644 --- a/.gitignore +++ b/.gitignore @@ -108,6 +108,7 @@ dist packages/graphqlsp/api/* packages/graphqlsp/api +packages/graphqlsp/check # VSCode extension packaging packages/vscode-graphqlsp/.staging diff --git a/README.md b/README.md index 60ee0b09..9cb8630b 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,44 @@ when on a TypeScript file or adding a file like [this](https://github.com/0no-co - `tadaDisablePreprocessing` this setting disables the optimisation of `tadaOutput` to a pre-processed TypeScript type, this is off by default. - `clientDirectives` this setting allows you to specify additional `clientDirectives` which won't be seen as a missing schema-directive. +## Checking projects with TypeScript 7 + +TypeScript 7 doesn't load language service plugins, so GraphQLSP's diagnostics +can't run inside `tsc` or the TypeScript 7 language server. Instead, +`checkProject` reports them for a whole project, using the TypeScript 7.1 API +(`typescript@>=7.1`, or [`tsrs`](https://github.com/oxc-project/tsrs)). With +TypeScript 5/6 installed, it uses the TypeScript compiler API instead. + +```ts +import { checkProject } from '@0no-co/graphqlsp/check'; + +const { diagnostics } = await checkProject({ + tsconfigPath: './tsconfig.json', + // 'typescript' (default) or 'tsrs', resolved from the tsconfig.json's directory + compiler: 'typescript', +}); + +for (const diagnostic of diagnostics) { + console.log( + `${diagnostic.file}:${diagnostic.line}:${diagnostic.column} ` + + `${diagnostic.severity} (${diagnostic.code}): ${diagnostic.message}` + ); +} +``` + +The plugin entry is read from `compilerOptions.plugins` in your `tsconfig.json` +(following `extends`), like the TypeScript plugin's configuration. Schemas are +loaded once, without watching them, and gql.tada's `tadaOutputLocation` typings +aren't written. + +If your project uses [content mappers](https://devblogs.microsoft.com/typescript/announcing-typescript-7-1-beta/) +(`contentMappers` in your `tsconfig.json`), e.g. to import documents from `.graphql` +files with `@gql.tada/content-mapper`, pass `runExternalCode: true`. Like +`tsc --runExternalCode`, this runs the mappers, so documents imported from mapped +files can be resolved, e.g. by `graphql.persisted()`. Mapped files themselves aren't +checked, since their mappers report their diagnostics. tsrs's API doesn't run +content mappers yet. + ## Tracking unused fields Currently the tracking unused fields feature has a few caveats with regards to tracking, first and foremost diff --git a/packages/graphqlsp/package.json b/packages/graphqlsp/package.json index f3b05e92..bbf2feb0 100644 --- a/packages/graphqlsp/package.json +++ b/packages/graphqlsp/package.json @@ -18,6 +18,12 @@ "require": "./dist/api.js", "source": "./src/api.ts" }, + "./check": { + "types": "./dist/check.d.ts", + "import": "./dist/check.mjs", + "require": "./dist/check.js", + "source": "./src/check.ts" + }, "./package.json": "./package.json" }, "scripts": { @@ -55,7 +61,7 @@ }, "peerDependencies": { "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", - "typescript": "^5.0.0 || ^6.0.0" + "typescript": "^5.0.0 || ^6.0.0 || ^7.1.0-0" }, "publishConfig": { "provenance": true diff --git a/packages/graphqlsp/src/ast/checks.ts b/packages/graphqlsp/src/ast/checks.ts index ad993cd5..849b066a 100644 --- a/packages/graphqlsp/src/ast/checks.ts +++ b/packages/graphqlsp/src/ast/checks.ts @@ -1,4 +1,5 @@ import { ts } from '../ts'; +import type { HostChecker } from '../host'; import { templates } from './templates'; /** Checks for an immediately-invoked function expression */ @@ -13,8 +14,7 @@ export const isIIFE = (node: ts.Node): boolean => /** Checks if node is a known identifier of graphql functions ('graphql' or 'gql') */ export const isGraphQLFunctionIdentifier = ( node: ts.Node -): node is ts.Identifier => - ts.isIdentifier(node) && templates.has(node.escapedText as string); +): node is ts.Identifier => ts.isIdentifier(node) && templates.has(node.text); // The verdicts of the type probes below never change for a given function // within one program, but are requested once per call site. Caching them per @@ -22,18 +22,18 @@ export const isGraphQLFunctionIdentifier = ( // per distinct callee. Keying by checker makes invalidation automatic, since // a new program comes with a new checker. const tadaFunctionCache = new WeakMap< - ts.TypeChecker, + HostChecker, WeakMap >(); const schemaNameCache = new WeakMap< - ts.TypeChecker, + HostChecker, WeakMap >(); const getCached = ( - caches: WeakMap>, - checker: ts.TypeChecker, + caches: WeakMap>, + checker: HostChecker, node: ts.Node, compute: () => T ): T => { @@ -49,7 +49,7 @@ const getCached = ( /** If `checker` is passed, checks if node (as identifier/expression) is a gql.tada graphql() function */ export const isTadaGraphQLFunction = ( node: ts.Node, - checker: ts.TypeChecker | undefined + checker: HostChecker | undefined ): node is ts.LeftHandSideExpression => { if (!ts.isLeftHandSideExpression(node) || !checker) return false; return getCached(tadaFunctionCache, checker, node, () => { @@ -78,7 +78,7 @@ const graphqlDocumentPrefix = /** If `checker` is passed, checks if node is a gql.tada graphql() call */ export const isTadaGraphQLCall = ( node: ts.CallExpression, - checker: ts.TypeChecker | undefined + checker: HostChecker | undefined ): boolean => { // We expect graphql() to be called with either a string literal // or a string literal and an array of fragments @@ -104,7 +104,7 @@ export const isTadaGraphQLCall = ( /** Checks if node is a gql.tada graphql.persisted() call */ export const isTadaPersistedCall = ( node: ts.Node | undefined, - checker: ts.TypeChecker | undefined + checker: HostChecker | undefined ): node is ts.CallExpression => { if (!node) { return false; @@ -114,7 +114,7 @@ export const isTadaPersistedCall = ( return false; // rejecting non property access calls: .() } else if ( !ts.isIdentifier(node.expression.name) || - node.expression.name.escapedText !== 'persisted' + node.expression.name.text !== 'persisted' ) { return false; // rejecting calls on anyting but 'persisted': .persisted() } else if (isGraphQLFunctionIdentifier(node.expression.expression)) { @@ -132,7 +132,7 @@ export type GraphQLCallNode = ts.CallExpression & { /** Checks if node is a gql.tada or regular graphql() call */ export const isGraphQLCall = ( node: ts.Node, - checker: ts.TypeChecker | undefined + checker: HostChecker | undefined ): node is GraphQLCallNode => { return ( ts.isCallExpression(node) && @@ -152,7 +152,7 @@ export const isGraphQLTag = ( /** Retrieves the `__name` branded tag from gql.tada `graphql()` or `graphql.persisted()` calls */ export const getSchemaName = ( node: ts.CallExpression, - typeChecker: ts.TypeChecker | undefined, + typeChecker: HostChecker | undefined, isTadaPersistedCall = false ): string | null => { if (!typeChecker) return null; diff --git a/packages/graphqlsp/src/ast/declaration.ts b/packages/graphqlsp/src/ast/declaration.ts index 818e1ad4..7d7d00ce 100644 --- a/packages/graphqlsp/src/ast/declaration.ts +++ b/packages/graphqlsp/src/ast/declaration.ts @@ -1,4 +1,5 @@ import { ts } from '../ts'; +import type { HostChecker } from '../host'; export type ValueDeclaration = | ts.BinaryExpression @@ -192,7 +193,7 @@ function getNameFromPropertyName(name: ts.PropertyName): string | undefined { */ export function getDeclarationOfIdentifier( node: ts.Identifier, - checker: ts.TypeChecker + checker: HostChecker ): ValueDeclaration | undefined { // See: https://github.com/microsoft/TypeScript/blob/a5eec24/src/services/goToDefinition.ts#L523-L540 let symbol = checker.getSymbolAtLocation(node); @@ -311,7 +312,7 @@ export function getDeclarationOfIdentifier( /** Loops {@link getDeclarationOfIdentifier} until a value of the identifier is found */ export function getValueOfIdentifier( node: ts.Identifier, - checker: ts.TypeChecker + checker: HostChecker ): ValueOfDeclaration | undefined { while (ts.isIdentifier(node)) { const declaration = getDeclarationOfIdentifier(node, checker); diff --git a/packages/graphqlsp/src/ast/index.ts b/packages/graphqlsp/src/ast/index.ts index fcbef78d..d97ae2ba 100644 --- a/packages/graphqlsp/src/ast/index.ts +++ b/packages/graphqlsp/src/ast/index.ts @@ -1,4 +1,5 @@ import { ts } from '../ts'; +import type { GraphQLSPHost, HostChecker } from '../host'; import { FragmentDefinitionNode, parse } from 'graphql'; import * as checks from './checks'; import { resolveTadaFragmentArray } from './resolve'; @@ -10,7 +11,7 @@ import { export { getSchemaName } from './checks'; -export function getSource(info: ts.server.PluginCreateInfo, filename: string) { +export function getSource(info: GraphQLSPHost, filename: string) { const program = info.languageService.getProgram(); if (!program) return undefined; @@ -56,8 +57,8 @@ export function findAllTaggedTemplateNodes( export function resolveIdentifierToGraphQLCall( input: ts.Identifier, - info: ts.server.PluginCreateInfo, - checker: ts.TypeChecker | undefined + info: GraphQLSPHost, + checker: HostChecker | undefined ): checks.GraphQLCallNode | null { if (!checker) return null; @@ -70,8 +71,8 @@ export function resolveIdentifierToGraphQLCall( export function unrollFragment( element: ts.Identifier, - info: ts.server.PluginCreateInfo, - checker: ts.TypeChecker | undefined + info: GraphQLSPHost, + checker: HostChecker | undefined ): Array { const fragments: FragmentDefinitionNode[] = []; const elements: ts.Identifier[] = [element]; @@ -109,7 +110,7 @@ export function unrollFragment( export function unrollTadaFragments( fragmentsArray: ts.ArrayLiteralExpression, wip: FragmentDefinitionNode[], - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): FragmentDefinitionNode[] { const typeChecker = info.languageService.getProgram()?.getTypeChecker(); fragmentsArray.elements.forEach(element => { @@ -136,7 +137,7 @@ export type FindAllCallExpressionsOptions = { export function findAllCallExpressions( sourceFile: ts.SourceFile, - info: ts.server.PluginCreateInfo, + info: GraphQLSPHost, options: boolean | FindAllCallExpressionsOptions = true ): { nodes: Array<{ @@ -230,12 +231,12 @@ export function findAllPersistedCallExpressions( ): Array; export function findAllPersistedCallExpressions( sourceFile: ts.SourceFile, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): Array<{ node: ts.CallExpression; schema: string | null }>; export function findAllPersistedCallExpressions( sourceFile: ts.SourceFile, - info?: ts.server.PluginCreateInfo + info?: GraphQLSPHost ) { const result: Array< ts.CallExpression | { node: ts.CallExpression; schema: string | null } @@ -259,10 +260,7 @@ export function findAllPersistedCallExpressions( return result; } -export function getAllFragments( - node: ts.Node, - info: ts.server.PluginCreateInfo -) { +export function getAllFragments(node: ts.Node, info: GraphQLSPHost) { let fragments: Array = []; const typeChecker = info.languageService.getProgram()?.getTypeChecker(); diff --git a/packages/graphqlsp/src/ast/resolve.ts b/packages/graphqlsp/src/ast/resolve.ts index df83aad0..252a82f9 100644 --- a/packages/graphqlsp/src/ast/resolve.ts +++ b/packages/graphqlsp/src/ast/resolve.ts @@ -1,5 +1,6 @@ import { print } from '@0no-co/graphql.web'; import { ts } from '../ts'; +import type { GraphQLSPHost } from '../host'; import { getDeclarationOfIdentifier, getValueOfValueDeclaration, @@ -20,7 +21,7 @@ export function resolveTemplate( node: ts.TaggedTemplateExpression | ts.StringLiteralLike | ts.TemplateExpression, filename: string, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): TemplateResult { if (ts.isStringLiteralLike(node)) { return { @@ -93,7 +94,7 @@ export function resolveTemplate( const parent = declaration; if (ts.isVariableDeclaration(parent)) { - const identifierName = span.expression.escapedText; + const identifierName = span.expression.text; const value = getValueOfValueDeclaration(parent); if (!value) return; @@ -112,7 +113,7 @@ export function resolveTemplate( info ); templateText = templateText.replace( - '${' + span.expression.escapedText + '}', + '${' + span.expression.text + '}', text.combinedText ); @@ -137,7 +138,7 @@ export function resolveTemplate( info ); templateText = templateText.replace( - '${' + span.expression.escapedText + '}', + '${' + span.expression.text + '}', text.combinedText ); const alteredSpan = { @@ -159,7 +160,7 @@ export function resolveTemplate( const astObject = JSON.parse(value.expression.expression.getText()); const resolvedTemplate = print(astObject); templateText = templateText.replace( - '${' + span.expression.escapedText + '}', + '${' + span.expression.text + '}', resolvedTemplate ); const alteredSpan = { diff --git a/packages/graphqlsp/src/check.ts b/packages/graphqlsp/src/check.ts new file mode 100644 index 00000000..3cebba63 --- /dev/null +++ b/packages/graphqlsp/src/check.ts @@ -0,0 +1,346 @@ +import path from 'node:path'; + +import { ts, init } from './ts'; +import { templates } from './ast/templates'; +import { loadSchema } from './graphql/getSchema'; +import { + clearGraphQLDiagnosticsCache, + getGraphQLDiagnostics, +} from './diagnostics'; +import type { GraphQLSPHost, HostProgram } from './host'; +import { loadCompiler } from './ts7/modules'; +import type { + CompilerModules, + CompilerName, + LegacyCompiler, +} from './ts7/modules'; +import { createTypeScriptNamespace } from './ts7/namespace'; +import { createChecker } from './ts7/checker'; +import { readPluginConfig } from './ts7/config'; +import type { ReadConfigFile } from './ts7/config'; + +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 interface CheckProjectOptions { + /** Path of the `tsconfig.json` to check. */ + tsconfigPath: string; + /** Which compiler to check with: `typescript` (5, 6, or 7.1+) or `tsrs`. + * @defaultValue `'typescript'` */ + compiler?: CompilerName; + /** Path of the compiler's `package.json` or package directory. By default, + * `compiler` is resolved from the `tsconfigPath`. */ + compilerPath?: string; + /** Files to check. Defaults to all of the project's non-declaration files + * outside of `node_modules`. */ + files?: readonly string[]; + /** Receives GraphQLSP's log messages. */ + logger?: (message: string) => void; + /** Runs the project's content mappers (`contentMappers` in its `tsconfig.json`), + * like `tsc --runExternalCode`, so documents imported from mapped files (e.g. + * `.graphql` files) are part of the program. Mappers are external processes, + * so this is opt-in. Only applies to TypeScript 7.1+ and tsrs. + * @defaultValue `false` */ + runExternalCode?: boolean; +} + +export interface CheckProjectResult { + compiler: { name: CompilerName; version: string }; + /** Absolute path of the checked `tsconfig.json`. */ + tsconfigPath: string; + /** Absolute paths of the checked files. */ + files: string[]; + diagnostics: CheckDiagnostic[]; +} + +const severities: Record = { + 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 +const isScriptFile = (fileName: string) => /\.[cm]?[jt]sx?$/.test(fileName); + +const isCheckedFile = (fileName: string) => + isScriptFile(fileName) && + !/\.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 = Promise.resolve(); + +/** Checks a project's GraphQL documents with TypeScript (5, 6, or 7.1+) or tsrs. + * + * @remarks + * TypeScript 7 doesn't load language service plugins, so this runs + * GraphQLSP's diagnostics on the TypeScript 7.1 API instead, reporting the + * same diagnostics the TypeScript 5/6 plugin reports in editors. With + * TypeScript 5/6, the project is loaded with its compiler API instead. + * + * The project's GraphQLSP plugin entry is read from its `tsconfig.json`. + * Schemas are loaded once, without watching them, and gql.tada typings + * aren't written. + */ +export function checkProject( + options: CheckProjectOptions +): Promise { + const result = queue.then(() => runCheck(options)); + queue = result.catch(() => {}); + return result; +} + +interface OpenedProject { + /** The `typescript` namespace GraphQLSP's modules run with. */ + typescript: typeof ts; + program: HostProgram; + /** The project's files that are checked by default. */ + fileNames: string[]; + dispose(): void; +} + +interface ProjectLoader { + readConfigFile: ReadConfigFile; + open(tsconfigPath: string): OpenedProject; + dispose(): void; +} + +const createApiLoader = ( + modules: CompilerModules, + tsconfigPath: string, + runExternalCode: boolean +): ProjectLoader => { + const api = new modules.sync.API({ + cwd: path.dirname(tsconfigPath), + runExternalCode, + }); + return { + readConfigFile: configPath => api.readConfigFile(configPath), + open(tsconfigPath) { + const snapshot = api.createSnapshot({ openProjects: [tsconfigPath] }); + const project = snapshot.getConfiguredProject(tsconfigPath); + if (!project) { + throw new Error(`Could not load the project of "${tsconfigPath}".`); + } + const checker = createChecker(project.checker, project); + return { + typescript: createTypeScriptNamespace(modules), + program: { + getSourceFile: fileName => project.program.getSourceFile(fileName), + getTypeChecker: () => checker, + }, + fileNames: project.program.getSourceFileNames().filter(isCheckedFile), + dispose() { + snapshot.dispose(); + }, + }; + }, + dispose() { + api.close(); + }, + }; +}; + +const createLegacyLoader = (compiler: LegacyCompiler): ProjectLoader => { + const typescript: typeof ts = compiler.typescript; + return { + readConfigFile: configPath => + typescript.readConfigFile(configPath, typescript.sys.readFile), + open(tsconfigPath) { + const parsed = typescript.getParsedCommandLineOfConfigFile( + tsconfigPath, + {}, + { + ...typescript.sys, + onUnRecoverableConfigFileDiagnostic(diagnostic) { + throw new Error( + typescript.flattenDiagnosticMessageText( + diagnostic.messageText, + '\n' + ) + ); + }, + } + ); + if (!parsed) { + throw new Error(`Could not load the project of "${tsconfigPath}".`); + } + const program = typescript.createProgram({ + rootNames: parsed.fileNames, + options: parsed.options, + projectReferences: parsed.projectReferences, + }); + return { + typescript, + program, + fileNames: program + .getSourceFiles() + .map(source => source.fileName) + .filter(isCheckedFile), + dispose() {}, + }; + }, + dispose() {}, + }; +}; + +async function runCheck( + options: CheckProjectOptions +): Promise { + const tsconfigPath = path.resolve(options.tsconfigPath); + const logger = options.logger || (() => {}); + const compiler = await loadCompiler(options.compiler || 'typescript', { + from: tsconfigPath, + packagePath: options.compilerPath, + }); + + const loader = + compiler.kind === 'api' + ? createApiLoader(compiler, tsconfigPath, !!options.runExternalCode) + : createLegacyLoader(compiler); + const previousTypeScript = ts; + let project: OpenedProject | undefined; + let template: string | undefined; + try { + const plugin = readPluginConfig(loader.readConfigFile, tsconfigPath); + if (!plugin) { + throw new Error( + `No GraphQLSP plugin entry was found in "${tsconfigPath}". ` + + 'Add "@0no-co/graphqlsp" or "gql.tada/ts-plugin" to its "compilerOptions.plugins".' + ); + } + + project = loader.open(tsconfigPath); + init({ typescript: project.typescript }); + clearGraphQLDiagnosticsCache(); + if (plugin.config.template && !templates.has(plugin.config.template)) { + template = plugin.config.template; + templates.add(template!); + } + + const { program } = project; + const host: GraphQLSPHost = { + config: plugin.config, + languageService: { getProgram: () => program }, + project: { + getProjectName: () => tsconfigPath, + projectService: { logger: { info: logger } }, + }, + }; + + const schema = loadSchema(host, logger, { + rootPath: path.dirname(plugin.configPath), + watch: false, + persist: false, + }); + await schema.ready; + + const files = options.files + ? options.files.map(file => path.resolve(file)).filter(isScriptFile) + : project.fileNames; + + const diagnostics: CheckDiagnostic[] = []; + const lineStartsByFile = new Map(); + for (const file of files) { + let fileDiagnostics: ts.Diagnostic[] | undefined; + try { + fileDiagnostics = getGraphQLDiagnostics(file, schema, host); + } catch (error) { + throw new Error(`Failed to check "${file}": ${error}`, { + cause: error, + }); + } + + 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', + }); + } + } + + return { + compiler: { name: compiler.name, version: compiler.version }, + tsconfigPath, + files, + diagnostics, + }; + } finally { + if (template) templates.delete(template); + init({ typescript: previousTypeScript }); + if (project) project.dispose(); + loader.dispose(); + } +} diff --git a/packages/graphqlsp/src/checkImports.ts b/packages/graphqlsp/src/checkImports.ts index 92a74c79..3ecebe34 100644 --- a/packages/graphqlsp/src/checkImports.ts +++ b/packages/graphqlsp/src/checkImports.ts @@ -1,4 +1,5 @@ import { ts } from './ts'; +import type { GraphQLSPHost, HostChecker } from './host'; import { FragmentDefinitionNode, Kind, parse } from 'graphql'; import { findAllCallExpressions, findAllImports } from './ast'; @@ -9,7 +10,7 @@ export const MISSING_FRAGMENT_CODE = 52003; export const getColocatedFragmentNames = ( source: ts.SourceFile, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): Record< string, { start: number; length: number; fragments: Array } @@ -143,8 +144,8 @@ export const getColocatedFragmentNames = ( function getFragmentsInSource( src: ts.SourceFile, - typeChecker: ts.TypeChecker, - info: ts.server.PluginCreateInfo + typeChecker: HostChecker, + info: GraphQLSPHost ): Array { let fragments: Array = []; const callExpressions = findAllCallExpressions(src, info, false); diff --git a/packages/graphqlsp/src/diagnostics.ts b/packages/graphqlsp/src/diagnostics.ts index 1fe48293..9c31a09a 100644 --- a/packages/graphqlsp/src/diagnostics.ts +++ b/packages/graphqlsp/src/diagnostics.ts @@ -1,4 +1,5 @@ import { ts } from './ts'; +import type { GraphQLSPHost, HostChecker } from './host'; import { Diagnostic, getDiagnostics } from 'graphql-language-service'; import { FragmentDefinitionNode, @@ -96,7 +97,7 @@ const getMisconfigurationDiagnostics = ( schema: string | null; }[], schema: SchemaRef, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): ts.Diagnostic[] => { if (!schema.errors || !nodes.length) return []; const diagnostics: ts.Diagnostic[] = []; @@ -169,7 +170,7 @@ const getMisconfigurationDiagnostics = ( const getModeMismatchDiagnostic = ( source: ts.SourceFile, isCallExpression: boolean, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): ts.Diagnostic | null => { // Cheap text probe before walking the AST of files without any documents const probe = new RegExp( @@ -220,10 +221,16 @@ const cache = new LRUCache({ max: 5000, }); +/** Drops cached diagnostics, whose keys only cover a schema's version and + * not its contents, e.g. before checking another project in the same process. */ +export const clearGraphQLDiagnosticsCache = () => { + cache.clear(); +}; + export function getGraphQLDiagnostics( filename: string, schema: SchemaRef, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): ts.Diagnostic[] | undefined { const isCallExpression = info.config.templateIsCallExpression ?? true; @@ -527,8 +534,8 @@ export function getGraphQLDiagnostics( * document, excluding fragments it only composes via its reference array. */ function getFragmentNamesForIdentifier( identifier: ts.Identifier, - checker: ts.TypeChecker, - info: ts.server.PluginCreateInfo + checker: HostChecker, + info: GraphQLSPHost ): string[] { const value = getValueOfIdentifier(identifier, checker); if (!value || !ts.isCallExpression(value)) return []; @@ -568,8 +575,8 @@ function getFragmentNamesForIdentifier( function getDirectlyUsedFragments( source: ts.SourceFile, nodes: Array<{ tadaFragmentRefs?: readonly ts.Identifier[] | null }>, - typeChecker: ts.TypeChecker | undefined, - info: ts.server.PluginCreateInfo + typeChecker: HostChecker | undefined, + info: GraphQLSPHost ): Set { const directlyUsedFragments = new Set(); if (!typeChecker) return directlyUsedFragments; @@ -647,7 +654,7 @@ const runDiagnostics = ( fragments: FragmentDefinitionNode[]; }, schema: SchemaRef, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): ts.Diagnostic[] => { const filename = source.fileName; const isCallExpression = info.config.templateIsCallExpression ?? true; diff --git a/packages/graphqlsp/src/fieldUsage.ts b/packages/graphqlsp/src/fieldUsage.ts index 1a29b043..5bf4cace 100644 --- a/packages/graphqlsp/src/fieldUsage.ts +++ b/packages/graphqlsp/src/fieldUsage.ts @@ -1,4 +1,5 @@ import { ts } from './ts'; +import type { GraphQLSPHost, HostChecker } from './host'; import { parse, visit } from 'graphql'; import { getValueOfIdentifier } from './ast/declaration'; @@ -74,13 +75,12 @@ interface Entry { * type-arguments or through the `__apiType` call-signature. */ const resolveDataType = ( node: ts.Node, - checker: ts.TypeChecker + checker: HostChecker ): ts.Type | undefined => { let dataType: ts.Type | undefined; const type = checker.getTypeAtLocation(node.parent) as - | ts.TypeReference - | ts.Type; + ts.TypeReference | ts.Type; // Attempt to retrieve type from internally resolve type arguments if ('target' in type) { const typeArguments = (type as any) @@ -113,7 +113,7 @@ const resolveDataType = ( export const checkFieldUsageInFile = ( source: ts.SourceFile, nodes: ts.NoSubstitutionTemplateLiteral[], - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ) => { const diagnostics: ts.Diagnostic[] = []; const shouldTrackFieldUsage = info.config.trackFieldUsage ?? true; diff --git a/packages/graphqlsp/src/graphql/getSchema.ts b/packages/graphqlsp/src/graphql/getSchema.ts index c3fbbbcd..8356615b 100644 --- a/packages/graphqlsp/src/graphql/getSchema.ts +++ b/packages/graphqlsp/src/graphql/getSchema.ts @@ -20,6 +20,7 @@ import { } from '@gql.tada/internal'; import { ts } from '../ts'; +import type { GraphQLSPHost } from '../host'; import { Logger } from '../index'; const statFile = ( @@ -161,6 +162,21 @@ export interface SchemaRef { /** Best-effort detection of schema file changes that the file watcher * missed; throttled internally, so it's safe to call often. */ checkStale(): void; + /** Settles once the initial schema load has finished, successfully or not; + * failures are reported through `errors` instead. */ + readonly ready: Promise; +} + +export interface LoadSchemaOptions { + /** Directory relative schema paths resolve against. Defaults to the + * `tsconfig.json` root directory of the project. */ + rootPath?: string; + /** Whether to keep watching schemas for changes after the initial load. + * @defaultValue `true` */ + watch?: boolean; + /** Whether to write gql.tada typings (`tadaOutputLocation`) files. + * @defaultValue `true` */ + persist?: boolean; } /** Falls back to the `current` schema for single-schema setups or unnamed documents. */ @@ -191,9 +207,9 @@ const EMPTY_MULTI: { [name: string]: SchemaLoaderResult | null } = {}; const STALE_CHECK_INTERVAL = 5_000; export const loadSchema = ( - // TODO: abstract info away - info: ts.server.PluginCreateInfo, - logger: Logger + info: GraphQLSPHost, + logger: Logger, + options: LoadSchemaOptions = {} ): SchemaRef => { const errors: SchemaErrors = { config: null, @@ -208,7 +224,11 @@ export const loadSchema = ( let detectStaleSchemas: (() => void) | null = null; let lastStaleCheck = 0; + let ready: Promise = Promise.resolve(); const ref: SchemaRef = { + get ready() { + return ready; + }, errors, outputLocations, sourceLocations, @@ -231,9 +251,10 @@ export const loadSchema = ( }, }; - (async () => { + ready = (async () => { const projectName = info.project.getProjectName(); const rootPath = + options.rootPath || (await resolveTypeScriptRootDir(projectName)) || path.dirname(projectName); @@ -289,6 +310,7 @@ export const loadSchema = ( }; const persistSchema = (value: SchemaLoaderResult | null | undefined) => { + if (options.persist === false) return; if (value && value.tadaOutputLocation) { saveTadaIntrospection( value.introspection, @@ -373,6 +395,8 @@ export const loadSchema = ( await recordMtimes(); persistAll(); + if (options.watch === false) return; + (inner.autoupdate as AutoupdateWithErrors)( { rootPath }, (schemaRef, value) => { diff --git a/packages/graphqlsp/src/host.ts b/packages/graphqlsp/src/host.ts new file mode 100644 index 00000000..3fc9c6fe --- /dev/null +++ b/packages/graphqlsp/src/host.ts @@ -0,0 +1,49 @@ +import type { ts } from './ts'; + +/** The type checker methods GraphQLSP's diagnostics rely on. + * + * @remarks + * The TypeScript 7 adapter (see `./ts7`) implements these on top of the + * TypeScript 7.1 API, which has no `ts.server` API to plug into, so anything + * the diagnostics need from the checker has to be listed here. + */ +export type HostChecker = Pick< + ts.TypeChecker, + | 'getSymbolAtLocation' + | 'getAliasedSymbol' + | 'getTypeAtLocation' + | 'getTypeOfSymbol' + | 'getShorthandAssignmentValueSymbol' + | 'getContextualType' + | 'getExportsOfModule' +>; + +/** The program methods GraphQLSP's diagnostics rely on. */ +export interface HostProgram { + getSourceFile(fileName: string): ts.SourceFile | undefined; + getTypeChecker(): HostChecker; +} + +/** The subset of a TypeScript plugin's `PluginCreateInfo` that GraphQLSP's + * diagnostics (document discovery, fragment resolution, schema validation, + * colocation and field usage checks) rely on. + * + * @remarks + * A `ts.server.PluginCreateInfo` satisfies this interface as-is, which is how + * the TypeScript 5/6 plugin uses it. The TypeScript 7 adapter creates one + * from a TypeScript 7.1 API project instead. + */ +export interface GraphQLSPHost { + /** The GraphQLSP plugin configuration. */ + config: any; + languageService: { + getProgram(): HostProgram | undefined; + }; + project: { + /** The project's `tsconfig.json` path. */ + getProjectName(): string; + projectService: { + logger: { info(message: string): void }; + }; + }; +} diff --git a/packages/graphqlsp/src/persisted.ts b/packages/graphqlsp/src/persisted.ts index b8d1797b..0b5f45d6 100644 --- a/packages/graphqlsp/src/persisted.ts +++ b/packages/graphqlsp/src/persisted.ts @@ -1,4 +1,5 @@ import { ts } from './ts'; +import type { GraphQLSPHost } from './host'; import { createHash } from 'crypto'; @@ -33,7 +34,7 @@ type PersistedAction = { export function getPersistedCodeFixAtPosition( filename: string, position: number, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): PersistedAction | undefined { const isCallExpression = info.config.templateIsCallExpression ?? true; const typeChecker = info.languageService.getProgram()?.getTypeChecker(); @@ -177,8 +178,21 @@ export function getPersistedCodeFixAtPosition( } } +// `resolveTemplate` returns a string literal's source text, so that diagnostics +// line up with the source, but hashes are computed from the document itself, +// e.g. without the escape sequences of a `"query {\n field\n}"` literal +const getDocumentText = ( + node: + ts.StringLiteralLike | ts.TemplateExpression | ts.TaggedTemplateExpression, + filename: string, + info: GraphQLSPHost +): string => + ts.isStringLiteralLike(node) + ? node.text + : resolveTemplate(node, filename, info).combinedText; + export const generateHashForDocument = ( - info: ts.server.PluginCreateInfo, + info: GraphQLSPHost, templateLiteral: ts.StringLiteralLike | ts.TemplateExpression | ts.TaggedTemplateExpression, foundFilename: string, @@ -187,11 +201,7 @@ export const generateHashForDocument = ( if (referencedFragments) { const fragments: Array = []; unrollTadaFragments(referencedFragments, fragments, info); - let text = resolveTemplate( - templateLiteral, - foundFilename, - info - ).combinedText; + let text = getDocumentText(templateLiteral, foundFilename, info); const parsed = parse(text); const seen = new Set(); for (const definition of parsed.definitions) { @@ -219,11 +229,7 @@ export const generateHashForDocument = ( const externalSource = getSource(info, foundFilename)!; const { fragments } = findAllCallExpressions(externalSource, info); - const text = resolveTemplate( - templateLiteral, - foundFilename, - info - ).combinedText; + const text = getDocumentText(templateLiteral, foundFilename, info); const parsed = parse(text); const seen = new Set(); @@ -284,7 +290,7 @@ export const generateHashForDocument = ( export const getDocumentReferenceFromTypeQuery = ( typeQuery: ts.TypeQueryNode, filename: string, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): { node: ts.CallExpression | null; filename: string } => { const typeChecker = info.languageService.getProgram()?.getTypeChecker(); if (!typeChecker) return { node: null, filename }; @@ -314,7 +320,7 @@ export const getDocumentReferenceFromTypeQuery = ( export const getDocumentReferenceFromDocumentNode = ( documentNodeArgument: ts.Identifier | ts.CallExpression, filename: string, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): { node: ts.CallExpression | null; filename: string } => { if (ts.isIdentifier(documentNodeArgument)) { const typeChecker = info.languageService.getProgram()?.getTypeChecker(); diff --git a/packages/graphqlsp/src/ts7/checker.ts b/packages/graphqlsp/src/ts7/checker.ts new file mode 100644 index 00000000..1012fffc --- /dev/null +++ b/packages/graphqlsp/src/ts7/checker.ts @@ -0,0 +1,167 @@ +import type { HostChecker } from '../host'; + +/** Adapts a TypeScript 7.1 API checker to the TypeScript 5/6 checker API + * that GraphQLSP's diagnostics use. + * + * @remarks + * The TypeScript 7.1 API differs from TypeScript 5/6 where the checker hands + * out objects: + * - symbols' `declarations` and `valueDeclaration` are node handles, which + * have to be resolved to nodes against the project + * - types have `getTypes()` instead of `types`, and no `isUnionOrIntersection()` + * - types' `symbol` is a reference rather than a symbol (`getSymbol()`) + * + * Symbols, types, and signatures are wrapped in proxies providing these, and + * are unwrapped again when they're passed back to the checker. + */ +export const createChecker = (checker: any, project: any): HostChecker => { + const targets = new WeakMap(); + const proxies = new WeakMap(); + + const unwrap = (value: any): any => { + if (value && typeof value === 'object') { + const target = targets.get(value); + if (target) return target; + if (Array.isArray(value)) return value.map(unwrap); + } + return value; + }; + + const resolveHandle = (handle: any): any => { + if (!handle || typeof handle.resolve !== 'function') return handle; + try { + return handle.resolve(project); + } catch (_error) { + return undefined; + } + }; + + const createProxy = ( + target: T, + get: (target: any, key: PropertyKey) => { value: any } | undefined + ): T => { + let proxy = proxies.get(target) as T | undefined; + if (!proxy) { + const cache = new Map(); + proxy = new Proxy(target, { + get(target, key) { + let entry = cache.get(key); + if (!entry) { + entry = get(target, key); + if (entry) { + cache.set(key, entry); + } else { + const value = Reflect.get(target, key, target); + return typeof value === 'function' ? value.bind(target) : value; + } + } + return entry.value; + }, + }); + proxies.set(target, proxy); + targets.set(proxy, target); + } + return proxy; + }; + + const isSymbol = (value: any) => + 'escapedName' in value && 'declarations' in value; + const isType = (value: any) => + typeof value.getTypes === 'function' && 'flags' in value; + const isSignature = (value: any) => + typeof value.getReturnType === 'function' && 'parameters' in value; + + const wrap = (value: any): any => { + if (!value || typeof value !== 'object') { + return value; + } else if (Array.isArray(value)) { + return value.map(wrap); + } else if (targets.has(value)) { + return value; + } else if (isSymbol(value)) { + return wrapSymbol(value); + } else if (isType(value)) { + return wrapType(value); + } else if (isSignature(value)) { + return wrapSignature(value); + } else { + return value; + } + }; + + // Wraps a method whose arguments may be wrapped objects and whose result is wrapped + const wrapMethod = + (target: any, method: (...args: any[]) => any) => + (...args: any[]) => + wrap(method.apply(target, args.map(unwrap))); + + const wrapSymbol = (symbol: object) => + createProxy(symbol, (target, key) => { + if (key === 'declarations') { + const declarations = target.declarations; + return { + value: declarations + ? declarations.map(resolveHandle).filter(Boolean) + : undefined, + }; + } else if (key === 'valueDeclaration') { + return { value: resolveHandle(target.valueDeclaration) }; + } + return undefined; + }); + + const wrapType = (type: object) => + createProxy(type, (target, key) => { + switch (key) { + case 'types': + return { + value: + target.isUnionType() || target.isIntersectionType() + ? wrap(target.getTypes()) + : undefined, + }; + case 'symbol': + return { value: wrap(target.getSymbol()) }; + case 'resolvedTypeArguments': + return { + value: target.isTypeReference() + ? wrap(checker.getTypeArguments(target)) + : undefined, + }; + case 'isUnion': + return { value: () => target.isUnionType() }; + case 'isIntersection': + return { value: () => target.isIntersectionType() }; + case 'isUnionOrIntersection': + return { + value: () => target.isUnionType() || target.isIntersectionType(), + }; + case 'isStringLiteral': + return { value: () => target.isStringLiteralType() }; + default: { + const value = Reflect.get(target, key, target); + return typeof value === 'function' + ? { value: wrapMethod(target, value) } + : undefined; + } + } + }); + + const wrapSignature = (signature: object) => + createProxy(signature, (target, key) => { + if (key === 'declaration') { + return { value: resolveHandle(target.declaration) }; + } + const value = Reflect.get(target, key, target); + return typeof value === 'function' + ? { value: wrapMethod(target, value) } + : undefined; + }); + + return createProxy(checker, (target, key) => { + const value = Reflect.get(target, key, target); + return typeof value === 'function' + ? { value: wrapMethod(target, value) } + : undefined; + }) as HostChecker; +}; diff --git a/packages/graphqlsp/src/ts7/config.ts b/packages/graphqlsp/src/ts7/config.ts new file mode 100644 index 00000000..6b716f52 --- /dev/null +++ b/packages/graphqlsp/src/ts7/config.ts @@ -0,0 +1,88 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const PLUGIN_NAMES = new Set([ + '@0no-co/graphqlsp', + 'gql.tada/ts-plugin', + 'gql.tada/lsp', +]); + +export interface PluginConfigEntry { + /** The GraphQLSP plugin entry from `compilerOptions.plugins`. */ + config: Record; + /** The `tsconfig.json` declaring the plugin entry, which may be an extended config. */ + configPath: string; +} + +const resolveExtends = (specifier: string, fromConfig: string) => { + if (specifier.startsWith('.') || path.isAbsolute(specifier)) { + const resolved = path.resolve(path.dirname(fromConfig), specifier); + if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) + return resolved; + return fs.existsSync(`${resolved}.json`) ? `${resolved}.json` : null; + } + const require = createRequire(fromConfig); + for (const candidate of [ + specifier, + `${specifier}/tsconfig.json`, + `${specifier}.json`, + ]) { + try { + return require.resolve(candidate); + } catch (_error) {} + } + return null; +}; + +/** Reads a `tsconfig.json`'s raw contents, e.g. with `ts.readConfigFile`. */ +export type ReadConfigFile = ( + configPath: string +) => { config?: any } | undefined; + +/** Finds the GraphQLSP plugin entry of a `tsconfig.json`, following `extends`. + * + * @remarks + * TypeScript 7 drops plugin options from parsed compiler options, so the raw + * configs are read (e.g. with the TypeScript 7.1 API's `readConfigFile`, + * which handles tsconfig's JSONC dialect), and `extends` is followed like + * `@gql.tada/internal` does. This also tells us which config declares the + * plugin entry, which relative schema paths resolve against. + */ +export const readPluginConfig = ( + readConfigFile: ReadConfigFile, + configPath: string, + seen = new Set() +): PluginConfigEntry | null => { + if (seen.has(configPath)) return null; + seen.add(configPath); + + let tsconfig: any; + try { + const result = readConfigFile(configPath); + tsconfig = result && result.config; + } catch (_error) { + return null; + } + if (!tsconfig) return null; + + const plugins = tsconfig.compilerOptions && tsconfig.compilerOptions.plugins; + const plugin = + Array.isArray(plugins) && + plugins.find(plugin => plugin && PLUGIN_NAMES.has(plugin.name)); + if (plugin) return { config: plugin, configPath }; + + const extendsList: unknown[] = Array.isArray(tsconfig.extends) + ? // Later entries override earlier ones, so the last one wins + [...tsconfig.extends].reverse() + : tsconfig.extends + ? [tsconfig.extends] + : []; + for (const specifier of extendsList) { + if (typeof specifier !== 'string') continue; + const resolved = resolveExtends(specifier, configPath); + const found = resolved && readPluginConfig(readConfigFile, resolved, seen); + if (found) return found; + } + return null; +}; diff --git a/packages/graphqlsp/src/ts7/modules.ts b/packages/graphqlsp/src/ts7/modules.ts new file mode 100644 index 00000000..7d93d643 --- /dev/null +++ b/packages/graphqlsp/src/ts7/modules.ts @@ -0,0 +1,125 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +/** Packages implementing the TypeScript 7.1 API. */ +export type CompilerName = 'typescript' | 'tsrs'; + +/** The TypeScript 7.1 API modules of a compiler package. + * + * @remarks + * This is the only module that loads the TypeScript 7.1 API. Its export paths + * and types haven't settled across releases yet, so they're typed loosely here + * and everything else goes through these modules. + */ +export interface CompilerModules { + kind: 'api'; + name: CompilerName; + version: string; + /** `typescript/sync`: the synchronous API client. */ + sync: any; + /** `typescript/ast`: `SyntaxKind` and AST utilities. */ + ast: any; + /** `typescript/ast/is`: node type guards. */ + is: any; +} + +/** A TypeScript 5/6 installation, which still has the compiler API. */ +export interface LegacyCompiler { + kind: 'legacy'; + name: 'typescript'; + version: string; + typescript: any; +} + +export interface LoadCompilerOptions { + /** Path the compiler package is resolved from, e.g. the `tsconfig.json` being checked. */ + from: string; + /** Path of the compiler's `package.json` or package directory, to skip resolving it by name. */ + packagePath?: string; +} + +// `typescript@7.1` exports its API as `typescript/sync` etc, while tsrs +// releases before 0.19 and earlier native previews export `/unstable/sync` +const API_PREFIXES = ['./', './unstable/']; + +const resolveTarget = (entry: unknown): string | undefined => { + if (typeof entry === 'string') { + return entry; + } else if (entry && typeof entry === 'object') { + const conditions = entry as Record; + for (const condition of ['import', 'node', 'default', 'require']) { + const target = resolveTarget(conditions[condition]); + if (target) return target; + } + } + return undefined; +}; + +const findPackageJson = (options: LoadCompilerOptions, name: CompilerName) => { + if (options.packagePath) { + return path.basename(options.packagePath) === 'package.json' + ? path.resolve(options.packagePath) + : path.resolve(options.packagePath, 'package.json'); + } + try { + return createRequire(path.resolve(options.from)).resolve( + `${name}/package.json` + ); + } catch (_error) { + throw new Error( + `Could not find "${name}" from "${options.from}". ` + + (name === 'tsrs' + ? 'Install "tsrs" to check GraphQL documents with tsrs.' + : 'Install "typescript" to check GraphQL documents.') + ); + } +}; + +export const loadCompiler = async ( + name: CompilerName, + options: LoadCompilerOptions +): Promise => { + const packageJsonPath = findPackageJson(options, name); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const exports = (packageJson && packageJson.exports) || {}; + const packageDir = path.dirname(packageJsonPath); + + const major = parseInt(packageJson.version, 10); + if (packageJson.name === 'typescript' && major < 7) { + return { + kind: 'legacy', + name: 'typescript', + version: packageJson.version, + typescript: createRequire(packageJsonPath)(packageDir), + }; + } + + for (const prefix of API_PREFIXES) { + const sync = resolveTarget(exports[`${prefix}sync`]); + const ast = resolveTarget(exports[`${prefix}ast`]); + const is = resolveTarget(exports[`${prefix}ast/is`]); + if (!sync || !ast || !is) continue; + const load = (target: string) => + import(pathToFileURL(path.resolve(packageDir, target)).href); + const [syncModule, astModule, isModule] = await Promise.all([ + load(sync), + load(ast), + load(is), + ]); + return { + kind: 'api', + name: packageJson.name === 'tsrs' ? 'tsrs' : 'typescript', + version: packageJson.version, + sync: syncModule, + ast: astModule, + is: isModule, + }; + } + + throw new Error( + `"${packageJson.name}@${packageJson.version}" doesn't provide the TypeScript 7.1 API. ` + + 'Checking GraphQL documents requires TypeScript 5/6, "typescript@>=7.1", or "tsrs".' + ); +}; diff --git a/packages/graphqlsp/src/ts7/namespace.ts b/packages/graphqlsp/src/ts7/namespace.ts new file mode 100644 index 00000000..b07f43ae --- /dev/null +++ b/packages/graphqlsp/src/ts7/namespace.ts @@ -0,0 +1,64 @@ +import type { ts } from '../ts'; +import type { CompilerModules } from './modules'; + +/** Creates the subset of the `typescript` (5/6) namespace that GraphQLSP's + * diagnostics use from the TypeScript 7.1 API's AST modules. + * + * @remarks + * The TypeScript 7.1 API's nodes mirror the shape of TypeScript 5/6's nodes + * (`kind`, `parent`, `getStart()`, `getText()`, named children, ...), but its + * `SyntaxKind` values differ (and differ between TypeScript and tsrs too), and + * a few type guards are missing or were renamed. Enums are therefore always + * taken from the loaded compiler. + */ +export const createTypeScriptNamespace = ( + modules: CompilerModules +): typeof ts => { + const { ast, is, sync } = modules; + const SyntaxKind = ast.SyntaxKind; + const kindIs = + (...kinds: number[]) => + (node: ts.Node | undefined): boolean => + !!node && kinds.includes(node.kind); + + const isFunctionLike = (node: ts.Node | undefined): boolean => + !!node && + (is.isFunctionLikeDeclaration(node) || is.isSignatureDeclaration(node)); + + const namespace = { + ...is, + + SyntaxKind, + SymbolFlags: sync.SymbolFlags, + TypeFlags: sync.TypeFlags, + DiagnosticCategory: sync.DiagnosticCategory, + + forEachChild: ( + node: ts.Node, + cbNode: (node: ts.Node) => T | undefined, + cbNodes?: (nodes: ts.NodeArray) => T | undefined + ): T | undefined => (node as any).forEachChild(cbNode, cbNodes), + idText: (identifier: ts.Identifier | ts.PrivateIdentifier) => + identifier.text, + + // Missing from the TypeScript 7.1 API, or renamed + isStringLiteralLike: kindIs( + SyntaxKind.StringLiteral, + SyntaxKind.NoSubstitutionTemplateLiteral + ), + isFunctionLike, + isClassLike: is.isClassLikeDeclaration, + isObjectLiteralElement: (node: ts.Node | undefined) => + !!node && + (is.isObjectLiteralElementLike(node) || + is.isJsxAttribute(node) || + is.isJsxSpreadAttribute(node)), + isParameter: is.isParameterDeclaration, + isMethodSignature: is.isMethodSignatureDeclaration, + isPropertySignature: is.isPropertySignatureDeclaration, + // TypeScript 7 doesn't have a `CommaListExpression` node + isCommaListExpression: () => false, + }; + + return namespace as unknown as typeof ts; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac275374..f1af8ec1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -356,6 +356,27 @@ importers: specifier: ^6.0.3 version: 6.0.3 + test/e2e/fixture-project-ts7: + dependencies: + '@0no-co/graphqlsp': + specifier: workspace:* + version: link:../../../packages/graphqlsp + gql.tada: + specifier: ^1.11.0 + version: 1.11.0(graphql@16.8.1)(typescript@6.0.3) + graphql: + specifier: ^16.0.0 + version: 16.8.1 + devDependencies: + tsrs: + specifier: 0.18.0-ts7.1.0-dev.20260929 + version: 0.18.0-ts7.1.0-dev.20260929 + typescript-7: + specifier: npm:typescript@7.1.0-beta + version: typescript@7.1.0-beta + + test/e2e/fixture-project-ts7/mapped/mapper: {} + test/e2e/fixture-project-unused-fields: dependencies: '@0no-co/graphqlsp': @@ -1523,6 +1544,40 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@tsrs/darwin-arm64@0.18.0-ts7.1.0-dev.20260929': + resolution: {integrity: sha512-sAirMpaGA9uRQm2W+t4zC7PjnijNuFa4PCSIfLKKo0JyAUCRfnMeev0UGBQ9F3g+9Nz187tvikUs5Irh5+eajA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@tsrs/linux-arm64-gnu@0.18.0-ts7.1.0-dev.20260929': + resolution: {integrity: sha512-zm9/z9yylig68JiZF4Aq0Cm26n09KEU8YoQRlG7tdDWTBNAIViYw8knxnWYsjMWVZ2XiU4mYq1xdrOKuOgABHg==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tsrs/linux-arm64-musl@0.18.0-ts7.1.0-dev.20260929': + resolution: {integrity: sha512-Lw7VVU/YQ9JcarODICEiwzMLdFqZprqh5RGTgbS5Uc6I95P635/g5xA1pOC8RVs4efBvAR30J54iDG3l5JEYiw==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tsrs/linux-x64-gnu@0.18.0-ts7.1.0-dev.20260929': + resolution: {integrity: sha512-EdBwBgNICGu1r1h23ixdcKwsMo6E9ll7ZsY3mNERNYqE1Pwdtcat99d5E7zB1J+oy/Xk6YqiizaB6nolIjpDTw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tsrs/linux-x64-musl@0.18.0-ts7.1.0-dev.20260929': + resolution: {integrity: sha512-LHk+AOAtXuTlhJhOJ2+elriEIX17ycXljojeNxrrzckOipTG1LksoVWuCSas2nu00EnWAzrdKpt9oqPylUKZfQ==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + libc: [musl] + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -1568,6 +1623,132 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript/typescript-aix-ppc64@7.1.0-beta': + resolution: {integrity: sha512-DXDG+jIWxBHGdoO70tcHON33rCnSzsEZj9P13oyf/r/KEGIo6awsdSNVNjAw0uMwEpqRu75IrgCcW58YcVkZHg==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-android-arm64@7.1.0-beta': + resolution: {integrity: sha512-UZx6PxoQavuvaVTrSf3aw0Oy/J/uJpRylipA0kzc038jMvZXh0UtE8vnQp20FqtdgD5bhXZ0aJrRJyoTSUO58Q==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [android] + + '@typescript/typescript-darwin-arm64@7.1.0-beta': + resolution: {integrity: sha512-q7mxblcyX0E7hXsY4k+Q6KWKK5mGmfVq+BDesdTgRVgjodk27aIxER77Zyj9+TgSTlCje8F7+JM6f6hl2BuNdw==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.1.0-beta': + resolution: {integrity: sha512-D5EYqG5Wm/guw9+0j8+Mn1Jbr/gfS/VtFTuoH8PJtFbcqeDaQGMulc7SW8q51QG6Y3wGOGX3nbsTOw6fIKWWAA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.1.0-beta': + resolution: {integrity: sha512-9A0XX0tcnH9qPdolSKKzSjTb93dlB0UEcAJbHYEsrFX2fkFNSum9+dvkQQJ9vLc+7vxHetDPujq9Qe6wx+Xhow==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.1.0-beta': + resolution: {integrity: sha512-EDf/dBHfY0TxX3NmUTek0hbffeyZlh3+ls6WQW3ICB8vYxwYLFnzg7wkgEUvl9eRlpiJ/nmc2S8arPMzINJ7rg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.1.0-beta': + resolution: {integrity: sha512-hNqQBgQ5b5oGqN76QqbAU6CwYhJZ6iOcWc2kPTUs2OentgpxZktETziH6KIyRFJPaiBncognwn2ZLu3DNhHiWw==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.1.0-beta': + resolution: {integrity: sha512-AvN9HmzOQh46G4312ooSgMHhbB7DtnLwg8D9mo324X49B7weMwtJrBG1MxT9IyI8kzDd7YRtW+1hWGun7WG7Qw==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.1.0-beta': + resolution: {integrity: sha512-xbUustlBrFflt2pNEyG5ye+d4WGPc/rfr2xK29MXeobPSw297JqwuKRrvbKM5o3fE/eUM/c+urVm6jQ3IaMVTw==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.1.0-beta': + resolution: {integrity: sha512-fS4/G7/0bXZ3NSq3nBMAXyg65I3tO4S7j08VNQ2g1aNC/qq79gE7Qne1BNjVsjMFfHBVBFjQYoTQCjmVU2iFlg==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.1.0-beta': + resolution: {integrity: sha512-0upCCxYPX8X6gOwkvfv+Ee7/ufyy7k97mTrGE0e5BxLlnUEa+w+rtgnv2dsKcxFdwdeZYVlZKCqgN/xCBV08+A==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.1.0-beta': + resolution: {integrity: sha512-0jKU9YRgOPRgiy5V/UWv37kB53PaTpIbwGCbbOMUPPAkrjVRNOIAJaz7Wa7s3H7mdDebX1LopUEPEJR+7Xa7zg==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.1.0-beta': + resolution: {integrity: sha512-j+d04s2kxaPQh6DLPb01sknAQfzsrdSZy15jBRHyLEZoaaQ19dVjbFRlkNVvYwUG7ZdP8+y5b/7RmPxdtHUKdQ==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.1.0-beta': + resolution: {integrity: sha512-kcD51ihf+pnI5KT4uQQYG04OpwznaZYBlIQhDhl8fflFd27NktxXsUeguxZyvUMyWfum7IF2bBS6Jv7ox0AgFQ==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.1.0-beta': + resolution: {integrity: sha512-8W85YyoGK+rgGoSU9jJQUpELbrKcZcbdJk2jPNQ6UKDKI7ryPlPFTrV2jCE5RQuv4Tt96hMDTc33s4YfRrLFBA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.1.0-beta': + resolution: {integrity: sha512-iGsTE0ve2PG0AcyZinwAR0LnGEZhu/y4dceS0hZsYCaKdYETvQtvtlNWlmYKempsBr6C7Il7dMXHVk+VFm9u0g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.1.0-beta': + resolution: {integrity: sha512-Z2FERdHB9fAKI+KGqdWn04O7uNlnB+VYiEm6INE1aZuzzLNd3KBqQ6IpKUJF2ny4GigGU6nVu75XD9tAUODPng==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.1.0-beta': + resolution: {integrity: sha512-NVG3qeyCq/rJEOhaRjR3LsWgjrjo5vJW25omVLeHAVnpKIG4pE4cenwwKGR06DiJ1s0QTOymcJeKXMEpsiNBVg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.1.0-beta': + resolution: {integrity: sha512-ovAdoWXEn2B5/fyWG69pLU3FWmQwPWOjQV5TFbcxtpnOv+R3g8ySGHl1NYRq7Lb/GYdFDvaGcZ+SiU7iKuiMsA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.1.0-beta': + resolution: {integrity: sha512-lJbo71IEOfL7ZZIZnArRaOLqjn9BAfoSzyUQn0aSjfSpDZL4ru0afUsdBfo6PQEjD6ssdmi0n/RwaPADX6bsvQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.1.0-beta': + resolution: {integrity: sha512-UKcHvbybY2tDTvFW2b1vwiAvDlIRooo69lh8CXWVhOQejcvpM5+IgbiFW9EIwR5Wi5X/v+BdN7gdWbigBuJ3rw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@typespec/ts-http-runtime@0.3.8': resolution: {integrity: sha512-bLMpVcWZNzq6lYOybwFwOAR1IXKcHnhUNqYeHjl1bET/qE3jFPFH+p8Wrh3rU4xwdnifPxmKNESBYnvnmc75aA==} engines: {node: '>=22.0.0'} @@ -3540,6 +3721,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsrs@0.18.0-ts7.1.0-dev.20260929: + resolution: {integrity: sha512-9T0b4Uam/yMFIK16GUx0fUrJqYZnJ/gvSmV9JB8NTyDePvwCI45WFWxyEV/UMV3KfdNRJG/PBsm3fSIB9WmWsA==} + engines: {node: '>=16.20.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -3563,6 +3749,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@7.1.0-beta: + resolution: {integrity: sha512-cjWjZAJDsmZzsNC5GwHNlW0YPM65jZa5oR0KyQcYX3KSVIfTvtBq//vewISyatKk4OLi5grZjHhIVE9fdrw1UQ==} + engines: {node: '>=16.20.0'} + hasBin: true + ua-parser-js@1.0.41: resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} hasBin: true @@ -5335,6 +5526,21 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@tsrs/darwin-arm64@0.18.0-ts7.1.0-dev.20260929': + optional: true + + '@tsrs/linux-arm64-gnu@0.18.0-ts7.1.0-dev.20260929': + optional: true + + '@tsrs/linux-arm64-musl@0.18.0-ts7.1.0-dev.20260929': + optional: true + + '@tsrs/linux-x64-gnu@0.18.0-ts7.1.0-dev.20260929': + optional: true + + '@tsrs/linux-x64-musl@0.18.0-ts7.1.0-dev.20260929': + optional: true + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -5381,6 +5587,69 @@ snapshots: dependencies: '@types/node': 22.20.0 + '@typescript/typescript-aix-ppc64@7.1.0-beta': + optional: true + + '@typescript/typescript-android-arm64@7.1.0-beta': + optional: true + + '@typescript/typescript-darwin-arm64@7.1.0-beta': + optional: true + + '@typescript/typescript-darwin-x64@7.1.0-beta': + optional: true + + '@typescript/typescript-freebsd-arm64@7.1.0-beta': + optional: true + + '@typescript/typescript-freebsd-x64@7.1.0-beta': + optional: true + + '@typescript/typescript-linux-arm64@7.1.0-beta': + optional: true + + '@typescript/typescript-linux-arm@7.1.0-beta': + optional: true + + '@typescript/typescript-linux-loong64@7.1.0-beta': + optional: true + + '@typescript/typescript-linux-mips64el@7.1.0-beta': + optional: true + + '@typescript/typescript-linux-ppc64@7.1.0-beta': + optional: true + + '@typescript/typescript-linux-riscv64@7.1.0-beta': + optional: true + + '@typescript/typescript-linux-s390x@7.1.0-beta': + optional: true + + '@typescript/typescript-linux-x64@7.1.0-beta': + optional: true + + '@typescript/typescript-netbsd-arm64@7.1.0-beta': + optional: true + + '@typescript/typescript-netbsd-x64@7.1.0-beta': + optional: true + + '@typescript/typescript-openbsd-arm64@7.1.0-beta': + optional: true + + '@typescript/typescript-openbsd-x64@7.1.0-beta': + optional: true + + '@typescript/typescript-sunos-x64@7.1.0-beta': + optional: true + + '@typescript/typescript-win32-arm64@7.1.0-beta': + optional: true + + '@typescript/typescript-win32-x64@7.1.0-beta': + optional: true + '@typespec/ts-http-runtime@0.3.8': dependencies: http-proxy-agent: 7.0.2 @@ -7452,6 +7721,14 @@ snapshots: tslib@2.8.1: {} + tsrs@0.18.0-ts7.1.0-dev.20260929: + optionalDependencies: + '@tsrs/darwin-arm64': 0.18.0-ts7.1.0-dev.20260929 + '@tsrs/linux-arm64-gnu': 0.18.0-ts7.1.0-dev.20260929 + '@tsrs/linux-arm64-musl': 0.18.0-ts7.1.0-dev.20260929 + '@tsrs/linux-x64-gnu': 0.18.0-ts7.1.0-dev.20260929 + '@tsrs/linux-x64-musl': 0.18.0-ts7.1.0-dev.20260929 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -7471,6 +7748,30 @@ snapshots: typescript@6.0.3: {} + typescript@7.1.0-beta: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.1.0-beta + '@typescript/typescript-android-arm64': 7.1.0-beta + '@typescript/typescript-darwin-arm64': 7.1.0-beta + '@typescript/typescript-darwin-x64': 7.1.0-beta + '@typescript/typescript-freebsd-arm64': 7.1.0-beta + '@typescript/typescript-freebsd-x64': 7.1.0-beta + '@typescript/typescript-linux-arm': 7.1.0-beta + '@typescript/typescript-linux-arm64': 7.1.0-beta + '@typescript/typescript-linux-loong64': 7.1.0-beta + '@typescript/typescript-linux-mips64el': 7.1.0-beta + '@typescript/typescript-linux-ppc64': 7.1.0-beta + '@typescript/typescript-linux-riscv64': 7.1.0-beta + '@typescript/typescript-linux-s390x': 7.1.0-beta + '@typescript/typescript-linux-x64': 7.1.0-beta + '@typescript/typescript-netbsd-arm64': 7.1.0-beta + '@typescript/typescript-netbsd-x64': 7.1.0-beta + '@typescript/typescript-openbsd-arm64': 7.1.0-beta + '@typescript/typescript-openbsd-x64': 7.1.0-beta + '@typescript/typescript-sunos-x64': 7.1.0-beta + '@typescript/typescript-win32-arm64': 7.1.0-beta + '@typescript/typescript-win32-x64': 7.1.0-beta + ua-parser-js@1.0.41: {} uc.micro@2.1.0: {} diff --git a/test/e2e/fixture-project-ts7/mapped/mapper/package.json b/test/e2e/fixture-project-ts7/mapped/mapper/package.json new file mode 100644 index 00000000..1f24f53e --- /dev/null +++ b/test/e2e/fixture-project-ts7/mapped/mapper/package.json @@ -0,0 +1,13 @@ +{ + "name": "graphqlsp-test-content-mapper", + "version": "0.0.0", + "private": true, + "typescript": { + "contentMapper": { + "exec": [ + "node", + "./server.mjs" + ] + } + } +} diff --git a/test/e2e/fixture-project-ts7/mapped/mapper/server.mjs b/test/e2e/fixture-project-ts7/mapped/mapper/server.mjs new file mode 100644 index 00000000..99a95918 --- /dev/null +++ b/test/e2e/fixture-project-ts7/mapped/mapper/server.mjs @@ -0,0 +1,61 @@ +// A minimal TypeScript 7.1 content mapper for tests, which maps each `.graphql` +// file to a module exporting its operation through an aliased `graphql()`, +// like @gql.tada/content-mapper does. Documents are emitted as string literals +// with escape sequences, which persisted hashes have to be computed without. +let buffer = Buffer.alloc(0); + +const send = message => { + const json = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message })); + process.stdout.write(`Content-Length: ${json.length}\r\n\r\n`); + process.stdout.write(json); +}; + +const transform = ({ content }) => { + const match = + /(?:query|mutation|subscription)\s+([_A-Za-z][_0-9A-Za-z]*)/.exec(content); + const name = match ? match[1] : 'Document'; + return { + text: + 'import { graphql as $graphql } from "./graphql.js";\n' + + `export const ${name} = $graphql(${JSON.stringify(content)});\n`, + extension: '.ts', + mappings: [], + }; +}; + +const handlers = { + initialize: () => ({ + protocolVersion: 1, + positionEncoding: 'utf-16', + diagnosticSource: 'test-mapper', + }), + openProject: () => ({}), + closeProject: () => null, + transform, +}; + +process.stdin.on('data', chunk => { + buffer = Buffer.concat([buffer, chunk]); + for (;;) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd < 0) return; + const length = Number( + /Content-Length: (\d+)/i.exec(buffer.subarray(0, headerEnd).toString())[1] + ); + if (buffer.length < headerEnd + 4 + length) return; + const message = JSON.parse( + buffer.subarray(headerEnd + 4, headerEnd + 4 + length).toString() + ); + buffer = buffer.subarray(headerEnd + 4 + length); + if (message.id === undefined) continue; + const handler = handlers[message.method]; + if (handler) { + send({ id: message.id, result: handler(message.params) }); + } else { + send({ + id: message.id, + error: { code: -32601, message: `Unknown method ${message.method}` }, + }); + } + } +}); diff --git a/test/e2e/fixture-project-ts7/mapped/src/Pokemon.graphql b/test/e2e/fixture-project-ts7/mapped/src/Pokemon.graphql new file mode 100644 index 00000000..7e114550 --- /dev/null +++ b/test/e2e/fixture-project-ts7/mapped/src/Pokemon.graphql @@ -0,0 +1,6 @@ +query PokemonDoc($id: ID!) { + pokemon(id: $id) { + id + name + } +} diff --git a/test/e2e/fixture-project-ts7/mapped/src/graphql.ts b/test/e2e/fixture-project-ts7/mapped/src/graphql.ts new file mode 100644 index 00000000..16c0a447 --- /dev/null +++ b/test/e2e/fixture-project-ts7/mapped/src/graphql.ts @@ -0,0 +1 @@ +export { graphql } from '../../src/graphql.js'; diff --git a/test/e2e/fixture-project-ts7/mapped/src/persisted.ts b/test/e2e/fixture-project-ts7/mapped/src/persisted.ts new file mode 100644 index 00000000..a0014a61 --- /dev/null +++ b/test/e2e/fixture-project-ts7/mapped/src/persisted.ts @@ -0,0 +1,9 @@ +import { graphql } from './graphql.js'; +import { PokemonDoc } from './Pokemon.graphql'; + +export const PersistedPokemon = graphql.persisted( + 'sha256:9f8ed4df6f77f52f0331b1358cedf2d35108ed8835cdb191c6b606726bf432a2', + PokemonDoc +); + +export const StalePokemon = graphql.persisted('sha256:outdated', PokemonDoc); diff --git a/test/e2e/fixture-project-ts7/mapped/tsconfig.json b/test/e2e/fixture-project-ts7/mapped/tsconfig.json new file mode 100644 index 00000000..53b5fca3 --- /dev/null +++ b/test/e2e/fixture-project-ts7/mapped/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "plugins": [ + { + "name": "@0no-co/graphqlsp", + "schema": "../schema.graphql", + "tadaOutputLocation": "../src/introspection.d.ts" + } + ], + "target": "es2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"], + "contentMappers": [ + { + "package": "graphqlsp-test-content-mapper", + "extensions": [".graphql"] + } + ] +} diff --git a/test/e2e/fixture-project-ts7/package.json b/test/e2e/fixture-project-ts7/package.json new file mode 100644 index 00000000..94f4dc09 --- /dev/null +++ b/test/e2e/fixture-project-ts7/package.json @@ -0,0 +1,13 @@ +{ + "name": "fixtures", + "private": true, + "dependencies": { + "graphql": "^16.0.0", + "gql.tada": "^1.11.0", + "@0no-co/graphqlsp": "workspace:*" + }, + "devDependencies": { + "tsrs": "0.18.0-ts7.1.0-dev.20260929", + "typescript-7": "npm:typescript@7.1.0-beta" + } +} diff --git a/test/e2e/fixture-project-ts7/schema.graphql b/test/e2e/fixture-project-ts7/schema.graphql new file mode 100644 index 00000000..6ffcfb95 --- /dev/null +++ b/test/e2e/fixture-project-ts7/schema.graphql @@ -0,0 +1,94 @@ +### This file was generated by Nexus Schema +### Do not make changes to this file directly + +""" +Move a Pokémon can perform with the associated damage and type. +""" +type Attack { + damage: Int + name: String + type: PokemonType +} + +type AttacksConnection { + fast: [Attack] + special: [Attack] +} + +""" +Requirement that prevents an evolution through regular means of levelling up. +""" +type EvolutionRequirement { + amount: Int + name: String +} + +type Pokemon { + attacks: AttacksConnection + classification: String @deprecated(reason: "And this is the reason why") + evolutionRequirements: [EvolutionRequirement] + evolutions: [Pokemon] + + """ + Likelihood of an attempt to catch a Pokémon to fail. + """ + fleeRate: Float + height: PokemonDimension + id: ID! + + """ + Maximum combat power a Pokémon may achieve at max level. + """ + maxCP: Int + + """ + Maximum health points a Pokémon may achieve at max level. + """ + maxHP: Int + name: String! + resistant: [PokemonType] + types: [PokemonType] + weaknesses: [PokemonType] + weight: PokemonDimension +} + +type PokemonDimension { + maximum: String + minimum: String +} + +""" +Elemental property associated with either a Pokémon or one of their moves. +""" +enum PokemonType { + Bug + Dark + Dragon + Electric + Fairy + Fighting + Fire + Flying + Ghost + Grass + Ground + Ice + Normal + Poison + Psychic + Rock + Steel + Water +} + +type Query { + """ + Get a single Pokémon by its ID, a three character long identifier padded with zeroes + """ + pokemon(id: ID!): Pokemon + + """ + List out all Pokémon, optionally in pages + """ + pokemons(limit: Int = 10, skip: Int): [Pokemon] +} diff --git a/test/e2e/fixture-project-ts7/src/colocation.ts b/test/e2e/fixture-project-ts7/src/colocation.ts new file mode 100644 index 00000000..d44e8add --- /dev/null +++ b/test/e2e/fixture-project-ts7/src/colocation.ts @@ -0,0 +1,13 @@ +import { graphql } from './graphql.js'; +import { Pokemon } from './fragment.js'; + +export const PokemonQuery = graphql(` + query Pokemon($id: ID!) { + pokemon(id: $id) { + id + name + } + } +`); + +console.log(Pokemon); diff --git a/test/e2e/fixture-project-ts7/src/field-usage.ts b/test/e2e/fixture-project-ts7/src/field-usage.ts new file mode 100644 index 00000000..10399f71 --- /dev/null +++ b/test/e2e/fixture-project-ts7/src/field-usage.ts @@ -0,0 +1,27 @@ +import type { TadaDocumentNode } from 'gql.tada'; +import { graphql } from './graphql.js'; + +declare function useQuery(args: { + query: TadaDocumentNode; + variables: Variables; +}): [{ data?: Data }]; + +const PokemonQuery = graphql(` + query PokemonUsage($id: ID!) { + pokemon(id: $id) { + id + name + fleeRate + weight { + minimum + maximum + } + } + } +`); + +export function logPokemon() { + const [result] = useQuery({ query: PokemonQuery, variables: { id: '1' } }); + const pokemon = result.data?.pokemon; + console.log(pokemon?.name, pokemon?.weight?.minimum); +} diff --git a/test/e2e/fixture-project-ts7/src/fragment.ts b/test/e2e/fixture-project-ts7/src/fragment.ts new file mode 100644 index 00000000..c8da94de --- /dev/null +++ b/test/e2e/fixture-project-ts7/src/fragment.ts @@ -0,0 +1,11 @@ +import { graphql } from './graphql.js'; + +export const PokemonFields = graphql(` + fragment pokemonFields on Pokemon { + id + name + fleeRate + } +`); + +export const Pokemon = () => {}; diff --git a/test/e2e/fixture-project-ts7/src/graphql.ts b/test/e2e/fixture-project-ts7/src/graphql.ts new file mode 100644 index 00000000..ce748758 --- /dev/null +++ b/test/e2e/fixture-project-ts7/src/graphql.ts @@ -0,0 +1,9 @@ +import { initGraphQLTada } from 'gql.tada'; +import type { introspection } from './introspection.js'; + +export const graphql = initGraphQLTada<{ + introspection: introspection; +}>(); + +export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada'; +export { readFragment } from 'gql.tada'; diff --git a/test/e2e/fixture-project-ts7/src/introspection.d.ts b/test/e2e/fixture-project-ts7/src/introspection.d.ts new file mode 100644 index 00000000..f7c08876 --- /dev/null +++ b/test/e2e/fixture-project-ts7/src/introspection.d.ts @@ -0,0 +1,41 @@ +/* eslint-disable */ +/* prettier-ignore */ + +export type introspection_types = { + 'Attack': { kind: 'OBJECT'; name: 'Attack'; fields: { 'damage': { name: 'damage'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'name': { name: 'name'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'type': { name: 'type'; type: { kind: 'ENUM'; name: 'PokemonType'; ofType: null; } }; }; }; + 'AttacksConnection': { kind: 'OBJECT'; name: 'AttacksConnection'; fields: { 'fast': { name: 'fast'; type: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'Attack'; ofType: null; }; } }; 'special': { name: 'special'; type: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'Attack'; ofType: null; }; } }; }; }; + 'Boolean': unknown; + 'EvolutionRequirement': { kind: 'OBJECT'; name: 'EvolutionRequirement'; fields: { 'amount': { name: 'amount'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'name': { name: 'name'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; }; }; + 'Float': unknown; + 'ID': unknown; + 'Int': unknown; + 'Pokemon': { kind: 'OBJECT'; name: 'Pokemon'; fields: { 'attacks': { name: 'attacks'; type: { kind: 'OBJECT'; name: 'AttacksConnection'; ofType: null; } }; 'classification': { name: 'classification'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'evolutionRequirements': { name: 'evolutionRequirements'; type: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'EvolutionRequirement'; ofType: null; }; } }; 'evolutions': { name: 'evolutions'; type: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'Pokemon'; ofType: null; }; } }; 'fleeRate': { name: 'fleeRate'; type: { kind: 'SCALAR'; name: 'Float'; ofType: null; } }; 'height': { name: 'height'; type: { kind: 'OBJECT'; name: 'PokemonDimension'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'maxCP': { name: 'maxCP'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'maxHP': { name: 'maxHP'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'resistant': { name: 'resistant'; type: { kind: 'LIST'; name: never; ofType: { kind: 'ENUM'; name: 'PokemonType'; ofType: null; }; } }; 'types': { name: 'types'; type: { kind: 'LIST'; name: never; ofType: { kind: 'ENUM'; name: 'PokemonType'; ofType: null; }; } }; 'weaknesses': { name: 'weaknesses'; type: { kind: 'LIST'; name: never; ofType: { kind: 'ENUM'; name: 'PokemonType'; ofType: null; }; } }; 'weight': { name: 'weight'; type: { kind: 'OBJECT'; name: 'PokemonDimension'; ofType: null; } }; }; }; + 'PokemonDimension': { kind: 'OBJECT'; name: 'PokemonDimension'; fields: { 'maximum': { name: 'maximum'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'minimum': { name: 'minimum'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; }; }; + 'PokemonType': { name: 'PokemonType'; enumValues: 'Bug' | 'Dark' | 'Dragon' | 'Electric' | 'Fairy' | 'Fighting' | 'Fire' | 'Flying' | 'Ghost' | 'Grass' | 'Ground' | 'Ice' | 'Normal' | 'Poison' | 'Psychic' | 'Rock' | 'Steel' | 'Water'; }; + 'Query': { kind: 'OBJECT'; name: 'Query'; fields: { 'pokemon': { name: 'pokemon'; type: { kind: 'OBJECT'; name: 'Pokemon'; ofType: null; } }; 'pokemons': { name: 'pokemons'; type: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'Pokemon'; ofType: null; }; } }; }; }; + 'String': unknown; +}; + +/** An IntrospectionQuery representation of your schema. + * + * @remarks + * This is an introspection of your schema saved as a file by GraphQLSP. + * It will automatically be used by `gql.tada` to infer the types of your GraphQL documents. + * If you need to reuse this data or update your `scalars`, update `tadaOutputLocation` to + * instead save to a .ts instead of a .d.ts file. + */ +export type introspection = { + name: never; + query: 'Query'; + mutation: never; + subscription: never; + types: introspection_types; +}; + +import * as gqlTada from 'gql.tada'; + +declare module 'gql.tada' { + interface setupSchema { + introspection: introspection + } +} \ No newline at end of file diff --git a/test/e2e/fixture-project-ts7/src/invalid.ts b/test/e2e/fixture-project-ts7/src/invalid.ts new file mode 100644 index 00000000..65b3d439 --- /dev/null +++ b/test/e2e/fixture-project-ts7/src/invalid.ts @@ -0,0 +1,12 @@ +import { graphql } from './graphql.js'; + +export const PokemonQuery = graphql(` + query Pokemon($id: ID!) { + pokemon(id: $id) { + id + nmae + classification + ...missingFragment + } + } +`); diff --git a/test/e2e/fixture-project-ts7/src/valid.ts b/test/e2e/fixture-project-ts7/src/valid.ts new file mode 100644 index 00000000..8f4c71cc --- /dev/null +++ b/test/e2e/fixture-project-ts7/src/valid.ts @@ -0,0 +1,15 @@ +import { graphql } from './graphql.js'; +import { PokemonFields } from './fragment.js'; + +// Spreads a fragment from another file, which has to be resolved through the type checker +export const PokemonsQuery = graphql( + ` + query Pokemons($limit: Int!) { + pokemons(limit: $limit) { + id + ...pokemonFields + } + } + `, + [PokemonFields] +); diff --git a/test/e2e/fixture-project-ts7/tsconfig.json b/test/e2e/fixture-project-ts7/tsconfig.json new file mode 100644 index 00000000..9d21ab47 --- /dev/null +++ b/test/e2e/fixture-project-ts7/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "plugins": [ + { + "name": "@0no-co/graphqlsp", + "schema": "./schema.graphql", + "tadaOutputLocation": "./src/introspection.d.ts", + "trackFieldUsage": true, + "shouldCheckForColocatedFragments": true + } + ], + "target": "es2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/test/e2e/ts7-content-mapper.test.ts b/test/e2e/ts7-content-mapper.test.ts new file mode 100644 index 00000000..83945648 --- /dev/null +++ b/test/e2e/ts7-content-mapper.test.ts @@ -0,0 +1,92 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { createRequire } from 'node:module'; +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +import { checkProject } from '../../packages/graphqlsp/src/check'; + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); + +// A project in the TypeScript 7 fixture whose `.graphql` files are mapped by a +// minimal content mapper (`mapped/mapper`) to modules exporting their documents +// through an aliased `graphql()`, like @gql.tada/content-mapper +const projectPath = path.resolve(__dirname, 'fixture-project-ts7', 'mapped'); +const tsconfigPath = path.join(projectPath, 'tsconfig.json'); +const compilerPath = createRequire(tsconfigPath).resolve( + 'typescript-7/package.json' +); + +// tsrs's API doesn't run content mappers yet (as of 0.18), even with `runExternalCode` +const skip = + !!process.env.GRAPHQLSP_TS7_COMPILER && + process.env.GRAPHQLSP_TS7_COMPILER !== 'typescript'; + +const check = async (runExternalCode: boolean) => { + const result = await checkProject({ + tsconfigPath, + compilerPath, + runExternalCode, + }); + return { + files: result.files.map(file => path.relative(projectPath, file)).sort(), + diagnostics: result.diagnostics.map(diagnostic => ({ + file: path.relative(projectPath, diagnostic.file), + line: diagnostic.line, + column: diagnostic.column, + code: diagnostic.code, + message: diagnostic.message, + })), + }; +}; + +describe.skipIf(skip)('checkProject with content mappers', () => { + beforeAll(() => { + // TypeScript resolves content mappers from node_modules + const link = path.join( + projectPath, + 'node_modules', + 'graphqlsp-test-content-mapper' + ); + fs.mkdirSync(path.dirname(link), { recursive: true }); + fs.rmSync(link, { force: true }); + fs.symlinkSync(path.join(projectPath, 'mapper'), link, 'junction'); + }); + + it("doesn't find documents in mapped files without runExternalCode", async () => { + const { diagnostics } = await check(false); + expect(diagnostics).toEqual([ + expect.objectContaining({ + file: 'src/persisted.ts', + line: 6, + message: 'Can\'t find reference to "PokemonDoc".', + }), + expect.objectContaining({ + file: 'src/persisted.ts', + line: 9, + message: 'Can\'t find reference to "PokemonDoc".', + }), + ]); + }); + + it('resolves documents in mapped files with runExternalCode', async () => { + const { files, diagnostics } = await check(true); + // The mapped `.graphql` files are part of the program, but aren't checked, + // since their mapper reports their diagnostics + expect(files).toEqual([ + path.join('..', 'src', 'graphql.ts'), + path.join('src', 'graphql.ts'), + path.join('src', 'persisted.ts'), + ]); + // The first hash matches the mapped document, the second one doesn't + expect(diagnostics).toEqual([ + { + file: 'src/persisted.ts', + line: 9, + column: 47, + code: 520103, + message: "The persisted document's hash is outdated", + }, + ]); + }); +}); diff --git a/test/e2e/ts7.test.ts b/test/e2e/ts7.test.ts new file mode 100644 index 00000000..5a1aef26 --- /dev/null +++ b/test/e2e/ts7.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import url from 'node:url'; + +import { + checkProject, + type CheckDiagnostic, + type CompilerName, +} from '../../packages/graphqlsp/src/check'; + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); + +const projectPath = path.resolve(__dirname, 'fixture-project-ts7'); +const tsconfigPath = path.join(projectPath, 'tsconfig.json'); +// The fixture project installs TypeScript 7 (as `typescript-7`) and tsrs, +// while the workspace's own `typescript` is TypeScript 5/6 +const require = createRequire(tsconfigPath); +const workspaceRequire = createRequire(import.meta.url); + +interface Compiler { + label: string; + name: CompilerName; + packagePath: string; +} + +// The TypeScript 7.1 API implementations to check with. CI installs other +// versions of these packages, and `GRAPHQLSP_TS7_COMPILER` selects one of them +const compilers: Compiler[] = [ + { + label: 'TypeScript 7', + name: 'typescript' as const, + packagePath: require.resolve('typescript-7/package.json'), + }, + { + label: 'tsrs', + name: 'tsrs' as const, + packagePath: require.resolve('tsrs/package.json'), + }, +].filter( + compiler => + !process.env.GRAPHQLSP_TS7_COMPILER || + process.env.GRAPHQLSP_TS7_COMPILER === compiler.name +); + +const toSnapshot = (diagnostics: CheckDiagnostic[]) => + diagnostics + .map(({ file, ...diagnostic }) => ({ + file: path.relative(projectPath, file), + ...diagnostic, + })) + .sort((a, b) => a.file.localeCompare(b.file) || a.start - b.start); + +// The workspace's TypeScript 5/6 runs the same diagnostics through its +// compiler API, which TypeScript 7 has to report identically +const legacyDiagnostics = checkProject({ + tsconfigPath, + compilerPath: workspaceRequire.resolve('typescript/package.json'), +}).then(result => toSnapshot(result.diagnostics)); + +describe('checkProject with TypeScript 5/6', () => { + it('reports diagnostics for the fixture project', async () => { + expect(await legacyDiagnostics).toMatchInlineSnapshot(` + [ + { + "code": 52003, + "column": 25, + "file": "src/colocation.ts", + "length": 15, + "line": 2, + "message": "Unused co-located fragment definition(s) "pokemonFields" in './fragment.js'", + "severity": "warning", + "start": 64, + }, + { + "code": 52005, + "column": 5, + "file": "src/field-usage.ts", + "length": 7, + "line": 11, + "message": "Field(s) 'pokemon.fleeRate' are not used.", + "severity": "warning", + "start": 302, + }, + { + "code": 52005, + "column": 7, + "file": "src/field-usage.ts", + "length": 6, + "line": 15, + "message": "Field(s) 'pokemon.weight.maximum' are not used.", + "severity": "warning", + "start": 362, + }, + { + "code": 52001, + "column": 7, + "file": "src/invalid.ts", + "length": 5, + "line": 7, + "message": "Cannot query field "nmae" on type "Pokemon". Did you mean "name"?", + "severity": "error", + "start": 145, + }, + { + "code": 52004, + "column": 7, + "file": "src/invalid.ts", + "length": 15, + "line": 8, + "message": "The field Pokemon.classification is deprecated. And this is the reason why", + "severity": "warning", + "start": 156, + }, + { + "code": 52001, + "column": 10, + "file": "src/invalid.ts", + "length": 16, + "line": 9, + "message": "Unknown fragment "missingFragment".", + "severity": "error", + "start": 180, + }, + ] + `); + }, 60_000); +}); + +describe.each(compilers)('checkProject with $label', compiler => { + it('reports the same diagnostics as TypeScript 5/6', async () => { + const result = await checkProject({ + tsconfigPath, + compiler: compiler.name, + compilerPath: compiler.packagePath, + }); + expect(result.compiler).toEqual({ + name: compiler.name, + version: require(compiler.packagePath).version, + }); + if (compiler.name === 'typescript') + expect(parseInt(result.compiler.version, 10)).toBeGreaterThanOrEqual(7); + expect(result.files.map(file => path.relative(projectPath, file)).sort()) + .toMatchInlineSnapshot(` + [ + "src/colocation.ts", + "src/field-usage.ts", + "src/fragment.ts", + "src/graphql.ts", + "src/invalid.ts", + "src/valid.ts", + ] + `); + expect(toSnapshot(result.diagnostics)).toEqual(await legacyDiagnostics); + }, 60_000); + + it('checks only the given files', async () => { + const result = await checkProject({ + tsconfigPath, + compiler: compiler.name, + compilerPath: compiler.packagePath, + files: [path.join(projectPath, 'src/valid.ts')], + }); + expect(result.files).toEqual([path.join(projectPath, 'src/valid.ts')]); + expect(result.diagnostics).toEqual([]); + }, 60_000); +});