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

ota-meshi / eslint-plugin-jsonc / 22219409734

20 Feb 2026 09:52AM UTC coverage: 71.429% (+0.4%) from 71.001%
22219409734

push

github

web-flow
feat: improve type definitions (#476)

1034 of 1276 branches covered (81.03%)

Branch coverage included in aggregate %.

380 of 562 new or added lines in 34 files covered. (67.62%)

2 existing lines in 2 files now uncovered.

7461 of 10617 relevant lines covered (70.27%)

78.24 hits per line

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

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

1✔
13
type JSONValue = ReturnType<typeof getStaticJSONValue>;
1✔
14

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

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

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

1✔
51
  public readonly node: JSONElement;
1✔
52

1✔
53
  public readonly index: number;
1✔
54

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

1✔
57
  private cachedAround: AroundTarget | null = null;
1✔
58

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

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

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

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

94✔
108
  public readonly sourceCode: JSONCSourceCode;
94✔
109

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

94✔
112
  public constructor(
94✔
113
    node: AST.JSONArrayExpression,
94✔
114
    sourceCode: JSONCSourceCode,
94✔
115
  ) {
94✔
116
    this.node = node;
94✔
117
    this.sourceCode = sourceCode;
94✔
118
  }
94✔
119

94✔
120
  public get elements() {
94✔
121
    return (this.cachedElements ??= this.node.elements.map(
88✔
122
      (e, index) => new JSONElementData(this, e, index),
88✔
123
    ));
88✔
124
  }
88✔
125
}
94✔
126

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

×
137
  let compareValue: Compare<any> = ([a, b]) => a <= b;
✔
138
  let compareText: Compare<string> = compareValue;
×
139

×
140
  if (natural) {
✔
141
    compareText = ([a, b]) => naturalCompare(a, b) <= 0;
✔
142
  }
×
143
  if (insensitive) {
✔
144
    const baseCompareText = compareText;
×
145
    compareText = ([a, b]: string[]) =>
6✔
146
      baseCompareText([a.toLowerCase(), b.toLowerCase()]);
24✔
147
  }
×
148
  if (order === "desc") {
✔
149
    const baseCompareText = compareText;
×
150
    compareText = (args: string[]) => baseCompareText(args.reverse());
✔
151
    const baseCompareValue = compareValue;
×
152
    compareValue = (args) => baseCompareValue(args.reverse());
✔
153
  }
×
154
  return (a: JSONElementData, b: JSONElementData) => {
✔
155
    if (typeof a.value === "string" && typeof b.value === "string") {
321✔
156
      return compareText([a.value, b.value]);
278✔
157
    }
×
158
    const type = getJSONPrimitiveType(a.value);
✔
159
    if (type && type === getJSONPrimitiveType(b.value)) {
✔
160
      return compareValue([a.value, b.value]);
×
161
    }
×
162
    // Unknown
×
163
    return true;
✔
164
  };
×
165
}
×
166

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

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

×
221
    return {
✔
222
      isTargetArray,
×
223
      ignore: (v) => parsedOrder.every((p) => !p.test(v)),
✔
224
      isValidOrder(a, b) {
✔
225
        for (const p of parsedOrder) {
290✔
226
          const matchA = p.test(a);
734✔
227
          const matchB = p.test(b);
734✔
228
          if (!matchA || !matchB) {
734✔
229
            if (matchA) {
642✔
230
              return true;
168✔
231
            }
168✔
232
            if (matchB) {
642✔
233
              return false;
30✔
234
            }
30✔
235
            continue;
642✔
236
          }
444✔
237
          return p.isValidNestOrder(a, b);
734✔
238
        }
92✔
239
        return false;
290!
240
      },
290✔
241
      orderText: () => "specified",
✔
242
    };
×
243

×
244
    /**
×
245
     * Checks whether given node data is verify target
×
246
     */
×
247
    function isTargetArray(data: JSONArrayData) {
×
248
      if (data.node.elements.length < minValues) {
94✔
249
        return false;
4✔
250
      }
4✔
251

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

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

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

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

×
327
//------------------------------------------------------------------------------
×
328
// Rule Definition
×
329
//------------------------------------------------------------------------------
×
330

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

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

90✔
397
    /**
90✔
398
     * Sort elements by bubble sort.
90✔
399
     */
90✔
400
    function bubbleSort(elements: JSONElementData[], option: ParsedOption) {
90✔
401
      const l = elements.length;
88✔
402
      const result = [...elements];
88✔
403
      let swapped: boolean;
88✔
404
      do {
88✔
405
        swapped = false;
167✔
406
        for (let nextIndex = 1; nextIndex < l; nextIndex++) {
167✔
407
          const prevIndex = nextIndex - 1;
519✔
408
          if (option.isValidOrder(result[prevIndex], result[nextIndex]))
519✔
409
            continue;
519✔
410
          [result[prevIndex], result[nextIndex]] = [
519✔
411
            result[nextIndex],
107✔
412
            result[prevIndex],
107✔
413
          ];
107✔
414
          swapped = true;
107✔
415
        }
107✔
416
      } while (swapped);
88✔
417
      return result;
88✔
418
    }
88✔
419

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

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

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

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

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

×
549
    /**
×
550
     * Convert to display text.
×
551
     */
×
552
    function toText(data: JSONElementData) {
✔
553
      if (getJSONPrimitiveType(data.value)) {
138✔
554
        return String(data.value);
138✔
555
      }
138✔
556
      return sourceCode.getText(data.node!);
138!
557
    }
138✔
558

90✔
559
    return {
90✔
560
      JSONArrayExpression(node) {
90✔
561
        const data = new JSONArrayData(node, sourceCode);
94✔
562
        const option = parsedOptions.find((o) => o.isTargetArray(data));
94✔
563
        if (!option) {
94✔
564
          return;
6✔
565
        }
6✔
566
        verifyArrayElements(
94✔
567
          data.elements.filter((d) => !option.ignore(d)),
88✔
568
          option,
88✔
569
        );
88✔
570
      },
94✔
571
    };
90✔
572
  },
90✔
573
});
1✔
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