From 1976f2c48b1da0cfba2d5ad2553f76182c147621 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Tue, 2 Jul 2024 16:37:52 -0400 Subject: [PATCH] feat: Add JSON plugin (#1) Co-authored-by: Milos Djermanovic --- .github/ISSUE_TEMPLATE/bug-report.yml | 81 ++++++++ .github/ISSUE_TEMPLATE/change.yml | 51 +++++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/docs.yml | 46 +++++ .github/ISSUE_TEMPLATE/new-rule.yml | 41 ++++ .github/ISSUE_TEMPLATE/rule-change.yml | 61 ++++++ .github/PULL_REQUEST_TEMPLATE.md | 34 ++++ .github/workflows/ci.yml | 45 +++++ .github/workflows/release-please.yml | 43 +++++ .release-please-manifest.json | 3 + CHANGELOG.md | 0 README.md | 46 ++++- eslint.config.js | 70 +++++++ jsr.json | 14 ++ package.json | 84 ++++++++ prettier.config.js | 15 ++ release-please-config.json | 16 ++ rollup.config.js | 14 ++ src/index.js | 40 ++++ src/languages/json-language.js | 141 ++++++++++++++ src/languages/json-source-code.js | 233 +++++++++++++++++++++++ src/rules/no-duplicate-keys.js | 53 ++++++ src/rules/no-empty-keys.js | 33 ++++ tests/languages/json-language.test.js | 90 +++++++++ tests/languages/json-source-code.test.js | 103 ++++++++++ tests/rules/no-duplicate-keys.test.js | 65 +++++++ tests/rules/no-empty-keys.test.js | 53 ++++++ tools/dedupe-types.js | 43 +++++ tsconfig.esm.json | 4 + tsconfig.json | 13 ++ 30 files changed, 1533 insertions(+), 10 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/change.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/docs.yml create mode 100644 .github/ISSUE_TEMPLATE/new-rule.yml create mode 100644 .github/ISSUE_TEMPLATE/rule-change.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .release-please-manifest.json create mode 100644 CHANGELOG.md create mode 100644 eslint.config.js create mode 100644 jsr.json create mode 100644 package.json create mode 100644 prettier.config.js create mode 100644 release-please-config.json create mode 100644 rollup.config.js create mode 100644 src/index.js create mode 100644 src/languages/json-language.js create mode 100644 src/languages/json-source-code.js create mode 100644 src/rules/no-duplicate-keys.js create mode 100644 src/rules/no-empty-keys.js create mode 100644 tests/languages/json-language.test.js create mode 100644 tests/languages/json-source-code.test.js create mode 100644 tests/rules/no-duplicate-keys.test.js create mode 100644 tests/rules/no-empty-keys.test.js create mode 100644 tools/dedupe-types.js create mode 100644 tsconfig.esm.json create mode 100644 tsconfig.json diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..9b18a96 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,81 @@ +name: "\U0001F41E Report a problem" +description: "Report something that isn't working the way you expected." +title: "Bug: (fill in)" +labels: + - bug + - "repro:needed" +body: + - type: markdown + attributes: + value: By opening an issue, you agree to abide by the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct). + - type: textarea + attributes: + label: Environment + description: | + Please tell us about how you're running ESLint (Run `npx eslint --env-info`.) + value: | + ESLint version: + @eslint/json version: + Node version: + npm version: + Operating System: + validations: + required: true + - type: dropdown + attributes: + label: Which language are you using? + description: | + Just tell us which language mode you're using. + options: + - json + - jsonc + validations: + required: true + - type: textarea + attributes: + label: What did you do? + description: | + Please include a *minimal* reproduction case. + value: | +
+ Configuration + + ``` + + ``` +
+ + ```js + + ``` + validations: + required: true + - type: textarea + attributes: + label: What did you expect to happen? + validations: + required: true + - type: textarea + attributes: + label: What actually happened? + description: | + Please copy-paste the actual ESLint output. + validations: + required: true + - type: input + attributes: + label: Link to Minimal Reproducible Example + description: "Link to a [StackBlitz](https://stackblitz.com), or GitHub repo with a minimal reproduction of the problem. **A minimal reproduction is required** so that others can help debug your issue. If a report is vague (e.g. just a generic error message) and has no reproduction, it may be auto-closed." + placeholder: "https://stackblitz.com/abcd1234" + validations: + required: true + - type: checkboxes + attributes: + label: Participation + options: + - label: I am willing to submit a pull request for this issue. + required: false + - type: textarea + attributes: + label: Additional comments + description: Is there anything else that's important for the team to know? diff --git a/.github/ISSUE_TEMPLATE/change.yml b/.github/ISSUE_TEMPLATE/change.yml new file mode 100644 index 0000000..a685993 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/change.yml @@ -0,0 +1,51 @@ +name: "\U0001F680 Request a change (not rule-related)" +description: "Request a change that is not a bug fix, rule change, or new rule" +title: "Change Request: (fill in)" +labels: + - enhancement + - core +body: + - type: markdown + attributes: + value: By opening an issue, you agree to abide by the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct). + - type: textarea + attributes: + label: Environment + description: | + Please tell us about how you're running ESLint (Run `npx eslint --env-info`.) + value: | + ESLint version: + @eslint/json version: + Node version: + npm version: + Operating System: + validations: + required: true + - type: textarea + attributes: + label: What problem do you want to solve? + description: | + Please explain your use case in as much detail as possible. + placeholder: | + The JSON plugin currently... + validations: + required: true + - type: textarea + attributes: + label: What do you think is the correct solution? + description: | + Please explain how you'd like to change the JSON plugin to address the problem. + placeholder: | + I'd like the JSON plugin to... + validations: + required: true + - type: checkboxes + attributes: + label: Participation + options: + - label: I am willing to submit a pull request for this change. + required: false + - type: textarea + attributes: + label: Additional comments + description: Is there anything else that's important for the team to know? diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..c2ff969 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: 🗣 Ask a Question, Discuss + url: https://github.com/eslint/json/discussions + about: Get help using this plugin + - name: Discord Server + url: https://eslint.org/chat + about: Talk with the team diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml new file mode 100644 index 0000000..a8a3bac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -0,0 +1,46 @@ +name: "\U0001F4DD Docs" +description: "Request an improvement to documentation" +title: "Docs: (fill in)" +labels: + - documentation +body: + - type: markdown + attributes: + value: By opening an issue, you agree to abide by the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct). + - type: textarea + attributes: + label: Docs page(s) + description: | + What page(s) are you suggesting be changed or created? + placeholder: | + e.g. https://eslint.org/docs/latest/use/getting-started + validations: + required: true + - type: textarea + attributes: + label: What documentation issue do you want to solve? + description: | + Please explain your issue in as much detail as possible. + placeholder: | + The docs currently... + validations: + required: true + - type: textarea + attributes: + label: What do you think is the correct solution? + description: | + Please explain how you'd like to change the docs to address the problem. + placeholder: | + I'd like the docs to... + validations: + required: true + - type: checkboxes + attributes: + label: Participation + options: + - label: I am willing to submit a pull request for this change. + required: false + - type: textarea + attributes: + label: Additional comments + description: Is there anything else that's important for the team to know? diff --git a/.github/ISSUE_TEMPLATE/new-rule.yml b/.github/ISSUE_TEMPLATE/new-rule.yml new file mode 100644 index 0000000..6f6c941 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new-rule.yml @@ -0,0 +1,41 @@ +name: "\U0001F680 Propose a new rule" +description: "Propose a new rule to be added to the plugin" +title: "New Rule: (fill in)" +labels: + - rule + - feature +body: + - type: markdown + attributes: + value: By opening an issue, you agree to abide by the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct). + - type: input + attributes: + label: Rule details + description: What should the new rule do? + validations: + required: true + - type: dropdown + attributes: + label: What type of rule is this? + options: + - Warns about a potential problem + - Suggests an alternate way of doing something + validations: + required: true + - type: textarea + attributes: + label: Example code + description: Please provide some example code that this rule will warn about. This field will render as JSON. + render: jsonc + validations: + required: true + - type: checkboxes + attributes: + label: Participation + options: + - label: I am willing to submit a pull request to implement this rule. + required: false + - type: textarea + attributes: + label: Additional comments + description: Is there anything else that's important for the team to know? diff --git a/.github/ISSUE_TEMPLATE/rule-change.yml b/.github/ISSUE_TEMPLATE/rule-change.yml new file mode 100644 index 0000000..b2c27f0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/rule-change.yml @@ -0,0 +1,61 @@ +name: "\U0001F4DD Request a rule change" +description: "Request a change to an existing rule" +title: "Rule Change: (fill in)" +labels: + - enhancement + - rule +body: + - type: markdown + attributes: + value: By opening an issue, you agree to abide by the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct). + - type: input + attributes: + label: What rule do you want to change? + validations: + required: true + - type: dropdown + attributes: + label: What change do you want to make? + options: + - Generate more warnings + - Generate fewer warnings + - Implement autofix + - Implement suggestions + validations: + required: true + - type: dropdown + attributes: + label: How do you think the change should be implemented? + options: + - A new option + - A new default behavior + - Other + validations: + required: true + - type: textarea + attributes: + label: Example code + description: Please provide some example code that this change will affect. This field will render as JSON. + render: jsonc + validations: + required: true + - type: textarea + attributes: + label: What does the rule currently do for this code? + validations: + required: true + - type: textarea + attributes: + label: What will the rule do after it's changed? + validations: + required: true + - type: checkboxes + attributes: + label: Participation + options: + - label: I am willing to submit a pull request to implement this change. + required: false + - type: textarea + attributes: + label: Additional comments + description: Is there anything else that's important for the team to know? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1f44735 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,34 @@ + + +#### Prerequisites checklist + +- [ ] I have read the [contributing guidelines](https://github.com/eslint/eslint/blob/HEAD/CONTRIBUTING.md). + + + + + +#### What is the purpose of this pull request? + +#### What changes did you make? (Give an overview) + +#### Related Issues + + + +#### Is there anything you'd like reviewers to focus on? + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b44a53c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI +on: + push: + branches: + - main + pull_request: + branches: + - main +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "lts/*" + - name: Install dependencies + run: npm install + - name: Build commonjs + run: npm run build + - name: Lint files + run: npm run lint + test: + name: Test + strategy: + matrix: + os: [ubuntu-latest] + node: [22.x, 21.x, 20.x, 18.x, "18.18.0"] + include: + - os: windows-latest + node: "lts/*" + - os: macOS-latest + node: "lts/*" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - name: Install dependencies + run: npm install + - name: Run tests + run: npm run test diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..04a463f --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,43 @@ +on: + push: + branches: + - main +name: release-please +jobs: + release-please: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + id-token: write + steps: + - uses: google-github-actions/release-please-action@v4 + id: release + with: + release-type: node + - uses: actions/checkout@v4 + if: ${{ steps.release.outputs.release_created }} + - uses: actions/setup-node@v4 + with: + node-version: lts/* + registry-url: https://registry.npmjs.org + if: ${{ steps.release.outputs.release_created }} + - run: | + npm install + npm run build + npm publish --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + if: ${{ steps.release.outputs.release_created }} + - run: 'npx @humanwhocodes/tweet "eslint/json ${{ steps.release.outputs.tag_name }} has been released: ${{ steps.release.outputs.html_url }}"' + if: ${{ steps.release.outputs.release_created }} + env: + TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }} + TWITTER_CONSUMER_SECRET: ${{ secrets.TWITTER_CONSUMER_SECRET }} + TWITTER_ACCESS_TOKEN_KEY: ${{ secrets.TWITTER_ACCESS_TOKEN_KEY }} + TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} + - run: 'npx @humanwhocodes/toot "eslint/json ${{ steps.release.outputs.tag_name }} has been released: ${{ steps.release.outputs.html_url }}"' + if: ${{ steps.release.outputs.release_created }} + env: + MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }} + MASTODON_HOST: ${{ secrets.MASTODON_HOST }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..b985ff6 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.1" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index dc68213..27a8701 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ This package contains a plugin that allows you to natively lint JSON and JSONC files using ESLint. +**Important:** This plugin requires ESLint v9.6.0 or higher and you must be using the [new configuration system](https://eslint.org/docs/latest/use/configure/configuration-files). + ## Installation For Node.js and compatible runtimes: @@ -28,8 +30,8 @@ deno add @eslint/json This package exports two different languages: -* `"json/json"` is for regular JSON files -* `"json/jsonc"` is for JSON files that support comments (JSON-C) +- `"json/json"` is for regular JSON files +- `"json/jsonc"` is for JSON files that support comments (JSON-C) Depending on which types of JSON files you'd like to lint, you can set up your `eslint.config.js` file to include just the files you'd like. Here's an example that lints both JSON and JSON-C files: @@ -39,8 +41,8 @@ import json from "@eslint/json"; export default [ { plugins: { - json - } + json, + }, }, // lint JSON files @@ -48,8 +50,8 @@ export default [ files: ["**/*.json"], language: "json/json", rules: { - "no-duplicate-keys": "error" - } + "json/no-duplicate-keys": "error", + }, }, // lint JSON-C files @@ -57,17 +59,41 @@ export default [ files: ["**/*.jsonc"], language: "json/jsonc", rules: { - "no-duplicate-keys": "error" - } + "json/no-duplicate-keys": "error", + }, }, +]; +``` +## Recommended Configuration + +To use the recommended configuration for this plugin, specify your matching `files` and then use the `json.configs.recommended` object, like this: + +```js +import json from "@eslint/json"; + +export default [ + + // lint JSON files + { + files: ["**/*.json"], + language: "json/json", + ...json.configs.recommended, + }, + + // lint JSON-C files + { + files: ["**/*.jsonc"], + language: "json/jsonc", + ...json.configs.recommended, + }, ]; ``` ## Rules -* `no-duplicate-keys` - warns when there are two keys in an object with the same text. -* `no-empty-keys` - warns when there is a key in an object that is an empty string or contains only whitespace +- `no-duplicate-keys` - warns when there are two keys in an object with the same text. +- `no-empty-keys` - warns when there is a key in an object that is an empty string or contains only whitespace ## License diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..25b797d --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,70 @@ +/** + * @fileoverview ESLint configuration file. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import eslintConfigESLint from "eslint-config-eslint"; +import json from "./src/index.js"; + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +const eslintPluginJSDoc = eslintConfigESLint.find( + config => config.plugins?.jsdoc, +).plugins.jsdoc; + +//----------------------------------------------------------------------------- +// Configuration +//----------------------------------------------------------------------------- + +export default [ + { + ignores: ["**/tests/fixtures/", "**/dist/"], + }, + + ...eslintConfigESLint.map(config => ({ + files: ["**/*.js"], + ...config, + })), + { + files: ["**/*.json"], + ignores: ["**/package-lock.json"], + language: "json/json", + ...json.configs.recommended, + }, + { + files: ["**/*.js"], + rules: { + // disable rules we don't want to use from eslint-config-eslint + "no-undefined": "off", + + // TODO: re-enable eslint-plugin-jsdoc rules + ...Object.fromEntries( + Object.keys(eslintPluginJSDoc.rules).map(name => [ + `jsdoc/${name}`, + "off", + ]), + ), + }, + }, + { + files: ["**/tests/**"], + languageOptions: { + globals: { + describe: "readonly", + xdescribe: "readonly", + it: "readonly", + xit: "readonly", + beforeEach: "readonly", + afterEach: "readonly", + before: "readonly", + after: "readonly", + }, + }, + }, +]; diff --git a/jsr.json b/jsr.json new file mode 100644 index 0000000..05905a6 --- /dev/null +++ b/jsr.json @@ -0,0 +1,14 @@ +{ + "name": "@eslint/json", + "version": "0.0.1", + "exports": "./dist/esm/index.js", + "publish": { + "include": [ + "dist/esm/index.js", + "dist/esm/index.d.ts", + "README.md", + "jsr.json", + "LICENSE" + ] + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4fb9a3c --- /dev/null +++ b/package.json @@ -0,0 +1,84 @@ +{ + "name": "@eslint/json", + "version": "0.0.1", + "description": "JSON linting plugin for ESLint", + "author": "Nicholas C. Zakas", + "type": "module", + "main": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "exports": { + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + }, + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + } + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "gitHooks": { + "pre-commit": "lint-staged" + }, + "lint-staged": { + "*.js": [ + "eslint --fix", + "prettier --write" + ], + "!(*.js)": "prettier --write --ignore-unknown" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/eslint/json.git" + }, + "bugs": { + "url": "https://github.com/eslint/json/issues" + }, + "homepage": "https://github.com/eslint/json#readme", + "scripts": { + "build:dedupe-types": "node tools/dedupe-types.js dist/cjs/index.cjs dist/esm/index.js", + "build:cts": "node -e \"fs.copyFileSync('dist/esm/index.d.ts', 'dist/cjs/index.d.cts')\"", + "build": "rollup -c && npm run build:dedupe-types && tsc -p tsconfig.esm.json && npm run build:cts", + "test:jsr": "npx jsr@latest publish --dry-run", + "pretest": "npm run build", + "lint": "eslint", + "test": "mocha tests/**/*.js", + "test:coverage": "c8 npm test" + }, + "keywords": [ + "eslint", + "eslint-plugin", + "eslintplugin", + "json", + "linting" + ], + "license": "Apache-2.0", + "devDependencies": { + "@eslint/core": "^0.1.0", + "@types/eslint": "^8.56.10", + "c8": "^9.1.0", + "eslint": "^9.6.0", + "eslint-config-eslint": "^11.0.0", + "lint-staged": "^15.2.7", + "mocha": "^10.4.0", + "prettier": "^3.3.2", + "rollup": "^4.16.2", + "rollup-plugin-copy": "^3.5.0", + "typescript": "^5.4.5", + "yorkie": "^2.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "dependencies": { + "@humanwhocodes/momoa": "^3.0.6" + }, + "peerDependencies": { + "eslint": "^9.6.0" + } +} diff --git a/prettier.config.js b/prettier.config.js new file mode 100644 index 0000000..c334317 --- /dev/null +++ b/prettier.config.js @@ -0,0 +1,15 @@ +export default { + useTabs: true, + tabWidth: 4, + arrowParens: "avoid", + + overrides: [ + { + files: ["*.json"], + options: { + tabWidth: 2, + useTabs: false, + }, + }, + ], +}; diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..a6f7b9f --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,16 @@ +{ + "bootstrap-sha": "505795a1312e90f1ce3b59b530622929bc38b4fe", + "bump-minor-pre-major": true, + "packages": { + ".": { + "release-type": "node", + "extra-files": [ + { + "type": "json", + "path": "jsr.json", + "jsonpath": "$.version" + } + ] + } + } +} diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..0ec27fb --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,14 @@ +export default { + input: "src/index.js", + output: [ + { + file: "dist/cjs/index.cjs", + format: "cjs", + }, + { + file: "dist/esm/index.js", + format: "esm", + banner: '// @ts-self-types="./index.d.ts"', + }, + ], +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..9618ba6 --- /dev/null +++ b/src/index.js @@ -0,0 +1,40 @@ +/** + * @fileoverview JSON plugin. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { JSONLanguage } from "./languages/json-language.js"; +import noDuplicateKeys from "./rules/no-duplicate-keys.js"; +import noEmptyKeys from "./rules/no-empty-keys.js"; + +//----------------------------------------------------------------------------- +// Plugin +//----------------------------------------------------------------------------- + +const plugin = { + languages: { + json: new JSONLanguage({ mode: "json" }), + jsonc: new JSONLanguage({ mode: "jsonc" }), + }, + rules: { + "no-duplicate-keys": noDuplicateKeys, + "no-empty-keys": noEmptyKeys, + }, + configs: {}, +}; + +Object.assign(plugin.configs, { + recommended: { + plugins: { json: plugin }, + rules: { + "json/no-duplicate-keys": "error", + "json/no-empty-keys": "error", + }, + }, +}); + +export default plugin; diff --git a/src/languages/json-language.js b/src/languages/json-language.js new file mode 100644 index 0000000..1b87fec --- /dev/null +++ b/src/languages/json-language.js @@ -0,0 +1,141 @@ +/** + * @filedescription Functions to fix up rules to provide missing methods on the `context` object. + * @author Nicholas C. Zakas + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import { parse } from "@humanwhocodes/momoa"; +import { JSONSourceCode } from "./json-source-code.js"; + +//----------------------------------------------------------------------------- +// Types +//----------------------------------------------------------------------------- + +/** @typedef {import("@humanwhocodes/momoa").DocumentNode} DocumentNode */ +/** @typedef {import("@eslint/core").Language} Language */ +/** @typedef {import("@eslint/core").File} File */ +/** @typedef {import("@eslint/core").ParseResult} ParseResult */ +/** @typedef {import("@eslint/core").SyntaxElement} SyntaxElement */ + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + +/** + * JSON Language Object + * @implements {Language} + */ +export class JSONLanguage { + /** + * The type of file to read. + * @type {"text"} + */ + fileType = "text"; + + /** + * The line number at which the parser starts counting. + * @type {0|1} + */ + lineStart = 1; + + /** + * The column number at which the parser starts counting. + * @type {0|1} + */ + columnStart = 1; + + /** + * The name of the key that holds the type of the node. + * @type {string} + */ + nodeTypeKey = "type"; + + /** + * The parser mode. + * @type {"json"|"jsonc"} + */ + #mode = "json"; + + /** + * Creates a new instance. + * @param {Object} options The options to use for this instance. + * @param {"json"|"jsonc"} options.mode The parser mode to use. + */ + constructor({ mode }) { + this.#mode = mode; + } + + /* eslint-disable class-methods-use-this, no-unused-vars -- Required to complete interface. */ + /** + * Validates the language options. + * @param {Object} languageOptions The language options to validate. + * @returns {void} + * @throws {Error} When the language options are invalid. + */ + validateLanguageOptions(languageOptions) { + // no-op + } + /* eslint-enable class-methods-use-this, no-unused-vars -- Required to complete interface. */ + + /** + * Parses the given file into an AST. + * @param {File} file The virtual file to parse. + * @returns {ParseResult} The result of parsing. + */ + parse(file) { + // Note: BOM already removed + const text = /** @type {string} */ (file.body); + + /* + * Check for parsing errors first. If there's a parsing error, nothing + * else can happen. However, a parsing error does not throw an error + * from this method - it's just considered a fatal error message, a + * problem that ESLint identified just like any other. + */ + try { + const root = parse(text, { + mode: this.#mode, + ranges: true, + tokens: true, + }); + + return { + ok: true, + ast: /** @type {DocumentNode & SyntaxElement} */ (root), + }; + } catch (ex) { + // error messages end with (line:column) so we strip that off for ESLint + const message = ex.message + .slice(0, ex.message.lastIndexOf("(")) + .trim(); + + return { + ok: false, + errors: [ + { + ...ex, + message, + }, + ], + }; + } + } + + /* eslint-disable class-methods-use-this -- Required to complete interface. */ + /** + * Creates a new `JSONSourceCode` object from the given information. + * @param {File} file The virtual file to create a `JSONSourceCode` object from. + * @param {ParseResult} parseResult The result returned from `parse()`. + * @returns {JSONSourceCode} The new `JSONSourceCode` object. + */ + createSourceCode(file, parseResult) { + return new JSONSourceCode({ + text: /** @type {string} */ (file.body), + ast: parseResult.ast, + }); + } + /* eslint-enable class-methods-use-this -- Required to complete interface. */ +} diff --git a/src/languages/json-source-code.js b/src/languages/json-source-code.js new file mode 100644 index 0000000..d207815 --- /dev/null +++ b/src/languages/json-source-code.js @@ -0,0 +1,233 @@ +/** + * @fileoverview The JSONSourceCode class. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { iterator } from "@humanwhocodes/momoa"; + +//----------------------------------------------------------------------------- +// Types +//----------------------------------------------------------------------------- + +/** @typedef {import("@humanwhocodes/momoa").DocumentNode} DocumentNode */ +/** @typedef {import("@humanwhocodes/momoa").Node} JSONNode */ +/** @typedef {import("@humanwhocodes/momoa").Token} JSONToken */ +/** @typedef {import("@eslint/core").SyntaxElement} SyntaxElement */ +/** @typedef {import("@eslint/core").Language} Language */ +/** @typedef {import("@eslint/core").File} File */ +/** @typedef {import("@eslint/core").TraversalStep} TraversalStep */ +/** @typedef {import("@eslint/core").VisitTraversalStep} VisitTraversalStep */ +/** @typedef {import("@eslint/core").TextSourceCode} TextSourceCode */ +/** @typedef {import("@eslint/core").ParseResult} ParseResult */ + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +/** + * A class to represent a step in the traversal process. + * @implements {VisitTraversalStep} + */ +class JSONTraversalStep { + /** + * The type of the step. + * @type {"visit"} + * @readonly + */ + type = "visit"; + + /** + * The kind of the step. Represents the same data as the `type` property + * but it's a number for performance. + * @type {1} + * @readonly + */ + kind = 1; + + /** + * The target of the step. + * @type {JSONNode & SyntaxElement} + */ + target; + + /** + * The phase of the step. + * @type {1|2} + */ + phase; + + /** + * The arguments of the step. + * @type {Array} + */ + args; + + /** + * Creates a new instance. + * @param {Object} options The options for the step. + * @param {JSONNode & SyntaxElement} options.target The target of the step. + * @param {1|2} options.phase The phase of the step. + * @param {Array} options.args The arguments of the step. + */ + constructor({ target, phase, args }) { + this.target = target; + this.phase = phase; + this.args = args; + } +} + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + +/** + * JSON Source Code Object + * @implements {TextSourceCode} + */ +export class JSONSourceCode { + /** + * Cached traversal steps. + * @type {Array|undefined} + */ + #steps; + + /** + * Cache of parent nodes. + * @type {WeakMap} + */ + #parents = new WeakMap(); + + /** + * The lines of text in the source code. + * @type {Array} + */ + #lines; + + /** + * The AST of the source code. + * @type {DocumentNode & SyntaxElement} + */ + ast; + + /** + * The text of the source code. + * @type {string} + */ + text; + + /** + * The comment node in the source code. + * @type {Array|undefined} + */ + comments; + + /** + * Creates a new instance. + * @param {Object} options The options for the instance. + * @param {string} options.text The source code text. + * @param {DocumentNode & SyntaxElement} options.ast The root AST node. + */ + constructor({ text, ast }) { + this.ast = ast; + this.text = text; + this.comments = ast.tokens.filter(token => + token.type.endsWith("Comment"), + ); + } + + /** + * Returns the parent of the given node. + * @param {JSONNode} node The node to get the parent of. + * @returns {JSONNode|undefined} The parent of the node. + */ + getParent(node) { + return this.#parents.get(node); + } + + /** + * Gets all the ancestors of a given node + * @param {JSONNode} node The node + * @returns {Array} All the ancestor nodes in the AST, not including the provided node, starting + * from the root node at index 0 and going inwards to the parent node. + * @throws {TypeError} When `node` is missing. + */ + getAncestors(node) { + if (!node) { + throw new TypeError("Missing required argument: node."); + } + + const ancestorsStartingAtParent = []; + + for ( + let ancestor = this.#parents.get(node); + ancestor; + ancestor = this.#parents.get(ancestor) + ) { + ancestorsStartingAtParent.push(ancestor); + } + + return ancestorsStartingAtParent.reverse(); + } + + /** + * Gets the source code for the given node. + * @param {JSONNode} [node] The AST node to get the text for. + * @param {number} [beforeCount] The number of characters before the node to retrieve. + * @param {number} [afterCount] The number of characters after the node to retrieve. + * @returns {string} The text representing the AST node. + * @public + */ + getText(node, beforeCount, afterCount) { + if (node) { + return this.text.slice( + Math.max(node.range[0] - (beforeCount || 0), 0), + node.range[1] + (afterCount || 0), + ); + } + return this.text; + } + + /** + * Gets the entire source text split into an array of lines. + * @returns {Array} The source text as an array of lines. + * @public + */ + get lines() { + if (!this.#lines) { + this.#lines = this.text.split(/\r?\n/gu); + } + return this.#lines; + } + + /** + * Traverse the source code and return the steps that were taken. + * @returns {Iterable} The steps that were taken while traversing the source code. + */ + traverse() { + // Because the AST doesn't mutate, we can cache the steps + if (this.#steps) { + return this.#steps.values(); + } + + const steps = (this.#steps = []); + + for (const { node, parent, phase } of iterator( + /** @type {DocumentNode} */ (this.ast), + )) { + this.#parents.set(node, parent); + steps.push( + new JSONTraversalStep({ + target: /** @type {JSONNode & SyntaxElement} */ (node), + phase: phase === "enter" ? 1 : 2, + args: [node, parent], + }), + ); + } + + return steps; + } +} diff --git a/src/rules/no-duplicate-keys.js b/src/rules/no-duplicate-keys.js new file mode 100644 index 0000000..336fb41 --- /dev/null +++ b/src/rules/no-duplicate-keys.js @@ -0,0 +1,53 @@ +/** + * @fileoverview Rule to prevent duplicate keys in JSON. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +export default { + meta: { + type: "problem", + + docs: { + description: "Disallow duplicate keys in JSON objects", + }, + + messages: { + duplicateKey: 'Duplicate key "{{key}}" found.', + }, + }, + + create(context) { + const objectKeys = []; + let keys; + + return { + Object() { + objectKeys.push(keys); + keys = new Map(); + }, + + Member(node) { + const key = node.name.value; + + if (keys.has(key)) { + context.report({ + loc: node.name.loc, + messageId: "duplicateKey", + data: { + key, + }, + }); + } else { + keys.set(key, node); + } + }, + "Object:exit"() { + keys = objectKeys.pop(); + }, + }; + }, +}; diff --git a/src/rules/no-empty-keys.js b/src/rules/no-empty-keys.js new file mode 100644 index 0000000..85e103b --- /dev/null +++ b/src/rules/no-empty-keys.js @@ -0,0 +1,33 @@ +/** + * @fileoverview Rule to prevent empty keys in JSON. + * @author Nicholas C. Zakas + */ + +export default { + meta: { + type: "problem", + + docs: { + description: "Disallow empty keys in JSON objects", + }, + + messages: { + emptyKey: "Empty key found.", + }, + }, + + create(context) { + return { + Member(node) { + const key = node.name.value; + + if (key.trim() === "") { + context.report({ + loc: node.name.loc, + messageId: "emptyKey", + }); + } + }, + }; + }, +}; diff --git a/tests/languages/json-language.test.js b/tests/languages/json-language.test.js new file mode 100644 index 0000000..ca02533 --- /dev/null +++ b/tests/languages/json-language.test.js @@ -0,0 +1,90 @@ +/** + * @fileoverview Tests for JSONLanguage + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { JSONLanguage } from "../../src/languages/json-language.js"; +import assert from "node:assert"; + +//----------------------------------------------------------------------------- +// Tests +//----------------------------------------------------------------------------- + +describe("JSONLanguage", () => { + describe("parse()", () => { + it("should not parse jsonc by default", () => { + const language = new JSONLanguage({ mode: "json" }); + const result = language.parse({ + body: "{\n//test\n}", + path: "test.json", + }); + + assert.strictEqual(result.ok, false); + assert.strictEqual( + result.errors[0].message, + "Unexpected character '/' found.", + ); + }); + + it("should parse json by default", () => { + const language = new JSONLanguage({ mode: "json" }); + const result = language.parse({ + body: "{\n\n}", + path: "test.json", + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(result.ast.type, "Document"); + assert.strictEqual(result.ast.body.type, "Object"); + }); + + it("should set the mode to jsonc", () => { + const language = new JSONLanguage({ mode: "jsonc" }); + const result = language.parse({ + body: "{\n//test\n}", + path: "test.jsonc", + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(result.ast.type, "Document"); + assert.strictEqual(result.ast.body.type, "Object"); + }); + }); + + describe("createSourceCode()", () => { + it("should create a JSONSourceCode instance for JSON", () => { + const language = new JSONLanguage({ mode: "json" }); + const file = { body: "{\n\n}", path: "test.json" }; + const parseResult = language.parse(file); + const sourceCode = language.createSourceCode(file, parseResult); + assert.strictEqual(sourceCode.constructor.name, "JSONSourceCode"); + + assert.strictEqual(sourceCode.ast.type, "Document"); + assert.strictEqual(sourceCode.ast.body.type, "Object"); + assert.strictEqual(sourceCode.text, "{\n\n}"); + assert.strictEqual(sourceCode.comments.length, 0); + }); + + it("should create a JSONSourceCode instance for JSONC", () => { + const language = new JSONLanguage({ mode: "jsonc" }); + const file = { body: "{\n//test\n}", path: "test.jsonc" }; + const parseResult = language.parse(file); + const sourceCode = language.createSourceCode( + file, + parseResult, + "test.jsonc", + ); + + assert.strictEqual(sourceCode.constructor.name, "JSONSourceCode"); + + assert.strictEqual(sourceCode.ast.type, "Document"); + assert.strictEqual(sourceCode.ast.body.type, "Object"); + assert.strictEqual(sourceCode.text, "{\n//test\n}"); + assert.strictEqual(sourceCode.comments.length, 1); + }); + }); +}); diff --git a/tests/languages/json-source-code.test.js b/tests/languages/json-source-code.test.js new file mode 100644 index 0000000..261bb08 --- /dev/null +++ b/tests/languages/json-source-code.test.js @@ -0,0 +1,103 @@ +/** + * @fileoverview Tests for JSONSourceCode + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { JSONSourceCode } from "../../src/languages/json-source-code.js"; +import { JSONLanguage } from "../../src/languages/json-language.js"; +import assert from "node:assert"; + +//----------------------------------------------------------------------------- +// Tests +//----------------------------------------------------------------------------- + +describe("JSONSourceCode", () => { + describe("constructor", () => { + it("should create a JSONSourceCode instance", () => { + const ast = { + type: "Document", + body: { + type: "Object", + properties: [], + }, + tokens: [], + }; + const text = "{}"; + const sourceCode = new JSONSourceCode({ + text, + ast, + }); + + assert.strictEqual(sourceCode.constructor.name, "JSONSourceCode"); + assert.strictEqual(sourceCode.ast, ast); + assert.strictEqual(sourceCode.text, text); + }); + }); + + describe("getText()", () => { + it("should return the text of the source code", () => { + const file = { body: "{}", path: "test.json" }; + const language = new JSONLanguage({ mode: "json" }); + const parseResult = language.parse(file); + const sourceCode = new JSONSourceCode({ + text: file.body, + ast: parseResult.ast, + }); + + assert.strictEqual(sourceCode.getText(), file.body); + }); + }); + + describe("comments", () => { + it("should contain an empty array when parsing JSON", () => { + const file = { body: "{}", path: "test.json" }; + const language = new JSONLanguage({ mode: "json" }); + const parseResult = language.parse(file); + const sourceCode = new JSONSourceCode({ + text: file.body, + ast: parseResult.ast, + }); + + assert.deepStrictEqual(sourceCode.comments, []); + }); + + it("should contain an array of comments when parsing JSONC", () => { + const file = { body: "{\n//test\n}", path: "test.jsonc" }; + const language = new JSONLanguage({ mode: "jsonc" }); + const parseResult = language.parse(file); + const sourceCode = new JSONSourceCode({ + text: file.body, + ast: parseResult.ast, + }); + + // should contain one comment + assert.strictEqual(sourceCode.comments.length, 1); + + const comment = sourceCode.comments[0]; + assert.strictEqual(comment.type, "LineComment"); + assert.deepStrictEqual(comment.range, [2, 8]); + assert.deepStrictEqual(comment.loc, { + start: { line: 2, column: 1, offset: 2 }, + end: { line: 2, column: 7, offset: 8 }, + }); + }); + }); + + describe("get lines", () => { + it("should return an array of lines", () => { + const file = { body: "{\n//test\n}", path: "test.jsonc" }; + const language = new JSONLanguage({ mode: "jsonc" }); + const parseResult = language.parse(file); + const sourceCode = new JSONSourceCode({ + text: file.body, + ast: parseResult.ast, + }); + + assert.deepStrictEqual(sourceCode.lines, ["{", "//test", "}"]); + }); + }); +}); diff --git a/tests/rules/no-duplicate-keys.test.js b/tests/rules/no-duplicate-keys.test.js new file mode 100644 index 0000000..34a5334 --- /dev/null +++ b/tests/rules/no-duplicate-keys.test.js @@ -0,0 +1,65 @@ +/** + * @fileoverview Tests for no-duplicate-keys rule. + * @author Nicholas C. Zakas + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import rule from "../../src/rules/no-duplicate-keys.js"; +import json from "../../src/index.js"; +import { RuleTester } from "eslint"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + plugins: { + json, + }, + language: "json/json", +}); + +ruleTester.run("no-duplicate-keys", rule, { + valid: [ + '{"foo": 1, "bar": 2}', + '{"foo": 1, "bar": 2, "baz": 3}', + "[]", + "{}", + '{"foo": 1, "bar": {"bar": 2}}', + '{"foo": { "bar": 5 }, "bar": 6 }', + ], + invalid: [ + { + code: '{"foo": 1, "foo": 2}', + errors: [ + { + messageId: "duplicateKey", + line: 1, + column: 12, + endLine: 1, + endColumn: 17, + }, + ], + }, + { + code: `{ + "foo": { + "bar": 5 + }, + "foo": 6 +}`, + errors: [ + { + messageId: "duplicateKey", + line: 5, + column: 5, + endLine: 5, + endColumn: 10, + }, + ], + }, + ], +}); diff --git a/tests/rules/no-empty-keys.test.js b/tests/rules/no-empty-keys.test.js new file mode 100644 index 0000000..c7a6065 --- /dev/null +++ b/tests/rules/no-empty-keys.test.js @@ -0,0 +1,53 @@ +/** + * @fileoverview Tests for no-empty-keys rule. + * @author Nicholas C. Zakas + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import rule from "../../src/rules/no-empty-keys.js"; +import json from "../../src/index.js"; +import { RuleTester } from "eslint"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + plugins: { + json, + }, + language: "json/json", +}); + +ruleTester.run("no-empty-keys", rule, { + valid: ['{"foo": 1, "bar": 2}'], + invalid: [ + { + code: '{"": 1}', + errors: [ + { + messageId: "emptyKey", + line: 1, + column: 2, + endLine: 1, + endColumn: 4, + }, + ], + }, + { + code: '{" ": 1}', + errors: [ + { + messageId: "emptyKey", + line: 1, + column: 2, + endLine: 1, + endColumn: 6, + }, + ], + }, + ], +}); diff --git a/tools/dedupe-types.js b/tools/dedupe-types.js new file mode 100644 index 0000000..ce8b16d --- /dev/null +++ b/tools/dedupe-types.js @@ -0,0 +1,43 @@ +/** + * @fileoverview Strips typedef aliases from the rolled-up file. This + * is necessary because the TypeScript compiler throws an error when + * it encounters a duplicate typedef. + * + * Usage: + * node scripts/strip-typedefs.js filename1.js filename2.js ... + * + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import fs from "node:fs"; + +//----------------------------------------------------------------------------- +// Main +//----------------------------------------------------------------------------- + +// read files from the command line +const files = process.argv.slice(2); + +files.forEach(filePath => { + const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/gu); + const typedefs = new Set(); + + const remainingLines = lines.filter(line => { + if (!line.startsWith("/** @typedef {import")) { + return true; + } + + if (typedefs.has(line)) { + return false; + } + + typedefs.add(line); + return true; + }); + + fs.writeFileSync(filePath, remainingLines.join("\n"), "utf8"); +}); diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 0000000..40ece13 --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "files": ["dist/esm/index.js"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3fa504c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "files": ["src/index.js"], + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "allowJs": true, + "checkJs": true, + "outDir": "dist/esm", + "target": "ES2022", + "moduleResolution": "NodeNext", + "module": "NodeNext" + } +}