eslint-plugin-absolute 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,19 @@
1
1
  import { TSESLint, TSESTree } from "@typescript-eslint/utils";
2
2
 
3
3
  /**
4
- * @fileoverview Enforce sorted keys in object literals (like ESLints built-in sort-keys)
4
+ * @fileoverview Enforce sorted keys in object literals (like ESLint's built-in sort-keys)
5
5
  * with an auto-fix for simple cases that preserves comments.
6
6
  *
7
7
  * Note: This rule reports errors just like the original sort-keys rule.
8
- * However, the auto-fix only applies if all properties are fixable – i.e.:
8
+ * However, the auto-fix only applies if all properties are "fixable" – i.e.:
9
9
  * - They are of type Property (not SpreadElement, etc.)
10
10
  * - They are not computed (e.g. [foo])
11
11
  * - They are an Identifier or a Literal.
12
12
  *
13
13
  * Comments attached to the properties are preserved in the auto-fix.
14
14
  *
15
- * Use this rule with a grain of salt. I did not test every edge case and theres
16
- * a reason the original rule doesnt have auto-fix. Computed keys, spread elements,
15
+ * Use this rule with a grain of salt. I did not test every edge case and there's
16
+ * a reason the original rule doesn't have auto-fix. Computed keys, spread elements,
17
17
  * comments, and formatting are not handled perfectly.
18
18
  */
19
19
 
@@ -34,51 +34,107 @@ type KeyInfo = {
34
34
  isFunction: boolean;
35
35
  };
36
36
 
37
- export const sortKeysFixable: TSESLint.RuleModule<MessageIds, Options> = {
38
- meta: {
39
- type: "suggestion",
40
- docs: {
41
- description:
42
- "enforce sorted keys in object literals with auto-fix (limited to simple cases, preserving comments)"
43
- },
44
- fixable: "code",
45
- // The schema supports the same options as the built-in sort-keys rule plus:
46
- // variablesBeforeFunctions: boolean (when true, non-function properties come before function properties)
47
- schema: [
48
- {
49
- type: "object",
50
- properties: {
51
- order: {
52
- type: "string",
53
- enum: ["asc", "desc"]
54
- },
55
- caseSensitive: {
56
- type: "boolean"
57
- },
58
- natural: {
59
- type: "boolean"
60
- },
61
- minKeys: {
62
- type: "integer",
63
- minimum: 2
64
- },
65
- variablesBeforeFunctions: {
66
- type: "boolean"
67
- }
68
- },
69
- additionalProperties: false
70
- }
71
- ],
72
- messages: {
73
- unsorted: "Object keys are not sorted."
37
+ const SORT_BEFORE = -1;
38
+
39
+ const hasDuplicateNames = (names: Array<string | null>) => {
40
+ const seen = new Set<string>();
41
+ const nonNullNames = names.flatMap((name) => (name === null ? [] : [name]));
42
+
43
+ for (const name of nonNullNames) {
44
+ if (seen.has(name)) {
45
+ return true;
74
46
  }
75
- },
47
+ seen.add(name);
48
+ }
76
49
 
77
- defaultOptions: [{}],
50
+ return false;
51
+ };
52
+
53
+ const isSafeStaticTemplate = (node: TSESTree.TemplateLiteral) =>
54
+ node.expressions.length === 0;
55
+
56
+ const isSafeArrayElement: (node: TSESTree.Node | null) => boolean = (node) => {
57
+ if (!node || node.type === "SpreadElement") {
58
+ return false;
59
+ }
60
+
61
+ return isSafeToReorderExpression(node);
62
+ };
63
+
64
+ const isSafeObjectProperty: (
65
+ property: TSESTree.ObjectExpression["properties"][number]
66
+ ) => boolean = (property) => {
67
+ if (
68
+ property.type !== "Property" ||
69
+ property.computed ||
70
+ property.kind !== "init"
71
+ ) {
72
+ return false;
73
+ }
74
+
75
+ if (property.key.type !== "Identifier" && property.key.type !== "Literal") {
76
+ return false;
77
+ }
78
+
79
+ if (property.method) {
80
+ return true;
81
+ }
82
+
83
+ return isSafeToReorderExpression(property.value);
84
+ };
85
+
86
+ const isSafeToReorderExpression: (node: TSESTree.Node | null) => boolean = (
87
+ node
88
+ ) => {
89
+ if (!node || node.type === "PrivateIdentifier") {
90
+ return false;
91
+ }
92
+
93
+ switch (node.type) {
94
+ case "Identifier":
95
+ case "Literal":
96
+ case "ThisExpression":
97
+ case "FunctionExpression":
98
+ case "ArrowFunctionExpression":
99
+ case "ClassExpression":
100
+ return true;
101
+ case "TemplateLiteral":
102
+ return isSafeStaticTemplate(node);
103
+ case "UnaryExpression":
104
+ return isSafeToReorderExpression(node.argument);
105
+ case "ArrayExpression":
106
+ return node.elements.every(isSafeArrayElement);
107
+ case "ObjectExpression":
108
+ return node.properties.every(isSafeObjectProperty);
109
+ default:
110
+ return false;
111
+ }
112
+ };
113
+
114
+ const isSafeJSXAttributeValue = (value: TSESTree.JSXAttribute["value"]) => {
115
+ if (value === null) {
116
+ return true;
117
+ }
118
+
119
+ if (value.type === "Literal") {
120
+ return true;
121
+ }
122
+
123
+ if (value.type !== "JSXExpressionContainer") {
124
+ return false;
125
+ }
126
+
127
+ if (value.expression.type === "JSXEmptyExpression") {
128
+ return false;
129
+ }
130
+
131
+ return isSafeToReorderExpression(value.expression);
132
+ };
78
133
 
134
+ export const sortKeysFixable: TSESLint.RuleModule<MessageIds, Options> = {
79
135
  create(context) {
80
- const sourceCode = context.sourceCode;
81
- const option = context.options[0];
136
+ const { sourceCode } = context;
137
+ const [option] = context.options;
82
138
 
83
139
  const order: "asc" | "desc" =
84
140
  option && option.order ? option.order : "asc";
@@ -105,110 +161,153 @@ export const sortKeysFixable: TSESLint.RuleModule<MessageIds, Options> = {
105
161
  * Compare two key strings based on the provided options.
106
162
  * This function mimics the behavior of the built-in rule.
107
163
  */
108
- function compareKeys(a: string, b: string) {
109
- let keyA = a;
110
- let keyB = b;
164
+ const compareKeys = (keyLeft: string, keyRight: string) => {
165
+ let left = keyLeft;
166
+ let right = keyRight;
111
167
 
112
168
  if (!caseSensitive) {
113
- keyA = keyA.toLowerCase();
114
- keyB = keyB.toLowerCase();
169
+ left = left.toLowerCase();
170
+ right = right.toLowerCase();
115
171
  }
116
172
 
117
173
  if (natural) {
118
- return keyA.localeCompare(keyB, undefined, {
174
+ return left.localeCompare(right, undefined, {
119
175
  numeric: true
120
176
  });
121
177
  }
122
178
 
123
- return keyA.localeCompare(keyB);
124
- }
179
+ return left.localeCompare(right);
180
+ };
125
181
 
126
182
  /**
127
183
  * Determines if a property is a function property.
128
184
  */
129
- function isFunctionProperty(prop: TSESTree.Property) {
130
- const value = prop.value;
185
+ const isFunctionProperty = (prop: TSESTree.Property) => {
186
+ const { value } = prop;
131
187
  return (
132
- !!value &&
188
+ Boolean(value) &&
133
189
  (value.type === "FunctionExpression" ||
134
190
  value.type === "ArrowFunctionExpression" ||
135
191
  prop.method === true)
136
192
  );
137
- }
193
+ };
138
194
 
139
195
  /**
140
196
  * Safely extracts a key name from a Property that we already know
141
197
  * only uses Identifier or Literal keys in the fixer.
142
198
  */
143
- function getPropertyKeyName(prop: TSESTree.Property) {
144
- const key = prop.key;
199
+ const getPropertyKeyName = (prop: TSESTree.Property) => {
200
+ const { key } = prop;
145
201
  if (key.type === "Identifier") {
146
202
  return key.name;
147
203
  }
148
204
  if (key.type === "Literal") {
149
- const value = key.value;
205
+ const { value } = key;
150
206
  if (typeof value === "string") {
151
207
  return value;
152
208
  }
153
209
  return String(value);
154
210
  }
155
211
  return "";
156
- }
212
+ };
157
213
 
158
214
  /**
159
215
  * Get leading comments for a property, excluding any comments that
160
216
  * are on the same line as the previous property (those are trailing
161
217
  * comments of the previous property).
162
218
  */
163
- function getLeadingComments(
219
+ const getLeadingComments = (
164
220
  prop: TSESTree.Property,
165
221
  prevProp: TSESTree.Property | null
166
- ): TSESTree.Comment[] {
222
+ ) => {
167
223
  const comments = sourceCode.getCommentsBefore(prop);
168
224
  if (!prevProp || comments.length === 0) {
169
225
  return comments;
170
226
  }
171
227
  // Filter out comments on the same line as the previous property
172
228
  return comments.filter(
173
- (c) => c.loc.start.line !== prevProp.loc.end.line
229
+ (comment) => comment.loc.start.line !== prevProp.loc.end.line
174
230
  );
175
- }
231
+ };
176
232
 
177
233
  /**
178
234
  * Get trailing comments for a property that are on the same line.
179
235
  * This includes both getCommentsAfter AND any getCommentsBefore of the
180
236
  * next property that are on the same line as this property.
181
237
  */
182
- function getTrailingComments(
238
+ const getTrailingComments = (
183
239
  prop: TSESTree.Property,
184
240
  nextProp: TSESTree.Property | null
185
- ): TSESTree.Comment[] {
241
+ ) => {
186
242
  const after = sourceCode
187
243
  .getCommentsAfter(prop)
188
- .filter((c) => c.loc.start.line === prop.loc.end.line);
189
- if (nextProp) {
190
- const beforeNext = sourceCode.getCommentsBefore(nextProp);
191
- const trailingOfPrev = beforeNext.filter(
192
- (c) => c.loc.start.line === prop.loc.end.line
244
+ .filter(
245
+ (comment) => comment.loc.start.line === prop.loc.end.line
193
246
  );
194
- // Merge, avoiding duplicates
195
- for (const c of trailingOfPrev) {
196
- if (!after.some((a) => a.range[0] === c.range[0])) {
197
- after.push(c);
198
- }
199
- }
247
+ if (!nextProp) {
248
+ return after;
200
249
  }
250
+
251
+ const beforeNext = sourceCode.getCommentsBefore(nextProp);
252
+ const trailingOfPrev = beforeNext.filter(
253
+ (comment) => comment.loc.start.line === prop.loc.end.line
254
+ );
255
+ // Merge, avoiding duplicates
256
+ const newComments = trailingOfPrev.filter(
257
+ (comment) =>
258
+ !after.some(
259
+ (existing) => existing.range[0] === comment.range[0]
260
+ )
261
+ );
262
+ after.push(...newComments);
201
263
  return after;
202
- }
264
+ };
265
+
266
+ const getChunkStart = (
267
+ idx: number,
268
+ fixableProps: TSESTree.Property[],
269
+ rangeStart: number,
270
+ fullStart: number
271
+ ) => {
272
+ if (idx === 0) {
273
+ return rangeStart;
274
+ }
275
+
276
+ const prevProp = fixableProps[idx - 1]!;
277
+ const currentProp = fixableProps[idx]!;
278
+ const prevTrailing = getTrailingComments(prevProp, currentProp);
279
+ const prevEnd =
280
+ prevTrailing.length > 0
281
+ ? prevTrailing[prevTrailing.length - 1]!.range[1]
282
+ : prevProp.range[1];
283
+ // Find the comma after the previous property/comments
284
+ const allTokens = sourceCode.getTokensBetween(
285
+ prevProp,
286
+ currentProp,
287
+ {
288
+ includeComments: false
289
+ }
290
+ );
291
+ const tokenAfterPrev =
292
+ allTokens.find((tok) => tok.range[0] >= prevEnd) ?? null;
293
+ if (
294
+ tokenAfterPrev &&
295
+ tokenAfterPrev.value === "," &&
296
+ tokenAfterPrev.range[1] <= fullStart
297
+ ) {
298
+ return tokenAfterPrev.range[1];
299
+ }
300
+ return prevEnd;
301
+ };
203
302
 
204
303
  /**
205
304
  * Build the sorted text from the fixable properties while preserving
206
305
  * comments and formatting.
207
306
  */
208
- function buildSortedText(
307
+ const buildSortedText = (
209
308
  fixableProps: TSESTree.Property[],
210
309
  rangeStart: number
211
- ) {
310
+ ) => {
212
311
  // For each property, capture its "chunk": the property text plus
213
312
  // its associated comments (leading comments on separate lines,
214
313
  // trailing comments on the same line).
@@ -217,11 +316,13 @@ export const sortKeysFixable: TSESLint.RuleModule<MessageIds, Options> = {
217
316
  text: string;
218
317
  }[] = [];
219
318
 
220
- for (let i = 0; i < fixableProps.length; i++) {
221
- const prop = fixableProps[i]!;
222
- const prevProp = i > 0 ? fixableProps[i - 1]! : null;
319
+ for (let idx = 0; idx < fixableProps.length; idx++) {
320
+ const prop = fixableProps[idx]!;
321
+ const prevProp = idx > 0 ? fixableProps[idx - 1]! : null;
223
322
  const nextProp =
224
- i < fixableProps.length - 1 ? fixableProps[i + 1]! : null;
323
+ idx < fixableProps.length - 1
324
+ ? fixableProps[idx + 1]!
325
+ : null;
225
326
 
226
327
  const leading = getLeadingComments(prop, prevProp);
227
328
  const trailing = getTrailingComments(prop, nextProp);
@@ -233,52 +334,31 @@ export const sortKeysFixable: TSESLint.RuleModule<MessageIds, Options> = {
233
334
  ? trailing[trailing.length - 1]!.range[1]
234
335
  : prop.range[1];
235
336
 
236
- // Find the chunk start (after previous property's separator)
237
- let chunkStart: number;
238
- if (i === 0) {
239
- chunkStart = rangeStart;
240
- } else {
241
- const prevTrailing = getTrailingComments(prevProp!, prop);
242
- const prevEnd =
243
- prevTrailing.length > 0
244
- ? prevTrailing[prevTrailing.length - 1]!.range[1]
245
- : prevProp!.range[1];
246
- // Find the comma after the previous property/comments
247
- const tokenAfterPrev = sourceCode.getTokenAfter(
248
- {
249
- range: [prevEnd, prevEnd]
250
- } as TSESTree.Node,
251
- { includeComments: false }
252
- );
253
- if (
254
- tokenAfterPrev &&
255
- tokenAfterPrev.value === "," &&
256
- tokenAfterPrev.range[1] <= fullStart
257
- ) {
258
- chunkStart = tokenAfterPrev.range[1];
259
- } else {
260
- chunkStart = prevEnd;
261
- }
262
- }
337
+ const chunkStart = getChunkStart(
338
+ idx,
339
+ fixableProps,
340
+ rangeStart,
341
+ fullStart
342
+ );
263
343
 
264
344
  const text = sourceCode.text.slice(chunkStart, fullEnd);
265
345
  chunks.push({ prop, text });
266
346
  }
267
347
 
268
348
  // Sort the chunks
269
- const sorted = chunks.slice().sort((a, b) => {
349
+ const sorted = chunks.slice().sort((left, right) => {
270
350
  if (variablesBeforeFunctions) {
271
- const aIsFunc = isFunctionProperty(a.prop);
272
- const bIsFunc = isFunctionProperty(b.prop);
273
- if (aIsFunc !== bIsFunc) {
274
- return aIsFunc ? 1 : -1;
351
+ const leftIsFunc = isFunctionProperty(left.prop);
352
+ const rightIsFunc = isFunctionProperty(right.prop);
353
+ if (leftIsFunc !== rightIsFunc) {
354
+ return leftIsFunc ? 1 : SORT_BEFORE;
275
355
  }
276
356
  }
277
357
 
278
- const aKey = getPropertyKeyName(a.prop);
279
- const bKey = getPropertyKeyName(b.prop);
358
+ const leftKey = getPropertyKeyName(left.prop);
359
+ const rightKey = getPropertyKeyName(right.prop);
280
360
 
281
- let res = compareKeys(aKey, bKey);
361
+ let res = compareKeys(leftKey, rightKey);
282
362
  if (order === "desc") {
283
363
  res = -res;
284
364
  }
@@ -300,7 +380,7 @@ export const sortKeysFixable: TSESLint.RuleModule<MessageIds, Options> = {
300
380
  fixableProps[0]!.range[0] - col,
301
381
  fixableProps[0]!.range[0]
302
382
  );
303
- separator = ",\n" + indent;
383
+ separator = `,\n${indent}`;
304
384
  } else {
305
385
  separator = ", ";
306
386
  }
@@ -308,8 +388,8 @@ export const sortKeysFixable: TSESLint.RuleModule<MessageIds, Options> = {
308
388
  // Rebuild: first chunk keeps original leading whitespace,
309
389
  // subsequent chunks use the detected separator
310
390
  return sorted
311
- .map((chunk, i) => {
312
- if (i === 0) {
391
+ .map((chunk, idx) => {
392
+ if (idx === 0) {
313
393
  const originalFirstChunk = chunks[0]!;
314
394
  const originalLeadingWs =
315
395
  originalFirstChunk.text.match(/^(\s*)/)?.[1] ?? "";
@@ -320,7 +400,16 @@ export const sortKeysFixable: TSESLint.RuleModule<MessageIds, Options> = {
320
400
  return separator + stripped;
321
401
  })
322
402
  .join("");
323
- }
403
+ };
404
+
405
+ const getFixableProps = (node: TSESTree.ObjectExpression) =>
406
+ node.properties.filter(
407
+ (prop): prop is TSESTree.Property =>
408
+ prop.type === "Property" &&
409
+ !prop.computed &&
410
+ (prop.key.type === "Identifier" ||
411
+ prop.key.type === "Literal")
412
+ );
324
413
 
325
414
  /**
326
415
  * Checks an ObjectExpression node for unsorted keys.
@@ -329,7 +418,7 @@ export const sortKeysFixable: TSESLint.RuleModule<MessageIds, Options> = {
329
418
  * For auto-fix purposes, only simple properties are considered fixable.
330
419
  * (Computed keys, spread elements, or non-Identifier/Literal keys disable the fix.)
331
420
  */
332
- function checkObjectExpression(node: TSESTree.ObjectExpression) {
421
+ const checkObjectExpression = (node: TSESTree.ObjectExpression) => {
333
422
  if (node.properties.length < minKeys) {
334
423
  return;
335
424
  }
@@ -340,251 +429,287 @@ export const sortKeysFixable: TSESLint.RuleModule<MessageIds, Options> = {
340
429
  let keyName: string | null = null;
341
430
  let isFunc = false;
342
431
 
343
- if (prop.type === "Property") {
344
- if (prop.computed) {
345
- autoFixable = false;
346
- }
432
+ if (prop.type !== "Property") {
433
+ autoFixable = false;
434
+ return {
435
+ isFunction: isFunc,
436
+ keyName,
437
+ node: prop
438
+ };
439
+ }
347
440
 
348
- if (prop.key.type === "Identifier") {
349
- keyName = prop.key.name;
350
- } else if (prop.key.type === "Literal") {
351
- const value = prop.key.value;
352
- keyName =
353
- typeof value === "string" ? value : String(value);
354
- } else {
355
- autoFixable = false;
356
- }
441
+ if (prop.computed) {
442
+ autoFixable = false;
443
+ }
357
444
 
358
- if (isFunctionProperty(prop)) {
359
- isFunc = true;
360
- }
445
+ if (prop.key.type === "Identifier") {
446
+ keyName = prop.key.name;
447
+ } else if (prop.key.type === "Literal") {
448
+ const { value } = prop.key;
449
+ keyName = typeof value === "string" ? value : String(value);
361
450
  } else {
362
- // Spread elements or other non-Property nodes.
363
451
  autoFixable = false;
364
452
  }
365
453
 
454
+ if (isFunctionProperty(prop)) {
455
+ isFunc = true;
456
+ }
457
+
366
458
  return {
459
+ isFunction: isFunc,
367
460
  keyName,
368
- node: prop,
369
- isFunction: isFunc
461
+ node: prop
370
462
  };
371
463
  });
372
464
 
373
- const getFixableProps = () => {
374
- const props: TSESTree.Property[] = [];
375
- for (const prop of node.properties) {
376
- if (prop.type !== "Property") {
377
- continue;
378
- }
379
- if (prop.computed) {
380
- continue;
381
- }
382
- if (
383
- prop.key.type !== "Identifier" &&
384
- prop.key.type !== "Literal"
385
- ) {
386
- continue;
387
- }
388
- props.push(prop);
389
- }
390
- return props;
391
- };
465
+ if (hasDuplicateNames(keys.map((key) => key.keyName))) {
466
+ autoFixable = false;
467
+ }
468
+
469
+ if (
470
+ autoFixable &&
471
+ keys.some(
472
+ (key) =>
473
+ key.node.type === "Property" &&
474
+ !isSafeToReorderExpression(key.node.value)
475
+ )
476
+ ) {
477
+ autoFixable = false;
478
+ }
392
479
 
393
480
  let fixProvided = false;
394
481
 
395
- for (let i = 1; i < keys.length; i++) {
396
- const prev = keys[i - 1];
397
- const curr = keys[i];
482
+ const createReportWithFix = (curr: KeyInfo, shouldFix: boolean) => {
483
+ context.report({
484
+ fix: shouldFix
485
+ ? (fixer) => {
486
+ const fixableProps = getFixableProps(node);
487
+ if (fixableProps.length < minKeys) {
488
+ return null;
489
+ }
490
+
491
+ const [firstProp] = fixableProps;
492
+ const lastProp =
493
+ fixableProps[fixableProps.length - 1];
398
494
 
399
- if (!prev || !curr) {
400
- continue;
401
- }
495
+ if (!firstProp || !lastProp) {
496
+ return null;
497
+ }
498
+
499
+ const firstLeading = getLeadingComments(
500
+ firstProp,
501
+ null
502
+ );
503
+ const [firstLeadingComment] = firstLeading;
504
+ const rangeStart = firstLeadingComment
505
+ ? firstLeadingComment.range[0]
506
+ : firstProp.range[0];
507
+ const lastTrailing = getTrailingComments(
508
+ lastProp,
509
+ null
510
+ );
511
+ const rangeEnd =
512
+ lastTrailing.length > 0
513
+ ? lastTrailing[lastTrailing.length - 1]!
514
+ .range[1]
515
+ : lastProp.range[1];
516
+ const sortedText = buildSortedText(
517
+ fixableProps,
518
+ rangeStart
519
+ );
520
+
521
+ return fixer.replaceTextRange(
522
+ [rangeStart, rangeEnd],
523
+ sortedText
524
+ );
525
+ }
526
+ : null,
527
+ messageId: "unsorted",
528
+ node:
529
+ curr.node.type === "Property"
530
+ ? curr.node.key
531
+ : curr.node
532
+ });
533
+ fixProvided = true;
534
+ };
402
535
 
403
- if (prev.keyName === null || curr.keyName === null) {
404
- continue;
536
+ keys.forEach((curr, idx) => {
537
+ if (idx === 0) {
538
+ return;
539
+ }
540
+ const prev = keys[idx - 1];
541
+
542
+ if (
543
+ !prev ||
544
+ !curr ||
545
+ prev.keyName === null ||
546
+ curr.keyName === null
547
+ ) {
548
+ return;
405
549
  }
406
550
 
407
551
  const shouldFix = !fixProvided && autoFixable;
408
552
 
409
- const reportWithFix = () => {
410
- context.report({
411
- node:
412
- curr.node.type === "Property"
413
- ? curr.node.key
414
- : curr.node,
415
- messageId: "unsorted",
416
- fix: shouldFix
417
- ? (fixer) => {
418
- const fixableProps = getFixableProps();
419
- if (fixableProps.length < minKeys) {
420
- return null;
421
- }
422
-
423
- const firstProp = fixableProps[0];
424
- const lastProp =
425
- fixableProps[fixableProps.length - 1];
426
-
427
- if (!firstProp || !lastProp) {
428
- return null;
429
- }
430
-
431
- const firstLeading = getLeadingComments(
432
- firstProp,
433
- null
434
- );
435
- const rangeStart =
436
- firstLeading.length > 0
437
- ? firstLeading[0]!.range[0]
438
- : firstProp.range[0];
439
- const lastTrailing = getTrailingComments(
440
- lastProp,
441
- null
442
- );
443
- const rangeEnd =
444
- lastTrailing.length > 0
445
- ? lastTrailing[
446
- lastTrailing.length - 1
447
- ]!.range[1]
448
- : lastProp.range[1];
449
- const sortedText = buildSortedText(
450
- fixableProps,
451
- rangeStart
452
- );
453
-
454
- return fixer.replaceTextRange(
455
- [rangeStart, rangeEnd],
456
- sortedText
457
- );
458
- }
459
- : null
460
- });
461
- fixProvided = true;
462
- };
553
+ if (
554
+ variablesBeforeFunctions &&
555
+ prev.isFunction &&
556
+ !curr.isFunction
557
+ ) {
558
+ createReportWithFix(curr, shouldFix);
559
+ return;
560
+ }
463
561
 
464
- if (variablesBeforeFunctions) {
465
- if (prev.isFunction && !curr.isFunction) {
466
- reportWithFix();
467
- continue;
468
- }
562
+ if (
563
+ variablesBeforeFunctions &&
564
+ prev.isFunction === curr.isFunction &&
565
+ compareKeys(prev.keyName, curr.keyName) > 0
566
+ ) {
567
+ createReportWithFix(curr, shouldFix);
568
+ return;
569
+ }
469
570
 
470
- if (
471
- prev.isFunction === curr.isFunction &&
472
- compareKeys(prev.keyName, curr.keyName) > 0
473
- ) {
474
- reportWithFix();
475
- }
476
- } else {
477
- if (compareKeys(prev.keyName, curr.keyName) > 0) {
478
- reportWithFix();
479
- }
571
+ if (
572
+ !variablesBeforeFunctions &&
573
+ compareKeys(prev.keyName, curr.keyName) > 0
574
+ ) {
575
+ createReportWithFix(curr, shouldFix);
480
576
  }
481
- }
482
- }
577
+ });
578
+ };
483
579
 
484
580
  // Also check object literals inside JSX prop expressions
485
- function checkJSXAttributeObject(attr: TSESTree.JSXAttribute) {
486
- const value = attr.value;
487
- if (value && value.type === "JSXExpressionContainer") {
488
- const expr = value.expression;
489
- if (expr && expr.type === "ObjectExpression") {
490
- checkObjectExpression(expr);
491
- }
581
+ const checkJSXAttributeObject = (attr: TSESTree.JSXAttribute) => {
582
+ const { value } = attr;
583
+ if (
584
+ value &&
585
+ value.type === "JSXExpressionContainer" &&
586
+ value.expression &&
587
+ value.expression.type === "ObjectExpression"
588
+ ) {
589
+ checkObjectExpression(value.expression);
492
590
  }
493
- }
591
+ };
592
+
593
+ const getAttrName = (
594
+ attr: TSESTree.JSXAttribute | TSESTree.JSXSpreadAttribute
595
+ ) => {
596
+ if (
597
+ attr.type !== "JSXAttribute" ||
598
+ attr.name.type !== "JSXIdentifier"
599
+ ) {
600
+ return "";
601
+ }
602
+ return attr.name.name;
603
+ };
604
+
605
+ const compareAttrNames = (nameLeft: string, nameRight: string) => {
606
+ let res = compareKeys(nameLeft, nameRight);
607
+ if (order === "desc") {
608
+ res = -res;
609
+ }
610
+ return res;
611
+ };
612
+
613
+ const isOutOfOrder = (names: string[]) =>
614
+ names.some((currName, idx) => {
615
+ if (idx === 0 || !currName) {
616
+ return false;
617
+ }
618
+ const prevName = names[idx - 1];
619
+ return (
620
+ prevName !== undefined &&
621
+ compareAttrNames(prevName, currName) > 0
622
+ );
623
+ });
494
624
 
495
625
  // Also sort JSX attributes on elements
496
- function checkJSXOpeningElement(node: TSESTree.JSXOpeningElement) {
626
+ const checkJSXOpeningElement = (node: TSESTree.JSXOpeningElement) => {
497
627
  const attrs = node.attributes;
498
628
  if (attrs.length < minKeys) {
499
629
  return;
500
630
  }
501
631
 
502
- if (attrs.some((a) => a.type !== "JSXAttribute")) {
632
+ if (attrs.some((attr) => attr.type !== "JSXAttribute")) {
503
633
  return;
504
634
  }
505
635
  if (
506
636
  attrs.some(
507
- (a) =>
508
- a.type === "JSXAttribute" &&
509
- a.name.type !== "JSXIdentifier"
637
+ (attr) =>
638
+ attr.type === "JSXAttribute" &&
639
+ attr.name.type !== "JSXIdentifier"
510
640
  )
511
641
  ) {
512
642
  return;
513
643
  }
514
644
 
515
- const names = attrs.map((a) => {
516
- if (a.type !== "JSXAttribute") {
517
- return "";
518
- }
519
- if (a.name.type !== "JSXIdentifier") {
520
- return "";
521
- }
522
- return a.name.name;
523
- });
645
+ const names = attrs.map((attr) => getAttrName(attr));
524
646
 
525
- const cmp = (a: string, b: string) => {
526
- let res = compareKeys(a, b);
527
- if (order === "desc") {
528
- res = -res;
529
- }
530
- return res;
531
- };
647
+ if (!isOutOfOrder(names)) {
648
+ return;
649
+ }
532
650
 
533
- let outOfOrder = false;
534
- for (let i = 1; i < names.length; i++) {
535
- const prevName = names[i - 1];
536
- const currName = names[i];
537
- if (!prevName || !currName) {
538
- continue;
539
- }
540
- if (cmp(prevName, currName) > 0) {
541
- outOfOrder = true;
542
- break;
543
- }
651
+ if (hasDuplicateNames(names)) {
652
+ context.report({
653
+ messageId: "unsorted",
654
+ node:
655
+ attrs[0]!.type === "JSXAttribute"
656
+ ? attrs[0]!.name
657
+ : attrs[0]!
658
+ });
659
+ return;
544
660
  }
545
661
 
546
- if (!outOfOrder) {
662
+ if (
663
+ attrs.some(
664
+ (attr) =>
665
+ attr.type === "JSXAttribute" &&
666
+ !isSafeJSXAttributeValue(attr.value)
667
+ )
668
+ ) {
669
+ context.report({
670
+ messageId: "unsorted",
671
+ node:
672
+ attrs[0]!.type === "JSXAttribute"
673
+ ? attrs[0]!.name
674
+ : attrs[0]!
675
+ });
547
676
  return;
548
677
  }
549
678
 
550
679
  // Be conservative: only fix if there are no JSX comments/braces between attributes.
551
- for (let i = 1; i < attrs.length; i++) {
552
- const prevAttr = attrs[i - 1];
553
- const currAttr = attrs[i];
554
-
555
- if (!prevAttr || !currAttr) {
556
- continue;
680
+ const braceConflict = attrs.find((currAttr, idx) => {
681
+ if (idx === 0) {
682
+ return false;
683
+ }
684
+ const prevAttr = attrs[idx - 1];
685
+ if (!prevAttr) {
686
+ return false;
557
687
  }
558
-
559
688
  const between = sourceCode.text.slice(
560
689
  prevAttr.range[1],
561
690
  currAttr.range[0]
562
691
  );
563
- if (between.includes("{")) {
564
- context.report({
565
- node:
566
- currAttr.type === "JSXAttribute"
567
- ? currAttr.name
568
- : currAttr,
569
- messageId: "unsorted"
570
- });
571
- return;
572
- }
692
+ return between.includes("{");
693
+ });
694
+
695
+ if (braceConflict) {
696
+ context.report({
697
+ messageId: "unsorted",
698
+ node:
699
+ braceConflict.type === "JSXAttribute"
700
+ ? braceConflict.name
701
+ : braceConflict
702
+ });
703
+ return;
573
704
  }
574
705
 
575
- const sortedAttrs = attrs.slice().sort((a, b) => {
576
- const aName =
577
- a.type === "JSXAttribute" && a.name.type === "JSXIdentifier"
578
- ? a.name.name
579
- : "";
580
- const bName =
581
- b.type === "JSXAttribute" && b.name.type === "JSXIdentifier"
582
- ? b.name.name
583
- : "";
584
- return cmp(aName, bName);
585
- });
706
+ const sortedAttrs = attrs
707
+ .slice()
708
+ .sort((left, right) =>
709
+ compareAttrNames(getAttrName(left), getAttrName(right))
710
+ );
586
711
 
587
- const firstAttr = attrs[0];
712
+ const [firstAttr] = attrs;
588
713
  const lastAttr = attrs[attrs.length - 1];
589
714
 
590
715
  if (!firstAttr || !lastAttr) {
@@ -592,30 +717,69 @@ export const sortKeysFixable: TSESLint.RuleModule<MessageIds, Options> = {
592
717
  }
593
718
 
594
719
  const replacement = sortedAttrs
595
- .map((a) => sourceCode.getText(a))
720
+ .map((attr) => sourceCode.getText(attr))
596
721
  .join(" ");
597
722
 
598
723
  context.report({
599
- node:
600
- firstAttr.type === "JSXAttribute"
601
- ? firstAttr.name
602
- : firstAttr,
603
- messageId: "unsorted",
604
724
  fix(fixer) {
605
725
  return fixer.replaceTextRange(
606
726
  [firstAttr.range[0], lastAttr.range[1]],
607
727
  replacement
608
728
  );
609
- }
729
+ },
730
+ messageId: "unsorted",
731
+ node:
732
+ firstAttr.type === "JSXAttribute"
733
+ ? firstAttr.name
734
+ : firstAttr
610
735
  });
611
- }
736
+ };
612
737
 
613
738
  return {
614
- ObjectExpression: checkObjectExpression,
615
739
  JSXAttribute(node: TSESTree.JSXAttribute) {
616
740
  checkJSXAttributeObject(node);
617
741
  },
618
- JSXOpeningElement: checkJSXOpeningElement
742
+ JSXOpeningElement: checkJSXOpeningElement,
743
+ ObjectExpression: checkObjectExpression
619
744
  };
745
+ },
746
+ defaultOptions: [{}],
747
+ meta: {
748
+ docs: {
749
+ description:
750
+ "enforce sorted keys in object literals with auto-fix (limited to simple cases, preserving comments)"
751
+ },
752
+ fixable: "code",
753
+ messages: {
754
+ unsorted: "Object keys are not sorted."
755
+ },
756
+ // The schema supports the same options as the built-in sort-keys rule plus:
757
+ // variablesBeforeFunctions: boolean (when true, non-function properties come before function properties)
758
+ schema: [
759
+ {
760
+ additionalProperties: false,
761
+ properties: {
762
+ caseSensitive: {
763
+ type: "boolean"
764
+ },
765
+ minKeys: {
766
+ minimum: 2,
767
+ type: "integer"
768
+ },
769
+ natural: {
770
+ type: "boolean"
771
+ },
772
+ order: {
773
+ enum: ["asc", "desc"],
774
+ type: "string"
775
+ },
776
+ variablesBeforeFunctions: {
777
+ type: "boolean"
778
+ }
779
+ },
780
+ type: "object"
781
+ }
782
+ ],
783
+ type: "suggestion"
620
784
  }
621
785
  };