diff --git a/.changeset/thin-steaks-protect.md b/.changeset/thin-steaks-protect.md new file mode 100644 index 0000000..ea2b123 --- /dev/null +++ b/.changeset/thin-steaks-protect.md @@ -0,0 +1,9 @@ +--- +"sort-jsonc": minor +--- + +New function: isSortedJsonc() checks if a JSON/JSONC/JSON5 string is sorted + +The new function isSortedJsonc can be used to check if a JSON/JSONC/JSON5 string is sorted. The implementation +short-circuits as soon as an unsorted element is found. Custom sort functions and order arrays can be provided to +customize the sort logic. diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..a1192ca --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "command": "pnpm test", + "name": "Run pnpm test", + "request": "launch", + "type": "node-terminal", + "cwd": "${workspaceFolder}/packages/sort-jsonc" + }, + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 25fa621..acbe8bf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "editor.defaultFormatter": "esbenp.prettier-vscode" } diff --git a/packages/sort-jsonc/package.json b/packages/sort-jsonc/package.json index 16d880d..636c88c 100644 --- a/packages/sort-jsonc/package.json +++ b/packages/sort-jsonc/package.json @@ -31,7 +31,7 @@ "build": "tsup", "dev": "tsup --watch", "prepublish": "npm run build", - "test": "vitest", + "test": "vitest --run", "lint": "biome check src", "lint:apply": "biome check src --apply" }, diff --git a/packages/sort-jsonc/src/index.ts b/packages/sort-jsonc/src/index.ts index ba43580..c2c11bd 100644 --- a/packages/sort-jsonc/src/index.ts +++ b/packages/sort-jsonc/src/index.ts @@ -1 +1 @@ -export { sortJsonc, type CompareFn, type SortJsoncOptions } from './sortJsonc'; +export { sortJsonc, isSortedJsonc, type CompareFn, type SortJsoncOptions } from './sortJsonc'; diff --git a/packages/sort-jsonc/src/sortJsonc.test.ts b/packages/sort-jsonc/src/sortJsonc.test.ts index d4cb319..3503f21 100644 --- a/packages/sort-jsonc/src/sortJsonc.test.ts +++ b/packages/sort-jsonc/src/sortJsonc.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { sortJsonc } from './sortJsonc'; +import { isSortedJsonc, sortJsonc } from './sortJsonc'; describe('sortJsonc', () => { it('sorts a .jsonc string and preserves comments', () => { @@ -226,3 +226,161 @@ describe('sortJsonc', () => { }); }); }); + +describe('isSortedJsonc', () => { + const unsortedString = ` +{ + /* comment above h */ + "h": 8, + "c": 3, // comment after c + "a": 1, + "d": { + // in d above f, + "f": 6, + "e": 5, + }, + "i": { + "k": 11, + /** + * block comment above j + */ + "j": 10, + "l": { + "m": { + "p": 16, + "o": 15, + "n": 14 + } + } + }, + "g": 7, + // above b + "b": 2, +}`; + + const sortedString = ` +{ + "a": 1, + // above b + "b": 2, + "c": 3, // comment after c + "d": { + "e": 5, + // in d above f, + "f": 6 + }, + "g": 7, + /* comment above h */ + "h": 8, + "i": { + /** + * block comment above j + */ + "j": 10, + "k": 11, + "l": { + "m": { + "n": 14, + "o": 15, + "p": 16 + } + } + } +}`; + + it('sorted input returns true', () => { + const result = isSortedJsonc(sortedString); + + expect(result).to.be.true; + }); + + it('unsorted input returns false', () => { + const result = isSortedJsonc(unsortedString); + + expect(result).to.be.false; + }); + + it('checks unsorted nested arrays', () => { + const jsoncString = ` +{ + "k": 11, + "a": [ + { + "c": 3, + "b": 2 + }, + { + "e": 5, + "d": 4, + "f": [ + { + "h": 8, + "g": 7 + }, + // array comment! + { + "j": 10, + "i": 9 + } + ] + } + ] +} + `; + + const result = isSortedJsonc(jsoncString); + + expect(result).to.be.false; + }); + + describe('order sort', () => { + const sortedString = ` +{ + // Specify the base directory to resolve non-relative module names. + "baseUrl": ".", + // Generate .d.ts files from TypeScript and JavaScript files in your project. + "declaration": true, + // Create sourcemaps for d.ts files. + "declarationMap": true, + // Ensure 'use strict' is always emitted. + "alwaysStrict": true, + // Disable error reporting for unreachable code. + "allowUnreachableCode": false, + // Enable error reporting in type-checked JavaScript files. + "checkJs": false +} + `; + + const unsortedString = ` +{ + // Ensure 'use strict' is always emitted. + "alwaysStrict": true, + // Disable error reporting for unreachable code. + "allowUnreachableCode": false, + // Specify the base directory to resolve non-relative module names. + "baseUrl": ".", + // Enable error reporting in type-checked JavaScript files. + "checkJs": false, + // Generate .d.ts files from TypeScript and JavaScript files in your project. + "declaration": true, + // Create sourcemaps for d.ts files. + "declarationMap": true +} + `; + it('sorted input returns true', () => { + const result = isSortedJsonc(sortedString, { + sort: ['baseUrl', 'declaration', 'declarationMap', 'alwaysStrict', 'allowUnreachableCode', 'checkJs'], + }); + + expect(result).to.be.true; + }); + + it('unsorted input returns false', () => { + const result = isSortedJsonc(unsortedString, { + sort: ['baseUrl', 'declaration', 'declarationMap', 'alwaysStrict', 'allowUnreachableCode', 'checkJs'], + }); + + expect(result).to.be.false; + }); + }); +}); diff --git a/packages/sort-jsonc/src/sortJsonc.ts b/packages/sort-jsonc/src/sortJsonc.ts index 80462c7..9e5e680 100644 --- a/packages/sort-jsonc/src/sortJsonc.ts +++ b/packages/sort-jsonc/src/sortJsonc.ts @@ -39,7 +39,7 @@ export type SortJsoncOptions = { export function sortJsonc(jsoncString: string, options?: SortJsoncOptions) { const { parseReviver, removeComments, spaces, sort } = options || {}; const parsed = parse(jsoncString, parseReviver || undefined, removeComments || undefined) as any; - const sorted = sortDeepWithSymbols(parsed, getCompareFn(sort)); + const { sorted } = sortDeepWithSymbols(parsed, getCompareFn(sort), false); return stringify(sorted, parseReviver || undefined, spaces || 2); } @@ -56,8 +56,34 @@ function getCompareFn(sortOption: SortJsoncOptions['sort']) { return createIntlCompareFn(); } -export function sortDeepWithSymbols>(initial: T, compareFn: CompareFn): T { - const result = { sorted: initial }; +/** + * Sorts the properties of the given object deeply (including symbol properties) or checks if they are already sorted, + * based on the `checkOnly` parameter. This function can handle nested objects. + * + * @param initial - The object to be sorted or checked. This object must be a Record with string or symbol keys. + * @param compareFn - Compare-function like {@link Array.prototype.sort()}. + * @param checkOnly - Determines whether the function will only check if the object is sorted. + * @returns If `checkOnly` is `true`, returns a boolean indicating whether the object is already sorted. + * If `checkOnly` is `false`, returns an object with two properties: + * - `sorted`: The sorted object. + * - `alreadySorted`: A boolean indicating whether the object was already sorted. + * + * @typeParam T - The type of the object to be sorted or checked. Must extend `Record`. + * @typeParam C - The conditional type that extends boolean, representing the `checkOnly` parameter. + */ +export function sortDeepWithSymbols, C extends boolean>( + initial: T, + compareFn: CompareFn, + checkOnly: C +): C extends true ? boolean : { sorted: T; alreadySorted: boolean }; + +export function sortDeepWithSymbols>( + initial: T, + compareFn: CompareFn, + checkOnly = false +): boolean | { sorted: T; alreadySorted: boolean } { + let alreadySorted = false; + const result = { sorted: initial, alreadySorted }; const stack: [any, string][] = [[result, 'sorted']]; while (stack.length) { @@ -79,7 +105,15 @@ export function sortDeepWithSymbols>(init } if (!Array.isArray(current)) { - keys.sort(compareFn); + alreadySorted = isSortedArray(keys, compareFn); + + if (!alreadySorted) { + if (checkOnly) { + return false + } + + keys.sort(compareFn); + } } for (const key of keys) { @@ -94,7 +128,7 @@ export function sortDeepWithSymbols>(init parent[keyOnParent] = sorted; } - return result.sorted as any; + return { ...result, alreadySorted }; } export function createIntlCompareFn(): CompareFn { @@ -117,3 +151,41 @@ export function createOrderCompareFn(order: string[]): CompareFn { return aWeight - bWeight; }; } + +/** + * Checks if a JSON/JSONC/JSON5 string is sorted. + * @param jsoncString JSON/JSONC/JSON5 string + * @param options sorting, parsing and formatting options + * @returns true if the string is sorted, false otherwise. + */ +export function isSortedJsonc(jsoncString: string, options?: Pick): boolean { + const { parseReviver, sort } = options || {}; + const parsed = parse(jsoncString, parseReviver || undefined) as any; + return sortDeepWithSymbols(parsed, getCompareFn(sort), true); +} + +/** + * Checks if an array is sorted given a compare function. The implementation short-circuits immediately after an + * unsorted element is found. + * + * @param arr - an array to check. + * @param compareFn - a compare function to use for the comparison. + * @returns true if the array is sorted, false otherwise. + */ +function isSortedArray(arr: T[], compareFn: CompareFn): boolean { + return ( + arr + // This creates a copy of the array that we will iterate over. + // By starting at the second item in the array, we can easily create pairs to compare. + .slice(1) + // We're iterating over every element in the slice, so the item at i=0 is arr[1]. + // More generally, item === arr[i+1], and arr[i] is the previous item in the array. + // We check each pair, and if any pair is out of ordered, return immediately + .every((item, i) => { + // arr[i] is the previous item in the array. + // We expect it to be less than or equal the current item, or the array isn't sorted. + // biome-ignore lint/style/noNonNullAssertion: we're iterating over a slice of arr, so i will always be indexable + return compareFn(arr[i]!, item) <= 0; + }) + ); +}