@ttsc/lint 0.18.3 → 0.19.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 (188) hide show
  1. package/README.md +95 -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 +129 -25
  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 +160 -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 +151 -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 +145 -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,29 +1,776 @@
1
- // unicorn/filename-case: enforces a single case convention (kebab,
2
- // camel, snake, or pascal) on every source file name. The MVP target
3
- // would be kebab-case, but the in-tree test fixture for this rule
4
- // lives at `tests/test-lint/src/cases/unicorn-filename-case.ts`
5
- // which is already kebab-case so the rule would never fire on its
6
- // own fixture and the corpus test would have no positive case to
7
- // assert on. A proper port also needs a way to influence the
8
- // fixture's virtual filename from the harness, which is not exposed
9
- // today.
1
+ // unicorn/filename-case: every path segment of a source file its
2
+ // directories and its filename must follow one of the configured case
3
+ // styles (kebab-case by default; camelCase, camelCase-with-acronyms,
4
+ // snake_case, and PascalCase selectable via `case` / `cases`). Mixed
5
+ // conventions in one tree make files hard to locate on case-sensitive
6
+ // filesystems and turn renames into cross-platform hazards, so the rule
7
+ // pins one spelling and proposes the exact renames.
10
8
  //
11
- // Registered as a no-op stub to keep
12
- // `TestTypedKeysMatchRegisteredRules` aligned with the typed
13
- // `ITtscLintUnicornRules` surface. The fixture keeps its
14
- // `@ttsc-corpus-skip` directive.
9
+ // Faithful port of the upstream rule and its helpers:
10
+ // - Path segments come from the file's project-relative path (the
11
+ // upstream `path.relative(cwd, …)` walk); files outside the current
12
+ // directory are judged by basename alone.
13
+ // - Word splitting mirrors `change-case@5` `split()` semantics
14
+ // (lower/digit→upper and upper→upper+lower boundaries, non-letter
15
+ // strips), and the case functions reproduce `camelCase`, `kebabCase`,
16
+ // `snakeCase`, and `pascalCase` plus upstream's acronym-aware
17
+ // wrappers byte-for-byte on the ASCII word runs the rule feeds them.
18
+ // - The first failing check wins: ignore patterns, then directories,
19
+ // then the default-ignored `index.*` basenames, then the filename,
20
+ // then a lowercase-extension check on otherwise valid names. At most
21
+ // one diagnostic is reported per file.
22
+ //
23
+ // The diagnostic anchors on the file's first statement (falling back to
24
+ // offset 0 in a statement-less file) following the `unicorn/no-empty-file`
25
+ // precedent, so `path:line:col` renderers point at real source instead of
26
+ // a synthetic 1:1 location. `ignore` patterns compile with Go's regexp
27
+ // package rather than ECMAScript's `u`-flag grammar.
15
28
  // https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/filename-case.md
16
29
  package linthost
17
30
 
18
- import shimast "github.com/microsoft/typescript-go/shim/ast"
31
+ import (
32
+ "bytes"
33
+ "encoding/json"
34
+ "errors"
35
+ "fmt"
36
+ "path/filepath"
37
+ "regexp"
38
+ "strings"
39
+ "unicode"
40
+ "unicode/utf8"
41
+
42
+ shimast "github.com/microsoft/typescript-go/shim/ast"
43
+ )
19
44
 
20
- type unicornFilenameCase struct{}
45
+ type unicornFilenameCase struct{ optionsRule }
21
46
 
22
47
  func (unicornFilenameCase) Name() string { return "unicorn/filename-case" }
23
48
  func (unicornFilenameCase) Visits() []shimast.Kind { return []shimast.Kind{shimast.KindSourceFile} }
24
- func (unicornFilenameCase) Check(_ *Context, _ *shimast.Node) {
49
+
50
+ // unicornFilenameCaseOrder fixes the canonical case-key order used by the
51
+ // `case` enum and the `cases` object; message wording depends on the
52
+ // configured order, so decoding preserves the user's key order instead.
53
+ var unicornFilenameCaseOrder = []string{
54
+ "camelCase",
55
+ "camelCaseWithAcronyms",
56
+ "kebabCase",
57
+ "snakeCase",
58
+ "pascalCase",
59
+ }
60
+
61
+ // unicornFilenameCaseNames maps a case key to its human-readable message
62
+ // name, mirroring the upstream `cases` table.
63
+ var unicornFilenameCaseNames = map[string]string{
64
+ "camelCase": "camel case",
65
+ "camelCaseWithAcronyms": "camel case with acronyms",
66
+ "kebabCase": "kebab case",
67
+ "snakeCase": "snake case",
68
+ "pascalCase": "pascal case",
69
+ }
70
+
71
+ // unicornFilenameCaseIgnoredByDefault lists the `index.*` basenames the
72
+ // upstream rule always accepts regardless of the configured case.
73
+ var unicornFilenameCaseIgnoredByDefault = map[string]bool{
74
+ "index.js": true,
75
+ "index.mjs": true,
76
+ "index.cjs": true,
77
+ "index.ts": true,
78
+ "index.tsx": true,
79
+ "index.vue": true,
80
+ }
81
+
82
+ type unicornFilenameCaseOptions struct {
83
+ chosenCases []string
84
+ ignore []*regexp.Regexp
85
+ multipleFileExtensions bool
86
+ checkDirectories bool
87
+ }
88
+
89
+ func (unicornFilenameCase) ValidateOptions(raw json.RawMessage) error {
90
+ _, err := decodeUnicornFilenameCaseOptions(raw)
91
+ return err
92
+ }
93
+
94
+ func decodeUnicornFilenameCaseOptions(raw json.RawMessage) (unicornFilenameCaseOptions, error) {
95
+ options := unicornFilenameCaseOptions{
96
+ chosenCases: []string{"kebabCase"},
97
+ multipleFileExtensions: true,
98
+ checkDirectories: true,
99
+ }
100
+ trimmed := bytes.TrimSpace(raw)
101
+ if len(trimmed) == 0 {
102
+ return options, nil
103
+ }
104
+ if trimmed[0] != '{' {
105
+ return options, errors.New("options must be an object")
106
+ }
107
+ var fields map[string]json.RawMessage
108
+ if err := json.Unmarshal(trimmed, &fields); err != nil {
109
+ return options, fmt.Errorf("options must be an object: %w", err)
110
+ }
111
+ for name := range fields {
112
+ switch name {
113
+ case "case", "cases", "ignore", "multipleFileExtensions", "checkDirectories":
114
+ default:
115
+ return options, fmt.Errorf("unknown option %q", name)
116
+ }
117
+ }
118
+ // The upstream schema is an anyOf over two shapes: one with `case`, one
119
+ // with `cases`, each rejecting the other key. Both together is invalid.
120
+ if _, hasCase := fields["case"]; hasCase {
121
+ if _, hasCases := fields["cases"]; hasCases {
122
+ return options, errors.New(`options "case" and "cases" are mutually exclusive`)
123
+ }
124
+ var chosen string
125
+ if err := json.Unmarshal(fields["case"], &chosen); err != nil {
126
+ return options, fmt.Errorf(`option "case" must be a string: %w`, err)
127
+ }
128
+ if _, known := unicornFilenameCaseNames[chosen]; !known {
129
+ return options, fmt.Errorf(`option "case" must be one of %s`, strings.Join(unicornFilenameCaseOrder, ", "))
130
+ }
131
+ options.chosenCases = []string{chosen}
132
+ } else if value, hasCases := fields["cases"]; hasCases {
133
+ chosen, err := decodeUnicornFilenameCaseCases(value)
134
+ if err != nil {
135
+ return options, err
136
+ }
137
+ if len(chosen) > 0 {
138
+ options.chosenCases = chosen
139
+ }
140
+ }
141
+ if value, has := fields["multipleFileExtensions"]; has {
142
+ enabled, err := decodeUnicornFilenameCaseBool("multipleFileExtensions", value)
143
+ if err != nil {
144
+ return options, err
145
+ }
146
+ options.multipleFileExtensions = enabled
147
+ }
148
+ if value, has := fields["checkDirectories"]; has {
149
+ enabled, err := decodeUnicornFilenameCaseBool("checkDirectories", value)
150
+ if err != nil {
151
+ return options, err
152
+ }
153
+ options.checkDirectories = enabled
154
+ }
155
+ if value, has := fields["ignore"]; has {
156
+ patterns := []string{}
157
+ if err := json.Unmarshal(value, &patterns); err != nil {
158
+ return options, fmt.Errorf(`option "ignore" must be an array of pattern strings: %w`, err)
159
+ }
160
+ seen := map[string]bool{}
161
+ for _, pattern := range patterns {
162
+ if seen[pattern] {
163
+ return options, fmt.Errorf(`option "ignore" has a duplicate pattern %q`, pattern)
164
+ }
165
+ seen[pattern] = true
166
+ expression, err := regexp.Compile(pattern)
167
+ if err != nil {
168
+ return options, fmt.Errorf(`option "ignore" pattern %q: %w`, pattern, err)
169
+ }
170
+ options.ignore = append(options.ignore, expression)
171
+ }
172
+ }
173
+ return options, nil
174
+ }
175
+
176
+ // decodeUnicornFilenameCaseCases reads the `cases` object with a token
177
+ // decoder so the enabled case keys keep their configured order — the
178
+ // order decides both the message's case-name list and the rename samples.
179
+ func decodeUnicornFilenameCaseCases(raw json.RawMessage) ([]string, error) {
180
+ decoder := json.NewDecoder(bytes.NewReader(raw))
181
+ open, err := decoder.Token()
182
+ if err != nil || open != json.Delim('{') {
183
+ return nil, errors.New(`option "cases" must be an object of case-name booleans`)
184
+ }
185
+ order := []string{}
186
+ enabled := map[string]bool{}
187
+ for decoder.More() {
188
+ token, err := decoder.Token()
189
+ if err != nil {
190
+ return nil, fmt.Errorf(`option "cases" must be an object of case-name booleans: %w`, err)
191
+ }
192
+ key, ok := token.(string)
193
+ if !ok {
194
+ return nil, errors.New(`option "cases" must be an object of case-name booleans`)
195
+ }
196
+ if _, known := unicornFilenameCaseNames[key]; !known {
197
+ return nil, fmt.Errorf(`option "cases" has an unknown case %q`, key)
198
+ }
199
+ var value bool
200
+ if err := decoder.Decode(&value); err != nil {
201
+ return nil, fmt.Errorf(`option "cases" value for %q must be a boolean`, key)
202
+ }
203
+ if _, seen := enabled[key]; !seen {
204
+ order = append(order, key)
205
+ }
206
+ enabled[key] = value
207
+ }
208
+ if _, err := decoder.Token(); err != nil {
209
+ return nil, fmt.Errorf(`option "cases" must be an object of case-name booleans: %w`, err)
210
+ }
211
+ chosen := []string{}
212
+ for _, key := range order {
213
+ if enabled[key] {
214
+ chosen = append(chosen, key)
215
+ }
216
+ }
217
+ return chosen, nil
218
+ }
219
+
220
+ func decodeUnicornFilenameCaseBool(name string, raw json.RawMessage) (bool, error) {
221
+ var value bool
222
+ if err := json.Unmarshal(raw, &value); err != nil {
223
+ return false, fmt.Errorf("option %q must be a boolean: %w", name, err)
224
+ }
225
+ return value, nil
226
+ }
227
+
228
+ func (unicornFilenameCase) Check(ctx *Context, _ *shimast.Node) {
229
+ if ctx.File == nil {
230
+ return
231
+ }
232
+ fileName := ctx.File.FileName()
233
+ if fileName == "" || fileName == "<input>" || fileName == "<text>" {
234
+ return
235
+ }
236
+ options, err := decodeUnicornFilenameCaseOptions(ctx.Options)
237
+ if err != nil {
238
+ return
239
+ }
240
+ caseFunctions := make([]func(string) string, 0, len(options.chosenCases))
241
+ for _, chosen := range options.chosenCases {
242
+ caseFunctions = append(caseFunctions, unicornFilenameCaseFunction(chosen))
243
+ }
244
+
245
+ segments := unicornFilenameCasePathSegments(ctx.CurrentDirectory, fileName)
246
+ for _, segment := range segments {
247
+ for _, pattern := range options.ignore {
248
+ if pattern.MatchString(segment) {
249
+ return
250
+ }
251
+ }
252
+ }
253
+
254
+ if options.checkDirectories {
255
+ for _, directory := range segments[:len(segments)-1] {
256
+ if strings.HasPrefix(directory, "$") {
257
+ continue
258
+ }
259
+ leading, words := unicornFilenameCaseSplitName(directory)
260
+ if unicornFilenameCaseIsValidName(words, caseFunctions) {
261
+ continue
262
+ }
263
+ renamed := unicornFilenameCaseFixName(words, caseFunctions, leading, "")
264
+ unicornFilenameCaseReport(ctx, fmt.Sprintf(
265
+ "Directory name `%s` is not in %s. Rename it to %s.",
266
+ directory,
267
+ unicornFilenameCaseCaseNames(options.chosenCases),
268
+ unicornFilenameCaseJoinBackticked(renamed),
269
+ ))
270
+ return
271
+ }
272
+ }
273
+
274
+ basenameWithExtension := segments[len(segments)-1]
275
+ if unicornFilenameCaseIgnoredByDefault[basenameWithExtension] {
276
+ return
277
+ }
278
+ filename, middle, extension := unicornFilenameCaseFilenameParts(
279
+ basenameWithExtension,
280
+ options.multipleFileExtensions,
281
+ )
282
+ leading, words := unicornFilenameCaseSplitName(filename)
283
+ if strings.HasPrefix(filename, "$") || unicornFilenameCaseIsValidName(words, caseFunctions) {
284
+ if extension != strings.ToLower(extension) {
285
+ unicornFilenameCaseReport(ctx, fmt.Sprintf(
286
+ "File extension `%s` is not in lowercase. Rename it to `%s`.",
287
+ extension,
288
+ filename+middle+strings.ToLower(extension),
289
+ ))
290
+ }
291
+ return
292
+ }
293
+ renamed := unicornFilenameCaseFixName(words, caseFunctions, leading, middle+strings.ToLower(extension))
294
+ unicornFilenameCaseReport(ctx, fmt.Sprintf(
295
+ "Filename is not in %s. Rename it to %s.",
296
+ unicornFilenameCaseCaseNames(options.chosenCases),
297
+ unicornFilenameCaseJoinBackticked(renamed),
298
+ ))
299
+ }
300
+
301
+ // unicornFilenameCaseReport anchors the file-level diagnostic on the first
302
+ // statement when one exists (so renderers point at a real source line) and
303
+ // falls back to offset 0 for statement-less files, mirroring the
304
+ // unicorn/no-empty-file anchor policy.
305
+ func unicornFilenameCaseReport(ctx *Context, message string) {
306
+ statements := ctx.File.Statements
307
+ if statements != nil && len(statements.Nodes) > 0 {
308
+ ctx.Report(statements.Nodes[0], message)
309
+ return
310
+ }
311
+ ctx.ReportRange(0, 0, message)
312
+ }
313
+
314
+ // unicornFilenameCasePathSegments mirrors the upstream `getPathSegments`:
315
+ // the file's path relative to the project directory, split into segments;
316
+ // a file outside the project directory contributes only its basename.
317
+ func unicornFilenameCasePathSegments(currentDirectory, fileName string) []string {
318
+ resolved := fileName
319
+ // Rooted-but-driveless names (tsgo's virtual `/…` spellings on Windows)
320
+ // stay as-is: joining them under the project directory would invent a
321
+ // project-relative identity the file does not have.
322
+ if !filepath.IsAbs(resolved) && !strings.HasPrefix(filepath.ToSlash(resolved), "/") {
323
+ resolved = filepath.Join(currentDirectory, resolved)
324
+ }
325
+ relative, err := filepath.Rel(currentDirectory, resolved)
326
+ if err != nil {
327
+ return []string{unicornFilenameCaseBasename(fileName)}
328
+ }
329
+ relative = filepath.ToSlash(relative)
330
+ if relative == "" || relative == "." || relative == ".." || strings.HasPrefix(relative, "../") {
331
+ return []string{unicornFilenameCaseBasename(fileName)}
332
+ }
333
+ parts := strings.Split(relative, "/")
334
+ segments := make([]string, 0, len(parts))
335
+ for _, segment := range parts {
336
+ if segment != "." && segment != "" {
337
+ segments = append(segments, segment)
338
+ }
339
+ }
340
+ if len(segments) == 0 {
341
+ return []string{unicornFilenameCaseBasename(fileName)}
342
+ }
343
+ return segments
344
+ }
345
+
346
+ func unicornFilenameCaseBasename(fileName string) string {
347
+ slashed := strings.TrimRight(filepath.ToSlash(fileName), "/")
348
+ if index := strings.LastIndexByte(slashed, '/'); index >= 0 {
349
+ return slashed[index+1:]
350
+ }
351
+ return slashed
352
+ }
353
+
354
+ // unicornFilenameCaseFilenameParts splits a basename into the checked stem,
355
+ // the untouched middle (extra dot-separated parts when
356
+ // `multipleFileExtensions` is on), and the primary extension using Node's
357
+ // `path.extname` semantics — a lone leading dot marks a hidden file, not
358
+ // an extension.
359
+ func unicornFilenameCaseFilenameParts(basenameWithExtension string, multipleFileExtensions bool) (filename, middle, extension string) {
360
+ extension = unicornFilenameCaseExtname(basenameWithExtension)
361
+ filename = basenameWithExtension[:len(basenameWithExtension)-len(extension)]
362
+ if multipleFileExtensions {
363
+ firstPart := filename
364
+ if index := strings.IndexByte(filename, '.'); index >= 0 {
365
+ firstPart = filename[:index]
366
+ }
367
+ middle = filename[len(firstPart):]
368
+ filename = firstPart
369
+ }
370
+ return filename, middle, extension
371
+ }
372
+
373
+ // unicornFilenameCaseExtname ports Node's `path.extname` state machine for
374
+ // basenames: the extension starts at the last dot, except when the only
375
+ // content before it is dots at the start of the name (`.gitignore`, `..`).
376
+ func unicornFilenameCaseExtname(basename string) string {
377
+ startDot := -1
378
+ end := -1
379
+ preDotState := 0
380
+ for index := len(basename) - 1; index >= 0; index-- {
381
+ if end == -1 {
382
+ end = index + 1
383
+ }
384
+ if basename[index] == '.' {
385
+ if startDot == -1 {
386
+ startDot = index
387
+ } else if preDotState != 1 {
388
+ preDotState = 1
389
+ }
390
+ } else if startDot != -1 {
391
+ preDotState = -1
392
+ }
393
+ }
394
+ if startDot == -1 || end == -1 ||
395
+ preDotState == 0 ||
396
+ (preDotState == 1 && startDot == end-1 && startDot == 1) {
397
+ return ""
398
+ }
399
+ return basename[startDot:end]
400
+ }
401
+
402
+ type unicornFilenameCaseWord struct {
403
+ word string
404
+ ignored bool
405
+ }
406
+
407
+ // unicornFilenameCaseSplitName mirrors upstream `splitName`: strip leading
408
+ // underscores, then group the rest into alternating runs of word characters
409
+ // (`[A-Za-z0-9_-]`, the checked runs) and ignored characters (everything
410
+ // else — brackets, `$`, dots, and any non-ASCII rune pass through verbatim).
411
+ func unicornFilenameCaseSplitName(name string) (string, []unicornFilenameCaseWord) {
412
+ leading := ""
413
+ tailing := name
414
+ if strings.HasPrefix(name, "_") {
415
+ count := 0
416
+ for count < len(name) && name[count] == '_' {
417
+ count++
418
+ }
419
+ // The upstream `^(_+)(.*)$` regex only captures when `.*` can reach the
420
+ // end of the string; JavaScript's `.` matches neither newlines nor the
421
+ // Unicode line separators.
422
+ if !strings.ContainsAny(name[count:], "\n\r

") {
423
+ leading = name[:count]
424
+ tailing = name[count:]
425
+ }
426
+ }
427
+ words := []unicornFilenameCaseWord{}
428
+ for _, char := range tailing {
429
+ ignored := !unicornFilenameCaseIsWordRune(char)
430
+ if len(words) > 0 && words[len(words)-1].ignored == ignored {
431
+ words[len(words)-1].word += string(char)
432
+ continue
433
+ }
434
+ words = append(words, unicornFilenameCaseWord{word: string(char), ignored: ignored})
435
+ }
436
+ return leading, words
437
+ }
438
+
439
+ func unicornFilenameCaseIsWordRune(char rune) bool {
440
+ return char == '_' || char == '-' ||
441
+ (char >= '0' && char <= '9') ||
442
+ (char >= 'a' && char <= 'z') ||
443
+ (char >= 'A' && char <= 'Z')
444
+ }
445
+
446
+ func unicornFilenameCaseIsValidName(words []unicornFilenameCaseWord, caseFunctions []func(string) string) bool {
447
+ for _, word := range words {
448
+ if word.ignored {
449
+ continue
450
+ }
451
+ matched := false
452
+ for _, caseFunction := range caseFunctions {
453
+ if caseFunction(word.word) == word.word {
454
+ matched = true
455
+ break
456
+ }
457
+ }
458
+ if !matched {
459
+ return false
460
+ }
461
+ }
462
+ return true
25
463
  }
26
464
 
465
+ // unicornFilenameCaseFixName enumerates upstream `fixName`'s cartesian
466
+ // product of per-word case conversions (last word varying fastest) and
467
+ // deduplicates the joined names in first-occurrence order. Per-word
468
+ // duplicates collapse up front; that preserves both the resulting set and
469
+ // its order while keeping the enumeration small.
470
+ func unicornFilenameCaseFixName(
471
+ words []unicornFilenameCaseWord,
472
+ caseFunctions []func(string) string,
473
+ leading string,
474
+ trailing string,
475
+ ) []string {
476
+ replacements := make([][]string, 0, len(words))
477
+ for _, word := range words {
478
+ if word.ignored {
479
+ replacements = append(replacements, []string{word.word})
480
+ continue
481
+ }
482
+ variants := make([]string, 0, len(caseFunctions))
483
+ seen := map[string]bool{}
484
+ for _, caseFunction := range caseFunctions {
485
+ variant := caseFunction(word.word)
486
+ if !seen[variant] {
487
+ seen[variant] = true
488
+ variants = append(variants, variant)
489
+ }
490
+ }
491
+ replacements = append(replacements, variants)
492
+ }
493
+
494
+ names := []string{}
495
+ seen := map[string]bool{}
496
+ indexes := make([]int, len(replacements))
497
+ for {
498
+ var builder strings.Builder
499
+ builder.WriteString(leading)
500
+ for position, index := range indexes {
501
+ builder.WriteString(replacements[position][index])
502
+ }
503
+ builder.WriteString(trailing)
504
+ name := builder.String()
505
+ if !seen[name] {
506
+ seen[name] = true
507
+ names = append(names, name)
508
+ }
509
+ position := len(indexes) - 1
510
+ for position >= 0 {
511
+ indexes[position]++
512
+ if indexes[position] < len(replacements[position]) {
513
+ break
514
+ }
515
+ indexes[position] = 0
516
+ position--
517
+ }
518
+ if position < 0 {
519
+ return names
520
+ }
521
+ }
522
+ }
523
+
524
+ func unicornFilenameCaseCaseNames(chosenCases []string) string {
525
+ names := make([]string, 0, len(chosenCases))
526
+ for _, chosen := range chosenCases {
527
+ names = append(names, unicornFilenameCaseNames[chosen])
528
+ }
529
+ return unicornFilenameCaseJoinDisjunction(names)
530
+ }
531
+
532
+ func unicornFilenameCaseJoinBackticked(names []string) string {
533
+ wrapped := make([]string, 0, len(names))
534
+ for _, name := range names {
535
+ wrapped = append(wrapped, "`"+name+"`")
536
+ }
537
+ return unicornFilenameCaseJoinDisjunction(wrapped)
538
+ }
539
+
540
+ // unicornFilenameCaseJoinDisjunction reproduces `Intl.ListFormat("en-US",
541
+ // {type: "disjunction"})`: "a", "a or b", "a, b, or c".
542
+ func unicornFilenameCaseJoinDisjunction(items []string) string {
543
+ switch len(items) {
544
+ case 0:
545
+ return ""
546
+ case 1:
547
+ return items[0]
548
+ case 2:
549
+ return items[0] + " or " + items[1]
550
+ default:
551
+ return strings.Join(items[:len(items)-1], ", ") + ", or " + items[len(items)-1]
552
+ }
553
+ }
554
+
555
+ func unicornFilenameCaseFunction(name string) func(string) string {
556
+ switch name {
557
+ case "camelCase":
558
+ return unicornFilenameCaseCamel
559
+ case "camelCaseWithAcronyms":
560
+ return unicornFilenameCaseCamelWithAcronyms
561
+ case "kebabCase":
562
+ return unicornFilenameCaseKebab
563
+ case "snakeCase":
564
+ return unicornFilenameCaseSnake
565
+ default: // "pascalCase"
566
+ return unicornFilenameCasePascalWithLeadingAcronym
567
+ }
568
+ }
569
+
570
+ // unicornFilenameCaseSplitWords ports `change-case@5`'s `split()`: break
571
+ // between a lowercase-or-digit rune and an uppercase rune, break between
572
+ // an uppercase rune and an uppercase-then-lowercase pair, then treat every
573
+ // non-letter, non-ASCII-digit run as a separator.
574
+ func unicornFilenameCaseSplitWords(value string) []string {
575
+ runes := []rune(strings.TrimSpace(value))
576
+ breakAfter := make([]bool, len(runes))
577
+ for index := 0; index+1 < len(runes); index++ {
578
+ if (unicode.IsLower(runes[index]) || unicornFilenameCaseIsASCIIDigitRune(runes[index])) &&
579
+ unicode.IsUpper(runes[index+1]) {
580
+ breakAfter[index] = true
581
+ }
582
+ }
583
+ for index := 0; index+2 < len(runes); index++ {
584
+ if unicode.IsUpper(runes[index]) && unicode.IsUpper(runes[index+1]) && unicode.IsLower(runes[index+2]) {
585
+ breakAfter[index] = true
586
+ }
587
+ }
588
+ words := []string{}
589
+ var current []rune
590
+ flush := func() {
591
+ if len(current) > 0 {
592
+ words = append(words, string(current))
593
+ current = current[:0]
594
+ }
595
+ }
596
+ for index, char := range runes {
597
+ if unicode.IsLetter(char) || unicornFilenameCaseIsASCIIDigitRune(char) {
598
+ current = append(current, char)
599
+ } else {
600
+ flush()
601
+ }
602
+ if breakAfter[index] {
603
+ flush()
604
+ }
605
+ }
606
+ flush()
607
+ return words
608
+ }
609
+
610
+ func unicornFilenameCaseIsASCIIDigitRune(char rune) bool {
611
+ return char >= '0' && char <= '9'
612
+ }
613
+
614
+ // unicornFilenameCasePascalTransform is `change-case`'s pascal transform:
615
+ // uppercase the first rune and lowercase the rest, except that a non-first
616
+ // word starting with an ASCII digit keeps the digit behind a `_` separator.
617
+ func unicornFilenameCasePascalTransform(word string, index int) string {
618
+ first, size := utf8.DecodeRuneInString(word)
619
+ rest := strings.ToLower(word[size:])
620
+ if index > 0 && first >= '0' && first <= '9' {
621
+ return "_" + string(first) + rest
622
+ }
623
+ return strings.ToUpper(string(first)) + rest
624
+ }
625
+
626
+ func unicornFilenameCaseCamel(value string) string {
627
+ words := unicornFilenameCaseSplitWords(value)
628
+ var builder strings.Builder
629
+ for index, word := range words {
630
+ if index == 0 {
631
+ builder.WriteString(strings.ToLower(word))
632
+ continue
633
+ }
634
+ builder.WriteString(unicornFilenameCasePascalTransform(word, index))
635
+ }
636
+ return builder.String()
637
+ }
638
+
639
+ func unicornFilenameCasePascal(value string) string {
640
+ words := unicornFilenameCaseSplitWords(value)
641
+ var builder strings.Builder
642
+ for index, word := range words {
643
+ builder.WriteString(unicornFilenameCasePascalTransform(word, index))
644
+ }
645
+ return builder.String()
646
+ }
647
+
648
+ func unicornFilenameCaseDelimited(value, delimiter string) string {
649
+ words := unicornFilenameCaseSplitWords(value)
650
+ for index, word := range words {
651
+ words[index] = strings.ToLower(word)
652
+ }
653
+ return strings.Join(words, delimiter)
654
+ }
655
+
656
+ func unicornFilenameCaseKebab(value string) string {
657
+ return unicornFilenameCaseDelimited(value, "-")
658
+ }
659
+
660
+ func unicornFilenameCaseSnake(value string) string {
661
+ return unicornFilenameCaseDelimited(value, "_")
662
+ }
663
+
664
+ // unicornFilenameCaseCamelWithAcronyms accepts camelCase words whose
665
+ // uppercase runs form acronyms (`innerHTML`, `getDOMRangeRect`) and
666
+ // converts everything else with plain camelCase.
667
+ func unicornFilenameCaseCamelWithAcronyms(value string) string {
668
+ if unicornFilenameCaseIsCamelWithAcronyms(value) {
669
+ return value
670
+ }
671
+ return unicornFilenameCaseCamel(value)
672
+ }
673
+
674
+ // unicornFilenameCaseIsCamelWithAcronyms ports upstream
675
+ // `isCamelCaseWithAcronyms` including its index rewinds: an uppercase run
676
+ // either ends the word (optionally through digits), hands off to a
677
+ // lowercase tail by giving back its last letter, or must be followed by
678
+ // another uppercase letter.
679
+ func unicornFilenameCaseIsCamelWithAcronyms(value string) bool {
680
+ if len(value) == 0 || !unicornFilenameCaseIsASCIILower(value[0]) {
681
+ return false
682
+ }
683
+ for index := 1; index < len(value); index++ {
684
+ char := value[index]
685
+ if unicornFilenameCaseIsASCIILower(char) || unicornFilenameCaseIsASCIIDigit(char) {
686
+ continue
687
+ }
688
+ if !unicornFilenameCaseIsASCIIUpper(char) {
689
+ return false
690
+ }
691
+ uppercaseStartIndex := index
692
+ for index+1 < len(value) && unicornFilenameCaseIsASCIIUpper(value[index+1]) {
693
+ index++
694
+ }
695
+ if index == uppercaseStartIndex {
696
+ continue
697
+ }
698
+ if index+1 < len(value) && unicornFilenameCaseIsASCIILower(value[index+1]) {
699
+ index--
700
+ continue
701
+ }
702
+ for index+1 < len(value) && unicornFilenameCaseIsASCIIDigit(value[index+1]) {
703
+ index++
704
+ }
705
+ if index == len(value)-1 {
706
+ return true
707
+ }
708
+ if !unicornFilenameCaseIsASCIIUpper(value[index+1]) {
709
+ return false
710
+ }
711
+ }
712
+ return true
713
+ }
714
+
715
+ var unicornFilenameCaseASCIIAlphanumeric = regexp.MustCompile(`^[0-9A-Za-z]+$`)
716
+
717
+ // unicornFilenameCasePascalWithLeadingAcronym keeps a PascalCase word whose
718
+ // leading three-or-more-letter acronym is followed by an otherwise valid
719
+ // PascalCase tail (`FAQPage`, `URL2Path`), and pascal-cases everything else.
720
+ func unicornFilenameCasePascalWithLeadingAcronym(value string) string {
721
+ if unicornFilenameCaseASCIIAlphanumeric.MatchString(value) {
722
+ if acronym := unicornFilenameCaseLeadingAcronym(value); acronym != "" {
723
+ suffix := value[len(acronym):]
724
+ if suffix != "" && unicornFilenameCasePascal(suffix) == suffix {
725
+ return value
726
+ }
727
+ }
728
+ }
729
+ return unicornFilenameCasePascal(value)
730
+ }
731
+
732
+ // unicornFilenameCaseLeadingAcronym matches upstream
733
+ // `/^[A-Z]{3,}(?=\d*[A-Z](?:[a-z]|\d+[a-z]))/`: the longest uppercase
734
+ // prefix of at least three letters whose remainder starts a new cased word.
735
+ func unicornFilenameCaseLeadingAcronym(value string) string {
736
+ run := 0
737
+ for run < len(value) && unicornFilenameCaseIsASCIIUpper(value[run]) {
738
+ run++
739
+ }
740
+ for length := run; length >= 3; length-- {
741
+ if unicornFilenameCaseLeadingAcronymLookahead(value[length:]) {
742
+ return value[:length]
743
+ }
744
+ }
745
+ return ""
746
+ }
747
+
748
+ // unicornFilenameCaseLeadingAcronymLookahead evaluates the lookahead
749
+ // `\d*[A-Z](?:[a-z]|\d+[a-z])`. The greedy digit runs never need
750
+ // backtracking because neither `[A-Z]` nor `[a-z]` can match a digit.
751
+ func unicornFilenameCaseLeadingAcronymLookahead(rest string) bool {
752
+ index := 0
753
+ for index < len(rest) && unicornFilenameCaseIsASCIIDigit(rest[index]) {
754
+ index++
755
+ }
756
+ if index >= len(rest) || !unicornFilenameCaseIsASCIIUpper(rest[index]) {
757
+ return false
758
+ }
759
+ index++
760
+ if index < len(rest) && unicornFilenameCaseIsASCIILower(rest[index]) {
761
+ return true
762
+ }
763
+ digits := index
764
+ for digits < len(rest) && unicornFilenameCaseIsASCIIDigit(rest[digits]) {
765
+ digits++
766
+ }
767
+ return digits > index && digits < len(rest) && unicornFilenameCaseIsASCIILower(rest[digits])
768
+ }
769
+
770
+ func unicornFilenameCaseIsASCIILower(char byte) bool { return char >= 'a' && char <= 'z' }
771
+ func unicornFilenameCaseIsASCIIUpper(char byte) bool { return char >= 'A' && char <= 'Z' }
772
+ func unicornFilenameCaseIsASCIIDigit(char byte) bool { return char >= '0' && char <= '9' }
773
+
27
774
  func init() {
28
775
  Register(unicornFilenameCase{})
29
776
  }