Skip to content

Commit fea720c

Browse files
feat: created initial core JSON language plugin (#91)
## PR Checklist - [x] Addresses an existing open issue: fixes #46 - [x] That issue was marked as [`status: accepting prs`](https://github.com/JoshuaKGoldberg/flint/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22) - [x] Steps in [CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/flint/blob/main/.github/CONTRIBUTING.md) were taken ## Overview Adds an initial JSON language that uses in-memory `ts.parseJsonText` from TypeScript. It's used for an initial JSON plugin with one rule, `duplicateKeys`. ❤️‍🔥
1 parent aff5abd commit fea720c

18 files changed

Lines changed: 202 additions & 25 deletions

flint.config.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1-
import { defineConfig, ts } from "./lib/index.js";
1+
import { defineConfig, json, ts } from "./lib/index.js";
22

33
export default defineConfig({
44
use: [
5+
{
6+
glob: json.globs.all,
7+
rules: [json.presets.logical],
8+
},
59
{
610
glob: ts.globs.all,
711
rules: [ts.presets.logical],
812
},
913
// Catch-all globs until we have dedicated plugins...
1014
{
1115
glob: [
12-
// https://github.com/JoshuaKGoldberg/flint/issues/46
13-
"**/*.json",
14-
1516
// https://github.com/JoshuaKGoldberg/flint/issues/47
1617
"**/*.md",
1718

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { defineConfig } from "./configs/defineConfig.js";
2+
export { json } from "./json/plugin.js";
23
export { createLanguage } from "./languages/createLanguage.js";
34
export { createPlugin } from "./plugins/createPlugin.js";
45
export { RuleTester } from "./testing/RuleTester.js";

src/json/createJsonFile.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import * as ts from "typescript";
2+
3+
import { LanguageFileDefinition } from "../types/languages.js";
4+
import { NormalizedRuleReport, RuleReport } from "../types/reports.js";
5+
import { normalizeRange } from "../typescript/normalizeRange.js";
6+
7+
// TODO: Eventually, it might make sense to use a native speed JSON parser.
8+
// The standard TypeScript language will likely use that itself.
9+
// https://github.com/JoshuaKGoldberg/flint/issues/44
10+
export function createTypeScriptJsonFile(
11+
filePathAbsolute: string,
12+
sourceText: string,
13+
): LanguageFileDefinition {
14+
const sourceFile = ts.parseJsonText(filePathAbsolute, sourceText);
15+
16+
return {
17+
runRule(rule, options) {
18+
const reports: NormalizedRuleReport[] = [];
19+
20+
const context = {
21+
report: (report: RuleReport) => {
22+
reports.push({
23+
...report,
24+
message: rule.messages[report.message],
25+
range: normalizeRange(report.range, sourceFile),
26+
});
27+
},
28+
sourceFile,
29+
};
30+
31+
const visitors = rule.setup(context, options);
32+
33+
if (!visitors) {
34+
return reports;
35+
}
36+
37+
function visit(node: ts.Node) {
38+
// @ts-expect-error - TODO: Figure this out later...
39+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
40+
visitors[ts.SyntaxKind[node.kind]]?.(node);
41+
42+
node.forEachChild(visit);
43+
}
44+
45+
sourceFile.forEachChild(visit);
46+
47+
return reports;
48+
},
49+
};
50+
}

src/json/language.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import fsSync from "node:fs";
2+
import * as ts from "typescript";
3+
4+
import { createLanguage } from "../languages/createLanguage.js";
5+
import { createTypeScriptJsonFile } from "./createJsonFile.js";
6+
7+
export interface JsonServices {
8+
sourceFile: ts.JsonSourceFile;
9+
}
10+
11+
export const json = createLanguage<JsonServices>({
12+
about: {
13+
name: "JSON",
14+
},
15+
prepare: () => {
16+
return {
17+
prepareFileOnDisk: (filePathAbsolute) => {
18+
return createTypeScriptJsonFile(
19+
filePathAbsolute,
20+
fsSync.readFileSync(filePathAbsolute, "utf8"),
21+
);
22+
},
23+
prepareFileVirtually: (filePathAbsolute, sourceText) => {
24+
return createTypeScriptJsonFile(filePathAbsolute, sourceText);
25+
},
26+
};
27+
},
28+
});

src/json/plugin.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { createPlugin } from "../plugins/createPlugin.js";
2+
import duplicateKeys from "./rules/duplicateKeys.js";
3+
4+
export const json = createPlugin({
5+
globs: {
6+
all: ["**/*.json"],
7+
},
8+
name: "json",
9+
rules: [duplicateKeys],
10+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { ruleTester } from "../../ruleTester.js";
2+
import rule from "./duplicateKeys.js";
3+
4+
ruleTester.describe(rule, {
5+
invalid: [
6+
{
7+
code: `
8+
{
9+
"a": "first",
10+
"a": "second",
11+
}
12+
`,
13+
snapshot: `
14+
{
15+
"a": "first",
16+
~~~
17+
This key is made redundant by an identical key later in the object.
18+
"a": "second",
19+
}
20+
`,
21+
},
22+
],
23+
valid: [
24+
`{}`,
25+
`{ "a": "apple" }`,
26+
`
27+
{
28+
"a": "first",
29+
"b": "second",
30+
}
31+
`,
32+
],
33+
});

src/json/rules/duplicateKeys.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import * as ts from "typescript";
2+
3+
import { json } from "../language.js";
4+
5+
export default json.createRule({
6+
about: {
7+
id: "duplicateKeys",
8+
preset: "logical",
9+
},
10+
messages: {
11+
duplicateKey: {
12+
primary:
13+
"This key is made redundant by an identical key later in the object.",
14+
secondary: [
15+
"Although JSON technically allows duplicate keys, using them is at best confusing.",
16+
"Most JSON parsers will ignore all but the last value for a given key.",
17+
"It's generally best to ensure that each key in a JSON object is unique.",
18+
],
19+
suggestions: [
20+
"If both values are meant to exist, change one of the keys to be different.",
21+
"If only the last value is meant to exist, you can remove any prior values.",
22+
],
23+
},
24+
},
25+
setup(context) {
26+
return {
27+
ObjectLiteralExpression(node) {
28+
const seenKeys = new Map<string, ts.StringLiteral>();
29+
30+
for (const property of node.properties.toReversed()) {
31+
if (
32+
!ts.isPropertyAssignment(property) ||
33+
!ts.isStringLiteral(property.name)
34+
) {
35+
continue;
36+
}
37+
38+
const key = property.name.text;
39+
const existingNode = seenKeys.get(key);
40+
41+
if (!existingNode) {
42+
seenKeys.set(key, property.name);
43+
continue;
44+
}
45+
46+
context.report({
47+
message: "duplicateKey",
48+
range: {
49+
begin: property.name.getStart(context.sourceFile) + 1,
50+
end: property.name.end + 1,
51+
},
52+
});
53+
}
54+
},
55+
};
56+
},
57+
});
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it } from "vitest";
22

3-
import { RuleTester } from "../testing/RuleTester.js";
3+
import { RuleTester } from "./testing/RuleTester.js";
44

55
export const ruleTester = new RuleTester({
66
describe,

src/typescript/getNodeRange.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ import type * as ts from "typescript";
22

33
import { CharacterReportRange } from "../types/ranges.js";
44

5-
export function getNodeRange(node: ts.Node): CharacterReportRange {
5+
export function getNodeRange(
6+
node: ts.Node,
7+
sourceFile: ts.SourceFile,
8+
): CharacterReportRange {
69
return {
7-
begin: node.getStart(),
10+
begin: node.getStart(sourceFile),
811
end: node.getEnd(),
912
};
1013
}

src/typescript/plugin.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
11
import { createPlugin } from "../plugins/createPlugin.js";
2-
import consecutiveNonNullAssertions from "../rules/consecutiveNonNullAssertions.js";
3-
import forInArrays from "../rules/forInArrays.js";
4-
import namespaceDeclarations from "../rules/namespaceDeclarations.js";
5-
6-
const rules = [
7-
forInArrays,
8-
consecutiveNonNullAssertions,
9-
namespaceDeclarations,
10-
];
2+
import consecutiveNonNullAssertions from "./rules/consecutiveNonNullAssertions.js";
3+
import forInArrays from "./rules/forInArrays.js";
4+
import namespaceDeclarations from "./rules/namespaceDeclarations.js";
115

126
export const ts = createPlugin({
137
globs: {
148
all: ["**/*.{cjs,js,jsx,mjs,ts,tsx}"],
159
},
1610
name: "ts",
17-
rules,
11+
rules: [forInArrays, consecutiveNonNullAssertions, namespaceDeclarations],
1812
});

0 commit comments

Comments
 (0)