diff --git a/src/configuration.js b/src/configuration.js index 9c3627a..71e234d 100644 --- a/src/configuration.js +++ b/src/configuration.js @@ -14,6 +14,7 @@ export class Configuration { - customRulePaths: [string array] path to additional custom rules to be loaded - commentDescriptions: [boolean] use old way of defining descriptions in GraphQL SDL - oldImplementsSyntax: [boolean] use old way of defining implemented interfaces in GraphQL SDL + - fix: [boolean] automatically fix errors where possible */ constructor(schema, options = {}) { const defaultOptions = { @@ -22,6 +23,7 @@ export class Configuration { commentDescriptions: false, oldImplementsSyntax: false, ignore: {}, + fix: false, }; this.schema = schema; @@ -120,6 +122,15 @@ export class Configuration { validate() { const issues = []; + if (this.options.stdin && this.options.fix) { + this.options.fix = false; + issues.push({ + message: `--fix and --stdin are incompatible; --fix will be ignored`, + field: 'fix', + type: 'warning', + }); + } + let rules; try { diff --git a/src/fix.js b/src/fix.js new file mode 100644 index 0000000..b8db6d6 --- /dev/null +++ b/src/fix.js @@ -0,0 +1,54 @@ +import { writeFileSync } from 'fs'; + +// Returns {: } for only those paths which changed. +export function fixSchema(errors, sourceMap) { + // Select fixable errors, and sort in by start-location of the fix. + errors = errors.filter((error) => error.fix != null); + if (errors.length === 0) { + return {}; + } + errors.sort((first, second) => first.fix.loc.start - second.fix.loc.start); + + // Apply the fixes by iterating through files, walking the errors list for + // each at the same time. + let fileStartOffset = 0; + const fixedPaths = {}; + let errorIndex = 0; + Object.entries(sourceMap.sourceFiles).forEach(([path, text]) => { + const fileEndOffset = fileStartOffset + text.length; + const fixedParts = []; + let currentFileOffset = 0; + while ( + errorIndex < errors.length && + errors[errorIndex].fix.loc.start <= fileEndOffset + ) { + const { loc, replacement } = errors[errorIndex].fix; + fixedParts.push( + text.slice(currentFileOffset, loc.start - fileStartOffset) + ); + fixedParts.push(replacement); + currentFileOffset = loc.end - fileStartOffset; + errorIndex++; + } + + if (fixedParts.length > 0) { + fixedParts.push(text.slice(currentFileOffset)); + fixedPaths[path] = fixedParts.join(''); + } + + fileStartOffset = fileEndOffset + 1; // sourceMap adds a newline in between + }); + + return fixedPaths; +} + +// Given output from fixSchema, write the fixes to disk. +export function applyFixes(fixes) { + for (const [path, text] of Object.entries(fixes)) { + try { + writeFileSync(path, text); + } catch (e) { + console.error(e.message); + } + } +} diff --git a/src/rules/descriptions_are_capitalized.js b/src/rules/descriptions_are_capitalized.js index 155a832..2c782f2 100644 --- a/src/rules/descriptions_are_capitalized.js +++ b/src/rules/descriptions_are_capitalized.js @@ -22,11 +22,26 @@ export function DescriptionsAreCapitalized(configuration, context) { const fieldName = node.name.value; const parentName = ancestors[ancestors.length - 1].name.value; + let fix; + if (node.description != null) { + // Supporting autofixes for comment-descriptions is a bunch of extra + // work, just do it for real descriptions. + let start = node.description.loc.start; + while (node.description.loc.source.body[start] === '"') { + start++; + } + fix = { + loc: { start, end: start + 1 }, + replacement: node.description.loc.source.body[start].toUpperCase(), + }; + } + context.reportError( new ValidationError( 'descriptions-are-capitalized', `The description for field \`${parentName}.${fieldName}\` should be capitalized.`, - [node] + [node], + fix ) ); }, diff --git a/src/rules/enum_values_all_caps.js b/src/rules/enum_values_all_caps.js index 76fe171..a19427a 100644 --- a/src/rules/enum_values_all_caps.js +++ b/src/rules/enum_values_all_caps.js @@ -11,7 +11,8 @@ export function EnumValuesAllCaps(context) { new ValidationError( 'enum-values-all-caps', `The enum value \`${parentName}.${enumValueName}\` should be uppercase.`, - [node] + [node], + { loc: node.name.loc, replacement: enumValueName.toUpperCase() } ) ); } diff --git a/src/rules/enum_values_sorted_alphabetically.js b/src/rules/enum_values_sorted_alphabetically.js index 6caaba8..c287f1b 100644 --- a/src/rules/enum_values_sorted_alphabetically.js +++ b/src/rules/enum_values_sorted_alphabetically.js @@ -1,22 +1,22 @@ import { ValidationError } from '../validation_error'; -import listIsAlphabetical from '../util/listIsAlphabetical'; +import alphabetizeNodes from '../util/alphabetizeNodes'; export function EnumValuesSortedAlphabetically(context) { return { EnumTypeDefinition(node, key, parent, path, ancestors) { - const enumValues = node.values.map((val) => { - return val.name.value; - }); - - const { isSorted, sortedList } = listIsAlphabetical(enumValues); + const { isSorted, sortedNames, fix } = alphabetizeNodes( + node.values, + (val) => val.name.value + ); if (!isSorted) { context.reportError( new ValidationError( 'enum-values-sorted-alphabetically', `The enum \`${node.name.value}\` should be sorted alphabetically. ` + - `Expected sorting: ${sortedList.join(', ')}`, - [node] + `Expected sorting: ${sortedNames.join(', ')}`, + [node], + fix ) ); } diff --git a/src/rules/fields_are_camel_cased.js b/src/rules/fields_are_camel_cased.js index 8375fac..64d45fd 100644 --- a/src/rules/fields_are_camel_cased.js +++ b/src/rules/fields_are_camel_cased.js @@ -2,6 +2,11 @@ import { ValidationError } from '../validation_error'; const camelCaseTest = RegExp('^[a-z][a-zA-Z0-9]*$'); +function makeCamelCase(name) { + name = name.replace(/_+([^_])/g, (match, g1) => g1.toUpperCase()); + return name[0].toLowerCase() + name.slice(1); +} + export function FieldsAreCamelCased(context) { return { FieldDefinition(node, key, parent, path, ancestors) { @@ -12,7 +17,8 @@ export function FieldsAreCamelCased(context) { new ValidationError( 'fields-are-camel-cased', `The field \`${parentName}.${fieldName}\` is not camel cased.`, - [node] + [node], + { loc: node.name.loc, replacement: makeCamelCase(fieldName) } ) ); } diff --git a/src/rules/input_object_fields_sorted_alphabetically.js b/src/rules/input_object_fields_sorted_alphabetically.js index f37403f..d5dc772 100644 --- a/src/rules/input_object_fields_sorted_alphabetically.js +++ b/src/rules/input_object_fields_sorted_alphabetically.js @@ -1,19 +1,22 @@ import { ValidationError } from '../validation_error'; -import listIsAlphabetical from '../util/listIsAlphabetical'; +import alphabetizeNodes from '../util/alphabetizeNodes'; export function InputObjectFieldsSortedAlphabetically(context) { return { InputObjectTypeDefinition(node) { - const fieldList = (node.fields || []).map((field) => field.name.value); - const { isSorted, sortedList } = listIsAlphabetical(fieldList); + const { isSorted, sortedNames, fix } = alphabetizeNodes( + node.fields || [], + (field) => field.name.value + ); if (!isSorted) { context.reportError( new ValidationError( 'input-object-fields-sorted-alphabetically', `The fields of input type \`${node.name.value}\` should be sorted alphabetically. ` + - `Expected sorting: ${sortedList.join(', ')}`, - [node] + `Expected sorting: ${sortedNames.join(', ')}`, + [node], + fix ) ); } diff --git a/src/rules/input_object_values_are_camel_cased.js b/src/rules/input_object_values_are_camel_cased.js index f1af55a..4d6ef10 100644 --- a/src/rules/input_object_values_are_camel_cased.js +++ b/src/rules/input_object_values_are_camel_cased.js @@ -2,6 +2,11 @@ import { ValidationError } from '../validation_error'; const camelCaseTest = RegExp('^[a-z][a-zA-Z0-9]*$'); +function makeCamelCase(name) { + name = name.replace(/_+([^_])/g, (match, g1) => g1.toUpperCase()); + return name[0].toLowerCase() + name.slice(1); +} + export function InputObjectValuesAreCamelCased(context) { return { InputValueDefinition(node, key, parent, path, ancestors) { @@ -15,7 +20,8 @@ export function InputObjectValuesAreCamelCased(context) { new ValidationError( 'input-object-values-are-camel-cased', `The input value \`${inputObjectName}.${inputValueName}\` is not camel cased.`, - [node] + [node], + { loc: node.name.loc, replacement: makeCamelCase(fieldName) } ) ); } diff --git a/src/rules/type_fields_sorted_alphabetically.js b/src/rules/type_fields_sorted_alphabetically.js index c38c754..71b8c42 100644 --- a/src/rules/type_fields_sorted_alphabetically.js +++ b/src/rules/type_fields_sorted_alphabetically.js @@ -1,19 +1,22 @@ import { ValidationError } from '../validation_error'; -import listIsAlphabetical from '../util/listIsAlphabetical'; +import alphabetizeNodes from '../util/alphabetizeNodes'; export function TypeFieldsSortedAlphabetically(context) { return { ObjectTypeDefinition(node) { - const fieldList = (node.fields || []).map((field) => field.name.value); - const { isSorted, sortedList } = listIsAlphabetical(fieldList); + const { isSorted, sortedNames, fix } = alphabetizeNodes( + node.fields || [], + (field) => field.name.value + ); if (!isSorted) { context.reportError( new ValidationError( 'type-fields-sorted-alphabetically', `The fields of object type \`${node.name.value}\` should be sorted alphabetically. ` + - `Expected sorting: ${sortedList.join(', ')}`, - [node] + `Expected sorting: ${sortedNames.join(', ')}`, + [node], + fix ) ); } diff --git a/src/rules/types_are_capitalized.js b/src/rules/types_are_capitalized.js index 1985732..36b9151 100644 --- a/src/rules/types_are_capitalized.js +++ b/src/rules/types_are_capitalized.js @@ -9,7 +9,11 @@ export function TypesAreCapitalized(context) { new ValidationError( 'types-are-capitalized', `The object type \`${typeName}\` should start with a capital letter.`, - [node.name] + [node.name], + { + loc: node.name.loc, + replacement: typeName[0].toUpperCase() + typeName.slice(1), + } ) ); } @@ -22,7 +26,11 @@ export function TypesAreCapitalized(context) { new ValidationError( 'types-are-capitalized', `The interface type \`${typeName}\` should start with a capital letter.`, - [node.name] + [node.name], + { + loc: node.name.loc, + replacement: typeName[0].toUpperCase() + typeName.slice(1), + } ) ); } diff --git a/src/runner.js b/src/runner.js index 3845060..f8f1f00 100644 --- a/src/runner.js +++ b/src/runner.js @@ -4,6 +4,7 @@ import { Command } from 'commander'; import { Configuration } from './configuration.js'; import { loadSchema } from './schema.js'; import { loadOptionsFromConfigDir } from './options.js'; +import { applyFixes, fixSchema } from './fix.js'; import figures from './figures'; import chalk from 'chalk'; @@ -42,6 +43,10 @@ export async function run(stdout, stdin, stderr, argv) { '--old-implements-syntax', 'use old way of defining implemented interfaces in GraphQL SDL' ) + .option( + '--fix', + 'when possible, automatically update input files to fix errors; incompatible with --stdin' + ) // DEPRECATED - This code should be removed in v1.0.0. .option( '-o, --only ', @@ -107,6 +112,10 @@ export async function run(stdout, stdin, stderr, argv) { const errors = validateSchemaDefinition(schema, rules, configuration); const groupedErrors = groupErrorsBySchemaFilePath(errors, schema.sourceMap); + if (configuration.options.fix) { + applyFixes(fixSchema(errors, schema.sourceMap)); + } + stdout.write(formatter(groupedErrors)); return errors.length > 0 ? 1 : 0; @@ -168,6 +177,10 @@ function getOptionsFromCommander(commander) { options.oldImplementsSyntax = commander.oldImplementsSyntax; } + if (commander.fix) { + options.fix = commander.fix; + } + if (commander.args && commander.args.length) { options.schemaPaths = commander.args; } diff --git a/src/util/alphabetizeNodes.js b/src/util/alphabetizeNodes.js new file mode 100644 index 0000000..a40c2cc --- /dev/null +++ b/src/util/alphabetizeNodes.js @@ -0,0 +1,150 @@ +import { TokenKind } from 'graphql/language/tokenKind'; + +// Return the start-character-offset of the logical-block, for re-ordering +// purposes, containing this node, defined as the end of the last token on the +// line containing the most recent non-comment token. For more context see +// comments near blockBoundaries, below. +function blockStart(node) { + let token = node.loc.startToken; + if (token == null) { + return node.loc.start; + } + + // walk to just after the next non-comment token + while (token.prev && token.prev.kind === TokenKind.COMMENT) { + token = token.prev; + } + + const line = token.line; + if (token.prev && token.prev.line === line) { + // if there are other tokens on the same line, walk back forwards to + // the end of that line if needed. + while (token && token.kind === TokenKind.COMMENT && token.line === line) { + token = token.next; + } + } + + return token.prev.end; +} + +// Return the end-character-offset of the logical-block, for re-ordering +// purposes, containing this node, defined as the end of the last token before +// the next newline or non-comment token. For more context see comments near +// blockBoundaries, below. +function blockEnd(node) { + let token = node.loc.endToken; + if (token == null) { + return node.loc.end; + } + + const line = token.line; + while ( + token.next && + token.next.kind === TokenKind.COMMENT && + token.next.line === line + ) { + token = token.next; + } + + let end = token.end; + while ('\r\n'.includes(node.loc.source.body[end + 1])) { + end++; + } + + return end; +} + +/** + * Return information about how a list of nodes should be sorted. + * + * Determining if the list is sorted is fairly easy; the complex part of this + * is constructing the fixed text, which requires moving comments and + * whitespace around too. The details of the algorithm are discussed inline. + * + * Arugments: + * nodes: a list of GraphQL nodes + * nameFunc: a function to get the name of each node, e.g. n => n.name.value. + * + * Returns: {isSorted: true} if the list is sorted correctly, or + * { + * isSorted: false, + * sortedNames: String[], + * fix: , + * } + * if not. + */ +export default function alphabetizeNodes(nodes, nameFunc) { + if (nodes.length < 1) { + return { isSorted: true }; + } + + const withIndices = nodes.map((node, i) => { + return { node, index: i, name: nameFunc(node) }; + }); + withIndices.sort((a, b) => { + if (a.name < b.name) { + return -1; + } else if (a.name > b.name) { + return 1; + } else { + return 0; + } + }); + + if (withIndices.every((item, i) => item.index === i)) { + return { isSorted: true }; + } + + const sortedNames = withIndices.map((item) => item.name); + + // Now construct the fix. We need to move each node's comments and + // whitespace along with it, so we find the boundaries of the "blocks" + // containing each node, and manipulate those blocks instead. The blocks + // include the full-line comments preceding the node, and any comments on + // the same line following the node. We also attach newlines to the + // preceding node. + const blockBoundaries = [blockStart(nodes[0])]; + blockBoundaries.push(...nodes.map(blockEnd)); + const blocks = blockBoundaries + .slice(0, -1) + .map((start, i) => + nodes[i].loc.source.body.slice(start, blockBoundaries[i + 1]) + ); + + // The block-based approach doesn't do a great job with the whitespace at + // the start and end of the list. (This is relevant when you have, for + // example, a double-newline in between elements, and a single-newline + // before the first and after the last.) So we swap the old and new last + // blocks' trailing whitespace (including commas), and similarly for + // the first blocks' leading whitespace. + // See examples in test/rules/enum_values_sorted_alphabetically.js. + const getTrailingNewlines = (text) => text.match(/[ ,\r\n]*$/)[0]; + const oldLastIndex = blocks.length - 1; + const newLastIndex = withIndices[oldLastIndex].index; + const oldLastBlock = blocks[oldLastIndex]; + const newLastBlock = blocks[newLastIndex]; + blocks[oldLastIndex] = + oldLastBlock.replace(/[ ,\r\n]*$/, '') + getTrailingNewlines(newLastBlock); + blocks[newLastIndex] = + newLastBlock.replace(/[ ,\r\n]*$/, '') + getTrailingNewlines(oldLastBlock); + + const getLeadingNewlines = (text) => text.match(/^[ ,\r\n]*/)[0]; + const oldFirstIndex = 0; + const newFirstIndex = withIndices[0].index; + const oldFirstBlock = blocks[oldFirstIndex]; + const newFirstBlock = blocks[newFirstIndex]; + blocks[oldFirstIndex] = + getLeadingNewlines(newFirstBlock) + oldFirstBlock.replace(/^[ ,\r\n]*/, ''); + blocks[newFirstIndex] = + getLeadingNewlines(oldFirstBlock) + newFirstBlock.replace(/^[ ,\r\n]*/, ''); + + // Finally, assemble the fix! + const loc = { start: blockBoundaries[0], end: blockBoundaries[nodes.length] }; + const replacement = withIndices.map((item) => blocks[item.index]).join(''); + + return { + isSorted: false, + sortedNames, + fix: { loc, replacement }, + }; +} diff --git a/src/util/listIsAlphabetical.js b/src/util/listIsAlphabetical.js deleted file mode 100644 index b42bf3d..0000000 --- a/src/util/listIsAlphabetical.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @summary Returns `true` if two arrays have the same item values in the same order. - */ -function arraysEqual(a, b) { - for (var i = 0; i < a.length; ++i) { - if (a[i] !== b[i]) return false; - } - return true; -} - -/** - * @summary Returns `true` if the list is in alphabetical order, - * or an alphabetized list if not - * @param {String[]} list Array of strings - * @return {Object} { isSorted: Bool, sortedList: String[] } - */ -export default function listIsAlphabetical(list) { - const sortedList = list.slice().sort(); - return { - isSorted: arraysEqual(list, sortedList), - sortedList, - }; -} diff --git a/src/validation_error.js b/src/validation_error.js index 2ac74ce..0b79f6d 100644 --- a/src/validation_error.js +++ b/src/validation_error.js @@ -1,9 +1,14 @@ import { GraphQLError } from 'graphql/error'; export class ValidationError extends GraphQLError { - constructor(ruleName, message, nodes) { + // fix is {loc: {start: number, end: number}, replacement: string} + // frequently loc will be `nodes[0].loc` but it need not be. + constructor(ruleName, message, nodes, fix) { super(message, nodes); this.ruleName = ruleName; + if (fix != null) { + this.fix = fix; + } } } diff --git a/test/assertions.js b/test/assertions.js index 8db2b08..77bd7ad 100644 --- a/test/assertions.js +++ b/test/assertions.js @@ -5,6 +5,8 @@ import { buildASTSchema } from 'graphql/utilities/buildASTSchema'; import { validateSchemaDefinition } from '../src/validator.js'; import { Configuration } from '../src/configuration.js'; import { Schema } from '../src/schema.js'; +import { SourceMap } from '../src/source_map.js'; +import { fixSchema } from '../src/fix.js'; const DefaultSchema = ` "Query root" @@ -14,22 +16,39 @@ const DefaultSchema = ` } `; -export function expectFailsRule(rule, schemaSDL, expectedErrors = []) { - return expectFailsRuleWithConfiguration(rule, schemaSDL, {}, expectedErrors); +export function expectFailsRule(rule, schemaSDL, expectedErrors = [], fixed) { + return expectFailsRuleWithConfiguration( + rule, + schemaSDL, + {}, + expectedErrors, + fixed + ); } export function expectFailsRuleWithConfiguration( rule, schemaSDL, configurationOptions, - expectedErrors = [] + expectedErrors = [], + expectedFixedSDL = null ) { const errors = validateSchemaWithRule(rule, schemaSDL, configurationOptions); assert(errors.length > 0, "Expected rule to fail but didn't"); + let actualErrors = errors; + if (expectedFixedSDL != null) { + // If expectedFixedSDL is provided, assert about that rather than + // errors[].fix. + actualErrors = errors.map((error) => { + const { fix, ...rest } = error; + return rest; + }); + } + assert.deepEqual( - errors, + actualErrors, expectedErrors.map((expectedError) => { return Object.assign(expectedError, { ruleName: rule.name @@ -39,6 +58,11 @@ export function expectFailsRuleWithConfiguration( }); }) ); + + if (expectedFixedSDL != null) { + const fixedFiles = fixSchema(errors, new SourceMap({ file: schemaSDL })); + assert.equal(fixedFiles.file, expectedFixedSDL); + } } export function expectPassesRule(rule, schemaSDL) { diff --git a/test/fix.js b/test/fix.js new file mode 100644 index 0000000..8c8c848 --- /dev/null +++ b/test/fix.js @@ -0,0 +1,90 @@ +import assert from 'assert'; +import { parse } from 'graphql'; +import { loadSchema } from '../src/schema.js'; +import { fixSchema } from '../src/fix.js'; +import { ValidationError } from '../src/validation_error.js'; + +function assertItemsEqual(expected, actual) { + expected = expected.slice(); + actual = actual.slice(); + expected.sort(); + actual.sort(); + assert.deepEqual(expected, actual); +} + +describe('fixSchema', () => { + it('applies fixes to a single-file schema', async () => { + const schemaPath = `${__dirname}/fixtures/schema.graphql`; + const schema = await loadSchema({ schemaPaths: [schemaPath] }); + const ast = parse(schema.definition); + const node = ast.definitions[0].fields[0].name; + const errors = [ + new ValidationError( + 'fake-rule', + 'field-names should be more than one letter', + [node], + { loc: node.loc, replacement: 'betterName' } + ), + ]; + + const fixedPaths = fixSchema(errors, schema.sourceMap); + + const expected = `type Query {\n` + ` betterName: String!\n` + `}\n`; + assert.deepEqual(Object.keys(fixedPaths), [schemaPath]); + assert.equal(fixedPaths[schemaPath], expected); + }); + + it('applies fixes to a multi-file schema', async () => { + const schemaDir = `${__dirname}/fixtures/schema`; + const schemaGlob = `${schemaDir}/*.graphql`; + const schema = await loadSchema({ schemaPaths: [schemaGlob] }); + const ast = parse(schema.definition); + + const usernameNode = ast.definitions.find( + (node) => + node.kind === 'ObjectTypeDefinition' && node.name.value === 'User' + ).fields[0]; + const obviousNode = ast.definitions.find( + (node) => + node.kind === 'ObjectTypeDefinition' && node.name.value === 'Obvious' + ); + const errors = [ + new ValidationError( + 'fake-rule', + 'this field should be optional', + [usernameNode], + { loc: usernameNode.loc, replacement: 'maybeUsername: String' } + ), + new ValidationError('fake-rule', "this isn't obvious", [obviousNode], { + loc: { start: obviousNode.loc.start, end: obviousNode.loc.start }, + replacement: `"""A node with a truly obvious purpose."""\n`, + }), + ]; + + const fixedPaths = fixSchema(errors, schema.sourceMap); + + const userPath = `${schemaDir}/user.graphql`; + const expectedUser = + `type User {\n` + + ` maybeUsername: String\n` + + ` email: String!\n` + + `}\n\n` + + `extend type Query {\n` + + ` viewer: User!\n` + + `}\n`; + const obviousPath = `${schemaDir}/obvious.graphql`; + const expectedObvious = + `"""A node with a truly obvious purpose."""\n` + + `type Obvious {\n` + + ` one: String!\n` + + ` two: Int\n` + + `}\n\n` + + `type DontPanic {\n` + + ` obvious: Boolean\n` + + `}\n`; + + assertItemsEqual(Object.keys(fixedPaths), [userPath, obviousPath]); + assert.equal(fixedPaths[userPath], expectedUser); + assert.equal(fixedPaths[obviousPath], expectedObvious); + }); +}); diff --git a/test/index.js b/test/index.js index fc5f81d..23965db 100644 --- a/test/index.js +++ b/test/index.js @@ -4,6 +4,7 @@ require('@babel/register'); // The tests, however, can and should be written with ECMAScript 2015. require('./configuration'); +require('./fix'); require('./runner'); require('./schema'); require('./source_map'); diff --git a/test/rules/descriptions_are_capitalized.js b/test/rules/descriptions_are_capitalized.js index e4cf81a..ca7197f 100644 --- a/test/rules/descriptions_are_capitalized.js +++ b/test/rules/descriptions_are_capitalized.js @@ -24,7 +24,16 @@ describe('DescriptionsAreCapitalized rule', () => { 'The description for field `Widget.name` should be capitalized.', locations: [{ line: 3, column: 9 }], }, - ] + ], + ` + type Widget { + "Widget name" + name: String! + + "Valid description" + other: Int + } + ` ); }); diff --git a/test/rules/enum_values_all_caps.js b/test/rules/enum_values_all_caps.js index aebb360..9fe6775 100644 --- a/test/rules/enum_values_all_caps.js +++ b/test/rules/enum_values_all_caps.js @@ -8,6 +8,7 @@ describe('EnumValuesAllCaps rule', () => { ` enum Stage { aaa + """A great enum value.""" bbb_bbb } `, @@ -20,7 +21,14 @@ describe('EnumValuesAllCaps rule', () => { message: 'The enum value `Stage.bbb_bbb` should be uppercase.', locations: [{ line: 4, column: 9 }], }, - ] + ], + ` + enum Stage { + AAA + """A great enum value.""" + BBB_BBB + } + ` ); }); diff --git a/test/rules/enum_values_sorted_alphabetically.js b/test/rules/enum_values_sorted_alphabetically.js index 20a2d37..b594db5 100644 --- a/test/rules/enum_values_sorted_alphabetically.js +++ b/test/rules/enum_values_sorted_alphabetically.js @@ -2,6 +2,18 @@ import { EnumValuesSortedAlphabetically } from '../../src/rules/enum_values_sort import { expectFailsRule, expectPassesRule } from '../assertions'; describe('EnumValuesSortedAlphabetically rule', () => { + it('allows enums that are sorted alphabetically ', () => { + expectPassesRule( + EnumValuesSortedAlphabetically, + ` + enum Stage { + AAA + ZZZ + } + ` + ); + }); + it('catches enums that are not sorted alphabetically', () => { expectFailsRule( EnumValuesSortedAlphabetically, @@ -17,19 +29,228 @@ describe('EnumValuesSortedAlphabetically rule', () => { 'The enum `Stage` should be sorted alphabetically. Expected sorting: AAA, ZZZ', locations: [{ line: 2, column: 7 }], }, - ] + ], + ` + enum Stage { + AAA + ZZZ + } + ` ); }); - it('allows enums that are sorted alphabetically ', () => { - expectPassesRule( + it('fixes enums that are not sorted alphabetically, handling spacing neatly', () => { + expectFailsRule( EnumValuesSortedAlphabetically, ` + enum Stage { + ZZZ + + YYY + + AAA + } + `, + [ + { + message: + 'The enum `Stage` should be sorted alphabetically. Expected sorting: AAA, YYY, ZZZ', + locations: [{ line: 2, column: 7 }], + }, + ], + ` enum Stage { AAA + + YYY + ZZZ } ` ); }); + + it('fixes enums that are not sorted alphabetically, handling multiple values on one line', () => { + expectFailsRule( + EnumValuesSortedAlphabetically, + ` + enum Stage { + ZZZ YYY AAA + } + `, + [ + { + message: + 'The enum `Stage` should be sorted alphabetically. Expected sorting: AAA, YYY, ZZZ', + locations: [{ line: 2, column: 7 }], + }, + ], + ` + enum Stage { + AAA YYY ZZZ + } + ` + ); + }); + + it('fixes enums that are not sorted alphabetically, handling multiple values on one line with commas', () => { + expectFailsRule( + EnumValuesSortedAlphabetically, + ` + enum Stage { + ZZZ, YYY, AAA + } + `, + [ + { + message: + 'The enum `Stage` should be sorted alphabetically. Expected sorting: AAA, YYY, ZZZ', + locations: [{ line: 2, column: 7 }], + }, + ], + ` + enum Stage { + AAA, YYY, ZZZ + } + ` + ); + }); + + it('fixes enums that are not sorted alphabetically, handling the whole enum on one line', () => { + expectFailsRule( + EnumValuesSortedAlphabetically, + ` + enum Stage { ZZZ YYY AAA } + `, + [ + { + message: + 'The enum `Stage` should be sorted alphabetically. Expected sorting: AAA, YYY, ZZZ', + locations: [{ line: 2, column: 7 }], + }, + ], + ` + enum Stage { AAA YYY ZZZ } + ` + ); + }); + + it('fixes enums that are not sorted alphabetically, handling descriptions', () => { + expectFailsRule( + EnumValuesSortedAlphabetically, + ` + enum Stage { + """The letter Z, three times.""" + ZZZ + + """The letter Y, three times.""" + YYY + + """The letter A, three times.""" + AAA + } + `, + [ + { + message: + 'The enum `Stage` should be sorted alphabetically. Expected sorting: AAA, YYY, ZZZ', + locations: [{ line: 2, column: 7 }], + }, + ], + ` + enum Stage { + """The letter A, three times.""" + AAA + + """The letter Y, three times.""" + YYY + + """The letter Z, three times.""" + ZZZ + } + ` + ); + }); + + it('fixes enums that are not sorted alphabetically, handling simple comments', () => { + expectFailsRule( + EnumValuesSortedAlphabetically, + ` + enum Stage { + # The letter Z, three times. + ZZZ + + # The letter Y, three times. + YYY + + # The letter A, three times. + AAA + } + `, + [ + { + message: + 'The enum `Stage` should be sorted alphabetically. Expected sorting: AAA, YYY, ZZZ', + locations: [{ line: 2, column: 7 }], + }, + ], + ` + enum Stage { + # The letter A, three times. + AAA + + # The letter Y, three times. + YYY + + # The letter Z, three times. + ZZZ + } + ` + ); + }); + + it('fixes complex comments on enums that are not sorted alphabetically', () => { + expectFailsRule( + EnumValuesSortedAlphabetically, + ` + enum Stage { # this comment sticks to the open-brace + # This comment goes with ZZZ + "Omega, you might say" + ZZZ # This one does too + + # This goes with AAA + + # As does this + """Alpha, or + a really good grade.""" + AAA # This stays with AAA too + + # But this sticks to the close-brace + } + `, + [ + { + message: + 'The enum `Stage` should be sorted alphabetically. Expected sorting: AAA, ZZZ', + locations: [{ line: 2, column: 7 }], + }, + ], + ` + enum Stage { # this comment sticks to the open-brace + # This goes with AAA + + # As does this + """Alpha, or + a really good grade.""" + AAA # This stays with AAA too + + # This comment goes with ZZZ + "Omega, you might say" + ZZZ # This one does too + + # But this sticks to the close-brace + } + ` + ); + }); }); diff --git a/test/rules/fields_are_camel_cased.js b/test/rules/fields_are_camel_cased.js index 26868c2..966b6d5 100644 --- a/test/rules/fields_are_camel_cased.js +++ b/test/rules/fields_are_camel_cased.js @@ -44,7 +44,33 @@ describe('FieldsAreCamelCased rule', () => { message: 'The field `Something.invalid_name` is not camel cased.', locations: [{ line: 18, column: 9 }], }, - ] + ], + ` + type A { + # Invalid + invalidName: String + + # Valid + thisIsValid: String + + # Valid + thisIDIsValid: String + + # Invalid + thisIsInvalid: String + } + + interface Something { + # Invalid + invalidName: String + + # Valid + thisIsValid: String + + # Valid + thisIDIsValid: String + } + ` ); }); }); diff --git a/test/rules/input_object_fields_sorted_alphabetically.js b/test/rules/input_object_fields_sorted_alphabetically.js index 3d13d66..9994acf 100644 --- a/test/rules/input_object_fields_sorted_alphabetically.js +++ b/test/rules/input_object_fields_sorted_alphabetically.js @@ -17,7 +17,13 @@ describe('InputObjectFieldsSortedAlphabetically rule', () => { 'The fields of input type `Stage` should be sorted alphabetically. Expected sorting: bar, foo', locations: [{ line: 2, column: 7 }], }, - ] + ], + ` + input Stage { + bar: String + foo: String + } + ` ); }); diff --git a/test/rules/input_object_values_are_camel_cased.js b/test/rules/input_object_values_are_camel_cased.js index 5692835..cc1c330 100644 --- a/test/rules/input_object_values_are_camel_cased.js +++ b/test/rules/input_object_values_are_camel_cased.js @@ -18,7 +18,15 @@ describe('InputObjectValuesAreCamelCased rule', () => { message: 'The input value `User.user_name` is not camel cased.', locations: [{ line: 3, column: 9 }], }, - ] + ], + ` + input User { + userName: String + + userID: String + withDescription: String + } + ` ); }); @@ -36,7 +44,12 @@ describe('InputObjectValuesAreCamelCased rule', () => { 'The input value `hello.argument_without_description` is not camel cased.', locations: [{ line: 3, column: 15 }], }, - ] + ], + ` + type A { + hello(argumentWithoutDescription: String): String + } + ` ); }); }); diff --git a/test/rules/type_fields_sorted_alphabetically.js b/test/rules/type_fields_sorted_alphabetically.js index 48ce2a5..759bbf0 100644 --- a/test/rules/type_fields_sorted_alphabetically.js +++ b/test/rules/type_fields_sorted_alphabetically.js @@ -17,7 +17,13 @@ describe('TypeFieldsSortedAlphabetically rule', () => { 'The fields of object type `Stage` should be sorted alphabetically. Expected sorting: bar, foo', locations: [{ line: 2, column: 7 }], }, - ] + ], + ` + type Stage { + bar: String + foo: String + } + ` ); }); diff --git a/test/rules/types_are_capitalized.js b/test/rules/types_are_capitalized.js index f8c2589..8649b3a 100644 --- a/test/rules/types_are_capitalized.js +++ b/test/rules/types_are_capitalized.js @@ -6,16 +6,21 @@ describe('TypesAreCapitalized rule', () => { expectFailsRule( TypesAreCapitalized, ` - type a { + type ab { a: String } `, [ { - message: 'The object type `a` should start with a capital letter.', + message: 'The object type `ab` should start with a capital letter.', locations: [{ line: 2, column: 12 }], }, - ] + ], + ` + type Ab { + a: String + } + ` ); }); @@ -32,7 +37,12 @@ describe('TypesAreCapitalized rule', () => { message: 'The interface type `a` should start with a capital letter.', locations: [{ line: 2, column: 17 }], }, - ] + ], + ` + interface A { + a: String + } + ` ); }); });