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/check-project-run-external-code.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/check-project-typescript-7.md
Original file line number Diff line number Diff line change
@@ -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`.
65 changes: 65 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ dist

packages/graphqlsp/api/*
packages/graphqlsp/api
packages/graphqlsp/check

# VSCode extension packaging
packages/vscode-graphqlsp/.staging
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion packages/graphqlsp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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
Expand Down
24 changes: 12 additions & 12 deletions packages/graphqlsp/src/ast/checks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ts } from '../ts';
import type { HostChecker } from '../host';
import { templates } from './templates';

/** Checks for an immediately-invoked function expression */
Expand All @@ -13,27 +14,26 @@ 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
// symbol turns the type checker work from one probe per call site into one
// 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<ts.Symbol, boolean>
>();

const schemaNameCache = new WeakMap<
ts.TypeChecker,
HostChecker,
WeakMap<ts.Symbol, string | null>
>();

const getCached = <T>(
caches: WeakMap<ts.TypeChecker, WeakMap<ts.Symbol, T>>,
checker: ts.TypeChecker,
caches: WeakMap<HostChecker, WeakMap<ts.Symbol, T>>,
checker: HostChecker,
node: ts.Node,
compute: () => T
): T => {
Expand All @@ -49,7 +49,7 @@ const getCached = <T>(
/** 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, () => {
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -114,7 +114,7 @@ export const isTadaPersistedCall = (
return false; // rejecting non property access calls: <expression>.<name>()
} 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': <expression>.persisted()
} else if (isGraphQLFunctionIdentifier(node.expression.expression)) {
Expand All @@ -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) &&
Expand All @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions packages/graphqlsp/src/ast/declaration.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ts } from '../ts';
import type { HostChecker } from '../host';

export type ValueDeclaration =
| ts.BinaryExpression
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 11 additions & 13 deletions packages/graphqlsp/src/ast/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -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;

Expand All @@ -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<FragmentDefinitionNode> {
const fragments: FragmentDefinitionNode[] = [];
const elements: ts.Identifier[] = [element];
Expand Down Expand Up @@ -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 => {
Expand All @@ -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<{
Expand Down Expand Up @@ -230,12 +231,12 @@ export function findAllPersistedCallExpressions(
): Array<ts.CallExpression>;
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 }
Expand All @@ -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<FragmentDefinitionNode> = [];

const typeChecker = info.languageService.getProgram()?.getTypeChecker();
Expand Down
Loading
Loading