@ttsc/lint 0.19.2 → 0.20.0

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.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/lib/index.js +26 -4
  3. package/lib/index.js.map +1 -1
  4. package/lib/structures/format/ITtscLintFormat.d.ts +14 -10
  5. package/lib/structures/rules/ITtscLintRegexpRules.d.ts +24 -9
  6. package/lib/structures/rules/ITtscLintSecurityRules.d.ts +18 -0
  7. package/lib/structures/rules/ITtscLintSolidRules.d.ts +22 -2
  8. package/lib/structures/rules/ITtscLintStorybookRules.d.ts +21 -0
  9. package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.d.ts +10 -1
  10. package/lib/structures/rules/ITtscLintTypeScriptRules.d.ts +13 -2
  11. package/linthost/compile.go +9 -4
  12. package/linthost/config.go +18 -14
  13. package/linthost/contrib_adapter.go +57 -0
  14. package/linthost/dispatch.go +5 -1
  15. package/linthost/display_width.go +179 -41
  16. package/linthost/engine.go +120 -4
  17. package/linthost/flags_gen.go +4 -4
  18. package/linthost/hints.go +207 -0
  19. package/linthost/host.go +68 -0
  20. package/linthost/lsp.go +148 -29
  21. package/linthost/print_dispatch.go +24 -0
  22. package/linthost/print_nodes_array.go +88 -15
  23. package/linthost/print_nodes_call.go +38 -31
  24. package/linthost/print_nodes_control_flow.go +319 -0
  25. package/linthost/print_nodes_function.go +140 -13
  26. package/linthost/print_nodes_list.go +22 -10
  27. package/linthost/project_engine.go +18 -1
  28. package/linthost/project_rules.go +47 -0
  29. package/linthost/rule_docs.go +114 -0
  30. package/linthost/rules_boundaries.go +80 -2
  31. package/linthost/rules_format_bracket_spacing.go +18 -6
  32. package/linthost/rules_format_clause_join.go +11 -8
  33. package/linthost/rules_format_indent.go +155 -4
  34. package/linthost/rules_format_print_width.go +12 -35
  35. package/linthost/rules_format_quote_props.go +88 -27
  36. package/linthost/rules_format_statement_split.go +81 -0
  37. package/linthost/rules_format_trailing_comma.go +62 -94
  38. package/linthost/rules_gap.go +26 -17
  39. package/linthost/rules_jsdoc.go +54 -0
  40. package/linthost/rules_logic.go +30 -15
  41. package/linthost/rules_no_redeclare.go +12 -3
  42. package/linthost/rules_promise.go +37 -15
  43. package/linthost/rules_regexp.go +443 -49
  44. package/linthost/rules_security.go +243 -4
  45. package/linthost/rules_solid.go +430 -10
  46. package/linthost/rules_storybook.go +255 -10
  47. package/linthost/rules_suggestions.go +60 -39
  48. package/linthost/rules_ts.go +7 -0
  49. package/linthost/rules_ts_async.go +80 -2
  50. package/linthost/rules_ts_extra.go +21 -7
  51. package/linthost/serve.go +318 -0
  52. package/linthost/width_tables_gen.go +219 -0
  53. package/package.json +2 -2
  54. package/rule/hint.go +142 -0
  55. package/rule/rule.go +197 -1
  56. package/src/index.ts +35 -4
  57. package/src/structures/format/ITtscLintFormat.ts +14 -10
  58. package/src/structures/rules/ITtscLintRegexpRules.ts +24 -9
  59. package/src/structures/rules/ITtscLintSecurityRules.ts +18 -0
  60. package/src/structures/rules/ITtscLintSolidRules.ts +22 -2
  61. package/src/structures/rules/ITtscLintStorybookRules.ts +21 -0
  62. package/src/structures/rules/ITtscLintTypeScriptRuleOptions.ts +10 -1
  63. package/src/structures/rules/ITtscLintTypeScriptRules.ts +13 -2
@@ -18,14 +18,40 @@ import (
18
18
  type regexpSourceRule struct {
19
19
  name string
20
20
  check func(regexpLiteralParts) bool
21
+ // repair turns an accepted finding into the correction the check already
22
+ // located. A rule without one stays diagnostic-only.
23
+ repair func(regexpLiteralParts) regexpRepair
21
24
  }
22
25
 
23
26
  type regexpLiteralParts struct {
27
+ // start is the literal's offset in the source file. Repairs are computed in
28
+ // literal-relative coordinates and lifted onto the file through it.
29
+ start int
24
30
  raw string
25
31
  pattern string
26
32
  flags string
27
33
  }
28
34
 
35
+ // patternOffset is where the pattern begins inside `raw`, past the opening `/`.
36
+ func (regexpLiteralParts) patternOffset() int { return 1 }
37
+
38
+ // flagsOffset is where the flag run begins inside `raw`, past the closing `/`.
39
+ func (parts regexpLiteralParts) flagsOffset() int { return len(parts.pattern) + 2 }
40
+
41
+ // regexpRepair is the correction a regexp rule computed for one literal.
42
+ //
43
+ // Its edits are relative to the literal's own text, so a rule never handles
44
+ // file coordinates; `Check` validates and lifts the whole repair in one place.
45
+ type regexpRepair struct {
46
+ // message replaces regexpRuleMessage when non-empty, for a rule whose
47
+ // finding can name the exact thing it located.
48
+ message string
49
+ // fix is the one correct rewrite, applied by `ttsc fix`.
50
+ fix []TextEdit
51
+ // suggestions are competing rewrites only the author can choose between.
52
+ suggestions []Suggestion
53
+ }
54
+
29
55
  func (r regexpSourceRule) Name() string { return r.name }
30
56
  func (regexpSourceRule) Visits() []shimast.Kind {
31
57
  return []shimast.Kind{shimast.KindRegularExpressionLiteral}
@@ -35,7 +61,80 @@ func (r regexpSourceRule) Check(ctx *Context, node *shimast.Node) {
35
61
  if !ok || r.check == nil || !r.check(parts) {
36
62
  return
37
63
  }
38
- ctx.Report(node, regexpRuleMessage(r.name))
64
+ message := regexpRuleMessage(r.name)
65
+ if r.repair == nil {
66
+ ctx.Report(node, message)
67
+ return
68
+ }
69
+ repair := r.repair(parts)
70
+ if repair.message != "" {
71
+ message = repair.message
72
+ }
73
+ suggestions := make([]Suggestion, 0, len(repair.suggestions))
74
+ for _, suggestion := range repair.suggestions {
75
+ edits := parts.acceptEdits(suggestion.Edits)
76
+ if len(edits) == 0 {
77
+ continue
78
+ }
79
+ suggestions = append(suggestions, Suggestion{Title: suggestion.Title, Edits: edits})
80
+ }
81
+ ctx.ReportFixSuggestions(node, message, parts.acceptEdits(repair.fix), suggestions...)
82
+ }
83
+
84
+ // acceptEdits validates one candidate rewrite of this literal and lifts it into
85
+ // file coordinates, or returns nil to leave the finding without that edit.
86
+ //
87
+ // The gate is the compiler's own regexp parser applied to the rewritten literal
88
+ // as a whole. Every repair here is a splice into live regex syntax, where a
89
+ // locally correct edit can still leave a pattern the engine rejects: `/{1,}/`
90
+ // carries no atom, so its brace run is an Annex B literal rather than a
91
+ // quantifier, and rewriting it to `/+/` yields "nothing to repeat". Rules stay
92
+ // free to compute the ideal edit and cannot emit one that fails to parse.
93
+ //
94
+ // Validity is not equivalence. That a rewrite still parses says nothing about
95
+ // whether it matches the same strings, so preserving semantics remains each
96
+ // rule's own burden -- this only keeps a syntactically broken edit off disk.
97
+ func (parts regexpLiteralParts) acceptEdits(edits []TextEdit) []TextEdit {
98
+ rewritten, ok := applyRegexpLiteralEdits(parts.raw, edits)
99
+ if !ok || rewritten == parts.raw {
100
+ return nil
101
+ }
102
+ if !shimscanner.IsValidRegularExpressionLiteral(rewritten) {
103
+ return nil
104
+ }
105
+ shifted := make([]TextEdit, 0, len(edits))
106
+ for _, edit := range edits {
107
+ shifted = append(shifted, TextEdit{
108
+ Pos: parts.start + edit.Pos,
109
+ End: parts.start + edit.End,
110
+ Text: edit.Text,
111
+ })
112
+ }
113
+ return shifted
114
+ }
115
+
116
+ // applyRegexpLiteralEdits splices literal-relative edits into `raw`. It reports
117
+ // false for an empty, out-of-bounds, or overlapping edit set rather than
118
+ // producing text from a half-applied rewrite.
119
+ func applyRegexpLiteralEdits(raw string, edits []TextEdit) (string, bool) {
120
+ if len(edits) == 0 {
121
+ return "", false
122
+ }
123
+ ordered := make([]TextEdit, len(edits))
124
+ copy(ordered, edits)
125
+ sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Pos < ordered[j].Pos })
126
+ boundary := 0
127
+ for _, edit := range ordered {
128
+ if edit.Pos < boundary || edit.End < edit.Pos || edit.End > len(raw) {
129
+ return "", false
130
+ }
131
+ boundary = edit.End
132
+ }
133
+ out := raw
134
+ for i := len(ordered) - 1; i >= 0; i-- {
135
+ out = out[:ordered[i].Pos] + ordered[i].Text + out[ordered[i].End:]
136
+ }
137
+ return out, true
39
138
  }
40
139
 
41
140
  type regexpNoUselessEscapeAlias struct{}
@@ -53,8 +152,11 @@ func (regexpNoUselessEscapeAlias) Check(ctx *Context, node *shimast.Node) {
53
152
  }
54
153
 
55
154
  func parseRegexpLiteralParts(ctx *Context, node *shimast.Node) (regexpLiteralParts, bool) {
155
+ // `nodeText` and `tokenRange` skip the same leading trivia, so the text a
156
+ // repair reasons about always begins at exactly `start` in the file.
157
+ start, _ := tokenRange(ctx.File, node)
56
158
  raw := nodeText(ctx.File, node)
57
- if len(raw) < 2 || raw[0] != '/' {
159
+ if start < 0 || len(raw) < 2 || raw[0] != '/' {
58
160
  return regexpLiteralParts{}, false
59
161
  }
60
162
  closing := strings.LastIndexByte(raw, '/')
@@ -62,6 +164,7 @@ func parseRegexpLiteralParts(ctx *Context, node *shimast.Node) (regexpLiteralPar
62
164
  return regexpLiteralParts{}, false
63
165
  }
64
166
  return regexpLiteralParts{
167
+ start: start,
65
168
  raw: raw,
66
169
  pattern: raw[1:closing],
67
170
  flags: raw[closing+1:],
@@ -184,40 +287,34 @@ func regexpHasEmptyCharacterClass(parts regexpLiteralParts) bool {
184
287
  return false
185
288
  }
186
289
 
187
- func regexpHasZeroQuantifier(parts regexpLiteralParts) bool {
188
- return scanRegexpQuantifiers(parts.pattern, func(min, max int, hasComma bool) bool {
189
- return min == 0 && (!hasComma || max == 0)
190
- })
290
+ func regexpQuantifierIsZero(quantifier regexpQuantifier) bool {
291
+ return quantifier.min == 0 && (!quantifier.hasComma || quantifier.max == 0)
191
292
  }
192
293
 
193
- func regexpHasUselessTwoNumsQuantifier(parts regexpLiteralParts) bool {
194
- return scanRegexpQuantifiers(parts.pattern, func(min, max int, hasComma bool) bool {
195
- return hasComma && min == max && min >= 0
196
- })
294
+ func regexpQuantifierIsTwoNums(quantifier regexpQuantifier) bool {
295
+ return quantifier.hasComma && quantifier.min == quantifier.max && quantifier.min >= 0
197
296
  }
198
297
 
199
- func regexpHasUselessQuantifier(parts regexpLiteralParts) bool {
200
- return scanRegexpQuantifiers(parts.pattern, func(min, max int, hasComma bool) bool {
201
- return !hasComma && min == 1 && max == -1
202
- })
298
+ func regexpQuantifierIsUseless(quantifier regexpQuantifier) bool {
299
+ return !quantifier.hasComma && quantifier.min == 1 && quantifier.max == -1
203
300
  }
204
301
 
205
- func regexpHasPlusQuantifierCandidate(parts regexpLiteralParts) bool {
206
- return scanRegexpQuantifiers(parts.pattern, func(min, max int, hasComma bool) bool {
207
- return hasComma && min == 1 && max == -1
208
- })
302
+ func regexpQuantifierIsPlus(quantifier regexpQuantifier) bool {
303
+ return quantifier.hasComma && quantifier.min == 1 && quantifier.max == -1
209
304
  }
210
305
 
211
- func regexpHasStarQuantifierCandidate(parts regexpLiteralParts) bool {
212
- return scanRegexpQuantifiers(parts.pattern, func(min, max int, hasComma bool) bool {
213
- return hasComma && min == 0 && max == -1
214
- })
306
+ func regexpQuantifierIsStar(quantifier regexpQuantifier) bool {
307
+ return quantifier.hasComma && quantifier.min == 0 && quantifier.max == -1
215
308
  }
216
309
 
217
- func regexpHasQuestionQuantifierCandidate(parts regexpLiteralParts) bool {
218
- return scanRegexpQuantifiers(parts.pattern, func(min, max int, hasComma bool) bool {
219
- return hasComma && min == 0 && max == 1
220
- })
310
+ func regexpQuantifierIsQuestion(quantifier regexpQuantifier) bool {
311
+ return quantifier.hasComma && quantifier.min == 0 && quantifier.max == 1
312
+ }
313
+
314
+ func regexpQuantifierCheck(accept func(regexpQuantifier) bool) func(regexpLiteralParts) bool {
315
+ return func(parts regexpLiteralParts) bool {
316
+ return scanRegexpQuantifiers(parts.pattern, accept)
317
+ }
221
318
  }
222
319
 
223
320
  func regexpNeedsUnicodeFlag(parts regexpLiteralParts) bool {
@@ -229,11 +326,75 @@ func regexpNeedsUnicodeSetsFlag(parts regexpLiteralParts) bool {
229
326
  }
230
327
 
231
328
  func regexpFlagsUnsorted(parts regexpLiteralParts) bool {
232
- sorted := []byte(parts.flags)
329
+ return regexpSortedFlags(parts.flags) != parts.flags
330
+ }
331
+
332
+ func regexpSortedFlags(flags string) string {
333
+ sorted := []byte(flags)
233
334
  sort.SliceStable(sorted, func(i, j int) bool {
234
335
  return regexpFlagOrder(sorted[i]) < regexpFlagOrder(sorted[j])
235
336
  })
236
- return string(sorted) != parts.flags
337
+ return string(sorted)
338
+ }
339
+
340
+ // regexpSortFlagsRepair hands back the sorted flag string the check already
341
+ // built to decide the finding. A permutation of the flag run cannot change what
342
+ // the literal matches, so this is a fix rather than a suggestion.
343
+ func regexpSortFlagsRepair(parts regexpLiteralParts) regexpRepair {
344
+ return regexpRepair{fix: []TextEdit{parts.flagEdit(regexpSortedFlags(parts.flags))}}
345
+ }
346
+
347
+ // flagEdit replaces this literal's whole flag run.
348
+ func (parts regexpLiteralParts) flagEdit(flags string) TextEdit {
349
+ return TextEdit{
350
+ Pos: parts.flagsOffset(),
351
+ End: parts.flagsOffset() + len(parts.flags),
352
+ Text: flags,
353
+ }
354
+ }
355
+
356
+ // regexpFlagsWith inserts `flag` at its canonical `dgimsuvy` position without
357
+ // reordering the flags already present, so adding one flag never silently does
358
+ // `regexp/sort-flags`' job on an unsorted literal.
359
+ func regexpFlagsWith(flags string, flag byte) string {
360
+ if strings.IndexByte(flags, flag) >= 0 {
361
+ return flags
362
+ }
363
+ order := regexpFlagOrder(flag)
364
+ for i := 0; i < len(flags); i++ {
365
+ if regexpFlagOrder(flags[i]) > order {
366
+ return flags[:i] + string(flag) + flags[i:]
367
+ }
368
+ }
369
+ return flags + string(flag)
370
+ }
371
+
372
+ // regexpUnicodeFlagRepair offers `u` and `v` as competing suggestions.
373
+ //
374
+ // Both satisfy the rule and neither is the obvious answer: `u` is the widely
375
+ // supported mode, `v` the stricter ES2024 superset. Both also change what the
376
+ // pattern matches -- surrogate pairs stop being two independent code units --
377
+ // so this is never applied automatically by `ttsc fix`.
378
+ func regexpUnicodeFlagRepair(parts regexpLiteralParts) regexpRepair {
379
+ return regexpRepair{suggestions: []Suggestion{
380
+ {Title: "Add the `u` flag.", Edits: []TextEdit{parts.flagEdit(regexpFlagsWith(parts.flags, 'u'))}},
381
+ {Title: "Add the `v` flag.", Edits: []TextEdit{parts.flagEdit(regexpFlagsWith(parts.flags, 'v'))}},
382
+ }}
383
+ }
384
+
385
+ // regexpUnicodeSetsFlagRepair offers the single `v` rewrite, replacing `u`
386
+ // where the literal already carries it. It stays a suggestion for the same
387
+ // reason as regexpUnicodeFlagRepair: `v` is a stricter mode with its own
388
+ // matching semantics, not a spelling of the existing pattern.
389
+ func regexpUnicodeSetsFlagRepair(parts regexpLiteralParts) regexpRepair {
390
+ title, flags := "Add the `v` flag.", parts.flags
391
+ if strings.IndexByte(flags, 'u') >= 0 {
392
+ title = "Replace the `u` flag with `v`."
393
+ flags = strings.Replace(flags, "u", "", 1)
394
+ }
395
+ return regexpRepair{suggestions: []Suggestion{
396
+ {Title: title, Edits: []TextEdit{parts.flagEdit(regexpFlagsWith(flags, 'v'))}},
397
+ }}
237
398
  }
238
399
 
239
400
  func regexpFlagOrder(flag byte) int {
@@ -257,22 +418,65 @@ func regexpFlagOrder(flag byte) int {
257
418
  // A literal the parser rejects yields no finding at all: the rule tells people
258
419
  // to delete a flag, so it stays silent whenever it cannot see the whole pattern.
259
420
  func regexpHasUselessFlag(parts regexpLiteralParts) bool {
421
+ return regexpUselessFlags(parts) != ""
422
+ }
423
+
424
+ // regexpUselessFlags returns every flag the literal carries that its own
425
+ // pattern can never exercise, in canonical order.
426
+ func regexpUselessFlags(parts regexpLiteralParts) string {
260
427
  ignoreCase := strings.Contains(parts.flags, "i")
261
428
  multiline := strings.Contains(parts.flags, "m")
262
429
  if !ignoreCase && !multiline {
263
- return false
430
+ return ""
264
431
  }
265
432
  parsed, err := regexParseLiteral(parts.raw)
266
433
  if err != nil {
267
- return false
434
+ return ""
268
435
  }
436
+ useless := make([]byte, 0, 2)
269
437
  if ignoreCase && !regexpNodeIsCaseVariant(parsed.Body, strings.ContainsAny(parts.flags, "uv")) {
270
- return true
438
+ useless = append(useless, 'i')
271
439
  }
272
440
  if multiline && !regexpNodeHasLineAnchor(parsed.Body) {
273
- return true
441
+ useless = append(useless, 'm')
274
442
  }
275
- return false
443
+ return string(useless)
444
+ }
445
+
446
+ // regexpUselessFlagRepair deletes the dead flags the analysis named. The
447
+ // analysis is one-sided -- anything it cannot settle counts as using the flag
448
+ // -- so a flag it does reach here is provably inert and the deletion is a fix
449
+ // rather than a suggestion.
450
+ func regexpUselessFlagRepair(parts regexpLiteralParts) regexpRepair {
451
+ useless := regexpUselessFlags(parts)
452
+ if useless == "" {
453
+ return regexpRepair{}
454
+ }
455
+ var edits []TextEdit
456
+ for i := 0; i < len(parts.flags); i++ {
457
+ if strings.IndexByte(useless, parts.flags[i]) < 0 {
458
+ continue
459
+ }
460
+ edits = append(edits, TextEdit{
461
+ Pos: parts.flagsOffset() + i,
462
+ End: parts.flagsOffset() + i + 1,
463
+ })
464
+ }
465
+ return regexpRepair{message: regexpUselessFlagMessage(useless), fix: edits}
466
+ }
467
+
468
+ // regexpUselessFlagMessage names the flags rather than leaving the reader to
469
+ // rediscover which one the analysis found inert. Only `i` and `m` are ever
470
+ // judged, so the list is one or two entries.
471
+ func regexpUselessFlagMessage(useless string) string {
472
+ quoted := make([]string, 0, len(useless))
473
+ for i := 0; i < len(useless); i++ {
474
+ quoted = append(quoted, "`"+string(useless[i])+"`")
475
+ }
476
+ if len(quoted) == 1 {
477
+ return "Unexpected useless regular expression flag " + quoted[0] + "."
478
+ }
479
+ return "Unexpected useless regular expression flags " + strings.Join(quoted, " and ") + "."
276
480
  }
277
481
 
278
482
  func regexpHasPreferD(parts regexpLiteralParts) bool {
@@ -341,7 +545,22 @@ func scanRegexpPattern(pattern string, visit func(pattern string, i int) bool) b
341
545
  return false
342
546
  }
343
547
 
344
- func scanRegexpQuantifiers(pattern string, visit func(min, max int, hasComma bool) bool) bool {
548
+ // regexpQuantifier is one `{...}` count quantifier located in a pattern.
549
+ //
550
+ // The span travels with the bounds because every quantifier-shorthand rule in
551
+ // this family answers "is this quantifier redundant?" and "what replaces it?"
552
+ // from the same scan.
553
+ type regexpQuantifier struct {
554
+ // start and end bracket `{`..`}` inclusive-exclusive, relative to the
555
+ // pattern rather than to the whole literal.
556
+ start int
557
+ end int
558
+ min int
559
+ max int
560
+ hasComma bool
561
+ }
562
+
563
+ func scanRegexpQuantifiers(pattern string, visit func(regexpQuantifier) bool) bool {
345
564
  return scanRegexpPattern(pattern, func(pattern string, i int) bool {
346
565
  if pattern[i] != '{' {
347
566
  return false
@@ -365,10 +584,81 @@ func scanRegexpQuantifiers(pattern string, visit func(min, max int, hasComma boo
365
584
  } else {
366
585
  min = parseRegexpQuantifierNumber(body)
367
586
  }
368
- return min >= 0 && visit(min, max, hasComma)
587
+ return min >= 0 && visit(regexpQuantifier{
588
+ start: i,
589
+ end: end + 1,
590
+ min: min,
591
+ max: max,
592
+ hasComma: hasComma,
593
+ })
369
594
  })
370
595
  }
371
596
 
597
+ // regexpQuantifierRepair builds the repair shared by the quantifier-shorthand
598
+ // rules: every `{...}` the rule accepts is rewritten by `rewrite`, and the
599
+ // whole set travels as one atomic fix so a literal is never half-canonicalized.
600
+ //
601
+ // `rewrite` may decline an individual quantifier whose neighbours make the
602
+ // rewrite unsafe; the remaining ones still apply.
603
+ func regexpQuantifierRepair(
604
+ accept func(regexpQuantifier) bool,
605
+ rewrite func(pattern string, quantifier regexpQuantifier) (string, bool),
606
+ ) func(regexpLiteralParts) regexpRepair {
607
+ return func(parts regexpLiteralParts) regexpRepair {
608
+ var edits []TextEdit
609
+ scanRegexpQuantifiers(parts.pattern, func(quantifier regexpQuantifier) bool {
610
+ if !accept(quantifier) {
611
+ return false
612
+ }
613
+ if text, ok := rewrite(parts.pattern, quantifier); ok {
614
+ edits = append(edits, TextEdit{
615
+ Pos: parts.patternOffset() + quantifier.start,
616
+ End: parts.patternOffset() + quantifier.end,
617
+ Text: text,
618
+ })
619
+ }
620
+ return false
621
+ })
622
+ return regexpRepair{fix: edits}
623
+ }
624
+ }
625
+
626
+ // regexpQuantifierSymbol rewrites a count quantifier to its one-character
627
+ // shorthand. `{1,}` and `+` bind identically, so a trailing lazy `?` survives
628
+ // the swap unchanged.
629
+ func regexpQuantifierSymbol(symbol string) func(string, regexpQuantifier) (string, bool) {
630
+ return func(string, regexpQuantifier) (string, bool) { return symbol, true }
631
+ }
632
+
633
+ // regexpQuantifierExactCount collapses `{n,n}` to `{n}`. The braces stay, so
634
+ // nothing can fuse with a neighbouring token.
635
+ func regexpQuantifierExactCount(_ string, quantifier regexpQuantifier) (string, bool) {
636
+ return "{" + strconv.Itoa(quantifier.min) + "}", true
637
+ }
638
+
639
+ // regexpQuantifierDrop deletes a `{1}` that repeats its atom exactly once.
640
+ //
641
+ // Two following characters make the deletion unsafe, and neither is caught by
642
+ // re-parsing the result, because both rewrites still parse:
643
+ //
644
+ // - `?`, `*`, `+`, or `{`: the braces are the quantifier and the `?` in
645
+ // `/a{1}?/` only makes it lazy, so dropping them turns "exactly one" into
646
+ // "zero or one".
647
+ // - A digit: the braces separate a backreference or octal escape from a
648
+ // digit, and `/\1{1}2/` would fuse into `\12`, backreference twelve.
649
+ func regexpQuantifierDrop(pattern string, quantifier regexpQuantifier) (string, bool) {
650
+ if quantifier.end >= len(pattern) {
651
+ return "", true
652
+ }
653
+ switch next := pattern[quantifier.end]; {
654
+ case next == '?', next == '*', next == '+', next == '{':
655
+ return "", false
656
+ case next >= '0' && next <= '9':
657
+ return "", false
658
+ }
659
+ return "", true
660
+ }
661
+
372
662
  func parseRegexpQuantifierNumber(text string) int {
373
663
  if text == "" {
374
664
  return -1
@@ -385,7 +675,20 @@ func parseRegexpQuantifierNumber(text string) int {
385
675
  return value
386
676
  }
387
677
 
388
- func walkRegexpCharacterClasses(pattern string, visit func(content string) bool) bool {
678
+ // regexpClassSpan is one character class located in a pattern.
679
+ type regexpClassSpan struct {
680
+ // start and end bracket `[`..`]` inclusive-exclusive, relative to the
681
+ // pattern rather than to the whole literal.
682
+ start int
683
+ end int
684
+ content string
685
+ }
686
+
687
+ // walkRegexpCharacterClassSpans visits every character class in source order,
688
+ // stopping early when visit returns true and reporting whether it did. A `[`
689
+ // that opens no class -- an escaped bracket, or one with no closing `]` -- is
690
+ // skipped, which is what keeps `/\[0-9]/` from being read as a digit class.
691
+ func walkRegexpCharacterClassSpans(pattern string, visit func(regexpClassSpan) bool) bool {
389
692
  for i := 0; i < len(pattern); i++ {
390
693
  if pattern[i] == '\\' {
391
694
  i++
@@ -401,7 +704,7 @@ func walkRegexpCharacterClasses(pattern string, visit func(content string) bool)
401
704
  continue
402
705
  }
403
706
  if pattern[j] == ']' {
404
- if visit(pattern[start:j]) {
707
+ if visit(regexpClassSpan{start: i, end: j + 1, content: pattern[start:j]}) {
405
708
  return true
406
709
  }
407
710
  i = j
@@ -412,6 +715,47 @@ func walkRegexpCharacterClasses(pattern string, visit func(content string) bool)
412
715
  return false
413
716
  }
414
717
 
718
+ func walkRegexpCharacterClasses(pattern string, visit func(content string) bool) bool {
719
+ return walkRegexpCharacterClassSpans(pattern, func(span regexpClassSpan) bool {
720
+ return visit(span.content)
721
+ })
722
+ }
723
+
724
+ // regexpClassShorthandRepair rewrites every character class spelled exactly as
725
+ // one of `spellings` into `shorthand`.
726
+ //
727
+ // The shorthand always begins with a backslash, so it cannot fuse with the
728
+ // atom before it the way a bare character could, and it is a complete atom, so
729
+ // a following quantifier keeps applying to the same thing.
730
+ //
731
+ // The class walk is stricter than the substring test that decides the finding:
732
+ // it will not see a spelled-out class nested inside a `v`-mode class, so such a
733
+ // literal is reported without a fix rather than rewritten through a bracket
734
+ // that is not the class boundary.
735
+ func regexpClassShorthandRepair(
736
+ shorthand string,
737
+ spellings ...string,
738
+ ) func(regexpLiteralParts) regexpRepair {
739
+ return func(parts regexpLiteralParts) regexpRepair {
740
+ var edits []TextEdit
741
+ walkRegexpCharacterClassSpans(parts.pattern, func(span regexpClassSpan) bool {
742
+ for _, spelling := range spellings {
743
+ if span.content != spelling {
744
+ continue
745
+ }
746
+ edits = append(edits, TextEdit{
747
+ Pos: parts.patternOffset() + span.start,
748
+ End: parts.patternOffset() + span.end,
749
+ Text: shorthand,
750
+ })
751
+ break
752
+ }
753
+ return false
754
+ })
755
+ return regexpRepair{fix: edits}
756
+ }
757
+ }
758
+
415
759
  func classHasRange(content string) bool {
416
760
  for i := 1; i+1 < len(content); i++ {
417
761
  if content[i] == '\\' {
@@ -611,16 +955,66 @@ func init() {
611
955
  }})
612
956
  Register(regexpSourceRule{name: "regexp/no-useless-character-class", check: regexpHasUselessCharacterClass})
613
957
  Register(regexpNoUselessEscapeAlias{})
614
- Register(regexpSourceRule{name: "regexp/no-useless-flag", check: regexpHasUselessFlag})
615
- Register(regexpSourceRule{name: "regexp/no-useless-quantifier", check: regexpHasUselessQuantifier})
616
- Register(regexpSourceRule{name: "regexp/no-useless-two-nums-quantifier", check: regexpHasUselessTwoNumsQuantifier})
617
- Register(regexpSourceRule{name: "regexp/no-zero-quantifier", check: regexpHasZeroQuantifier})
618
- Register(regexpSourceRule{name: "regexp/prefer-d", check: regexpHasPreferD})
619
- Register(regexpSourceRule{name: "regexp/prefer-plus-quantifier", check: regexpHasPlusQuantifierCandidate})
620
- Register(regexpSourceRule{name: "regexp/prefer-question-quantifier", check: regexpHasQuestionQuantifierCandidate})
621
- Register(regexpSourceRule{name: "regexp/prefer-star-quantifier", check: regexpHasStarQuantifierCandidate})
622
- Register(regexpSourceRule{name: "regexp/prefer-w", check: regexpHasPreferW})
623
- Register(regexpSourceRule{name: "regexp/require-unicode-regexp", check: regexpNeedsUnicodeFlag})
624
- Register(regexpSourceRule{name: "regexp/require-unicode-sets-regexp", check: regexpNeedsUnicodeSetsFlag})
625
- Register(regexpSourceRule{name: "regexp/sort-flags", check: regexpFlagsUnsorted})
958
+ Register(regexpSourceRule{
959
+ name: "regexp/no-useless-flag",
960
+ check: regexpHasUselessFlag,
961
+ repair: regexpUselessFlagRepair,
962
+ })
963
+ Register(regexpSourceRule{
964
+ name: "regexp/no-useless-quantifier",
965
+ check: regexpQuantifierCheck(regexpQuantifierIsUseless),
966
+ repair: regexpQuantifierRepair(regexpQuantifierIsUseless, regexpQuantifierDrop),
967
+ })
968
+ Register(regexpSourceRule{
969
+ name: "regexp/no-useless-two-nums-quantifier",
970
+ check: regexpQuantifierCheck(regexpQuantifierIsTwoNums),
971
+ repair: regexpQuantifierRepair(regexpQuantifierIsTwoNums, regexpQuantifierExactCount),
972
+ })
973
+ // `regexp/no-zero-quantifier` stays diagnostic-only: `{0}` says the atom
974
+ // never matches, so the correction is to delete the atom or repair the
975
+ // bound, and the rule computes neither.
976
+ Register(regexpSourceRule{
977
+ name: "regexp/no-zero-quantifier",
978
+ check: regexpQuantifierCheck(regexpQuantifierIsZero),
979
+ })
980
+ Register(regexpSourceRule{
981
+ name: "regexp/prefer-d",
982
+ check: regexpHasPreferD,
983
+ repair: regexpClassShorthandRepair("\\d", "0-9"),
984
+ })
985
+ Register(regexpSourceRule{
986
+ name: "regexp/prefer-plus-quantifier",
987
+ check: regexpQuantifierCheck(regexpQuantifierIsPlus),
988
+ repair: regexpQuantifierRepair(regexpQuantifierIsPlus, regexpQuantifierSymbol("+")),
989
+ })
990
+ Register(regexpSourceRule{
991
+ name: "regexp/prefer-question-quantifier",
992
+ check: regexpQuantifierCheck(regexpQuantifierIsQuestion),
993
+ repair: regexpQuantifierRepair(regexpQuantifierIsQuestion, regexpQuantifierSymbol("?")),
994
+ })
995
+ Register(regexpSourceRule{
996
+ name: "regexp/prefer-star-quantifier",
997
+ check: regexpQuantifierCheck(regexpQuantifierIsStar),
998
+ repair: regexpQuantifierRepair(regexpQuantifierIsStar, regexpQuantifierSymbol("*")),
999
+ })
1000
+ Register(regexpSourceRule{
1001
+ name: "regexp/prefer-w",
1002
+ check: regexpHasPreferW,
1003
+ repair: regexpClassShorthandRepair("\\w", "A-Za-z0-9_", "a-zA-Z0-9_"),
1004
+ })
1005
+ Register(regexpSourceRule{
1006
+ name: "regexp/require-unicode-regexp",
1007
+ check: regexpNeedsUnicodeFlag,
1008
+ repair: regexpUnicodeFlagRepair,
1009
+ })
1010
+ Register(regexpSourceRule{
1011
+ name: "regexp/require-unicode-sets-regexp",
1012
+ check: regexpNeedsUnicodeSetsFlag,
1013
+ repair: regexpUnicodeSetsFlagRepair,
1014
+ })
1015
+ Register(regexpSourceRule{
1016
+ name: "regexp/sort-flags",
1017
+ check: regexpFlagsUnsorted,
1018
+ repair: regexpSortFlagsRepair,
1019
+ })
626
1020
  }