Skip to content

Commit 06244fb

Browse files
Add support for autofixing
The core API is fairly simple: errors can have a new field, `fix`, which includes a location (likely a graphql node's `.loc`) and a replacement. The runner then takes care of applying the fix, if the `--fix` option was specified. I added autofixers for all of the capitalization and alphabetization rules. (The remainder can't (or shouldn't) really be autofixed.) In general, capitalization was very easy, and alphabetization was quite tricky, but in the latter case it's mostly in a shared util that handles all of annoying syntax-transformation junk. Fixes #23.
1 parent ae0ce9b commit 06244fb

25 files changed

+732
-66
lines changed

src/configuration.js

+11
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export class Configuration {
1414
- customRulePaths: [string array] path to additional custom rules to be loaded
1515
- commentDescriptions: [boolean] use old way of defining descriptions in GraphQL SDL
1616
- oldImplementsSyntax: [boolean] use old way of defining implemented interfaces in GraphQL SDL
17+
- fix: [boolean] automatically fix errors where possible
1718
*/
1819
constructor(schema, options = {}) {
1920
const defaultOptions = {
@@ -22,6 +23,7 @@ export class Configuration {
2223
commentDescriptions: false,
2324
oldImplementsSyntax: false,
2425
ignore: {},
26+
fix: false,
2527
};
2628

2729
this.schema = schema;
@@ -120,6 +122,15 @@ export class Configuration {
120122
validate() {
121123
const issues = [];
122124

125+
if (this.options.stdin && this.options.fix) {
126+
this.options.fix = false;
127+
issues.push({
128+
message: `--fix and --stdin are incompatible; --fix will be ignored`,
129+
field: 'fix',
130+
type: 'warning',
131+
});
132+
}
133+
123134
let rules;
124135

125136
try {

src/fix.js

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { writeFileSync } from 'fs';
2+
3+
// Returns {<path>: <new content>} for only those paths which changed.
4+
export function fixSchema(errors, sourceMap) {
5+
// Select fixable errors, and sort in by start-location of the fix.
6+
errors = errors.filter((error) => error.fix != null);
7+
if (errors.length === 0) {
8+
return {};
9+
}
10+
errors.sort((first, second) => first.fix.loc.start - second.fix.loc.start);
11+
12+
// Apply the fixes by iterating through files, walking the errors list for
13+
// each at the same time.
14+
let fileStartOffset = 0;
15+
const fixedPaths = {};
16+
let errorIndex = 0;
17+
Object.entries(sourceMap.sourceFiles).forEach(([path, text]) => {
18+
const fileEndOffset = fileStartOffset + text.length;
19+
const fixedParts = [];
20+
let currentFileOffset = 0;
21+
while (
22+
errorIndex < errors.length &&
23+
errors[errorIndex].fix.loc.start <= fileEndOffset
24+
) {
25+
const { loc, replacement } = errors[errorIndex].fix;
26+
fixedParts.push(
27+
text.slice(currentFileOffset, loc.start - fileStartOffset)
28+
);
29+
fixedParts.push(replacement);
30+
currentFileOffset = loc.end - fileStartOffset;
31+
errorIndex++;
32+
}
33+
34+
if (fixedParts.length > 0) {
35+
fixedParts.push(text.slice(currentFileOffset));
36+
fixedPaths[path] = fixedParts.join('');
37+
}
38+
39+
fileStartOffset = fileEndOffset + 1; // sourceMap adds a newline in between
40+
});
41+
42+
return fixedPaths;
43+
}
44+
45+
// Given output from fixSchema, write the fixes to disk.
46+
export function applyFixes(fixes) {
47+
for (const [path, text] of Object.entries(fixes)) {
48+
try {
49+
writeFileSync(path, text);
50+
} catch (e) {
51+
console.error(e.message);
52+
}
53+
}
54+
}

src/rules/descriptions_are_capitalized.js

+16-1
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,26 @@ export function DescriptionsAreCapitalized(configuration, context) {
2222
const fieldName = node.name.value;
2323
const parentName = ancestors[ancestors.length - 1].name.value;
2424

25+
let fix;
26+
if (node.description != null) {
27+
// Supporting autofixes for comment-descriptions is a bunch of extra
28+
// work, just do it for real descriptions.
29+
let start = node.description.loc.start;
30+
while (node.description.loc.source.body[start] === '"') {
31+
start++;
32+
}
33+
fix = {
34+
loc: { start, end: start + 1 },
35+
replacement: node.description.loc.source.body[start].toUpperCase(),
36+
};
37+
}
38+
2539
context.reportError(
2640
new ValidationError(
2741
'descriptions-are-capitalized',
2842
`The description for field \`${parentName}.${fieldName}\` should be capitalized.`,
29-
[node]
43+
[node],
44+
fix
3045
)
3146
);
3247
},

src/rules/enum_values_all_caps.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ export function EnumValuesAllCaps(context) {
1111
new ValidationError(
1212
'enum-values-all-caps',
1313
`The enum value \`${parentName}.${enumValueName}\` should be uppercase.`,
14-
[node]
14+
[node],
15+
{ loc: node.name.loc, replacement: enumValueName.toUpperCase() }
1516
)
1617
);
1718
}

src/rules/enum_values_sorted_alphabetically.js

+8-8
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
11
import { ValidationError } from '../validation_error';
2-
import listIsAlphabetical from '../util/listIsAlphabetical';
2+
import alphabetizeNodes from '../util/alphabetizeNodes';
33

44
export function EnumValuesSortedAlphabetically(context) {
55
return {
66
EnumTypeDefinition(node, key, parent, path, ancestors) {
7-
const enumValues = node.values.map((val) => {
8-
return val.name.value;
9-
});
10-
11-
const { isSorted, sortedList } = listIsAlphabetical(enumValues);
7+
const { isSorted, sortedNames, fix } = alphabetizeNodes(
8+
node.values,
9+
(val) => val.name.value
10+
);
1211

1312
if (!isSorted) {
1413
context.reportError(
1514
new ValidationError(
1615
'enum-values-sorted-alphabetically',
1716
`The enum \`${node.name.value}\` should be sorted alphabetically. ` +
18-
`Expected sorting: ${sortedList.join(', ')}`,
19-
[node]
17+
`Expected sorting: ${sortedNames.join(', ')}`,
18+
[node],
19+
fix
2020
)
2121
);
2222
}

src/rules/fields_are_camel_cased.js

+7-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { ValidationError } from '../validation_error';
22

33
const camelCaseTest = RegExp('^[a-z][a-zA-Z0-9]*$');
44

5+
function makeCamelCase(name) {
6+
name = name.replace(/_+([^_])/g, (match, g1) => g1.toUpperCase());
7+
return name[0].toLowerCase() + name.slice(1);
8+
}
9+
510
export function FieldsAreCamelCased(context) {
611
return {
712
FieldDefinition(node, key, parent, path, ancestors) {
@@ -12,7 +17,8 @@ export function FieldsAreCamelCased(context) {
1217
new ValidationError(
1318
'fields-are-camel-cased',
1419
`The field \`${parentName}.${fieldName}\` is not camel cased.`,
15-
[node]
20+
[node],
21+
{ loc: node.name.loc, replacement: makeCamelCase(fieldName) }
1622
)
1723
);
1824
}

src/rules/input_object_fields_sorted_alphabetically.js

+8-5
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
import { ValidationError } from '../validation_error';
2-
import listIsAlphabetical from '../util/listIsAlphabetical';
2+
import alphabetizeNodes from '../util/alphabetizeNodes';
33

44
export function InputObjectFieldsSortedAlphabetically(context) {
55
return {
66
InputObjectTypeDefinition(node) {
7-
const fieldList = (node.fields || []).map((field) => field.name.value);
8-
const { isSorted, sortedList } = listIsAlphabetical(fieldList);
7+
const { isSorted, sortedNames, fix } = alphabetizeNodes(
8+
node.fields || [],
9+
(field) => field.name.value
10+
);
911

1012
if (!isSorted) {
1113
context.reportError(
1214
new ValidationError(
1315
'input-object-fields-sorted-alphabetically',
1416
`The fields of input type \`${node.name.value}\` should be sorted alphabetically. ` +
15-
`Expected sorting: ${sortedList.join(', ')}`,
16-
[node]
17+
`Expected sorting: ${sortedNames.join(', ')}`,
18+
[node],
19+
fix
1720
)
1821
);
1922
}

src/rules/input_object_values_are_camel_cased.js

+7-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { ValidationError } from '../validation_error';
22

33
const camelCaseTest = RegExp('^[a-z][a-zA-Z0-9]*$');
44

5+
function makeCamelCase(name) {
6+
name = name.replace(/_+([^_])/g, (match, g1) => g1.toUpperCase());
7+
return name[0].toLowerCase() + name.slice(1);
8+
}
9+
510
export function InputObjectValuesAreCamelCased(context) {
611
return {
712
InputValueDefinition(node, key, parent, path, ancestors) {
@@ -15,7 +20,8 @@ export function InputObjectValuesAreCamelCased(context) {
1520
new ValidationError(
1621
'input-object-values-are-camel-cased',
1722
`The input value \`${inputObjectName}.${inputValueName}\` is not camel cased.`,
18-
[node]
23+
[node],
24+
{ loc: node.name.loc, replacement: makeCamelCase(fieldName) }
1925
)
2026
);
2127
}

src/rules/type_fields_sorted_alphabetically.js

+8-5
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
import { ValidationError } from '../validation_error';
2-
import listIsAlphabetical from '../util/listIsAlphabetical';
2+
import alphabetizeNodes from '../util/alphabetizeNodes';
33

44
export function TypeFieldsSortedAlphabetically(context) {
55
return {
66
ObjectTypeDefinition(node) {
7-
const fieldList = (node.fields || []).map((field) => field.name.value);
8-
const { isSorted, sortedList } = listIsAlphabetical(fieldList);
7+
const { isSorted, sortedNames, fix } = alphabetizeNodes(
8+
node.fields || [],
9+
(field) => field.name.value
10+
);
911

1012
if (!isSorted) {
1113
context.reportError(
1214
new ValidationError(
1315
'type-fields-sorted-alphabetically',
1416
`The fields of object type \`${node.name.value}\` should be sorted alphabetically. ` +
15-
`Expected sorting: ${sortedList.join(', ')}`,
16-
[node]
17+
`Expected sorting: ${sortedNames.join(', ')}`,
18+
[node],
19+
fix
1720
)
1821
);
1922
}

src/rules/types_are_capitalized.js

+10-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ export function TypesAreCapitalized(context) {
99
new ValidationError(
1010
'types-are-capitalized',
1111
`The object type \`${typeName}\` should start with a capital letter.`,
12-
[node.name]
12+
[node.name],
13+
{
14+
loc: node.name.loc,
15+
replacement: typeName[0].toUpperCase() + typeName.slice(1),
16+
}
1317
)
1418
);
1519
}
@@ -22,7 +26,11 @@ export function TypesAreCapitalized(context) {
2226
new ValidationError(
2327
'types-are-capitalized',
2428
`The interface type \`${typeName}\` should start with a capital letter.`,
25-
[node.name]
29+
[node.name],
30+
{
31+
loc: node.name.loc,
32+
replacement: typeName[0].toUpperCase() + typeName.slice(1),
33+
}
2634
)
2735
);
2836
}

src/runner.js

+13
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Command } from 'commander';
44
import { Configuration } from './configuration.js';
55
import { loadSchema } from './schema.js';
66
import { loadOptionsFromConfigDir } from './options.js';
7+
import { applyFixes, fixSchema } from './fix.js';
78
import figures from './figures';
89
import chalk from 'chalk';
910

@@ -42,6 +43,10 @@ export async function run(stdout, stdin, stderr, argv) {
4243
'--old-implements-syntax',
4344
'use old way of defining implemented interfaces in GraphQL SDL'
4445
)
46+
.option(
47+
'--fix',
48+
'when possible, automatically update input files to fix errors; incompatible with --stdin'
49+
)
4550
// DEPRECATED - This code should be removed in v1.0.0.
4651
.option(
4752
'-o, --only <rules>',
@@ -107,6 +112,10 @@ export async function run(stdout, stdin, stderr, argv) {
107112
const errors = validateSchemaDefinition(schema, rules, configuration);
108113
const groupedErrors = groupErrorsBySchemaFilePath(errors, schema.sourceMap);
109114

115+
if (configuration.options.fix) {
116+
applyFixes(fixSchema(errors, schema.sourceMap));
117+
}
118+
110119
stdout.write(formatter(groupedErrors));
111120

112121
return errors.length > 0 ? 1 : 0;
@@ -168,6 +177,10 @@ function getOptionsFromCommander(commander) {
168177
options.oldImplementsSyntax = commander.oldImplementsSyntax;
169178
}
170179

180+
if (commander.fix) {
181+
options.fix = commander.fix;
182+
}
183+
171184
if (commander.args && commander.args.length) {
172185
options.schemaPaths = commander.args;
173186
}

0 commit comments

Comments
 (0)