• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ota-meshi / eslint-plugin-jsonc / 18238483854

04 Oct 2025 02:43AM UTC coverage: 68.52% (-0.3%) from 68.782%
18238483854

push

github

web-flow
feat(sort-keys, sort-array-values): improve to calculate the minimum edit distance for sorting and report the optimal sorting direction (#426)

1205 of 2068 branches covered (58.27%)

Branch coverage included in aggregate %.

136 of 176 new or added lines in 4 files covered. (77.27%)

25 existing lines in 3 files now uncovered.

2123 of 2789 relevant lines covered (76.12%)

90.75 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.12
/lib/rules/sort-array-values.ts
1
import naturalCompare from "natural-compare";
2
import { createRule } from "../utils";
1✔
3
import type { AST } from "jsonc-eslint-parser";
4
import { getStaticJSONValue } from "jsonc-eslint-parser";
5
import type { SourceCode } from "eslint";
1✔
6
import type { AroundTarget } from "../utils/fix-sort-elements";
7
import {
8
  fixToMoveDownForSorting,
9
  fixToMoveUpForSorting,
10
} from "../utils/fix-sort-elements";
11
import { calcShortestEditScript } from "../utils/calc-shortest-edit-script";
3✔
12

13
type JSONValue = ReturnType<typeof getStaticJSONValue>;
14

1✔
15
//------------------------------------------------------------------------------
1✔
16
// Helpers
1✔
17
//------------------------------------------------------------------------------
1✔
18

1✔
19
type UserOptions = PatternOption[];
20
type OrderTypeOption = "asc" | "desc";
1!
21
type PatternOption = {
22
  pathPattern: string;
23
  order:
24
    | OrderObject
1✔
25
    | (
26
        | string
69!
27
        | {
69✔
28
            valuePattern?: string;
UNCOV
29
            order?: OrderObject;
×
UNCOV
30
          }
×
31
      )[];
32
  minValues?: number;
33
};
34
type OrderObject = {
35
  type?: OrderTypeOption;
36
  caseSensitive?: boolean;
138✔
37
  natural?: boolean;
14✔
38
};
39
type ParsedOption = {
124✔
40
  isTargetArray: (node: JSONArrayData) => boolean;
124!
41
  ignore: (data: JSONElementData) => boolean;
124✔
42
  isValidOrder: Validator;
43
  orderText: (data: JSONElementData) => string;
44
};
45
type Validator = (a: JSONElementData, b: JSONElementData) => boolean;
46

UNCOV
47
type JSONElement = AST.JSONArrayExpression["elements"][number];
×
UNCOV
48
class JSONElementData {
×
UNCOV
49
  public readonly array: JSONArrayData;
×
50

51
  public readonly node: JSONElement;
52

53
  public readonly index: number;
54

55
  private cached: { value: JSONValue } | null = null;
56

3,303✔
57
  private cachedAround: AroundTarget | null = null;
362✔
58

59
  public get reportLoc() {
60
    if (this.node) {
61
      return this.node.loc;
364✔
62
    }
364✔
63
    const around = this.around;
364✔
64
    return {
364✔
65
      start: around.before.loc.end,
364✔
66
      end: around.after.loc.start,
67
    };
68
  }
1✔
69

70
  public get around(): AroundTarget {
71
    if (this.cachedAround) {
364!
72
      return this.cachedAround;
73
    }
74
    const sourceCode = this.array.sourceCode;
94✔
75
    if (this.node) {
94✔
76
      return (this.cachedAround = {
94✔
77
        node: this.node,
78
        before: sourceCode.getTokenBefore(this.node as never)!,
79
        after: sourceCode.getTokenAfter(this.node as never)!,
80
      });
81
    }
82
    const before =
272✔
83
      this.index > 0
74✔
84
        ? this.array.elements[this.index - 1].around.after
74✔
85
        : sourceCode.getFirstToken(this.array.node as never)!;
24✔
86
    const after = sourceCode.getTokenAfter(before)!;
87
    return (this.cachedAround = { before, after });
74✔
88
  }
6✔
89

24✔
90
  public constructor(array: JSONArrayData, node: JSONElement, index: number) {
91
    this.array = array;
92
    this.node = node;
93
    this.index = index;
94
  }
74✔
95

10✔
96
  public get value() {
36✔
97
    return (
10✔
98
      this.cached ??
10✔
99
      (this.cached = {
100
        value: this.node == null ? null : getStaticJSONValue(this.node),
74✔
101
      })
321✔
102
    ).value;
278✔
103
  }
104
}
105
class JSONArrayData {
106
  public readonly node: AST.JSONArrayExpression;
107

43✔
108
  public readonly sourceCode: SourceCode;
43✔
109

18✔
110
  private cachedElements: JSONElementData[] | null = null;
111

112
  public constructor(node: AST.JSONArrayExpression, sourceCode: SourceCode) {
113
    this.node = node;
114
    this.sourceCode = sourceCode;
115
  }
25✔
116

117
  public get elements() {
118
    return (this.cachedElements ??= this.node.elements.map(
119
      (e, index) => new JSONElementData(this, e, index),
120
    ));
121
  }
90✔
122
}
90✔
123

90✔
124
/**
125
 * Build function which check that the given 2 names are in specific order.
90!
126
 */
90✔
127
function buildValidatorFromType(
128
  order: OrderTypeOption,
56!
129
  insensitive: boolean,
56✔
130
  natural: boolean,
56✔
131
): Validator {
56✔
132
  type Compare<T> = ([a, b]: T[]) => boolean;
133

190✔
134
  let compareValue: Compare<any> = ([a, b]) => a <= b;
135
  let compareText: Compare<string> = compareValue;
136

35✔
137
  if (natural) {
30✔
138
    compareText = ([a, b]) => naturalCompare(a, b) <= 0;
139
  }
5✔
140
  if (insensitive) {
141
    const baseCompareText = compareText;
142
    compareText = ([a, b]: string[]) =>
143
      baseCompareText([a.toLowerCase(), b.toLowerCase()]);
34✔
144
  }
34✔
145
  if (order === "desc") {
120✔
146
    const baseCompareText = compareText;
102✔
147
    compareText = (args: string[]) => baseCompareText(args.reverse());
1,704✔
UNCOV
148
    const baseCompareValue = compareValue;
×
149
    compareValue = (args) => baseCompareValue(args.reverse());
150
  }
151
  return (a: JSONElementData, b: JSONElementData) => {
18!
152
    if (typeof a.value === "string" && typeof b.value === "string") {
153
      return compareText([a.value, b.value]);
18!
154
    }
155
    const type = getJSONPrimitiveType(a.value);
18!
156
    if (type && type === getJSONPrimitiveType(b.value)) {
18✔
157
      return compareValue([a.value, b.value]);
18✔
158
    }
18✔
159
    // Unknown
238!
160
    return true;
161
  };
162
}
163

164
/**
34✔
165
 * Parse options
166
 */
474✔
167
function parseOptions(options: UserOptions): ParsedOption[] {
168
  return options.map((opt) => {
290✔
169
    const order = opt.order;
734✔
170
    const pathPattern = new RegExp(opt.pathPattern);
734✔
171
    const minValues: number = opt.minValues ?? 2;
734✔
172
    if (!Array.isArray(order)) {
642✔
173
      const type: OrderTypeOption = order.type ?? "asc";
168✔
174
      const insensitive = order.caseSensitive === false;
175
      const natural = Boolean(order.natural);
474✔
176

30✔
177
      return {
178
        isTargetArray,
444✔
179
        ignore: () => false,
180
        isValidOrder: buildValidatorFromType(type, insensitive, natural),
92✔
181
        orderText(data) {
UNCOV
182
          if (typeof data.value === "string") {
×
183
            return `${natural ? "natural " : ""}${
184
              insensitive ? "insensitive " : ""
34✔
185
            }${type}ending`;
186
          }
187
          return `${type}ending`;
188
        },
189
      };
94✔
190
    }
4✔
191
    const parsedOrder: {
192
      test: (v: JSONElementData) => boolean;
193
      isValidNestOrder: Validator;
90✔
194
    }[] = [];
90✔
195
    for (const o of order) {
90✔
196
      if (typeof o === "string") {
90✔
197
        parsedOrder.push({
336✔
198
          test: (v) => v.value === o,
78✔
199
          isValidNestOrder: () => true,
78✔
200
        });
76✔
201
      } else {
202
        const valuePattern = o.valuePattern ? new RegExp(o.valuePattern) : null;
2✔
203
        const nestOrder = o.order ?? {};
204
        const type: OrderTypeOption = nestOrder.type ?? "asc";
258!
UNCOV
205
        const insensitive = nestOrder.caseSensitive === false;
×
UNCOV
206
        const natural = Boolean(nestOrder.natural);
×
207
        parsedOrder.push({
208
          test: (v) =>
336✔
209
            valuePattern
336✔
210
              ? Boolean(getJSONPrimitiveType(v.value)) &&
211
                valuePattern.test(String(v.value))
90✔
212
              : true,
76✔
213
          isValidNestOrder: buildValidatorFromType(type, insensitive, natural),
214
        });
90✔
215
      }
216
    }
217

218
    return {
219
      isTargetArray,
220
      ignore: (v) => parsedOrder.every((p) => !p.test(v)),
78✔
221
      isValidOrder(a, b) {
78!
UNCOV
222
        for (const p of parsedOrder) {
×
223
          const matchA = p.test(a);
224
          const matchB = p.test(b);
78✔
225
          if (!matchA || !matchB) {
226
            if (matchA) {
227
              return true;
228
            }
229
            if (matchB) {
230
              return false;
220✔
231
            }
220✔
232
            continue;
201✔
233
          }
234
          return p.isValidNestOrder(a, b);
19✔
235
        }
15✔
236
        return false;
237
      },
4!
UNCOV
238
      orderText: () => "specified",
×
239
    };
240

4!
241
    /**
×
242
     * Checks whether given node data is verify target
243
     */
4✔
244
    function isTargetArray(data: JSONArrayData) {
245
      if (data.node.elements.length < minValues) {
1✔
246
        return false;
247
      }
248

249
      // Check whether the path is match or not.
1✔
250
      let path = "";
251
      let curr: AST.JSONNode = data.node;
252
      let p: AST.JSONNode | null = curr.parent;
253
      while (p) {
254
        if (p.type === "JSONProperty") {
255
          const name = getPropertyName(p);
256
          if (/^[$a-z_][\w$]*$/iu.test(name)) {
257
            path = `.${name}${path}`;
258
          } else {
259
            path = `[${JSON.stringify(name)}]${path}`;
260
          }
261
        } else if (p.type === "JSONArrayExpression") {
262
          const index = p.elements.indexOf(curr as never);
263
          path = `[${index}]${path}`;
264
        }
1✔
265
        curr = p;
266
        p = curr.parent;
267
      }
268
      if (path.startsWith(".")) {
269
        path = path.slice(1);
270
      }
271
      return pathPattern.test(path);
272
    }
273
  });
274

275
  /**
276
   * Gets the property name of the given `Property` node.
277
   */
278
  function getPropertyName(node: AST.JSONProperty): string {
279
    const prop = node.key;
280
    if (prop.type === "JSONIdentifier") {
281
      return prop.name;
282
    }
283
    return String(getStaticJSONValue(prop));
284
  }
285
}
286

287
/**
288
 * Get the type name from given value when value is primitive like value
289
 */
290
function getJSONPrimitiveType(val: JSONValue) {
291
  const t = typeof val;
292
  if (t === "string" || t === "number" || t === "boolean" || t === "bigint") {
293
    return t;
294
  }
295
  if (val === null) {
296
    return "null";
297
  }
298
  if (val === undefined) {
299
    return "undefined";
300
  }
301
  if (val instanceof RegExp) {
302
    return "regexp";
303
  }
304
  return null;
305
}
306

307
const ALLOW_ORDER_TYPES: OrderTypeOption[] = ["asc", "desc"];
308
const ORDER_OBJECT_SCHEMA = {
309
  type: "object",
310
  properties: {
311
    type: {
312
      enum: ALLOW_ORDER_TYPES,
313
    },
314
    caseSensitive: {
315
      type: "boolean",
316
    },
317
    natural: {
318
      type: "boolean",
319
    },
320
  },
321
  additionalProperties: false,
322
} as const;
323

324
//------------------------------------------------------------------------------
325
// Rule Definition
326
//------------------------------------------------------------------------------
327

90✔
328
export default createRule<UserOptions>("sort-array-values", {
90!
UNCOV
329
  meta: {
×
330
    docs: {
331
      description: "require array values to be sorted",
332
      recommended: null,
90✔
333
      extensionRule: false,
334
      layout: false,
335
    },
336
    fixable: "code",
88✔
337
    schema: {
88✔
338
      type: "array",
339
      items: {
340
        type: "object",
341
        properties: {
88✔
342
          pathPattern: { type: "string" },
167✔
343
          order: {
167✔
344
            oneOf: [
519✔
345
              {
519✔
346
                type: "array",
107✔
347
                items: {
348
                  anyOf: [
349
                    { type: "string" },
350
                    {
107✔
351
                      type: "object",
352
                      properties: {
353
                        valuePattern: {
88✔
354
                          type: "string",
355
                        },
356
                        order: ORDER_OBJECT_SCHEMA,
357
                      },
358
                      additionalProperties: false,
88✔
359
                    },
88✔
360
                  ],
88✔
361
                },
415✔
362
                uniqueItems: true,
415✔
363
              },
310✔
364
              ORDER_OBJECT_SCHEMA,
69!
UNCOV
365
            ],
×
366
          },
367
          minValues: {
69✔
368
            type: "integer",
51✔
369
            minimum: 2,
51!
UNCOV
370
          },
×
371
        },
372
        required: ["pathPattern", "order"],
51✔
373
        additionalProperties: false,
374
      },
375
      minItems: 1,
376
    },
377

378
    messages: {
379
      shouldBeBefore:
380
        "Expected array values to be in {{orderText}} order. '{{thisValue}}' should be before '{{targetValue}}'.",
381
      shouldBeAfter:
51✔
382
        "Expected array values to be in {{orderText}} order. '{{thisValue}}' should be after '{{targetValue}}'.",
383
    },
384
    type: "suggestion",
385
  },
18✔
386
  create(context) {
18!
UNCOV
387
    const sourceCode = context.sourceCode;
×
388
    if (!sourceCode.parserServices.isJSON) {
389
      return {};
18✔
390
    }
391
    // Parse options.
392
    const parsedOptions = parseOptions(context.options);
393

394
    /**
395
     * Sort elements by bubble sort.
396
     */
397
    function bubbleSort(elements: JSONElementData[], option: ParsedOption) {
398
      const l = elements.length;
18✔
399
      const result = [...elements];
400
      let swapped: boolean;
401
      do {
402
        swapped = false;
403
        for (let nextIndex = 1; nextIndex < l; nextIndex++) {
404
          const prevIndex = nextIndex - 1;
405
          if (option.isValidOrder(result[prevIndex], result[nextIndex]))
406
            continue;
51✔
407
          [result[prevIndex], result[nextIndex]] = [
65✔
408
            result[nextIndex],
65!
409
            result[prevIndex],
65✔
410
          ];
51✔
411
          swapped = true;
NEW
412
        }
×
NEW
413
      } while (swapped);
×
NEW
414
      return result;
×
NEW
415
    }
×
NEW
416

×
NEW
417
    /**
×
418
     * Verify for array elements
NEW
419
     */
×
420
    function verifyArrayElements(
NEW
421
      elements: JSONElementData[],
×
422
      option: ParsedOption,
423
    ) {
424
      const sorted = bubbleSort(elements, option);
425
      const editScript = calcShortestEditScript(elements, sorted);
426
      for (let index = 0; index < editScript.length; index++) {
18✔
427
        const edit = editScript[index];
18✔
428
        if (edit.type !== "delete") continue;
18!
429
        const insertEditIndex = editScript.findIndex(
18!
430
          (e) => e.type === "insert" && e.b === edit.a,
18✔
431
        );
NEW
432
        if (insertEditIndex === -1) {
×
NEW
433
          // should not happen
×
NEW
434
          continue;
×
NEW
435
        }
×
NEW
436
        if (index < insertEditIndex) {
×
NEW
437
          const target = findInsertAfterTarget(edit.a, insertEditIndex);
×
438
          if (!target) {
NEW
439
            // should not happen
×
440
            continue;
NEW
441
          }
×
442
          context.report({
443
            loc: edit.a.reportLoc,
444
            messageId: "shouldBeAfter",
445
            data: {
446
              thisValue: toText(edit.a),
447
              targetValue: toText(target),
138!
448
              orderText: option.orderText(edit.a),
138✔
449
            },
NEW
450
            *fix(fixer) {
×
451
              yield* fixToMoveDownForSorting(
452
                fixer,
90✔
453
                sourceCode,
454
                edit.a.around,
94✔
455
                target.around,
94✔
456
              );
94✔
457
            },
6✔
458
          });
459
        } else {
364✔
460
          const target = findInsertBeforeTarget(edit.a, insertEditIndex);
461
          if (!target) {
462
            // should not happen
463
            continue;
464
          }
465
          context.report({
466
            loc: edit.a.reportLoc,
467
            messageId: "shouldBeBefore",
468
            data: {
469
              thisValue: toText(edit.a),
470
              targetValue: toText(target),
471
              orderText: option.orderText(edit.a),
472
            },
473
            *fix(fixer) {
474
              yield* fixToMoveUpForSorting(
475
                fixer,
476
                sourceCode,
477
                edit.a.around,
478
                target.around,
479
              );
480
            },
481
          });
482
        }
483
      }
484

485
      /**
486
       * Find insert after target
487
       */
488
      function findInsertAfterTarget(
489
        element: JSONElementData,
490
        insertEditIndex: number,
491
      ) {
492
        for (let index = insertEditIndex - 1; index >= 0; index--) {
493
          const edit = editScript[index];
494
          if (edit.type === "delete" && edit.a === element) break;
495
          if (edit.type !== "common") continue;
496
          return edit.a;
497
        }
498

499
        let lastTarget: JSONElementData | null = null;
500
        for (
501
          let index = elements.indexOf(element) + 1;
502
          index < elements.length;
503
          index++
504
        ) {
505
          const el = elements[index];
506
          if (option.isValidOrder(el, element)) {
507
            lastTarget = el;
508
            continue;
509
          }
510
          return lastTarget;
511
        }
512
        return lastTarget;
513
      }
514

515
      /**
516
       * Find insert before target
517
       */
518
      function findInsertBeforeTarget(
519
        element: JSONElementData,
520
        insertEditIndex: number,
521
      ) {
522
        for (
523
          let index = insertEditIndex + 1;
524
          index < editScript.length;
525
          index++
526
        ) {
527
          const edit = editScript[index];
528
          if (edit.type === "delete" && edit.a === element) break;
529
          if (edit.type !== "common") continue;
530
          return edit.a;
531
        }
532

533
        let lastTarget: JSONElementData | null = null;
534
        for (let index = elements.indexOf(element) - 1; index >= 0; index--) {
535
          const el = elements[index];
536
          if (option.isValidOrder(element, el)) {
537
            lastTarget = el;
538
            continue;
539
          }
540
          return lastTarget;
541
        }
542
        return lastTarget;
543
      }
544
    }
545

546
    /**
547
     * Convert to display text.
548
     */
549
    function toText(data: JSONElementData) {
550
      if (getJSONPrimitiveType(data.value)) {
551
        return String(data.value);
552
      }
553
      return sourceCode.getText(data.node! as never);
554
    }
555

556
    return {
557
      JSONArrayExpression(node) {
558
        const data = new JSONArrayData(node, sourceCode);
559
        const option = parsedOptions.find((o) => o.isTargetArray(data));
560
        if (!option) {
561
          return;
562
        }
563
        verifyArrayElements(
564
          data.elements.filter((d) => !option.ignore(d)),
565
          option,
566
        );
567
      },
568
    };
569
  },
570
});
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc