@ttsc/lint 0.12.4 → 0.13.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/lib/index.js +225 -135
- package/lib/index.js.map +1 -1
- package/lib/structures/ITtscLintPluginConfig.d.ts +10 -77
- package/lib/structures/TtscLintRuleOptions.d.ts +14 -0
- package/linthost/ast_helpers.go +68 -16
- package/linthost/compile.go +117 -119
- package/linthost/config.go +518 -707
- package/linthost/config_format.go +16 -4
- package/linthost/contrib_adapter.go +7 -0
- package/linthost/directives.go +44 -0
- package/linthost/engine.go +152 -44
- package/linthost/fix.go +24 -33
- package/linthost/flags_gen.go +33 -0
- package/linthost/format.go +96 -3
- package/linthost/host.go +144 -8
- package/linthost/print_dispatch.go +121 -23
- package/linthost/print_doc.go +19 -0
- package/linthost/print_engine.go +168 -4
- package/linthost/print_nodes_array.go +17 -7
- package/linthost/print_nodes_call.go +129 -20
- package/linthost/print_nodes_function.go +353 -0
- package/linthost/print_nodes_imports.go +46 -29
- package/linthost/print_nodes_list.go +86 -5
- package/linthost/print_nodes_object.go +56 -11
- package/linthost/rules_escape.go +20 -3
- package/linthost/rules_format_print_width.go +267 -15
- package/linthost/rules_gap.go +55 -3
- package/linthost/rules_logic.go +64 -5
- package/linthost/rules_problems.go +65 -21
- package/linthost/rules_promise.go +3 -0
- package/linthost/rules_suggestions.go +160 -5
- package/linthost/rules_var.go +7 -1
- package/package.json +3 -3
- package/src/index.ts +243 -168
- package/src/structures/ITtscLintPluginConfig.ts +10 -83
- package/src/structures/TtscLintRuleOptions.ts +15 -0
- package/linthost/eslint_runtime.go +0 -351
package/linthost/format.go
CHANGED
|
@@ -41,8 +41,12 @@ func runFormat(opts *subcommandOpts) int {
|
|
|
41
41
|
fmt.Fprintln(os.Stderr, err)
|
|
42
42
|
return 2
|
|
43
43
|
}
|
|
44
|
+
resolver := formatCommandResolver{inner: rules}
|
|
45
|
+
engine := NewEngineWithResolver(resolver)
|
|
46
|
+
engine.SetSerial(opts.singleThreaded)
|
|
47
|
+
needsRuleChecker := engine.NeedsTypeChecker()
|
|
44
48
|
|
|
45
|
-
prog, code := loadFixProgram(opts)
|
|
49
|
+
prog, code := loadFixProgram(opts, needsRuleChecker)
|
|
46
50
|
if code != 0 {
|
|
47
51
|
return code
|
|
48
52
|
}
|
|
@@ -55,7 +59,6 @@ func runFormat(opts *subcommandOpts) int {
|
|
|
55
59
|
totalFixes := 0
|
|
56
60
|
cascadeConverged := false
|
|
57
61
|
for pass := 0; pass < maxFormatPasses; pass++ {
|
|
58
|
-
engine := NewEngineWithResolver(formatCommandResolver{inner: rules})
|
|
59
62
|
findings := engine.Run(prog.userSourceFiles(), prog.checker)
|
|
60
63
|
fixed, err := applyFindingFixes(opts.cwd, filterFormatFindings(findings))
|
|
61
64
|
if err != nil {
|
|
@@ -67,7 +70,7 @@ func runFormat(opts *subcommandOpts) int {
|
|
|
67
70
|
break
|
|
68
71
|
}
|
|
69
72
|
totalFixes += fixed
|
|
70
|
-
prog, code = reloadFixProgram(prog, opts)
|
|
73
|
+
prog, code = reloadFixProgram(prog, opts, needsRuleChecker)
|
|
71
74
|
if code != 0 {
|
|
72
75
|
return code
|
|
73
76
|
}
|
|
@@ -102,11 +105,34 @@ type formatCommandResolver struct {
|
|
|
102
105
|
// ResolveRules implements RuleResolver. It delegates to the inner resolver
|
|
103
106
|
// and then upgrades format-rule entries from off to warn so they are applied
|
|
104
107
|
// even when the project config omits them.
|
|
108
|
+
//
|
|
109
|
+
// A user-authored entry whose `ignores` list matches `fileName` is honored
|
|
110
|
+
// here even when the entry also carries a `rules` block: the engine already
|
|
111
|
+
// skips that entry's rule contributions via `ConfigEntry.matchesFile`, so the
|
|
112
|
+
// format command must skip its blanket format-rule upgrade as well. Without
|
|
113
|
+
// this guard, `ttsc format` would rewrite files that the user explicitly
|
|
114
|
+
// asked the lint config to ignore — the engine's lint walk would skip the
|
|
115
|
+
// file but the formatter would still touch it.
|
|
116
|
+
//
|
|
117
|
+
// The symmetric guard applies to `files`: if every non-IgnoreOnly entry
|
|
118
|
+
// carries a `files` filter and `fileName` matches none of them, the engine
|
|
119
|
+
// would not run any rules on the file (`ConfigEntry.matchesFile` returns
|
|
120
|
+
// false for every entry). The format command must skip its blanket
|
|
121
|
+
// format-rule upgrade for the same reason — otherwise `ttsc format` would
|
|
122
|
+
// rewrite files that fall outside every entry's scope, e.g. a `.json`
|
|
123
|
+
// resolved into the program via `resolveJsonModule` when the only entry
|
|
124
|
+
// targets `src/**/*.ts`.
|
|
105
125
|
func (r formatCommandResolver) ResolveRules(fileName string) ResolvedRuleConfig {
|
|
106
126
|
resolved := r.inner.ResolveRules(fileName)
|
|
107
127
|
if resolved.Ignored {
|
|
108
128
|
return resolved
|
|
109
129
|
}
|
|
130
|
+
if r.fileIsIgnoredByEntry(fileName) {
|
|
131
|
+
return resolved
|
|
132
|
+
}
|
|
133
|
+
if !r.fileMatchesAnyEntry(fileName) {
|
|
134
|
+
return resolved
|
|
135
|
+
}
|
|
110
136
|
if resolved.Rules == nil {
|
|
111
137
|
resolved.Rules = RuleConfig{}
|
|
112
138
|
}
|
|
@@ -118,6 +144,73 @@ func (r formatCommandResolver) ResolveRules(fileName string) ResolvedRuleConfig
|
|
|
118
144
|
return resolved
|
|
119
145
|
}
|
|
120
146
|
|
|
147
|
+
// fileIsIgnoredByEntry reports whether any non-IgnoreOnly entry in the inner
|
|
148
|
+
// ConfigStore has an `ignores` glob that matches `fileName`. IgnoreOnly
|
|
149
|
+
// entries are already handled by ResolvedRuleConfig.Ignored — they are
|
|
150
|
+
// checked first by ConfigStore.ResolveRules and short-circuit the walk. This
|
|
151
|
+
// helper covers the complementary case: an entry that carries both `rules`
|
|
152
|
+
// and `ignores`, where the engine skips the entry's rule contributions via
|
|
153
|
+
// `ConfigEntry.matchesFile` but the format command must learn the same fact
|
|
154
|
+
// independently because its job is to upgrade format rules to `warn`, not to
|
|
155
|
+
// read the engine's resolved severity map.
|
|
156
|
+
func (r formatCommandResolver) fileIsIgnoredByEntry(fileName string) bool {
|
|
157
|
+
store, ok := r.inner.(*ConfigStore)
|
|
158
|
+
if !ok || store == nil {
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
for _, entry := range store.entries {
|
|
162
|
+
if entry.IgnoreOnly {
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
if entry.matchesIgnores(fileName) {
|
|
166
|
+
return true
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return false
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// fileMatchesAnyEntry reports whether `fileName` falls inside the `files`
|
|
173
|
+
// scope of at least one non-IgnoreOnly entry. An entry without an explicit
|
|
174
|
+
// `files` list matches every file by definition (eslint flat-config
|
|
175
|
+
// semantics), so a config with any unrestricted non-IgnoreOnly entry returns
|
|
176
|
+
// true for every file. The format command treats a `false` result the same
|
|
177
|
+
// way the engine does: no entry contributes rules, so the blanket
|
|
178
|
+
// format-rule upgrade must be skipped.
|
|
179
|
+
//
|
|
180
|
+
// Base cases:
|
|
181
|
+
//
|
|
182
|
+
// - Empty entries slice: returns `true`. A store with no entries cannot
|
|
183
|
+
// skip a file by scope (the engine has nothing to walk), so format
|
|
184
|
+
// mode must be allowed to apply its default upgrade. Matching engine
|
|
185
|
+
// behavior at `ConfigStore.ResolveRules` for the same input
|
|
186
|
+
// (`Ignored = false`, empty rule map).
|
|
187
|
+
// - All entries are IgnoreOnly: returns `false`. No non-IgnoreOnly
|
|
188
|
+
// entry contributes a `files` scope, so the file is out-of-scope by
|
|
189
|
+
// construction.
|
|
190
|
+
// - Inner resolver is not a *ConfigStore: returns `true` (conservative
|
|
191
|
+
// default). The `files` concept is store-specific; an in-process
|
|
192
|
+
// custom resolver that does not surface a scope cannot have its
|
|
193
|
+
// rule-eligibility reasoned about from the outside, so the format
|
|
194
|
+
// upgrade applies the same as it does for an entry without `files`.
|
|
195
|
+
func (r formatCommandResolver) fileMatchesAnyEntry(fileName string) bool {
|
|
196
|
+
store, ok := r.inner.(*ConfigStore)
|
|
197
|
+
if !ok || store == nil {
|
|
198
|
+
return true
|
|
199
|
+
}
|
|
200
|
+
if len(store.entries) == 0 {
|
|
201
|
+
return true
|
|
202
|
+
}
|
|
203
|
+
for _, entry := range store.entries {
|
|
204
|
+
if entry.IgnoreOnly {
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
if entry.matchesFile(fileName) {
|
|
208
|
+
return true
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return false
|
|
212
|
+
}
|
|
213
|
+
|
|
121
214
|
// ActiveRuleNames implements RuleResolver. Returns the union of the inner
|
|
122
215
|
// resolver's active rules and every format-option rule that is registered.
|
|
123
216
|
func (r formatCommandResolver) ActiveRuleNames() []string {
|
package/linthost/host.go
CHANGED
|
@@ -14,6 +14,7 @@ import (
|
|
|
14
14
|
"errors"
|
|
15
15
|
"fmt"
|
|
16
16
|
"path/filepath"
|
|
17
|
+
"strings"
|
|
17
18
|
|
|
18
19
|
shimast "github.com/microsoft/typescript-go/shim/ast"
|
|
19
20
|
"github.com/microsoft/typescript-go/shim/bundled"
|
|
@@ -39,10 +40,25 @@ type loadProgramOptions struct {
|
|
|
39
40
|
forceEmit bool
|
|
40
41
|
forceNoEmit bool
|
|
41
42
|
outDir string
|
|
43
|
+
// needsRuleChecker asks loadProgram to pin the checker pool and acquire the
|
|
44
|
+
// checker that type-aware lint rules receive through Context.Checker.
|
|
45
|
+
needsRuleChecker bool
|
|
46
|
+
// singleThreaded mirrors `tsgo --singleThreaded`: one checker, serial
|
|
47
|
+
// parse/check/emit.
|
|
48
|
+
singleThreaded bool
|
|
49
|
+
// checkers mirrors `tsgo --checkers`: type-checker pool size. Zero leaves
|
|
50
|
+
// TypeScript-Go's default; ignored when singleThreaded is set.
|
|
51
|
+
checkers int
|
|
52
|
+
// tsgoArgs carries tsgo CLI flags the `ttsc` launcher forwarded (`--strict`,
|
|
53
|
+
// `--target es2020`, …). They are parsed through TypeScript-Go's own
|
|
54
|
+
// command-line parser into a CompilerOptions overlay that wins over the
|
|
55
|
+
// tsconfig, exactly as tsgo's CLI merges them.
|
|
56
|
+
tsgoArgs []string
|
|
42
57
|
}
|
|
43
58
|
|
|
44
|
-
// loadProgram parses the given tsconfig
|
|
45
|
-
//
|
|
59
|
+
// loadProgram parses the given tsconfig and builds a Program. When
|
|
60
|
+
// needsRuleChecker is set, it also acquires a type checker for lint rules.
|
|
61
|
+
// Mirrors the canonical bootstrap pattern from
|
|
46
62
|
// `03-tsgo.md` — the only ttsc-specific bit is that `forceEmit`/
|
|
47
63
|
// `forceNoEmit`/`outDir` overrides are merged into the parsed config
|
|
48
64
|
// before the program is created so `--noEmit` and friends behave like
|
|
@@ -63,9 +79,14 @@ func loadProgram(cwd, tsconfigPath string, options loadProgramOptions) (*program
|
|
|
63
79
|
fs := bundled.WrapFS(cachedvfs.From(osvfs.FS()))
|
|
64
80
|
host := shimcompiler.NewCompilerHost(cwd, fs, bundled.LibPath(), nil, nil)
|
|
65
81
|
|
|
82
|
+
cliOptions, cliDiags := parseTsgoArgs(options.tsgoArgs, host)
|
|
83
|
+
if len(cliDiags) > 0 {
|
|
84
|
+
return nil, cliDiags, nil
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
parsed, parseDiags := tsoptions.GetParsedCommandLineOfConfigFile(
|
|
67
88
|
resolved,
|
|
68
|
-
|
|
89
|
+
cliOptions,
|
|
69
90
|
nil,
|
|
70
91
|
host,
|
|
71
92
|
nil,
|
|
@@ -88,17 +109,34 @@ func loadProgram(cwd, tsconfigPath string, options loadProgramOptions) (*program
|
|
|
88
109
|
if options.outDir != "" {
|
|
89
110
|
overrideOutDir(cwd, parsed, options.outDir)
|
|
90
111
|
}
|
|
112
|
+
applyThreading(parsed, options.singleThreaded, options.checkers)
|
|
113
|
+
if options.needsRuleChecker {
|
|
114
|
+
forceSingleChecker(parsed)
|
|
115
|
+
}
|
|
91
116
|
|
|
117
|
+
// SingleThreaded is left unset so the program keeps TypeScript-Go's parallel
|
|
118
|
+
// source parsing and parallel emit. For type-aware lint rules, the checker
|
|
119
|
+
// pool is pinned to a single checker (see forceSingleChecker): the lint
|
|
120
|
+
// engine walks files serially against the one checker GetTypeChecker hands
|
|
121
|
+
// back, and rules ask that checker to resolve types in nodes drawn from every
|
|
122
|
+
// source file. TypeScript-Go's multi-checker pool affinitizes each file to a
|
|
123
|
+
// different checker and forbids mixing types across them, so a type whose
|
|
124
|
+
// declarations span files on different checkers (e.g. a circular
|
|
125
|
+
// indexed-access alias) resolves to `any` on the borrowed checker. AST-only
|
|
126
|
+
// lint rules do not receive a checker, so they keep the user's checker pool.
|
|
92
127
|
tsProgram := shimcompiler.NewProgram(shimcompiler.ProgramOptions{
|
|
93
128
|
Config: parsed,
|
|
94
|
-
SingleThreaded: shimcore.TSTrue,
|
|
95
129
|
Host: host,
|
|
96
130
|
UseSourceOfProjectReference: true,
|
|
97
131
|
})
|
|
98
132
|
if tsProgram == nil {
|
|
99
133
|
return nil, nil, errors.New("compiler.NewProgram returned nil")
|
|
100
134
|
}
|
|
101
|
-
checker
|
|
135
|
+
var checker *shimchecker.Checker
|
|
136
|
+
var release func()
|
|
137
|
+
if options.needsRuleChecker {
|
|
138
|
+
checker, release = tsProgram.GetTypeChecker(context.Background())
|
|
139
|
+
}
|
|
102
140
|
return &program{
|
|
103
141
|
cwd: cwd,
|
|
104
142
|
tsProgram: tsProgram,
|
|
@@ -120,12 +158,19 @@ func (p *program) close() {
|
|
|
120
158
|
}
|
|
121
159
|
}
|
|
122
160
|
|
|
123
|
-
// userSourceFiles returns the
|
|
124
|
-
//
|
|
161
|
+
// userSourceFiles returns the tsconfig-selected source files the lint engine
|
|
162
|
+
// owns. The tsconfig file list is the boundary: imported libraries, generated
|
|
163
|
+
// output, and JSON modules may still appear in Program.SourceFiles(), but lint
|
|
164
|
+
// and format should not walk them unless the project selected them as TS/JS
|
|
165
|
+
// source roots.
|
|
125
166
|
func (p *program) userSourceFiles() []*shimast.SourceFile {
|
|
167
|
+
roots := p.userSourceFileNames()
|
|
126
168
|
out := make([]*shimast.SourceFile, 0)
|
|
127
169
|
for _, f := range p.tsProgram.SourceFiles() {
|
|
128
|
-
if f == nil
|
|
170
|
+
if f == nil {
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
if _, ok := roots[canonicalProjectPath(p.cwd, f.FileName())]; !ok {
|
|
129
174
|
continue
|
|
130
175
|
}
|
|
131
176
|
out = append(out, f)
|
|
@@ -133,6 +178,35 @@ func (p *program) userSourceFiles() []*shimast.SourceFile {
|
|
|
133
178
|
return out
|
|
134
179
|
}
|
|
135
180
|
|
|
181
|
+
func (p *program) userSourceFileNames() map[string]struct{} {
|
|
182
|
+
out := make(map[string]struct{})
|
|
183
|
+
if p == nil || p.parsed == nil || p.parsed.ParsedConfig == nil {
|
|
184
|
+
return out
|
|
185
|
+
}
|
|
186
|
+
for _, fileName := range p.parsed.ParsedConfig.FileNames {
|
|
187
|
+
if isLintSourceFileName(fileName) {
|
|
188
|
+
out[canonicalProjectPath(p.cwd, fileName)] = struct{}{}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return out
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
func canonicalProjectPath(cwd, fileName string) string {
|
|
195
|
+
if !filepath.IsAbs(fileName) {
|
|
196
|
+
fileName = filepath.Join(cwd, fileName)
|
|
197
|
+
}
|
|
198
|
+
return filepath.ToSlash(filepath.Clean(fileName))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
func isLintSourceFileName(fileName string) bool {
|
|
202
|
+
switch strings.ToLower(filepath.Ext(fileName)) {
|
|
203
|
+
case ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs":
|
|
204
|
+
return true
|
|
205
|
+
default:
|
|
206
|
+
return false
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
136
210
|
// programDiagnostics returns the bind + semantic diagnostics for the
|
|
137
211
|
// loaded program. Same surface tsgo's CLI prints when you run a regular
|
|
138
212
|
// `tsgo --noEmit`.
|
|
@@ -185,6 +259,68 @@ func forceNoEmit(parsed *tsoptions.ParsedCommandLine) {
|
|
|
185
259
|
parsed.ParsedConfig.CompilerOptions.NoEmit = shimcore.TSTrue
|
|
186
260
|
}
|
|
187
261
|
|
|
262
|
+
// parseTsgoArgs runs forwarded tsgo CLI flags through TypeScript-Go's own
|
|
263
|
+
// command-line parser, yielding a CompilerOptions overlay loadProgram merges
|
|
264
|
+
// over the tsconfig — so a flag like `ttsc --strict` reaches the in-process
|
|
265
|
+
// lint program even though @ttsc/lint never shells out to `tsgo`. Returns an
|
|
266
|
+
// empty (non-nil) options value when there are no forwarded flags.
|
|
267
|
+
func parseTsgoArgs(args []string, host shimcompiler.CompilerHost) (*shimcore.CompilerOptions, []*shimast.Diagnostic) {
|
|
268
|
+
if len(args) == 0 {
|
|
269
|
+
return &shimcore.CompilerOptions{}, nil
|
|
270
|
+
}
|
|
271
|
+
cli := tsoptions.ParseCommandLine(args, host)
|
|
272
|
+
if cli == nil {
|
|
273
|
+
return &shimcore.CompilerOptions{}, nil
|
|
274
|
+
}
|
|
275
|
+
if len(cli.Errors) > 0 {
|
|
276
|
+
return nil, cli.Errors
|
|
277
|
+
}
|
|
278
|
+
return cli.CompilerOptions(), nil
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// applyThreading forwards the --singleThreaded / --checkers knobs onto the
|
|
282
|
+
// parsed compiler options. ttsc mirrors tsgo here: the values land in
|
|
283
|
+
// CompilerOptions, and both Program.SingleThreaded() and the checker pool read
|
|
284
|
+
// them from there. SingleThreaded wins over Checkers, matching the pool.
|
|
285
|
+
//
|
|
286
|
+
// When a type-aware lint rule is active, loadProgram calls forceSingleChecker
|
|
287
|
+
// afterwards, so a `--checkers N` greater than 1 is recorded here and then
|
|
288
|
+
// clamped back to a single checker. AST-only lint runs keep the recorded
|
|
289
|
+
// checker count. `--singleThreaded` still takes full effect.
|
|
290
|
+
func applyThreading(parsed *tsoptions.ParsedCommandLine, singleThreaded bool, checkers int) {
|
|
291
|
+
if parsed == nil || parsed.ParsedConfig == nil || parsed.ParsedConfig.CompilerOptions == nil {
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
options := parsed.ParsedConfig.CompilerOptions
|
|
295
|
+
if singleThreaded {
|
|
296
|
+
options.SingleThreaded = shimcore.TSTrue
|
|
297
|
+
}
|
|
298
|
+
if checkers > 0 {
|
|
299
|
+
n := checkers
|
|
300
|
+
options.Checkers = &n
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// forceSingleChecker pins the TypeScript-Go checker pool to a single checker.
|
|
305
|
+
//
|
|
306
|
+
// The lint engine walks the program serially and obtains types through the
|
|
307
|
+
// single checker GetTypeChecker hands back. Rules query types on nodes from
|
|
308
|
+
// arbitrary source files, so the checker must be the same one that checked
|
|
309
|
+
// every file. A pool of size > 1 affinitizes files to distinct checkers;
|
|
310
|
+
// resolving a type whose declarations cross that boundary yields `any`.
|
|
311
|
+
// Parallel parsing and emit are unaffected — they do not consult the count.
|
|
312
|
+
func forceSingleChecker(parsed *tsoptions.ParsedCommandLine) {
|
|
313
|
+
if parsed == nil || parsed.ParsedConfig == nil || parsed.ParsedConfig.CompilerOptions == nil {
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
options := parsed.ParsedConfig.CompilerOptions
|
|
317
|
+
if options.SingleThreaded == shimcore.TSTrue {
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
one := 1
|
|
321
|
+
options.Checkers = &one
|
|
322
|
+
}
|
|
323
|
+
|
|
188
324
|
// overrideOutDir replaces the parsed config's OutDir with `outDir`.
|
|
189
325
|
// Relative outDir values are resolved against `cwd`; absolute paths are
|
|
190
326
|
// used as-is. Paths are converted to forward slashes for tsgo
|
|
@@ -9,10 +9,10 @@ import (
|
|
|
9
9
|
//
|
|
10
10
|
// The dispatcher is the bridge between the TypeScript-Go AST and the
|
|
11
11
|
// printer engine. It walks one node at a time and emits a Doc tree
|
|
12
|
-
// shaped to that node's grammar. Coverage is intentionally partial
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
12
|
+
// shaped to that node's grammar. Coverage is intentionally partial;
|
|
13
|
+
// the verbatim fallback below guarantees that an un-handled node kind
|
|
14
|
+
// contributes its original source bytes verbatim, so the printer can
|
|
15
|
+
// be wired up to a rule without breaking files that happen to use
|
|
16
16
|
// shapes the per-node printers don't yet understand.
|
|
17
17
|
//
|
|
18
18
|
// The price of verbatim fallback is that reflow stops at the boundary
|
|
@@ -20,6 +20,19 @@ import (
|
|
|
20
20
|
// dispatcher doesn't recognize stays long. That trade-off is preferable
|
|
21
21
|
// to corrupting unfamiliar shapes — extension over time turns each
|
|
22
22
|
// verbatim hop into a real reflow.
|
|
23
|
+
//
|
|
24
|
+
// Coverage signal. A verbatim slice keeps its *original* source column.
|
|
25
|
+
// When an un-handled node spans multiple lines, its interior lines are
|
|
26
|
+
// frozen at the columns the user wrote while the enclosing reflow
|
|
27
|
+
// re-indents everything around it — the result is inconsistently
|
|
28
|
+
// indented, corrupt output. To prevent that, every printer reports a
|
|
29
|
+
// `covered` boolean alongside its Doc: `true` means the whole printed
|
|
30
|
+
// subtree is reflow-safe (no multi-line verbatim node), `false` means a
|
|
31
|
+
// multi-line verbatim node is buried inside. The format/print-width
|
|
32
|
+
// rule abstains entirely when `covered` is false, so `ttsc format`
|
|
33
|
+
// either reflows correctly or leaves the bytes untouched — it never
|
|
34
|
+
// emits the half-reflowed shape. Single-line verbatim is always safe:
|
|
35
|
+
// a node confined to one source line has no interior column to freeze.
|
|
23
36
|
|
|
24
37
|
// PrintContext bundles the per-file inputs every per-node printer
|
|
25
38
|
// needs. The dispatcher constructs one per top-level reflow and threads
|
|
@@ -42,46 +55,96 @@ func NewPrintContext(file *shimast.SourceFile, opts PrintOptions) *PrintContext
|
|
|
42
55
|
|
|
43
56
|
// PrintNode is the dispatcher entry. It picks a per-node printer based
|
|
44
57
|
// on `node.Kind` and falls back to the verbatim source slice when no
|
|
45
|
-
// printer is registered. Returns the printed Doc and a
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
58
|
+
// printer is registered. Returns the printed Doc and a `covered`
|
|
59
|
+
// boolean: `true` when the whole printed subtree is reflow-safe,
|
|
60
|
+
// `false` when a multi-line verbatim node is buried inside it.
|
|
61
|
+
//
|
|
62
|
+
// The format/print-width rule consults `covered` to decide whether to
|
|
63
|
+
// emit an edit at all — see the coverage-signal note at the top of this
|
|
64
|
+
// file. A `false` reading is a hard abstain, not a soft hint.
|
|
50
65
|
func PrintNode(ctx *PrintContext, node *shimast.Node) (Doc, bool) {
|
|
51
66
|
if node == nil {
|
|
52
|
-
return Doc{},
|
|
67
|
+
return Doc{}, true
|
|
53
68
|
}
|
|
54
|
-
if doc,
|
|
55
|
-
return doc,
|
|
69
|
+
if doc, covered, ok := dispatchNode(ctx, node); ok {
|
|
70
|
+
return doc, covered
|
|
56
71
|
}
|
|
57
|
-
return verbatim(ctx, node),
|
|
72
|
+
return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
|
|
58
73
|
}
|
|
59
74
|
|
|
60
75
|
// dispatchNode is the per-kind switch. Each branch returns
|
|
61
|
-
// (doc,
|
|
76
|
+
// (doc, covered, true) when it produces a structured Doc, or
|
|
62
77
|
// (zero, false, false) to let the caller fall back to verbatim. The
|
|
63
|
-
//
|
|
64
|
-
//
|
|
78
|
+
// `covered` flag is `true` only when the per-node printer guarantees
|
|
79
|
+
// the whole subtree it produced is free of multi-line verbatim slices.
|
|
65
80
|
func dispatchNode(ctx *PrintContext, node *shimast.Node) (Doc, bool, bool) {
|
|
66
81
|
switch node.Kind {
|
|
67
82
|
case shimast.KindObjectLiteralExpression:
|
|
68
|
-
|
|
83
|
+
doc, covered := printObjectLiteral(ctx, node)
|
|
84
|
+
return doc, covered, true
|
|
69
85
|
case shimast.KindArrayLiteralExpression:
|
|
70
|
-
|
|
86
|
+
doc, covered := printArrayLiteral(ctx, node)
|
|
87
|
+
return doc, covered, true
|
|
71
88
|
case shimast.KindCallExpression:
|
|
72
|
-
|
|
89
|
+
doc, covered := printCallExpression(ctx, node)
|
|
90
|
+
return doc, covered, true
|
|
73
91
|
case shimast.KindNewExpression:
|
|
74
|
-
|
|
92
|
+
doc, covered := printNewExpression(ctx, node)
|
|
93
|
+
return doc, covered, true
|
|
75
94
|
case shimast.KindNamedImports:
|
|
76
|
-
|
|
95
|
+
doc, covered := printNamedImports(ctx, node)
|
|
96
|
+
return doc, covered, true
|
|
77
97
|
case shimast.KindNamedExports:
|
|
78
|
-
|
|
98
|
+
doc, covered := printNamedExports(ctx, node)
|
|
99
|
+
return doc, covered, true
|
|
79
100
|
case shimast.KindImportDeclaration:
|
|
80
|
-
|
|
101
|
+
doc, covered := printImportDeclaration(ctx, node)
|
|
102
|
+
return doc, covered, true
|
|
103
|
+
case shimast.KindArrowFunction:
|
|
104
|
+
doc, covered := printArrowFunction(ctx, node)
|
|
105
|
+
return doc, covered, true
|
|
106
|
+
case shimast.KindFunctionExpression:
|
|
107
|
+
doc, covered := printFunctionExpression(ctx, node)
|
|
108
|
+
return doc, covered, true
|
|
109
|
+
case shimast.KindParenthesizedExpression:
|
|
110
|
+
doc, covered := printParenthesizedExpression(ctx, node)
|
|
111
|
+
return doc, covered, true
|
|
112
|
+
case shimast.KindBlock:
|
|
113
|
+
doc, covered := printBlock(ctx, node)
|
|
114
|
+
return doc, covered, true
|
|
115
|
+
case shimast.KindExpressionStatement:
|
|
116
|
+
doc, covered := printExpressionStatement(ctx, node)
|
|
117
|
+
return doc, covered, true
|
|
118
|
+
case shimast.KindReturnStatement:
|
|
119
|
+
doc, covered := printReturnStatement(ctx, node)
|
|
120
|
+
return doc, covered, true
|
|
81
121
|
}
|
|
82
122
|
return Doc{}, false, false
|
|
83
123
|
}
|
|
84
124
|
|
|
125
|
+
// nodeSpansMultipleLines reports whether `node`'s trivia-trimmed source
|
|
126
|
+
// range crosses a newline. A verbatim slice that stays on one line is
|
|
127
|
+
// always reflow-safe — there is no interior column for the enclosing
|
|
128
|
+
// re-indent to leave stranded — so the dispatcher treats single-line
|
|
129
|
+
// verbatim as `covered`. A multi-line verbatim node freezes its
|
|
130
|
+
// interior columns and is reported uncovered.
|
|
131
|
+
func nodeSpansMultipleLines(ctx *PrintContext, node *shimast.Node) bool {
|
|
132
|
+
if node == nil {
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
start := shimscanner.SkipTrivia(ctx.Source, node.Pos())
|
|
136
|
+
end := node.End()
|
|
137
|
+
if start < 0 || end < start || end > len(ctx.Source) {
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
for i := start; i < end; i++ {
|
|
141
|
+
if ctx.Source[i] == '\n' {
|
|
142
|
+
return true
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
|
|
85
148
|
// verbatim returns the original source bytes for `node`, leading trivia
|
|
86
149
|
// trimmed. Use this whenever a printer cannot fully cover a node — the
|
|
87
150
|
// surrounding doc tree still flows, but the verbatim slice carries
|
|
@@ -118,3 +181,38 @@ func (ctx *PrintContext) indentUnit() int {
|
|
|
118
181
|
}
|
|
119
182
|
return 2
|
|
120
183
|
}
|
|
184
|
+
|
|
185
|
+
// trailingCommaMode normalizes ctx.Opts.TrailingComma to one of "all",
|
|
186
|
+
// "es5", or "none". An empty value — the zero value for the field —
|
|
187
|
+
// reads as "all" to keep pre-existing callers and tests that built a
|
|
188
|
+
// PrintOptions{} without setting the field on Prettier's default
|
|
189
|
+
// behavior. Any other value also reads as "all"; the config layer
|
|
190
|
+
// rejects unknown strings before they reach the printer (see
|
|
191
|
+
// `expandFormatBlock` in config_format.go), so a stray value here is a
|
|
192
|
+
// programmer error and the safest fallback is the most-commas mode.
|
|
193
|
+
func (ctx *PrintContext) trailingCommaMode() string {
|
|
194
|
+
switch ctx.Opts.TrailingComma {
|
|
195
|
+
case "es5", "none":
|
|
196
|
+
return ctx.Opts.TrailingComma
|
|
197
|
+
}
|
|
198
|
+
return "all"
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// allowsCallArgumentTrailingComma reports whether a multi-line call /
|
|
202
|
+
// new argument list should emit a trailing comma under the current
|
|
203
|
+
// trailingComma setting. Trailing commas in call arguments arrived in
|
|
204
|
+
// ES2017, so Prettier's "es5" mode excludes them just like "none" does;
|
|
205
|
+
// only "all" keeps them. Parameter lists are not printed by the
|
|
206
|
+
// dispatcher today, but the same rule applies to them when they are.
|
|
207
|
+
func (ctx *PrintContext) allowsCallArgumentTrailingComma() bool {
|
|
208
|
+
return ctx.trailingCommaMode() == "all"
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// allowsEs5TrailingComma reports whether a multi-line ES5-permitted
|
|
212
|
+
// list — arrays, objects, named imports / exports — should emit a
|
|
213
|
+
// trailing comma. Only "none" suppresses the comma here; "es5" and
|
|
214
|
+
// "all" both keep it because ES5 has accepted trailing commas in these
|
|
215
|
+
// positions since the language's inception.
|
|
216
|
+
func (ctx *PrintContext) allowsEs5TrailingComma() bool {
|
|
217
|
+
return ctx.trailingCommaMode() != "none"
|
|
218
|
+
}
|
package/linthost/print_doc.go
CHANGED
|
@@ -52,6 +52,9 @@ package linthost
|
|
|
52
52
|
// - DocLineSuffix queues output until the next hardline/softline that
|
|
53
53
|
// actually breaks; used for trailing line comments that must stick
|
|
54
54
|
// to their source line.
|
|
55
|
+
// - DocConditionalGroup offers an ordered list of layout options; the
|
|
56
|
+
// engine renders the first whose first line fits the width budget
|
|
57
|
+
// and uses the last option as the unconditional fallback.
|
|
55
58
|
//
|
|
56
59
|
// The doc tree is built by helper constructors (Text, Line, Group, …)
|
|
57
60
|
// below. Constructors take their children as variadic or slice
|
|
@@ -75,6 +78,7 @@ const (
|
|
|
75
78
|
docIfBreak
|
|
76
79
|
docConcat
|
|
77
80
|
docLineSuffix
|
|
81
|
+
docConditionalGroup
|
|
78
82
|
)
|
|
79
83
|
|
|
80
84
|
// Doc is one node in the layout tree. Only the fields relevant to the
|
|
@@ -93,6 +97,11 @@ type Doc struct {
|
|
|
93
97
|
Text string
|
|
94
98
|
Children []Doc
|
|
95
99
|
Width int
|
|
100
|
+
// Break, meaningful only on a docGroup, forces the group to render
|
|
101
|
+
// broken regardless of whether its flat form would fit. A
|
|
102
|
+
// ConditionalGroup option uses it to commit its last argument — a
|
|
103
|
+
// hugged object literal — to the multi-line shape.
|
|
104
|
+
Break bool
|
|
96
105
|
// IfBreak pairs: BreakChild stored in Children[0], FlatChild in Children[1].
|
|
97
106
|
}
|
|
98
107
|
|
|
@@ -116,6 +125,16 @@ func Literalline() Doc { return Doc{Kind: docLiteralline} }
|
|
|
116
125
|
// concatenated.
|
|
117
126
|
func Group(parts ...Doc) Doc { return Doc{Kind: docGroup, Children: parts} }
|
|
118
127
|
|
|
128
|
+
// ConditionalGroup picks the first option whose first line fits the
|
|
129
|
+
// remaining width budget, falling back to the last option when none
|
|
130
|
+
// fit. Where Group makes a single flat-or-break decision, a
|
|
131
|
+
// ConditionalGroup lets a printer offer several distinct shapes — a
|
|
132
|
+
// call's hugged vs. exploded argument list — and have the engine choose
|
|
133
|
+
// between them. The last option must always be a safe fallback.
|
|
134
|
+
func ConditionalGroup(options ...Doc) Doc {
|
|
135
|
+
return Doc{Kind: docConditionalGroup, Children: options}
|
|
136
|
+
}
|
|
137
|
+
|
|
119
138
|
// Indent adds `width` columns of indentation to every newline emitted by
|
|
120
139
|
// the child doc. Nesting composes.
|
|
121
140
|
func Indent(width int, parts ...Doc) Doc {
|