Skip to main content

sort_package_json/
lib.rs

1//! Sort `package.json` fields according to established npm conventions.
2//!
3//! The default API returns pretty-printed JSON with a trailing newline:
4//!
5//! ```
6//! let input = r#"{"version":"1.0.0","name":"example"}"#;
7//! let sorted = sort_package_json::sort_package_json(input)?;
8//!
9//! assert_eq!(sorted, "{\n  \"name\": \"example\",\n  \"version\": \"1.0.0\"\n}\n");
10//! # Ok::<(), serde_json::Error>(())
11//! ```
12
13mod value;
14
15use std::borrow::Cow;
16
17use value::{Object, Value};
18
19/// UTF-8 BOM (`U+FEFF`).
20const BOM_STR: &str = "\u{FEFF}";
21
22/// Options for controlling JSON formatting when sorting.
23#[derive(Debug, Clone, Copy, Eq, PartialEq)]
24#[non_exhaustive]
25pub struct SortOptions {
26    /// Whether to pretty-print the output JSON.
27    pub pretty: bool,
28    /// Whether to sort the scripts field alphabetically.
29    pub sort_scripts: bool,
30}
31
32impl SortOptions {
33    /// Creates options with the default behavior.
34    #[must_use]
35    pub const fn new() -> Self {
36        Self { pretty: true, sort_scripts: false }
37    }
38
39    /// Sets whether the output is pretty-printed.
40    #[must_use]
41    pub const fn with_pretty(mut self, pretty: bool) -> Self {
42        self.pretty = pretty;
43        self
44    }
45
46    /// Sets whether the `scripts`, `betterScripts`, and `wireit` fields are sorted.
47    #[must_use]
48    pub const fn with_sort_scripts(mut self, sort_scripts: bool) -> Self {
49        self.sort_scripts = sort_scripts;
50        self
51    }
52}
53
54impl Default for SortOptions {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60/// Sorts a `package.json` string using custom [`SortOptions`].
61///
62/// A UTF-8 byte order mark in `input` is preserved. Pretty-printed output ends in a newline;
63/// compact output does not.
64///
65/// # Errors
66///
67/// Returns an error when `input` is not valid JSON.
68pub fn sort_package_json_with_options(
69    input: &str,
70    options: &SortOptions,
71) -> Result<String, serde_json::Error> {
72    let (has_bom, body) =
73        input.strip_prefix(BOM_STR).map_or((false, input), |stripped| (true, stripped));
74
75    let value: Value<'_> = serde_json::from_str(body)?;
76
77    let sorted = match value {
78        Value::Object(obj) => Value::Object(sort_object_keys(obj, options)),
79        other => other,
80    };
81
82    // Serialize directly into a byte buffer so the (optional) BOM, the JSON body, and the
83    // trailing newline are all written into a single allocation. This skips the extra
84    // String allocation + copy that `to_string_pretty` followed by manual BOM-prepending
85    // would incur.
86    //
87    // Sized for the common case where the input is already pretty-printed: output ≈ input
88    // in length. The `+ 16` absorbs the trailing `'\n'` push and minor reformatting slop
89    // without forcing a final realloc.
90    let mut buf: Vec<u8> = Vec::with_capacity(input.len() + 16);
91    if has_bom {
92        buf.extend_from_slice(BOM_STR.as_bytes());
93    }
94    if options.pretty {
95        serde_json::to_writer_pretty(&mut buf, &sorted)?;
96        buf.push(b'\n');
97    } else {
98        serde_json::to_writer(&mut buf, &sorted)?;
99    }
100    // SAFETY: `serde_json::to_writer{,_pretty}` are contractually required to emit valid
101    // UTF-8 (this is also what `serde_json::to_string_pretty` itself relies on). The BOM
102    // bytes and the trailing `\n` are also valid UTF-8.
103    Ok(unsafe { String::from_utf8_unchecked(buf) })
104}
105
106/// Sorts a `package.json` string with the default options.
107///
108/// The output is pretty-printed with a trailing newline. Script order is preserved.
109///
110/// # Errors
111///
112/// Returns an error when `input` is not valid JSON.
113pub fn sort_package_json(input: &str) -> Result<String, serde_json::Error> {
114    sort_package_json_with_options(input, &SortOptions::default())
115}
116
117// ===== Value-level transformations ==========================================
118
119#[inline]
120fn transform_value<'a, F>(value: Value<'a>, f: F) -> Value<'a>
121where
122    F: FnOnce(Object<'a>) -> Object<'a>,
123{
124    match value {
125        Value::Object(o) => Value::Object(f(o)),
126        other => other,
127    }
128}
129
130#[inline]
131fn transform_array<'a, F>(value: Value<'a>, f: F) -> Value<'a>
132where
133    F: FnOnce(Vec<Value<'a>>) -> Vec<Value<'a>>,
134{
135    match value {
136        Value::Array(arr) => Value::Array(f(arr)),
137        other => other,
138    }
139}
140
141#[inline]
142fn transform_with_key_order<'a>(value: Value<'a>, key_order: &[&str]) -> Value<'a> {
143    transform_value(value, |o| sort_object_by_key_order(o, key_order))
144}
145
146fn sort_object_alphabetically(mut obj: Object<'_>) -> Object<'_> {
147    obj.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
148    obj
149}
150
151fn sort_object_recursive(mut obj: Object<'_>) -> Object<'_> {
152    sort_object_recursive_in_place(&mut obj);
153    obj
154}
155
156fn sort_object_recursive_in_place(obj: &mut Object<'_>) {
157    for (_, value) in &mut *obj {
158        if let Value::Object(nested) = value {
159            sort_object_recursive_in_place(nested);
160        }
161    }
162    obj.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
163}
164
165/// Filters non-strings, sorts ascending, and removes duplicates.
166fn sort_array_unique(mut arr: Vec<Value<'_>>) -> Vec<Value<'_>> {
167    arr.retain(Value::is_string);
168    // `unwrap` is sound: `retain` above guarantees every element is a string.
169    arr.sort_unstable_by(|a, b| a.as_str().unwrap().cmp(b.as_str().unwrap()));
170    arr.dedup_by(|a, b| a.as_str() == b.as_str());
171    arr
172}
173
174/// Removes duplicate string entries while preserving original order. Used for fields
175/// where order matters (e.g., `files` with `!` negation patterns).
176fn dedupe_array(mut arr: Vec<Value<'_>>) -> Vec<Value<'_>> {
177    let mut write = 0;
178    for read in 0..arr.len() {
179        let keep = match arr[read].as_str() {
180            Some(s) => !arr[..write].iter().any(|seen| seen.as_str() == Some(s)),
181            None => false,
182        };
183        if keep {
184            if write != read {
185                arr.swap(write, read);
186            }
187            write += 1;
188        }
189    }
190    arr.truncate(write);
191    arr
192}
193
194/// Reorders `obj` so that any keys present in `key_order` appear first (in the given
195/// order), with the remaining keys following alphabetically.
196///
197/// Single-pass classification followed by an alphabetical sort of unrecognized keys.
198fn sort_object_by_key_order<'a>(obj: Object<'a>, key_order: &[&str]) -> Object<'a> {
199    let mut known: Vec<Option<(Cow<'a, str>, Value<'a>)>> =
200        (0..key_order.len()).map(|_| None).collect();
201    let mut others: Object<'a> = Vec::new();
202
203    for (key, value) in obj {
204        match key_order.iter().position(|kn| *kn == key.as_ref()) {
205            Some(idx) => known[idx] = Some((key, value)),
206            None => others.push((key, value)),
207        }
208    }
209
210    others.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
211
212    let mut result = Vec::with_capacity(known.len() + others.len());
213    for (key, value) in known.into_iter().flatten() {
214        result.push((key, value));
215    }
216    for (key, value) in others {
217        result.push((key, value));
218    }
219    result
220}
221
222fn sort_people_object(obj: Object<'_>) -> Object<'_> {
223    sort_object_by_key_order(obj, &["name", "email", "url"])
224}
225
226fn sort_dev_engine(value: Value<'_>) -> Value<'_> {
227    const KEY_ORDER: &[&str] = &["name", "version", "onFail"];
228
229    match value {
230        Value::Array(arr) => Value::Array(
231            arr.into_iter().map(|value| transform_with_key_order(value, KEY_ORDER)).collect(),
232        ),
233        other => transform_with_key_order(other, KEY_ORDER),
234    }
235}
236
237fn sort_dev_engines(obj: Object<'_>) -> Object<'_> {
238    let mut obj: Object<'_> =
239        obj.into_iter().map(|(key, value)| (key, sort_dev_engine(value))).collect();
240    obj.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
241    obj
242}
243
244// ===== Top-level field ordering =============================================
245
246/// Declares the canonical order for known top-level `package.json` fields. For each
247/// matched key, the field is bucketed with its order index; an optional transformation
248/// expression (with `value` and `options` in scope) rewrites the value before storage.
249/// Unknown fields fall through to the catch-all arm.
250macro_rules! declare_field_order {
251    (
252        $key:ident, $value:ident, $known:ident, $unknown:ident;
253        [ $( $idx:literal => $field_name:literal $( => $transform:expr )? ),* $(,)? ]
254    ) => {
255        match $key.as_ref() {
256            $(
257                $field_name => $known.push((
258                    $idx,
259                    $key,
260                    declare_field_order!(@value $value $(, $transform)?),
261                )),
262            )*
263            _ => $unknown.push(($key, $value)),
264        }
265    };
266    (@value $value:ident) => { $value };
267    (@value $value:ident, $transform:expr) => { $transform };
268}
269
270fn sort_object_keys<'a>(obj: Object<'a>, options: &SortOptions) -> Object<'a> {
271    // `known` collects fields with a canonical position; `unknown` collects everything
272    // else, sorted with private (`_`-prefixed) keys after non-private ones.
273    let mut known: Vec<(usize, Cow<'a, str>, Value<'a>)> = Vec::new();
274    let mut unknown: Object<'a> = Vec::new();
275
276    for (key, value) in obj {
277        declare_field_order!(key, value, known, unknown; [
278            // Core Package Metadata
279            0 => "$schema",
280            1 => "name",
281            2 => "displayName",
282            3 => "version",
283            4 => "stableVersion",
284            5 => "gitHead",
285            6 => "private",
286            7 => "description",
287            8 => "categories" => transform_array(value, sort_array_unique),
288            9 => "keywords" => transform_array(value, sort_array_unique),
289            10 => "homepage",
290            11 => "bugs" => transform_with_key_order(value, &["url", "email"]),
291            // License & People
292            12 => "license",
293            13 => "author" => transform_value(value, sort_people_object),
294            14 => "maintainers",
295            15 => "contributors",
296            // Repository & Funding
297            16 => "repository" => transform_with_key_order(value, &["type", "url"]),
298            17 => "funding" => transform_with_key_order(value, &["type", "url"]),
299            18 => "donate" => transform_with_key_order(value, &["type", "url"]),
300            19 => "sponsor" => transform_with_key_order(value, &["type", "url"]),
301            20 => "qna",
302            21 => "publisher",
303            // Package Content & Distribution
304            22 => "man",
305            23 => "style",
306            24 => "example",
307            25 => "examplestyle",
308            26 => "assets",
309            27 => "bin" => transform_value(value, sort_object_alphabetically),
310            28 => "source",
311            29 => "directories" => transform_with_key_order(value, &["lib", "bin", "man", "doc", "example", "test"]),
312            30 => "workspaces",
313            31 => "binary" => transform_with_key_order(value, &["module_name", "module_path", "remote_path", "package_name", "host"]),
314            32 => "files" => transform_array(value, dedupe_array),
315            33 => "os",
316            34 => "cpu",
317            35 => "libc" => transform_array(value, sort_array_unique),
318            // Package Entry Points
319            36 => "type",
320            37 => "sideEffects",
321            38 => "main",
322            39 => "module",
323            40 => "browser",
324            41 => "types",
325            42 => "typings",
326            43 => "typesVersions",
327            44 => "typeScriptVersion",
328            45 => "typesPublisherContentHash",
329            46 => "react-native",
330            47 => "svelte",
331            48 => "unpkg",
332            49 => "jsdelivr",
333            50 => "jsnext:main",
334            51 => "umd",
335            52 => "umd:main",
336            53 => "es5",
337            54 => "esm5",
338            55 => "fesm5",
339            56 => "es2015",
340            57 => "esm2015",
341            58 => "fesm2015",
342            59 => "es2020",
343            60 => "esm2020",
344            61 => "fesm2020",
345            62 => "esnext",
346            63 => "imports",
347            64 => "exports",
348            65 => "publishConfig" => transform_value(value, |o| sort_object_keys(o, options)),
349            // Scripts
350            66 => "scripts" => if options.sort_scripts { transform_value(value, sort_object_alphabetically) } else { value },
351            67 => "betterScripts" => if options.sort_scripts { transform_value(value, sort_object_alphabetically) } else { value },
352            68 => "wireit" => if options.sort_scripts { transform_value(value, sort_object_alphabetically) } else { value },
353            // Dependencies
354            69 => "dependencies" => transform_value(value, sort_object_alphabetically),
355            70 => "devDependencies" => transform_value(value, sort_object_alphabetically),
356            71 => "dependenciesMeta",
357            72 => "peerDependencies" => transform_value(value, sort_object_alphabetically),
358            73 => "peerDependenciesMeta",
359            74 => "optionalDependencies" => transform_value(value, sort_object_alphabetically),
360            75 => "bundledDependencies" => transform_array(value, sort_array_unique),
361            76 => "bundleDependencies" => transform_array(value, sort_array_unique),
362            77 => "resolutions" => transform_value(value, sort_object_alphabetically),
363            78 => "overrides" => transform_value(value, sort_object_alphabetically),
364            // Git Hooks & Commit Tools
365            79 => "husky" => transform_value(value, sort_object_recursive),
366            80 => "simple-git-hooks",
367            81 => "vite-staged",
368            82 => "lint-staged",
369            83 => "nano-staged",
370            84 => "pre-commit",
371            85 => "commitlint" => transform_value(value, sort_object_recursive),
372            // VSCode Extension Specific
373            86 => "l10n",
374            87 => "contributes",
375            88 => "activationEvents" => transform_array(value, sort_array_unique),
376            89 => "extensionPack" => transform_array(value, sort_array_unique),
377            90 => "extensionDependencies" => transform_array(value, sort_array_unique),
378            91 => "extensionKind" => transform_array(value, sort_array_unique),
379            92 => "icon",
380            93 => "badges",
381            94 => "galleryBanner",
382            95 => "preview",
383            96 => "markdown",
384            // Build & Tool Configuration
385            97 => "napi" => transform_value(value, sort_object_alphabetically),
386            98 => "flat",
387            99 => "config" => transform_value(value, sort_object_alphabetically),
388            100 => "nodemonConfig" => transform_value(value, sort_object_recursive),
389            101 => "browserify" => transform_value(value, sort_object_recursive),
390            102 => "babel" => transform_value(value, sort_object_recursive),
391            103 => "browserslist",
392            104 => "xo" => transform_value(value, sort_object_recursive),
393            105 => "prettier" => transform_value(value, sort_object_recursive),
394            106 => "eslintConfig" => transform_value(value, sort_object_recursive),
395            107 => "eslintIgnore",
396            108 => "standard" => transform_value(value, sort_object_recursive),
397            109 => "npmpkgjsonlint",
398            110 => "npmPackageJsonLintConfig",
399            111 => "npmpackagejsonlint",
400            112 => "release",
401            // Only sorts top-level keys: `replaceText` maps regexes to replacements applied sequentially in key order
402            113 => "auto-changelog" => transform_value(value, sort_object_alphabetically),
403            // Only sorts top-level keys: `plugins` in object form runs plugins in key order
404            114 => "remarkConfig" => transform_value(value, sort_object_alphabetically),
405            115 => "stylelint" => transform_value(value, sort_object_recursive),
406            116 => "typescript" => transform_value(value, sort_object_recursive),
407            117 => "typedoc" => transform_value(value, sort_object_recursive),
408            // Only sorts top-level keys: `exports` values may be pass-through conditional exports
409            118 => "tshy" => transform_value(value, sort_object_alphabetically),
410            119 => "tsdown" => transform_value(value, sort_object_recursive),
411            120 => "size-limit",
412            // Testing
413            121 => "ava" => transform_value(value, sort_object_recursive),
414            // Only sorts top-level keys: nested config like `moduleNameMapper` is order-dependent
415            122 => "jest" => transform_value(value, sort_object_alphabetically),
416            123 => "jest-junit",
417            124 => "jest-stare",
418            125 => "mocha" => transform_value(value, sort_object_recursive),
419            126 => "nyc" => transform_value(value, sort_object_recursive),
420            127 => "c8" => transform_value(value, sort_object_recursive),
421            128 => "tap",
422            129 => "tsd" => transform_value(value, sort_object_recursive),
423            130 => "typeCoverage" => transform_value(value, sort_object_recursive),
424            131 => "oclif" => transform_value(value, sort_object_recursive),
425            // Runtime & Package Manager
426            132 => "languageName",
427            133 => "preferGlobal",
428            134 => "devEngines" => transform_value(value, sort_dev_engines),
429            135 => "engines" => transform_value(value, sort_object_alphabetically),
430            136 => "engineStrict",
431            137 => "volta" => transform_value(value, sort_object_recursive),
432            138 => "packageManager",
433            139 => "pnpm",
434        ]);
435    }
436
437    known.sort_unstable_by_key(|(idx, _, _)| *idx);
438    // Single sort over all unknowns: non-private (`!_`) before private (`_`-prefixed),
439    // each group alphabetical.
440    unknown.sort_unstable_by(|(a, _), (b, _)| {
441        let a_priv = a.starts_with('_');
442        let b_priv = b.starts_with('_');
443        a_priv.cmp(&b_priv).then_with(|| a.cmp(b))
444    });
445
446    let mut result = Vec::with_capacity(known.len() + unknown.len());
447    for (_, key, value) in known {
448        result.push((key, value));
449    }
450    for (key, value) in unknown {
451        result.push((key, value));
452    }
453    result
454}