fast-json-lint 0.1.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.
- fast_json_lint-0.1.0/.github/workflows/release.yml +108 -0
- fast_json_lint-0.1.0/.gitignore +2 -0
- fast_json_lint-0.1.0/Cargo.lock +135 -0
- fast_json_lint-0.1.0/Cargo.toml +14 -0
- fast_json_lint-0.1.0/PKG-INFO +33 -0
- fast_json_lint-0.1.0/README.md +18 -0
- fast_json_lint-0.1.0/pyproject.toml +23 -0
- fast_json_lint-0.1.0/src/main.rs +173 -0
- fast_json_lint-0.1.0/src/natural.rs +100 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# This file is autogenerated by maturin v1.15.0
|
|
2
|
+
# To update, run
|
|
3
|
+
#
|
|
4
|
+
# maturin generate-ci github --platform linux --platform macos
|
|
5
|
+
#
|
|
6
|
+
name: CI
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
branches:
|
|
11
|
+
- main
|
|
12
|
+
- master
|
|
13
|
+
tags:
|
|
14
|
+
- 'v*'
|
|
15
|
+
pull_request:
|
|
16
|
+
workflow_dispatch:
|
|
17
|
+
|
|
18
|
+
permissions:
|
|
19
|
+
contents: read
|
|
20
|
+
|
|
21
|
+
jobs:
|
|
22
|
+
linux:
|
|
23
|
+
runs-on: ${{ matrix.platform.runner }}
|
|
24
|
+
strategy:
|
|
25
|
+
matrix:
|
|
26
|
+
platform:
|
|
27
|
+
- runner: ubuntu-22.04
|
|
28
|
+
target: x86_64
|
|
29
|
+
- runner: ubuntu-22.04
|
|
30
|
+
target: aarch64
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/checkout@v6
|
|
33
|
+
- name: Build wheels
|
|
34
|
+
uses: PyO3/maturin-action@v1
|
|
35
|
+
with:
|
|
36
|
+
target: ${{ matrix.platform.target }}
|
|
37
|
+
args: --release --out dist
|
|
38
|
+
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
|
39
|
+
manylinux: auto
|
|
40
|
+
- name: Upload wheels
|
|
41
|
+
uses: actions/upload-artifact@v6
|
|
42
|
+
with:
|
|
43
|
+
name: wheels-linux-${{ matrix.platform.target }}
|
|
44
|
+
path: dist
|
|
45
|
+
|
|
46
|
+
macos:
|
|
47
|
+
runs-on: ${{ matrix.platform.runner }}
|
|
48
|
+
strategy:
|
|
49
|
+
matrix:
|
|
50
|
+
platform:
|
|
51
|
+
- runner: macos-15-intel
|
|
52
|
+
target: x86_64
|
|
53
|
+
- runner: macos-latest
|
|
54
|
+
target: aarch64
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/checkout@v6
|
|
57
|
+
- name: Build wheels
|
|
58
|
+
uses: PyO3/maturin-action@v1
|
|
59
|
+
with:
|
|
60
|
+
target: ${{ matrix.platform.target }}
|
|
61
|
+
args: --release --out dist
|
|
62
|
+
sccache: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
|
63
|
+
- name: Upload wheels
|
|
64
|
+
uses: actions/upload-artifact@v6
|
|
65
|
+
with:
|
|
66
|
+
name: wheels-macos-${{ matrix.platform.target }}
|
|
67
|
+
path: dist
|
|
68
|
+
|
|
69
|
+
sdist:
|
|
70
|
+
runs-on: ubuntu-latest
|
|
71
|
+
steps:
|
|
72
|
+
- uses: actions/checkout@v6
|
|
73
|
+
- name: Build sdist
|
|
74
|
+
uses: PyO3/maturin-action@v1
|
|
75
|
+
with:
|
|
76
|
+
command: sdist
|
|
77
|
+
args: --out dist
|
|
78
|
+
- name: Upload sdist
|
|
79
|
+
uses: actions/upload-artifact@v6
|
|
80
|
+
with:
|
|
81
|
+
name: wheels-sdist
|
|
82
|
+
path: dist
|
|
83
|
+
|
|
84
|
+
release:
|
|
85
|
+
name: Release
|
|
86
|
+
runs-on: ubuntu-latest
|
|
87
|
+
if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }}
|
|
88
|
+
needs: [linux, macos, sdist]
|
|
89
|
+
permissions:
|
|
90
|
+
# Use to sign the release artifacts
|
|
91
|
+
id-token: write
|
|
92
|
+
# Used to upload release artifacts
|
|
93
|
+
contents: write
|
|
94
|
+
# Used to generate artifact attestation
|
|
95
|
+
attestations: write
|
|
96
|
+
steps:
|
|
97
|
+
- uses: actions/download-artifact@v7
|
|
98
|
+
- name: Generate artifact attestation
|
|
99
|
+
uses: actions/attest@v4
|
|
100
|
+
with:
|
|
101
|
+
subject-path: 'wheels-*/*'
|
|
102
|
+
- name: Install uv
|
|
103
|
+
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
|
104
|
+
uses: astral-sh/setup-uv@v7
|
|
105
|
+
- name: Publish to PyPI
|
|
106
|
+
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
|
107
|
+
# Trusted Publishing (OIDC): configure this repo + release.yml on PyPI, no token needed.
|
|
108
|
+
run: uv publish --trusted-publishing always 'wheels-*/*'
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# This file is automatically @generated by Cargo.
|
|
2
|
+
# It is not intended for manual editing.
|
|
3
|
+
version = 4
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "equivalent"
|
|
7
|
+
version = "1.0.2"
|
|
8
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
9
|
+
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
|
10
|
+
|
|
11
|
+
[[package]]
|
|
12
|
+
name = "hashbrown"
|
|
13
|
+
version = "0.17.1"
|
|
14
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
15
|
+
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
|
16
|
+
|
|
17
|
+
[[package]]
|
|
18
|
+
name = "indexmap"
|
|
19
|
+
version = "2.14.2"
|
|
20
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
21
|
+
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
|
22
|
+
dependencies = [
|
|
23
|
+
"equivalent",
|
|
24
|
+
"hashbrown",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[[package]]
|
|
28
|
+
name = "itoa"
|
|
29
|
+
version = "1.0.18"
|
|
30
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
31
|
+
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|
32
|
+
|
|
33
|
+
[[package]]
|
|
34
|
+
name = "json-lint"
|
|
35
|
+
version = "0.1.0"
|
|
36
|
+
dependencies = [
|
|
37
|
+
"libc",
|
|
38
|
+
"serde_json",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[[package]]
|
|
42
|
+
name = "libc"
|
|
43
|
+
version = "0.2.189"
|
|
44
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
45
|
+
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
|
46
|
+
|
|
47
|
+
[[package]]
|
|
48
|
+
name = "memchr"
|
|
49
|
+
version = "2.8.3"
|
|
50
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
51
|
+
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
|
52
|
+
|
|
53
|
+
[[package]]
|
|
54
|
+
name = "proc-macro2"
|
|
55
|
+
version = "1.0.107"
|
|
56
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
57
|
+
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
|
58
|
+
dependencies = [
|
|
59
|
+
"unicode-ident",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
[[package]]
|
|
63
|
+
name = "quote"
|
|
64
|
+
version = "1.0.47"
|
|
65
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
66
|
+
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
|
67
|
+
dependencies = [
|
|
68
|
+
"proc-macro2",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[[package]]
|
|
72
|
+
name = "serde"
|
|
73
|
+
version = "1.0.229"
|
|
74
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
75
|
+
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
|
76
|
+
dependencies = [
|
|
77
|
+
"serde_core",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
[[package]]
|
|
81
|
+
name = "serde_core"
|
|
82
|
+
version = "1.0.229"
|
|
83
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
84
|
+
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
|
85
|
+
dependencies = [
|
|
86
|
+
"serde_derive",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
[[package]]
|
|
90
|
+
name = "serde_derive"
|
|
91
|
+
version = "1.0.229"
|
|
92
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
93
|
+
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|
94
|
+
dependencies = [
|
|
95
|
+
"proc-macro2",
|
|
96
|
+
"quote",
|
|
97
|
+
"syn",
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
[[package]]
|
|
101
|
+
name = "serde_json"
|
|
102
|
+
version = "1.0.151"
|
|
103
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
104
|
+
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
|
105
|
+
dependencies = [
|
|
106
|
+
"indexmap",
|
|
107
|
+
"itoa",
|
|
108
|
+
"memchr",
|
|
109
|
+
"serde",
|
|
110
|
+
"serde_core",
|
|
111
|
+
"zmij",
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
[[package]]
|
|
115
|
+
name = "syn"
|
|
116
|
+
version = "3.0.6"
|
|
117
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
118
|
+
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
|
119
|
+
dependencies = [
|
|
120
|
+
"proc-macro2",
|
|
121
|
+
"quote",
|
|
122
|
+
"unicode-ident",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
[[package]]
|
|
126
|
+
name = "unicode-ident"
|
|
127
|
+
version = "1.0.26"
|
|
128
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
129
|
+
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
|
130
|
+
|
|
131
|
+
[[package]]
|
|
132
|
+
name = "zmij"
|
|
133
|
+
version = "1.0.23"
|
|
134
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
135
|
+
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "json-lint"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2024"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
|
|
7
|
+
[dependencies]
|
|
8
|
+
libc = "0.2.189"
|
|
9
|
+
serde_json = { version = "1.0.151", features = ["preserve_order", "arbitrary_precision"] }
|
|
10
|
+
|
|
11
|
+
[profile.release]
|
|
12
|
+
lto = "fat"
|
|
13
|
+
codegen-units = 1
|
|
14
|
+
panic = "abort"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fast-json-lint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Classifier: Programming Language :: Rust
|
|
5
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
6
|
+
Classifier: Operating System :: MacOS
|
|
7
|
+
Classifier: Environment :: Console
|
|
8
|
+
Summary: Fast JSON linter and fixer: natural key sorting and 2-space formatting
|
|
9
|
+
Keywords: json,lint,formatter
|
|
10
|
+
Author-email: Kamil Kobza <kamil1pl@gmail.com>
|
|
11
|
+
License: MIT
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
14
|
+
|
|
15
|
+
# json-lint
|
|
16
|
+
|
|
17
|
+
A fast Rust JSON linter and fixer, inspired by
|
|
18
|
+
[json-linter](https://github.com/atomicptr/json-linter). It is distributed as a
|
|
19
|
+
prebuilt binary on PyPI, the same way as ruff.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install fast-json-lint
|
|
23
|
+
json-lint my-file.json some-dir/
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
For every file (or every `*.json` file in a given directory) it:
|
|
27
|
+
|
|
28
|
+
- sorts object keys recursively in natural order (`a2` before `a10`)
|
|
29
|
+
- rewrites the file with 2-space indentation and a trailing newline
|
|
30
|
+
- reports files that are not valid JSON and leaves them unchanged
|
|
31
|
+
|
|
32
|
+
It exits with status 1 if any file fails.
|
|
33
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# json-lint
|
|
2
|
+
|
|
3
|
+
A fast Rust JSON linter and fixer, inspired by
|
|
4
|
+
[json-linter](https://github.com/atomicptr/json-linter). It is distributed as a
|
|
5
|
+
prebuilt binary on PyPI, the same way as ruff.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install fast-json-lint
|
|
9
|
+
json-lint my-file.json some-dir/
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
For every file (or every `*.json` file in a given directory) it:
|
|
13
|
+
|
|
14
|
+
- sorts object keys recursively in natural order (`a2` before `a10`)
|
|
15
|
+
- rewrites the file with 2-space indentation and a trailing newline
|
|
16
|
+
- reports files that are not valid JSON and leaves them unchanged
|
|
17
|
+
|
|
18
|
+
It exits with status 1 if any file fails.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["maturin>=1.5,<2"]
|
|
3
|
+
build-backend = "maturin"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fast-json-lint"
|
|
7
|
+
description = "Fast JSON linter and fixer: natural key sorting and 2-space formatting"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.8"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Kamil Kobza", email = "kamil1pl@gmail.com" }]
|
|
12
|
+
keywords = ["json", "lint", "formatter"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Rust",
|
|
15
|
+
"Operating System :: POSIX :: Linux",
|
|
16
|
+
"Operating System :: MacOS",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
]
|
|
19
|
+
dynamic = ["version"]
|
|
20
|
+
|
|
21
|
+
[tool.maturin]
|
|
22
|
+
bindings = "bin"
|
|
23
|
+
strip = true
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
//! A Rust take on `json-linter PATH... --fix --config-set indent=2`
|
|
2
|
+
//! (json-linter 1.3.0, https://github.com/atomicptr/json-linter): sorts object
|
|
3
|
+
//! keys naturally, rewrites files with 2-space indent, then lints them.
|
|
4
|
+
|
|
5
|
+
mod natural;
|
|
6
|
+
|
|
7
|
+
use std::collections::BTreeMap;
|
|
8
|
+
use std::ffi::OsStr;
|
|
9
|
+
use std::io::{self, Write};
|
|
10
|
+
use std::os::unix::ffi::OsStrExt;
|
|
11
|
+
use std::path::Path;
|
|
12
|
+
use std::{env, fs};
|
|
13
|
+
|
|
14
|
+
use serde_json::Value;
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
const COLOR_GREEN: &str = "\x1b[92m";
|
|
18
|
+
const COLOR_RED: &str = "\x1b[91m";
|
|
19
|
+
const COLOR_RESET: &str = "\x1b[00m";
|
|
20
|
+
|
|
21
|
+
/// One rule outcome: its name and the failure message, if it failed.
|
|
22
|
+
type RuleResult = (&'static str, Option<String>);
|
|
23
|
+
|
|
24
|
+
fn main() {
|
|
25
|
+
let args: Vec<_> = env::args_os().skip(1).collect();
|
|
26
|
+
if args.is_empty() || args.iter().any(|a| a.as_bytes().starts_with(b"-")) {
|
|
27
|
+
eprintln!("usage: json-lint PATH [PATH ...]");
|
|
28
|
+
eprintln!("Fixes and lints like: json-linter PATH... --fix --config-set indent=2");
|
|
29
|
+
std::process::exit(2);
|
|
30
|
+
}
|
|
31
|
+
let mut out = io::BufWriter::new(io::stdout().lock());
|
|
32
|
+
let code = run(&args, &mut out);
|
|
33
|
+
let _ = out.flush();
|
|
34
|
+
std::process::exit(code);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fn run(args: &[std::ffi::OsString], out: &mut impl Write) -> i32 {
|
|
38
|
+
let cwd = normalize(b"", env::current_dir().unwrap().as_os_str().as_bytes());
|
|
39
|
+
let files: Vec<Vec<u8>> = args.iter().flat_map(|a| gather_files(&cwd, a.as_bytes())).collect();
|
|
40
|
+
if files.is_empty() {
|
|
41
|
+
let _ = writeln!(out, "No files found.");
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let mut per_path: BTreeMap<Vec<u8>, Vec<RuleResult>> = BTreeMap::new();
|
|
46
|
+
for file in &files {
|
|
47
|
+
let results = per_path.entry(display_path(file, &cwd)).or_default();
|
|
48
|
+
results.extend(fix_and_lint(file));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
print_header(out, "json-linter", None);
|
|
52
|
+
let (mut passed, mut total) = (0, 0);
|
|
53
|
+
for (path, results) in &per_path {
|
|
54
|
+
let _ = out.write_all(path);
|
|
55
|
+
let _ = out.write_all(b" ");
|
|
56
|
+
for (_, err) in results {
|
|
57
|
+
let (color, marker) = if err.is_none() { (COLOR_GREEN, '.') } else { (COLOR_RED, 'F') };
|
|
58
|
+
let _ = write!(out, "{color}{marker}{COLOR_RESET}");
|
|
59
|
+
}
|
|
60
|
+
let _ = writeln!(out);
|
|
61
|
+
for (name, err) in results {
|
|
62
|
+
if let Some(msg) = err {
|
|
63
|
+
let msg = if msg.is_empty() { String::new() } else { format!(": {msg}") };
|
|
64
|
+
let _ = writeln!(out, "\t{COLOR_RED}{name}{msg}{COLOR_RESET}");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
total += results.len();
|
|
68
|
+
passed += results.iter().filter(|(_, e)| e.is_none()).count();
|
|
69
|
+
}
|
|
70
|
+
print_header(out, &format!("{passed} / {total} passed"), Some(COLOR_GREEN));
|
|
71
|
+
if passed == total { 0 } else { 1 }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Rewrites the file with naturally sorted keys and 2-space indent, then
|
|
75
|
+
/// returns the rule results. Unparseable files are reported and left alone.
|
|
76
|
+
fn fix_and_lint(path: &[u8]) -> Vec<RuleResult> {
|
|
77
|
+
let value = match fix_file(path) {
|
|
78
|
+
Ok(v) => v,
|
|
79
|
+
Err(e) => return vec![("lint_file", Some(e))],
|
|
80
|
+
};
|
|
81
|
+
let sorted = natural::keys_are_sorted(&value);
|
|
82
|
+
vec![
|
|
83
|
+
("rule_keys_are_sorted", (!sorted).then(String::new)),
|
|
84
|
+
// No naming style is configured, so every key name is accepted.
|
|
85
|
+
("rule_naming_style_is_correct", None),
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fn fix_file(path: &[u8]) -> Result<Value, String> {
|
|
90
|
+
let p = as_path(path);
|
|
91
|
+
let data = fs::read(p).map_err(|e| format!("IOError: {e}"))?;
|
|
92
|
+
let mut value: Value =
|
|
93
|
+
serde_json::from_slice(&data).map_err(|e| format!("JSONDecodeError: {e}"))?;
|
|
94
|
+
natural::sort_keys(&mut value);
|
|
95
|
+
let mut fixed = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?;
|
|
96
|
+
fixed.push(b'\n');
|
|
97
|
+
fs::write(p, fixed).map_err(|e| format!("IOError: {e}"))?;
|
|
98
|
+
Ok(value)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ------------------------------------------------------------------- files --
|
|
102
|
+
|
|
103
|
+
/// Joins `path` onto `base` and normalizes it lexically, like `pathlib.Path`.
|
|
104
|
+
fn normalize(base: &[u8], path: &[u8]) -> Vec<u8> {
|
|
105
|
+
let joined = if path.starts_with(b"/") { path.to_vec() } else { [base, b"/", path].concat() };
|
|
106
|
+
let mut out = Vec::with_capacity(joined.len());
|
|
107
|
+
for part in joined.split(|&c| c == b'/').filter(|p| !p.is_empty() && *p != b".") {
|
|
108
|
+
out.push(b'/');
|
|
109
|
+
out.extend_from_slice(part);
|
|
110
|
+
}
|
|
111
|
+
if out.is_empty() {
|
|
112
|
+
out.push(b'/');
|
|
113
|
+
}
|
|
114
|
+
out
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fn as_path(p: &[u8]) -> &Path {
|
|
118
|
+
Path::new(OsStr::from_bytes(p))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// The file itself, or a directory's `*.json` entries (non-recursive, sorted).
|
|
122
|
+
fn gather_files(cwd: &[u8], arg: &[u8]) -> Vec<Vec<u8>> {
|
|
123
|
+
let path = normalize(cwd, arg);
|
|
124
|
+
let p = as_path(&path);
|
|
125
|
+
if p.is_file() {
|
|
126
|
+
return vec![path];
|
|
127
|
+
}
|
|
128
|
+
let Ok(entries) = fs::read_dir(p) else {
|
|
129
|
+
return vec![];
|
|
130
|
+
};
|
|
131
|
+
let mut files: Vec<_> = entries
|
|
132
|
+
.filter_map(Result::ok)
|
|
133
|
+
.filter(|e| e.path().is_file() && e.file_name().as_bytes().ends_with(b".json"))
|
|
134
|
+
.map(|e| normalize(&path, e.file_name().as_bytes()))
|
|
135
|
+
.collect();
|
|
136
|
+
files.sort();
|
|
137
|
+
files
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// The path relative to cwd when it is inside it, otherwise absolute.
|
|
141
|
+
fn display_path(path: &[u8], cwd: &[u8]) -> Vec<u8> {
|
|
142
|
+
if cwd == b"/" {
|
|
143
|
+
return path[1..].to_vec();
|
|
144
|
+
}
|
|
145
|
+
match path.strip_prefix(cwd) {
|
|
146
|
+
Some(rest) if rest.starts_with(b"/") => rest[1..].to_vec(),
|
|
147
|
+
_ => path.to_vec(),
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ------------------------------------------------------------------ output --
|
|
152
|
+
|
|
153
|
+
/// The text centered in `=` signs, 80 per side when stdout is not a terminal.
|
|
154
|
+
fn print_header(out: &mut impl Write, text: &str, color: Option<&str>) {
|
|
155
|
+
let width = match terminal_columns() {
|
|
156
|
+
Some(cols) => (cols as f64 / 2.0 - (text.chars().count() as f64 + 2.0) / 2.0).floor(),
|
|
157
|
+
None => 80.0,
|
|
158
|
+
};
|
|
159
|
+
let segment = "=".repeat(width.max(0.0) as usize);
|
|
160
|
+
let _ = match color {
|
|
161
|
+
Some(c) => writeln!(out, "{c}{segment} {text} {segment}{COLOR_RESET}"),
|
|
162
|
+
None => writeln!(out, "{segment} {text} {segment}"),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// Width of the terminal attached to stdout, if any.
|
|
167
|
+
fn terminal_columns() -> Option<u16> {
|
|
168
|
+
// SAFETY: TIOCGWINSZ only writes into the provided winsize struct.
|
|
169
|
+
unsafe {
|
|
170
|
+
let mut ws: libc::winsize = std::mem::zeroed();
|
|
171
|
+
(libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) == 0).then_some(ws.ws_col)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
//! Natural key ordering ("a2" < "a10") and the recursive key-sorting fixer.
|
|
2
|
+
|
|
3
|
+
use std::cmp::Ordering;
|
|
4
|
+
|
|
5
|
+
use serde_json::{Map, Value};
|
|
6
|
+
|
|
7
|
+
/// Compares strings with ASCII digit runs ordered by numeric value.
|
|
8
|
+
pub fn natural_cmp(a: &str, b: &str) -> Ordering {
|
|
9
|
+
let (mut a, mut b) = (a.as_bytes(), b.as_bytes());
|
|
10
|
+
loop {
|
|
11
|
+
match (a.first(), b.first()) {
|
|
12
|
+
(None, None) => return Ordering::Equal,
|
|
13
|
+
(None, Some(_)) => return Ordering::Less,
|
|
14
|
+
(Some(_), None) => return Ordering::Greater,
|
|
15
|
+
(Some(x), Some(y)) if x.is_ascii_digit() && y.is_ascii_digit() => {
|
|
16
|
+
let (na, ra) = split_digits(a);
|
|
17
|
+
let (nb, rb) = split_digits(b);
|
|
18
|
+
let ord = na.len().cmp(&nb.len()).then_with(|| na.cmp(nb));
|
|
19
|
+
if ord != Ordering::Equal {
|
|
20
|
+
return ord;
|
|
21
|
+
}
|
|
22
|
+
(a, b) = (ra, rb);
|
|
23
|
+
}
|
|
24
|
+
// A digit run ends the text before it, so it sorts before any other char.
|
|
25
|
+
(Some(x), Some(_)) if x.is_ascii_digit() => return Ordering::Less,
|
|
26
|
+
(Some(_), Some(y)) if y.is_ascii_digit() => return Ordering::Greater,
|
|
27
|
+
(Some(x), Some(y)) => {
|
|
28
|
+
if x != y {
|
|
29
|
+
return x.cmp(y);
|
|
30
|
+
}
|
|
31
|
+
(a, b) = (&a[1..], &b[1..]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Splits off a leading digit run, returning it without leading zeros.
|
|
38
|
+
fn split_digits(s: &[u8]) -> (&[u8], &[u8]) {
|
|
39
|
+
let end = s.iter().position(|c| !c.is_ascii_digit()).unwrap_or(s.len());
|
|
40
|
+
let zeros = s[..end].iter().take_while(|&&c| c == b'0').count();
|
|
41
|
+
(&s[zeros..end], &s[end..])
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Recursively sorts every object's keys in natural order (stable on ties).
|
|
45
|
+
pub fn sort_keys(v: &mut Value) {
|
|
46
|
+
match v {
|
|
47
|
+
Value::Array(items) => items.iter_mut().for_each(sort_keys),
|
|
48
|
+
Value::Object(map) => {
|
|
49
|
+
let mut entries: Vec<_> = std::mem::take(map).into_iter().collect();
|
|
50
|
+
entries.sort_by(|(a, _), (b, _)| natural_cmp(a, b));
|
|
51
|
+
*map = entries
|
|
52
|
+
.into_iter()
|
|
53
|
+
.map(|(k, mut v)| {
|
|
54
|
+
sort_keys(&mut v);
|
|
55
|
+
(k, v)
|
|
56
|
+
})
|
|
57
|
+
.collect::<Map<_, _>>();
|
|
58
|
+
}
|
|
59
|
+
_ => {}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// The `keys_are_sorted` rule.
|
|
64
|
+
pub fn keys_are_sorted(v: &Value) -> bool {
|
|
65
|
+
match v {
|
|
66
|
+
Value::Array(items) => items.iter().all(keys_are_sorted),
|
|
67
|
+
Value::Object(map) => {
|
|
68
|
+
let keys: Vec<_> = map.keys().collect();
|
|
69
|
+
keys.windows(2).all(|w| natural_cmp(w[0], w[1]) != Ordering::Greater)
|
|
70
|
+
&& map.values().all(keys_are_sorted)
|
|
71
|
+
}
|
|
72
|
+
_ => true,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#[cfg(test)]
|
|
77
|
+
mod tests {
|
|
78
|
+
use super::*;
|
|
79
|
+
use serde_json::json;
|
|
80
|
+
|
|
81
|
+
#[test]
|
|
82
|
+
fn natural_order() {
|
|
83
|
+
assert_eq!(natural_cmp("a2", "a10"), Ordering::Less);
|
|
84
|
+
assert_eq!(natural_cmp("a01", "a1"), Ordering::Equal);
|
|
85
|
+
assert_eq!(natural_cmp("", "a"), Ordering::Less);
|
|
86
|
+
assert_eq!(natural_cmp("10b", "9b"), Ordering::Greater);
|
|
87
|
+
assert_eq!(natural_cmp("x1", "x1y"), Ordering::Less);
|
|
88
|
+
assert_eq!(natural_cmp("B", "a"), Ordering::Less);
|
|
89
|
+
assert_eq!(natural_cmp("20Z", "-a"), Ordering::Less);
|
|
90
|
+
assert_eq!(natural_cmp("a1", "a "), Ordering::Less);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#[test]
|
|
94
|
+
fn sorts_nested() {
|
|
95
|
+
let mut v = json!([{"b10": {"z": 1, "y": 2}, "b9": 0, "a01": 1, "a1": 2}]);
|
|
96
|
+
sort_keys(&mut v);
|
|
97
|
+
assert_eq!(v.to_string(), r#"[{"a01":1,"a1":2,"b9":0,"b10":{"y":2,"z":1}}]"#);
|
|
98
|
+
assert!(keys_are_sorted(&v));
|
|
99
|
+
}
|
|
100
|
+
}
|