Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(sort-jsonc): New function: isSortedJsonc() checks if a JSON/JSONC/JSON5 string is sorted #7

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/thin-steaks-protect.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -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"
},
]
}
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
"typescript.tsdk": "node_modules/typescript/lib",
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
2 changes: 1 addition & 1 deletion packages/sort-jsonc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/sort-jsonc/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { sortJsonc, type CompareFn, type SortJsoncOptions } from './sortJsonc';
export { sortJsonc, isSortedJsonc, type CompareFn, type SortJsoncOptions } from './sortJsonc';
160 changes: 159 additions & 1 deletion packages/sort-jsonc/src/sortJsonc.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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;
});
});
});
82 changes: 77 additions & 5 deletions packages/sort-jsonc/src/sortJsonc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -56,8 +56,34 @@ function getCompareFn(sortOption: SortJsoncOptions['sort']) {
return createIntlCompareFn();
}

export function sortDeepWithSymbols<T extends Record<string | symbol, any>>(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<string | symbol, any>`.
* @typeParam C - The conditional type that extends boolean, representing the `checkOnly` parameter.
*/
export function sortDeepWithSymbols<T extends Record<string | symbol, any>, C extends boolean>(
initial: T,
compareFn: CompareFn,
checkOnly: C
): C extends true ? boolean : { sorted: T; alreadySorted: boolean };

export function sortDeepWithSymbols<T extends Record<string | symbol, any>>(
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) {
Expand All @@ -79,7 +105,15 @@ export function sortDeepWithSymbols<T extends Record<string | symbol, any>>(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) {
Expand All @@ -94,7 +128,7 @@ export function sortDeepWithSymbols<T extends Record<string | symbol, any>>(init
parent[keyOnParent] = sorted;
}

return result.sorted as any;
return { ...result, alreadySorted };
}

export function createIntlCompareFn(): CompareFn {
Expand All @@ -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<SortJsoncOptions, 'sort' | 'parseReviver'>): 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<T extends string>(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;
})
);
}