@ttsc/lint 0.18.4 → 0.19.1

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 (188) hide show
  1. package/README.md +125 -43
  2. package/go.mod +5 -4
  3. package/internal/rulecode/rulecode.go +88 -0
  4. package/lib/index.d.ts +10 -1
  5. package/lib/index.js +45 -17
  6. package/lib/index.js.map +1 -1
  7. package/lib/structures/ITtscLintConfig.d.ts +3 -0
  8. package/lib/structures/TtscLintRuleSetting.d.ts +3 -2
  9. package/lib/structures/format/ITtscLintFormat.d.ts +3 -2
  10. package/lib/structures/format/ITtscLintFormatSortImports.d.ts +20 -4
  11. package/lib/structures/rules/ITtscLintBoundariesRuleOptions.d.ts +80 -8
  12. package/lib/structures/rules/ITtscLintBoundariesRules.d.ts +6 -8
  13. package/lib/structures/rules/ITtscLintContributorRules.d.ts +18 -2
  14. package/lib/structures/rules/ITtscLintCoreRuleOptions.d.ts +347 -0
  15. package/lib/structures/rules/ITtscLintCoreRuleOptions.js +3 -0
  16. package/lib/structures/rules/ITtscLintCoreRuleOptions.js.map +1 -0
  17. package/lib/structures/rules/ITtscLintCoreRules.d.ts +101 -67
  18. package/lib/structures/rules/ITtscLintRegexpRules.d.ts +7 -2
  19. package/lib/structures/rules/ITtscLintRuleOptionsMap.d.ts +53 -7
  20. package/lib/structures/rules/ITtscLintRules.d.ts +6 -2
  21. package/lib/structures/rules/ITtscLintSolidRules.d.ts +0 -8
  22. package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.d.ts +206 -0
  23. package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.js +8 -0
  24. package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.js.map +1 -0
  25. package/lib/structures/rules/ITtscLintTypeScriptRules.d.ts +55 -42
  26. package/lib/structures/rules/ITtscLintUnicornRuleOptions.d.ts +276 -0
  27. package/lib/structures/rules/ITtscLintUnicornRuleOptions.js +3 -0
  28. package/lib/structures/rules/ITtscLintUnicornRuleOptions.js.map +1 -0
  29. package/lib/structures/rules/ITtscLintUnicornRules.d.ts +44 -36
  30. package/lib/structures/rules/index.d.ts +3 -0
  31. package/lib/structures/rules/index.js +3 -0
  32. package/lib/structures/rules/index.js.map +1 -1
  33. package/linthost/ast_helpers.go +521 -23
  34. package/linthost/ast_selector_matcher.go +1628 -0
  35. package/linthost/ast_selector_parser.go +628 -0
  36. package/linthost/comment_scan.go +154 -0
  37. package/linthost/compile.go +129 -79
  38. package/linthost/config.go +352 -88
  39. package/linthost/config_format.go +15 -1
  40. package/linthost/contrib_adapter.go +47 -8
  41. package/linthost/declaration_rules.go +12 -11
  42. package/linthost/directives.go +9 -67
  43. package/linthost/dispatch.go +37 -3
  44. package/linthost/engine.go +368 -53
  45. package/linthost/escape_string.go +77 -0
  46. package/linthost/fix.go +133 -23
  47. package/linthost/flags_gen.go +18 -17
  48. package/linthost/format.go +73 -167
  49. package/linthost/format_editor_settings.go +134 -26
  50. package/linthost/grapheme_tables_gen.go +1920 -0
  51. package/linthost/host.go +159 -61
  52. package/linthost/literal_escape_scan.go +117 -0
  53. package/linthost/lsp.go +545 -98
  54. package/linthost/numeric_literal_precision.go +228 -0
  55. package/linthost/polyfill_browserslist.go +1439 -0
  56. package/linthost/polyfill_browserslist_config.go +603 -0
  57. package/linthost/polyfill_corejs.go +750 -0
  58. package/linthost/polyfill_data.go +113 -0
  59. package/linthost/polyfill_data_gen.json +39868 -0
  60. package/linthost/print_nodes_array.go +7 -1
  61. package/linthost/print_nodes_object.go +7 -1
  62. package/linthost/project_engine.go +231 -0
  63. package/linthost/project_rules.go +78 -0
  64. package/linthost/regex_clean.go +191 -0
  65. package/linthost/regex_tree.go +1544 -0
  66. package/linthost/regex_tree_optimizer.go +1685 -0
  67. package/linthost/regexp_cache.go +44 -0
  68. package/linthost/rule_codes.go +105 -0
  69. package/linthost/rule_codes.json +745 -0
  70. package/linthost/rules_ban_ts_comment.go +312 -0
  71. package/linthost/rules_boundaries.go +128 -43
  72. package/linthost/rules_boundaries_dependencies.go +1013 -0
  73. package/linthost/rules_core_extra.go +62 -92
  74. package/linthost/rules_cypress.go +1 -1
  75. package/linthost/rules_default_case.go +87 -11
  76. package/linthost/rules_dupes.go +38 -13
  77. package/linthost/rules_empty.go +188 -20
  78. package/linthost/rules_escape.go +62 -48
  79. package/linthost/rules_format_arrow_parens.go +19 -5
  80. package/linthost/rules_format_bracket_spacing.go +1 -1
  81. package/linthost/rules_format_clause_join.go +1 -1
  82. package/linthost/rules_format_declaration_header.go +31 -19
  83. package/linthost/rules_format_indent.go +1 -1
  84. package/linthost/rules_format_jsdoc.go +1 -1
  85. package/linthost/rules_format_orphan_semi.go +1 -1
  86. package/linthost/rules_format_parameter_properties.go +17 -7
  87. package/linthost/rules_format_print_width.go +1 -1
  88. package/linthost/rules_format_quote_props.go +1 -1
  89. package/linthost/rules_format_quotes.go +1 -1
  90. package/linthost/rules_format_semi.go +106 -79
  91. package/linthost/rules_format_sort_imports.go +79 -45
  92. package/linthost/rules_format_statement_split.go +1 -1
  93. package/linthost/rules_format_ternary_nullish_parens.go +1 -1
  94. package/linthost/rules_format_trailing_comma.go +64 -1
  95. package/linthost/rules_format_whitespace.go +1 -1
  96. package/linthost/rules_functional.go +21 -22
  97. package/linthost/rules_gap.go +80 -3
  98. package/linthost/rules_grouped_accessor_pairs.go +84 -25
  99. package/linthost/rules_guard_for_in.go +64 -147
  100. package/linthost/rules_jsx_a11y.go +232 -22
  101. package/linthost/rules_logic.go +84 -38
  102. package/linthost/rules_nextjs.go +105 -16
  103. package/linthost/rules_no_class_assign.go +70 -0
  104. package/linthost/rules_no_duplicate_imports.go +266 -0
  105. package/linthost/rules_no_else_return.go +156 -38
  106. package/linthost/rules_no_extend_native.go +113 -17
  107. package/linthost/rules_no_fallthrough.go +1013 -0
  108. package/linthost/rules_no_func_assign.go +70 -0
  109. package/linthost/rules_no_import_assign.go +278 -86
  110. package/linthost/rules_no_inner_declarations.go +186 -0
  111. package/linthost/rules_no_loop_func.go +438 -53
  112. package/linthost/rules_no_mixed_operators.go +208 -52
  113. package/linthost/rules_no_param_reassign.go +376 -61
  114. package/linthost/rules_no_promise_executor_return.go +156 -0
  115. package/linthost/rules_no_restricted_imports.go +1013 -57
  116. package/linthost/rules_no_restricted_syntax.go +190 -26
  117. package/linthost/rules_no_useless_constructor.go +6 -36
  118. package/linthost/rules_problems.go +154 -311
  119. package/linthost/rules_promise.go +2578 -109
  120. package/linthost/rules_react_extras.go +71 -45
  121. package/linthost/rules_react_hooks.go +18 -2
  122. package/linthost/rules_react_perf.go +1 -0
  123. package/linthost/rules_react_refresh.go +1 -1
  124. package/linthost/rules_regexp.go +230 -14
  125. package/linthost/rules_security.go +45 -19
  126. package/linthost/rules_self.go +21 -1
  127. package/linthost/rules_solid.go +13 -7
  128. package/linthost/rules_storybook.go +1 -1
  129. package/linthost/rules_suggestions.go +490 -84
  130. package/linthost/rules_testing_library.go +12 -1
  131. package/linthost/rules_ts.go +227 -54
  132. package/linthost/rules_ts_async.go +0 -131
  133. package/linthost/rules_ts_no_base_to_string.go +3 -4
  134. package/linthost/rules_ts_no_misused_promises.go +817 -0
  135. package/linthost/rules_ts_no_restricted_types.go +320 -34
  136. package/linthost/rules_ts_no_unnecessary_qualifier.go +132 -55
  137. package/linthost/rules_ts_no_unsafe_assignment.go +608 -60
  138. package/linthost/rules_ts_require_array_sort_compare.go +5 -5
  139. package/linthost/rules_ts_switch_exhaustiveness_check.go +556 -86
  140. package/linthost/rules_unicorn_better_regex.go +224 -16
  141. package/linthost/rules_unicorn_consistent_destructuring.go +1002 -7
  142. package/linthost/rules_unicorn_consistent_existence_index_check.go +208 -29
  143. package/linthost/rules_unicorn_consistent_function_scoping.go +1041 -8
  144. package/linthost/rules_unicorn_consistent_template_literal_escape.go +178 -7
  145. package/linthost/rules_unicorn_escape_case.go +30 -19
  146. package/linthost/rules_unicorn_filename_case.go +763 -16
  147. package/linthost/rules_unicorn_import_style.go +783 -17
  148. package/linthost/rules_unicorn_isolated_functions.go +934 -14
  149. package/linthost/rules_unicorn_no_hex_escape.go +26 -11
  150. package/linthost/rules_unicorn_no_typeof_undefined.go +234 -25
  151. package/linthost/rules_unicorn_no_unnecessary_polyfills.go +376 -21
  152. package/linthost/rules_unicorn_no_unused_properties.go +756 -19
  153. package/linthost/rules_unicorn_number_literal_case.go +58 -39
  154. package/linthost/rules_unicorn_prefer_at.go +7 -3
  155. package/linthost/rules_unicorn_prefer_number_properties.go +381 -48
  156. package/linthost/rules_unicorn_prefer_optional_catch_binding.go +24 -24
  157. package/linthost/rules_unicorn_prefer_simple_condition_first.go +597 -19
  158. package/linthost/rules_unicorn_prefer_string_raw.go +147 -16
  159. package/linthost/rules_unicorn_prevent_abbreviations.go +1963 -95
  160. package/linthost/rules_unicorn_string_content.go +611 -15
  161. package/linthost/rules_unicorn_switch_case_break_position.go +172 -15
  162. package/linthost/rules_unicorn_template_indent.go +923 -18
  163. package/linthost/rules_unicorn_text_encoding_identifier_case.go +202 -42
  164. package/linthost/rules_unicorn_throw_new_error.go +218 -26
  165. package/linthost/rules_var.go +829 -142
  166. package/linthost/string_length.go +222 -0
  167. package/package.json +4 -3
  168. package/rule/project.go +206 -0
  169. package/rule/rule.go +58 -1
  170. package/src/index.ts +58 -17
  171. package/src/structures/ITtscLintConfig.ts +3 -0
  172. package/src/structures/TtscLintRuleSetting.ts +3 -2
  173. package/src/structures/format/ITtscLintFormat.ts +3 -2
  174. package/src/structures/format/ITtscLintFormatSortImports.ts +21 -4
  175. package/src/structures/rules/ITtscLintBoundariesRuleOptions.ts +122 -8
  176. package/src/structures/rules/ITtscLintBoundariesRules.ts +6 -8
  177. package/src/structures/rules/ITtscLintContributorRules.ts +18 -2
  178. package/src/structures/rules/ITtscLintCoreRuleOptions.ts +465 -0
  179. package/src/structures/rules/ITtscLintCoreRules.ts +122 -67
  180. package/src/structures/rules/ITtscLintRegexpRules.ts +7 -2
  181. package/src/structures/rules/ITtscLintRuleOptionsMap.ts +88 -7
  182. package/src/structures/rules/ITtscLintRules.ts +7 -2
  183. package/src/structures/rules/ITtscLintSolidRules.ts +0 -9
  184. package/src/structures/rules/ITtscLintTypeScriptRuleOptions.ts +248 -0
  185. package/src/structures/rules/ITtscLintTypeScriptRules.ts +64 -42
  186. package/src/structures/rules/ITtscLintUnicornRuleOptions.ts +350 -0
  187. package/src/structures/rules/ITtscLintUnicornRules.ts +60 -36
  188. package/src/structures/rules/index.ts +3 -0
@@ -1,25 +1,621 @@
1
- // unicorn/string-content: rewrites configured substrings inside string
2
- // literals (e.g. straight quotes -> curly quotes, `...` -> `…`). The
3
- // rule has NO defaults; behavior depends entirely on the user-supplied
4
- // `patterns: { match: replacement }` configuration. Without that
5
- // per-pattern configuration plumbing in the host's rule-options
6
- // pipeline, the rule has nothing to enforce.
1
+ // unicorn/string-content rewrites configured substrings inside string
2
+ // literals and template quasis (e.g. straight quotes -> curly quotes,
3
+ // `...` -> `…`). The rule has NO default patterns: behavior depends
4
+ // entirely on the user-supplied `patterns: { match: replacement }`
5
+ // configuration, so a bare severity is intentionally silent.
7
6
  //
8
- // Registered as a no-op stub to keep
9
- // `TestTypedKeysMatchRegisteredRules` aligned with the typed
10
- // `ITtscLintUnicornRules` surface. The matching fixture under
11
- // `tests/test-lint/src/cases/unicorn-string-content.ts` keeps its
12
- // `@ttsc-corpus-skip` directive.
13
- // https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/string-content.md
7
+ // Faithful to the current upstream semantics: the first configured
8
+ // pattern whose regular expression matches a node's text wins and every
9
+ // occurrence is replaced; string literals match on their cooked value
10
+ // and are re-quoted with escapes, template quasis match on their raw
11
+ // text and re-escape backticks and `${`; gql/html/sql/svg tags and
12
+ // `styled.*` member tags exempt their template quasis; JSX attribute
13
+ // strings encode the delimiter quote as an HTML entity; `fix: false`
14
+ // downgrades the rewrite to an editor suggestion; `selectors` restrict
15
+ // the checked nodes through AST selectors.
16
+ // https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/rules/string-content.js
14
17
  package linthost
15
18
 
16
- import shimast "github.com/microsoft/typescript-go/shim/ast"
19
+ import (
20
+ "bytes"
21
+ "encoding/json"
22
+ "fmt"
23
+ "regexp"
24
+ "sort"
25
+ "strconv"
26
+ "strings"
17
27
 
18
- type unicornStringContent struct{}
28
+ shimast "github.com/microsoft/typescript-go/shim/ast"
29
+ shimscanner "github.com/microsoft/typescript-go/shim/scanner"
30
+ )
31
+
32
+ const (
33
+ unicornStringContentDefaultMessage = "Prefer `{{suggest}}` over `{{match}}`."
34
+ unicornStringContentSuggestionMessage = "Replace `{{match}}` with `{{suggest}}`."
35
+ )
36
+
37
+ // unicornStringContentIgnoredIdentifierTags lists template tags whose quasis
38
+ // embed foreign languages, mirroring upstream's `ignoredIdentifier` set.
39
+ var unicornStringContentIgnoredIdentifierTags = map[string]struct{}{
40
+ "gql": {},
41
+ "html": {},
42
+ "sql": {},
43
+ "svg": {},
44
+ }
45
+
46
+ // unicornStringContentIgnoredMemberObjects lists member-expression tag
47
+ // objects (`styled.div`), mirroring upstream's `ignoredMemberExpressionObject`.
48
+ var unicornStringContentIgnoredMemberObjects = map[string]struct{}{
49
+ "styled": {},
50
+ }
51
+
52
+ type unicornStringContent struct{ optionsRule }
53
+
54
+ type unicornStringContentPattern struct {
55
+ match string
56
+ suggest string
57
+ fix bool
58
+ caseSensitive bool
59
+ message string
60
+ messageSet bool
61
+ regex *regexp.Regexp
62
+ }
63
+
64
+ type unicornStringContentOptions struct {
65
+ patterns []unicornStringContentPattern
66
+ selectors []*astSelector
67
+ }
19
68
 
20
69
  func (unicornStringContent) Name() string { return "unicorn/string-content" }
21
70
  func (unicornStringContent) Visits() []shimast.Kind { return []shimast.Kind{shimast.KindSourceFile} }
22
- func (unicornStringContent) Check(_ *Context, _ *shimast.Node) {
71
+
72
+ // ValidateOptions is consumed by the engine's optional rule-options
73
+ // validation interface so malformed patterns and selectors become a project
74
+ // configuration error before any file is linted.
75
+ func (unicornStringContent) ValidateOptions(raw json.RawMessage) error {
76
+ _, err := parseUnicornStringContentOptions(raw)
77
+ return err
78
+ }
79
+
80
+ func (unicornStringContent) Check(ctx *Context, root *shimast.Node) {
81
+ if ctx == nil || ctx.File == nil || root == nil {
82
+ return
83
+ }
84
+ options, err := parseUnicornStringContentOptions(ctx.Options)
85
+ if err != nil || len(options.patterns) == 0 {
86
+ // Engine construction already records malformed options as a
87
+ // ConfigError; Check stays side-effect-free for direct calls.
88
+ return
89
+ }
90
+ source := ctx.File.Text()
91
+ for _, target := range collectUnicornStringContentTargets(root, options.selectors) {
92
+ checkUnicornStringContentNode(ctx, source, target, options.patterns)
93
+ }
94
+ }
95
+
96
+ // parseUnicornStringContentOptions decodes the single `{patterns, selectors}`
97
+ // option slot. The patterns object is decoded with a token stream so the
98
+ // configured replacement order is preserved: upstream applies the FIRST
99
+ // matching pattern per node, so ordering is behavior, not presentation.
100
+ func parseUnicornStringContentOptions(raw json.RawMessage) (unicornStringContentOptions, error) {
101
+ var options unicornStringContentOptions
102
+ raw = bytes.TrimSpace(raw)
103
+ if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
104
+ return options, nil
105
+ }
106
+ if raw[0] != '{' {
107
+ return options, fmt.Errorf("unicorn/string-content options must be a single object with %q and/or %q", "patterns", "selectors")
108
+ }
109
+
110
+ decoder := json.NewDecoder(bytes.NewReader(raw))
111
+ if _, err := decoder.Token(); err != nil {
112
+ return options, fmt.Errorf("unicorn/string-content options must be valid JSON: %w", err)
113
+ }
114
+ for decoder.More() {
115
+ keyToken, err := decoder.Token()
116
+ if err != nil {
117
+ return options, fmt.Errorf("unicorn/string-content options must be valid JSON: %w", err)
118
+ }
119
+ key, ok := keyToken.(string)
120
+ if !ok {
121
+ return options, fmt.Errorf("unicorn/string-content options must be valid JSON")
122
+ }
123
+ var value json.RawMessage
124
+ if err := decoder.Decode(&value); err != nil {
125
+ return options, fmt.Errorf("unicorn/string-content option %q must be valid JSON: %w", key, err)
126
+ }
127
+ switch key {
128
+ case "patterns":
129
+ patterns, err := parseUnicornStringContentPatterns(value)
130
+ if err != nil {
131
+ return options, err
132
+ }
133
+ options.patterns = patterns
134
+ case "selectors":
135
+ selectors, err := parseUnicornStringContentSelectors(value)
136
+ if err != nil {
137
+ return options, err
138
+ }
139
+ options.selectors = selectors
140
+ default:
141
+ return options, fmt.Errorf("unicorn/string-content options contain unknown key %q (want patterns, selectors)", key)
142
+ }
143
+ }
144
+ if _, err := decoder.Token(); err != nil {
145
+ return options, fmt.Errorf("unicorn/string-content options must be valid JSON: %w", err)
146
+ }
147
+ return options, nil
148
+ }
149
+
150
+ // parseUnicornStringContentPatterns decodes the `patterns` object in JS
151
+ // property-enumeration order: canonical array-index keys ascending first,
152
+ // then string keys in insertion order (`Object.entries` semantics). A
153
+ // duplicate JSON key keeps its first position with its last value, matching
154
+ // `JSON.parse`.
155
+ func parseUnicornStringContentPatterns(raw json.RawMessage) ([]unicornStringContentPattern, error) {
156
+ raw = bytes.TrimSpace(raw)
157
+ if len(raw) == 0 || raw[0] != '{' {
158
+ return nil, fmt.Errorf("unicorn/string-content option %q must be an object mapping regex patterns to replacements", "patterns")
159
+ }
160
+ decoder := json.NewDecoder(bytes.NewReader(raw))
161
+ if _, err := decoder.Token(); err != nil {
162
+ return nil, fmt.Errorf("unicorn/string-content option %q must be valid JSON: %w", "patterns", err)
163
+ }
164
+ patterns := make([]unicornStringContentPattern, 0)
165
+ indexes := make(map[string]int)
166
+ for decoder.More() {
167
+ keyToken, err := decoder.Token()
168
+ if err != nil {
169
+ return nil, fmt.Errorf("unicorn/string-content option %q must be valid JSON: %w", "patterns", err)
170
+ }
171
+ match, ok := keyToken.(string)
172
+ if !ok {
173
+ return nil, fmt.Errorf("unicorn/string-content option %q must be valid JSON", "patterns")
174
+ }
175
+ var value json.RawMessage
176
+ if err := decoder.Decode(&value); err != nil {
177
+ return nil, fmt.Errorf("unicorn/string-content pattern %q must be valid JSON: %w", match, err)
178
+ }
179
+ pattern, err := parseUnicornStringContentPattern(match, value)
180
+ if err != nil {
181
+ return nil, err
182
+ }
183
+ if index, duplicate := indexes[match]; duplicate {
184
+ patterns[index] = pattern
185
+ continue
186
+ }
187
+ indexes[match] = len(patterns)
188
+ patterns = append(patterns, pattern)
189
+ }
190
+ if _, err := decoder.Token(); err != nil {
191
+ return nil, fmt.Errorf("unicorn/string-content option %q must be valid JSON: %w", "patterns", err)
192
+ }
193
+ return sortUnicornStringContentPatterns(patterns), nil
194
+ }
195
+
196
+ // parseUnicornStringContentPattern decodes one replacement entry: a bare
197
+ // string is shorthand for `{suggest}`; the object form carries the optional
198
+ // `fix`, `caseSensitive`, and `message` switches.
199
+ func parseUnicornStringContentPattern(match string, raw json.RawMessage) (unicornStringContentPattern, error) {
200
+ pattern := unicornStringContentPattern{match: match, fix: true, caseSensitive: true}
201
+ raw = bytes.TrimSpace(raw)
202
+ if len(raw) == 0 {
203
+ return pattern, fmt.Errorf("unicorn/string-content pattern %q must be a replacement string or an object with %q", match, "suggest")
204
+ }
205
+ switch raw[0] {
206
+ case '"':
207
+ if err := decodeStrictJSON(raw, &pattern.suggest); err != nil {
208
+ return pattern, fmt.Errorf("unicorn/string-content pattern %q must be a replacement string: %w", match, err)
209
+ }
210
+ case '{':
211
+ object := struct {
212
+ Suggest json.RawMessage `json:"suggest"`
213
+ Fix json.RawMessage `json:"fix"`
214
+ CaseSensitive json.RawMessage `json:"caseSensitive"`
215
+ Message json.RawMessage `json:"message"`
216
+ }{}
217
+ if err := decodeStrictJSON(raw, &object); err != nil {
218
+ return pattern, fmt.Errorf("unicorn/string-content pattern %q must contain only suggest, fix, caseSensitive, and message: %w", match, err)
219
+ }
220
+ if len(object.Suggest) == 0 {
221
+ return pattern, fmt.Errorf("unicorn/string-content pattern %q requires %q", match, "suggest")
222
+ }
223
+ if err := decodeStrictJSON(object.Suggest, &pattern.suggest); err != nil {
224
+ return pattern, fmt.Errorf("unicorn/string-content pattern %q option %q must be a string: %w", match, "suggest", err)
225
+ }
226
+ if len(object.Fix) > 0 {
227
+ if err := decodeStrictJSON(object.Fix, &pattern.fix); err != nil {
228
+ return pattern, fmt.Errorf("unicorn/string-content pattern %q option %q must be a boolean: %w", match, "fix", err)
229
+ }
230
+ }
231
+ if len(object.CaseSensitive) > 0 {
232
+ if err := decodeStrictJSON(object.CaseSensitive, &pattern.caseSensitive); err != nil {
233
+ return pattern, fmt.Errorf("unicorn/string-content pattern %q option %q must be a boolean: %w", match, "caseSensitive", err)
234
+ }
235
+ }
236
+ if len(object.Message) > 0 {
237
+ if err := decodeStrictJSON(object.Message, &pattern.message); err != nil {
238
+ return pattern, fmt.Errorf("unicorn/string-content pattern %q option %q must be a string: %w", match, "message", err)
239
+ }
240
+ pattern.messageSet = true
241
+ }
242
+ default:
243
+ return pattern, fmt.Errorf("unicorn/string-content pattern %q must be a replacement string or an object with %q", match, "suggest")
244
+ }
245
+
246
+ expression := match
247
+ if !pattern.caseSensitive {
248
+ expression = "(?i:" + expression + ")"
249
+ }
250
+ regex, err := regexp.Compile(expression)
251
+ if err != nil {
252
+ return pattern, fmt.Errorf("unicorn/string-content pattern %q must be a valid regular expression: %w", match, err)
253
+ }
254
+ pattern.regex = regex
255
+ return pattern, nil
256
+ }
257
+
258
+ // sortUnicornStringContentPatterns applies JS property-enumeration order:
259
+ // canonical array-index keys ("0", "7", "42") are enumerated first in
260
+ // ascending numeric order, everything else keeps insertion order.
261
+ func sortUnicornStringContentPatterns(patterns []unicornStringContentPattern) []unicornStringContentPattern {
262
+ integers := make([]unicornStringContentPattern, 0)
263
+ others := make([]unicornStringContentPattern, 0, len(patterns))
264
+ for _, pattern := range patterns {
265
+ if _, ok := unicornStringContentArrayIndexKey(pattern.match); ok {
266
+ integers = append(integers, pattern)
267
+ } else {
268
+ others = append(others, pattern)
269
+ }
270
+ }
271
+ if len(integers) == 0 {
272
+ return others
273
+ }
274
+ sort.SliceStable(integers, func(left, right int) bool {
275
+ leftKey, _ := unicornStringContentArrayIndexKey(integers[left].match)
276
+ rightKey, _ := unicornStringContentArrayIndexKey(integers[right].match)
277
+ return leftKey < rightKey
278
+ })
279
+ return append(integers, others...)
280
+ }
281
+
282
+ // unicornStringContentArrayIndexKey reports whether `key` is a canonical
283
+ // ECMAScript array index (an unsigned decimal integer below 2^32-1 with no
284
+ // leading zeros), the class of property keys JS enumerates first.
285
+ func unicornStringContentArrayIndexKey(key string) (uint64, bool) {
286
+ if key == "" || len(key) > 10 {
287
+ return 0, false
288
+ }
289
+ if key[0] == '0' {
290
+ if key == "0" {
291
+ return 0, true
292
+ }
293
+ return 0, false
294
+ }
295
+ for index := 0; index < len(key); index++ {
296
+ if key[index] < '0' || key[index] > '9' {
297
+ return 0, false
298
+ }
299
+ }
300
+ value, err := strconv.ParseUint(key, 10, 64)
301
+ if err != nil || value > 4294967294 {
302
+ return 0, false
303
+ }
304
+ return value, true
305
+ }
306
+
307
+ // parseUnicornStringContentSelectors decodes and compiles the `selectors`
308
+ // list. The upstream schema requires unique string items; each entry is
309
+ // compiled through the shared AST-selector grammar.
310
+ func parseUnicornStringContentSelectors(raw json.RawMessage) ([]*astSelector, error) {
311
+ raw = bytes.TrimSpace(raw)
312
+ if len(raw) == 0 || raw[0] != '[' {
313
+ return nil, fmt.Errorf("unicorn/string-content option %q must be an array of unique strings", "selectors")
314
+ }
315
+ var sources []string
316
+ if err := decodeStrictJSON(raw, &sources); err != nil {
317
+ return nil, fmt.Errorf("unicorn/string-content option %q must be an array of unique strings: %w", "selectors", err)
318
+ }
319
+ seen := make(map[string]struct{}, len(sources))
320
+ selectors := make([]*astSelector, 0, len(sources))
321
+ for index, source := range sources {
322
+ if _, duplicate := seen[source]; duplicate {
323
+ return nil, fmt.Errorf("unicorn/string-content option %q must not contain duplicate %q", "selectors", source)
324
+ }
325
+ seen[source] = struct{}{}
326
+ selector, err := parseASTSelector(source)
327
+ if err != nil {
328
+ return nil, fmt.Errorf("unicorn/string-content selector %d %q is invalid: %w", index+1, source, err)
329
+ }
330
+ selectors = append(selectors, selector)
331
+ }
332
+ return selectors, nil
333
+ }
334
+
335
+ // collectUnicornStringContentTargets gathers the string-shaped nodes to
336
+ // check. Without selectors every string literal and template quasi is a
337
+ // target (upstream's `Literal` + `TemplateElement` listener); with selectors
338
+ // only selected nodes of those shapes are, each checked once even when
339
+ // multiple selectors reach it.
340
+ func collectUnicornStringContentTargets(root *shimast.Node, selectors []*astSelector) []*shimast.Node {
341
+ targets := make([]*shimast.Node, 0)
342
+ if len(selectors) == 0 {
343
+ walkDescendants(root, func(node *shimast.Node) {
344
+ if isUnicornStringContentTarget(node) {
345
+ targets = append(targets, node)
346
+ }
347
+ })
348
+ return targets
349
+ }
350
+ seen := make(map[*shimast.Node]struct{})
351
+ for _, selector := range selectors {
352
+ for _, match := range matchASTSelector(root, selector) {
353
+ if !isUnicornStringContentTarget(match) {
354
+ continue
355
+ }
356
+ if _, duplicate := seen[match]; duplicate {
357
+ continue
358
+ }
359
+ seen[match] = struct{}{}
360
+ targets = append(targets, match)
361
+ }
362
+ }
363
+ sort.SliceStable(targets, func(left, right int) bool {
364
+ return targets[left].Pos() < targets[right].Pos()
365
+ })
366
+ return targets
367
+ }
368
+
369
+ // isUnicornStringContentTarget mirrors upstream's target node types: ESTree
370
+ // `Literal` (only string literals can carry a string value) and
371
+ // `TemplateElement` (TemplateHead/Middle/Tail plus the quasi of a
372
+ // no-substitution template).
373
+ func isUnicornStringContentTarget(node *shimast.Node) bool {
374
+ if node == nil {
375
+ return false
376
+ }
377
+ switch node.Kind {
378
+ case shimast.KindStringLiteral,
379
+ shimast.KindNoSubstitutionTemplateLiteral,
380
+ shimast.KindTemplateHead,
381
+ shimast.KindTemplateMiddle,
382
+ shimast.KindTemplateTail:
383
+ return true
384
+ }
385
+ return false
386
+ }
387
+
388
+ func checkUnicornStringContentNode(
389
+ ctx *Context,
390
+ source string,
391
+ node *shimast.Node,
392
+ patterns []unicornStringContentPattern,
393
+ ) {
394
+ if node.Kind == shimast.KindStringLiteral {
395
+ // Upstream matches string literals on the COOKED value and skips
396
+ // empty strings (`if (!string) return`).
397
+ value := stringLiteralText(node)
398
+ if value == "" {
399
+ return
400
+ }
401
+ replacement, ok := findUnicornStringContentReplacement(patterns, value)
402
+ if !ok {
403
+ return
404
+ }
405
+ fixed := replacement.regex.ReplaceAllLiteralString(value, replacement.suggest)
406
+ reportUnicornStringContent(ctx, node, replacement, unicornStringContentLiteralEdits(source, node, fixed))
407
+ return
408
+ }
409
+
410
+ // Template quasis match on their RAW text so escape spelling and
411
+ // substitution boundaries stay untouched. Foreign-language tags are
412
+ // exempt even when the quasi was reached through a configured selector.
413
+ if unicornStringContentHasIgnoredTag(node) {
414
+ return
415
+ }
416
+ innerPos, innerEnd, ok := unicornStringContentTemplateInnerRange(source, node)
417
+ if !ok {
418
+ return
419
+ }
420
+ // Matching and rewriting both run on the acorn-normalized raw text, so a
421
+ // reported quasi that contained CRLF comes back LF-only after the fix,
422
+ // exactly like upstream.
423
+ raw := normalizeTemplateRaw(source[innerPos:innerEnd])
424
+ if raw == "" {
425
+ return
426
+ }
427
+ replacement, ok := findUnicornStringContentReplacement(patterns, raw)
428
+ if !ok {
429
+ return
430
+ }
431
+ fixed := replacement.regex.ReplaceAllLiteralString(raw, replacement.suggest)
432
+ edits := []TextEdit{{Pos: innerPos, End: innerEnd, Text: unicornStringContentEscapeTemplateRaw(fixed)}}
433
+ reportUnicornStringContent(ctx, node, replacement, edits)
434
+ }
435
+
436
+ // findUnicornStringContentReplacement returns the FIRST configured pattern
437
+ // whose regular expression matches, mirroring upstream's
438
+ // `replacements.find(({regex}) => regex.test(string))`.
439
+ func findUnicornStringContentReplacement(
440
+ patterns []unicornStringContentPattern,
441
+ subject string,
442
+ ) (unicornStringContentPattern, bool) {
443
+ for _, pattern := range patterns {
444
+ if pattern.regex.MatchString(subject) {
445
+ return pattern, true
446
+ }
447
+ }
448
+ return unicornStringContentPattern{}, false
449
+ }
450
+
451
+ func reportUnicornStringContent(
452
+ ctx *Context,
453
+ node *shimast.Node,
454
+ replacement unicornStringContentPattern,
455
+ edits []TextEdit,
456
+ ) {
457
+ data := map[string]string{
458
+ "match": replacement.match,
459
+ "suggest": replacement.suggest,
460
+ }
461
+ template := unicornStringContentDefaultMessage
462
+ if replacement.messageSet {
463
+ template = replacement.message
464
+ }
465
+ message := unicornStringContentInterpolate(template, data)
466
+ if replacement.fix {
467
+ ctx.ReportFix(node, message, edits...)
468
+ return
469
+ }
470
+ title := unicornStringContentInterpolate(unicornStringContentSuggestionMessage, data)
471
+ ctx.ReportSuggestion(node, message, title, edits...)
472
+ }
473
+
474
+ // unicornStringContentPlaceholderPattern mirrors ESLint's message-data
475
+ // interpolation syntax (`{{ term }}`).
476
+ var unicornStringContentPlaceholderPattern = regexp.MustCompile(`\{\{([^{}]+?)\}\}`)
477
+
478
+ func unicornStringContentInterpolate(template string, data map[string]string) string {
479
+ return unicornStringContentPlaceholderPattern.ReplaceAllStringFunc(template, func(placeholder string) string {
480
+ term := strings.TrimSpace(placeholder[2 : len(placeholder)-2])
481
+ if value, ok := data[term]; ok {
482
+ return value
483
+ }
484
+ return placeholder
485
+ })
486
+ }
487
+
488
+ // unicornStringContentHasIgnoredTag reports whether a template quasi belongs
489
+ // to a tagged template whose tag is one of the ignored identifiers
490
+ // (gql/html/sql/svg) or a member expression on `styled`.
491
+ func unicornStringContentHasIgnoredTag(element *shimast.Node) bool {
492
+ template := element
493
+ switch element.Kind {
494
+ case shimast.KindTemplateHead:
495
+ template = element.Parent
496
+ case shimast.KindTemplateMiddle, shimast.KindTemplateTail:
497
+ if element.Parent == nil {
498
+ return false
499
+ }
500
+ template = element.Parent.Parent
501
+ }
502
+ if template == nil || template.Parent == nil || template.Parent.Kind != shimast.KindTaggedTemplateExpression {
503
+ return false
504
+ }
505
+ tagged := template.Parent.AsTaggedTemplateExpression()
506
+ if tagged == nil || tagged.Template != template || tagged.Tag == nil {
507
+ return false
508
+ }
509
+ tag := stripParens(tagged.Tag)
510
+ if tag == nil {
511
+ return false
512
+ }
513
+ if name := identifierText(tag); name != "" {
514
+ _, ignored := unicornStringContentIgnoredIdentifierTags[name]
515
+ return ignored
516
+ }
517
+ var object *shimast.Node
518
+ switch tag.Kind {
519
+ case shimast.KindPropertyAccessExpression:
520
+ if access := tag.AsPropertyAccessExpression(); access != nil {
521
+ object = access.Expression
522
+ }
523
+ case shimast.KindElementAccessExpression:
524
+ if access := tag.AsElementAccessExpression(); access != nil {
525
+ object = access.Expression
526
+ }
527
+ default:
528
+ return false
529
+ }
530
+ if name := identifierText(stripParens(object)); name != "" {
531
+ _, ignored := unicornStringContentIgnoredMemberObjects[name]
532
+ return ignored
533
+ }
534
+ return false
535
+ }
536
+
537
+ // unicornStringContentTemplateInnerRange bounds the raw quasi payload of a
538
+ // template element: the text between the enclosing backtick / `${` / `}`
539
+ // delimiters. Mirrors upstream's `replaceTemplateElement` arithmetic
540
+ // (`[start + 1, end - (tail ? 1 : 2)]`).
541
+ func unicornStringContentTemplateInnerRange(source string, node *shimast.Node) (int, int, bool) {
542
+ start := shimscanner.SkipTrivia(source, node.Pos())
543
+ end := node.End()
544
+ if start < 0 || end > len(source) || start >= end {
545
+ return 0, 0, false
546
+ }
547
+ switch node.Kind {
548
+ case shimast.KindNoSubstitutionTemplateLiteral:
549
+ if start+2 > end || source[start] != '`' || source[end-1] != '`' {
550
+ return 0, 0, false
551
+ }
552
+ return start + 1, end - 1, true
553
+ case shimast.KindTemplateHead:
554
+ if start+3 > end || source[start] != '`' || source[end-2:end] != "${" {
555
+ return 0, 0, false
556
+ }
557
+ return start + 1, end - 2, true
558
+ case shimast.KindTemplateMiddle:
559
+ if start+3 > end || source[start] != '}' || source[end-2:end] != "${" {
560
+ return 0, 0, false
561
+ }
562
+ return start + 1, end - 2, true
563
+ case shimast.KindTemplateTail:
564
+ if start+2 > end || source[start] != '}' || source[end-1] != '`' {
565
+ return 0, 0, false
566
+ }
567
+ return start + 1, end - 1, true
568
+ }
569
+ return 0, 0, false
570
+ }
571
+
572
+ // unicornStringContentLiteralEdits rewrites a whole string literal with the
573
+ // replaced cooked value. JSX attribute strings cannot use backslash escapes,
574
+ // so the delimiter quote is encoded as an HTML entity (which JSX decodes)
575
+ // instead; everywhere else the value is re-escaped and re-quoted.
576
+ func unicornStringContentLiteralEdits(source string, node *shimast.Node, fixed string) []TextEdit {
577
+ start := shimscanner.SkipTrivia(source, node.Pos())
578
+ end := node.End()
579
+ if start < 0 || end > len(source) || start >= end {
580
+ return nil
581
+ }
582
+ quote := source[start]
583
+ if quote != '\'' && quote != '"' {
584
+ return nil
585
+ }
586
+ if node.Parent != nil && node.Parent.Kind == shimast.KindJsxAttribute {
587
+ entity := "&#39;"
588
+ if quote == '"' {
589
+ entity = "&quot;"
590
+ }
591
+ text := string(quote) + strings.ReplaceAll(fixed, string(quote), entity) + string(quote)
592
+ return []TextEdit{{Pos: start, End: end, Text: text}}
593
+ }
594
+ return []TextEdit{{Pos: start, End: end, Text: escapeString(fixed, quote)}}
595
+ }
596
+
597
+ // unicornStringContentEscapeTemplateRaw ports upstream's
598
+ // escapeTemplateElementRaw: a backtick or `${`-starting dollar preceded by
599
+ // an even number of backslashes (i.e. not already escaped) gains one.
600
+ func unicornStringContentEscapeTemplateRaw(text string) string {
601
+ var out strings.Builder
602
+ out.Grow(len(text))
603
+ backslashes := 0
604
+ for index := 0; index < len(text); index++ {
605
+ character := text[index]
606
+ if character == '\\' {
607
+ backslashes++
608
+ out.WriteByte(character)
609
+ continue
610
+ }
611
+ if backslashes%2 == 0 &&
612
+ (character == '`' || character == '$' && index+1 < len(text) && text[index+1] == '{') {
613
+ out.WriteByte('\\')
614
+ }
615
+ backslashes = 0
616
+ out.WriteByte(character)
617
+ }
618
+ return out.String()
23
619
  }
24
620
 
25
621
  func init() {