check-json5 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Simon van Lierde
4
+ Copyright (c) 2021 Ben Mares
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
7
+
8
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
9
+
10
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: check-json5
3
+ Version: 1.3.0
4
+ Summary: pre-commit hook to check json files for syntax parseable as json5 (with comments)
5
+ Author-email: Simon van Lierde <30519500-simonvanlierde@users.noreply.gitlab.com>
6
+ License-Expression: MIT
7
+ Project-URL: Changelog, https://github.com/simonvanlierde/check-json5/blob/main/CHANGELOG.md
8
+ Project-URL: Issues, https://github.com/simonvanlierde/check-json5/issues
9
+ Project-URL: Source, https://github.com/simonvanlierde/check-json5
10
+ Keywords: json,json5,jsonc,lint,pre-commit
11
+ Classifier: Environment :: Console
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Quality Assurance
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: json5>=0.15.0
18
+ Dynamic: license-file
19
+
20
+ # check-json5
21
+
22
+ [![CI](https://github.com/simonvanlierde/check-json5/actions/workflows/ci.yml/badge.svg)](https://github.com/simonvanlierde/check-json5/actions/workflows/ci.yml)
23
+ [![codecov](https://codecov.io/gh/simonvanlierde/check-json5/branch/main/graph/badge.svg)](https://codecov.io/gh/simonvanlierde/check-json5)
24
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
25
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit)
26
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
27
+
28
+ A [pre-commit](https://pre-commit.com/) hook that checks whether `.json` files parse as
29
+ [JSON5](https://json5.org/) (JSON with comments, trailing commas, and more). It replaces the
30
+ standard `check-json` hook, accepts JSON5 too, and keeps the speed of the C parser on ordinary
31
+ JSON.
32
+
33
+ ## Features
34
+
35
+ - Accepts JSON5 syntax (comments, trailing commas) that the standard `check-json` hook rejects.
36
+ - Every failure names its place, as `file:line:col: message`, so editors and terminals link
37
+ straight to it. A duplicate key also gets its path, and every repeat in a file is listed:
38
+
39
+ ```text
40
+ config.json:4:5: duplicate key $.server.port
41
+ config.json:9:3: duplicate key $.plugins[2].name
42
+ ```
43
+
44
+ - On GitHub Actions, each failure is also an annotation on the line of the pull request diff.
45
+ - `--strict GLOB` holds chosen files to strict JSON (no comments, no `NaN`), for files that a
46
+ JSON parser reads at runtime, such as `package.json`.
47
+ - Fast path: strict JSON is parsed with the C-speed stdlib `json` parser, falling back to the
48
+ pure-Python `json5` parser only when a file actually uses JSON5 features (~500× faster on large
49
+ strict-JSON files; see [Performance](#performance)).
50
+ - Accepts `NaN` and `Infinity` (both parsers allow them) unless `--strict` covers the file.
51
+
52
+ ## Links
53
+
54
+ - 🐙 [GitHub (main)](https://github.com/simonvanlierde/check-json5)
55
+ - 🦊 [GitLab (mirror)](https://gitlab.com/simonvanlierde/check-json5)
56
+
57
+ ## Usage
58
+
59
+ In `.pre-commit-config.yaml`, under `repos:`, add:
60
+
61
+ ```yaml
62
+ - repo: https://github.com/simonvanlierde/check-json5
63
+ rev: v1.3.0
64
+ hooks:
65
+ - id: check-json5
66
+ ```
67
+
68
+ Remove the original `check-json` hook if it is already included. A file passes when it parses as
69
+ JSON5 and has no duplicate keys. The hook picks files by extension (`.json`, `.jsonc`, `.json5`,
70
+ `.geojson`, `.jsonld`), while `check-json` picks them by type, so files such as `.babelrc` or
71
+ `.ipynb` are no longer checked after the swap. Add them with `files:` if you need them.
72
+
73
+ ### Strict files
74
+
75
+ Some JSON files are read by a strict parser at runtime: a comment in `package.json` passes a JSON5
76
+ check and then breaks `npm`. Name those files with `--strict`. Each pattern matches the file name
77
+ at any depth, or the whole path when it contains a `/`. `*` also matches across `/`. A leading `!`
78
+ exempts files, and the last matching pattern wins:
79
+
80
+ ```yaml
81
+ - id: check-json5
82
+ args: [--strict, "*.json", --strict, "!tsconfig*.json", --strict, "!.vscode/*"]
83
+ ```
84
+
85
+ ### Without pre-commit
86
+
87
+ The hook is also a command on PyPI:
88
+
89
+ ```bash
90
+ uvx check-json5 --strict package.json config/*.json
91
+ ```
92
+
93
+ ## Introduction
94
+
95
+ JSON5 is similar to JSON but permits comments, trailing commas, and more. It resembles "JSONC" (JSON
96
+ with Comments) but, unlike JSONC, has an actual specification. Many config files in the wild are
97
+ really JSON5, so the standard `check-json` hook, which rejects comments outright, flags them as
98
+ invalid. This hook accepts them.
99
+
100
+ Strict-JSON files are parsed with the fast built-in [`json`](https://docs.python.org/3/library/json.html)
101
+ library; only files that use JSON5 features fall back to the pure-Python
102
+ [`json5`](https://pypi.org/project/json5/) library.
103
+
104
+ ## Performance
105
+
106
+ The pure-Python `json5` parser is slow on large files. Since most `.json` files are strict JSON, the
107
+ hook tries the C-speed stdlib `json` parser first and only falls back to `json5` when a file uses
108
+ comments or trailing commas. Duplicate-key detection works on both paths.
109
+
110
+ Time for one hook call per file (strict JSON, taking the fast path), best of 10 runs of
111
+ `uv run python scripts/bench.py` on 2026-10-11 (Intel i9-14900KF, Linux, Python 3.13.7, json5 0.15.0,
112
+ load average 1.5):
113
+
114
+ | File size | Before (`json5` only) | After (fast path) | Speedup |
115
+ | --------- | --------------------- | ----------------- | ------- |
116
+ | 1 KB | 2.5 ms | 0.02 ms | ~100× |
117
+ | 47 KB | 123 ms | 0.19 ms | ~660× |
118
+ | 483 KB | 1.3 s | 1.8 ms | ~750× |
119
+ | 2 MB | 5.4 s | 7.7 ms | ~700× |
120
+
121
+ The script generates its fixture files in a temporary directory, so nothing large is committed.
122
+ Timings vary between runs by up to 2× on a loaded machine. The 1 KB fast-path time is close to
123
+ the timer's resolution, so its speed-up ranged from ~80× to ~100× across runs.
124
+
125
+ To time whole hook runs instead, run `bash scripts/bench.sh` (needs
126
+ [hyperfine](https://github.com/sharkdp/hyperfine)). It times the hook against the `json5` parser
127
+ on 50 KB, 500 KB and 2 MB files and prints one table; `--quick` times the 50 KB file once. Each
128
+ run includes about 20 ms of Python startup, so its speed-ups are smaller than the ones above.
129
+
130
+ Files that use JSON5 syntax still go through the `json5` parser and are unchanged. For tiny config
131
+ files the absolute saving is negligible next to Python's own startup cost; the win matters on large
132
+ JSON files.
133
+
134
+ ## Development
135
+
136
+ The project uses [uv](https://github.com/astral-sh/uv) for dependency management, `pytest` for
137
+ tests, `pre-commit` for local checks, and GitHub Actions for CI with coverage reported to Codecov.
138
+
139
+ ```bash
140
+ uv sync # install dependencies
141
+ uv run pytest # run the test suite
142
+ uv run pytest --cov # tests with coverage
143
+ uv run pre-commit run -a # run the configured hooks locally
144
+ ```
145
+
146
+ ## Credits
147
+
148
+ Adapted by Simon van Lierde from the original [check-json5](https://gitlab.com/bmares/check-json5) by
149
+ Ben Mares, itself based on the
150
+ [pre-commit-hooks](https://github.com/pre-commit/pre-commit-hooks) repository by Anthony Sottile and
151
+ contributors. This version switches dependency management from Poetry to uv and adds the fast-path
152
+ parser and benchmark above.
153
+
154
+ ## License
155
+
156
+ [MIT](LICENSE), consistent with the upstream projects it is based on
157
+ ([Ben Mares](https://gitlab.com/bmares/check-json5/-/blob/main/LICENSE),
158
+ [pre-commit-hooks](https://github.com/pre-commit/pre-commit-hooks/blob/main/LICENSE)).
@@ -0,0 +1,139 @@
1
+ # check-json5
2
+
3
+ [![CI](https://github.com/simonvanlierde/check-json5/actions/workflows/ci.yml/badge.svg)](https://github.com/simonvanlierde/check-json5/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/simonvanlierde/check-json5/branch/main/graph/badge.svg)](https://codecov.io/gh/simonvanlierde/check-json5)
5
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
6
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
8
+
9
+ A [pre-commit](https://pre-commit.com/) hook that checks whether `.json` files parse as
10
+ [JSON5](https://json5.org/) (JSON with comments, trailing commas, and more). It replaces the
11
+ standard `check-json` hook, accepts JSON5 too, and keeps the speed of the C parser on ordinary
12
+ JSON.
13
+
14
+ ## Features
15
+
16
+ - Accepts JSON5 syntax (comments, trailing commas) that the standard `check-json` hook rejects.
17
+ - Every failure names its place, as `file:line:col: message`, so editors and terminals link
18
+ straight to it. A duplicate key also gets its path, and every repeat in a file is listed:
19
+
20
+ ```text
21
+ config.json:4:5: duplicate key $.server.port
22
+ config.json:9:3: duplicate key $.plugins[2].name
23
+ ```
24
+
25
+ - On GitHub Actions, each failure is also an annotation on the line of the pull request diff.
26
+ - `--strict GLOB` holds chosen files to strict JSON (no comments, no `NaN`), for files that a
27
+ JSON parser reads at runtime, such as `package.json`.
28
+ - Fast path: strict JSON is parsed with the C-speed stdlib `json` parser, falling back to the
29
+ pure-Python `json5` parser only when a file actually uses JSON5 features (~500× faster on large
30
+ strict-JSON files; see [Performance](#performance)).
31
+ - Accepts `NaN` and `Infinity` (both parsers allow them) unless `--strict` covers the file.
32
+
33
+ ## Links
34
+
35
+ - 🐙 [GitHub (main)](https://github.com/simonvanlierde/check-json5)
36
+ - 🦊 [GitLab (mirror)](https://gitlab.com/simonvanlierde/check-json5)
37
+
38
+ ## Usage
39
+
40
+ In `.pre-commit-config.yaml`, under `repos:`, add:
41
+
42
+ ```yaml
43
+ - repo: https://github.com/simonvanlierde/check-json5
44
+ rev: v1.3.0
45
+ hooks:
46
+ - id: check-json5
47
+ ```
48
+
49
+ Remove the original `check-json` hook if it is already included. A file passes when it parses as
50
+ JSON5 and has no duplicate keys. The hook picks files by extension (`.json`, `.jsonc`, `.json5`,
51
+ `.geojson`, `.jsonld`), while `check-json` picks them by type, so files such as `.babelrc` or
52
+ `.ipynb` are no longer checked after the swap. Add them with `files:` if you need them.
53
+
54
+ ### Strict files
55
+
56
+ Some JSON files are read by a strict parser at runtime: a comment in `package.json` passes a JSON5
57
+ check and then breaks `npm`. Name those files with `--strict`. Each pattern matches the file name
58
+ at any depth, or the whole path when it contains a `/`. `*` also matches across `/`. A leading `!`
59
+ exempts files, and the last matching pattern wins:
60
+
61
+ ```yaml
62
+ - id: check-json5
63
+ args: [--strict, "*.json", --strict, "!tsconfig*.json", --strict, "!.vscode/*"]
64
+ ```
65
+
66
+ ### Without pre-commit
67
+
68
+ The hook is also a command on PyPI:
69
+
70
+ ```bash
71
+ uvx check-json5 --strict package.json config/*.json
72
+ ```
73
+
74
+ ## Introduction
75
+
76
+ JSON5 is similar to JSON but permits comments, trailing commas, and more. It resembles "JSONC" (JSON
77
+ with Comments) but, unlike JSONC, has an actual specification. Many config files in the wild are
78
+ really JSON5, so the standard `check-json` hook, which rejects comments outright, flags them as
79
+ invalid. This hook accepts them.
80
+
81
+ Strict-JSON files are parsed with the fast built-in [`json`](https://docs.python.org/3/library/json.html)
82
+ library; only files that use JSON5 features fall back to the pure-Python
83
+ [`json5`](https://pypi.org/project/json5/) library.
84
+
85
+ ## Performance
86
+
87
+ The pure-Python `json5` parser is slow on large files. Since most `.json` files are strict JSON, the
88
+ hook tries the C-speed stdlib `json` parser first and only falls back to `json5` when a file uses
89
+ comments or trailing commas. Duplicate-key detection works on both paths.
90
+
91
+ Time for one hook call per file (strict JSON, taking the fast path), best of 10 runs of
92
+ `uv run python scripts/bench.py` on 2026-10-11 (Intel i9-14900KF, Linux, Python 3.13.7, json5 0.15.0,
93
+ load average 1.5):
94
+
95
+ | File size | Before (`json5` only) | After (fast path) | Speedup |
96
+ | --------- | --------------------- | ----------------- | ------- |
97
+ | 1 KB | 2.5 ms | 0.02 ms | ~100× |
98
+ | 47 KB | 123 ms | 0.19 ms | ~660× |
99
+ | 483 KB | 1.3 s | 1.8 ms | ~750× |
100
+ | 2 MB | 5.4 s | 7.7 ms | ~700× |
101
+
102
+ The script generates its fixture files in a temporary directory, so nothing large is committed.
103
+ Timings vary between runs by up to 2× on a loaded machine. The 1 KB fast-path time is close to
104
+ the timer's resolution, so its speed-up ranged from ~80× to ~100× across runs.
105
+
106
+ To time whole hook runs instead, run `bash scripts/bench.sh` (needs
107
+ [hyperfine](https://github.com/sharkdp/hyperfine)). It times the hook against the `json5` parser
108
+ on 50 KB, 500 KB and 2 MB files and prints one table; `--quick` times the 50 KB file once. Each
109
+ run includes about 20 ms of Python startup, so its speed-ups are smaller than the ones above.
110
+
111
+ Files that use JSON5 syntax still go through the `json5` parser and are unchanged. For tiny config
112
+ files the absolute saving is negligible next to Python's own startup cost; the win matters on large
113
+ JSON files.
114
+
115
+ ## Development
116
+
117
+ The project uses [uv](https://github.com/astral-sh/uv) for dependency management, `pytest` for
118
+ tests, `pre-commit` for local checks, and GitHub Actions for CI with coverage reported to Codecov.
119
+
120
+ ```bash
121
+ uv sync # install dependencies
122
+ uv run pytest # run the test suite
123
+ uv run pytest --cov # tests with coverage
124
+ uv run pre-commit run -a # run the configured hooks locally
125
+ ```
126
+
127
+ ## Credits
128
+
129
+ Adapted by Simon van Lierde from the original [check-json5](https://gitlab.com/bmares/check-json5) by
130
+ Ben Mares, itself based on the
131
+ [pre-commit-hooks](https://github.com/pre-commit/pre-commit-hooks) repository by Anthony Sottile and
132
+ contributors. This version switches dependency management from Poetry to uv and adds the fast-path
133
+ parser and benchmark above.
134
+
135
+ ## License
136
+
137
+ [MIT](LICENSE), consistent with the upstream projects it is based on
138
+ ([Ben Mares](https://gitlab.com/bmares/check-json5/-/blob/main/LICENSE),
139
+ [pre-commit-hooks](https://github.com/pre-commit/pre-commit-hooks/blob/main/LICENSE)).
@@ -0,0 +1 @@
1
+ """Pre-commit hook to check JSON files for syntax parseable as JSON5 (with comments)."""
@@ -0,0 +1,269 @@
1
+ """Based on https://gitlab.com/bmares/check-json5/-/blob/db9b5c0f76dea9b0f28097b4421e4b40f39b2266/pypackages/pre_commit_hooks/check_json5.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import sys
9
+
10
+ # The hook starts once per commit, so its imports are a large share of a run:
11
+ # the type-only ones load for the type checker alone. `re` is free: json imports it.
12
+ TYPE_CHECKING = False
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Callable, Iterable, Iterator, Sequence
15
+ from typing import Any
16
+
17
+ # (line, column, message); line and column are 1-based, or None when the parser gives no position.
18
+ Problem = tuple[int | None, int | None, str]
19
+
20
+
21
+ class DuplicateKeyError(ValueError):
22
+ """A JSON object repeats a key."""
23
+
24
+ def __init__(self, key: str) -> None:
25
+ """Keep the repeated key for the error message."""
26
+ super().__init__(f"Duplicate key: {key}")
27
+ self.key = key
28
+
29
+
30
+ class NonStandardConstantError(ValueError):
31
+ """Strict JSON contains NaN, Infinity or -Infinity."""
32
+
33
+
34
+ def raise_duplicate_keys(ordered_pairs: Iterable[tuple[str, Any]]) -> dict[str, Any]:
35
+ """Build a dict from a JSON object's pairs, refusing a repeated key.
36
+
37
+ Raises:
38
+ DuplicateKeyError: If a key repeats.
39
+
40
+ """
41
+ d = {}
42
+ for key, val in ordered_pairs:
43
+ if key in d:
44
+ raise DuplicateKeyError(key)
45
+ d[key] = val
46
+ return d
47
+
48
+
49
+ def _reject_constant(name: str) -> float:
50
+ raise NonStandardConstantError(name)
51
+
52
+
53
+ # Only ever run over text a parser has looked at, so it can be loose: it skips what it
54
+ # does not recognise. Strings come before comments so a "//" inside one stays a string.
55
+ _TOKEN = re.compile(
56
+ r"""(?P<str>"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')
57
+ | (?P<skip>\s+|//[^\n]*|/\*.*?\*/)
58
+ | (?P<punct>[{}\[\]:,])
59
+ | (?P<bare>[^\s{}\[\]:,"'/]+)""",
60
+ re.VERBOSE | re.DOTALL,
61
+ )
62
+ _IDENTIFIER = re.compile(r"[A-Za-z_$][\w$]*")
63
+ _JSON5_ERROR = re.compile(r"<string>:(\d+) (.*) at column (\d+)", re.DOTALL)
64
+
65
+
66
+ def _tokens(text: str) -> Iterator[tuple[str, str, int]]:
67
+ for m in _TOKEN.finditer(text):
68
+ if m.lastgroup != "skip":
69
+ yield m.lastgroup or "", m.group(), m.start()
70
+
71
+
72
+ def _member(path: str, key: str) -> str:
73
+ return f"{path}.{key}" if _IDENTIFIER.fullmatch(key) else f"{path}[{json.dumps(key)}]"
74
+
75
+
76
+ def _duplicates(text: str, decode: Callable[[str], Any]) -> list[tuple[int, str]]:
77
+ """Offset and JSONPath of every key that repeats an earlier key of the same object."""
78
+ found: list[tuple[int, str]] = []
79
+ # One frame per open container: [bracket, its path, keys seen (objects), current key or index].
80
+ stack: list[list[Any]] = []
81
+ prev = ("", "", 0)
82
+ for kind, tok, offset in _tokens(text):
83
+ top = stack[-1] if stack else None
84
+ if kind != "punct":
85
+ pass
86
+ elif tok in "{[":
87
+ stack.append([tok, _child_path(top), set(), None if tok == "{" else 0])
88
+ elif tok in "}]":
89
+ if stack:
90
+ stack.pop()
91
+ elif top is None:
92
+ pass
93
+ elif tok == "," and top[0] == "[":
94
+ top[3] += 1
95
+ elif tok == ":" and top[0] == "{":
96
+ key = decode(prev[1]) if prev[0] == "str" else prev[1]
97
+ if key in top[2]:
98
+ found.append((prev[2], _member(top[1], key)))
99
+ top[2].add(key)
100
+ top[3] = key
101
+ prev = (kind, tok, offset)
102
+ return found
103
+
104
+
105
+ def _child_path(parent: list[Any] | None) -> str:
106
+ if parent is None:
107
+ return "$"
108
+ if parent[0] == "{":
109
+ return _member(parent[1], parent[3] or "")
110
+ return f"{parent[1]}[{parent[3]}]"
111
+
112
+
113
+ def _position(text: str, offset: int) -> tuple[int, int]:
114
+ return text.count("\n", 0, offset) + 1, offset - text.rfind("\n", 0, offset)
115
+
116
+
117
+ def _duplicate_problems(text: str, decode: Callable[[str], Any], exc: DuplicateKeyError) -> list[Problem]:
118
+ found = _duplicates(text, decode)
119
+ if not found: # the scanner missed what the parser saw: still fail, just without a place
120
+ return [(None, None, f"duplicate key {exc.key!r}")]
121
+ return [(*_position(text, offset), f"duplicate key {path}") for offset, path in found]
122
+
123
+
124
+ def check(text: str, *, strict: bool = False) -> list[Problem]:
125
+ """Check one file's text: JSON5 syntax and duplicate keys, or strict JSON when `strict`.
126
+
127
+ Returns:
128
+ The problems found, empty when the text passes.
129
+
130
+ Raises:
131
+ RecursionError: If the text nests deeper than the parser can follow.
132
+
133
+ """
134
+ problems = _check_json(text, strict=strict)
135
+ return _check_json5(text) if problems is None else problems
136
+
137
+
138
+ def _check_json(text: str, *, strict: bool) -> list[Problem] | None:
139
+ """Problems as strict JSON, or None when the text is not strict JSON and may be JSON5."""
140
+ try:
141
+ # Fast path: strict JSON parses at C speed and covers most files.
142
+ if strict:
143
+ json.loads(text, object_pairs_hook=raise_duplicate_keys, parse_constant=_reject_constant)
144
+ else:
145
+ json.loads(text, object_pairs_hook=raise_duplicate_keys)
146
+ except DuplicateKeyError as exc:
147
+ return _duplicate_problems(text, json.loads, exc)
148
+ except NonStandardConstantError:
149
+ return [
150
+ (*_position(text, offset), f"{tok} is not valid in strict JSON")
151
+ for kind, tok, offset in _tokens(text)
152
+ if kind == "bare" and tok in {"NaN", "Infinity", "-Infinity"}
153
+ ]
154
+ except json.JSONDecodeError as exc:
155
+ return [(exc.lineno, exc.colno, f"not strict JSON: {exc.msg}")] if strict else None
156
+ return []
157
+
158
+
159
+ def _check_json5(text: str) -> list[Problem]:
160
+ # Imported here so a run over strict JSON never pays json5's import time.
161
+ # NOTE: a top-level `lazy import` does the same, once 3.15 is the floor.
162
+ import json5 # noqa: PLC0415
163
+
164
+ # json5 re-raises an error from the hook as a plain ValueError, so keep the original here.
165
+ duplicate: list[DuplicateKeyError] = []
166
+
167
+ def hook(pairs: Iterable[tuple[str, Any]]) -> dict[str, Any]:
168
+ try:
169
+ return raise_duplicate_keys(pairs)
170
+ except DuplicateKeyError as exc:
171
+ duplicate.append(exc)
172
+ raise
173
+
174
+ try:
175
+ json5.loads(text, object_pairs_hook=hook)
176
+ except ValueError as exc:
177
+ if duplicate:
178
+ return _duplicate_problems(text, json5.loads, duplicate[0])
179
+ m = _JSON5_ERROR.fullmatch(str(exc))
180
+ return [(int(m[1]), int(m[3]), m[2])] if m else [(None, None, str(exc))]
181
+ return []
182
+
183
+
184
+ def is_strict(filename: str, patterns: Sequence[str]) -> bool:
185
+ """Whether `filename` must be strict JSON: the last matching pattern decides.
186
+
187
+ A pattern without a `/` matches the file name at any depth, one with a `/` the whole
188
+ path, `*` matches across `/`, and a leading `!` exempts the files it matches.
189
+ """
190
+ if not patterns:
191
+ return False
192
+ from fnmatch import fnmatchcase # noqa: PLC0415
193
+
194
+ path = filename.replace(os.sep, "/")
195
+ name = path.rsplit("/", 1)[-1]
196
+ strict = False
197
+ for pattern in patterns:
198
+ bare = pattern.removeprefix("!")
199
+ if fnmatchcase(path if "/" in bare else name, bare):
200
+ strict = not pattern.startswith("!")
201
+ return strict
202
+
203
+
204
+ def _escape(value: str, *, prop: bool = False) -> str:
205
+ value = value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
206
+ return value.replace(":", "%3A").replace(",", "%2C") if prop else value
207
+
208
+
209
+ def report(filename: str, problem: Problem, *, annotate: bool = False) -> None:
210
+ """Print one problem as `file:line:col: message`, plus a GitHub annotation when `annotate`."""
211
+ line, col, msg = problem
212
+ print(f"{filename}: {msg}" if line is None else f"{filename}:{line}:{col}: {msg}")
213
+ if annotate:
214
+ where = "" if line is None else f",line={line},col={col}"
215
+ print(f"::error file={_escape(filename, prop=True)}{where},title=check-json5::{_escape(msg)}")
216
+
217
+
218
+ def main(argv: Sequence[str] | None = None) -> int:
219
+ """Check JSON5 files for syntax errors and duplicate keys.
220
+
221
+ Args:
222
+ argv: options and filenames. If None, defaults to sys.argv[1:].
223
+
224
+ Returns:
225
+ An exit code indicating success (0) or failure (1).
226
+
227
+ """
228
+ args = sys.argv[1:] if argv is None else list(argv)
229
+ strict_patterns: list[str] = []
230
+ if any(a.startswith("-") for a in args): # argparse costs a few ms, so only load it for options
231
+ import argparse # noqa: PLC0415
232
+
233
+ parser = argparse.ArgumentParser(prog="check-json5", description="Check JSON5 files.")
234
+ parser.add_argument(
235
+ "--strict",
236
+ action="append",
237
+ default=[],
238
+ metavar="GLOB",
239
+ help="files matching GLOB must be strict JSON; repeat it, `!GLOB` exempts (last match wins)",
240
+ )
241
+ parser.add_argument("filenames", nargs="*")
242
+ ns = parser.parse_args(args)
243
+ args, strict_patterns = ns.filenames, ns.strict
244
+
245
+ # Python caps int literals at 4300 digits (3.10.7+), which fails valid JSON with a long number.
246
+ if hasattr(sys, "set_int_max_str_digits"):
247
+ sys.set_int_max_str_digits(0)
248
+ annotate = os.environ.get("GITHUB_ACTIONS") == "true"
249
+ retval = 0
250
+ for filename in args:
251
+ if not os.path.isfile(filename): # noqa: PTH113
252
+ problems: list[Problem] = [(None, None, "No such file")]
253
+ else:
254
+ try:
255
+ # utf-8-sig transparently strips a leading BOM if present.
256
+ with open(filename, encoding="utf-8-sig") as f: # noqa: PTH123
257
+ problems = check(f.read(), strict=is_strict(filename, strict_patterns))
258
+ except (OSError, UnicodeDecodeError) as exc:
259
+ problems = [(None, None, f"Failed to read ({exc})")]
260
+ except RecursionError: # fails this file instead of ending the run
261
+ problems = [(None, None, "nested too deeply to check")]
262
+ for problem in problems:
263
+ report(filename, problem, annotate=annotate)
264
+ retval |= bool(problems)
265
+ return retval
266
+
267
+
268
+ if __name__ == "__main__":
269
+ raise SystemExit(main())
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: check-json5
3
+ Version: 1.3.0
4
+ Summary: pre-commit hook to check json files for syntax parseable as json5 (with comments)
5
+ Author-email: Simon van Lierde <30519500-simonvanlierde@users.noreply.gitlab.com>
6
+ License-Expression: MIT
7
+ Project-URL: Changelog, https://github.com/simonvanlierde/check-json5/blob/main/CHANGELOG.md
8
+ Project-URL: Issues, https://github.com/simonvanlierde/check-json5/issues
9
+ Project-URL: Source, https://github.com/simonvanlierde/check-json5
10
+ Keywords: json,json5,jsonc,lint,pre-commit
11
+ Classifier: Environment :: Console
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Quality Assurance
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: json5>=0.15.0
18
+ Dynamic: license-file
19
+
20
+ # check-json5
21
+
22
+ [![CI](https://github.com/simonvanlierde/check-json5/actions/workflows/ci.yml/badge.svg)](https://github.com/simonvanlierde/check-json5/actions/workflows/ci.yml)
23
+ [![codecov](https://codecov.io/gh/simonvanlierde/check-json5/branch/main/graph/badge.svg)](https://codecov.io/gh/simonvanlierde/check-json5)
24
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
25
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit)
26
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
27
+
28
+ A [pre-commit](https://pre-commit.com/) hook that checks whether `.json` files parse as
29
+ [JSON5](https://json5.org/) (JSON with comments, trailing commas, and more). It replaces the
30
+ standard `check-json` hook, accepts JSON5 too, and keeps the speed of the C parser on ordinary
31
+ JSON.
32
+
33
+ ## Features
34
+
35
+ - Accepts JSON5 syntax (comments, trailing commas) that the standard `check-json` hook rejects.
36
+ - Every failure names its place, as `file:line:col: message`, so editors and terminals link
37
+ straight to it. A duplicate key also gets its path, and every repeat in a file is listed:
38
+
39
+ ```text
40
+ config.json:4:5: duplicate key $.server.port
41
+ config.json:9:3: duplicate key $.plugins[2].name
42
+ ```
43
+
44
+ - On GitHub Actions, each failure is also an annotation on the line of the pull request diff.
45
+ - `--strict GLOB` holds chosen files to strict JSON (no comments, no `NaN`), for files that a
46
+ JSON parser reads at runtime, such as `package.json`.
47
+ - Fast path: strict JSON is parsed with the C-speed stdlib `json` parser, falling back to the
48
+ pure-Python `json5` parser only when a file actually uses JSON5 features (~500× faster on large
49
+ strict-JSON files; see [Performance](#performance)).
50
+ - Accepts `NaN` and `Infinity` (both parsers allow them) unless `--strict` covers the file.
51
+
52
+ ## Links
53
+
54
+ - 🐙 [GitHub (main)](https://github.com/simonvanlierde/check-json5)
55
+ - 🦊 [GitLab (mirror)](https://gitlab.com/simonvanlierde/check-json5)
56
+
57
+ ## Usage
58
+
59
+ In `.pre-commit-config.yaml`, under `repos:`, add:
60
+
61
+ ```yaml
62
+ - repo: https://github.com/simonvanlierde/check-json5
63
+ rev: v1.3.0
64
+ hooks:
65
+ - id: check-json5
66
+ ```
67
+
68
+ Remove the original `check-json` hook if it is already included. A file passes when it parses as
69
+ JSON5 and has no duplicate keys. The hook picks files by extension (`.json`, `.jsonc`, `.json5`,
70
+ `.geojson`, `.jsonld`), while `check-json` picks them by type, so files such as `.babelrc` or
71
+ `.ipynb` are no longer checked after the swap. Add them with `files:` if you need them.
72
+
73
+ ### Strict files
74
+
75
+ Some JSON files are read by a strict parser at runtime: a comment in `package.json` passes a JSON5
76
+ check and then breaks `npm`. Name those files with `--strict`. Each pattern matches the file name
77
+ at any depth, or the whole path when it contains a `/`. `*` also matches across `/`. A leading `!`
78
+ exempts files, and the last matching pattern wins:
79
+
80
+ ```yaml
81
+ - id: check-json5
82
+ args: [--strict, "*.json", --strict, "!tsconfig*.json", --strict, "!.vscode/*"]
83
+ ```
84
+
85
+ ### Without pre-commit
86
+
87
+ The hook is also a command on PyPI:
88
+
89
+ ```bash
90
+ uvx check-json5 --strict package.json config/*.json
91
+ ```
92
+
93
+ ## Introduction
94
+
95
+ JSON5 is similar to JSON but permits comments, trailing commas, and more. It resembles "JSONC" (JSON
96
+ with Comments) but, unlike JSONC, has an actual specification. Many config files in the wild are
97
+ really JSON5, so the standard `check-json` hook, which rejects comments outright, flags them as
98
+ invalid. This hook accepts them.
99
+
100
+ Strict-JSON files are parsed with the fast built-in [`json`](https://docs.python.org/3/library/json.html)
101
+ library; only files that use JSON5 features fall back to the pure-Python
102
+ [`json5`](https://pypi.org/project/json5/) library.
103
+
104
+ ## Performance
105
+
106
+ The pure-Python `json5` parser is slow on large files. Since most `.json` files are strict JSON, the
107
+ hook tries the C-speed stdlib `json` parser first and only falls back to `json5` when a file uses
108
+ comments or trailing commas. Duplicate-key detection works on both paths.
109
+
110
+ Time for one hook call per file (strict JSON, taking the fast path), best of 10 runs of
111
+ `uv run python scripts/bench.py` on 2026-10-11 (Intel i9-14900KF, Linux, Python 3.13.7, json5 0.15.0,
112
+ load average 1.5):
113
+
114
+ | File size | Before (`json5` only) | After (fast path) | Speedup |
115
+ | --------- | --------------------- | ----------------- | ------- |
116
+ | 1 KB | 2.5 ms | 0.02 ms | ~100× |
117
+ | 47 KB | 123 ms | 0.19 ms | ~660× |
118
+ | 483 KB | 1.3 s | 1.8 ms | ~750× |
119
+ | 2 MB | 5.4 s | 7.7 ms | ~700× |
120
+
121
+ The script generates its fixture files in a temporary directory, so nothing large is committed.
122
+ Timings vary between runs by up to 2× on a loaded machine. The 1 KB fast-path time is close to
123
+ the timer's resolution, so its speed-up ranged from ~80× to ~100× across runs.
124
+
125
+ To time whole hook runs instead, run `bash scripts/bench.sh` (needs
126
+ [hyperfine](https://github.com/sharkdp/hyperfine)). It times the hook against the `json5` parser
127
+ on 50 KB, 500 KB and 2 MB files and prints one table; `--quick` times the 50 KB file once. Each
128
+ run includes about 20 ms of Python startup, so its speed-ups are smaller than the ones above.
129
+
130
+ Files that use JSON5 syntax still go through the `json5` parser and are unchanged. For tiny config
131
+ files the absolute saving is negligible next to Python's own startup cost; the win matters on large
132
+ JSON files.
133
+
134
+ ## Development
135
+
136
+ The project uses [uv](https://github.com/astral-sh/uv) for dependency management, `pytest` for
137
+ tests, `pre-commit` for local checks, and GitHub Actions for CI with coverage reported to Codecov.
138
+
139
+ ```bash
140
+ uv sync # install dependencies
141
+ uv run pytest # run the test suite
142
+ uv run pytest --cov # tests with coverage
143
+ uv run pre-commit run -a # run the configured hooks locally
144
+ ```
145
+
146
+ ## Credits
147
+
148
+ Adapted by Simon van Lierde from the original [check-json5](https://gitlab.com/bmares/check-json5) by
149
+ Ben Mares, itself based on the
150
+ [pre-commit-hooks](https://github.com/pre-commit/pre-commit-hooks) repository by Anthony Sottile and
151
+ contributors. This version switches dependency management from Poetry to uv and adds the fast-path
152
+ parser and benchmark above.
153
+
154
+ ## License
155
+
156
+ [MIT](LICENSE), consistent with the upstream projects it is based on
157
+ ([Ben Mares](https://gitlab.com/bmares/check-json5/-/blob/main/LICENSE),
158
+ [pre-commit-hooks](https://github.com/pre-commit/pre-commit-hooks/blob/main/LICENSE)).
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ check_json5/__init__.py
5
+ check_json5/check_json5.py
6
+ check_json5.egg-info/PKG-INFO
7
+ check_json5.egg-info/SOURCES.txt
8
+ check_json5.egg-info/dependency_links.txt
9
+ check_json5.egg-info/entry_points.txt
10
+ check_json5.egg-info/requires.txt
11
+ check_json5.egg-info/top_level.txt
12
+ tests/test_check_json5.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ check-json5 = check_json5.check_json5:main
@@ -0,0 +1 @@
1
+ json5>=0.15.0
@@ -0,0 +1 @@
1
+ check_json5
@@ -0,0 +1,71 @@
1
+ [build-system]
2
+ build-backend = "setuptools.build_meta"
3
+ # 77 reads the SPDX `license` string below.
4
+ requires = ["setuptools>=77"]
5
+
6
+ [project]
7
+ authors = [{ "name" = "Simon van Lierde", "email" = "30519500-simonvanlierde@users.noreply.gitlab.com" }]
8
+ classifiers = [
9
+ "Environment :: Console",
10
+ "Programming Language :: Python :: 3",
11
+ "Topic :: Software Development :: Quality Assurance",
12
+ ]
13
+ dependencies = ["json5>=0.15.0"]
14
+ description = "pre-commit hook to check json files for syntax parseable as json5 (with comments)"
15
+ keywords = ["json", "json5", "jsonc", "lint", "pre-commit"]
16
+ license = "MIT"
17
+ name = "check-json5"
18
+ readme = "README.md"
19
+ requires-python = ">=3.10"
20
+ version = "1.3.0"
21
+
22
+ [project.urls]
23
+ Changelog = "https://github.com/simonvanlierde/check-json5/blob/main/CHANGELOG.md"
24
+ Issues = "https://github.com/simonvanlierde/check-json5/issues"
25
+ Source = "https://github.com/simonvanlierde/check-json5"
26
+
27
+ [project.scripts]
28
+ check-json5 = "check_json5.check_json5:main"
29
+
30
+ [dependency-groups]
31
+ dev = ["pre-commit>=4.6.0", "pytest>=8", "pytest-cov>=5", "ruff>=0.15.20", "ty>=0.0.56"]
32
+
33
+ [tool.ruff]
34
+ fix = true
35
+ line-length = 120
36
+ target-version = "py310"
37
+
38
+ [tool.ruff.format]
39
+ docstring-code-format = true
40
+
41
+ [tool.ruff.lint]
42
+ extend-select = ["ALL"]
43
+ fixable = ["ALL"]
44
+
45
+ # These rules are ignored to prevent conflicts with formatter or because they are overly strict
46
+ ignore = [
47
+ "COM812", # missing-trailing-comma: conflicts with the formatter
48
+ "CPY001", # missing-copyright-notice: LICENSE covers the project
49
+ "D206", # indent-with-spaces
50
+ "D300", # triple-single-quotes
51
+ "E111", # indentation-with-invalid-multiple
52
+ "E114", # indentation-with-invalid-multiple-comment
53
+ "E117", # over-indented
54
+ "ISC001", # single-line-implicit-string-concatenation
55
+ "ISC002", # multi-line-implicit-string-concatenation
56
+ "RET504", # unnecessary-assign
57
+ "W191", # tab-indentation
58
+ ]
59
+
60
+ [tool.ruff.lint.per-file-ignores]
61
+ "tests/**" = ["S101", "D103"]
62
+ # Standalone script that prints a results table.
63
+ "scripts/**" = ["INP001", "T201"]
64
+ # CLI entry point: printing failures to stdout is the hook's job.
65
+ "check_json5/check_json5.py" = ["T201"]
66
+
67
+ [tool.ruff.lint.pydocstyle]
68
+ convention = "google"
69
+
70
+ [tool.uv]
71
+ package = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,208 @@
1
+ """Tests for the check-json5 pre-commit hook."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from check_json5.check_json5 import _escape, check, is_strict, main
11
+
12
+
13
+ @pytest.fixture(autouse=True)
14
+ def _no_github_actions(monkeypatch: pytest.MonkeyPatch) -> None:
15
+ # CI sets it, and its annotation lines would break the exact-output assertions.
16
+ monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
17
+
18
+
19
+ def write(tmp_path: Path, content: str) -> str:
20
+ """Write content to a temp .json5 file and return its path."""
21
+ f = tmp_path / "f.json5"
22
+ f.write_text(content)
23
+ return str(f)
24
+
25
+
26
+ def test_valid_json5_passes(tmp_path: Path) -> None:
27
+ assert main([write(tmp_path, "{a: 1, /* comment */ b: 2,}")]) == 0
28
+
29
+
30
+ def test_invalid_syntax_fails(tmp_path: Path) -> None:
31
+ assert main([write(tmp_path, "{a: 1, b:}")]) == 1
32
+
33
+
34
+ def test_duplicate_keys_fail(tmp_path: Path) -> None:
35
+ assert main([write(tmp_path, "{a: 1, a: 2}")]) == 1
36
+
37
+
38
+ def test_nested_duplicate_keys_fail(tmp_path: Path) -> None:
39
+ assert main([write(tmp_path, "{outer: {a: 1, a: 2}}")]) == 1
40
+
41
+
42
+ def test_strict_json_fast_path_passes(tmp_path: Path) -> None:
43
+ # Strict JSON takes the stdlib fast path, never reaching json5.
44
+ assert main([write(tmp_path, '{"a": 1, "b": 2}')]) == 0
45
+
46
+
47
+ def test_strict_json_run_skips_slow_imports(tmp_path: Path) -> None:
48
+ # A fresh interpreter: this one has them loaded already. Each costs a few ms per hook run.
49
+ f = write(tmp_path, '{"a": 1}')
50
+ code = (
51
+ "import json, sys; before = set(sys.modules); from check_json5.check_json5 import main; "
52
+ f"main([{f!r}]); print(json.dumps(sorted(set(sys.modules) - before)))"
53
+ )
54
+ out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True).stdout # noqa: S603
55
+ loaded = set(json.loads(out))
56
+ assert not loaded & {"argparse", "json5", "pathlib", "typing"}, loaded
57
+
58
+
59
+ def test_strict_json_duplicate_keys_fail(tmp_path: Path) -> None:
60
+ # Dup keys must fail on the fast path, not fall back to json5.
61
+ assert main([write(tmp_path, '{"a": 1, "a": 2}')]) == 1
62
+
63
+
64
+ def test_non_utf8_file_fails_cleanly(tmp_path: Path) -> None:
65
+ # An undecodable file fails that file, not the whole run.
66
+ f = tmp_path / "f.json5"
67
+ f.write_bytes(b'{"a": "\xff\xfe"}')
68
+ assert main([str(f)]) == 1
69
+
70
+
71
+ def test_missing_file_fails(tmp_path: Path) -> None:
72
+ assert main([str(tmp_path / "nope.json5")]) == 1
73
+
74
+
75
+ def test_no_files_passes() -> None:
76
+ assert main([]) == 0
77
+
78
+
79
+ @pytest.mark.parametrize(
80
+ ("name", "content", "expected"),
81
+ [
82
+ ("utf8_bom", b'\xef\xbb\xbf{"a": 1}', 0),
83
+ ("empty_file", b"", 1),
84
+ ("whitespace_only", b" \n ", 1),
85
+ ("top_level_scalar", b"42", 0),
86
+ ("nan_infinity", b'{"a": NaN, "b": Infinity, "c": -Infinity}', 0),
87
+ ("duplicate_keys_in_array_of_objects", b'[{"a": 1, "a": 2}]', 1),
88
+ ("duplicate_keys_on_json5_fallback", b'// comment\n{"a": 1, "a": 2}', 1),
89
+ ],
90
+ )
91
+ def test_edge_cases(tmp_path: Path, name: str, content: bytes, expected: int) -> None:
92
+ f = tmp_path / f"{name}.json"
93
+ f.write_bytes(content)
94
+ assert main([str(f)]) == expected
95
+
96
+
97
+ def test_int_over_4300_digits_passes(tmp_path: Path) -> None:
98
+ assert main([write(tmp_path, '{"n": ' + "1" * 5000 + "}")]) == 0
99
+
100
+
101
+ @pytest.mark.parametrize("prefix", ["", "// comment\n"], ids=["fast_path", "json5_fallback"])
102
+ def test_nesting_past_recursion_limit_fails_only_that_file(
103
+ tmp_path: Path, capsys: pytest.CaptureFixture[str], prefix: str
104
+ ) -> None:
105
+ deep = tmp_path / "deep.json"
106
+ deep.write_text(prefix + "[" * 100_000 + "]" * 100_000)
107
+ bad = tmp_path / "bad.json"
108
+ bad.write_text("{a:}")
109
+ assert main([str(deep), str(bad)]) == 1
110
+ out = capsys.readouterr().out
111
+ # Python 3.14's C parser no longer recurses on arrays, so the deep file may pass there.
112
+ assert f"{deep}:" not in out or f"{deep}: nested too deeply to check" in out
113
+ assert f"{bad}:1:4: Unexpected" in out
114
+
115
+
116
+ def test_mixed_pass_and_fail_returns_1(tmp_path: Path) -> None:
117
+ assert main([write(tmp_path, "{a: 1}"), write(tmp_path, "{a:}")]) == 1
118
+
119
+
120
+ @pytest.mark.parametrize(
121
+ ("text", "expected"),
122
+ [
123
+ ("// c\n{\n a: 1,\n b: ,\n}", [(4, 6, 'Unexpected ","')]),
124
+ ("", [(None, None, "Empty strings are not legal JSON5")]),
125
+ ('{"a": 1,\n "a": 2}', [(2, 2, "duplicate key $.a")]),
126
+ # Nested, in arrays, through an escape, and each one reported.
127
+ (
128
+ '{"x": {"a": 1, "\\u0061": 2},\n "l": [{"k": 1}, {"k": 2, "k": 3}],\n "x": 0}',
129
+ [(1, 16, "duplicate key $.x.a"), (2, 27, "duplicate key $.l[1].k"), (3, 2, "duplicate key $.x")],
130
+ ),
131
+ # A key-like string or comment is not a key.
132
+ ('{"s": "\\"a\\": 1, // no", /* "s": */ "s": 2}', [(1, 37, "duplicate key $.s")]),
133
+ (
134
+ "// c\n{a: 1, 'a': 2, \"b c\": {d: 1, d: 2}}",
135
+ [(2, 8, "duplicate key $.a"), (2, 30, 'duplicate key $["b c"].d')],
136
+ ),
137
+ ],
138
+ )
139
+ def test_problems_carry_their_position(text: str, expected: list) -> None:
140
+ assert check(text) == expected
141
+
142
+
143
+ @pytest.mark.parametrize(
144
+ ("text", "expected"),
145
+ [
146
+ ('{"a": 1}', []),
147
+ ("{a: 1}", [(1, 2, "not strict JSON: Expecting property name enclosed in double quotes")]),
148
+ ("// c\n{}", [(1, 1, "not strict JSON: Expecting value")]),
149
+ (
150
+ '{"a": NaN,\n "b": [-Infinity]}',
151
+ [(1, 7, "NaN is not valid in strict JSON"), (2, 8, "-Infinity is not valid in strict JSON")],
152
+ ),
153
+ ('{"a": 1, "a": 2}', [(1, 10, "duplicate key $.a")]),
154
+ ],
155
+ )
156
+ def test_strict_mode(text: str, expected: list) -> None:
157
+ assert check(text, strict=True) == expected
158
+
159
+
160
+ @pytest.mark.parametrize(
161
+ ("filename", "patterns", "expected"),
162
+ [
163
+ ("a/package.json", [], False),
164
+ ("a/package.json", ["package.json"], True),
165
+ ("a/b/x.json", ["*.json"], True),
166
+ ("a/tsconfig.json", ["*.json", "!tsconfig*.json"], False),
167
+ ("a/tsconfig.json", ["!tsconfig*.json", "*.json"], True),
168
+ ("src/x.json", ["src/*.json"], True),
169
+ ("lib/src/x.json", ["src/*.json"], False),
170
+ ],
171
+ )
172
+ def test_is_strict(filename: str, patterns: list[str], *, expected: bool) -> None:
173
+ assert is_strict(filename, patterns) is expected
174
+
175
+
176
+ def test_strict_flag_applies_per_file(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
177
+ (tmp_path / "package.json").write_text('{"a": 1, // c\n}')
178
+ (tmp_path / "tsconfig.json").write_text('{"a": 1, // c\n}')
179
+ files = [str(tmp_path / "package.json"), str(tmp_path / "tsconfig.json")]
180
+ assert main(["--strict", "*.json", "--strict", "!tsconfig.json", *files]) == 1
181
+ out = capsys.readouterr().out
182
+ assert "package.json:1:10: not strict JSON" in out
183
+ assert "tsconfig.json" not in out
184
+
185
+
186
+ def test_output_is_file_line_col(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
187
+ f = write(tmp_path, "{a:}")
188
+ assert main([f]) == 1
189
+ assert capsys.readouterr().out == f'{f}:1:4: Unexpected "}}"\n'
190
+
191
+
192
+ def test_github_annotations(
193
+ tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
194
+ ) -> None:
195
+ monkeypatch.setenv("GITHUB_ACTIONS", "true")
196
+ f = tmp_path / "a,b:c.json"
197
+ f.write_text('{"a": 1, "a": 2}')
198
+ missing = tmp_path / "gone.json"
199
+ assert main([str(f), str(missing)]) == 1
200
+ lines = capsys.readouterr().out.splitlines()
201
+ escaped = str(f).replace(":", "%3A").replace(",", "%2C")
202
+ assert lines[1] == f"::error file={escaped},line=1,col=10,title=check-json5::duplicate key $.a"
203
+ assert lines[3] == f"::error file={missing},title=check-json5::No such file"
204
+
205
+
206
+ def test_message_escaping_for_annotations() -> None:
207
+ assert _escape("50% a\nb") == "50%25 a%0Ab"
208
+ assert _escape("a:b,c", prop=True) == "a%3Ab%2Cc"