diff --git a/.changeset/curvy-cameras-fetch.md b/.changeset/curvy-cameras-fetch.md new file mode 100644 index 00000000000..8d3ca0d669e --- /dev/null +++ b/.changeset/curvy-cameras-fetch.md @@ -0,0 +1,5 @@ +--- +'@graphql-eslint/eslint-plugin': minor +--- + +NEW RULE: no-hashtag-description diff --git a/.changeset/sweet-colts-jam.md b/.changeset/sweet-colts-jam.md new file mode 100644 index 00000000000..3416ed6d2e1 --- /dev/null +++ b/.changeset/sweet-colts-jam.md @@ -0,0 +1,5 @@ +--- +'@graphql-eslint/eslint-plugin': patch +--- + +Fixed missing `loc` property when rawNode is called on Document node diff --git a/docs/README.md b/docs/README.md index defadb4a840..e8042b938a4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ - [`unique-fragment-name`](./rules/unique-fragment-name.md) - [`unique-operation-name`](./rules/unique-operation-name.md) - [`validate-against-schema`](./rules/validate-against-schema.md) +- [`no-hashtag-description`](./rules/no-hashtag-description.md) - [`no-anonymous-operations`](./rules/no-anonymous-operations.md) - [`no-operation-name-suffix`](./rules/no-operation-name-suffix.md) - [`require-deprecation-reason`](./rules/require-deprecation-reason.md) diff --git a/docs/rules/no-hashtag-description.md b/docs/rules/no-hashtag-description.md new file mode 100644 index 00000000000..ebbc4e87d1e --- /dev/null +++ b/docs/rules/no-hashtag-description.md @@ -0,0 +1,50 @@ +# `no-hashtag-description` + +- Category: `Best Practices` +- Rule name: `@graphql-eslint/no-hashtag-description` +- Requires GraphQL Schema: `false` [ℹ️](../../README.md#extended-linting-rules-with-graphql-schema) +- Requires GraphQL Operations: `false` [ℹ️](../../README.md#extended-linting-rules-with-siblings-operations) + +Requires to use """ or " for adding a GraphQL description instead of #. +This rule allows you to use hashtag for comments, as long as it's not attached to a AST definition. + +## Usage Examples + +### Incorrect + +```graphql +# eslint @graphql-eslint/no-hashtag-description: ["error"] + +# Represents a user +type User { + id: ID! + name: String +} +``` + +### Correct + +```graphql +# eslint @graphql-eslint/no-hashtag-description: ["error"] + +" Represents a user " +type User { + id: ID! + name: String +} +``` + +### Correct + +```graphql +# eslint @graphql-eslint/no-hashtag-description: ["error"] + +# This file defines the basic User type. +# This comment is valid because it's not attached specifically to an AST object. + +" Represents a user " +type User { + id: ID! # This one is also valid, since it comes after the AST object + name: String +} +``` \ No newline at end of file diff --git a/packages/plugin/src/estree-parser/converter.ts b/packages/plugin/src/estree-parser/converter.ts index bbbdb125a06..3430efb135e 100644 --- a/packages/plugin/src/estree-parser/converter.ts +++ b/packages/plugin/src/estree-parser/converter.ts @@ -1,6 +1,6 @@ import { convertDescription, convertLocation, convertRange, extractCommentsFromAst } from './utils'; import { GraphQLESTreeNode, SafeGraphQLType } from './estree-ast'; -import { ASTNode, TypeNode, TypeInfo, visit, visitWithTypeInfo, Location, Kind } from 'graphql'; +import { ASTNode, TypeNode, TypeInfo, visit, visitWithTypeInfo, Location, Kind, DocumentNode } from 'graphql'; import { Comment } from 'estree'; export function convertToESTree( @@ -71,7 +71,8 @@ const convertNode = (typeInfo?: TypeInfo) => ( rawNode: () => { if (!parent || key === undefined) { if (node && (node as any).definitions) { - return { + return { + loc: gqlLocation, kind: Kind.DOCUMENT, definitions: (node as any).definitions.map(d => d.rawNode()), }; @@ -96,7 +97,8 @@ const convertNode = (typeInfo?: TypeInfo) => ( rawNode: () => { if (!parent || key === undefined) { if (node && (node as any).definitions) { - return { + return { + loc: gqlLocation, kind: Kind.DOCUMENT, definitions: (node as any).definitions.map(d => d.rawNode()), }; diff --git a/packages/plugin/src/rules/index.ts b/packages/plugin/src/rules/index.ts index 614e6aa44e5..d44715e5fa0 100644 --- a/packages/plugin/src/rules/index.ts +++ b/packages/plugin/src/rules/index.ts @@ -13,6 +13,7 @@ import inputName from './input-name'; import uniqueFragmentName from './unique-fragment-name'; import uniqueOperationName from './unique-operation-name'; import noDeprecated from './no-deprecated'; +import noHashtagDescription from './no-hashtag-description'; import { GraphQLESLintRule } from '../types'; import { GRAPHQL_JS_VALIDATIONS } from './graphql-js-validation'; @@ -21,6 +22,7 @@ export const rules: Record = { 'unique-fragment-name': uniqueFragmentName, 'unique-operation-name': uniqueOperationName, 'validate-against-schema': validate, + 'no-hashtag-description': noHashtagDescription, 'no-anonymous-operations': noAnonymousOperations, 'no-operation-name-suffix': noOperationNameSuffix, 'require-deprecation-reason': requireDeprecationReason, diff --git a/packages/plugin/src/rules/no-hashtag-description.ts b/packages/plugin/src/rules/no-hashtag-description.ts new file mode 100644 index 00000000000..7d9854f9491 --- /dev/null +++ b/packages/plugin/src/rules/no-hashtag-description.ts @@ -0,0 +1,117 @@ +import { GraphQLESLintRule } from '../types'; +import { TokenKind } from 'graphql'; + +const HASHTAG_COMMENT = 'HASHTAG_COMMENT'; + +const rule: GraphQLESLintRule = { + meta: { + messages: { + [HASHTAG_COMMENT]: `Using hashtag (#) for adding GraphQL descriptions is not allowed. Prefer using """ for multiline, or " for a single line description.`, + }, + docs: { + description: `Requires to use """ or " for adding a GraphQL description instead of #.\nThis rule allows you to use hashtag for comments, as long as it's not attached to a AST definition.`, + category: 'Best Practices', + url: `https://github.com/dotansimha/graphql-eslint/blob/master/docs/rules/no-hashtag-description.md`, + requiresSchema: false, + requiresSiblings: false, + examples: [ + { + title: 'Incorrect', + code: /* GraphQL */ ` + # Represents a user + type User { + id: ID! + name: String + } + `, + }, + { + title: 'Correct', + code: /* GraphQL */ ` + " Represents a user " + type User { + id: ID! + name: String + } + `, + }, + { + title: 'Correct', + code: /* GraphQL */ ` + # This file defines the basic User type. + # This comment is valid because it's not attached specifically to an AST object. + + " Represents a user " + type User { + id: ID! # This one is also valid, since it comes after the AST object + name: String + } + `, + }, + ], + }, + type: 'suggestion', + }, + create(context) { + return { + Document(node) { + if (node) { + const rawNode = node.rawNode(); + + if (rawNode && rawNode.loc && rawNode.loc.startToken) { + let token = rawNode.loc.startToken; + + while (token !== null) { + if (token.kind === TokenKind.COMMENT && token.next && token.prev) { + if ( + token.prev.kind !== TokenKind.SOF && + token.prev.kind !== TokenKind.COMMENT && + token.next.kind !== TokenKind.COMMENT && + token.next.line - token.line > 1 && + token.prev.line !== token.line + ) { + context.report({ + messageId: HASHTAG_COMMENT, + loc: { + start: { + line: token.line, + column: token.column, + }, + end: { + line: token.line, + column: token.column, + }, + }, + }); + } else if ( + token.next.kind !== TokenKind.COMMENT && + token.next.kind !== TokenKind.EOF && + token.next.line - token.line < 2 && + token.prev.line !== token.line + ) { + context.report({ + messageId: HASHTAG_COMMENT, + loc: { + start: { + line: token.line, + column: token.column, + }, + end: { + line: token.line, + column: token.column, + }, + }, + }); + } + } + + token = token.next; + } + } + } + }, + }; + }, +}; + +export default rule; diff --git a/packages/plugin/tests/no-hashtag-description.spec.ts b/packages/plugin/tests/no-hashtag-description.spec.ts new file mode 100644 index 00000000000..31852468457 --- /dev/null +++ b/packages/plugin/tests/no-hashtag-description.spec.ts @@ -0,0 +1,99 @@ +import { GraphQLRuleTester } from '../src/testkit'; +import rule from '../src/rules/no-hashtag-description'; +import { Kind } from 'graphql'; + +const ruleTester = new GraphQLRuleTester(); + +ruleTester.runGraphQLTests('no-hashtag-description', rule, { + valid: [ + { + code: /* GraphQL */ ` + " test " + type Query { + foo: String + } + `, + }, + { + code: /* GraphQL */ ` + # Test + + type Query { + foo: String + } + `, + }, + { + code: `#import t + + type Query { + foo: String + } + `, + }, + { + code: /* GraphQL */ ` + # multiline + # multiline + # multiline + # multiline + + type Query { + foo: String + } + `, + }, + { + code: /* GraphQL */ ` + type Query { + foo: String + } + + # Test + `, + }, + { + code: /* GraphQL */ ` + type Query { + foo: String # this is also fine, comes after the definition + } + `, + }, + { + code: /* GraphQL */ ` + type Query { # this is also fine, comes after the definition + foo: String + } # this is also fine, comes after the definition + `, + }, + { + code: /* GraphQL */ ` + type Query { + foo: String + } + + # Test + `, + }, + ], + invalid: [ + { + code: /* GraphQL */ ` + # Test + type Query { + foo: String + } + `, + errors: [{ messageId: 'HASHTAG_COMMENT' }], + }, + { + code: /* GraphQL */ ` + type Query { + # Test + foo: String + } + `, + errors: [{ messageId: 'HASHTAG_COMMENT' }], + }, + ], +});