({
13 | fragment: repositoryItemRepositoryDocument,
14 | from: {
15 | __typename: 'Repository',
16 | id,
17 | },
18 | });
19 | if (!complete) return;
20 | return (
21 |
22 |
23 | {data.description}
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/cli/logger.ts:
--------------------------------------------------------------------------------
1 | export interface Logger {
2 | debug(...args: string[]): void;
3 | info(...args: string[]): void;
4 | error(...args: any[]): void;
5 | }
6 |
7 | export type LogLevel = 'silent' | 'error' | 'info' | 'debug';
8 |
9 | export class ConsoleLogger implements Logger {
10 | constructor(public logLevel: LogLevel = 'info') {}
11 |
12 | /* eslint-disable no-console */
13 | error(...args: any[]): void {
14 | if (this.logLevel !== 'silent') {
15 | console.error(...args);
16 | }
17 | }
18 | info(...args: string[]): void {
19 | if (this.logLevel !== 'silent' && this.logLevel !== 'error') {
20 | console.log(...args);
21 | }
22 | }
23 | debug(...args: string[]): void {
24 | if (this.logLevel === 'debug') {
25 | console.log(...args);
26 | }
27 | }
28 | /* eslint-enable no-console */
29 | }
30 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 | ---
8 |
9 | ### Describe the bug
10 |
11 | A clear and concise description of what the bug is.
12 |
13 | ### To Reproduce
14 |
15 | Steps to reproduce the behavior:
16 |
17 | ### Expected behavior
18 |
19 | A clear and concise description of what you expected to happen.
20 |
21 | ### Debug log
22 |
23 | If applicable, add screenshots to help explain your problem.
24 |
25 | If the bug occurs on editor/IDE, turn on verbose log with the following and paste tssserver.log of from the replay.
26 |
27 | ```sh
28 | $ export TSS_LOG="-file `pwd`/tsserver.log -level verbose"
29 | ```
30 |
31 | If the bug occurs on CLI, paste log with `--verbose` option.
32 |
33 | ### Additional context
34 |
35 | Add any other context about the problem here.
36 |
--------------------------------------------------------------------------------
/src/schema-manager/types.ts:
--------------------------------------------------------------------------------
1 | export type SchemaConfig = {
2 | schema:
3 | | string
4 | | {
5 | file: {
6 | path: string;
7 | };
8 | }
9 | | {
10 | http: {
11 | url: string;
12 | method?: string;
13 | headers?: { [key: string]: string };
14 | };
15 | }
16 | | {
17 | http: {
18 | fromScript: string;
19 | };
20 | };
21 | localSchemaExtensions?: string[];
22 | };
23 |
24 | export interface SchemaManagerHost {
25 | getProjectRootPath(): string;
26 |
27 | getConfig(): SchemaConfig;
28 |
29 | fileExists(path: string): boolean;
30 | readFile(path: string, encoding?: string): string | undefined;
31 | watchFile(
32 | path: string,
33 | cb: (fileName: string) => void,
34 | interval: number,
35 | ): {
36 | close(): void;
37 | };
38 |
39 | log(msg: string): void;
40 | }
41 |
--------------------------------------------------------------------------------
/project-fixtures/transformation-prj/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | const TsGraphQLPlugin = require('../../webpack');
4 |
5 | const tsgqlPlugin = new TsGraphQLPlugin({ tsconfigPath: __dirname });
6 |
7 | module.exports = {
8 | resolve: {
9 | extensions: ['.ts', '.js'],
10 | },
11 | entry: {
12 | main: path.resolve(__dirname, 'query.ts'),
13 | },
14 | output: {
15 | path: path.resolve(__dirname, 'dist'),
16 | filename: '[name].js',
17 | },
18 | module: {
19 | rules: [
20 | {
21 | test: /\.ts$/,
22 | exclude: /node_modules/,
23 | loader: 'ts-loader',
24 | options: {
25 | transpileOnly: true,
26 | getCustomTransformers: () => ({
27 | before: [tsgqlPlugin.getTransformer()],
28 | }),
29 | },
30 | },
31 | ],
32 | },
33 | plugins: [tsgqlPlugin],
34 | devtool: false,
35 | };
36 |
--------------------------------------------------------------------------------
/e2e/cli-specs/typegen.js:
--------------------------------------------------------------------------------
1 | const assert = require('assert');
2 | const fs = require('fs');
3 | const rimraf = require('rimraf');
4 |
5 | async function run(cli) {
6 | const { code: code0 } = await cli.run('typegen', ['-p', 'project-fixtures/react-apollo-prj', '--verbose']);
7 | assert.equal(code0, 0);
8 |
9 | const { code: code1 } = await cli.run('typegen', ['-p', 'project-fixtures/gql-errors-prj', '--verbose']);
10 | assert.equal(code1, 1);
11 |
12 | rimraf.sync('project-fixtures/typegen-addon-prj/__generated__/**');
13 | const { code: code2 } = await cli.run('typegen', ['-p', 'project-fixtures/typegen-addon-prj', '--verbose']);
14 | assert.equal(code2, 0);
15 | assert(fs.existsSync(__dirname + '/../../project-fixtures/typegen-addon-prj/__generated__/my-query.ts'));
16 | assert(fs.existsSync(__dirname + '/../../project-fixtures/typegen-addon-prj/__generated__/post-fragment.ts'));
17 | }
18 |
19 | module.exports = run;
20 |
--------------------------------------------------------------------------------
/project-fixtures/transformation-global-frag-prj/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | const TsGraphQLPlugin = require('../../webpack');
4 |
5 | const tsgqlPlugin = new TsGraphQLPlugin({ tsconfigPath: __dirname });
6 |
7 | module.exports = {
8 | resolve: {
9 | extensions: ['.ts', '.js'],
10 | },
11 | entry: {
12 | main: path.resolve(__dirname, 'query.ts'),
13 | },
14 | output: {
15 | path: path.resolve(__dirname, 'dist'),
16 | filename: '[name].js',
17 | },
18 | module: {
19 | rules: [
20 | {
21 | test: /\.ts$/,
22 | exclude: /node_modules/,
23 | loader: 'ts-loader',
24 | options: {
25 | transpileOnly: true,
26 | getCustomTransformers: () => ({
27 | before: [tsgqlPlugin.getTransformer()],
28 | }),
29 | },
30 | },
31 | ],
32 | },
33 | plugins: [tsgqlPlugin],
34 | devtool: false,
35 | };
36 |
--------------------------------------------------------------------------------
/src/register-hooks/register-typescript.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs';
2 | import type { CompilerOptions } from 'typescript';
3 |
4 | export function registerTypeScript() {
5 | let defaultCompileOptions: CompilerOptions;
6 | require.extensions['.ts'] = (module, fileName) => {
7 | const ts = require('typescript') as typeof import('typescript');
8 | if (!defaultCompileOptions) {
9 | defaultCompileOptions = ts.getDefaultCompilerOptions();
10 | }
11 | const content = fs.readFileSync(fileName, 'utf8');
12 | const { outputText } = ts.transpileModule(content, {
13 | fileName,
14 | compilerOptions: {
15 | ...defaultCompileOptions,
16 | noEmit: true,
17 | esModuleInterop: true,
18 | target: ts.ScriptTarget.ES2019,
19 | module: ts.ModuleKind.CommonJS,
20 | },
21 | reportDiagnostics: false,
22 | });
23 | (module as any)._compile(outputText, fileName);
24 | };
25 | }
26 |
--------------------------------------------------------------------------------
/src/analyzer/types.ts:
--------------------------------------------------------------------------------
1 | import { TsGraphQLPluginConfigOptions } from '../types';
2 | import { TypeGenAddonFactory } from '../typegen';
3 |
4 | export type OperationType = 'query' | 'mutation' | 'subscription' | 'fragment' | 'complex' | 'other';
5 |
6 | export type TsGraphQLPluginConfig = Omit & {
7 | typegen: {
8 | addonFactories: TypeGenAddonFactory[];
9 | };
10 | };
11 |
12 | export interface ManifestDocumentEntry {
13 | fileName: string;
14 | type: OperationType;
15 | operationName?: string;
16 | fragmentName?: string;
17 | body: string;
18 | tag?: string;
19 | documentStart: { line: number; character: number };
20 | documentEnd: { line: number; character: number };
21 | templateLiteralNodeStart: { line: number; character: number };
22 | templateLiteralNodeEnd: { line: number; character: number };
23 | }
24 |
25 | export interface ManifestOutput {
26 | documents: ManifestDocumentEntry[];
27 | }
28 |
--------------------------------------------------------------------------------
/src/analyzer/testing/testing-extractor.ts:
--------------------------------------------------------------------------------
1 | import { Extractor } from '../extractor';
2 | import { FragmentRegistry } from '../../gql-ast-util';
3 | import { createTestingLanguageServiceAndHost } from '../../ts-ast-util/testing/testing-language-service';
4 | import { createScriptSourceHelper } from '../../ts-ast-util';
5 |
6 | export function createTesintExtractor(
7 | files: { fileName: string; content: string }[],
8 | removeDuplicatedFragments = false,
9 | ) {
10 | const { languageService, languageServiceHost } = createTestingLanguageServiceAndHost({ files });
11 | const extractor = new Extractor({
12 | removeDuplicatedFragments,
13 | scriptSourceHelper: createScriptSourceHelper(
14 | {
15 | languageService,
16 | languageServiceHost,
17 | project: { getProjectName: () => '' },
18 | },
19 | { exclude: [] },
20 | ),
21 | fragmentRegistry: new FragmentRegistry(),
22 | debug: () => {},
23 | });
24 | return extractor;
25 | }
26 |
--------------------------------------------------------------------------------
/src/string-util/color.ts:
--------------------------------------------------------------------------------
1 | const resetCode = '\u001b[0m';
2 | const colorCode = {
3 | thin: '\u001b[2m',
4 | invert: '\u001b[7m',
5 | black: '\u001b[30m',
6 | red: '\u001b[31m',
7 | green: '\u001b[32m',
8 | yellow: '\u001b[33m',
9 | blue: '\u001b[34m',
10 | magenta: '\u001b[35m',
11 | cyan: '\u001b[36m',
12 | white: '\u001b[37m',
13 | };
14 |
15 | export const color = Object.entries(colorCode).reduce((acc: any, [name, code]) => {
16 | return {
17 | ...acc,
18 | [name]: (msg: string) => code + msg + resetCode,
19 | };
20 | }, {}) as {
21 | [P in keyof typeof colorCode]: (msg: string) => string;
22 | };
23 |
24 | export function clearColor(msg: string) {
25 | const outs: string[] = [];
26 | let i = 0;
27 | while (i < msg.length) {
28 | const charactor = msg[i++];
29 | if (charactor === '\u001b') {
30 | while (msg[i++] !== 'm') continue;
31 | } else {
32 | outs.push(charactor);
33 | }
34 | }
35 | return outs.join('');
36 | }
37 |
--------------------------------------------------------------------------------
/src/language-service-plugin/language-service-proxy-builder.ts:
--------------------------------------------------------------------------------
1 | import type ts from 'typescript/lib/tsserverlibrary';
2 |
3 | export type LanguageServiceMethodWrapper = (
4 | delegate: ts.LanguageService[K],
5 | info?: { languageService: ts.LanguageService },
6 | ) => ts.LanguageService[K];
7 |
8 | export class LanguageServiceProxyBuilder {
9 | private _wrappers: any[] = [];
10 |
11 | constructor(private _info: { languageService: ts.LanguageService }) {}
12 |
13 | wrap>(name: K, wrapper: Q) {
14 | this._wrappers.push({ name, wrapper });
15 | return this;
16 | }
17 |
18 | build(): ts.LanguageService {
19 | const ret = this._info.languageService;
20 | this._wrappers.forEach(({ name, wrapper }) => {
21 | (ret as any)[name] = wrapper(this._info.languageService[name as keyof ts.LanguageService], this._info);
22 | });
23 | return ret;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/.eslintrc.yml:
--------------------------------------------------------------------------------
1 | root: true
2 | parser: '@typescript-eslint/parser'
3 | parserOptions:
4 | sourceType: 'module'
5 | project: './tsconfig.json'
6 | ecmaFeatures:
7 | jsx: true
8 | useJSXTextNode: true
9 | extends:
10 | - prettier
11 | plugins:
12 | - '@typescript-eslint'
13 | rules:
14 | no-eval: error
15 | no-debugger: error
16 | no-console: error
17 | no-duplicate-imports: error
18 | no-var: error
19 | no-unsafe-finally: error
20 | no-restricted-imports: off
21 | prefer-const: error
22 | prefer-rest-params: error
23 | no-trailing-spaces:
24 | - error
25 | - ignoreComments: true
26 | '@typescript-eslint/no-use-before-define': error
27 | '@typescript-eslint/no-namespace': error
28 | overrides:
29 | - files: '**/*.ts'
30 | excludedFiles: ['*.test.ts', '**/testing/**']
31 | rules:
32 | '@typescript-eslint/no-restricted-imports':
33 | - error
34 | - paths:
35 | - name: typescript
36 | message: "Use 'tsmodule' instead"
37 | allowTypeImports: true
38 |
--------------------------------------------------------------------------------
/src/schema-manager/__snapshots__/extension-manager.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`ExtensionManager should parse and extend base schema 1`] = `
4 | "type Query {
5 | hello: String!
6 | me: User!
7 | }
8 |
9 | type User {
10 | name: String!
11 | }"
12 | `;
13 |
14 | exports[`ExtensionManager should store parser errors with invalid extension 1`] = `
15 | [
16 | {
17 | "fileContent": "extend type NoExisitingType {
18 | name: String!
19 | }
20 | ",
21 | "fileName": "/testing/resources/invalid_extension.graphql",
22 | "message": "Cannot extend type "NoExisitingType" because it is not defined.",
23 | },
24 | ]
25 | `;
26 |
27 | exports[`ExtensionManager should store parser errors with invalid syntax file 1`] = `
28 | [
29 | {
30 | "fileContent": "directive @hoge() on FIEELD
31 | ",
32 | "fileName": "/testing/resources/invalid_syntax.graphql",
33 | "locations": [
34 | {
35 | "character": 16,
36 | "line": 0,
37 | },
38 | ],
39 | "message": "Syntax Error: Expected Name, found ")".",
40 | },
41 | ]
42 | `;
43 |
--------------------------------------------------------------------------------
/src/typegen-addons/typed-query-document.ts:
--------------------------------------------------------------------------------
1 | import ts from '../tsmodule';
2 | import type { TypeGenAddonFactory } from '../typegen';
3 | import { astf } from '../ts-ast-util';
4 |
5 | export const TypedQueryDocumentAddonFactory: TypeGenAddonFactory = ({ source }) => ({
6 | operationDefinition({ tsResultNode, tsVariableNode }) {
7 | const lhs = astf.createIdentifier(`${tsResultNode.name.text}Document`);
8 | const rhs = astf.createTypeReferenceNode(astf.createIdentifier('TypedDocumentNode'), [
9 | astf.createTypeReferenceNode(astf.createIdentifier(tsResultNode.name.text)),
10 | astf.createTypeReferenceNode(astf.createIdentifier(tsVariableNode.name.text)),
11 | ]);
12 | const modifiers = [astf.createModifier(ts.SyntaxKind.ExportKeyword)];
13 | const typeAliasDeclaration = astf.createTypeAliasDeclaration(modifiers, lhs, undefined, rhs);
14 | // source.pushNamedImportIfNeeded('TypedQueryDocumentNode', 'graphql');
15 | source.pushNamedImportIfNeeded('TypedDocumentNode', '@graphql-typed-document-node/core');
16 | source.pushStatement(typeAliasDeclaration);
17 | },
18 | });
19 |
--------------------------------------------------------------------------------
/src/typegen/addon/merge-addons.ts:
--------------------------------------------------------------------------------
1 | import {
2 | TypeGenVisitorAddon,
3 | StrictAddon,
4 | CustomScalarInput,
5 | CustomScalarOutput,
6 | DocumentInput,
7 | OperationDefinionInput,
8 | FragmentDefinitionInput,
9 | } from './types';
10 |
11 | export function mergeAddons(addonList: (TypeGenVisitorAddon | undefined)[]) {
12 | const addon: StrictAddon = {
13 | customScalar: (input: CustomScalarInput) => {
14 | return addonList.reduce((acc: CustomScalarOutput | undefined, addon) => {
15 | return addon?.customScalar ? acc || addon?.customScalar(input) : acc;
16 | }, undefined);
17 | },
18 |
19 | document(input: DocumentInput) {
20 | return addonList.forEach(addon => addon?.document?.(input));
21 | },
22 |
23 | operationDefinition(input: OperationDefinionInput) {
24 | return addonList.forEach(addon => addon?.operationDefinition?.(input));
25 | },
26 |
27 | fragmentDefinition(input: FragmentDefinitionInput) {
28 | return addonList.forEach(addon => addon?.fragmentDefinition?.(input));
29 | },
30 | };
31 |
32 | return addon;
33 | }
34 |
--------------------------------------------------------------------------------
/e2e/lang-server-specs/diagnostics-syntax.js:
--------------------------------------------------------------------------------
1 | const assert = require('assert');
2 | const path = require('path');
3 |
4 | function findResponse(responses, eventName) {
5 | return responses.find(response => response.event === eventName);
6 | }
7 |
8 | const fileContent = `
9 | import gql from 'graphql-tag';
10 | const q = gql\`{\`;
11 | `;
12 |
13 | async function run(server) {
14 | const file = path.resolve(__dirname, '../../project-fixtures/simple-prj/main.ts');
15 | server.send({ command: 'open', arguments: { file, fileContent, scriptKindName: 'TS' } });
16 | await server.waitEvent('projectLoadingFinish');
17 | server.send({ command: 'geterr', arguments: { files: [file], delay: 0 } });
18 | await server.waitEvent('semanticDiag');
19 | return server.close().then(() => {
20 | const semanticDiagEvent = findResponse(server.responses, 'semanticDiag');
21 | assert(!!semanticDiagEvent);
22 | assert.strictEqual(semanticDiagEvent.body.diagnostics.length, 1);
23 | assert.strictEqual(semanticDiagEvent.body.diagnostics[0].text, 'Syntax Error: Expected Name, found .');
24 | });
25 | }
26 |
27 | module.exports = run;
28 |
--------------------------------------------------------------------------------
/e2e/lang-server-specs/diagnostics.js:
--------------------------------------------------------------------------------
1 | const assert = require('assert');
2 | const path = require('path');
3 |
4 | function findResponse(responses, eventName) {
5 | return responses.find(response => response.event === eventName);
6 | }
7 |
8 | const fileContent = `
9 | import gql from 'graphql-tag';
10 | const q = gql\`query { goodbye }\`;
11 | `;
12 |
13 | async function run(server) {
14 | const file = path.resolve(__dirname, '../../project-fixtures/simple-prj/main.ts');
15 | server.send({ command: 'open', arguments: { file, fileContent, scriptKindName: 'TS' } });
16 | await server.waitEvent('projectLoadingFinish');
17 | server.send({ command: 'geterr', arguments: { files: [file], delay: 0 } });
18 | await server.waitEvent('semanticDiag');
19 | return server.close().then(() => {
20 | const semanticDiagEvent = findResponse(server.responses, 'semanticDiag');
21 | assert(!!semanticDiagEvent);
22 | assert.equal(semanticDiagEvent.body.diagnostics.length, 1);
23 | assert.equal(semanticDiagEvent.body.diagnostics[0].text, 'Cannot query field "goodbye" on type "Query".');
24 | });
25 | }
26 |
27 | module.exports = run;
28 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) [2017] [Quramy]
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: publish
2 |
3 | on:
4 | push:
5 | tags:
6 | - v*
7 |
8 | jobs:
9 | publish:
10 | runs-on: ubuntu-latest
11 |
12 | strategy:
13 | matrix:
14 | node-version: [18.x]
15 |
16 | steps:
17 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
18 |
19 | - name: Use Node.js ${{ matrix.node-version }}
20 | uses: actions/setup-node@v6
21 | with:
22 | node-version: ${{ matrix.node-version }}
23 | cache: npm
24 |
25 | - id: dist-tag
26 | uses: actions/github-script@v8
27 | with:
28 | result-encoding: string
29 | script: |
30 | return /^refs\/tags\/v\d+\.\d+\.\d+$/.test(context.ref) ? "latest" : "next"
31 |
32 | - name: npm publish
33 | run: |
34 | echo "//registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN}" > ~/.npmrc
35 | npm whoami
36 | npm ci
37 | npm run build
38 | npm publish --tag ${{ steps.dist-tag.outputs.result }}
39 | env:
40 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
41 | CI: true
42 |
--------------------------------------------------------------------------------
/project-fixtures/react-apollo-prj/GRAPHQL_OPERATIONS.md:
--------------------------------------------------------------------------------
1 | # Extracted GraphQL Operations
2 | ## Queries
3 |
4 | ### AppQuery
5 |
6 | ```graphql
7 | query AppQuery($first: Int!) {
8 | viewer {
9 | repositories(first: $first) {
10 | nodes {
11 | id
12 | ...RepositoryItem_Repository @nonreactive
13 | }
14 | }
15 | }
16 | }
17 |
18 | fragment RepositoryItem_Repository on Repository {
19 | name
20 | description
21 | }
22 | ```
23 |
24 | From [src/App.tsx:6:19](src/App.tsx#L6-L17)
25 |
26 | ## Mutations
27 |
28 | ### UpdateMyRepository
29 |
30 | ```graphql
31 | mutation UpdateMyRepository($repositoryId: ID!) {
32 | updateRepository(input: {repositoryId: $repositoryId}) {
33 | clientMutationId
34 | }
35 | }
36 | ```
37 |
38 | From [src/App.tsx:19:22](src/App.tsx#L19-L25)
39 |
40 | ## Fragments
41 |
42 | ### RepositoryItem_Repository
43 |
44 | ```graphql
45 | fragment RepositoryItem_Repository on Repository {
46 | name
47 | description
48 | }
49 | ```
50 |
51 | From [src/RepositoryItem.tsx:4:53](src/RepositoryItem.tsx#L4-L9)
52 |
53 | ---
54 | Extracted by [ts-graphql-plugin](https://github.com/Quramy/ts-graphql-plugin)
--------------------------------------------------------------------------------
/src/ts-ast-util/file-name-filter.ts:
--------------------------------------------------------------------------------
1 | import _path from 'node:path';
2 |
3 | import { globToRegExp } from '../string-util/glob-to-regexp';
4 |
5 | export function createFileNameFilter({
6 | specs,
7 | projectName,
8 | _forceWin32 = false,
9 | }: {
10 | specs: string[] | undefined;
11 | projectName: string;
12 | _forceWin32?: boolean;
13 | }) {
14 | const path = _forceWin32 ? _path.win32 : _path;
15 |
16 | const testers = (specs ?? []).map(pattern => {
17 | if (pattern.includes('*') || pattern.includes('?')) {
18 | const regexp = globToRegExp(pattern);
19 | return (normalized: string) => regexp.test(normalized);
20 | } else {
21 | const dirOrFileName = pattern[pattern.length - 1] === '/' ? pattern.slice(0, pattern.length - 1) : pattern;
22 | return (normalized: string) => normalized === dirOrFileName || normalized.startsWith(dirOrFileName + '/');
23 | }
24 | });
25 |
26 | const projectRootDirName = path.dirname(projectName);
27 |
28 | const match = (fileName: string) => {
29 | const normalized = path.relative(projectRootDirName, fileName).replace(/\\/g, '/');
30 | return testers.some(tester => tester(normalized));
31 | };
32 |
33 | return match;
34 | }
35 |
--------------------------------------------------------------------------------
/e2e/lang-server-specs/completions.js:
--------------------------------------------------------------------------------
1 | const assert = require('assert');
2 | const path = require('path');
3 | const { extract } = require('fretted-strings');
4 |
5 | function findResponse(responses, commandName) {
6 | return responses.find(response => response.command === commandName);
7 | }
8 |
9 | async function run(server) {
10 | const file = path.resolve(__dirname, '../../project-fixtures/simple-prj/main.ts');
11 | const [fileContent, frets] = extract(
12 | `
13 | const q = gql\`query {
14 | %%% \\ ^ %%%
15 | %%% \\ p %%%
16 | `,
17 | );
18 | server.send({ command: 'open', arguments: { file, fileContent, scriptKindName: 'TS' } });
19 | await server.waitEvent('projectLoadingFinish');
20 | server.send({
21 | command: 'completions',
22 | arguments: { file, offset: frets.p.character + 1, line: frets.p.line + 1, prefix: '' },
23 | });
24 | await server.waitResponse('completions');
25 | return server.close().then(() => {
26 | const completionsResponse = findResponse(server.responses, 'completions');
27 | assert(!!completionsResponse);
28 | assert(completionsResponse.body.some(item => item.name === 'hello'));
29 | });
30 | }
31 |
32 | module.exports = run;
33 |
--------------------------------------------------------------------------------
/src/graphql-language-service-adapter/simple-position.test.ts:
--------------------------------------------------------------------------------
1 | import { SimplePosition } from './simple-position';
2 |
3 | describe(SimplePosition, () => {
4 | describe('setter methods', () => {
5 | test(SimplePosition.prototype.setLine.name, () => {
6 | const p = new SimplePosition({ line: 0, character: 0 });
7 | p.setLine(1);
8 | expect(p.line).toBe(1);
9 | });
10 |
11 | test(SimplePosition.prototype.setCharacter.name, () => {
12 | const p = new SimplePosition({ line: 0, character: 0 });
13 | p.setCharacter(1);
14 | expect(p.character).toBe(1);
15 | });
16 | });
17 |
18 | describe(SimplePosition.prototype.lessThanOrEqualTo, () => {
19 | it('should return compared result to another position', () => {
20 | const p1 = new SimplePosition({ line: 1, character: 10 });
21 | const p2 = new SimplePosition({ line: 0, character: 10 });
22 | const p3 = new SimplePosition({ line: 1, character: 11 });
23 | const p4 = new SimplePosition({ line: 1, character: 10 });
24 | expect(p1.lessThanOrEqualTo(p2)).toBeFalsy();
25 | expect(p1.lessThanOrEqualTo(p3)).toBeTruthy();
26 | expect(p1.lessThanOrEqualTo(p4)).toBeTruthy();
27 | });
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/project-fixtures/react-apollo-prj/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { gql, useSuspenseQuery, useFragment } from '@apollo/client';
2 |
3 | import { RepositoryItem } from './RepositoryItem';
4 | import type { AppQueryDocument } from './__generated__/app-query';
5 |
6 | const query = gql`
7 | query AppQuery($first: Int!) {
8 | viewer {
9 | repositories(first: $first) {
10 | nodes {
11 | id
12 | ...RepositoryItem_Repository @nonreactive
13 | }
14 | }
15 | }
16 | }
17 | `;
18 |
19 | const mutation = gql`
20 | mutation UpdateMyRepository($repositoryId: ID!) {
21 | updateRepository(input: { repositoryId: $repositoryId }) {
22 | clientMutationId
23 | }
24 | }
25 | `;
26 |
27 | export function App() {
28 | const { data } = useSuspenseQuery(query as AppQueryDocument, { variables: { first: 100 } });
29 | if (!data.viewer || !data.viewer.repositories.nodes) return null;
30 | return (
31 |
32 | {data.viewer.repositories.nodes.map(
33 | repository =>
34 | repository && (
35 | -
36 |
37 |
38 | ),
39 | )}
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/e2e/lang-server-specs/quickinfo.js:
--------------------------------------------------------------------------------
1 | const assert = require('assert');
2 | const path = require('path');
3 | const { extract } = require('fretted-strings');
4 |
5 | function findResponse(responses, commandName) {
6 | return responses.find(response => response.command === commandName);
7 | }
8 |
9 | async function run(server) {
10 | const file = path.resolve(__dirname, '../../project-fixtures/simple-prj/main.ts');
11 | const [fileContent, frets] = extract(
12 | `
13 | const q = gql\`query {
14 | hello
15 | %%% ^ %%%
16 | %%% p %%%
17 | }
18 | `,
19 | );
20 | server.send({
21 | command: 'open',
22 | arguments: { file, fileContent, scriptKindName: 'TS' },
23 | });
24 | await server.waitEvent('projectLoadingFinish');
25 | server.send({ command: 'quickinfo', arguments: { file, offset: frets.p.character + 1, line: frets.p.line + 1 } });
26 | await server.waitResponse('quickinfo');
27 | return server.close().then(() => {
28 | const quickinfoResponse = findResponse(server.responses, 'quickinfo');
29 | assert(!!quickinfoResponse);
30 | assert(quickinfoResponse.body.displayString === 'Query.hello: String!');
31 | });
32 | }
33 |
34 | module.exports = run;
35 |
--------------------------------------------------------------------------------
/project-fixtures/graphql-codegen-prj/GRAPHQL_OPERATIONS.md:
--------------------------------------------------------------------------------
1 | # Extracted GraphQL Operations
2 | ## Queries
3 |
4 | ### PopularPosts_Query
5 |
6 | ```graphql
7 | query PopularPosts_Query {
8 | popularPosts {
9 | id
10 | ...PostSummary_Post
11 | }
12 | }
13 |
14 | fragment PostSummary_Post on Post {
15 | id
16 | title
17 | author {
18 | name
19 | ...UserAvatar_User
20 | }
21 | }
22 |
23 | fragment UserAvatar_User on User {
24 | name
25 | avatarURL
26 | }
27 | ```
28 |
29 | From [src/PopularPosts.tsx:6:24](src/PopularPosts.tsx#L6-L13)
30 |
31 | ## Fragments
32 |
33 | ### PostSummary_Post
34 |
35 | ```graphql
36 | fragment PostSummary_Post on Post {
37 | id
38 | title
39 | author {
40 | name
41 | ...UserAvatar_User
42 | }
43 | }
44 |
45 | fragment UserAvatar_User on User {
46 | name
47 | avatarURL
48 | }
49 | ```
50 |
51 | From [src/PostSummary.tsx:5:27](src/PostSummary.tsx#L5-L14)
52 |
53 |
54 | ### UserAvatar_User
55 |
56 | ```graphql
57 | fragment UserAvatar_User on User {
58 | name
59 | avatarURL
60 | }
61 | ```
62 |
63 | From [src/UserAvatar.tsx:3:27](src/UserAvatar.tsx#L3-L8)
64 |
65 | ---
66 | Extracted by [ts-graphql-plugin](https://github.com/Quramy/ts-graphql-plugin)
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: build
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches:
7 | - main
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | strategy:
14 | matrix:
15 | node-version: [18.x]
16 |
17 | steps:
18 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
19 | - name: Use Node.js ${{ matrix.node-version }}
20 | uses: actions/setup-node@v6
21 | with:
22 | node-version: ${{ matrix.node-version }}
23 | cache: npm
24 | - name: Install dependencies
25 | run: |
26 | npm ci
27 | - name: Build
28 | run: |
29 | npm run build
30 | git diff --name-only --exit-code
31 | - name: Test
32 | run: |
33 | npm link
34 | npm link ts-graphql-plugin
35 | npm test
36 | env:
37 | CI: true
38 | - uses: codecov/codecov-action@v5
39 | with:
40 | name: jest
41 | token: ${{ secrets.CODECOV_TOKEN }}
42 | files: ./coverage/coverage-final.json
43 | - uses: codecov/codecov-action@v5
44 | with:
45 | name: e2e
46 | token: ${{ secrets.CODECOV_TOKEN }}
47 | files: ./e2e_coverage/coverage-final.json
48 |
--------------------------------------------------------------------------------
/src/cache/lru-cache.test.ts:
--------------------------------------------------------------------------------
1 | import { LRUCache } from './lru-cache';
2 |
3 | describe(LRUCache, () => {
4 | test('should return cached value', () => {
5 | const cache = new LRUCache(1);
6 |
7 | cache.set('a', 'a');
8 |
9 | expect(cache.get('a')).toBe('a');
10 | });
11 |
12 | test('should release entry via delete', () => {
13 | const cache = new LRUCache(1);
14 |
15 | cache.set('a', 'a');
16 | cache.delete('a');
17 |
18 | expect(cache.get('a')).toBe(undefined);
19 | });
20 |
21 | it('should store entries whose size is specified length via maxLength', () => {
22 | const cache = new LRUCache(2);
23 |
24 | cache.set('a', 'a');
25 | cache.set('b', 'b');
26 | cache.set('c', 'c');
27 |
28 | expect(cache.has('a')).toBeFalsy();
29 | expect(cache.has('b')).toBeTruthy();
30 | expect(cache.has('c')).toBeTruthy();
31 | });
32 |
33 | it('should hold entries last recently used', () => {
34 | const cache = new LRUCache(2);
35 |
36 | cache.set('a', 'a');
37 | cache.set('b', 'b');
38 | cache.get('a');
39 | cache.set('c', 'c');
40 |
41 | expect(cache.has('a')).toBeTruthy();
42 | expect(cache.has('b')).toBeFalsy();
43 | expect(cache.has('c')).toBeTruthy();
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/src/string-util/glob-to-regexp.ts:
--------------------------------------------------------------------------------
1 | export function globToRegExp(pattern: string) {
2 | let reStr = '';
3 | for (let i = 0; i < pattern.length; i++) {
4 | const char = pattern[i];
5 |
6 | switch (char) {
7 | case '/':
8 | case '$':
9 | case '^':
10 | case '+':
11 | case '.':
12 | case '(':
13 | case ')':
14 | case '=':
15 | case '!':
16 | case '|':
17 | case '[':
18 | case ']':
19 | case '{':
20 | case '}':
21 | reStr += '\\' + char;
22 | break;
23 |
24 | case '?':
25 | reStr += '.';
26 | break;
27 |
28 | case '*': {
29 | const prevChar = pattern[i - 1];
30 | let starCount = 1;
31 | while (pattern[i + 1] === '*') {
32 | starCount++;
33 | i++;
34 | }
35 | const nextChar = pattern[i + 1];
36 | const isGlobstar =
37 | starCount > 1 && (prevChar === '/' || prevChar == null) && (nextChar === '/' || nextChar == null);
38 |
39 | if (isGlobstar) {
40 | reStr += '((?:[^/]*(?:/|$))*)';
41 | i++;
42 | } else {
43 | reStr += '([^/]*)';
44 | }
45 | break;
46 | }
47 |
48 | default:
49 | reStr += char;
50 | }
51 | }
52 |
53 | reStr = '^' + reStr + '$';
54 | return new RegExp(reStr);
55 | }
56 |
--------------------------------------------------------------------------------
/e2e/lang-server-specs/diagnostics-complex-template.js:
--------------------------------------------------------------------------------
1 | const assert = require('assert');
2 | const path = require('path');
3 |
4 | const { ERROR_CODES } = require('../../lib/errors');
5 |
6 | function findResponse(responses, eventName) {
7 | return responses.find(response => response.event === eventName);
8 | }
9 |
10 | const fileContent = `
11 | import gql from 'graphql-tag';
12 | const fn = (msg: string) => msg;
13 | const f = gql\`
14 | fragment MyFragment on Query {
15 | hello
16 | }
17 | \`;
18 | const q = gql\`
19 | \${fn(f)}
20 | query {
21 | ...MyFragment
22 | }
23 | \`;
24 | `;
25 |
26 | async function run(server) {
27 | const file = path.resolve(__dirname, '../../project-fixtures/simple-prj/main.ts');
28 | server.send({ command: 'open', arguments: { file, fileContent, scriptKindName: 'TS' } });
29 | await server.waitEvent('projectLoadingFinish');
30 | server.send({ command: 'geterr', arguments: { files: [file], delay: 0 } });
31 | await server.waitEvent('semanticDiag');
32 | return server.close().then(() => {
33 | const semanticDiagEvent = findResponse(server.responses, 'semanticDiag');
34 | assert(!!semanticDiagEvent);
35 | assert.equal(semanticDiagEvent.body.diagnostics.length, 1);
36 | assert.equal(semanticDiagEvent.body.diagnostics[0].text, ERROR_CODES.templateIsTooComplex.message);
37 | });
38 | }
39 |
40 | module.exports = run;
41 |
--------------------------------------------------------------------------------
/src/errors/__snapshots__/error-reporter.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`ErrorReporter outputError ErrorWithLocation should output location of errors in human readable format 1`] = `
4 | "main.ts:2:27 - some error
5 |
6 | 1
7 | 2 const query = invalidQuery;
8 | ~~~~~~~~~~~~
9 | "
10 | `;
11 |
12 | exports[`ErrorReporter outputError ErrorWithLocation should output location of errors in human readable format with 2 lines 1`] = `
13 | "main.ts:3:21 - some error
14 |
15 | 2 const query = gql\`;
16 | 3 query MyQuery {
17 | ~~~~~~~~~
18 | 4 name
19 | ~~~~~~~~~~~~~~~~~~~~
20 | 5 }
21 | "
22 | `;
23 |
24 | exports[`ErrorReporter outputError ErrorWithLocation should output location of errors in human readable format with 3 or more lines 1`] = `
25 | "main.ts:3:21 - some error
26 |
27 | 2 const query = gql\`;
28 | 3 query MyQuery {
29 | ~~~~~~~~~
30 | 4 id
31 | ~~~~~~~~~~~~~~~~~~
32 | 5 name
33 | ~~~~~~~~~~~~~~~~~~~~
34 | 6 }
35 | "
36 | `;
37 |
38 | exports[`ErrorReporter outputError ErrorWithoutLocation should output error message 1`] = `"error: hoge"`;
39 |
40 | exports[`ErrorReporter outputError ErrorWithoutLocation should output warn message 1`] = `"warn: hoge"`;
41 |
--------------------------------------------------------------------------------
/src/ts-ast-util/testing/testing-language-service.ts:
--------------------------------------------------------------------------------
1 | import ts from 'typescript';
2 | import path from 'path';
3 |
4 | import { ScriptHost } from '../script-host';
5 |
6 | type Options = {
7 | files?: {
8 | fileName: string;
9 | content: string;
10 | }[];
11 | };
12 |
13 | export class TestingLanguageServiceHost extends ScriptHost implements ts.LanguageServiceHost {
14 | constructor(options: Options) {
15 | super(path.resolve(__dirname, '../../'), ts.getDefaultCompilerOptions());
16 | (options.files || []).forEach(({ fileName, content }) => this._updateFile(fileName, content));
17 | }
18 |
19 | getFile(fileName: string) {
20 | const content = this.readFile(fileName);
21 | if (!content) return undefined;
22 | return { fileName, content };
23 | }
24 |
25 | loadFromFileSystem(_fileName: string) {
26 | return undefined;
27 | }
28 |
29 | fileExists(_path: string) {
30 | return true;
31 | }
32 |
33 | updateFile(fileName: string, content: string) {
34 | this._updateFile(fileName, content);
35 | }
36 | }
37 |
38 | export function createTestingLanguageServiceAndHost(options: Options) {
39 | const host = new TestingLanguageServiceHost(options);
40 | return {
41 | languageService: ts.createLanguageService(host),
42 | languageServiceHost: host,
43 | };
44 | }
45 |
46 | export function createTestingLanguageService(options: Options) {
47 | return createTestingLanguageServiceAndHost(options).languageService;
48 | }
49 |
--------------------------------------------------------------------------------
/src/gql-ast-util/utility-functions.test.ts:
--------------------------------------------------------------------------------
1 | import { parse } from 'graphql';
2 | import { detectDuplicatedFragments } from './utility-functions';
3 |
4 | describe(detectDuplicatedFragments, () => {
5 | it('should detect duplicated fragments info', () => {
6 | const documentContent = `
7 | fragment Hoge on Query {
8 | id
9 | }
10 | fragment Foo on Query {
11 | id
12 | }
13 | fragment Hoge on Query {
14 | id
15 | }
16 | `;
17 | expect(detectDuplicatedFragments(parse(documentContent))).toMatchSnapshot();
18 | });
19 |
20 | it('should return empty array when no duplication', () => {
21 | const documentContent = `
22 | fragment Hoge on Query {
23 | id
24 | }
25 | fragment Foo on Query {
26 | id
27 | }
28 | `;
29 | expect(detectDuplicatedFragments(parse(documentContent))).toStrictEqual([]);
30 | });
31 |
32 | it('should return duplicated fragments order by location range desc', () => {
33 | const documentContent = `
34 | fragment Hoge on Query {
35 | id
36 | }
37 | fragment Bar on Query {
38 | id
39 | }
40 | fragment Foo on Query {
41 | id
42 | }
43 | fragment Hoge on Query {
44 | id
45 | }
46 | fragment Bar on Query {
47 | id
48 | }
49 | `;
50 | const actual = detectDuplicatedFragments(parse(documentContent));
51 | expect(actual[0].start >= actual[1].end).toBeTruthy();
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/e2e/webpack-specs/watch.js:
--------------------------------------------------------------------------------
1 | const assert = require('assert');
2 | const fs = require('fs');
3 | const path = require('path');
4 | const { execSync } = require('child_process');
5 | const webpack = require('webpack');
6 | const { print } = require('graphql/language');
7 |
8 | async function run() {
9 | const config = require('../../project-fixtures/transformation-prj/webpack.config.js');
10 | const fileToChange = path.resolve(__dirname, '../../project-fixtures/transformation-prj/fragment-node.ts');
11 | const originalContent = fs.readFileSync(fileToChange, 'utf8');
12 | const compiler = webpack({ ...config, mode: 'production' });
13 | let watching;
14 | let called = 0;
15 | const stats = await new Promise((res, rej) => {
16 | watching = compiler.watch(
17 | {
18 | aggregateTimeout: 300,
19 | poll: undefined,
20 | },
21 | (err, stats) => {
22 | if (err) return rej(err);
23 | if (!called) {
24 | called++;
25 | fs.writeFileSync(fileToChange, originalContent.replace('bye', 'goodBye'), 'utf8');
26 | } else {
27 | res(stats);
28 | }
29 | },
30 | );
31 | });
32 | watching.close();
33 | fs.writeFileSync(fileToChange, originalContent, 'utf8');
34 | assert(!stats.hasErrors());
35 | const distFilePath = path.resolve(stats.toJson().outputPath, 'main.js');
36 | const result = execSync(`node ${distFilePath}`);
37 | assert(print(JSON.parse(result.toString())).indexOf('goodBye') !== -1);
38 | }
39 |
40 | module.exports = run;
41 |
--------------------------------------------------------------------------------
/src/graphql-language-service-adapter/get-quick-info-at-position.ts:
--------------------------------------------------------------------------------
1 | import { getHoverInformation } from 'graphql-language-service';
2 |
3 | import ts from '../tsmodule';
4 | import type { AnalysisContext, GetQuickInfoAtPosition } from './types';
5 | import { SimplePosition } from './simple-position';
6 |
7 | export function getQuickInfoAtPosition(
8 | ctx: AnalysisContext,
9 | delegate: GetQuickInfoAtPosition,
10 | fileName: string,
11 | position: number,
12 | ) {
13 | if (ctx.getScriptSourceHelper().isExcluded(fileName)) return delegate(fileName, position);
14 | const schema = ctx.getSchema();
15 | if (!schema) return delegate(fileName, position);
16 | const node = ctx.findAscendantTemplateNode(fileName, position);
17 | if (!node) return delegate(fileName, position);
18 | const { resolvedInfo } = ctx.resolveTemplateInfo(fileName, node);
19 | if (!resolvedInfo) return delegate(fileName, position);
20 | const { combinedText, getInnerPosition, convertInnerPosition2InnerLocation } = resolvedInfo;
21 | const cursor = new SimplePosition(convertInnerPosition2InnerLocation(getInnerPosition(position).pos + 1));
22 | const result = getHoverInformation(schema, combinedText, cursor);
23 | if (typeof result !== 'string' || !result.length) return delegate(fileName, position);
24 | return {
25 | kind: ts.ScriptElementKind.string,
26 | textSpan: {
27 | start: position,
28 | length: 1,
29 | },
30 | kindModifiers: '',
31 | displayParts: [{ text: result, kind: '' }],
32 | } as ts.QuickInfo;
33 | }
34 |
--------------------------------------------------------------------------------
/src/ts-ast-util/__snapshots__/output-source.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`createOutputSource pushDefaultImportIfNeeded should add import specifier if named import statement for same module already exists 1`] = `
4 | "import Hoge, { Foo } from "./foo";
5 | "
6 | `;
7 |
8 | exports[`createOutputSource pushDefaultImportIfNeeded should not add import statement when the same statement exists 1`] = `
9 | "import Hoge from "./foo";
10 | "
11 | `;
12 |
13 | exports[`createOutputSource pushImportDeclaration should add statement at first when the helpper has no import statement 1`] = `
14 | "import "typescript";
15 | hoge;
16 | "
17 | `;
18 |
19 | exports[`createOutputSource pushImportDeclaration should add statement at next the last import declaration 1`] = `
20 | "import "graphql";
21 | import "typescript";
22 | hoge;
23 | "
24 | `;
25 |
26 | exports[`createOutputSource pushNamedImportIfNeeded should add import specifier if named import statement for same module already exists 1`] = `
27 | "import { Foo, Hoge } from "./foo";
28 | "
29 | `;
30 |
31 | exports[`createOutputSource pushNamedImportIfNeeded should not add import statement when the same statement exists 1`] = `
32 | "import { Hoge } from "./foo";
33 | "
34 | `;
35 |
36 | exports[`createOutputSource writeLeadingComment should comment at the top of file 1`] = `
37 | "/* foo */
38 | /* bar */
39 | hoge;
40 | "
41 | `;
42 |
43 | exports[`createOutputSource writeLeadingComment should comment when outputSource has no statement 1`] = `
44 | "/* foo */
45 | hoge;
46 | "
47 | `;
48 |
--------------------------------------------------------------------------------
/src/string-util/position-converter.ts:
--------------------------------------------------------------------------------
1 | export class OutOfRangeError extends Error {
2 | constructor() {
3 | super('Out of range');
4 | }
5 | }
6 |
7 | export function pos2location(content: string, pos: number, throwErrorIfOutOfRange = false) {
8 | if (throwErrorIfOutOfRange) {
9 | if (pos < 0 || content.length <= pos) {
10 | throw new OutOfRangeError();
11 | }
12 | }
13 | let l = 0,
14 | c = 0;
15 | for (let i = 0; i < content.length && i < pos; i++) {
16 | const cc = content[i];
17 | if (cc === '\n') {
18 | c = 0;
19 | l++;
20 | } else {
21 | c++;
22 | }
23 | }
24 | return { line: l, character: c };
25 | }
26 |
27 | export function location2pos(
28 | content: string,
29 | location: { line: number; character: number },
30 | throwErrorIfOutOfRange = false,
31 | ) {
32 | let il = 0,
33 | ic = 0;
34 | if (throwErrorIfOutOfRange) {
35 | if (location.line < 0 || location.character < 0) {
36 | throw new OutOfRangeError();
37 | }
38 | }
39 | for (let i = 0; i < content.length; i++) {
40 | const cc = content[i];
41 | if (il === location.line) {
42 | if (throwErrorIfOutOfRange && (cc === '\n' || (cc === '\r' && content[i + 1] === '\n'))) {
43 | throw new OutOfRangeError();
44 | }
45 | if (ic === location.character) {
46 | return i;
47 | }
48 | }
49 | if (cc === '\n') {
50 | ic = 0;
51 | il++;
52 | } else {
53 | ic++;
54 | }
55 | }
56 | if (throwErrorIfOutOfRange) {
57 | throw new OutOfRangeError();
58 | }
59 | return content.length;
60 | }
61 |
--------------------------------------------------------------------------------
/src/errors/index.ts:
--------------------------------------------------------------------------------
1 | export { ErrorReporter } from './error-reporter';
2 |
3 | export type ErrorRange = {
4 | fileName: string;
5 | start: number;
6 | end: number;
7 | };
8 |
9 | export type Severity = 'Error' | 'Warn';
10 |
11 | export type ErrorContent = ErrorRange & {
12 | severity?: Severity;
13 | content: string;
14 | };
15 |
16 | export class ErrorWithLocation extends Error {
17 | readonly name = 'ErrorWithLocation';
18 | readonly severity: Severity = 'Error';
19 |
20 | constructor(
21 | public readonly message: string,
22 | public readonly errorContent: ErrorContent,
23 | ) {
24 | super(message);
25 | if (errorContent.severity) {
26 | this.severity = errorContent.severity;
27 | }
28 | }
29 | }
30 |
31 | export class ErrorWithoutLocation extends Error {
32 | readonly name = 'ErrorWithoutLocation';
33 | constructor(
34 | public readonly message: string,
35 | public readonly severity: Severity = 'Error',
36 | ) {
37 | super(message);
38 | }
39 | }
40 |
41 | export type TsGqlError = ErrorWithLocation | ErrorWithoutLocation;
42 |
43 | export const ERROR_CODES = {
44 | graphqlLangServiceError: {
45 | code: 51001,
46 | },
47 | duplicatedFragmentDefinitions: {
48 | code: 51002,
49 | message: 'All fragments must have an unique name.',
50 | },
51 | templateIsTooComplex: {
52 | code: 51010,
53 | message: 'This operation or fragment has too complex interpolation to analyze.',
54 | },
55 | errorInOtherInterpolation: {
56 | code: 51011,
57 | message: 'This expression has some GraphQL errors.',
58 | },
59 | schemaBuildError: {
60 | code: 51020,
61 | },
62 | };
63 |
--------------------------------------------------------------------------------
/src/analyzer/__snapshots__/markdown-reporter.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`MarkdownReporter should convert from manifest to markdown content 1`] = `
4 | "# Extracted GraphQL Operations
5 | ## Queries
6 |
7 | ### MyQuery
8 |
9 | \`\`\`graphql
10 | fragment MyFragment on Query {
11 | hello
12 | }
13 |
14 | query MyQuery {
15 | ...MyFragment
16 | }
17 | \`\`\`
18 |
19 | From [src/main.ts:8:27](../src/main.ts#L8-L13)
20 |
21 | ## Mutations
22 |
23 | ### Greeting
24 |
25 | \`\`\`graphql
26 | mutation Greeting {
27 | greeting {
28 | reply
29 | }
30 | }
31 | \`\`\`
32 |
33 | From [src/main.ts:14:30](../src/main.ts#L14-L20)
34 |
35 | ---
36 | Extracted by [ts-graphql-plugin](https://github.com/Quramy/ts-graphql-plugin)"
37 | `;
38 |
39 | exports[`MarkdownReporter should convert from manifest to markdown content with ignoreFragments: false 1`] = `
40 | "# Extracted GraphQL Operations
41 | ## Queries
42 |
43 | ### MyQuery
44 |
45 | \`\`\`graphql
46 | fragment MyFragment on Query {
47 | hello
48 | }
49 |
50 | query MyQuery {
51 | ...MyFragment
52 | }
53 | \`\`\`
54 |
55 | From [src/main.ts:8:27](../src/main.ts#L8-L13)
56 |
57 | ## Mutations
58 |
59 | ### Greeting
60 |
61 | \`\`\`graphql
62 | mutation Greeting {
63 | greeting {
64 | reply
65 | }
66 | }
67 | \`\`\`
68 |
69 | From [src/main.ts:14:30](../src/main.ts#L14-L20)
70 |
71 | ## Fragments
72 |
73 | ### MyFragment
74 |
75 | \`\`\`graphql
76 | fragment MyFragment on Query {
77 | hello
78 | }
79 | \`\`\`
80 |
81 | From [src/main.ts:3:30](../src/main.ts#L3-L7)
82 |
83 | ---
84 | Extracted by [ts-graphql-plugin](https://github.com/Quramy/ts-graphql-plugin)"
85 | `;
86 |
--------------------------------------------------------------------------------
/src/typegen-addons/testing/addon-tester.ts:
--------------------------------------------------------------------------------
1 | import { buildSchema } from 'graphql';
2 |
3 | import { TypeGenAddonFactory } from '../../typegen';
4 | import { createTesintExtractor } from '../../analyzer/testing/testing-extractor';
5 | import { TypeGenerator } from '../../analyzer/type-generator';
6 | import { parseTagConfig, type TagConfig } from '../../ts-ast-util';
7 |
8 | function createTestingTypeGenerator({
9 | files = [],
10 | tag = '',
11 | addonFactories = [],
12 | }: {
13 | files?: { fileName: string; content: string }[];
14 | tag?: TagConfig;
15 | addonFactories?: TypeGenAddonFactory[];
16 | }) {
17 | const extractor = createTesintExtractor(files, true);
18 | const generator = new TypeGenerator({
19 | prjRootPath: '',
20 | tag: parseTagConfig(tag),
21 | addonFactories,
22 | extractor,
23 | debug: () => {},
24 | });
25 | return generator;
26 | }
27 |
28 | type InputFile = { fileName: string; content: string };
29 |
30 | class AddonTester {
31 | constructor(
32 | private readonly facory: TypeGenAddonFactory,
33 | private readonly options: { tag?: string },
34 | ) {}
35 |
36 | generateTypes({ files, schemaSDL }: { files: InputFile[]; schemaSDL: string }) {
37 | const schema = buildSchema(schemaSDL);
38 | const generator = createTestingTypeGenerator({ files, addonFactories: [this.facory], tag: this.options.tag });
39 | const { errors, outputSourceFiles } = generator.generateTypes({ files: files.map(f => f.fileName), schema });
40 | return { errors, outputSourceFiles };
41 | }
42 | }
43 |
44 | export function createAddonTester(factory: TypeGenAddonFactory, options: { tag?: string } = {}) {
45 | return new AddonTester(factory, options);
46 | }
47 |
--------------------------------------------------------------------------------
/src/graphql-language-service-adapter/get-quick-info-at-position.test.ts:
--------------------------------------------------------------------------------
1 | import ts from 'typescript';
2 | import { GraphQLSchema } from 'graphql';
3 | import extract from 'fretted-strings';
4 | import { createSimpleSchema } from './testing/simple-schema';
5 | import { AdapterFixture } from './testing/adapter-fixture';
6 |
7 | function delegateFn(): ts.QuickInfo {
8 | return {
9 | kind: ts.ScriptElementKind.string,
10 | kindModifiers: '',
11 | textSpan: {
12 | start: 0,
13 | length: 0,
14 | },
15 | displayParts: [],
16 | };
17 | }
18 |
19 | function createFixture(name: string, schema?: GraphQLSchema) {
20 | return new AdapterFixture(name, schema);
21 | }
22 |
23 | describe('getQuickInfoAtPosition', () => {
24 | it('should return GraphQL quick info', () => {
25 | const fixture = createFixture('main.ts', createSimpleSchema());
26 | const quickInfoFn = fixture.adapter.getQuickInfoAtPosition.bind(fixture.adapter, delegateFn, 'main.ts');
27 | const [content, frets] = extract(
28 | `
29 | const query = \`
30 | query {
31 | hello
32 | %%% ^ ^ %%%
33 | %%% a1 a2 %%%
34 | }
35 | \`;
36 | `,
37 | );
38 | fixture.source = content;
39 | expect(quickInfoFn(frets.a1.pos - 1)!.displayParts).toEqual([]);
40 | expect(
41 | quickInfoFn(frets.a1.pos)!
42 | .displayParts!.map(dp => dp.text)
43 | .join(''),
44 | ).toMatchSnapshot();
45 | expect(
46 | quickInfoFn(frets.a2.pos)!
47 | .displayParts!.map(dp => dp.text)
48 | .join(''),
49 | ).toMatchSnapshot();
50 | expect(quickInfoFn(frets.a2.pos + 1)!.displayParts).toEqual([]);
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/src/webpack/plugin.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import type { Compiler } from 'webpack';
3 | import { TransformerHost, type GetTransformerOptions } from '../transformer';
4 |
5 | type WatchFileSystemCompiler = Compiler & {
6 | watchFileSystem: {
7 | watcher: {
8 | mtimes?: { [name: string]: number };
9 | };
10 | wfs: {
11 | watcher: {
12 | mtimes?: { [name: string]: number };
13 | };
14 | };
15 | };
16 | };
17 |
18 | const PLUGIN_NAME = 'ts-graphql-plugin';
19 |
20 | export class WebpackPlugin {
21 | private readonly _host: TransformerHost;
22 | private _disabled = false;
23 |
24 | constructor({ tsconfigPath = process.cwd() }: { tsconfigPath?: string } = {}) {
25 | this._host = new TransformerHost({ projectPath: tsconfigPath });
26 | }
27 |
28 | getTransformer(options?: GetTransformerOptions) {
29 | return this._host.getTransformer({ ...options, getEnabled: () => !this._disabled });
30 | }
31 |
32 | apply(compiler: WatchFileSystemCompiler) {
33 | compiler.hooks.afterPlugins.tap(PLUGIN_NAME, () => this._host.loadProject());
34 | compiler.hooks.watchRun.tap(PLUGIN_NAME, () => {
35 | this._disabled = compiler.options.mode === 'development';
36 | const watcher = compiler.watchFileSystem.watcher || compiler.watchFileSystem.wfs.watcher;
37 | const changedFiles = compiler.modifiedFiles
38 | ? [...compiler.modifiedFiles.keys()]
39 | : Object.keys(watcher.mtimes ?? []); // webpack v4 does not expose modifiedFiles. So we access to changed files with some hacks.
40 | const changedSourceFileNames = changedFiles.filter(f => path.extname(f) === '.ts' || path.extname(f) === '.tsx');
41 | this._host.updateFiles(changedSourceFileNames);
42 | });
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/schema-manager/testing/testing-schema-manager-host.ts:
--------------------------------------------------------------------------------
1 | import { SchemaManagerHost, SchemaConfig } from '../types';
2 |
3 | export type CreateTestingSchemaManagerHostOptions = SchemaConfig & {
4 | prjRootPath?: string;
5 | files?: {
6 | fileName: string;
7 | content: string;
8 | }[];
9 | log?: (msg: string) => void;
10 | };
11 |
12 | class TestingSchemaManagerHost implements SchemaManagerHost {
13 | private _files: { fileName: string; content: string }[];
14 |
15 | private _watchers: { path: string; cb: (fileName: string) => void }[] = [];
16 |
17 | constructor(private _config: CreateTestingSchemaManagerHostOptions) {
18 | this._files = _config.files || [];
19 | }
20 |
21 | getConfig() {
22 | return this._config;
23 | }
24 |
25 | getProjectRootPath() {
26 | return this._config.prjRootPath || '/';
27 | }
28 |
29 | readFile(path: string) {
30 | const found = this._files.find(f => f.fileName === path);
31 | if (found) return found.content;
32 | }
33 |
34 | fileExists(path: string) {
35 | return !!this._files.find(f => f.fileName === path);
36 | }
37 |
38 | watchFile(path: string, cb: (fileName: string) => void) {
39 | this._watchers.push({ path, cb });
40 | return { close() {} };
41 | }
42 |
43 | log(msg: string) {
44 | if (this._config.log) {
45 | this._config.log(msg);
46 | }
47 | }
48 |
49 | updateFile(path: string, content: string) {
50 | this._files = this._files.map(f => (f.fileName === path ? { ...f, content } : f));
51 | this._watchers.filter(w => w.path === path).forEach(w => w.cb(w.path));
52 | }
53 | }
54 |
55 | export function createTestingSchemaManagerHost(config: CreateTestingSchemaManagerHostOptions) {
56 | return new TestingSchemaManagerHost(config);
57 | }
58 |
--------------------------------------------------------------------------------
/src/graphql-language-service-adapter/get-definition-at-position.test.ts:
--------------------------------------------------------------------------------
1 | import extract from 'fretted-strings';
2 | import { AdapterFixture } from './testing/adapter-fixture';
3 |
4 | function createFixture(name: string) {
5 | return new AdapterFixture(name);
6 | }
7 |
8 | describe('getDefinitionAtPosition', () => {
9 | const delegateFn = jest.fn(() => []);
10 |
11 | it('should not return definition info when the cursor does not point fragment spread', () => {
12 | const fixture = createFixture('input.ts');
13 | const [content, frets] = extract(
14 | `
15 | const query = \`
16 | query MyQuery {
17 | %%% ^ %%%
18 | %%% cur %%%
19 | ...MyFragment
20 | }
21 |
22 | fragment MyFragment on Query {
23 | __typename
24 | }
25 | \`;
26 | `,
27 | );
28 | fixture.source = content;
29 | const actual = fixture.adapter.getDefinitionAtPosition(delegateFn, 'input.ts', frets.cur.pos);
30 | expect(actual?.length).toBe(0);
31 | });
32 |
33 | it('should return definition of fragment spread under cursor', () => {
34 | const fixture = createFixture('input.ts');
35 | const [content, frets] = extract(
36 | `
37 | const query = \`
38 | query MyQuery {
39 | ...MyFragment
40 | %%% ^ %%%
41 | %%% cur %%%
42 | }
43 |
44 | fragment MyFragment on Query {
45 | __typename
46 | }
47 | \`;
48 | `,
49 | );
50 | fixture.source = content;
51 | const actual = fixture.adapter.getDefinitionAtPosition(delegateFn, 'input.ts', frets.cur.pos);
52 | expect(actual?.length).toBe(1);
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/project-fixtures/typegen-addon-prj/addon.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { template } from 'talt';
3 | import { TypeGenAddonFactory, TypeGenVisitorAddon } from '../../lib';
4 |
5 | // `addonFactory` function is called for each output ts file
6 | // and should return an object which implements `TypeGenVisitorAddon` interface.
7 | const addonFactory: TypeGenAddonFactory = typegenContext => {
8 | const { source, extractedInfo } = typegenContext;
9 |
10 | const typesModuleRelativePath = path.relative(source.outputDirName, path.join(__dirname, 'types'));
11 |
12 | const addon: TypeGenVisitorAddon = {
13 | // `document` callback reacts GraphQL Document (Root) AST node.
14 | document() {
15 | source.writeLeadingComment(
16 | `The following types are extracted from ${path.relative(__dirname, extractedInfo.fileName)}`,
17 | );
18 | },
19 |
20 | // `customScalar` is called back when processing GraphQL Scalar field.
21 | // And it can return corresponding TypeScript TypeNode such as:
22 | // `ts.createKeywordTypeNode(ts.SyntaxKind.StringKeyword)`, `ts.createTypeReferenceNode('SomeType')`
23 | customScalar({ scalarType }) {
24 | switch (scalarType.name) {
25 | case 'URL': {
26 | // Write `import { GqlURL } from '../../types';
27 | source.pushNamedImportIfNeeded('GqlURL', typesModuleRelativePath);
28 |
29 | // Set this field as TypeScript `GqlURL` type
30 | return template.typeNode`GqlURL`();
31 | }
32 | case 'Date': {
33 | return template.typeNode`Date`();
34 | }
35 | default:
36 | // If return undefined, this scalar field type is determined by the core type generator.
37 | return;
38 | }
39 | },
40 | };
41 |
42 | return addon;
43 | };
44 |
45 | module.exports = addonFactory;
46 |
--------------------------------------------------------------------------------
/src/ts-ast-util/file-name-filter.test.ts:
--------------------------------------------------------------------------------
1 | import { createFileNameFilter } from './file-name-filter';
2 |
3 | describe(createFileNameFilter, () => {
4 | it('should return macher function for dirname', () => {
5 | const match = createFileNameFilter({
6 | specs: ['__generated__'],
7 | projectName: '/a/b/tsconfig.json',
8 | });
9 | expect(match('/a/b/__generated__/x.ts')).toBeTruthy();
10 | expect(match('/a/b/x.ts')).toBeFalsy();
11 | });
12 |
13 | it('should return macher function for dirname with trailing slash', () => {
14 | const match = createFileNameFilter({
15 | specs: ['__generated__/'],
16 | projectName: '/a/b/tsconfig.json',
17 | });
18 | expect(match('/a/b/__generated__/x.ts')).toBeTruthy();
19 | expect(match('/a/b/x.ts')).toBeFalsy();
20 | });
21 |
22 | it('should return macher function for filename', () => {
23 | const match = createFileNameFilter({
24 | specs: ['__generated__/x.ts'],
25 | projectName: '/a/b/tsconfig.json',
26 | });
27 | expect(match('/a/b/__generated__/x.ts')).toBeTruthy();
28 | expect(match('/a/b/x.ts')).toBeFalsy();
29 | });
30 |
31 | it('should return macher function for wildcard', () => {
32 | const match = createFileNameFilter({
33 | specs: ['**/__generated__/**/*'],
34 | projectName: '/a/b/tsconfig.json',
35 | });
36 | expect(match('/a/b/__generated__/x.ts')).toBeTruthy();
37 | expect(match('/a/b/c/__generated__/x.ts')).toBeTruthy();
38 | expect(match('/a/b/x.ts')).toBeFalsy();
39 | });
40 |
41 | it('should work for win32', () => {
42 | const match = createFileNameFilter({
43 | specs: ['__generated__'],
44 | projectName: '\\a\\b\\tsconfig.json',
45 | _forceWin32: true,
46 | });
47 | expect(match('\\a\\b\\__generated__\\x.ts')).toBeTruthy();
48 | expect(match('\\a\\b\\x.ts')).toBeFalsy();
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/src/ts-ast-util/script-host.ts:
--------------------------------------------------------------------------------
1 | import ts from '../tsmodule';
2 |
3 | export class ScriptHost implements ts.LanguageServiceHost {
4 | private readonly _fileMap = new Map();
5 | private readonly _fileVersionMap = new Map();
6 |
7 | constructor(
8 | private readonly _currentDirectory: string,
9 | private readonly _compilerOptions: ts.CompilerOptions,
10 | ) {}
11 |
12 | readFile(fileName: string) {
13 | const hit = this._fileMap.get(fileName);
14 | if (hit != null) return hit;
15 | return this.loadFromFileSystem(fileName);
16 | }
17 |
18 | loadFromFileSystem(fileName: string) {
19 | const content = ts.sys.readFile(fileName, 'uts8');
20 | this._updateFile(fileName, content);
21 | return content;
22 | }
23 |
24 | getCurrentDirectory() {
25 | return this._currentDirectory;
26 | }
27 |
28 | getScriptSnapshot(fileName: string) {
29 | const file = this._fileMap.get(fileName);
30 | if (file == null) return;
31 | return ts.ScriptSnapshot.fromString(file);
32 | }
33 |
34 | getScriptVersion(fileName: string) {
35 | const version = this._fileVersionMap.get(fileName);
36 | if (!version) return '0';
37 | return version + '';
38 | }
39 |
40 | getScriptFileNames() {
41 | return [...this._fileMap.keys()];
42 | }
43 |
44 | getCompilationSettings() {
45 | return this._compilerOptions;
46 | }
47 |
48 | getDefaultLibFileName(opt: ts.CompilerOptions) {
49 | return ts.getDefaultLibFileName(opt);
50 | }
51 |
52 | fileExists(path: string) {
53 | return ts.sys.fileExists(path);
54 | }
55 |
56 | protected _updateFile(fileName: string, content: string | undefined) {
57 | this._fileMap.set(fileName, content);
58 | const currentVersion = this._fileVersionMap.get(fileName) || 0;
59 | this._fileVersionMap.set(fileName, currentVersion + 1);
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/graphql-language-service-adapter/types.ts:
--------------------------------------------------------------------------------
1 | import type ts from 'typescript';
2 | import type { GraphQLSchema, DocumentNode, FragmentDefinitionNode } from 'graphql';
3 | import type { ScriptSourceHelper, ResolveResult } from '../ts-ast-util';
4 | import type { SchemaBuildErrorInfo } from '../schema-manager/schema-manager';
5 |
6 | export type GetCompletionAtPosition = ts.LanguageService['getCompletionsAtPosition'];
7 | export type GetSemanticDiagnostics = ts.LanguageService['getSemanticDiagnostics'];
8 | export type GetQuickInfoAtPosition = ts.LanguageService['getQuickInfoAtPosition'];
9 | export type GetDefinitionAndBoundSpan = ts.LanguageService['getDefinitionAndBoundSpan'];
10 | export type GetDefinitionAtPosition = ts.LanguageService['getDefinitionAtPosition'];
11 |
12 | export interface AnalysisContext {
13 | debug(msg: string): void;
14 | getScriptSourceHelper(): ScriptSourceHelper;
15 | getSchema(): GraphQLSchema | null | undefined;
16 | getSchemaOrSchemaErrors(): [GraphQLSchema, null] | [null, SchemaBuildErrorInfo[]];
17 | getGlobalFragmentDefinitions(): FragmentDefinitionNode[];
18 | getGlobalFragmentDefinitionEntry(
19 | name: string,
20 | ): { node: FragmentDefinitionNode; fileName: string; position: number } | undefined;
21 | getExternalFragmentDefinitions(
22 | documentStr: string,
23 | fileName: string,
24 | sourcePosition: number,
25 | ): FragmentDefinitionNode[];
26 | getDuplicaterdFragmentDefinitions(): Set;
27 | getGraphQLDocumentNode(text: string): DocumentNode | undefined;
28 | findAscendantTemplateNode(
29 | fileName: string,
30 | position: number,
31 | ): ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression | undefined;
32 | findTemplateNodes(fileName: string): (ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression)[];
33 | resolveTemplateInfo(fileName: string, node: ts.TemplateExpression | ts.NoSubstitutionTemplateLiteral): ResolveResult;
34 | }
35 |
--------------------------------------------------------------------------------
/e2e/webpack-specs/transform.js:
--------------------------------------------------------------------------------
1 | const assert = require('assert');
2 | const path = require('path');
3 | const { execSync } = require('child_process');
4 | const webpack = require('webpack');
5 | const { print } = require('graphql/language');
6 |
7 | async function specWithoutGlobalFragments() {
8 | const config = require('../../project-fixtures/transformation-prj/webpack.config.js');
9 | const compiler = webpack({ ...config, mode: 'production' });
10 | const stats = await new Promise((res, rej) => {
11 | compiler.run((err, stats) => {
12 | if (err) return rej(err);
13 | return res(stats);
14 | });
15 | });
16 | assert(!stats.hasErrors());
17 | const distFilePath = path.resolve(stats.toJson().outputPath, 'main.js');
18 | const result = execSync(`node ${distFilePath}`);
19 | assert.equal(typeof print(JSON.parse(result.toString())), 'string');
20 | assert(print(JSON.parse(result.toString())).indexOf('MyQuery') !== -1);
21 | assert(print(JSON.parse(result.toString())).indexOf('fragment FragmentLeaf') !== -1);
22 | }
23 |
24 | async function specWithGlobalFragments() {
25 | const config = require('../../project-fixtures/transformation-global-frag-prj/webpack.config.js');
26 | const compiler = webpack({ ...config, mode: 'production' });
27 | const stats = await new Promise((res, rej) => {
28 | compiler.run((err, stats) => {
29 | if (err) return rej(err);
30 | return res(stats);
31 | });
32 | });
33 | assert(!stats.hasErrors());
34 | const distFilePath = path.resolve(stats.toJson().outputPath, 'main.js');
35 | const result = execSync(`node ${distFilePath}`);
36 | assert(print(JSON.parse(result.toString())).indexOf('MyQuery') !== -1);
37 | assert(print(JSON.parse(result.toString())).indexOf('fragment FragmentLeaf') !== -1);
38 | }
39 |
40 | async function run() {
41 | await specWithoutGlobalFragments();
42 | await specWithGlobalFragments();
43 | }
44 |
45 | module.exports = run;
46 |
--------------------------------------------------------------------------------
/src/gql-ast-util/utility-functions.ts:
--------------------------------------------------------------------------------
1 | import type { DocumentNode, FragmentDefinitionNode } from 'graphql';
2 |
3 | export function getFragmentsInDocument(...documentNodes: (DocumentNode | undefined)[]) {
4 | const fragmentDefs = new Map();
5 | for (const documentNode of documentNodes) {
6 | if (!documentNode) return [];
7 | for (const def of documentNode.definitions) {
8 | if (def.kind === 'FragmentDefinition') {
9 | fragmentDefs.set(def.name.value, def);
10 | }
11 | }
12 | }
13 | return [...fragmentDefs.values()];
14 | }
15 |
16 | export function getFragmentNamesInDocument(...documentNodes: (DocumentNode | undefined)[]) {
17 | const nameSet = new Set();
18 | for (const documentNode of documentNodes) {
19 | if (!documentNode) return [];
20 | for (const def of documentNode.definitions) {
21 | if (def.kind === 'FragmentDefinition') {
22 | nameSet.add(def.name.value);
23 | }
24 | }
25 | }
26 | return [...nameSet];
27 | }
28 |
29 | export function cloneFragmentMap(from: Map, namesToBeExcluded: string[] = []) {
30 | const map = new Map(from);
31 | for (const name in namesToBeExcluded) {
32 | map.delete(name);
33 | }
34 | return map;
35 | }
36 |
37 | export function detectDuplicatedFragments(documentNode: DocumentNode) {
38 | const fragments: FragmentDefinitionNode[] = [];
39 | const duplicatedFragments: FragmentDefinitionNode[] = [];
40 | documentNode.definitions.forEach(def => {
41 | if (def.kind === 'FragmentDefinition') {
42 | if (fragments.some(f => f.name.value === def.name.value)) {
43 | duplicatedFragments.push(def);
44 | } else {
45 | fragments.push(def);
46 | }
47 | }
48 | });
49 | return duplicatedFragments
50 | .map(def => {
51 | return {
52 | name: def.name.value,
53 | start: def.loc!.start,
54 | end: def.loc!.end,
55 | };
56 | })
57 | .sort((a, b) => b.start - a.start);
58 | }
59 |
--------------------------------------------------------------------------------
/src/cli/commands/extract.ts:
--------------------------------------------------------------------------------
1 | import type { CommandOptions, CommandCliSetting } from '../parser';
2 | import { ConsoleLogger } from '../logger';
3 |
4 | export const cliDefinition = {
5 | description: 'Extract GraphQL documents from TypeScript sources.',
6 | options: {
7 | project: {
8 | alias: 'p',
9 | description:
10 | "Analyze the project given the path to its configuration file, or to a folder with a 'tsconfig.json'.",
11 | defaultValue: '.',
12 | type: 'string',
13 | },
14 | outFile: {
15 | alias: 'o',
16 | description: 'Output file name of manifest.',
17 | defaultValue: 'manifest.json',
18 | type: 'string',
19 | },
20 | verbose: {
21 | description: 'Show debug messages.',
22 | type: 'boolean',
23 | },
24 | },
25 | } as const satisfies CommandCliSetting;
26 |
27 | export async function extractCommand({ options }: CommandOptions) {
28 | const ts = require('typescript') as typeof import('typescript');
29 | const { AnalyzerFactory } = require('../../analyzer') as typeof import('../../analyzer');
30 | const { ErrorReporter } = require('../../errors/error-reporter') as typeof import('../../errors');
31 | const { color } = require('../../string-util') as typeof import('../../string-util');
32 |
33 | const logger = new ConsoleLogger(options.verbose ? 'debug' : 'info');
34 | const errorReporter = new ErrorReporter(process.cwd(), logger.error.bind(logger));
35 |
36 | const { project, outFile } = options;
37 | const analyzer = new AnalyzerFactory().createAnalyzerFromProjectPath(project, logger.debug.bind(logger));
38 | const [errors, manifest] = analyzer.extractToManifest();
39 |
40 | if (errors.length) {
41 | logger.error(color.magenta('Found some errors extracting operations.\n'));
42 | errors.forEach(error => errorReporter.outputError(error));
43 | }
44 | ts.sys.writeFile(outFile, JSON.stringify(manifest, null, 2));
45 | logger.info(`Write manifest file to '${color.green(outFile)}'.`);
46 | return true;
47 | }
48 |
--------------------------------------------------------------------------------
/src/string-util/glob-to-regexp.test.ts:
--------------------------------------------------------------------------------
1 | import { globToRegExp } from './glob-to-regexp';
2 |
3 | describe(globToRegExp, () => {
4 | describe('wildcard character', () => {
5 | test.each([
6 | { pattern: '**/*', fileName: 'index.ts' },
7 | { pattern: '**/*', fileName: 'a/index.ts' },
8 | { pattern: '**/*', fileName: 'a/b/index.ts' },
9 | { pattern: '**/*.test.ts', fileName: 'index.test.ts' },
10 | { pattern: 'a/b/*.ts', fileName: 'a/b/index.ts' },
11 | { pattern: '**/b/*.ts', fileName: 'b/index.ts' },
12 | { pattern: '**/b/*.ts', fileName: 'a/b/index.ts' },
13 | { pattern: '**/b/**/*', fileName: 'a/b/index.ts' },
14 | { pattern: 'index.?ts', fileName: 'index.mts' },
15 | { pattern: 'index.?ts', fileName: 'index.cts' },
16 | ])("'$pattern' matches '$fileName'", ({ pattern, fileName }) => {
17 | expect(globToRegExp(pattern).test(fileName)).toBeTruthy();
18 | });
19 |
20 | test.each([
21 | { pattern: '**/*.test.ts', fileName: 'index.ts' },
22 | { pattern: 'a/b/*.ts', fileName: 'a/index.ts' },
23 | { pattern: '**/b/*.ts', fileName: 'a/bb/index.ts' },
24 | { pattern: 'index.?ts', fileName: 'index.ts' },
25 | ])("'$pattern' does not match '$fileName'", ({ pattern, fileName }) => {
26 | expect(globToRegExp(pattern).test(fileName)).toBeFalsy();
27 | });
28 | });
29 |
30 | describe('escape', () => {
31 | test.each([
32 | { pattern: '../.', fileName: '../.' },
33 | { pattern: '$.ts', fileName: '$.ts' },
34 | { pattern: '^.ts', fileName: '^.ts' },
35 | { pattern: '+.ts', fileName: '+.ts' },
36 | { pattern: '=.ts', fileName: '=.ts' },
37 | { pattern: '!.ts', fileName: '!.ts' },
38 | { pattern: '(a)/b.ts', fileName: '(a)/b.ts' },
39 | { pattern: '[a]/b.ts', fileName: '[a]/b.ts' },
40 | { pattern: '{a}/b.ts', fileName: '{a}/b.ts' },
41 | ])("'$pattern' matches '$fileName'", ({ pattern, fileName }) => {
42 | expect(globToRegExp(pattern).test(fileName)).toBeTruthy();
43 | });
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/src/cli/commands/typegen.ts:
--------------------------------------------------------------------------------
1 | import type { CommandOptions, CommandCliSetting } from '../parser';
2 | import { ConsoleLogger } from '../logger';
3 |
4 | export const cliDefinition = {
5 | description: 'Generate TypeScript types from GraphQL operations or fragments in your .ts source files.',
6 | options: {
7 | project: {
8 | alias: 'p',
9 | description:
10 | "Analyze the project given the path to its configuration file, or to a folder with a 'tsconfig.json'.",
11 | defaultValue: '.',
12 | type: 'string',
13 | },
14 | verbose: {
15 | description: 'Show debug messages.',
16 | type: 'boolean',
17 | },
18 | },
19 | } as const satisfies CommandCliSetting;
20 |
21 | export async function typegenCommand({ options }: CommandOptions) {
22 | const ts = require('typescript') as typeof import('typescript');
23 | const { AnalyzerFactory } = require('../../analyzer') as typeof import('../../analyzer');
24 | const { ErrorReporter } = require('../../errors/error-reporter') as typeof import('../../errors');
25 | const { color } = require('../../string-util') as typeof import('../../string-util');
26 |
27 | const logger = new ConsoleLogger(options.verbose ? 'debug' : 'info');
28 | const { project } = options;
29 | const errorReporter = new ErrorReporter(process.cwd(), logger.error.bind(logger));
30 | const analyzer = new AnalyzerFactory().createAnalyzerFromProjectPath(project, logger.debug.bind(logger));
31 | const { errors, outputSourceFiles } = await analyzer.typegen();
32 | if (errors.length) {
33 | logger.error(`Found ${color.red(errors.length + '')} errors generating type files.\n`);
34 | errors.forEach(error => errorReporter.outputError(error));
35 | }
36 | if (!outputSourceFiles || outputSourceFiles.length === 0) {
37 | logger.error('No type files to generate.');
38 | return false;
39 | }
40 | outputSourceFiles.forEach(source => ts.sys.writeFile(source.fileName, source.content));
41 | logger.info(`Write ${color.green(outputSourceFiles.length + ' type files')}.`);
42 | return true;
43 | }
44 |
--------------------------------------------------------------------------------
/src/schema-manager/http-schema-manager.ts:
--------------------------------------------------------------------------------
1 | import { SchemaManager } from './schema-manager';
2 | import type { SchemaManagerHost } from './types';
3 | import { requestIntrospectionQuery, type RequestSetup } from './request-introspection-query';
4 |
5 | export class HttpSchemaManager extends SchemaManager {
6 | private _schema: any = null;
7 |
8 | constructor(
9 | _host: SchemaManagerHost,
10 | protected _options: RequestSetup | null = null,
11 | ) {
12 | super(_host);
13 | }
14 |
15 | protected async _getOptions(): Promise {
16 | return this._options;
17 | }
18 |
19 | protected _fetchErrorOcurred(): void {}
20 |
21 | getBaseSchema() {
22 | return this._schema;
23 | }
24 |
25 | async waitBaseSchema() {
26 | try {
27 | const options = await this._getOptions();
28 |
29 | if (options === null) {
30 | return null;
31 | }
32 |
33 | return await requestIntrospectionQuery(options);
34 | } catch (error) {
35 | return null;
36 | }
37 | }
38 |
39 | startWatch(interval: number = 1000) {
40 | const makeRequest = async (backoff = interval) => {
41 | let options;
42 |
43 | try {
44 | options = await this._getOptions();
45 | } catch (error) {
46 | setTimeout(makeRequest, backoff * 2.0);
47 | return;
48 | }
49 |
50 | if (options === null) {
51 | this.log(`Options cannot be null`);
52 | setTimeout(makeRequest, backoff * 2.0);
53 | return;
54 | }
55 |
56 | try {
57 | const query = await requestIntrospectionQuery(options);
58 |
59 | this.log(`Fetch schema data from ${options.url}.`);
60 |
61 | if (query) {
62 | this._schema = query;
63 | this.emitChange();
64 | }
65 |
66 | setTimeout(makeRequest, interval);
67 | } catch (reason) {
68 | this.log(`Fail to fetch schema data from ${options.url} via:`);
69 | this.log(`${JSON.stringify(reason, null, 2)}`);
70 |
71 | this._fetchErrorOcurred();
72 | setTimeout(makeRequest, backoff * 2.0);
73 | }
74 | };
75 |
76 | makeRequest();
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/e2e/fixtures/lang-server.js:
--------------------------------------------------------------------------------
1 | const { fork } = require('child_process');
2 | const path = require('path');
3 | const { EventEmitter } = require('events');
4 |
5 | class TSServer {
6 | constructor() {
7 | this._responseEventEmitter = new EventEmitter();
8 | this._responseCommandEmitter = new EventEmitter();
9 | const tsserverPath = require.resolve('typescript/lib/tsserver');
10 | const server = fork(tsserverPath, {
11 | cwd: path.join(__dirname, '../../project-fixtures/simple-prj'),
12 | stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
13 | });
14 | this._exitPromise = new Promise((resolve, reject) => {
15 | server.on('exit', code => resolve(code));
16 | server.on('error', reason => reject(reason));
17 | });
18 | server.stdout.setEncoding('utf-8');
19 | server.stdout.on('data', data => {
20 | const [, , res] = data.split('\n');
21 | const obj = JSON.parse(res);
22 | if (obj.type === 'event') {
23 | this._responseEventEmitter.emit(obj.event, obj);
24 | } else if (obj.type === 'response') {
25 | this._responseCommandEmitter.emit(obj.command, obj);
26 | }
27 | this.responses.push(obj);
28 | });
29 | this._isClosed = false;
30 | this._server = server;
31 | this._seq = 0;
32 | this.responses = [];
33 | }
34 |
35 | send(command) {
36 | const seq = ++this._seq;
37 | const req = JSON.stringify(Object.assign({ seq: seq, type: 'request' }, command)) + '\n';
38 | this._server.stdin.write(req);
39 | }
40 |
41 | close() {
42 | if (!this._isClosed) {
43 | this._isClosed = true;
44 | this._server.stdin.end();
45 | }
46 | return this._exitPromise;
47 | }
48 |
49 | wait(time = 0) {
50 | return new Promise(res => setTimeout(() => res(), time));
51 | }
52 |
53 | waitEvent(eventName) {
54 | return new Promise(res => this._responseEventEmitter.once(eventName, () => res()));
55 | }
56 |
57 | waitResponse(commandName) {
58 | return new Promise(res => this._responseCommandEmitter.once(commandName, () => res()));
59 | }
60 | }
61 |
62 | function createServer() {
63 | return new TSServer();
64 | }
65 |
66 | module.exports = createServer;
67 |
--------------------------------------------------------------------------------
/src/schema-manager/schema-manager-host.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import ts from '../tsmodule';
3 | import type { SchemaManagerHost, SchemaConfig } from './types';
4 | import type { TsGraphQLPluginConfigOptions } from '../types';
5 |
6 | class SystemSchemaManagerHost implements SchemaManagerHost {
7 | constructor(
8 | private readonly _pluginConfig: TsGraphQLPluginConfigOptions,
9 | private readonly _prjRootPath: string,
10 | private readonly _debug: (msg: string) => void,
11 | ) {}
12 |
13 | log(msg: string): void {
14 | return this._debug(msg);
15 | }
16 | watchFile(path: string, cb: (fileName: string) => void, interval: number): { close(): void } {
17 | return ts.sys.watchFile!(path, cb, interval);
18 | }
19 | readFile(path: string, encoding?: string | undefined): string | undefined {
20 | return ts.sys.readFile(path, encoding);
21 | }
22 | fileExists(path: string): boolean {
23 | return ts.sys.fileExists(path);
24 | }
25 | getConfig(): SchemaConfig {
26 | return this._pluginConfig;
27 | }
28 | getProjectRootPath(): string {
29 | return this._prjRootPath;
30 | }
31 | }
32 |
33 | export function createSchemaManagerHostFromTSGqlPluginConfig(
34 | pluginConfig: Omit,
35 | prjRootPath: string,
36 | debug: (msg: string) => void = () => {},
37 | ) {
38 | return new SystemSchemaManagerHost(pluginConfig, prjRootPath, debug);
39 | }
40 |
41 | export function createSchemaManagerHostFromLSPluginInfo(info: ts.server.PluginCreateInfo): SchemaManagerHost {
42 | return {
43 | getConfig() {
44 | return info.config as SchemaConfig;
45 | },
46 | fileExists(path) {
47 | return info.serverHost.fileExists(path);
48 | },
49 | readFile(path, encoding) {
50 | return info.serverHost.readFile(path, encoding);
51 | },
52 | watchFile(path, cb, interval) {
53 | return info.serverHost.watchFile(path, cb, interval);
54 | },
55 | getProjectRootPath() {
56 | return path.dirname(info.project.getProjectName());
57 | },
58 | log(msg) {
59 | info.project.projectService.logger.info(`[ts-graphql-plugin] ${msg}`);
60 | },
61 | };
62 | }
63 |
--------------------------------------------------------------------------------
/e2e/run.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const glob = require('glob');
3 | const createServer = require('./fixtures/lang-server');
4 | const createCLI = require('./fixtures/cli');
5 |
6 | async function runLangServerSpecs() {
7 | const langServerSpecFiles = glob.sync('lang-server-specs/*.js', { cwd: __dirname });
8 | console.log('Start lang server e2e testing.');
9 | let server;
10 | await langServerSpecFiles.reduce(
11 | (queue, file) =>
12 | queue.then(() => require(path.join(__dirname, file))((server = createServer())).then(() => server.close())),
13 | Promise.resolve(null),
14 | );
15 | console.log(`🌟 ${langServerSpecFiles.length} lang server specs were passed.`);
16 | console.log('');
17 | }
18 |
19 | async function runCliSpecs() {
20 | const cliSpecFiles = glob.sync('cli-specs/*.js', { cwd: __dirname });
21 | console.log('Start CLI e2e testing.');
22 | await cliSpecFiles.reduce(
23 | (queue, file) => queue.then(() => require(path.resolve(__dirname, file))(createCLI())),
24 | Promise.resolve(),
25 | );
26 | console.log(`🌟 ${cliSpecFiles.length} CLI specs were passed.`);
27 | console.log('');
28 | }
29 |
30 | async function runWebpackSpecs() {
31 | const webpackSpecFiles = glob.sync('webpack-specs/*.js', { cwd: __dirname });
32 | console.log('Start webpack e2e testing.');
33 | await webpackSpecFiles.reduce(
34 | (queue, file) => queue.then(() => require(path.resolve(__dirname, file))()),
35 | Promise.resolve(),
36 | );
37 | console.log(`🌟 ${webpackSpecFiles.length} webpack specs were passed.`);
38 | console.log('');
39 | }
40 |
41 | const suitesMap = {
42 | 'lang-server': [runLangServerSpecs],
43 | cli: [runCliSpecs],
44 | webpack: [runWebpackSpecs],
45 | all: [runLangServerSpecs, runCliSpecs, runWebpackSpecs],
46 | };
47 |
48 | async function run(suiteName) {
49 | try {
50 | const suites = suitesMap[suiteName] || suitesMap.all;
51 | await suites.reduce((queue, suite) => queue.then(() => suite()), Promise.resolve());
52 | } catch (reason) {
53 | console.log('😢 some specs were failed...');
54 | console.error(reason);
55 | process.exit(1);
56 | }
57 | }
58 |
59 | const suiteName = process.argv.slice(2)[0];
60 |
61 | run(suiteName);
62 |
--------------------------------------------------------------------------------
/src/cli/commands/validate.ts:
--------------------------------------------------------------------------------
1 | import type { CommandOptions, CommandCliSetting } from '../parser';
2 | import { ConsoleLogger } from '../logger';
3 |
4 | export const cliDefinition = {
5 | description: 'Validate GraphQL documents in your TypeScript sources.',
6 | options: {
7 | project: {
8 | alias: 'p',
9 | description:
10 | "Analyze the project given the path to its configuration file, or to a folder with a 'tsconfig.json'.",
11 | defaultValue: '.',
12 | type: 'string',
13 | },
14 | verbose: {
15 | description: 'Show debug messages.',
16 | type: 'boolean',
17 | },
18 | exitOnWarn: {
19 | description: 'Exit with code 0 even when warnings are found.',
20 | type: 'boolean',
21 | },
22 | },
23 | } as const satisfies CommandCliSetting;
24 |
25 | export async function validateCommand({ options }: CommandOptions) {
26 | const { AnalyzerFactory } = require('../../analyzer') as typeof import('../../analyzer');
27 | const { ErrorReporter } = require('../../errors/error-reporter') as typeof import('../../errors');
28 | const { color } = require('../../string-util') as typeof import('../../string-util');
29 |
30 | const logger = new ConsoleLogger(options.verbose ? 'debug' : 'info');
31 | const errorReporter = new ErrorReporter(process.cwd(), logger.error.bind(logger));
32 | const analyzer = new AnalyzerFactory().createAnalyzerFromProjectPath(options.project, logger.debug.bind(logger));
33 | const { errors } = await analyzer.validate();
34 | const errorErrors = errors.filter(e => e.severity === 'Error');
35 | const warnErrors = errors.filter(e => e.severity === 'Warn');
36 | if (errorErrors.length) {
37 | logger.error(`Found ${color.red(errorErrors.length + '')} errors:`);
38 | errorErrors.forEach(errorReporter.outputError.bind(errorReporter));
39 | }
40 | if (warnErrors.length) {
41 | logger.error(`Found ${color.yellow(warnErrors.length + '')} warnings:`);
42 | warnErrors.forEach(errorReporter.outputError.bind(errorReporter));
43 | }
44 | if (errorErrors.length) {
45 | return false;
46 | } else if (warnErrors.length) {
47 | return options.exitOnWarn;
48 | }
49 | logger.info(color.green('No GraphQL validation errors.'));
50 | return true;
51 | }
52 |
--------------------------------------------------------------------------------
/e2e/lang-server-specs/diagnostics-with-update.js:
--------------------------------------------------------------------------------
1 | const assert = require('assert');
2 | const path = require('path');
3 | const { extract } = require('fretted-strings');
4 |
5 | function findResponse(responses, eventName) {
6 | return responses.find(response => response.event === eventName);
7 | }
8 |
9 | async function run(server) {
10 | const fileFragments = path.resolve(__dirname, '../../project-fixtures/simple-prj/fragments.ts');
11 | const fileFragmentsContent = `
12 | import gql from 'graphql-tag';
13 | const f = gql\`fragment MyFragment on Query { hello }\`;
14 | `;
15 |
16 | const fileMain = path.resolve(__dirname, '../../project-fixtures/simple-prj/main.ts');
17 | const [fileMainContent, frets] = extract(
18 | `
19 | import gql from 'graphql-tag';
20 | const q = gql\`query MyQuery { }\`;
21 | %%% \\ ^ %%%
22 | %%% \\ p %%%
23 | `,
24 | );
25 |
26 | server.send({
27 | command: 'open',
28 | arguments: { file: fileFragments, fileContent: fileFragmentsContent, scriptKindName: 'TS' },
29 | });
30 | server.send({ command: 'open', arguments: { file: fileMain, fileContent: fileMainContent, scriptKindName: 'TS' } });
31 |
32 | await server.waitEvent('projectLoadingFinish');
33 |
34 | server.send({
35 | command: 'updateOpen',
36 | arguments: {
37 | changedFiles: [
38 | {
39 | fileName: fileMain,
40 | textChanges: [
41 | {
42 | newText: '...MyFragment',
43 | start: {
44 | line: frets.p.line + 1,
45 | offset: frets.p.character + 1,
46 | },
47 | end: {
48 | line: frets.p.line + 1,
49 | offset: frets.p.character + 1,
50 | },
51 | },
52 | ],
53 | },
54 | ],
55 | },
56 | });
57 | await server.waitResponse('updateOpen');
58 | server.send({ command: 'geterr', arguments: { files: [fileMain], delay: 0 } });
59 | await server.waitEvent('semanticDiag');
60 | return server.close().then(() => {
61 | const semanticDiagEvent = findResponse(server.responses, 'semanticDiag');
62 | assert(!!semanticDiagEvent);
63 | assert.equal(semanticDiagEvent.body.diagnostics.length, 0);
64 | });
65 | }
66 |
67 | module.exports = run;
68 |
--------------------------------------------------------------------------------
/e2e/lang-server-specs/definition.js:
--------------------------------------------------------------------------------
1 | const assert = require('assert');
2 | const path = require('path');
3 | const { extract } = require('fretted-strings');
4 |
5 | function findResponse(responses, commandName) {
6 | return responses.find(response => response.command === commandName);
7 | }
8 |
9 | async function run(server) {
10 | const fileFragments = path.resolve(__dirname, '../../project-fixtures/simple-prj/fragments.ts');
11 | const fileMain = path.resolve(__dirname, '../../project-fixtures/simple-prj/main.ts');
12 | const fileFragmentsContent = `
13 | const fragment = gql\`
14 | fragment MyFragment on Query {
15 | hello
16 | }
17 | \`;
18 | `;
19 | const [fileMainContent, frets] = extract(
20 | `
21 | const query = gql\`
22 | query MyQuery {
23 | ...MyFragment
24 | %%% ^ %%%
25 | %%% p %%%
26 | }
27 | \`;
28 | `,
29 | );
30 | server.send({
31 | command: 'open',
32 | arguments: { file: fileFragments, fileContent: fileFragmentsContent, scriptKindName: 'TS' },
33 | });
34 | server.send({ command: 'open', arguments: { file: fileMain, fileContent: fileMainContent, scriptKindName: 'TS' } });
35 |
36 | await server.waitEvent('projectLoadingFinish');
37 |
38 | server.send({
39 | command: 'definition',
40 | arguments: { file: fileMain, offset: frets.p.character + 1, line: frets.p.line + 1, prefix: '' },
41 | });
42 |
43 | await server.waitResponse('definition');
44 |
45 | server.send({
46 | command: 'definitionAndBoundSpan',
47 | arguments: { file: fileMain, offset: frets.p.character + 1, line: frets.p.line + 1, prefix: '' },
48 | });
49 |
50 | await server.waitResponse('definitionAndBoundSpan');
51 |
52 | await server.close();
53 |
54 | const definitionResponse = findResponse(server.responses, 'definition');
55 | assert(!!definitionResponse);
56 | assert(definitionResponse.body.length === 1);
57 | assert(definitionResponse.body[0].file === fileFragments);
58 |
59 | const definitionAndBoundSpanResponse = findResponse(server.responses, 'definitionAndBoundSpan');
60 | assert(!!definitionAndBoundSpanResponse);
61 | assert(definitionAndBoundSpanResponse.body.definitions.length === 1);
62 | assert(definitionAndBoundSpanResponse.body.definitions[0].file === fileFragments);
63 | }
64 |
65 | module.exports = run;
66 |
--------------------------------------------------------------------------------
/src/schema-manager/request-introspection-query.test.ts:
--------------------------------------------------------------------------------
1 | import { http, HttpResponse, graphql } from 'msw';
2 | import { setupServer } from 'msw/node';
3 | import { GraphQLSchema, ExecutionResult } from 'graphql';
4 | import { requestIntrospectionQuery } from './request-introspection-query';
5 | import { executeTestingSchema } from './testing/testing-schema-object';
6 |
7 | describe(requestIntrospectionQuery, () => {
8 | const server = setupServer();
9 |
10 | beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
11 | afterEach(() => server.resetHandlers());
12 | afterAll(() => server.close());
13 |
14 | it('should reject if HTTP fail', async () => {
15 | await expect(requestIntrospectionQuery({ url: 'http://localhost/graphql' })).rejects.toMatchObject({});
16 | });
17 |
18 | it('should reject if HTTP returns bad status', async () => {
19 | server.use(http.post('http://localhost/graphql', () => new Response(null, { status: 404 })));
20 | await expect(requestIntrospectionQuery({ url: 'http://localhost/graphql' })).rejects.toMatchObject({
21 | statusCode: 404,
22 | });
23 | });
24 |
25 | it('should reject if server returns not json', async () => {
26 | server.use(http.post('http://localhost/graphql', () => new Response('', { status: 200 })));
27 | await expect(requestIntrospectionQuery({ url: 'http://localhost/graphql' })).rejects.toBeInstanceOf(SyntaxError);
28 | });
29 |
30 | it('should reject if server returns invalid introspection data', async () => {
31 | server.use(
32 | http.post('http://localhost/graphql', () =>
33 | HttpResponse.json({
34 | data: 'hoge',
35 | errors: null,
36 | }),
37 | ),
38 | );
39 | await expect(requestIntrospectionQuery({ url: 'http://localhost/graphql' })).rejects.toBeInstanceOf(Error);
40 | });
41 |
42 | describe('when server returns valid introspection result', () => {
43 | beforeEach(() => {
44 | server.use(
45 | graphql.operation(({ query, variables }) =>
46 | HttpResponse.json(executeTestingSchema({ query, variables })),
47 | ),
48 | );
49 | });
50 |
51 | it.each(['http://localhost/graphql', 'https://localhost/graphql'])('should resolve schema for %s', async url => {
52 | await expect(requestIntrospectionQuery({ url })).resolves.toBeInstanceOf(GraphQLSchema);
53 | });
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/src/graphql-language-service-adapter/get-definition-and-bound-span.ts:
--------------------------------------------------------------------------------
1 | import { visit, type FragmentSpreadNode } from 'graphql';
2 | import ts from '../tsmodule';
3 | import { getSanitizedTemplateText } from '../ts-ast-util';
4 | import type { AnalysisContext, GetDefinitionAndBoundSpan } from './types';
5 |
6 | export function getDefinitionAndBoundSpan(
7 | ctx: AnalysisContext,
8 | delegate: GetDefinitionAndBoundSpan,
9 | fileName: string,
10 | position: number,
11 | ) {
12 | if (ctx.getScriptSourceHelper().isExcluded(fileName)) return delegate(fileName, position);
13 | const node = ctx.findAscendantTemplateNode(fileName, position);
14 | if (!node) return delegate(fileName, position);
15 | const { text, sourcePosition } = getSanitizedTemplateText(node);
16 | const documentNode = ctx.getGraphQLDocumentNode(text);
17 | if (!documentNode) return delegate(fileName, position);
18 | const innerPosition = position - sourcePosition;
19 | let fragmentSpreadNodeUnderCursor: FragmentSpreadNode | undefined;
20 | visit(documentNode, {
21 | FragmentSpread: node => {
22 | if (node.name.loc!.start <= innerPosition && innerPosition < node.name.loc!.end) {
23 | fragmentSpreadNodeUnderCursor = node;
24 | }
25 | },
26 | });
27 | if (!fragmentSpreadNodeUnderCursor) return delegate(fileName, position);
28 | const foundDefinitionDetail = ctx.getGlobalFragmentDefinitionEntry(fragmentSpreadNodeUnderCursor.name.value);
29 | if (!foundDefinitionDetail) return delegate(fileName, position);
30 | const definitionSourcePosition = foundDefinitionDetail.position + foundDefinitionDetail.node.name.loc!.start;
31 | return {
32 | textSpan: {
33 | start: sourcePosition + fragmentSpreadNodeUnderCursor.name.loc!.start,
34 | length: fragmentSpreadNodeUnderCursor.name.loc!.end - fragmentSpreadNodeUnderCursor.name.loc!.start,
35 | },
36 | definitions: [
37 | {
38 | fileName: foundDefinitionDetail.fileName,
39 | name: foundDefinitionDetail.node.name.value,
40 | textSpan: {
41 | start: definitionSourcePosition,
42 | length: foundDefinitionDetail.node.name.loc!.end - foundDefinitionDetail.node.name.loc!.start,
43 | },
44 | kind: ts.ScriptElementKind.unknown,
45 | containerKind: ts.ScriptElementKind.unknown,
46 | containerName: '',
47 | },
48 | ],
49 | } satisfies ts.DefinitionInfoAndBoundSpan;
50 | }
51 |
--------------------------------------------------------------------------------
/src/schema-manager/request-introspection-query.ts:
--------------------------------------------------------------------------------
1 | import { parse } from 'node:url';
2 | import Http from 'node:http';
3 | import Https from 'node:https';
4 | import { buildClientSchema, getIntrospectionQuery, type GraphQLSchema } from 'graphql';
5 |
6 | const INTROSPECTION_QUERY_BODY = JSON.stringify({
7 | query: getIntrospectionQuery(),
8 | });
9 |
10 | const INTROSPECTION_QUERY_LENGTH = Buffer.byteLength(INTROSPECTION_QUERY_BODY);
11 |
12 | export interface RequestSetup {
13 | url: string;
14 | method?: string;
15 | headers?: { [key: string]: string };
16 | }
17 |
18 | export function isRequestSetup(requestSetup: RequestSetup | any): requestSetup is RequestSetup {
19 | const availablePropertyNames = ['url', 'method', 'headers'];
20 |
21 | for (const property in requestSetup) {
22 | if (!availablePropertyNames.includes(property)) {
23 | return false;
24 | }
25 | }
26 |
27 | return !!requestSetup.url;
28 | }
29 |
30 | export function requestIntrospectionQuery(options: RequestSetup) {
31 | const headers: { [key: string]: string | number } = {
32 | 'Content-Type': 'application/json',
33 | 'Content-Length': INTROSPECTION_QUERY_LENGTH,
34 | 'User-Agent': 'ts-graphql-plugin',
35 | ...options.headers,
36 | };
37 |
38 | return new Promise((resolve, reject) => {
39 | const uri = parse(options.url);
40 |
41 | const { method = 'POST' } = options;
42 | const { hostname, protocol, path } = uri;
43 | const port = uri.port && Number.parseInt(uri.port, 10);
44 | const reqParam = { hostname, protocol, path, port, headers, method };
45 |
46 | const requester = protocol === 'https:' ? Https.request : Http.request;
47 | let body = '';
48 |
49 | const req = requester(reqParam, res => {
50 | res.on('data', chunk => (body += chunk));
51 | res.on('end', () => {
52 | if (!res.statusCode || res.statusCode < 200 || res.statusCode > 300) {
53 | reject({
54 | statusCode: res.statusCode,
55 | body,
56 | });
57 | } else {
58 | let result: any;
59 | try {
60 | result = JSON.parse(body);
61 | resolve(buildClientSchema(result.data));
62 | } catch (e) {
63 | reject(e);
64 | }
65 | }
66 | });
67 | });
68 |
69 | req.on('error', reason => reject(reason));
70 | req.write(INTROSPECTION_QUERY_BODY);
71 | req.end();
72 | });
73 | }
74 |
--------------------------------------------------------------------------------
/src/schema-manager/schema-manager-factory.ts:
--------------------------------------------------------------------------------
1 | import type { SchemaManagerHost } from './types';
2 | import { SchemaManager, NoopSchemaManager } from './schema-manager';
3 | import { FileSchemaManagerOptions, FileSchemaManager } from './file-schema-manager';
4 | import { HttpSchemaManager } from './http-schema-manager';
5 | import { ScriptedHttpSchemaManager } from './scripted-http-schema-manager';
6 | import { RequestSetup } from './request-introspection-query';
7 |
8 | interface FileSchemaConfigOptions {
9 | file: FileSchemaManagerOptions;
10 | }
11 |
12 | interface HttpSchemaConfigOptions {
13 | http: RequestSetup;
14 | }
15 |
16 | interface ScriptedHttpSchemaManagerOptions {
17 | http: {
18 | fromScript: string;
19 | };
20 | }
21 |
22 | type SchemaConfigOptions = FileSchemaConfigOptions | HttpSchemaConfigOptions | ScriptedHttpSchemaManagerOptions;
23 |
24 | function isFileType(conf: SchemaConfigOptions): conf is FileSchemaConfigOptions {
25 | return !!(conf as any).file;
26 | }
27 |
28 | function isHttpType(conf: SchemaConfigOptions): conf is HttpSchemaConfigOptions {
29 | return !!(conf as any).http?.url;
30 | }
31 |
32 | function isScriptedHttpType(conf: SchemaConfigOptions): conf is ScriptedHttpSchemaManagerOptions {
33 | return !!(conf as any).http?.fromScript;
34 | }
35 |
36 | export class SchemaManagerFactory {
37 | constructor(private _host: SchemaManagerHost) {}
38 |
39 | create(): SchemaManager {
40 | const schemaConfig = this._host.getConfig().schema;
41 | let options: SchemaConfigOptions;
42 |
43 | if (typeof schemaConfig === 'string') {
44 | options = this._convertOptionsFromString(schemaConfig);
45 | } else {
46 | options = schemaConfig as SchemaConfigOptions;
47 | }
48 |
49 | if (isFileType(options)) {
50 | return new FileSchemaManager(this._host, options.file);
51 | } else if (isHttpType(options)) {
52 | return new HttpSchemaManager(this._host, options.http);
53 | } else if (isScriptedHttpType(options)) {
54 | return new ScriptedHttpSchemaManager(this._host, options.http);
55 | }
56 |
57 | return new NoopSchemaManager(this._host);
58 | }
59 |
60 | _convertOptionsFromString(path: string): SchemaConfigOptions {
61 | if (/https?/.test(path)) {
62 | return {
63 | http: {
64 | url: path,
65 | } as RequestSetup,
66 | };
67 | } else {
68 | return {
69 | file: {
70 | path,
71 | } as FileSchemaManagerOptions,
72 | };
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/ts-ast-util/script-source-helper.ts:
--------------------------------------------------------------------------------
1 | import ts from '../tsmodule';
2 | import { findAllNodes, findNode } from './utilily-functions';
3 | import { TemplateExpressionResolver } from './template-expression-resolver';
4 | import { createFileNameFilter } from './file-name-filter';
5 | import type { ScriptSourceHelper } from './types';
6 |
7 | export function createScriptSourceHelper(
8 | {
9 | languageService,
10 | languageServiceHost,
11 | project,
12 | }: {
13 | languageService: ts.LanguageService;
14 | languageServiceHost: ts.LanguageServiceHost;
15 | project: { getProjectName: () => string };
16 | },
17 | {
18 | exclude,
19 | reuseProgram,
20 | }: {
21 | exclude: string[] | undefined;
22 | reuseProgram?: boolean;
23 | },
24 | ): ScriptSourceHelper {
25 | let cachedProgram: ts.Program | undefined;
26 |
27 | const getSourceFile = (fileName: string) => {
28 | // Note:
29 | // Reuse program in batching procedure(e.g. CLI) because getProgram() is "heavy" function.
30 | const program = cachedProgram ?? languageService.getProgram();
31 | if (!program) {
32 | throw new Error('language service host does not have program!');
33 | }
34 | if (reuseProgram) cachedProgram = program;
35 | const s = program.getSourceFile(fileName);
36 | if (!s) {
37 | throw new Error('No source file: ' + fileName);
38 | }
39 | return s;
40 | };
41 |
42 | const isExcluded = createFileNameFilter({ specs: exclude, projectName: project.getProjectName() });
43 | const getNode = (fileName: string, position: number) => {
44 | return findNode(getSourceFile(fileName), position);
45 | };
46 | const getAllNodes = (fileName: string, cond: (n: ts.Node) => undefined | boolean | S) => {
47 | const s = getSourceFile(fileName);
48 | return findAllNodes(s, cond);
49 | };
50 | const getLineAndChar = (fileName: string, position: number) => {
51 | const s = getSourceFile(fileName);
52 | return ts.getLineAndCharacterOfPosition(s, position);
53 | };
54 | const resolver = new TemplateExpressionResolver(
55 | languageService,
56 | (fileName: string) => languageServiceHost.getScriptVersion(fileName),
57 | isExcluded,
58 | );
59 | const resolveTemplateLiteral = resolver.resolve.bind(resolver);
60 | const updateTemplateLiteralInfo = resolver.update.bind(resolver);
61 | return {
62 | isExcluded,
63 | getNode,
64 | getAllNodes,
65 | getLineAndChar,
66 | resolveTemplateLiteral,
67 | updateTemplateLiteralInfo,
68 | };
69 | }
70 |
--------------------------------------------------------------------------------
/src/analyzer/markdown-reporter.test.ts:
--------------------------------------------------------------------------------
1 | import { MarkdownReporter } from './markdown-reporter';
2 | import { createTesintExtractor } from './testing/testing-extractor';
3 | import { parseTagConfig } from '../ts-ast-util';
4 |
5 | describe(MarkdownReporter, () => {
6 | it('should convert from manifest to markdown content', () => {
7 | const extractor = createTesintExtractor([
8 | {
9 | fileName: '/prj-root/src/main.ts',
10 | content: `
11 | import gql from 'graphql-tag';
12 | const fragment = gql\`
13 | fragment MyFragment on Query {
14 | hello
15 | }
16 | \`;
17 | const query = gql\`
18 | \${fragment}
19 | query MyQuery {
20 | ...MyFragment
21 | }
22 | \`;
23 | const mutation = gql\`
24 | mutation Greeting {
25 | greeting {
26 | reply
27 | }
28 | }
29 | \`;
30 | `,
31 | },
32 | ]);
33 | const manifest = extractor.toManifest(
34 | extractor.extract(['/prj-root/src/main.ts'], parseTagConfig('gql')),
35 | parseTagConfig('gql'),
36 | );
37 | const content = new MarkdownReporter().toMarkdownConntent(manifest, {
38 | baseDir: '/prj-root',
39 | outputDir: '/prj-root/dist',
40 | });
41 | expect(content).toMatchSnapshot();
42 | });
43 |
44 | it('should convert from manifest to markdown content with ignoreFragments: false', () => {
45 | const extractor = createTesintExtractor([
46 | {
47 | fileName: '/prj-root/src/main.ts',
48 | content: `
49 | import gql from 'graphql-tag';
50 | const fragment = gql\`
51 | fragment MyFragment on Query {
52 | hello
53 | }
54 | \`;
55 | const query = gql\`
56 | \${fragment}
57 | query MyQuery {
58 | ...MyFragment
59 | }
60 | \`;
61 | const mutation = gql\`
62 | mutation Greeting {
63 | greeting {
64 | reply
65 | }
66 | }
67 | \`;
68 | `,
69 | },
70 | ]);
71 | const manifest = extractor.toManifest(
72 | extractor.extract(['/prj-root/src/main.ts'], parseTagConfig('gql')),
73 | parseTagConfig('gql'),
74 | );
75 | const content = new MarkdownReporter().toMarkdownConntent(manifest, {
76 | ignoreFragments: false,
77 | baseDir: '/prj-root',
78 | outputDir: '/prj-root/dist',
79 | });
80 | expect(content).toMatchSnapshot();
81 | });
82 | });
83 |
--------------------------------------------------------------------------------
/src/cli/cli.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import { registerTypeScript } from '../register-hooks';
4 | import { createParser } from './parser';
5 | import { cliDefinition as typegenOptions, typegenCommand } from './commands/typegen';
6 | import { cliDefinition as extractOptions, extractCommand } from './commands/extract';
7 | import { cliDefinition as validateOptions, validateCommand } from './commands/validate';
8 | import { cliDefinition as reportOptions, reportCommand } from './commands/report';
9 | import { ConsoleLogger } from './logger';
10 |
11 | async function main() {
12 | const logger = new ConsoleLogger();
13 | const parser = createParser({
14 | options: {
15 | help: {
16 | alias: 'h',
17 | description: 'Print this message.',
18 | type: 'boolean',
19 | },
20 | version: {
21 | alias: 'v',
22 | description: 'Print version.',
23 | type: 'boolean',
24 | },
25 | },
26 | commands: {
27 | typegen: typegenOptions,
28 | extract: extractOptions,
29 | validate: validateOptions,
30 | report: reportOptions,
31 | },
32 | logger,
33 | });
34 |
35 | const cli = parser.parse();
36 |
37 | if (cli.errors) {
38 | if (cli.errors.unknownCommand) {
39 | logger.error(
40 | `Unknown command name: ${cli.errors.unknownCommand}. Available commands are: ${cli
41 | .availableCommandNames()
42 | .join(', ')} .`,
43 | );
44 | }
45 | process.exit(1);
46 | }
47 |
48 | if (!cli.command) {
49 | if (cli.options.help) {
50 | cli.showHelp();
51 | process.exit(0);
52 | }
53 | if (cli.options.version) {
54 | logger.info(require('../../package.json').version);
55 | process.exit(0);
56 | }
57 | cli.showHelp();
58 | process.exit(1);
59 | } else {
60 | if (cli.options.help) {
61 | cli.showCommandHelp(Object.keys(cli.command)[0]);
62 | process.exit(0);
63 | }
64 | }
65 |
66 | let result: boolean = false;
67 | try {
68 | registerTypeScript();
69 | if (cli.command.typegen) {
70 | result = await typegenCommand(cli.command.typegen);
71 | } else if (cli.command.extract) {
72 | result = await extractCommand(cli.command.extract);
73 | } else if (cli.command.validate) {
74 | result = await validateCommand(cli.command.validate);
75 | } else if (cli.command.report) {
76 | result = await reportCommand(cli.command.report);
77 | }
78 | process.exit(result ? 0 : 1);
79 | } catch (e) {
80 | logger.error(e);
81 | process.exit(1);
82 | }
83 | }
84 |
85 | main();
86 |
--------------------------------------------------------------------------------
/src/analyzer/__snapshots__/extractor.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Extractor should convert results to manifest JSON 1`] = `
4 | {
5 | "documents": [
6 | {
7 | "body": "query MyQuery {
8 | hello
9 | }",
10 | "documentEnd": {
11 | "character": 8,
12 | "line": 6,
13 | },
14 | "documentStart": {
15 | "character": 26,
16 | "line": 2,
17 | },
18 | "fileName": "main.ts",
19 | "fragmentName": undefined,
20 | "operationName": "MyQuery",
21 | "tag": "",
22 | "templateLiteralNodeEnd": {
23 | "character": 9,
24 | "line": 6,
25 | },
26 | "templateLiteralNodeStart": {
27 | "character": 25,
28 | "line": 2,
29 | },
30 | "type": "query",
31 | },
32 | {
33 | "body": "mutation Greeting {
34 | greeting {
35 | reply
36 | }
37 | }",
38 | "documentEnd": {
39 | "character": 8,
40 | "line": 13,
41 | },
42 | "documentStart": {
43 | "character": 29,
44 | "line": 7,
45 | },
46 | "fileName": "main.ts",
47 | "fragmentName": undefined,
48 | "operationName": "Greeting",
49 | "tag": "",
50 | "templateLiteralNodeEnd": {
51 | "character": 9,
52 | "line": 13,
53 | },
54 | "templateLiteralNodeStart": {
55 | "character": 28,
56 | "line": 7,
57 | },
58 | "type": "mutation",
59 | },
60 | ],
61 | }
62 | `;
63 |
64 | exports[`Extractor should extract GraphQL documents 1`] = `
65 | [
66 | "query MyQuery {
67 | hello
68 | }",
69 | "mutation Greeting {
70 | greeting {
71 | reply
72 | }
73 | }",
74 | ]
75 | `;
76 |
77 | exports[`Extractor should extract GraphQL documents and shrink duplicated fragments when removeDuplicatedFragments: false 1`] = `
78 | [
79 | "fragment A on Query {
80 | hello
81 | }
82 |
83 | fragment A on Query {
84 | hello
85 | }
86 |
87 | query MyQuery {
88 | ...A
89 | }",
90 | ]
91 | `;
92 |
93 | exports[`Extractor should extract GraphQL documents and shrink duplicated fragments when removeDuplicatedFragments: true 1`] = `
94 | [
95 | "fragment A on Query {
96 | hello
97 | }
98 |
99 | query MyQuery {
100 | ...A
101 | }",
102 | ]
103 | `;
104 |
105 | exports[`Extractor should store template resolve errors with too complex interpolation 1`] = `
106 | {
107 | "end": 219,
108 | "message": "This operation or fragment has too complex interpolation to analyze.",
109 | "start": 207,
110 | }
111 | `;
112 |
--------------------------------------------------------------------------------
/src/graphql-language-service-adapter/get-completion-at-position.ts:
--------------------------------------------------------------------------------
1 | import type ts from '../tsmodule';
2 | import { getAutocompleteSuggestions, type CompletionItem } from 'graphql-language-service';
3 | import type { AnalysisContext, GetCompletionAtPosition } from './types';
4 | import { SimplePosition } from './simple-position';
5 |
6 | function translateCompletionItems(items: CompletionItem[]): ts.CompletionInfo {
7 | const result: ts.CompletionInfo = {
8 | isGlobalCompletion: false,
9 | isMemberCompletion: false,
10 | isNewIdentifierLocation: false,
11 | entries: items.map(r => {
12 | // FIXME use ts.ScriptElementKind
13 | const kind = r.kind ? r.kind + '' : ('unknown' as any);
14 | return {
15 | name: r.label,
16 | kindModifiers: 'declare',
17 | kind,
18 | sortText: '0',
19 | };
20 | }),
21 | };
22 | return result;
23 | }
24 |
25 | export function getCompletionAtPosition(
26 | ctx: AnalysisContext,
27 | delegate: GetCompletionAtPosition,
28 | fileName: string,
29 | position: number,
30 | options: ts.GetCompletionsAtPositionOptions | undefined,
31 | formattingSettings?: ts.FormatCodeSettings | undefined,
32 | ) {
33 | if (ctx.getScriptSourceHelper().isExcluded(fileName)) return delegate(fileName, position, options);
34 | const schema = ctx.getSchema();
35 | if (!schema) return delegate(fileName, position, options);
36 | const node = ctx.findAscendantTemplateNode(fileName, position);
37 | if (!node) return delegate(fileName, position, options);
38 | const { resolvedInfo } = ctx.resolveTemplateInfo(fileName, node);
39 | if (!resolvedInfo) {
40 | return delegate(fileName, position, options, formattingSettings);
41 | }
42 | const { combinedText, getInnerPosition, convertInnerPosition2InnerLocation } = resolvedInfo;
43 | // NOTE: The getAutocompleteSuggestions function does not return if missing '+1' shift
44 | const innerPositionToSearch = getInnerPosition(position).pos + 1;
45 | const innerLocation = convertInnerPosition2InnerLocation(innerPositionToSearch);
46 | ctx.debug(
47 | 'Get GraphQL complete suggestions. documentText: "' + combinedText + '", position: ' + innerPositionToSearch,
48 | );
49 | const positionForSeach = new SimplePosition({
50 | line: innerLocation.line,
51 | character: innerLocation.character,
52 | });
53 | const gqlCompletionItems = getAutocompleteSuggestions(
54 | schema,
55 | combinedText,
56 | positionForSeach,
57 | undefined,
58 | ctx.getGlobalFragmentDefinitions(),
59 | );
60 | ctx.debug(JSON.stringify(gqlCompletionItems));
61 | return translateCompletionItems(gqlCompletionItems);
62 | }
63 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contribution
2 |
3 | We're always welcome to issues / PRs :smile:
4 |
5 |
6 |
7 | - [Setup](#setup)
8 | - [Code format](#code-format)
9 | - [Testing](#testing)
10 | - [Unit testing](#unit-testing)
11 | - [E2E testing](#e2e-testing)
12 | - [Manual testing](#manual-testing)
13 | - [Language service plugin](#language-service-plugin)
14 | - [CLI](#cli)
15 | - [Adding New Dependencies](#adding-new-dependencies)
16 |
17 |
18 |
19 | ## Setup
20 |
21 | 1. Clone this repository
22 |
23 | ```sh
24 | git clone https://github.com/Quramy/ts-graphql-plugin.git
25 | cd ts-graphql-plugin
26 | ```
27 |
28 | 2. Install dependencies
29 |
30 | ```sh
31 | npm install --no-save
32 | ```
33 |
34 | 3. Compile TypeScript sources
35 |
36 | ```sh
37 | npm run build
38 | ```
39 |
40 | ## Code format
41 |
42 | We use Prettier and configure to format sources automatically when they're git staged.
43 |
44 | And we use ESLint.
45 |
46 | ```sh
47 | npm run lint
48 | ```
49 |
50 | ## Testing
51 |
52 | ### Unit testing
53 |
54 | If you add / modify some functions, write unit testing code about them.
55 |
56 | Execute the following to run all unit testing codes:
57 |
58 | ```sh
59 | npm run test
60 | ```
61 |
62 | ### E2E testing
63 |
64 | In some cases, it's difficult to cover entire functions by unit testing. For example, we should assert "Our language service extension should react when text editor/IDE send a request". We should make sure the whole feature works together correctly.
65 |
66 | In such cases, consider adding E2E test specs.
67 |
68 | ```sh
69 | npm run build
70 | npm link
71 | npm link ts-graphql-plugin
72 | npm run e2e all
73 | ```
74 |
75 | You can specify test suite name via:
76 |
77 | ```sh
78 | npm run e2e cli # Execute only specs under e2e/cli-specs
79 | ```
80 |
81 | ### Manual testing
82 |
83 | #### Language service plugin
84 |
85 | You can check manually language service plugin features with our example project.
86 |
87 | ```sh
88 | npm run bulid
89 | npm link
90 | cd project-fixtures/react-apollo-prj
91 | npm install
92 | npm link ts-graphql-plugin
93 | code . # Or launch editor/IDE what you like
94 | ```
95 |
96 | Of course, you can use other editor which communicates with tsserver .
97 |
98 | #### CLI
99 |
100 | You can run CLI using compiled `cli.js`. For example:
101 |
102 | ```
103 | node lib/cli/cli.js validate -p project-fixtures/gql-errors-prj
104 | ```
105 |
106 | ## Adding New Dependencies
107 |
108 | Not add new dependencies. ts-graphql-plugin is implemented for the purpose of being able to be installed by users in a short installation time.
109 |
--------------------------------------------------------------------------------
/src/schema-manager/file-schema-manager.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import { buildSchema, buildClientSchema } from 'graphql';
3 |
4 | import type ts from '../tsmodule';
5 |
6 | import { SchemaManager } from './schema-manager';
7 | import type { SchemaManagerHost } from './types';
8 |
9 | function extractIntrospectionContentFromJson(jsonObject: any) {
10 | if (jsonObject.data) {
11 | return jsonObject.data;
12 | } else {
13 | return jsonObject;
14 | }
15 | }
16 |
17 | export interface FileSchemaManagerOptions {
18 | path: string;
19 | }
20 |
21 | export class FileSchemaManager extends SchemaManager {
22 | private _schemaPath: string;
23 | private _watcher?: ts.FileWatcher;
24 |
25 | constructor(
26 | protected _host: SchemaManagerHost,
27 | options: FileSchemaManagerOptions,
28 | ) {
29 | super(_host);
30 | this._schemaPath = options.path;
31 | }
32 |
33 | getBaseSchema() {
34 | if (!this._schemaPath || typeof this._schemaPath !== 'string') return null;
35 | try {
36 | const resolvedSchmaPath = this.getAbsoluteSchemaPath(this._host.getProjectRootPath(), this._schemaPath);
37 | this.log('Read schema from ' + resolvedSchmaPath);
38 | const isExists = this._host.fileExists(resolvedSchmaPath);
39 | if (!isExists) return null;
40 | if (this._schemaPath.endsWith('.graphql') || this._schemaPath.endsWith('.gql')) {
41 | const sdl = this._host.readFile(resolvedSchmaPath, 'utf-8');
42 | return sdl ? buildSchema(sdl) : null;
43 | } else {
44 | const introspectionContents = this._host.readFile(resolvedSchmaPath, 'utf-8');
45 | return introspectionContents
46 | ? buildClientSchema(extractIntrospectionContentFromJson(JSON.parse(introspectionContents)))
47 | : null;
48 | }
49 | } catch (err) {
50 | this.log('Fail to read schema file...');
51 | this.log(err instanceof Error ? err.message : `Unknown error: ${err}`);
52 | return null;
53 | }
54 | }
55 |
56 | async waitBaseSchema() {
57 | return this.getBaseSchema();
58 | }
59 |
60 | getAbsoluteSchemaPath(projectRootPath: string, schemaPath: string) {
61 | if (path.isAbsolute(schemaPath)) return schemaPath;
62 | return path.resolve(projectRootPath, schemaPath);
63 | }
64 |
65 | startWatch(interval: number = 100) {
66 | const resolvedSchmaPath = this.getAbsoluteSchemaPath(this._host.getProjectRootPath(), this._schemaPath);
67 | this._watcher = this._host.watchFile(
68 | resolvedSchmaPath,
69 | () => {
70 | this.log('Change schema file.');
71 | this.emitChange();
72 | },
73 | interval,
74 | );
75 | }
76 |
77 | closeWatch() {
78 | if (this._watcher) this._watcher.close();
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/schema-manager/schema-manager.ts:
--------------------------------------------------------------------------------
1 | import type { GraphQLSchema } from 'graphql';
2 | import { ExtensionManager } from './extension-manager';
3 | import type { SchemaManagerHost } from './types';
4 |
5 | export type SchemaBuildErrorInfo = {
6 | message: string;
7 | fileName: string;
8 | fileContent: string;
9 | locations?: ReadonlyArray<{ line: number; character: number }>;
10 | };
11 |
12 | export type OnChangeCallback = (errors: SchemaBuildErrorInfo[] | null, schema: GraphQLSchema | null) => void;
13 |
14 | export abstract class SchemaManager {
15 | private _onChanges: OnChangeCallback[];
16 | private _extensionManager: ExtensionManager;
17 |
18 | constructor(protected _host: SchemaManagerHost) {
19 | this._onChanges = [];
20 | this._extensionManager = new ExtensionManager(_host);
21 | this._extensionManager.readExtensions();
22 | }
23 |
24 | abstract getBaseSchema(): GraphQLSchema | null;
25 | protected abstract waitBaseSchema(): Promise;
26 | protected abstract startWatch(interval?: number): void;
27 |
28 | start(interval?: number) {
29 | this._extensionManager.startWatch(() => this.emitChange(), interval);
30 | this.startWatch(interval);
31 | }
32 |
33 | getSchema(): { schema: GraphQLSchema | null; errors: SchemaBuildErrorInfo[] | null } {
34 | const baseSchema = this.getBaseSchema();
35 | const schema = baseSchema && this._extensionManager.extendSchema(baseSchema);
36 | if (schema) {
37 | return { schema, errors: null };
38 | } else {
39 | return { schema: null, errors: this._extensionManager.getSchemaErrors() };
40 | }
41 | }
42 |
43 | async waitSchema(): Promise<{ schema: GraphQLSchema | null; errors: SchemaBuildErrorInfo[] | null }> {
44 | const baseSchema = await this.waitBaseSchema();
45 | if (!baseSchema) return { schema: null, errors: null };
46 | const schema = this._extensionManager.extendSchema(baseSchema);
47 | if (schema) {
48 | return { schema, errors: null };
49 | } else {
50 | return { schema: null, errors: this._extensionManager.getSchemaErrors() };
51 | }
52 | }
53 |
54 | registerOnChange(cb: OnChangeCallback) {
55 | this._onChanges.push(cb);
56 | return () => {
57 | this._onChanges = this._onChanges.filter(x => x !== cb);
58 | };
59 | }
60 |
61 | protected emitChange() {
62 | const { errors, schema } = this.getSchema();
63 | this._onChanges.forEach(cb => cb(errors, schema));
64 | }
65 |
66 | protected log(msg: string) {
67 | this._host.log(msg);
68 | }
69 | }
70 |
71 | export class NoopSchemaManager extends SchemaManager {
72 | startWatch() {}
73 |
74 | async waitBaseSchema() {
75 | return null;
76 | }
77 |
78 | getBaseSchema() {
79 | return null;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/ts-ast-util/__snapshots__/template-expression-resolver.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`resolve string combinination pattern should return combined string in TemplateExpression with StringLiteral 1`] = `"query { foo }"`;
4 |
5 | exports[`resolve string combinination pattern should return combined string with class static property interpolation 1`] = `
6 | "
7 |
8 | fragment Foo on Hoge {
9 | name
10 | }
11 |
12 | query {
13 | ...Foo
14 | }"
15 | `;
16 |
17 | exports[`resolve string combinination pattern should return combined string with hopping reference 1`] = `
18 | "
19 |
20 | fragment Foo on Hoge {
21 | name
22 | }
23 | query {
24 | ...Foo
25 | }"
26 | `;
27 |
28 | exports[`resolve string combinination pattern should return combined string with property access interpolation 1`] = `
29 | "
30 |
31 | fragment Foo on Hoge {
32 | name
33 | }
34 | query {
35 | ...Foo
36 | }"
37 | `;
38 |
39 | exports[`resolve string combinination pattern should return combined string with reference between multiple files 1`] = `
40 | "
41 |
42 | fragment Foo on Hoge {
43 | name
44 | }
45 | query {
46 | ...Foo
47 | }"
48 | `;
49 |
50 | exports[`resolve string combinination pattern should return combined string with reference to other literal 1`] = `
51 | "
52 |
53 | fragment Foo on Hoge {
54 | name
55 | }
56 | query {
57 | ...Foo
58 | }"
59 | `;
60 |
61 | exports[`resolve string combinination pattern should return combined string with shorthand property access interpolation 1`] = `
62 | "
63 |
64 | fragment Foo on Hoge {
65 | name
66 | }
67 | query {
68 | ...Foo
69 | }"
70 | `;
71 |
72 | exports[`resolve string combinination pattern should return combined string with template in call expression 1`] = `
73 | "
74 |
75 |
76 | fragment Foo on Hoge {
77 | name
78 | }
79 |
80 | fragment Piyo on Hoge {
81 | ...Foo
82 | }
83 |
84 | query {
85 | ...Piyo
86 | }"
87 | `;
88 |
--------------------------------------------------------------------------------
/src/schema-manager/scripted-http-schema-manager.ts:
--------------------------------------------------------------------------------
1 | import { join, isAbsolute } from 'node:path';
2 | import type { SchemaManagerHost } from './types';
3 | import { RequestSetup, isRequestSetup } from './request-introspection-query';
4 | import { HttpSchemaManager } from './http-schema-manager';
5 |
6 | interface ScriptedHttpSchemaManagerOptions {
7 | fromScript: string;
8 | }
9 |
10 | export class ScriptedHttpSchemaManager extends HttpSchemaManager {
11 | private _scriptFileName: string;
12 |
13 | constructor(_host: SchemaManagerHost, options: ScriptedHttpSchemaManagerOptions) {
14 | super(_host);
15 | this._scriptFileName = options.fromScript;
16 | this._host.watchFile(this._getScriptFilePath(), this._configurationScriptChanged.bind(this), 100);
17 | }
18 |
19 | private _getScriptFilePath() {
20 | const rootPath = isAbsolute(this._host.getProjectRootPath()) ? this._host.getProjectRootPath() : process.cwd();
21 | return join(rootPath, this._scriptFileName);
22 | }
23 |
24 | private _requireScript(path: string) {
25 | delete require.cache[path];
26 | return require(path);
27 | }
28 |
29 | private _configurationScriptChanged() {
30 | this._options = null;
31 | }
32 |
33 | protected _fetchErrorOcurred() {
34 | this._options = null;
35 | }
36 |
37 | protected async _getOptions(): Promise {
38 | if (this._options !== null) {
39 | return this._options;
40 | }
41 |
42 | const configurationScriptPath = this._getScriptFilePath();
43 |
44 | if (!this._host.fileExists(configurationScriptPath)) {
45 | const errorMessage = `ScriptedHttpSchemaManager configuration script '${configurationScriptPath}' does not exist`;
46 | this.log(errorMessage);
47 | throw new Error(errorMessage);
48 | }
49 |
50 | const configurationScript = this._requireScript(configurationScriptPath);
51 |
52 | let setup = null;
53 |
54 | try {
55 | setup = await configurationScript(this._host.getProjectRootPath());
56 | } catch (error) {
57 | const errorMessage = `ScriptedHttpSchemaManager configuration script '${this._scriptFileName}' execution failed due to: ${error}`;
58 | this.log(errorMessage);
59 | throw new Error(errorMessage);
60 | }
61 |
62 | if (!isRequestSetup(setup)) {
63 | const errorMessage = `RequestSetup object is wrong: ${JSON.stringify(setup, null, 2)}`;
64 | this.log(errorMessage);
65 | throw new Error(errorMessage);
66 | }
67 |
68 | if (!/https?:/.test(setup.url)) {
69 | const errorMessage = `RequestSetup.url have to be valid url: ${setup.url}`;
70 | this.log(errorMessage);
71 | throw new Error(errorMessage);
72 | }
73 |
74 | setup.method = setup.method || 'POST';
75 | this._options = setup;
76 | return setup;
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/ts-ast-util/register-document-change-event.ts:
--------------------------------------------------------------------------------
1 | import type ts from '../tsmodule';
2 |
3 | type DocumentChangeEventListener = {
4 | onAcquire: (fileName: string, sourceFile: ts.SourceFile, version: string) => void;
5 | onUpdate?: (fileName: string, sourceFile: ts.SourceFile, version: string) => void;
6 | onRelease?: (fileName: string) => void;
7 | };
8 |
9 | export function registerDocumentChangeEvent(
10 | target: ts.DocumentRegistry,
11 | documentChangeEventListener: DocumentChangeEventListener,
12 | ) {
13 | target.acquireDocument = new Proxy(target.acquireDocument, {
14 | apply: (delegate, thisArg, args: Parameters) => {
15 | const [fileName, , , version] = args;
16 | const sourceFile = delegate.apply(thisArg, args);
17 | documentChangeEventListener.onAcquire(fileName, sourceFile, version);
18 | return sourceFile;
19 | },
20 | });
21 |
22 | target.acquireDocumentWithKey = new Proxy(target.acquireDocumentWithKey, {
23 | apply: (delegate, thisArg, args: Parameters) => {
24 | const [fileName, , , , , version] = args;
25 | const sourceFile = delegate.apply(thisArg, args);
26 | documentChangeEventListener.onAcquire(fileName, sourceFile, version);
27 | return sourceFile;
28 | },
29 | });
30 |
31 | target.updateDocument = new Proxy(target.updateDocument, {
32 | apply: (delegate, thisArg, args: Parameters) => {
33 | const [fileName, , , version] = args;
34 | const sourceFile = delegate.apply(thisArg, args);
35 | documentChangeEventListener.onUpdate?.(fileName, sourceFile, version);
36 | return sourceFile;
37 | },
38 | });
39 |
40 | target.updateDocumentWithKey = new Proxy(target.updateDocumentWithKey, {
41 | apply: (delegate, thisArg, args: Parameters) => {
42 | const [fileName, , , , , version] = args;
43 | const sourceFile = delegate.apply(thisArg, args);
44 | documentChangeEventListener.onUpdate?.(fileName, sourceFile, version);
45 | return sourceFile;
46 | },
47 | });
48 |
49 | target.releaseDocument = new Proxy(target.releaseDocument, {
50 | apply: (delegate, thisArg, args: Parameters) => {
51 | const [fileName] = args;
52 | delegate.apply(thisArg, args);
53 | documentChangeEventListener.onRelease?.(fileName);
54 | },
55 | });
56 |
57 | target.releaseDocumentWithKey = new Proxy(target.releaseDocumentWithKey, {
58 | apply: (delegate, thisArg, args: Parameters) => {
59 | const [fileName] = args;
60 | delegate.apply(thisArg, args);
61 | documentChangeEventListener.onRelease?.(fileName);
62 | },
63 | });
64 | }
65 |
--------------------------------------------------------------------------------
/src/analyzer/markdown-reporter.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import type { ManifestOutput, ManifestDocumentEntry } from './types';
3 |
4 | export type ToMarkdownContentOptions = {
5 | baseDir: string;
6 | outputDir: string;
7 | ignoreFragments?: boolean;
8 | };
9 |
10 | function createLinkPath(fileName: string, baseDir: string, outputDir: string) {
11 | const displayPath = path.isAbsolute(fileName) ? path.relative(baseDir, fileName) : fileName;
12 | const linkPath = path.isAbsolute(fileName) ? path.relative(outputDir, fileName) : fileName;
13 | return { displayPath, linkPath };
14 | }
15 |
16 | function createSection(
17 | sectionName: string,
18 | operationDocs: ManifestDocumentEntry[],
19 | baseDir: string,
20 | outputDir: string,
21 | ) {
22 | const h2 = '## ' + sectionName[0].toUpperCase() + sectionName.slice(1);
23 | return (
24 | h2 +
25 | '\n' +
26 | operationDocs
27 | .map(doc => {
28 | const { displayPath, linkPath } = createLinkPath(doc.fileName, baseDir, outputDir);
29 | return `
30 | ### ${doc.type !== 'fragment' ? doc.operationName || 'anonymous' : doc.fragmentName}
31 |
32 | \`\`\`graphql
33 | ${doc.body.trim()}
34 | \`\`\`
35 |
36 | From [${displayPath}:${doc.documentStart.line + 1}:${doc.documentStart.character + 1}](${linkPath}#L${
37 | doc.documentStart.line + 1
38 | }-L${doc.documentEnd.line + 1})
39 | `;
40 | })
41 | .join('\n')
42 | );
43 | }
44 |
45 | export class MarkdownReporter {
46 | toMarkdownConntent(
47 | manifest: ManifestOutput,
48 | { ignoreFragments = true, baseDir, outputDir }: ToMarkdownContentOptions,
49 | ) {
50 | if (!manifest.documents.length) return null;
51 | const outs = ['# Extracted GraphQL Operations'];
52 | const groupedDocs = {
53 | queries: [],
54 | mutations: [],
55 | subscriptions: [],
56 | fragments: [],
57 | } as { [key: string]: ManifestDocumentEntry[] };
58 | manifest.documents.forEach(doc => {
59 | switch (doc.type) {
60 | case 'query':
61 | groupedDocs.queries.push(doc);
62 | break;
63 | case 'mutation':
64 | groupedDocs.mutations.push(doc);
65 | break;
66 | case 'subscription':
67 | groupedDocs.subscriptions.push(doc);
68 | break;
69 | case 'fragment':
70 | if (!ignoreFragments) groupedDocs.fragments.push(doc);
71 | break;
72 | default:
73 | break;
74 | }
75 | });
76 | Object.entries(groupedDocs).forEach(([name, docs]) => {
77 | if (docs.length) {
78 | outs.push(createSection(name, docs, baseDir, outputDir));
79 | }
80 | });
81 | outs.push('---');
82 | outs.push('Extracted by [ts-graphql-plugin](https://github.com/Quramy/ts-graphql-plugin)');
83 | return outs.join('\n');
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/analyzer/analyzer-factory.test.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import { AnalyzerFactory } from './analyzer-factory';
3 | import { Analyzer } from './analyzer';
4 |
5 | describe(AnalyzerFactory, () => {
6 | describe(AnalyzerFactory.prototype.createAnalyzerFromProjectPath, () => {
7 | it('should create analyzer instance from existing typescript project directory path', () => {
8 | const analyzer = new AnalyzerFactory().createAnalyzerFromProjectPath(
9 | path.resolve(__dirname, '../../project-fixtures/react-apollo-prj'),
10 | );
11 | expect(analyzer instanceof Analyzer).toBeTruthy();
12 | });
13 |
14 | it('should create analyzer instance from existing typescript project config file name', () => {
15 | const analyzer = new AnalyzerFactory().createAnalyzerFromProjectPath(
16 | path.resolve(__dirname, '../../project-fixtures/react-apollo-prj/tsconfig.json'),
17 | );
18 | expect(analyzer instanceof Analyzer).toBeTruthy();
19 | });
20 |
21 | it('should throw an error when not exsisting config path', () => {
22 | expect(() => new AnalyzerFactory().createAnalyzerFromProjectPath('NOT_EXISTING_PRJ')).toThrowError();
23 | });
24 |
25 | it('should throw an error when project dir does not have tsconfig.json', () => {
26 | expect(() =>
27 | new AnalyzerFactory().createAnalyzerFromProjectPath(
28 | path.resolve(__dirname, '../../project-fixtures/no-config-prj'),
29 | ),
30 | ).toThrowError();
31 | });
32 |
33 | it('should throw an error when config is written in invalid format', () => {
34 | expect(() =>
35 | new AnalyzerFactory().createAnalyzerFromProjectPath(
36 | path.resolve(__dirname, '../../project-fixtures/simple-prj/tsconfig.invalid.json'),
37 | ),
38 | ).toThrowError();
39 | });
40 |
41 | it('should throw an error when config has no plugins field', () => {
42 | expect(() =>
43 | new AnalyzerFactory().createAnalyzerFromProjectPath(
44 | path.resolve(__dirname, '../../project-fixtures/simple-prj/tsconfig.noplugin.json'),
45 | ),
46 | ).toThrowError();
47 | });
48 |
49 | it('should throw an error when config.plugins has no ts-graphql-plugin object', () => {
50 | expect(() =>
51 | new AnalyzerFactory().createAnalyzerFromProjectPath(
52 | path.resolve(__dirname, '../../project-fixtures/simple-prj/tsconfig.notsgqlplugin.json'),
53 | ),
54 | ).toThrowError();
55 | });
56 |
57 | it('should throw an error when config.plugins.typegen.addons includes invalid modules', () => {
58 | expect(() =>
59 | new AnalyzerFactory().createAnalyzerFromProjectPath(
60 | path.resolve(__dirname, '../../project-fixtures/simple-prj/tsconfig.invalid-addon.json'),
61 | ),
62 | ).toThrowError();
63 | });
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ts-graphql-plugin",
3 | "version": "4.0.3",
4 | "description": "TypeScript Language Service Plugin for GraphQL",
5 | "keywords": [
6 | "typescript",
7 | "graphql",
8 | "language service"
9 | ],
10 | "engines": {
11 | "node": ">=18"
12 | },
13 | "main": "lib/index.js",
14 | "bin": {
15 | "tsgql": "lib/cli/cli.js",
16 | "ts-graphql-plugin": "lib/cli/cli.js"
17 | },
18 | "types": "lib/index.d.ts",
19 | "files": [
20 | "webpack.js",
21 | "addons/**/*.js",
22 | "lib/**/*.js",
23 | "lib/**/*.d.ts",
24 | "!lib/**/*.test.*"
25 | ],
26 | "scripts": {
27 | "prepare": "husky install",
28 | "clean": "rimraf -g lib \"e2e/*.log\" \"*.tsbuildinfo\"",
29 | "build": "run-s build:ts build:doc",
30 | "build:ts": "tsc -p . && cp src/tsmodule.js lib && cp src/tsmodule.d.ts lib",
31 | "build:doc": "npm run doc:toc",
32 | "lint": "eslint \"src/**/*.{ts,tsx}\"",
33 | "jest": "jest",
34 | "jest:ci": "jest --coverage --maxWorkers=4",
35 | "e2e": "node e2e/run.js",
36 | "e2e:ci": "c8 -o e2e_coverage -x e2e -r json -i \"src/**/*\" node e2e/run.js",
37 | "test": "npm run format:check && npm run lint && npm run jest:ci && npm run e2e:ci",
38 | "prettier": "prettier .",
39 | "format": "npm run prettier -- --write",
40 | "format:check": "npm run prettier -- --check",
41 | "doc:toc": "ts-node -P tools/tsconfig.json tools/add-toc.ts",
42 | "watch:compile": "tsc --watch -p .",
43 | "watch:jest": "jest --watch",
44 | "watch": "npm run run clean && run-p watch:*"
45 | },
46 | "author": "Quramy",
47 | "license": "MIT",
48 | "repository": {
49 | "type": "git",
50 | "url": "https://github.com/Quramy/ts-graphql-plugin.git"
51 | },
52 | "dependencies": {
53 | "graphql-language-service": "^5.2.1"
54 | },
55 | "devDependencies": {
56 | "@types/jest": "29.5.14",
57 | "@types/node": "24.10.4",
58 | "@types/node-fetch": "3.0.2",
59 | "@typescript-eslint/eslint-plugin": "7.18.0",
60 | "@typescript-eslint/parser": "7.18.0",
61 | "c8": "10.1.3",
62 | "eslint": "8.57.1",
63 | "eslint-config-prettier": "10.1.8",
64 | "fretted-strings": "2.0.0",
65 | "glob": "11.1.0",
66 | "graphql": "16.12.0",
67 | "graphql-config": "5.1.5",
68 | "husky": "9.1.7",
69 | "jest": "29.7.0",
70 | "markdown-toc": "1.2.0",
71 | "msw": "2.10.3",
72 | "npm-run-all2": "8.0.4",
73 | "prettier": "^3.2.5",
74 | "pretty-quick": "4.2.2",
75 | "rimraf": "6.1.2",
76 | "talt": "2.4.4",
77 | "ts-jest": "29.4.6",
78 | "ts-loader": "9.5.4",
79 | "ts-node": "10.9.2",
80 | "typescript": "5.5.4",
81 | "typescript-eslint-language-service": "5.0.5",
82 | "webpack": "5.104.1",
83 | "webpack-cli": "6.0.1"
84 | },
85 | "peerDependencies": {
86 | "graphql": "^15.0.0 || ^16.0.0",
87 | "typescript": "^4.8.0 || ^5.0.0"
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/schema-manager/file-schema-manager.test.ts:
--------------------------------------------------------------------------------
1 | import { GraphQLSchema, graphql, buildSchema } from 'graphql';
2 | import { getIntrospectionQuery } from 'graphql/utilities';
3 | import { FileSchemaManager } from './file-schema-manager';
4 | import { createTestingSchemaManagerHost } from './testing/testing-schema-manager-host';
5 |
6 | function createManagerWithHost(path: string, content: string) {
7 | const host = createTestingSchemaManagerHost({
8 | schema: '',
9 | prjRootPath: '/',
10 | files: [
11 | {
12 | fileName: '/' + path,
13 | content,
14 | },
15 | ],
16 | });
17 | const manager = new FileSchemaManager(host, { path });
18 | return { manager, host };
19 | }
20 |
21 | describe(FileSchemaManager, () => {
22 | it('should provide base schema from SDL file', async () => {
23 | const sdl = `
24 | type Query {
25 | hello: String!
26 | }
27 | `;
28 | const { manager } = createManagerWithHost('schema.graphql', sdl);
29 | expect(await manager.waitBaseSchema()).toBeInstanceOf(GraphQLSchema);
30 | });
31 |
32 | it('should provide base schema from introspection query result', async () => {
33 | const sdl = `
34 | type Query {
35 | hello: String!
36 | }
37 | `;
38 | const introspectionResult = await graphql({ schema: buildSchema(sdl), source: getIntrospectionQuery() });
39 | const { manager } = createManagerWithHost('schema.json', JSON.stringify(introspectionResult.data));
40 | expect(await manager.waitBaseSchema()).toBeInstanceOf(GraphQLSchema);
41 | });
42 |
43 | it('should provide base schema from JSON object whose data is introspection query result', async () => {
44 | const sdl = `
45 | type Query {
46 | hello: String!
47 | }
48 | `;
49 | const introspectionResult = await graphql({ schema: buildSchema(sdl), source: getIntrospectionQuery() });
50 | const { manager } = createManagerWithHost('schema.json', JSON.stringify(introspectionResult));
51 | expect(await manager.waitBaseSchema()).toBeInstanceOf(GraphQLSchema);
52 | });
53 |
54 | it('should return null as getBaseSchema() when invalid JSON', async () => {
55 | const { manager } = createManagerWithHost('schema.json', '{ ');
56 | expect(await manager.waitBaseSchema()).toBeNull();
57 | });
58 |
59 | it('should update when schema changes', async () => {
60 | const sdl = `
61 | type Query {
62 | hello: String!
63 | }
64 | `;
65 | const { manager, host } = createManagerWithHost('schema.graphql', sdl);
66 | const lazySchema = new Promise(res => manager.registerOnChange(() => res(manager.getBaseSchema())));
67 | manager.startWatch();
68 | const newContent = `
69 | type Query {
70 | hello: Int!
71 | }
72 | `;
73 | host.updateFile('/schema.graphql', newContent);
74 | expect(await lazySchema).toBeInstanceOf(GraphQLSchema);
75 | });
76 | });
77 |
--------------------------------------------------------------------------------
/src/cli/commands/report.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import type { CommandOptions, CommandCliSetting } from '../parser';
3 | import { ConsoleLogger } from '../logger';
4 |
5 | export const cliDefinition = {
6 | description: 'Output GraphQL operations in your TypeScript sources to markdown file.',
7 | options: {
8 | project: {
9 | alias: 'p',
10 | description:
11 | "Analyze the project given the path to its configuration file, or to a folder with a 'tsconfig.json'.",
12 | defaultValue: '.',
13 | type: 'string',
14 | },
15 | outFile: {
16 | alias: 'o',
17 | description: 'Output Markdown file name.',
18 | defaultValue: 'GRAPHQL_OPERATIONS.md',
19 | type: 'string',
20 | },
21 | fromManifest: {
22 | alias: 'M',
23 | description: 'Path to manifest.json file.',
24 | type: 'string',
25 | },
26 | includeFragments: {
27 | description: 'If set, report including fragment informations.',
28 | type: 'boolean',
29 | },
30 | verbose: {
31 | description: 'Show debug messages.',
32 | type: 'boolean',
33 | },
34 | },
35 | } as const satisfies CommandCliSetting;
36 |
37 | export async function reportCommand({ options }: CommandOptions) {
38 | const ts = require('typescript') as typeof import('typescript');
39 | const { AnalyzerFactory } = require('../../analyzer') as typeof import('../../analyzer');
40 | const { ErrorReporter } = require('../../errors/error-reporter') as typeof import('../../errors');
41 | const { color } = require('../../string-util') as typeof import('../../string-util');
42 |
43 | const logger = new ConsoleLogger(options.verbose ? 'debug' : 'info');
44 | const { fromManifest, outFile, project, includeFragments } = options;
45 | const errorReporter = new ErrorReporter(process.cwd(), logger.error.bind(logger));
46 | const analyzer = new AnalyzerFactory().createAnalyzerFromProjectPath(project, logger.debug.bind(logger));
47 | const manifest = fromManifest ? JSON.parse(ts.sys.readFile(fromManifest, 'utf8') || '') : undefined;
48 | let outFileName = path.isAbsolute(outFile) ? outFile : path.resolve(process.cwd(), outFile);
49 | outFileName = ts.sys.directoryExists(outFileName) ? path.join(outFileName, 'GRAPHQL_OPERATIONS.md') : outFileName;
50 | const [errors, markdown] = await analyzer.report(outFileName, manifest, !includeFragments);
51 | errors.forEach(errorReporter.outputError.bind(errorReporter));
52 | if (errors.length) {
53 | logger.error(color.magenta('Found some errors extracting operations.\n'));
54 | errors.forEach(error => errorReporter.outputError(error));
55 | }
56 | if (!markdown) {
57 | logger.error('No GraphQL operations.');
58 | return false;
59 | }
60 | ts.sys.writeFile(outFileName, markdown);
61 | logger.info(`Write report file to '${color.green(path.relative(process.cwd(), outFileName))}'.`);
62 | return true;
63 | }
64 |
--------------------------------------------------------------------------------
/project-fixtures/graphql-codegen-prj/src/gql/gql.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | import * as types from './graphql';
3 | import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
4 |
5 | /**
6 | * Map of all GraphQL operations in the project.
7 | *
8 | * This map has several performance disadvantages:
9 | * 1. It is not tree-shakeable, so it will include all operations in the project.
10 | * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle.
11 | * 3. It does not support dead code elimination, so it will add unused operations.
12 | *
13 | * Therefore it is highly recommended to use the babel or swc plugin for production.
14 | */
15 | const documents = {
16 | "\n query PopularPosts_Query {\n popularPosts {\n id\n ...PostSummary_Post\n }\n }\n": types.PopularPosts_QueryDocument,
17 | "\n fragment PostSummary_Post on Post {\n id\n title\n author {\n name\n ...UserAvatar_User\n }\n }\n": types.PostSummary_PostFragmentDoc,
18 | "\n fragment UserAvatar_User on User {\n name\n avatarURL\n }\n": types.UserAvatar_UserFragmentDoc,
19 | };
20 |
21 | /**
22 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
23 | *
24 | *
25 | * @example
26 | * ```ts
27 | * const query = graphql(`query GetUser($id: ID!) { user(id: $id) { name } }`);
28 | * ```
29 | *
30 | * The query argument is unknown!
31 | * Please regenerate the types.
32 | */
33 | export function graphql(source: string): unknown;
34 |
35 | /**
36 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
37 | */
38 | export function graphql(source: "\n query PopularPosts_Query {\n popularPosts {\n id\n ...PostSummary_Post\n }\n }\n"): (typeof documents)["\n query PopularPosts_Query {\n popularPosts {\n id\n ...PostSummary_Post\n }\n }\n"];
39 | /**
40 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
41 | */
42 | export function graphql(source: "\n fragment PostSummary_Post on Post {\n id\n title\n author {\n name\n ...UserAvatar_User\n }\n }\n"): (typeof documents)["\n fragment PostSummary_Post on Post {\n id\n title\n author {\n name\n ...UserAvatar_User\n }\n }\n"];
43 | /**
44 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
45 | */
46 | export function graphql(source: "\n fragment UserAvatar_User on User {\n name\n avatarURL\n }\n"): (typeof documents)["\n fragment UserAvatar_User on User {\n name\n avatarURL\n }\n"];
47 |
48 | export function graphql(source: string) {
49 | return (documents as any)[source] ?? {};
50 | }
51 |
52 | export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never;
--------------------------------------------------------------------------------
/src/schema-manager/extension-manager.test.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 | import { buildSchema, printSchema } from 'graphql';
4 | import { ExtensionManager } from './extension-manager';
5 | import { createTestingSchemaManagerHost } from './testing/testing-schema-manager-host';
6 |
7 | function createManagerWithHost(config: { localSchemaExtensions: string[] }) {
8 | const host = createTestingSchemaManagerHost({
9 | schema: '',
10 | localSchemaExtensions: config.localSchemaExtensions,
11 | files: config.localSchemaExtensions.map(name => ({
12 | fileName: path.join(__dirname, name),
13 | content: fs.readFileSync(path.join(__dirname, name), 'utf-8'),
14 | })),
15 | prjRootPath: __dirname,
16 | });
17 | return { extensionManager: new ExtensionManager(host), host };
18 | }
19 |
20 | function createManager(config: { localSchemaExtensions: string[] }) {
21 | return createManagerWithHost(config).extensionManager;
22 | }
23 |
24 | const baseSdl = `
25 | type Query {
26 | hello: String!
27 | }
28 | `;
29 |
30 | const baseSchema = buildSchema(baseSdl);
31 |
32 | describe(ExtensionManager, () => {
33 | it('should parse and extend base schema', () => {
34 | const extensionManager = createManager({ localSchemaExtensions: ['./testing/resources/normal.graphql'] });
35 | extensionManager.readExtensions();
36 | const schema = extensionManager.extendSchema(baseSchema);
37 | expect(printSchema(schema!)).toMatchSnapshot();
38 | expect(extensionManager.getSchemaErrors()).toStrictEqual([]);
39 | });
40 |
41 | it('should store parser errors with invalid syntax file', () => {
42 | const extensionManager = createManager({ localSchemaExtensions: ['./testing/resources/invalid_syntax.graphql'] });
43 | extensionManager.readExtensions();
44 | const schema = extensionManager.extendSchema(baseSchema);
45 | expect(schema).toBeNull();
46 | const errors = extensionManager
47 | .getSchemaErrors()!
48 | .map(e => ({ ...e, fileName: e.fileName!.replace(__dirname, '') }));
49 | expect(errors).toMatchSnapshot();
50 | });
51 |
52 | it('should store parser errors with invalid extension', () => {
53 | const extensionManager = createManager({
54 | localSchemaExtensions: ['./testing/resources/invalid_extension.graphql'],
55 | });
56 | extensionManager.readExtensions();
57 | const schema = extensionManager.extendSchema(baseSchema);
58 | expect(schema).toBeNull();
59 | const errors = extensionManager
60 | .getSchemaErrors()!
61 | .map(e => ({ ...e, fileName: e.fileName!.replace(__dirname, '') }));
62 | expect(errors).toMatchSnapshot();
63 | });
64 |
65 | it('should execute call back when files change', async () => {
66 | const { extensionManager, host } = createManagerWithHost({
67 | localSchemaExtensions: ['./testing/resources/normal.graphql'],
68 | });
69 | const called = new Promise(res => extensionManager.startWatch(res));
70 | host.updateFile(path.join(__dirname, 'testing/resources/normal.graphql'), '');
71 | await called;
72 | });
73 | });
74 |
--------------------------------------------------------------------------------
/src/graphql-language-service-adapter/testing/adapter-fixture.ts:
--------------------------------------------------------------------------------
1 | import ts from 'typescript';
2 | import { GraphQLSchema } from 'graphql';
3 | import {
4 | createScriptSourceHelper,
5 | getTemplateNodeUnder,
6 | getSanitizedTemplateText,
7 | ScriptSourceHelper,
8 | } from '../../ts-ast-util';
9 | import { FragmentRegistry } from '../../gql-ast-util';
10 | import { GraphQLLanguageServiceAdapter } from '../graphql-language-service-adapter';
11 | import {
12 | createTestingLanguageServiceAndHost,
13 | TestingLanguageServiceHost,
14 | } from '../../ts-ast-util/testing/testing-language-service';
15 |
16 | export class AdapterFixture {
17 | readonly adapter: GraphQLLanguageServiceAdapter;
18 | readonly langService: ts.LanguageService;
19 | readonly scriptSourceHelper: ScriptSourceHelper;
20 | private readonly _sourceFileName: string;
21 | private readonly _langServiceHost: TestingLanguageServiceHost;
22 | private readonly _fragmentRegistry: FragmentRegistry;
23 |
24 | constructor(sourceFileName: string, schema?: GraphQLSchema) {
25 | const { languageService, languageServiceHost } = createTestingLanguageServiceAndHost({
26 | files: [{ fileName: sourceFileName, content: '' }],
27 | });
28 | this._sourceFileName = sourceFileName;
29 | this._langServiceHost = languageServiceHost;
30 | this._fragmentRegistry = new FragmentRegistry();
31 | this.langService = languageService;
32 | (this.scriptSourceHelper = createScriptSourceHelper(
33 | { languageService, languageServiceHost, project: { getProjectName: () => 'tsconfig.json' } },
34 | { exclude: [] },
35 | )),
36 | (this.adapter = new GraphQLLanguageServiceAdapter(this.scriptSourceHelper, {
37 | schema: schema || null,
38 | removeDuplicatedFragments: true,
39 | fragmentRegistry: this._fragmentRegistry,
40 | tag: {
41 | names: [],
42 | allowNotTaggedTemplate: true,
43 | allowTaggedTemplateExpression: true,
44 | allowFunctionCallExpression: true,
45 | },
46 | }));
47 | }
48 |
49 | get source() {
50 | return this._langServiceHost.getFile(this._sourceFileName)!.content;
51 | }
52 |
53 | set source(content: string) {
54 | this._langServiceHost.updateFile(this._sourceFileName, content);
55 | const documents = this.scriptSourceHelper
56 | .getAllNodes(this._sourceFileName, node =>
57 | getTemplateNodeUnder(node, {
58 | names: [],
59 | allowNotTaggedTemplate: true,
60 | allowTaggedTemplateExpression: true,
61 | allowFunctionCallExpression: true,
62 | }),
63 | )
64 | .map(node => getSanitizedTemplateText(node));
65 | this._fragmentRegistry.registerDocuments(this._sourceFileName, content, documents);
66 | }
67 |
68 | registerFragment(sourceFileName: string, fragmentDefDoc: string) {
69 | if (sourceFileName === this._sourceFileName) return this;
70 | this._fragmentRegistry.registerDocuments(sourceFileName, fragmentDefDoc, [
71 | { sourcePosition: 0, text: fragmentDefDoc },
72 | ]);
73 | return this;
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/project-fixtures/graphql-codegen-prj/src/gql/fragment-masking.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | import { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core';
3 | import { FragmentDefinitionNode } from 'graphql';
4 | import { Incremental } from './graphql';
5 |
6 |
7 | export type FragmentType> = TDocumentType extends DocumentTypeDecoration<
8 | infer TType,
9 | any
10 | >
11 | ? [TType] extends [{ ' $fragmentName'?: infer TKey }]
12 | ? TKey extends string
13 | ? { ' $fragmentRefs'?: { [key in TKey]: TType } }
14 | : never
15 | : never
16 | : never;
17 |
18 | // return non-nullable if `fragmentType` is non-nullable
19 | export function useFragment(
20 | _documentNode: DocumentTypeDecoration,
21 | fragmentType: FragmentType>
22 | ): TType;
23 | // return nullable if `fragmentType` is nullable
24 | export function useFragment(
25 | _documentNode: DocumentTypeDecoration,
26 | fragmentType: FragmentType> | null | undefined
27 | ): TType | null | undefined;
28 | // return array of non-nullable if `fragmentType` is array of non-nullable
29 | export function useFragment(
30 | _documentNode: DocumentTypeDecoration,
31 | fragmentType: ReadonlyArray>>
32 | ): ReadonlyArray;
33 | // return array of nullable if `fragmentType` is array of nullable
34 | export function useFragment(
35 | _documentNode: DocumentTypeDecoration,
36 | fragmentType: ReadonlyArray>> | null | undefined
37 | ): ReadonlyArray | null | undefined;
38 | export function useFragment(
39 | _documentNode: DocumentTypeDecoration,
40 | fragmentType: FragmentType> | ReadonlyArray>> | null | undefined
41 | ): TType | ReadonlyArray | null | undefined {
42 | return fragmentType as any;
43 | }
44 |
45 |
46 | export function makeFragmentData<
47 | F extends DocumentTypeDecoration,
48 | FT extends ResultOf
49 | >(data: FT, _fragment: F): FragmentType {
50 | return data as FragmentType;
51 | }
52 | export function isFragmentReady(
53 | queryNode: DocumentTypeDecoration,
54 | fragmentNode: TypedDocumentNode,
55 | data: FragmentType, any>> | null | undefined
56 | ): data is FragmentType {
57 | const deferredFields = (queryNode as { __meta__?: { deferredFields: Record } }).__meta__
58 | ?.deferredFields;
59 |
60 | if (!deferredFields) return true;
61 |
62 | const fragDef = fragmentNode.definitions[0] as FragmentDefinitionNode | undefined;
63 | const fragName = fragDef?.name?.value;
64 |
65 | const fields = (fragName && deferredFields[fragName]) || [];
66 | return fields.length > 0 && fields.every(field => data && field in data);
67 | }
68 |
--------------------------------------------------------------------------------
/project-fixtures/gql-errors-prj/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "gql-syntax-error",
3 | "version": "1.0.0",
4 | "lockfileVersion": 3,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "name": "gql-syntax-error",
9 | "version": "1.0.0",
10 | "license": "ISC",
11 | "devDependencies": {
12 | "graphql": "16.12.0",
13 | "ts-graphql-plugin": "file:../../",
14 | "typescript": "5.5.4"
15 | }
16 | },
17 | "../..": {
18 | "version": "4.0.3",
19 | "dev": true,
20 | "license": "MIT",
21 | "dependencies": {
22 | "graphql-language-service": "^5.2.1"
23 | },
24 | "bin": {
25 | "ts-graphql-plugin": "lib/cli/cli.js",
26 | "tsgql": "lib/cli/cli.js"
27 | },
28 | "devDependencies": {
29 | "@types/jest": "29.5.14",
30 | "@types/node": "22.19.1",
31 | "@types/node-fetch": "3.0.2",
32 | "@typescript-eslint/eslint-plugin": "7.18.0",
33 | "@typescript-eslint/parser": "7.18.0",
34 | "c8": "10.1.2",
35 | "eslint": "8.57.1",
36 | "eslint-config-prettier": "9.1.2",
37 | "fretted-strings": "2.0.0",
38 | "glob": "11.0.3",
39 | "graphql": "16.12.0",
40 | "graphql-config": "5.1.5",
41 | "husky": "9.1.7",
42 | "jest": "29.7.0",
43 | "markdown-toc": "1.2.0",
44 | "msw": "2.10.3",
45 | "npm-run-all2": "7.0.2",
46 | "prettier": "^3.2.5",
47 | "pretty-quick": "4.2.2",
48 | "rimraf": "6.1.0",
49 | "talt": "2.4.4",
50 | "ts-jest": "29.4.5",
51 | "ts-loader": "9.5.4",
52 | "ts-node": "10.9.2",
53 | "typescript": "5.5.4",
54 | "typescript-eslint-language-service": "5.0.5",
55 | "webpack": "5.99.9",
56 | "webpack-cli": "6.0.1"
57 | },
58 | "engines": {
59 | "node": ">=18"
60 | },
61 | "peerDependencies": {
62 | "graphql": "^15.0.0 || ^16.0.0",
63 | "typescript": "^4.8.0 || ^5.0.0"
64 | }
65 | },
66 | "node_modules/graphql": {
67 | "version": "16.12.0",
68 | "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz",
69 | "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==",
70 | "dev": true,
71 | "license": "MIT",
72 | "engines": {
73 | "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
74 | }
75 | },
76 | "node_modules/ts-graphql-plugin": {
77 | "resolved": "../..",
78 | "link": true
79 | },
80 | "node_modules/typescript": {
81 | "version": "5.5.4",
82 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
83 | "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
84 | "dev": true,
85 | "license": "Apache-2.0",
86 | "bin": {
87 | "tsc": "bin/tsc",
88 | "tsserver": "bin/tsserver"
89 | },
90 | "engines": {
91 | "node": ">=14.17"
92 | }
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/schema-manager/schema-manager-factory.test.ts:
--------------------------------------------------------------------------------
1 | import { SchemaManagerFactory } from './schema-manager-factory';
2 | import { HttpSchemaManager } from './http-schema-manager';
3 | import { FileSchemaManager } from './file-schema-manager';
4 | import { createTestingSchemaManagerHost } from './testing/testing-schema-manager-host';
5 | import { ScriptedHttpSchemaManager } from './scripted-http-schema-manager';
6 |
7 | describe(SchemaManagerFactory, () => {
8 | it('should return HttpSchemaManager from http url string', () => {
9 | const factory = new SchemaManagerFactory(
10 | createTestingSchemaManagerHost({
11 | schema: 'http://localhost',
12 | }),
13 | );
14 | const actual = factory.create();
15 | expect(actual instanceof HttpSchemaManager).toBeTruthy();
16 | });
17 |
18 | it('should return HttpSchemaManager from https url string', () => {
19 | const factory = new SchemaManagerFactory(
20 | createTestingSchemaManagerHost({
21 | schema: 'https://localhost',
22 | }),
23 | );
24 | const actual = factory.create();
25 | expect(actual instanceof HttpSchemaManager).toBeTruthy();
26 | });
27 |
28 | it('should return FileSchemaManager from file schema string', () => {
29 | const factory = new SchemaManagerFactory(
30 | createTestingSchemaManagerHost({
31 | schema: 'file:///tmp/s.json',
32 | }),
33 | );
34 | const actual = factory.create();
35 | expect(actual instanceof FileSchemaManager).toBeTruthy();
36 | });
37 |
38 | it('should return FileSchemaManager from no schema string', () => {
39 | const factory = new SchemaManagerFactory(
40 | createTestingSchemaManagerHost({
41 | schema: '/tmp/s.json',
42 | }),
43 | );
44 | const actual = factory.create();
45 | expect(actual instanceof FileSchemaManager).toBeTruthy();
46 | });
47 |
48 | it('should return HttpSchemaManager from http object with url property', () => {
49 | const factory = new SchemaManagerFactory(
50 | createTestingSchemaManagerHost({
51 | schema: {
52 | http: {
53 | url: 'http://localhost',
54 | },
55 | },
56 | }),
57 | );
58 | const actual = factory.create();
59 | expect(actual instanceof HttpSchemaManager).toBeTruthy();
60 | });
61 |
62 | it('should return FileSchemaManager from file object', () => {
63 | const factory = new SchemaManagerFactory(
64 | createTestingSchemaManagerHost({
65 | schema: {
66 | file: {
67 | path: 'http://localhost',
68 | },
69 | },
70 | }),
71 | );
72 | const actual = factory.create();
73 | expect(actual instanceof FileSchemaManager).toBeTruthy();
74 | });
75 |
76 | it('should return ScriptedHttpSchemaManager from http object with fromScript property', () => {
77 | const factory = new SchemaManagerFactory(
78 | createTestingSchemaManagerHost({
79 | schema: {
80 | http: {
81 | fromScript: 'graphql-config.js',
82 | },
83 | },
84 | }),
85 | );
86 | const actual = factory.create();
87 | expect(actual instanceof ScriptedHttpSchemaManager).toBeTruthy();
88 | });
89 | });
90 |
--------------------------------------------------------------------------------
/src/transformer/transformer.ts:
--------------------------------------------------------------------------------
1 | import { print, type DocumentNode } from 'graphql';
2 | import ts from '../tsmodule';
3 | import { astf, getTemplateNodeUnder, removeAliasFromImportDeclaration, type StrictTagCondition } from '../ts-ast-util';
4 |
5 | export type DocumentTransformer = (documentNode: DocumentNode) => DocumentNode;
6 |
7 | export type TransformOptions = {
8 | tag: StrictTagCondition;
9 | documentTransformers: DocumentTransformer[];
10 | removeFragmentDefinitions: boolean;
11 | target: 'text' | 'object';
12 | getDocumentNode: (node: ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression) => DocumentNode | undefined;
13 | getEnabled: () => boolean;
14 | };
15 |
16 | function toObjectNode(field: any): ts.Expression {
17 | if (field === null) {
18 | return astf.createNull();
19 | } else if (typeof field === 'boolean') {
20 | return field ? astf.createTrue() : astf.createFalse();
21 | } else if (typeof field === 'number') {
22 | return astf.createNumericLiteral(field + '');
23 | } else if (typeof field === 'string') {
24 | return astf.createStringLiteral(field);
25 | } else if (Array.isArray(field)) {
26 | return astf.createArrayLiteralExpression(field.map(item => toObjectNode(item)));
27 | }
28 | return astf.createObjectLiteralExpression(
29 | Object.entries(field)
30 | .filter(([k, v]) => k !== 'loc' && v !== undefined)
31 | .map(([k, v]) => astf.createPropertyAssignment(astf.createIdentifier(k), toObjectNode(v))),
32 | true,
33 | );
34 | }
35 |
36 | export function getTransformer({
37 | tag,
38 | target,
39 | getDocumentNode,
40 | removeFragmentDefinitions,
41 | documentTransformers,
42 | getEnabled,
43 | }: TransformOptions) {
44 | return (ctx: ts.TransformationContext) => {
45 | const visit = (node: ts.Node): ts.Node | undefined => {
46 | if (!getEnabled()) return node;
47 | let templateNode: ts.NoSubstitutionTemplateLiteral | ts.TemplateExpression | undefined = undefined;
48 |
49 | if (ts.isImportDeclaration(node) && tag.names.length > 0) {
50 | return removeAliasFromImportDeclaration(node, tag.names);
51 | }
52 |
53 | if (ts.isTaggedTemplateExpression(node) && (!tag.names.length || !!getTemplateNodeUnder(node, tag))) {
54 | templateNode = node.template;
55 | } else if (ts.isCallExpression(node) && !!getTemplateNodeUnder(node, tag)) {
56 | templateNode = node.arguments[0] as ts.TemplateLiteral;
57 | }
58 |
59 | if (!templateNode) return ts.visitEachChild(node, visit, ctx);
60 |
61 | const originalDocumentNode = getDocumentNode(templateNode);
62 | if (!originalDocumentNode) return ts.visitEachChild(node, visit, ctx);
63 | const documentNode = documentTransformers.reduce((doc, dt) => dt(doc), originalDocumentNode);
64 | if (!documentNode) return ts.visitEachChild(node, visit, ctx);
65 | const toBeRemoved =
66 | removeFragmentDefinitions && documentNode.definitions.every(def => def.kind === 'FragmentDefinition');
67 | if (target === 'text') {
68 | if (toBeRemoved) return astf.createStringLiteral('');
69 | return astf.createStringLiteral(print(documentNode));
70 | }
71 | if (toBeRemoved) return astf.createNumericLiteral('0');
72 | return toObjectNode(documentNode);
73 | };
74 | return (sourceFile: ts.SourceFile) => ts.visitEachChild(sourceFile, visit, ctx);
75 | };
76 | }
77 |
--------------------------------------------------------------------------------
/src/schema-manager/extension-manager.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import { parse, extendSchema, GraphQLError, type GraphQLSchema, type DocumentNode } from 'graphql';
3 | import type { SchemaBuildErrorInfo } from './schema-manager';
4 | import type { SchemaManagerHost } from './types';
5 |
6 | export class ExtensionManager {
7 | private _targetSdlFileNames: string[];
8 | private _parsedExtensionAstMap = new Map();
9 | private _graphqlErrorMap = new Map();
10 |
11 | constructor(private _host: SchemaManagerHost) {
12 | const { localSchemaExtensions } = this._host.getConfig();
13 | this._targetSdlFileNames = (localSchemaExtensions || []).map(filePath =>
14 | this._getAbsoluteSchemaPath(this._host.getProjectRootPath(), filePath),
15 | );
16 | }
17 |
18 | readExtensions() {
19 | this._targetSdlFileNames.forEach(filePath => this._readExtension(filePath));
20 | }
21 |
22 | extendSchema(baseSchema: GraphQLSchema) {
23 | if (this._graphqlErrorMap.size) return null;
24 | for (const [fileName, { node, sdlContent }] of this._parsedExtensionAstMap.entries()) {
25 | try {
26 | baseSchema = extendSchema(baseSchema, node);
27 | } catch (error) {
28 | if (error instanceof Error) {
29 | const { message } = error;
30 | this._graphqlErrorMap.set(fileName, { message, fileName, fileContent: sdlContent });
31 | }
32 | return null;
33 | }
34 | }
35 | this._graphqlErrorMap.clear();
36 | return baseSchema;
37 | }
38 |
39 | getSchemaErrors() {
40 | return [...this._graphqlErrorMap.values()];
41 | }
42 |
43 | startWatch(cb: () => void, interval = 100) {
44 | this._targetSdlFileNames.forEach(fileName => {
45 | this._host.watchFile(
46 | fileName,
47 | () => {
48 | this._host.log('Changed local extension schema: ' + fileName);
49 | this._readExtension(fileName);
50 | cb();
51 | },
52 | interval,
53 | );
54 | });
55 | }
56 |
57 | private _readExtension(fileName: string) {
58 | if (!this._host.fileExists(fileName)) return null;
59 | const sdlContent = this._host.readFile(fileName, 'utf8');
60 | if (!sdlContent) return null;
61 | this._host.log('Read local extension schema: ' + fileName);
62 | try {
63 | const node = parse(sdlContent);
64 | this._parsedExtensionAstMap.set(fileName, { node, sdlContent });
65 | this._graphqlErrorMap.delete(fileName);
66 | } catch (error) {
67 | if (error instanceof GraphQLError) {
68 | const { message, locations } = error;
69 | this._host.log('Failed to parse: ' + fileName + ', ' + message);
70 | if (locations) {
71 | this._graphqlErrorMap.set(fileName, {
72 | message,
73 | fileName,
74 | fileContent: sdlContent,
75 | locations: locations.map(loc => ({ line: loc.line - 1, character: loc.column - 1 })),
76 | });
77 | } else {
78 | this._graphqlErrorMap.set(fileName, { message, fileName, fileContent: sdlContent });
79 | }
80 | }
81 | }
82 | }
83 |
84 | private _getAbsoluteSchemaPath(projectRootPath: string, schemaPath: string) {
85 | if (path.isAbsolute(schemaPath)) return schemaPath;
86 | return path.resolve(projectRootPath, schemaPath);
87 | }
88 | }
89 |
--------------------------------------------------------------------------------