• 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

88.0
/lib/utils/fix-sort-elements.ts
1
import {
2
  isClosingBraceToken,
1✔
3
  isClosingBracketToken,
4
  isClosingParenToken,
5
  isCommaToken,
6
  isCommentToken,
2✔
7
  isNotCommaToken,
8
  isOpeningParenToken,
9
} from "@eslint-community/eslint-utils";
10
import type { Rule, AST as ESLintAST, SourceCode } from "eslint";
11
import type { AST } from "jsonc-eslint-parser";
1✔
12
import type * as ESTree from "estree";
13

133✔
14
export type AroundTarget =
15
  | {
16
      before: ESLintAST.Token;
38✔
17
      after: ESLintAST.Token;
18
      node?: AST.JSONNode | undefined;
19
    }
1✔
20
  | {
21
      before: ESLintAST.Token;
133✔
22
      after: ESLintAST.Token;
133✔
23
      node?: undefined;
133✔
24
    };
133✔
25
type NodeTarget = { node: AST.JSONNode; before?: undefined; after?: undefined };
51!
UNCOV
26
type Target = NodeTarget | AroundTarget;
×
27
/**
28
 * Fixed by moving the target element down for sorting.
51✔
29
 */
51✔
30
export function* fixToMoveDownForSorting(
51✔
31
  fixer: Rule.RuleFixer,
32
  sourceCode: SourceCode,
133✔
33
  target: Target,
34
  to: Target,
35
): IterableIterator<Rule.Fix> {
36
  const targetInfo = calcTargetMoveDownInfo(sourceCode, target);
133✔
37

38
  const toEndInfo = getElementEndInfo(sourceCode, to);
39

133✔
40
  let { insertCode, removeRanges, hasLeadingComma } = targetInfo;
41
  if (toEndInfo.trailingComma) {
2✔
42
    if (
43
      hasLeadingComma &&
44
      toEndInfo.last.range![1] <= toEndInfo.trailingComma.range[0]
45
    ) {
2✔
46
      yield fixer.removeRange(toEndInfo.trailingComma.range);
47
    }
48
    hasLeadingComma = true;
49
    insertCode = targetInfo.withTrailingComma.insertCode;
50
    removeRanges = targetInfo.withTrailingComma.removeRanges;
133✔
51
  }
82!
52

82✔
53
  let insertRange = [
NEW
54
    toEndInfo.last.range![1],
×
55
    toEndInfo.last.range![1],
56
  ] as ESLintAST.Range;
57
  const toNextToken = sourceCode.getTokenAfter(toEndInfo.last, {
133✔
58
    includeComments: true,
133✔
59
  })!;
426✔
60
  if (toNextToken.loc!.start.line - toEndInfo.last.loc!.end.line > 1) {
61
    // If there are blank lines, the element is inserted after the blank lines.
62
    const offset = sourceCode.getIndexFromLoc({
63
      line: toNextToken.loc!.start.line - 1,
38✔
64
      column: 0,
38✔
65
    });
38!
NEW
66
    insertRange = [offset, offset];
×
67
  }
68
  if (!hasLeadingComma) {
38✔
69
    if (to.node) {
70
      yield fixer.insertTextAfterRange(
71
        getLastTokenOfNode(sourceCode, to.node).range,
72
        ",",
38✔
73
      );
74
    } else {
75
      yield fixer.insertTextBeforeRange(to.after.range, ",");
38!
76
    }
NEW
77
  }
×
78
  yield fixer.insertTextAfterRange(insertRange, insertCode);
79

80
  for (const removeRange of removeRanges) {
NEW
81
    yield fixer.removeRange(removeRange);
×
82
  }
83
}
84

85
/**
86
 * Fixed by moving the target element up for sorting.
38✔
87
 */
38✔
88
export function* fixToMoveUpForSorting(
104✔
89
  fixer: Rule.RuleFixer,
90
  sourceCode: SourceCode,
91
  target: Target,
92
  to: Target,
93
): IterableIterator<Rule.Fix> {
94
  const targetInfo = calcTargetMoveUpInfo(sourceCode, target);
133!
95

133✔
96
  const toPrevInfo = getPrevElementInfo(sourceCode, to);
133✔
97

133✔
98
  if (
133✔
99
    toPrevInfo.leadingComma &&
100
    toPrevInfo.prevLast.range![1] <= toPrevInfo.leadingComma.range[0]
101
  ) {
102
    yield fixer.removeRange(toPrevInfo.leadingComma.range);
103
  }
104

133✔
105
  let insertRange = [
106
    toPrevInfo.prevLast.range![1],
133✔
107
    toPrevInfo.prevLast.range![1],
108
  ] as ESLintAST.Range;
109
  const toBeforeNextToken = sourceCode.getTokenAfter(toPrevInfo.prevLast, {
110
    includeComments: true,
133✔
111
  })!;
133✔
112
  if (
129✔
113
    toBeforeNextToken.loc!.start.line - toPrevInfo.prevLast.loc!.end.line >
114
    1
115
  ) {
116
    // If there are blank lines, the element is inserted after the blank lines.
117
    const offset = sourceCode.getIndexFromLoc({
118
      line: toBeforeNextToken.loc!.start.line - 1,
119
      column: 0,
129✔
120
    });
129!
121
    insertRange = [offset, offset];
129!
122
  }
129✔
123
  yield fixer.insertTextAfterRange(insertRange, targetInfo.insertCode);
124

125
  for (const removeRange of targetInfo.removeRanges) {
129✔
126
    yield fixer.removeRange(removeRange);
127
  }
128
}
129

130
/**
131
 * Calculate the fix information of the target element to be moved down for sorting.
132
 */
133
function calcTargetMoveDownInfo(
4!
134
  sourceCode: SourceCode,
4!
135
  target: Target,
4✔
136
): {
137
  insertCode: string;
138
  removeRanges: ESLintAST.Range[];
4✔
139
  hasLeadingComma: boolean;
140
  withTrailingComma: {
141
    insertCode: string;
142
    removeRanges: ESLintAST.Range[];
143
  };
144
} {
145
  const nodeStartIndex = target.node
4✔
146
    ? getFirstTokenOfNode(sourceCode, target.node).range[0]
4✔
147
    : target.before.range[1];
148

133✔
149
  const endInfo = getElementEndInfo(sourceCode, target);
150
  const prevInfo = getPrevElementInfo(sourceCode, target);
151

152
  let insertCode = sourceCode.text.slice(
153
    prevInfo.prevLast.range![1],
154
    nodeStartIndex,
155
  );
156
  const removeRanges: ESLintAST.Range[] = [
157
    [prevInfo.prevLast.range![1], nodeStartIndex],
158
  ];
38!
159
  const hasLeadingComma =
38✔
160
    prevInfo.leadingComma &&
38✔
161
    prevInfo.prevLast.range![1] <= prevInfo.leadingComma.range[0];
162

38✔
163
  let withTrailingComma: {
38!
NEW
164
    insertCode: string;
×
NEW
165
    removeRanges: ESLintAST.Range[];
×
166
  };
167

168
  const suffixRange: ESLintAST.Range = [nodeStartIndex, endInfo.last.range![1]];
169
  const suffix = sourceCode.text.slice(...suffixRange);
170
  if (
171
    endInfo.trailingComma &&
172
    endInfo.trailingComma.range[1] <= endInfo.last.range![1]
173
  ) {
38✔
174
    withTrailingComma = {
38✔
175
      insertCode: `${insertCode}${suffix}`,
176
      removeRanges: [...removeRanges, suffixRange],
177
    };
178
    insertCode += `${sourceCode.text.slice(nodeStartIndex, endInfo.trailingComma.range[0])}${sourceCode.text.slice(endInfo.trailingComma.range[1], endInfo.last.range![1])}`;
179

38✔
180
    if (!hasLeadingComma) {
38✔
181
      if (endInfo.trailingComma) {
28✔
182
        removeRanges.push(endInfo.trailingComma.range);
28!
183
      }
28✔
184
    }
185
    removeRanges.push(
186
      [nodeStartIndex, endInfo.trailingComma.range[0]],
38✔
187
      [endInfo.trailingComma.range[1], endInfo.last.range![1]],
38✔
188
    );
189
  } else {
190
    if (!hasLeadingComma) {
191
      if (endInfo.trailingComma) {
38✔
192
        removeRanges.push(endInfo.trailingComma.range);
193
      }
194
    }
195
    withTrailingComma = {
196
      insertCode: `${insertCode},${suffix}`,
197
      removeRanges: [...removeRanges, suffixRange],
198
    };
199
    insertCode += suffix;
342✔
200
    removeRanges.push(suffixRange);
342✔
201
  }
342✔
202

2✔
203
  return {
204
    insertCode,
342✔
205
    removeRanges,
206
    hasLeadingComma: Boolean(hasLeadingComma),
207
    withTrailingComma,
208
  };
209
}
780✔
210

780✔
211
/**
780✔
212
 * Calculate the fix information of the target element to be moved up for sorting.
5✔
213
 */
214
function calcTargetMoveUpInfo(
780✔
215
  sourceCode: SourceCode,
216
  target: Target,
217
): {
218
  insertCode: string;
219
  removeRanges: ESLintAST.Range[];
427!
220
} {
427✔
221
  const nodeEndIndex = target.node
222
    ? getLastTokenOfNode(sourceCode, target.node).range[1]
110✔
223
    : target.after.range[0];
224

225
  const endInfo = getElementEndInfo(sourceCode, target);
226
  const prevInfo = getPrevElementInfo(sourceCode, target);
227

228
  let insertCode: string;
317✔
229

317✔
230
  const removeRanges: ESLintAST.Range[] = [];
317✔
231
  if (
232
    prevInfo.leadingComma &&
233
    prevInfo.prevLast.range![1] <= prevInfo.leadingComma.range[0]
1✔
234
  ) {
235
    insertCode = `${sourceCode.text.slice(
236
      prevInfo.prevLast.range![1],
237
      prevInfo.leadingComma.range[0],
238
    )}${sourceCode.text.slice(prevInfo.leadingComma.range[1], nodeEndIndex)}`;
239
    removeRanges.push(
316✔
240
      [prevInfo.prevLast.range![1], prevInfo.leadingComma.range[0]],
241
      [prevInfo.leadingComma.range[1], nodeEndIndex],
242
    );
10✔
243
  } else {
244
    insertCode = sourceCode.text.slice(
245
      prevInfo.prevLast.range![1],
246
      nodeEndIndex,
247
    );
248
    removeRanges.push([prevInfo.prevLast.range![1], nodeEndIndex]);
306✔
249
  }
306✔
250

251
  const hasTrailingComma =
191✔
252
    endInfo.trailingComma &&
253
    endInfo.trailingComma.range[1] <= endInfo.last.range![1];
254
  if (!hasTrailingComma) {
255
    insertCode += ",";
256
    if (prevInfo.leadingComma) {
257
      removeRanges.push(prevInfo.leadingComma.range);
258
    }
115✔
259
  }
260
  insertCode += sourceCode.text.slice(nodeEndIndex, endInfo.last.range![1]);
261
  removeRanges.push([nodeEndIndex, endInfo.last.range![1]]);
2✔
262

263
  return {
264
    insertCode,
265
    removeRanges,
266
  };
267
}
113✔
268

269
/**
270
 * Get the first token of the node.
271
 */
272
function getFirstTokenOfNode(
273
  sourceCode: SourceCode,
274
  node: AST.JSONNode | ESLintAST.Token,
275
): ESLintAST.Token {
276
  let token = sourceCode.getFirstToken(node as never)!;
233!
UNCOV
277
  let target: ESLintAST.Token | null = token;
×
278
  while (
279
    (target = sourceCode.getTokenBefore(token)) &&
280
    isOpeningParenToken(target)
281
  ) {
233✔
282
    token = target;
233✔
283
  }
284
  return token;
233✔
285
}
286

287
/**
147✔
288
 * Get the last token of the node.
289
 */
233✔
290
function getLastTokenOfNode(
291
  sourceCode: SourceCode,
292
  node: AST.JSONNode | ESLintAST.Token,
293
): ESLintAST.Token {
294
  let token = sourceCode.getLastToken(node as never)!;
209!
295
  let target: ESLintAST.Token | null = token;
209✔
296
  while (
297
    (target = sourceCode.getTokenAfter(token)) &&
85✔
298
    isClosingParenToken(target)
299
  ) {
300
    token = target;
301
  }
302
  return token;
303
}
124✔
304

124✔
305
/**
124✔
306
 * Get the end of the target element and the next element and token information.
307
 */
308
function getElementEndInfo(
1✔
309
  sourceCode: SourceCode,
310
  target: Target | { node: ESLintAST.Token },
311
): {
312
  // Trailing comma
313
  trailingComma: ESLintAST.Token | null;
314
  // Next element token
123✔
315
  nextElement: ESLintAST.Token | null;
316
  // The last token of the target element
317
  last: ESLintAST.Token | ESTree.Comment;
123✔
318
} {
319
  const afterToken = target.node
320
    ? sourceCode.getTokenAfter(getLastTokenOfNode(sourceCode, target.node))!
321
    : target.after;
322
  if (isNotCommaToken(afterToken)) {
323
    // If there is no comma, the element is the last element.
324
    return {
325
      trailingComma: null,
326
      nextElement: null,
327
      last: getLastTokenWithTrailingComments(sourceCode, target),
328
    };
329
  }
330
  const comma = afterToken;
331
  const nextElement = sourceCode.getTokenAfter(afterToken)!;
332
  if (isCommaToken(nextElement)) {
333
    // If the next element is empty,
334
    // the position of the comma is the end of the element's range.
335
    return {
336
      trailingComma: comma,
337
      nextElement: null,
338
      last: comma,
339
    };
340
  }
341
  if (isClosingBraceToken(nextElement) || isClosingBracketToken(nextElement)) {
342
    // If the next token is a closing brace or bracket,
343
    // the position of the comma is the end of the element's range.
344
    return {
345
      trailingComma: comma,
346
      nextElement: null,
347
      last: getLastTokenWithTrailingComments(sourceCode, target),
348
    };
349
  }
350

351
  const node = target.node;
352

353
  if (node && node.loc.end.line === nextElement.loc.start.line) {
354
    // There is no line break between the target element and the next element.
355
    return {
356
      trailingComma: comma,
357
      nextElement,
358
      last: comma,
359
    };
360
  }
361
  // There are line breaks between the target element and the next element.
362
  if (
363
    node &&
364
    node.loc.end.line < comma.loc.start.line &&
365
    comma.loc.end.line < nextElement.loc.start.line
366
  ) {
367
    // If there is a line break between the target element and a comma and the next element,
368
    // the position of the comma is the end of the element's range.
369
    return {
370
      trailingComma: comma,
371
      nextElement,
372
      last: comma,
373
    };
374
  }
375

376
  return {
377
    trailingComma: comma,
378
    nextElement,
379
    last: getLastTokenWithTrailingComments(sourceCode, target),
380
  };
381
}
382

383
/**
384
 * Get the last token of the target element with trailing comments.
385
 */
386
function getLastTokenWithTrailingComments(
387
  sourceCode: SourceCode,
388
  target: Target | { node: ESLintAST.Token },
389
) {
390
  if (!target.node) {
391
    return sourceCode.getTokenBefore(target.after, {
392
      includeComments: true,
393
    })!;
394
  }
395
  const node = target.node;
396
  let last: ESLintAST.Token | ESTree.Comment = getLastTokenOfNode(
397
    sourceCode,
398
    node,
399
  );
400
  let after: ESLintAST.Token | ESTree.Comment | null;
401
  while (
402
    (after = sourceCode.getTokenAfter(last, {
403
      includeComments: true,
404
    })) &&
405
    (isCommentToken(after) || isCommaToken(after)) &&
406
    node.loc.end.line === after.loc!.end.line
407
  ) {
408
    last = after;
409
  }
410
  return last;
411
}
412

413
/**
414
 * Get the previous element and token information.
415
 */
416
function getPrevElementInfo(
417
  sourceCode: SourceCode,
418
  target: Target,
419
): {
420
  // Previous element token
421
  prevElement: ESLintAST.Token | null;
422
  // Leading comma
423
  leadingComma: ESLintAST.Token | null;
424
  // The last token of the previous element
425
  prevLast: ESLintAST.Token | ESTree.Comment;
426
} {
427
  const beforeToken = target.node
428
    ? sourceCode.getTokenBefore(getFirstTokenOfNode(sourceCode, target.node))!
429
    : target.before;
430
  if (isNotCommaToken(beforeToken)) {
431
    // If there is no comma, the element is the first element.
432
    return {
433
      prevElement: null,
434
      leadingComma: null,
435
      prevLast: beforeToken,
436
    };
437
  }
438
  const comma = beforeToken;
439
  const prevElement = sourceCode.getTokenBefore(beforeToken)!;
440

441
  if (isCommaToken(prevElement)) {
442
    // If the previous element is empty,
443
    // the position of the comma is the end of the previous element's range.
444
    return {
445
      prevElement: null,
446
      leadingComma: comma,
447
      prevLast: comma,
448
    };
449
  }
450

451
  const endInfo = getElementEndInfo(sourceCode, { node: prevElement });
452

453
  return {
454
    prevElement,
455
    leadingComma: endInfo.trailingComma,
456
    prevLast: endInfo.last,
457
  };
458
}
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