@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.
- package/README.md +125 -43
- package/go.mod +5 -4
- package/internal/rulecode/rulecode.go +88 -0
- package/lib/index.d.ts +10 -1
- package/lib/index.js +45 -17
- package/lib/index.js.map +1 -1
- package/lib/structures/ITtscLintConfig.d.ts +3 -0
- package/lib/structures/TtscLintRuleSetting.d.ts +3 -2
- package/lib/structures/format/ITtscLintFormat.d.ts +3 -2
- package/lib/structures/format/ITtscLintFormatSortImports.d.ts +20 -4
- package/lib/structures/rules/ITtscLintBoundariesRuleOptions.d.ts +80 -8
- package/lib/structures/rules/ITtscLintBoundariesRules.d.ts +6 -8
- package/lib/structures/rules/ITtscLintContributorRules.d.ts +18 -2
- package/lib/structures/rules/ITtscLintCoreRuleOptions.d.ts +347 -0
- package/lib/structures/rules/ITtscLintCoreRuleOptions.js +3 -0
- package/lib/structures/rules/ITtscLintCoreRuleOptions.js.map +1 -0
- package/lib/structures/rules/ITtscLintCoreRules.d.ts +101 -67
- package/lib/structures/rules/ITtscLintRegexpRules.d.ts +7 -2
- package/lib/structures/rules/ITtscLintRuleOptionsMap.d.ts +53 -7
- package/lib/structures/rules/ITtscLintRules.d.ts +6 -2
- package/lib/structures/rules/ITtscLintSolidRules.d.ts +0 -8
- package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.d.ts +206 -0
- package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.js +8 -0
- package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.js.map +1 -0
- package/lib/structures/rules/ITtscLintTypeScriptRules.d.ts +55 -42
- package/lib/structures/rules/ITtscLintUnicornRuleOptions.d.ts +276 -0
- package/lib/structures/rules/ITtscLintUnicornRuleOptions.js +3 -0
- package/lib/structures/rules/ITtscLintUnicornRuleOptions.js.map +1 -0
- package/lib/structures/rules/ITtscLintUnicornRules.d.ts +44 -36
- package/lib/structures/rules/index.d.ts +3 -0
- package/lib/structures/rules/index.js +3 -0
- package/lib/structures/rules/index.js.map +1 -1
- package/linthost/ast_helpers.go +521 -23
- package/linthost/ast_selector_matcher.go +1628 -0
- package/linthost/ast_selector_parser.go +628 -0
- package/linthost/comment_scan.go +154 -0
- package/linthost/compile.go +129 -79
- package/linthost/config.go +352 -88
- package/linthost/config_format.go +15 -1
- package/linthost/contrib_adapter.go +47 -8
- package/linthost/declaration_rules.go +12 -11
- package/linthost/directives.go +9 -67
- package/linthost/dispatch.go +37 -3
- package/linthost/engine.go +368 -53
- package/linthost/escape_string.go +77 -0
- package/linthost/fix.go +133 -23
- package/linthost/flags_gen.go +18 -17
- package/linthost/format.go +73 -167
- package/linthost/format_editor_settings.go +134 -26
- package/linthost/grapheme_tables_gen.go +1920 -0
- package/linthost/host.go +159 -61
- package/linthost/literal_escape_scan.go +117 -0
- package/linthost/lsp.go +545 -98
- package/linthost/numeric_literal_precision.go +228 -0
- package/linthost/polyfill_browserslist.go +1439 -0
- package/linthost/polyfill_browserslist_config.go +603 -0
- package/linthost/polyfill_corejs.go +750 -0
- package/linthost/polyfill_data.go +113 -0
- package/linthost/polyfill_data_gen.json +39868 -0
- package/linthost/print_nodes_array.go +7 -1
- package/linthost/print_nodes_object.go +7 -1
- package/linthost/project_engine.go +231 -0
- package/linthost/project_rules.go +78 -0
- package/linthost/regex_clean.go +191 -0
- package/linthost/regex_tree.go +1544 -0
- package/linthost/regex_tree_optimizer.go +1685 -0
- package/linthost/regexp_cache.go +44 -0
- package/linthost/rule_codes.go +105 -0
- package/linthost/rule_codes.json +745 -0
- package/linthost/rules_ban_ts_comment.go +312 -0
- package/linthost/rules_boundaries.go +128 -43
- package/linthost/rules_boundaries_dependencies.go +1013 -0
- package/linthost/rules_core_extra.go +62 -92
- package/linthost/rules_cypress.go +1 -1
- package/linthost/rules_default_case.go +87 -11
- package/linthost/rules_dupes.go +38 -13
- package/linthost/rules_empty.go +188 -20
- package/linthost/rules_escape.go +62 -48
- package/linthost/rules_format_arrow_parens.go +19 -5
- package/linthost/rules_format_bracket_spacing.go +1 -1
- package/linthost/rules_format_clause_join.go +1 -1
- package/linthost/rules_format_declaration_header.go +31 -19
- package/linthost/rules_format_indent.go +1 -1
- package/linthost/rules_format_jsdoc.go +1 -1
- package/linthost/rules_format_orphan_semi.go +1 -1
- package/linthost/rules_format_parameter_properties.go +17 -7
- package/linthost/rules_format_print_width.go +1 -1
- package/linthost/rules_format_quote_props.go +1 -1
- package/linthost/rules_format_quotes.go +1 -1
- package/linthost/rules_format_semi.go +106 -79
- package/linthost/rules_format_sort_imports.go +79 -45
- package/linthost/rules_format_statement_split.go +1 -1
- package/linthost/rules_format_ternary_nullish_parens.go +1 -1
- package/linthost/rules_format_trailing_comma.go +64 -1
- package/linthost/rules_format_whitespace.go +1 -1
- package/linthost/rules_functional.go +21 -22
- package/linthost/rules_gap.go +80 -3
- package/linthost/rules_grouped_accessor_pairs.go +84 -25
- package/linthost/rules_guard_for_in.go +64 -147
- package/linthost/rules_jsx_a11y.go +232 -22
- package/linthost/rules_logic.go +84 -38
- package/linthost/rules_nextjs.go +105 -16
- package/linthost/rules_no_class_assign.go +70 -0
- package/linthost/rules_no_duplicate_imports.go +266 -0
- package/linthost/rules_no_else_return.go +156 -38
- package/linthost/rules_no_extend_native.go +113 -17
- package/linthost/rules_no_fallthrough.go +1013 -0
- package/linthost/rules_no_func_assign.go +70 -0
- package/linthost/rules_no_import_assign.go +278 -86
- package/linthost/rules_no_inner_declarations.go +186 -0
- package/linthost/rules_no_loop_func.go +438 -53
- package/linthost/rules_no_mixed_operators.go +208 -52
- package/linthost/rules_no_param_reassign.go +376 -61
- package/linthost/rules_no_promise_executor_return.go +156 -0
- package/linthost/rules_no_restricted_imports.go +1013 -57
- package/linthost/rules_no_restricted_syntax.go +190 -26
- package/linthost/rules_no_useless_constructor.go +6 -36
- package/linthost/rules_problems.go +154 -311
- package/linthost/rules_promise.go +2578 -109
- package/linthost/rules_react_extras.go +71 -45
- package/linthost/rules_react_hooks.go +18 -2
- package/linthost/rules_react_perf.go +1 -0
- package/linthost/rules_react_refresh.go +1 -1
- package/linthost/rules_regexp.go +230 -14
- package/linthost/rules_security.go +45 -19
- package/linthost/rules_self.go +21 -1
- package/linthost/rules_solid.go +13 -7
- package/linthost/rules_storybook.go +1 -1
- package/linthost/rules_suggestions.go +490 -84
- package/linthost/rules_testing_library.go +12 -1
- package/linthost/rules_ts.go +227 -54
- package/linthost/rules_ts_async.go +0 -131
- package/linthost/rules_ts_no_base_to_string.go +3 -4
- package/linthost/rules_ts_no_misused_promises.go +817 -0
- package/linthost/rules_ts_no_restricted_types.go +320 -34
- package/linthost/rules_ts_no_unnecessary_qualifier.go +132 -55
- package/linthost/rules_ts_no_unsafe_assignment.go +608 -60
- package/linthost/rules_ts_require_array_sort_compare.go +5 -5
- package/linthost/rules_ts_switch_exhaustiveness_check.go +556 -86
- package/linthost/rules_unicorn_better_regex.go +224 -16
- package/linthost/rules_unicorn_consistent_destructuring.go +1002 -7
- package/linthost/rules_unicorn_consistent_existence_index_check.go +208 -29
- package/linthost/rules_unicorn_consistent_function_scoping.go +1041 -8
- package/linthost/rules_unicorn_consistent_template_literal_escape.go +178 -7
- package/linthost/rules_unicorn_escape_case.go +30 -19
- package/linthost/rules_unicorn_filename_case.go +763 -16
- package/linthost/rules_unicorn_import_style.go +783 -17
- package/linthost/rules_unicorn_isolated_functions.go +934 -14
- package/linthost/rules_unicorn_no_hex_escape.go +26 -11
- package/linthost/rules_unicorn_no_typeof_undefined.go +234 -25
- package/linthost/rules_unicorn_no_unnecessary_polyfills.go +376 -21
- package/linthost/rules_unicorn_no_unused_properties.go +756 -19
- package/linthost/rules_unicorn_number_literal_case.go +58 -39
- package/linthost/rules_unicorn_prefer_at.go +7 -3
- package/linthost/rules_unicorn_prefer_number_properties.go +381 -48
- package/linthost/rules_unicorn_prefer_optional_catch_binding.go +24 -24
- package/linthost/rules_unicorn_prefer_simple_condition_first.go +597 -19
- package/linthost/rules_unicorn_prefer_string_raw.go +147 -16
- package/linthost/rules_unicorn_prevent_abbreviations.go +1963 -95
- package/linthost/rules_unicorn_string_content.go +611 -15
- package/linthost/rules_unicorn_switch_case_break_position.go +172 -15
- package/linthost/rules_unicorn_template_indent.go +923 -18
- package/linthost/rules_unicorn_text_encoding_identifier_case.go +202 -42
- package/linthost/rules_unicorn_throw_new_error.go +218 -26
- package/linthost/rules_var.go +829 -142
- package/linthost/string_length.go +222 -0
- package/package.json +4 -3
- package/rule/project.go +206 -0
- package/rule/rule.go +58 -1
- package/src/index.ts +58 -17
- package/src/structures/ITtscLintConfig.ts +3 -0
- package/src/structures/TtscLintRuleSetting.ts +3 -2
- package/src/structures/format/ITtscLintFormat.ts +3 -2
- package/src/structures/format/ITtscLintFormatSortImports.ts +21 -4
- package/src/structures/rules/ITtscLintBoundariesRuleOptions.ts +122 -8
- package/src/structures/rules/ITtscLintBoundariesRules.ts +6 -8
- package/src/structures/rules/ITtscLintContributorRules.ts +18 -2
- package/src/structures/rules/ITtscLintCoreRuleOptions.ts +465 -0
- package/src/structures/rules/ITtscLintCoreRules.ts +122 -67
- package/src/structures/rules/ITtscLintRegexpRules.ts +7 -2
- package/src/structures/rules/ITtscLintRuleOptionsMap.ts +88 -7
- package/src/structures/rules/ITtscLintRules.ts +7 -2
- package/src/structures/rules/ITtscLintSolidRules.ts +0 -9
- package/src/structures/rules/ITtscLintTypeScriptRuleOptions.ts +248 -0
- package/src/structures/rules/ITtscLintTypeScriptRules.ts +64 -42
- package/src/structures/rules/ITtscLintUnicornRuleOptions.ts +350 -0
- package/src/structures/rules/ITtscLintUnicornRules.ts +60 -36
- package/src/structures/rules/index.ts +3 -0
|
@@ -1,29 +1,776 @@
|
|
|
1
|
-
// unicorn/filename-case:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// `
|
|
14
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
}
|