Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

wip: multi project files #1

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions examples/multiple-projects-single-file/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module.exports = {
root: true,
// ❗️ It's very important that you don't have any rules configured at the top-level config,
// and to move all configurations into the overrides section. Since JavaScript rules
// can't run on GraphQL files and vice versa, if you have rules configured at the top level,
// they will try to also execute for all overrides, as ESLint's configs cascade
overrides: [
{
files: ['*.js'],
processor: '@graphql-eslint/graphql',
extends: ['eslint:recommended'],
parserOptions: {
sourceType: 'module',
},
env: {
es6: true,
},
},
{
files: ['schema.*.graphql'],
extends: ['plugin:@graphql-eslint/schema-recommended'],
rules: {
'@graphql-eslint/require-description': 'off',
},
},
{
files: ['*.js/*.graphql'],
extends: ['plugin:@graphql-eslint/operations-recommended'],
rules: {
'@graphql-eslint/require-selections': 'off',
},
},
],
};
31 changes: 31 additions & 0 deletions examples/multiple-projects-single-file/graphql.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { IGraphQLConfig } from 'graphql-config';
import type { GraphQLTagPluckOptions } from '@graphql-tools/graphql-tag-pluck';

const config: IGraphQLConfig = {
projects: {
firstProject: {
schema: 'schema.first-project.graphql',
documents: 'query.js',
extensions: {
pluckConfig: {
modules: [{ name: 'graphql-tag', identifier: 'gql' }],
globalGqlIdentifierName: 'gql',
gqlMagicComment: 'GraphQL',
} satisfies GraphQLTagPluckOptions,
},
},
secondProject: {
schema: 'schema.second-project.graphql',
documents: 'query.js',
extensions: {
pluckConfig: {
modules: [{ name: 'custom-graphql-tag', identifier: 'custom' }],
globalGqlIdentifierName: 'custom',
gqlMagicComment: 'MyGraphQL',
} satisfies GraphQLTagPluckOptions,
},
},
},
};

export default config;
16 changes: 16 additions & 0 deletions examples/multiple-projects-single-file/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "@graphql-eslint/example-multiple-projects-single-file",
"version": "0.0.0",
"author": "Dimitri POSTOLOV",
"private": true,
"scripts": {
"lint": "eslint ."
},
"dependencies": {
"graphql": "16.8.0"
},
"devDependencies": {
"@graphql-eslint/eslint-plugin": "workspace:*",
"eslint": "8.48.0"
}
}
32 changes: 32 additions & 0 deletions examples/multiple-projects-single-file/query.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { gql } from 'graphql-tag';
import { custom } from 'custom-graphql-tag';

/* GraphQL */ `
fragment UserFields on User {
firstname
lastname
}
`;

gql`
{
user {
...UserFields
}
}
`;

/* MyGraphQL */ `
fragment SecondUserFields on User {
firstName
lastName
}
`;

custom`
{
users {
...SecondUserFields
}
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
type User {
firstname: String
lastname: String
}

type Query {
user: User
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
type User {
firstName: String
lastName: String
}

type Query {
users: [User]
}
7 changes: 7 additions & 0 deletions packages/plugin/__tests__/examples.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,11 @@ describe('Examples', () => {
expect(countErrors(results)).toBe(4);
testSnapshot(results);
});

it('should work in multiple projects within a single file', () => {
const cwd = join(CWD, 'examples/multiple-projects-single-file');
const results = getESLintOutput(cwd);
expect(countErrors(results)).toBe(4);
testSnapshot(results);
});
});
7 changes: 4 additions & 3 deletions packages/plugin/src/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Pointer } from './types.js';
const debug = debugFactory('graphql-eslint:operations');
const operationsCache = new ModuleCache<Source[]>();

const handleVirtualPath = (documents: Source[]): Source[] => {
const handleVirtualPath = (documents: Source[], project: GraphQLProjectConfig): Source[] => {
const filepathMap: Record<string, number> = Object.create(null);

return documents.map(source => {
Expand All @@ -19,9 +19,10 @@ const handleVirtualPath = (documents: Source[]): Source[] => {
}
filepathMap[location] ??= -1;
const index = (filepathMap[location] += 1);
const prefix = project.name && project.name !== 'default' ? `${project.name}_` : '';
return {
...source,
location: resolve(location, `${index}_document.graphql`),
location: resolve(location, `${index}_${prefix}document.graphql`),
};
});
};
Expand All @@ -45,7 +46,7 @@ export const getDocuments = (project: GraphQLProjectConfig): Source[] => {
const operationsPaths = fg.sync(project.documents as Pointer, { absolute: true });
debug('Operations pointers %O', operationsPaths);
}
siblings = handleVirtualPath(documents);
siblings = handleVirtualPath(documents, project);
operationsCache.set(documentsKey, siblings);
}

Expand Down
4 changes: 3 additions & 1 deletion packages/plugin/src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ export function parseForESLint(code: string, options: ParserOptions): GraphQLESL
if (typeof window === 'undefined') {
const gqlConfig = loadGraphQLConfig(options);
const realFilepath = filePath.replace(VIRTUAL_DOCUMENT_REGEX, '');
project = gqlConfig.getProjectForFile(realFilepath);
const projectMatch = filePath.match(VIRTUAL_DOCUMENT_REGEX);
const projectName = projectMatch ? projectMatch[1] : 'unknown'; // should never be 'unknown'
project = gqlConfig.projects[projectName] ?? gqlConfig.getProjectForFile(realFilepath);
documents = getDocuments(project);
} else {
documents = [
Expand Down
113 changes: 76 additions & 37 deletions packages/plugin/src/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
} from '@graphql-tools/graphql-tag-pluck';
import { asArray } from '@graphql-tools/utils';
import { Linter } from 'eslint';
import { GraphQLConfig } from 'graphql-config';
import { GraphQLConfig, GraphQLProjectConfig } from 'graphql-config';
import { loadOnDiskGraphQLConfig } from './graphql-config.js';
import { CWD, REPORT_ON_FIRST_CHARACTER, truthy } from './utils.js';

Expand All @@ -21,6 +21,34 @@ let onDiskConfigLoaded = false;

const RELEVANT_KEYWORDS = ['gql', 'graphql', 'GraphQL'] as const;

function getMatchingProjects(onDiskConfig: GraphQLConfig, filePath: string) {
const matchingProjects: Record<string, GraphQLProjectConfig> = {};

// Default project for file
// - ok if project is null
// - getProjectForFile may be mocked in tests
const project = onDiskConfig?.getProjectForFile(filePath);
const projectName = project?.name ?? 'default';
if (!matchingProjects[projectName]) {
matchingProjects[projectName] = project;
}

// If more than one project
if (onDiskConfig?.projects && Object.keys(onDiskConfig.projects).length > 1) {
// Reference:
// https://github.com/kamilkisiela/graphql-config/blob/423de0e07214ad6d800fb508a74951a5bfc045e6/src/config.ts#L143
for (const project of Object.values(onDiskConfig.projects)) {
if (project.match(filePath)) {
matchingProjects[project.name] = project;
} else if (!project.include && !project.exclude) {
matchingProjects[project.name] = project;
}
}
}

return matchingProjects;
}

export const processor = {
supportsAutofix: true,
preprocess(code, filePath) {
Expand All @@ -30,44 +58,55 @@ export const processor = {
}

let keywords: ReadonlyArray<string> = RELEVANT_KEYWORDS;
const pluckConfig: GraphQLTagPluckOptions =
onDiskConfig?.getProjectForFile(filePath).extensions.pluckConfig;

if (pluckConfig) {
const {
modules = [],
globalGqlIdentifierName = ['gql', 'graphql'],
gqlMagicComment = 'GraphQL',
} = pluckConfig;

keywords = [
...new Set(
[
...modules.map(({ identifier }) => identifier),
...asArray(globalGqlIdentifierName),
gqlMagicComment,
].filter(truthy),
),
];
}

if (keywords.every(keyword => !code.includes(keyword))) {
return [code];
}

try {
const sources = gqlPluckFromCodeStringSync(filePath, code, {
skipIndent: true,
...pluckConfig,
});

const blocks: Block[] = sources.map(item => ({
filename: 'document.graphql',
text: item.body,
lineOffset: item.locationOffset.line - 1,
// @ts-expect-error -- `index` field exist but show ts error
offset: item.locationOffset.index + 1,
}));
const blocks: Block[] = [];

const projects = getMatchingProjects(onDiskConfig, filePath);

for (const [projectName, project] of Object.entries(projects)) {
const pluckConfig: GraphQLTagPluckOptions = project?.extensions.pluckConfig;

if (pluckConfig) {
const {
modules = [],
globalGqlIdentifierName = ['gql', 'graphql'],
gqlMagicComment = 'GraphQL',
} = pluckConfig;

keywords = [
...new Set(
[
...modules.map(({ identifier }) => identifier),
...asArray(globalGqlIdentifierName),
gqlMagicComment,
].filter(truthy),
),
];
}

if (keywords.every(keyword => !code.includes(keyword))) {
continue;
}

const sources = gqlPluckFromCodeStringSync(filePath, code, {
skipIndent: true,
...pluckConfig,
});

for (const item of sources) {
// We avoid an unnecessary prefix when it's falsy or default
const prefix = projectName && projectName !== 'default' ? `${projectName}_` : '';
blocks.push({
filename: `${prefix}document.graphql`,
text: item.body,
lineOffset: item.locationOffset.line - 1,
// @ts-expect-error -- `index` field exist but show ts error
offset: item.locationOffset.index + 1,
});
}
}

blocksMap.set(filePath, blocks);

return [...blocks, code /* source code must be provided and be last */];
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const logger = {

export const normalizePath = (path: string): string => (path || '').replace(/\\/g, '/');

export const VIRTUAL_DOCUMENT_REGEX = /\/\d+_document.graphql$/;
export const VIRTUAL_DOCUMENT_REGEX = /\/\d+_([a-zA-Z0-9]*)?_?document.graphql$/;

export const CWD = process.cwd();

Expand Down
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.