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

ota-meshi / eslint-plugin-jsonc / 12941945364

24 Jan 2025 02:17AM UTC coverage: 72.146% (+0.1%) from 72.0%
12941945364

Pull #390

github

web-flow
Merge 60745b159 into 2dc3d182d
Pull Request #390: feat: improve auto-fix of sort rules

790 of 1299 branches covered (60.82%)

Branch coverage included in aggregate %.

81 of 95 new or added lines in 3 files covered. (85.26%)

1681 of 2126 relevant lines covered (79.07%)

124.26 hits per line

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

87.71
/lib/rules/sort-array-values.ts
1
import naturalCompare from "natural-compare";
1✔
2
import { createRule } from "../utils";
1✔
3
import type { AST } from "jsonc-eslint-parser";
4
import { getStaticJSONValue } from "jsonc-eslint-parser";
1✔
5
import type { SourceCode } from "eslint";
6
import type { AroundTarget } from "../utils/fix-sort-elements";
7
import { fixForSorting } from "../utils/fix-sort-elements";
1✔
8

9
type JSONValue = ReturnType<typeof getStaticJSONValue>;
10

11
//------------------------------------------------------------------------------
12
// Helpers
13
//------------------------------------------------------------------------------
14

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

43
type JSONElement = AST.JSONArrayExpression["elements"][number];
44
class JSONElementData {
45
  public readonly array: JSONArrayData;
46

47
  public readonly node: JSONElement;
48

49
  public readonly index: number;
50

51
  private cached: { value: JSONValue } | null = null;
364✔
52

53
  private cachedAround: AroundTarget | null = null;
364✔
54

55
  public get reportLoc() {
56
    if (this.node) {
69✔
57
      return this.node.loc;
69✔
58
    }
NEW
59
    const around = this.around;
×
60
    return {
×
61
      start: around.before.loc.end,
62
      end: around.after.loc.start,
63
    };
64
  }
65

66
  public get around(): AroundTarget {
67
    if (this.cachedAround) {
138✔
68
      return this.cachedAround;
10✔
69
    }
70
    const sourceCode = this.array.sourceCode;
128✔
71
    if (this.node) {
128✔
72
      return (this.cachedAround = {
128✔
73
        node: this.node,
74
        before: sourceCode.getTokenBefore(this.node as never)!,
75
        after: sourceCode.getTokenAfter(this.node as never)!,
76
      });
77
    }
78
    const before =
79
      this.index > 0
×
80
        ? this.array.elements[this.index - 1].around.after
81
        : sourceCode.getFirstToken(this.array.node as never)!;
82
    const after = sourceCode.getTokenAfter(before)!;
×
NEW
83
    return (this.cachedAround = { before, after });
×
84
  }
85

86
  public constructor(array: JSONArrayData, node: JSONElement, index: number) {
87
    this.array = array;
364✔
88
    this.node = node;
364✔
89
    this.index = index;
364✔
90
  }
91

92
  public get value() {
93
    return (
3,318✔
94
      this.cached ??
9,592✔
95
      (this.cached = {
96
        value: this.node == null ? null : getStaticJSONValue(this.node),
362✔
97
      })
98
    ).value;
99
  }
100
}
101
class JSONArrayData {
102
  public readonly node: AST.JSONArrayExpression;
103

104
  public readonly sourceCode: SourceCode;
105

106
  private cachedElements: JSONElementData[] | null = null;
94✔
107

108
  public constructor(node: AST.JSONArrayExpression, sourceCode: SourceCode) {
109
    this.node = node;
94✔
110
    this.sourceCode = sourceCode;
94✔
111
  }
112

113
  public get elements() {
114
    return (this.cachedElements ??= this.node.elements.map(
434✔
115
      (e, index) => new JSONElementData(this, e, index),
364✔
116
    ));
117
  }
118
}
119

120
/**
121
 * Build function which check that the given 2 names are in specific order.
122
 */
123
function buildValidatorFromType(
124
  order: OrderTypeOption,
125
  insensitive: boolean,
126
  natural: boolean,
127
): Validator {
128
  type Compare<T> = ([a, b]: T[]) => boolean;
129

130
  let compareValue: Compare<any> = ([a, b]) => a <= b;
209✔
131
  let compareText: Compare<string> = compareValue;
74✔
132

133
  if (natural) {
74✔
134
    compareText = ([a, b]) => naturalCompare(a, b) <= 0;
18✔
135
  }
136
  if (insensitive) {
74✔
137
    const baseCompareText = compareText;
6✔
138
    compareText = ([a, b]: string[]) =>
6✔
139
      baseCompareText([a.toLowerCase(), b.toLowerCase()]);
22✔
140
  }
141
  if (order === "desc") {
74✔
142
    const baseCompareText = compareText;
10✔
143
    compareText = (args: string[]) => baseCompareText(args.reverse());
32✔
144
    const baseCompareValue = compareValue;
10✔
145
    compareValue = (args) => baseCompareValue(args.reverse());
10✔
146
  }
147
  return (a: JSONElementData, b: JSONElementData) => {
74✔
148
    if (typeof a.value === "string" && typeof b.value === "string") {
251✔
149
      return compareText([a.value, b.value]);
210✔
150
    }
151
    const type = getJSONPrimitiveType(a.value);
41✔
152
    if (type && type === getJSONPrimitiveType(b.value)) {
41✔
153
      return compareValue([a.value, b.value]);
17✔
154
    }
155
    // Unknown
156
    return true;
24✔
157
  };
158
}
159

160
/**
161
 * Parse options
162
 */
163
function parseOptions(options: UserOptions): ParsedOption[] {
164
  return options.map((opt) => {
90✔
165
    const order = opt.order;
90✔
166
    const pathPattern = new RegExp(opt.pathPattern);
90✔
167
    const minValues: number = opt.minValues ?? 2;
90!
168
    if (!Array.isArray(order)) {
90✔
169
      const type: OrderTypeOption = order.type ?? "asc";
56!
170
      const insensitive = order.caseSensitive === false;
56✔
171
      const natural = Boolean(order.natural);
56✔
172

173
      return {
56✔
174
        isTargetArray,
175
        ignore: () => false,
456✔
176
        isValidOrder: buildValidatorFromType(type, insensitive, natural),
177
        orderText(data) {
178
          if (typeof data.value === "string") {
35✔
179
            return `${natural ? "natural " : ""}${
30✔
180
              insensitive ? "insensitive " : ""
30✔
181
            }${type}ending`;
182
          }
183
          return `${type}ending`;
5✔
184
        },
185
      };
186
    }
187
    const parsedOrder: {
188
      test: (v: JSONElementData) => boolean;
189
      isValidNestOrder: Validator;
190
    }[] = [];
34✔
191
    for (const o of order) {
34✔
192
      if (typeof o === "string") {
120✔
193
        parsedOrder.push({
102✔
194
          test: (v) => v.value === o,
2,000✔
195
          isValidNestOrder: () => true,
×
196
        });
197
      } else {
198
        const valuePattern = o.valuePattern ? new RegExp(o.valuePattern) : null;
18!
199
        const nestOrder = o.order ?? {};
18!
200
        const type: OrderTypeOption = nestOrder.type ?? "asc";
18!
201
        const insensitive = nestOrder.caseSensitive === false;
18✔
202
        const natural = Boolean(nestOrder.natural);
18✔
203
        parsedOrder.push({
18✔
204
          test: (v) =>
205
            valuePattern
222!
206
              ? Boolean(getJSONPrimitiveType(v.value)) &&
×
207
                valuePattern.test(String(v.value))
208
              : true,
209
          isValidNestOrder: buildValidatorFromType(type, insensitive, natural),
210
        });
211
      }
212
    }
213

214
    return {
34✔
215
      isTargetArray,
216
      ignore: (v) => parsedOrder.every((p) => !p.test(v)),
1,242✔
217
      isValidOrder(a, b) {
218
        for (const p of parsedOrder) {
200✔
219
          const matchA = p.test(a);
490✔
220
          const matchB = p.test(b);
490✔
221
          if (!matchA || !matchB) {
490✔
222
            if (matchA) {
438✔
223
              return true;
98✔
224
            }
225
            if (matchB) {
340✔
226
              return false;
50✔
227
            }
228
            continue;
290✔
229
          }
230
          return p.isValidNestOrder(a, b);
52✔
231
        }
232
        return false;
×
233
      },
234
      orderText: () => "specified",
34✔
235
    };
236

237
    /**
238
     * Checks whether given node data is verify target
239
     */
240
    function isTargetArray(data: JSONArrayData) {
241
      if (data.node.elements.length < minValues) {
94✔
242
        return false;
4✔
243
      }
244

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

271
  /**
272
   * Gets the property name of the given `Property` node.
273
   */
274
  function getPropertyName(node: AST.JSONProperty): string {
275
    const prop = node.key;
78✔
276
    if (prop.type === "JSONIdentifier") {
78!
277
      return prop.name;
×
278
    }
279
    return String(getStaticJSONValue(prop));
78✔
280
  }
281
}
282

283
/**
284
 * Get the type name from given value when value is primitive like value
285
 */
286
function getJSONPrimitiveType(val: JSONValue) {
287
  const t = typeof val;
216✔
288
  if (t === "string" || t === "number" || t === "boolean" || t === "bigint") {
216✔
289
    return t;
200✔
290
  }
291
  if (val === null) {
16✔
292
    return "null";
12✔
293
  }
294
  if (val === undefined) {
4!
295
    return "undefined";
×
296
  }
297
  if (val instanceof RegExp) {
4!
298
    return "regexp";
×
299
  }
300
  return null;
4✔
301
}
302

303
const ALLOW_ORDER_TYPES: OrderTypeOption[] = ["asc", "desc"];
1✔
304
const ORDER_OBJECT_SCHEMA = {
1✔
305
  type: "object",
306
  properties: {
307
    type: {
308
      enum: ALLOW_ORDER_TYPES,
309
    },
310
    caseSensitive: {
311
      type: "boolean",
312
    },
313
    natural: {
314
      type: "boolean",
315
    },
316
  },
317
  additionalProperties: false,
318
} as const;
319

320
//------------------------------------------------------------------------------
321
// Rule Definition
322
//------------------------------------------------------------------------------
323

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

374
    messages: {
375
      sortValues:
376
        "Expected array values to be in {{orderText}} order. '{{thisValue}}' should be before '{{prevValue}}'.",
377
    },
378
    type: "suggestion",
379
  },
380
  create(context) {
381
    const sourceCode = context.sourceCode;
90✔
382
    if (!sourceCode.parserServices.isJSON) {
90!
383
      return {};
×
384
    }
385
    // Parse options.
386
    const parsedOptions = parseOptions(context.options);
90✔
387

388
    /**
389
     * Verify for array element
390
     */
391
    function verifyArrayElement(data: JSONElementData, option: ParsedOption) {
392
      if (option.ignore(data)) {
364✔
393
        return;
18✔
394
      }
395
      const prevList = data.array.elements
346✔
396
        .slice(0, data.index)
397
        .reverse()
398
        .filter((d) => !option.ignore(d));
598✔
399

400
      if (prevList.length === 0) {
346✔
401
        return;
88✔
402
      }
403
      const prev = prevList[0];
258✔
404
      if (!option.isValidOrder(prev, data)) {
258✔
405
        const reportLoc = data.reportLoc;
69✔
406
        context.report({
69✔
407
          loc: reportLoc,
408
          messageId: "sortValues",
409
          data: {
410
            thisValue: toText(data),
411
            prevValue: toText(prev),
412
            orderText: option.orderText(data),
413
          },
414
          fix(fixer) {
415
            let moveTarget = prevList[0];
69✔
416
            for (const prev of prevList) {
69✔
417
              if (option.isValidOrder(prev, data)) {
141✔
418
                break;
36✔
419
              } else {
420
                moveTarget = prev;
105✔
421
              }
422
            }
423
            return fixForSorting(
69✔
424
              fixer,
425
              sourceCode,
426
              data.around,
427
              moveTarget.around,
428
            );
429
          },
430
        });
431
      }
432
    }
433

434
    /**
435
     * Convert to display text.
436
     */
437
    function toText(data: JSONElementData) {
438
      if (getJSONPrimitiveType(data.value)) {
138✔
439
        return String(data.value);
138✔
440
      }
441
      return sourceCode.getText(data.node! as never);
×
442
    }
443

444
    return {
90✔
445
      JSONArrayExpression(node) {
446
        const data = new JSONArrayData(node, sourceCode);
94✔
447
        const option = parsedOptions.find((o) => o.isTargetArray(data));
94✔
448
        if (!option) {
94✔
449
          return;
6✔
450
        }
451
        for (const element of data.elements) {
88✔
452
          verifyArrayElement(element, option);
364✔
453
        }
454
      },
455
    };
456
  },
457
});
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc