@ttsc/lint 0.19.3 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/lib/index.js +26 -4
  3. package/lib/index.js.map +1 -1
  4. package/lib/structures/format/ITtscLintFormat.d.ts +14 -10
  5. package/lib/structures/rules/ITtscLintRegexpRules.d.ts +24 -9
  6. package/lib/structures/rules/ITtscLintSecurityRules.d.ts +18 -0
  7. package/lib/structures/rules/ITtscLintSolidRules.d.ts +22 -2
  8. package/lib/structures/rules/ITtscLintStorybookRules.d.ts +21 -0
  9. package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.d.ts +10 -1
  10. package/lib/structures/rules/ITtscLintTypeScriptRules.d.ts +13 -2
  11. package/linthost/compile.go +9 -4
  12. package/linthost/config.go +18 -14
  13. package/linthost/contrib_adapter.go +57 -0
  14. package/linthost/dispatch.go +5 -1
  15. package/linthost/display_width.go +179 -41
  16. package/linthost/engine.go +120 -4
  17. package/linthost/flags_gen.go +4 -4
  18. package/linthost/hints.go +207 -0
  19. package/linthost/host.go +68 -0
  20. package/linthost/lsp.go +148 -29
  21. package/linthost/print_dispatch.go +24 -0
  22. package/linthost/print_nodes_array.go +88 -15
  23. package/linthost/print_nodes_call.go +38 -31
  24. package/linthost/print_nodes_control_flow.go +319 -0
  25. package/linthost/print_nodes_function.go +140 -13
  26. package/linthost/print_nodes_list.go +22 -10
  27. package/linthost/project_engine.go +18 -1
  28. package/linthost/project_rules.go +47 -0
  29. package/linthost/rule_docs.go +114 -0
  30. package/linthost/rules_boundaries.go +80 -2
  31. package/linthost/rules_format_bracket_spacing.go +18 -6
  32. package/linthost/rules_format_clause_join.go +11 -8
  33. package/linthost/rules_format_indent.go +155 -4
  34. package/linthost/rules_format_print_width.go +12 -35
  35. package/linthost/rules_format_quote_props.go +88 -27
  36. package/linthost/rules_format_statement_split.go +81 -0
  37. package/linthost/rules_format_trailing_comma.go +62 -94
  38. package/linthost/rules_gap.go +26 -17
  39. package/linthost/rules_jsdoc.go +54 -0
  40. package/linthost/rules_logic.go +30 -15
  41. package/linthost/rules_no_redeclare.go +12 -3
  42. package/linthost/rules_promise.go +37 -15
  43. package/linthost/rules_regexp.go +443 -49
  44. package/linthost/rules_security.go +243 -4
  45. package/linthost/rules_solid.go +430 -10
  46. package/linthost/rules_storybook.go +255 -10
  47. package/linthost/rules_suggestions.go +60 -39
  48. package/linthost/rules_ts.go +7 -0
  49. package/linthost/rules_ts_async.go +80 -2
  50. package/linthost/rules_ts_extra.go +21 -7
  51. package/linthost/serve.go +318 -0
  52. package/linthost/width_tables_gen.go +219 -0
  53. package/package.json +2 -2
  54. package/rule/hint.go +142 -0
  55. package/rule/rule.go +197 -1
  56. package/src/index.ts +35 -4
  57. package/src/structures/format/ITtscLintFormat.ts +14 -10
  58. package/src/structures/rules/ITtscLintRegexpRules.ts +24 -9
  59. package/src/structures/rules/ITtscLintSecurityRules.ts +18 -0
  60. package/src/structures/rules/ITtscLintSolidRules.ts +22 -2
  61. package/src/structures/rules/ITtscLintStorybookRules.ts +21 -0
  62. package/src/structures/rules/ITtscLintTypeScriptRuleOptions.ts +10 -1
  63. package/src/structures/rules/ITtscLintTypeScriptRules.ts +13 -2
@@ -0,0 +1,207 @@
1
+ package linthost
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+
7
+ publicrule "github.com/samchon/ttsc/packages/lint/rule"
8
+ )
9
+
10
+ // RunLSPHints prints the editor-completion corpus every project rule published
11
+ // for this project, as JSON.
12
+ //
13
+ // Unlike the other LSP verbs this takes no `--uri`: a corpus describes the
14
+ // Program, not a document. It loads a Program only when the resolved config
15
+ // declares a rule that can publish one, because a corpus is a projection of what
16
+ // a project rule's Check found — which is why the caller is expected to cache
17
+ // the answer and ask again only when the Program's inputs changed, never per
18
+ // editor request.
19
+ //
20
+ // An empty corpus is a successful answer. A project with no hint-publishing rule
21
+ // is the common case, and a caller must be able to tell it apart from a failure;
22
+ // a nonzero exit here would read as "the project is broken".
23
+ func RunLSPHints(args []string) int {
24
+ opts, ok := parseLSPCommandOptions("lsp-hints", args)
25
+ if !ok {
26
+ return 2
27
+ }
28
+ hints, code := computeLSPHints(opts)
29
+ if code != 0 {
30
+ return code
31
+ }
32
+ return writeJSON(hints)
33
+ }
34
+
35
+ // computeLSPHints builds the corpus for one project. Split from RunLSPHints so
36
+ // the resident lsp-serve loop reuses it over a warm Program, the same split
37
+ // computeLSPDiagnostics and computeLSPCodeActions already take.
38
+ func computeLSPHints(opts *lspCommandOptions) ([]publicrule.Hint, int) {
39
+ rules, err := loadRules(opts.pluginsJSON, opts.cwd, opts.tsconfig)
40
+ if err != nil {
41
+ fmt.Fprintln(os.Stderr, err)
42
+ return nil, 2
43
+ }
44
+ engine := NewEngineWithResolver(rules)
45
+ if err := engine.ConfigError(); err != nil {
46
+ fmt.Fprintln(os.Stderr, err)
47
+ return nil, 2
48
+ }
49
+ publishes, needsChecker := engine.hasHintPublisher()
50
+ if !publishes {
51
+ // Nothing the config declared can publish a corpus, so there is no
52
+ // projection to take and the Program is never built. A project with no
53
+ // hint-publishing rule is the common case, and it used to pay a full parse
54
+ // and bind — and a checker build, when any unrelated file rule was
55
+ // type-aware — to arrive at this same empty answer.
56
+ return []publicrule.Hint{}, 0
57
+ }
58
+ prog, parseDiags, closeProgram, err := acquireProgram(opts, needsChecker)
59
+ if closeProgram != nil {
60
+ defer closeProgram()
61
+ }
62
+ if err != nil {
63
+ fmt.Fprintf(os.Stderr, "@ttsc/lint: %v\n", err)
64
+ return nil, 2
65
+ }
66
+ if prog == nil || len(parseDiags) > 0 {
67
+ // The project does not parse right now. Rules never ran, so there is no
68
+ // corpus — but these are tsgo's diagnostics to own, and failing here would
69
+ // make an editor treat a syntax error mid-typing as a broken plugin. A nil
70
+ // Program without diagnostics reaches the same answer rather than a JSON
71
+ // null, which a caller decoding an array would read as a broken plugin too.
72
+ return []publicrule.Hint{}, 0
73
+ }
74
+ return collectProjectHints(prog.runProjectCycle(engine)), 0
75
+ }
76
+
77
+ // hasHintPublisher reports whether any declared project rule can publish a
78
+ // corpus, and whether serving those rules needs a type checker.
79
+ //
80
+ // Both answers come from the registration table and the resolved config, so
81
+ // they are available before a Program exists — which is the point. A rule that
82
+ // the config never declared, or turned off, cannot contribute to the corpus,
83
+ // and collectProjectHints would skip it after the Program was built anyway.
84
+ //
85
+ // The checker answer is deliberately narrower than Engine.NeedsTypeChecker.
86
+ // That flag is engine-wide, so one type-aware *file* rule would make this verb
87
+ // build a checker no corpus reads. Here a checker is requested only when a rule
88
+ // that actually publishes hints declined to say it needs none — the same
89
+ // conservative default projectRuleNeedsTypeChecker applies everywhere else.
90
+ func (e *Engine) hasHintPublisher() (publishes bool, needsChecker bool) {
91
+ if e == nil {
92
+ return false, false
93
+ }
94
+ for _, name := range allProjectRuleNames() {
95
+ setting := e.projectSettings[name]
96
+ if !setting.Declared || setting.Severity == SeverityOff {
97
+ continue
98
+ }
99
+ adapter, registered := registeredProjectRules[name]
100
+ if !registered {
101
+ continue
102
+ }
103
+ if _, publisher := adapter.inner.(publicrule.HintRule); !publisher {
104
+ continue
105
+ }
106
+ publishes = true
107
+ if projectRuleNeedsTypeChecker(name) {
108
+ // Every publisher's need is folded in rather than returning early: one
109
+ // rule declining a checker does not spare a sibling that reads one.
110
+ needsChecker = true
111
+ }
112
+ }
113
+ return publishes, needsChecker
114
+ }
115
+
116
+ // collectProjectHints gathers the editor-completion corpus every declared
117
+ // project rule published for this Program.
118
+ //
119
+ // It runs after evaluateProject rather than inside it, because a corpus is a
120
+ // projection of finished state: asking mid-check would publish whatever the rule
121
+ // had indexed so far. Nothing calls this during `ttsc check`, so a rule's Hints
122
+ // is never invoked, and its corpus never allocated, unless an editor asks.
123
+ //
124
+ // A rule is asked only when it passed and published state — the same gate a file
125
+ // rule writes by hand against ProjectRulePassed. A rule that failed produced a
126
+ // corpus describing a Program it just rejected, and offering that would complete
127
+ // against facts the rule itself disowns.
128
+ func collectProjectHints(cycle *projectCycle) []publicrule.Hint {
129
+ if cycle == nil || cycle.results == nil {
130
+ return nil
131
+ }
132
+ hints := []publicrule.Hint{}
133
+ for _, name := range allProjectRuleNames() {
134
+ result, exists := cycle.results.byName[name]
135
+ if !exists || result.reporter == nil {
136
+ continue
137
+ }
138
+ adapter, registered := registeredProjectRules[name]
139
+ if !registered {
140
+ continue
141
+ }
142
+ provider, ok := adapter.inner.(publicrule.HintRule)
143
+ if !ok {
144
+ continue
145
+ }
146
+ snapshot := result.reporter.snapshot()
147
+ if snapshot.Status != publicrule.ProjectRulePassed || snapshot.State == nil {
148
+ continue
149
+ }
150
+ hints = append(hints, ruleHints(name, provider, result, snapshot)...)
151
+ }
152
+ return hints
153
+ }
154
+
155
+ // ruleHints calls one rule's Hints behind a recover barrier and drops the hints
156
+ // a rule cannot have meant.
157
+ //
158
+ // The barrier matches the metadata-inspection contract: a contributor panicking
159
+ // while describing itself loses its contribution rather than the process. This
160
+ // path is worse than most to leave unguarded — it runs inside an editor session,
161
+ // where a crash is not a failed build but a language server that stopped
162
+ // answering.
163
+ func ruleHints(
164
+ name string,
165
+ provider publicrule.HintRule,
166
+ result projectCycleResult,
167
+ snapshot publicrule.ProjectRuleResult,
168
+ ) (hints []publicrule.Hint) {
169
+ defer func() {
170
+ if recovered := recover(); recovered != nil {
171
+ fmt.Fprintf(
172
+ os.Stderr,
173
+ "@ttsc/lint: project rule %q panicked while publishing hints: %v; dropping its corpus\n",
174
+ name,
175
+ recovered,
176
+ )
177
+ hints = nil
178
+ }
179
+ }()
180
+ published := provider.Hints(&publicrule.HintContext{
181
+ Identity: result.identity,
182
+ State: snapshot.State,
183
+ Severity: publicrule.Severity(result.severity),
184
+ Options: result.options,
185
+ })
186
+ kept := make([]publicrule.Hint, 0, len(published))
187
+ for _, hint := range published {
188
+ if !usableHint(hint) {
189
+ continue
190
+ }
191
+ kept = append(kept, hint)
192
+ }
193
+ return kept
194
+ }
195
+
196
+ // usableHint reports whether a hint can be offered at all.
197
+ //
198
+ // A hint with no scope would apply anywhere on any line, which is never what a
199
+ // rule meant — it would surface in every decorator and every string literal — so
200
+ // it is dropped rather than honored. A hint with no After is the same mistake
201
+ // spelled differently: it matches every line in scope, so the corpus fires on an
202
+ // empty doc comment. An empty Insert completes to nothing.
203
+ func usableHint(hint publicrule.Hint) bool {
204
+ return hint.Insert != "" &&
205
+ hint.Trigger.Scope != "" &&
206
+ hint.Trigger.After != ""
207
+ }
package/linthost/host.go CHANGED
@@ -257,6 +257,23 @@ func resolveProjectPathAncestor(target string) (string, bool) {
257
257
  }
258
258
  }
259
259
 
260
+ // runProjectCycle evaluates the project rules alone and returns the cycle,
261
+ // without walking a single file.
262
+ //
263
+ // A consumer wanting only what project rules produced — a hint corpus, say —
264
+ // should not pay for the file walk that produces findings it will throw away.
265
+ // The cycle is memoized on the program exactly as runLintCycle memoizes it, so
266
+ // asking for hints and then linting the same program evaluates each rule once.
267
+ func (p *program) runProjectCycle(engine *Engine) *projectCycle {
268
+ if p == nil || engine == nil {
269
+ return nil
270
+ }
271
+ if p.projectCycle == nil {
272
+ p.projectCycle = engine.evaluateProject(p.identity, p.userSourceFiles(), p.checker)
273
+ }
274
+ return p.projectCycle
275
+ }
276
+
260
277
  func (p *program) runLintCycle(engine *Engine) []*Finding {
261
278
  if p == nil || engine == nil {
262
279
  return nil
@@ -278,6 +295,57 @@ func (p *program) close() {
278
295
  p.checker = nil
279
296
  }
280
297
 
298
+ // sourceFileByPath returns the resident Program's source file whose name matches
299
+ // absPath (slash-normalized), or nil when the Program has no such file. Mirrors
300
+ // how driver.Program.SourceFile resolves a path over SourceFiles().
301
+ func (p *program) sourceFileByPath(absPath string) *shimast.SourceFile {
302
+ if p == nil || p.tsProgram == nil {
303
+ return nil
304
+ }
305
+ normalized := filepath.ToSlash(absPath)
306
+ for _, file := range p.tsProgram.SourceFiles() {
307
+ if file == nil {
308
+ continue
309
+ }
310
+ if filepath.ToSlash(file.FileName()) == normalized {
311
+ return file
312
+ }
313
+ }
314
+ return nil
315
+ }
316
+
317
+ // applyChange incrementally updates the resident Program for one changed file:
318
+ // tsgo re-parses only that file and reuses every other file's AST, and the
319
+ // standalone lint checker is rebuilt over the new Program. It mirrors
320
+ // driver.Session.Apply, adapted to lint's standalone serial checker (lint cannot
321
+ // borrow tsgo's pooled checker — see loadProgram). The caller has already
322
+ // confirmed absPath is a known source file; a config edit or a new/removed file
323
+ // is handled by a full reload upstream, not here. tsgo returns a rebuilt Program
324
+ // when the edit reshaped the import graph, and that rebuilt Program is still
325
+ // correct, so the reused flag is not consulted.
326
+ func (p *program) applyChange(absPath string) {
327
+ if p == nil || p.tsProgram == nil {
328
+ return
329
+ }
330
+ name := absPath
331
+ if file := p.sourceFileByPath(absPath); file != nil {
332
+ name = file.FileName()
333
+ }
334
+ fs := bundled.WrapFS(cachedvfs.From(osvfs.FS()))
335
+ host := shimcompiler.NewCompilerHost(p.cwd, fs, bundled.LibPath(), nil, nil)
336
+ changed := shimtspath.ToPath(name, p.cwd, fs.UseCaseSensitiveFileNames())
337
+ newProg, _ := p.tsProgram.UpdateProgram(changed, host, nil)
338
+ if newProg != nil {
339
+ p.tsProgram = newProg
340
+ if p.checker != nil {
341
+ p.checker, _ = shimchecker.NewChecker(newProg, nil)
342
+ }
343
+ }
344
+ // The prior cycle described the pre-edit Program; drop it so the next verb
345
+ // re-evaluates its rules over the updated ASTs.
346
+ p.projectCycle = nil
347
+ }
348
+
281
349
  // userSourceFiles returns the tsconfig-selected source files the lint engine
282
350
  // owns. The tsconfig file list is the boundary: imported libraries, generated
283
351
  // output, and JSON modules may still appear in Program.SourceFiles(), but lint
package/linthost/lsp.go CHANGED
@@ -50,11 +50,39 @@ type lspRangeWire struct {
50
50
  }
51
51
 
52
52
  type lspDiagnostic struct {
53
- Range lspRange `json:"range"`
54
- Severity int `json:"severity,omitempty"`
55
- Code string `json:"code,omitempty"`
56
- Source string `json:"source,omitempty"`
57
- Message string `json:"message"`
53
+ Range lspRange `json:"range"`
54
+ Severity int `json:"severity,omitempty"`
55
+ Code string `json:"code,omitempty"`
56
+ CodeDescription *lspCodeDescription `json:"codeDescription,omitempty"`
57
+ Source string `json:"source,omitempty"`
58
+ Message string `json:"message"`
59
+ Tags []int `json:"tags,omitempty"`
60
+ RelatedInformation []lspRelatedInformation `json:"relatedInformation,omitempty"`
61
+ }
62
+
63
+ // lspLocation is the LSP Location: a URI plus a range inside it. The lint
64
+ // sidecar only ever emits locations in the file being linted, so the URI is
65
+ // always that file's, but the field is present because LSP requires it on every
66
+ // related location.
67
+ type lspLocation struct {
68
+ URI string `json:"uri"`
69
+ Range lspRange `json:"range"`
70
+ }
71
+
72
+ // lspRelatedInformation is one entry of a diagnostic's relatedInformation: a
73
+ // secondary location the finding points at, with a message naming the link. The
74
+ // editor renders each as a clickable line under the diagnostic.
75
+ type lspRelatedInformation struct {
76
+ Location lspLocation `json:"location"`
77
+ Message string `json:"message"`
78
+ }
79
+
80
+ // lspCodeDescription mirrors the proxy's LSPCodeDescription: a docs URL for the
81
+ // diagnostic's Code, which editors surface as the link on the rule id in the
82
+ // Problems panel. ruleDocumentationURL derives the href per rule family (see
83
+ // rule_docs.go); rules with no vetted page leave the whole field absent.
84
+ type lspCodeDescription struct {
85
+ Href string `json:"href"`
58
86
  }
59
87
 
60
88
  type lspCommand struct {
@@ -123,18 +151,29 @@ type lspCommandOptions struct {
123
151
  projectIdentity publicrule.ProjectIdentity
124
152
  }
125
153
 
126
- // RunLSPCommandIDs prints the workspace/executeCommand ids owned by @ttsc/lint.
127
- func RunLSPCommandIDs([]string) int {
128
- return writeJSON([]string{
154
+ // lspCommandIDs is the workspace/executeCommand ids owned by @ttsc/lint, shared
155
+ // by the one-shot verb and the resident lsp-serve loop.
156
+ func lspCommandIDs() []string {
157
+ return []string{
129
158
  commandLintFixAll,
130
159
  commandLintApplySuggestion,
131
160
  commandFormatDocument,
132
- })
161
+ }
162
+ }
163
+
164
+ // lspCodeActionKinds is the CodeActionKind values @ttsc/lint may return.
165
+ func lspCodeActionKinds() []string {
166
+ return []string{"quickfix.ttsc", "source.fixAll.ttsc", "source.format"}
167
+ }
168
+
169
+ // RunLSPCommandIDs prints the workspace/executeCommand ids owned by @ttsc/lint.
170
+ func RunLSPCommandIDs([]string) int {
171
+ return writeJSON(lspCommandIDs())
133
172
  }
134
173
 
135
174
  // RunLSPCodeActionKinds prints the CodeActionKind values @ttsc/lint may return.
136
175
  func RunLSPCodeActionKinds([]string) int {
137
- return writeJSON([]string{"quickfix.ttsc", "source.fixAll.ttsc", "source.format"})
176
+ return writeJSON(lspCodeActionKinds())
138
177
  }
139
178
 
140
179
  // RunLSPDiagnostics prints lint diagnostics for one file URI as LSP JSON.
@@ -143,12 +182,23 @@ func RunLSPDiagnostics(args []string) int {
143
182
  if !ok {
144
183
  return 2
145
184
  }
185
+ result, code := computeLSPDiagnostics(opts)
186
+ if code != 0 {
187
+ return code
188
+ }
189
+ return writeJSON(result)
190
+ }
191
+
192
+ // computeLSPDiagnostics builds the diagnostics result for one file URI. Split
193
+ // from RunLSPDiagnostics so the resident lsp-serve loop can produce the same
194
+ // result against a warm Program without re-parsing per verb.
195
+ func computeLSPDiagnostics(opts *lspCommandOptions) (lspDiagnosticsResult, int) {
146
196
  findings, prog, closeProgram, code := lspFindings(opts, false)
147
197
  if closeProgram != nil {
148
198
  defer closeProgram()
149
199
  }
150
200
  if code != 0 {
151
- return code
201
+ return lspDiagnosticsResult{}, code
152
202
  }
153
203
  result := lspDiagnosticsResult{Document: []lspDiagnostic{}}
154
204
  for _, finding := range filterFindingsForPath(findings, mustFilePathFromURI(opts.uri)) {
@@ -164,7 +214,7 @@ func RunLSPDiagnostics(args []string) int {
164
214
  }
165
215
  result.Project = publication
166
216
  }
167
- return writeJSON(result)
217
+ return result, 0
168
218
  }
169
219
 
170
220
  // RunLSPCodeActions prints code actions available for one file URI/range.
@@ -173,6 +223,17 @@ func RunLSPCodeActions(args []string) int {
173
223
  if !ok {
174
224
  return 2
175
225
  }
226
+ actions, code := computeLSPCodeActions(opts)
227
+ if code != 0 {
228
+ return code
229
+ }
230
+ return writeJSON(actions)
231
+ }
232
+
233
+ // computeLSPCodeActions builds the code actions for one file URI/range. Split
234
+ // from RunLSPCodeActions so the resident lsp-serve loop reuses it over a warm
235
+ // Program.
236
+ func computeLSPCodeActions(opts *lspCommandOptions) ([]lspCodeAction, int) {
176
237
  acceptsLint := acceptsActionKind(opts.contextJSON, "source.fixAll.ttsc")
177
238
  acceptsQuickFix := acceptsActionKind(opts.contextJSON, "quickfix.ttsc")
178
239
  acceptsFormat := acceptsActionKind(opts.contextJSON, "source.format")
@@ -181,20 +242,20 @@ func RunLSPCodeActions(args []string) int {
181
242
  acceptsQuickFix = false
182
243
  }
183
244
  if !acceptsLint && !acceptsQuickFix && !acceptsFormat {
184
- return writeJSON([]lspCodeAction{})
245
+ return []lspCodeAction{}, 0
185
246
  }
186
247
  if lspProjectTargetHasSegment(opts, "node_modules") {
187
- return writeJSON([]lspCodeAction{})
248
+ return []lspCodeAction{}, 0
188
249
  }
189
250
  if lspProjectTargetOutsideCwd(opts) {
190
- return writeJSON([]lspCodeAction{})
251
+ return []lspCodeAction{}, 0
191
252
  }
192
253
  findings, _, closeProgram, code := lspFindings(opts, acceptsFormat)
193
254
  if closeProgram != nil {
194
255
  defer closeProgram()
195
256
  }
196
257
  if code != 0 {
197
- return code
258
+ return nil, code
198
259
  }
199
260
  findings = filterFindingsForPath(findings, mustFilePathFromURI(opts.uri))
200
261
  lintFindings := filterLintFindings(findings)
@@ -228,7 +289,7 @@ func RunLSPCodeActions(args []string) int {
228
289
  },
229
290
  })
230
291
  }
231
- return writeJSON(actions)
292
+ return actions, 0
232
293
  }
233
294
 
234
295
  // RunLSPExecuteCommand returns a WorkspaceEdit for a lint-owned command.
@@ -347,21 +408,17 @@ func lspFindings(opts *lspCommandOptions, includeFormatDefaults bool) ([]*Findin
347
408
  fmt.Fprintln(os.Stderr, err)
348
409
  return nil, nil, nil, 2
349
410
  }
350
- prog, parseDiags, err := loadProgram(opts.cwd, opts.tsconfig, loadProgramOptions{
351
- forceNoEmit: true,
352
- needsRuleChecker: engine.NeedsTypeChecker(),
353
- projectIdentity: opts.projectIdentity,
354
- })
411
+ prog, parseDiags, closeProgram, err := acquireProgram(opts, engine.NeedsTypeChecker())
355
412
  if err != nil {
356
413
  fmt.Fprintf(os.Stderr, "@ttsc/lint: %v\n", err)
357
414
  return nil, nil, nil, 2
358
415
  }
359
416
  if len(parseDiags) > 0 {
360
417
  // tsgo already owns parse diagnostics in the upstream LSP process.
361
- return nil, prog, prog.close, 0
418
+ return nil, prog, closeProgram, 0
362
419
  }
363
420
  findings := prog.runLintCycle(engine)
364
- return findings, prog, prog.close, 0
421
+ return findings, prog, closeProgram, 0
365
422
  }
366
423
 
367
424
  // loadLSPCommandRules constructs the resolver shared by every LSP command
@@ -415,14 +472,65 @@ func filterFindingsForPath(findings []*Finding, target string) []*Finding {
415
472
 
416
473
  func findingToLSPDiagnostic(finding *Finding) lspDiagnostic {
417
474
  return lspDiagnostic{
418
- Range: lspRangeForFinding(finding),
419
- Severity: lspSeverity(finding.Severity),
420
- Code: finding.Rule,
421
- Source: "@ttsc/lint",
422
- Message: finding.Message,
475
+ Range: lspRangeForFinding(finding),
476
+ Severity: lspSeverity(finding.Severity),
477
+ Code: finding.Rule,
478
+ CodeDescription: lspCodeDescriptionForRule(finding.Rule),
479
+ Source: "@ttsc/lint",
480
+ Message: finding.Message,
481
+ Tags: lspDiagnosticTags(finding.Tags),
482
+ RelatedInformation: lspRelatedInformationForFinding(finding),
423
483
  }
424
484
  }
425
485
 
486
+ // lspRelatedInformationForFinding renders the finding's related locations. Each
487
+ // location lives in the finding's own file, so it resolves the byte ranges
488
+ // against that file's text and stamps the file's URI onto every entry. Returns
489
+ // nil for a finding with no related locations, keeping the omitempty field
490
+ // absent rather than an empty array.
491
+ func lspRelatedInformationForFinding(finding *Finding) []lspRelatedInformation {
492
+ if finding == nil || len(finding.RelatedInformation) == 0 || finding.File == nil {
493
+ return nil
494
+ }
495
+ text := finding.File.Text()
496
+ uri := fileURL(finding.File.FileName())
497
+ out := make([]lspRelatedInformation, 0, len(finding.RelatedInformation))
498
+ for _, item := range finding.RelatedInformation {
499
+ out = append(out, lspRelatedInformation{
500
+ Location: lspLocation{
501
+ URI: uri,
502
+ Range: lspRange{
503
+ Start: byteOffsetToLSPPosition(text, item.Pos),
504
+ End: byteOffsetToLSPPosition(text, item.End),
505
+ },
506
+ },
507
+ Message: item.Message,
508
+ })
509
+ }
510
+ return out
511
+ }
512
+
513
+ // lspDiagnosticTags converts the rule-supplied tags to their integer wire
514
+ // values, dropping any the wire does not define so a future rule.DiagnosticTag
515
+ // value cannot ship an integer no editor understands. Returns nil for an empty
516
+ // set, keeping the omitempty field absent rather than an empty array.
517
+ func lspDiagnosticTags(tags []publicrule.DiagnosticTag) []int {
518
+ if len(tags) == 0 {
519
+ return nil
520
+ }
521
+ out := make([]int, 0, len(tags))
522
+ for _, tag := range tags {
523
+ switch tag {
524
+ case publicrule.DiagnosticTagUnnecessary, publicrule.DiagnosticTagDeprecated:
525
+ out = append(out, int(tag))
526
+ }
527
+ }
528
+ if len(out) == 0 {
529
+ return nil
530
+ }
531
+ return out
532
+ }
533
+
426
534
  func lspRangeForFinding(finding *Finding) lspRange {
427
535
  text := ""
428
536
  if finding != nil && finding.File != nil {
@@ -1297,6 +1405,17 @@ func filePathFromURI(raw string) (string, error) {
1297
1405
  return abs, nil
1298
1406
  }
1299
1407
 
1408
+ // byteOffsetToLSPPosition converts a byte offset in the linted file into the LSP
1409
+ // Position the editor is given for it: a 0-based line and a column counted in
1410
+ // UTF-16 code units.
1411
+ //
1412
+ // UTF-16 is both the LSP default and the encoding the sidecar protocol fixes:
1413
+ // the host settles the session's PositionEncodingKind negotiation on UTF-16 so a
1414
+ // sidecar always emits UTF-16 columns and always receives them, which is why no
1415
+ // request or response on this wire carries an encoding field. The compiler's own
1416
+ // ranges for a line and the ranges emitted here therefore agree. See the LSP
1417
+ // sidecar verb section of
1418
+ // website/src/content/docs/development/concepts/protocol.mdx.
1300
1419
  func byteOffsetToLSPPosition(text string, offset int) lspPosition {
1301
1420
  if offset < 0 {
1302
1421
  offset = 0
@@ -121,6 +121,30 @@ func dispatchNode(ctx *PrintContext, node *shimast.Node) (Doc, bool, bool) {
121
121
  case shimast.KindConditionalExpression:
122
122
  doc, covered := printConditionalExpression(ctx, node)
123
123
  return doc, covered, true
124
+ case shimast.KindPropertyAssignment,
125
+ shimast.KindMethodDeclaration,
126
+ shimast.KindGetAccessor,
127
+ shimast.KindSetAccessor:
128
+ doc, covered := printObjectMember(ctx, node)
129
+ return doc, covered, true
130
+ case shimast.KindForStatement,
131
+ shimast.KindForOfStatement,
132
+ shimast.KindForInStatement,
133
+ shimast.KindWhileStatement,
134
+ shimast.KindIfStatement,
135
+ shimast.KindTryStatement,
136
+ shimast.KindSwitchStatement:
137
+ doc, covered := printControlFlowStatement(ctx, node)
138
+ return doc, covered, true
139
+ case shimast.KindCaseBlock:
140
+ doc, covered := printSwitchCaseBlock(ctx, node)
141
+ return doc, covered, true
142
+ case shimast.KindVariableStatement:
143
+ doc, covered := printVariableStatement(ctx, node)
144
+ return doc, covered, true
145
+ case shimast.KindThrowStatement:
146
+ doc, covered := printThrowStatement(ctx, node)
147
+ return doc, covered, true
124
148
  }
125
149
  return Doc{}, false, false
126
150
  }