ttsc 0.11.0 → 0.12.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 (79) hide show
  1. package/README.md +25 -6
  2. package/cmd/ttsc/api_compile.go +101 -106
  3. package/cmd/ttsc/api_compile_branches_test.go +81 -0
  4. package/cmd/ttsc/api_transform.go +49 -54
  5. package/cmd/ttsc/api_transform_branches_test.go +41 -0
  6. package/cmd/ttsc/build.go +108 -109
  7. package/cmd/ttsc/build_branches_test.go +130 -0
  8. package/cmd/ttsc/helpers_test.go +47 -0
  9. package/cmd/ttsc/main.go +104 -103
  10. package/cmd/ttsc/main_alias_branches_test.go +29 -0
  11. package/cmd/ttscserver/main.go +104 -96
  12. package/cmd/ttscserver/run_lsp_prefers_tsgo_flag_test.go +42 -0
  13. package/cmd/ttscserver/run_lsp_reports_server_error_test.go +23 -25
  14. package/cmd/ttscserver/run_lsp_uses_tsgo_environment_test.go +44 -0
  15. package/cmd/utility-host/main.go +42 -0
  16. package/driver/lsp.go +60 -0
  17. package/driver/plugins.go +122 -0
  18. package/driver/program.go +346 -356
  19. package/driver/rewrite.go +336 -339
  20. package/{driver → internal/lspserver}/lsp_envelope.go +70 -64
  21. package/{driver → internal/lspserver}/lsp_frame.go +62 -62
  22. package/{driver → internal/lspserver}/lsp_plugin.go +58 -58
  23. package/internal/lspserver/lsp_proxy.go +391 -0
  24. package/internal/lspserver/lsp_server.go +231 -0
  25. package/lib/TtscCompiler.d.ts +3 -3
  26. package/lib/TtscCompiler.js +20 -6
  27. package/lib/TtscCompiler.js.map +1 -1
  28. package/lib/compiler/internal/resolveTsgo.d.ts +1 -0
  29. package/lib/compiler/internal/resolveTsgo.js +15 -4
  30. package/lib/compiler/internal/resolveTsgo.js.map +1 -1
  31. package/lib/compiler/internal/runBuild.js +12 -4
  32. package/lib/compiler/internal/runBuild.js.map +1 -1
  33. package/lib/compiler/internal/sharedHostHelpers.d.ts +12 -33
  34. package/lib/compiler/internal/sharedHostHelpers.js +27 -70
  35. package/lib/compiler/internal/sharedHostHelpers.js.map +1 -1
  36. package/lib/compiler/internal/transformProjectInMemory.js +12 -5
  37. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  38. package/lib/index.d.ts +0 -1
  39. package/lib/index.js +0 -1
  40. package/lib/index.js.map +1 -1
  41. package/lib/launcher/internal/runTtsc.js +9 -4
  42. package/lib/launcher/internal/runTtsc.js.map +1 -1
  43. package/lib/launcher/internal/runTtscserver.d.ts +1 -0
  44. package/lib/launcher/internal/runTtscserver.js +38 -1
  45. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  46. package/lib/plugin/internal/buildSourcePlugin.d.ts +3 -0
  47. package/lib/plugin/internal/buildSourcePlugin.js +444 -41
  48. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  49. package/lib/plugin/internal/loadProjectPlugins.js +153 -34
  50. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  51. package/lib/structures/ITtscCompilerContext.d.ts +2 -1
  52. package/lib/structures/ITtscPlugin.d.ts +8 -13
  53. package/lib/structures/ITtscPluginFactoryContext.d.ts +4 -5
  54. package/lib/structures/ITtscProjectPluginConfig.d.ts +2 -2
  55. package/lib/structures/internal/ITtscLoadedNativePlugin.d.ts +4 -2
  56. package/package.json +8 -8
  57. package/shim/lsp/shim.go +11 -17
  58. package/src/TtscCompiler.ts +20 -7
  59. package/src/compiler/internal/resolveTsgo.ts +18 -5
  60. package/src/compiler/internal/runBuild.ts +17 -4
  61. package/src/compiler/internal/sharedHostHelpers.ts +34 -67
  62. package/src/compiler/internal/transformProjectInMemory.ts +18 -5
  63. package/src/index.ts +0 -1
  64. package/src/launcher/internal/runTtsc.ts +9 -4
  65. package/src/launcher/internal/runTtscserver.ts +43 -1
  66. package/src/plugin/internal/buildSourcePlugin.ts +475 -41
  67. package/src/plugin/internal/loadProjectPlugins.ts +199 -39
  68. package/src/structures/ITtscCompilerContext.ts +2 -1
  69. package/src/structures/ITtscPlugin.ts +9 -13
  70. package/src/structures/ITtscPluginFactoryContext.ts +4 -5
  71. package/src/structures/ITtscProjectPluginConfig.ts +2 -2
  72. package/src/structures/internal/ITtscLoadedNativePlugin.ts +4 -2
  73. package/utility/host.go +267 -1296
  74. package/driver/lsp_proxy.go +0 -394
  75. package/driver/lsp_server.go +0 -197
  76. package/lib/defineTtscPlugin.d.ts +0 -18
  77. package/lib/defineTtscPlugin.js +0 -7
  78. package/lib/defineTtscPlugin.js.map +0 -1
  79. package/src/defineTtscPlugin.ts +0 -26
@@ -0,0 +1,391 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "io"
10
+ "sync"
11
+ )
12
+
13
+ // ErrCommandNotHandled is returned by PluginSource.ExecuteCommand for
14
+ // commands ttsc does not own. Callers should fall through to upstream.
15
+ var ErrCommandNotHandled = errors.New("lsp: command not handled by ttsc")
16
+
17
+ const (
18
+ methodPublishDiagnostics = "textDocument/publishDiagnostics"
19
+ methodCodeAction = "textDocument/codeAction"
20
+ methodExecuteCommand = "workspace/executeCommand"
21
+ methodCancelRequest = "$/cancelRequest"
22
+ )
23
+
24
+ // ProxyOptions wires the byte-level proxy together. ttscserver creates
25
+ // the upstream pipes around `tsgo --lsp --stdio` and hands the proxy
26
+ // editor stdio plus those pipe ends.
27
+ type ProxyOptions struct {
28
+ EditorIn io.Reader
29
+ EditorOut io.Writer
30
+ UpstreamIn io.Writer // we write here; the tsgo LSP process reads
31
+ UpstreamOut io.Reader // the tsgo LSP process writes here; we read
32
+ Source PluginSource
33
+ }
34
+
35
+ // Proxy bridges the editor and an upstream tsgo LSP process, intercepting
36
+ // the message types ttsc cares about (publishDiagnostics merge, code
37
+ // action augmentation, executeCommand for ttsc-owned commands).
38
+ type Proxy struct {
39
+ editorIn io.Reader
40
+ editorOut io.Writer
41
+ upstreamIn io.Writer
42
+ upstreamOut io.Reader
43
+ source PluginSource
44
+
45
+ writeMu sync.Mutex // serializes WriteFrame calls to editorOut
46
+
47
+ pendingMu sync.Mutex
48
+ pendingActions map[string]pendingCodeActionRequest
49
+ }
50
+
51
+ type pendingCodeActionRequest struct {
52
+ uri string
53
+ rng LSPRange
54
+ ctx LSPCodeActionContext
55
+ }
56
+
57
+ // NewProxy returns a Proxy ready to Run. The PluginSource is required;
58
+ // pass NullPluginSource{} for a no-contribution setup.
59
+ func NewProxy(opts ProxyOptions) *Proxy {
60
+ source := opts.Source
61
+ if source == nil {
62
+ source = NullPluginSource{}
63
+ }
64
+ return &Proxy{
65
+ editorIn: opts.EditorIn,
66
+ editorOut: opts.EditorOut,
67
+ upstreamIn: opts.UpstreamIn,
68
+ upstreamOut: opts.UpstreamOut,
69
+ source: source,
70
+ pendingActions: make(map[string]pendingCodeActionRequest),
71
+ }
72
+ }
73
+
74
+ // Run drives both pump goroutines until they return. Pumps return when
75
+ // their input stream closes (ErrFrameClosed), when context cancellation
76
+ // has already been observed by the upstream/editor closers, or when a
77
+ // pipe write fails. ErrFrameClosed and context.Canceled are folded into
78
+ // a nil result so editor shutdown does not look like a crash.
79
+ func (p *Proxy) Run(ctx context.Context) error {
80
+ errCh := make(chan error, 2)
81
+ go func() { errCh <- p.pumpEditorToUpstream(ctx) }()
82
+ go func() { errCh <- p.pumpUpstreamToEditor(ctx) }()
83
+
84
+ var first error
85
+ for i := 0; i < 2; i++ {
86
+ err := <-errCh
87
+ if first == nil && err != nil && !errors.Is(err, ErrFrameClosed) {
88
+ first = err
89
+ }
90
+ }
91
+ return first
92
+ }
93
+
94
+ // pumpEditorToUpstream reads frames from the editor, decides whether to
95
+ // forward verbatim or handle locally (executeCommand for ttsc commands,
96
+ // codeAction request bookkeeping), and writes the chosen frame. When the
97
+ // editor closes its end (ErrFrameClosed) the pump closes the upstream
98
+ // writer so tsgo's server-side Read returns EOF and its Run loop drains;
99
+ // without that nudge tsgo would wait forever for more input.
100
+ func (p *Proxy) pumpEditorToUpstream(_ context.Context) error {
101
+ fr := NewFrameReader(p.editorIn)
102
+ for {
103
+ _, body, err := fr.Read()
104
+ if err != nil {
105
+ if errors.Is(err, ErrFrameClosed) {
106
+ p.closeUpstreamInput()
107
+ }
108
+ return err
109
+ }
110
+ env, parseErr := ParseEnvelope(body)
111
+ if parseErr != nil {
112
+ if forwardErr := WriteFrame(p.upstreamIn, body); forwardErr != nil {
113
+ return forwardErr
114
+ }
115
+ continue
116
+ }
117
+ handled, handleErr := p.handleEditorEnvelope(env)
118
+ if handleErr != nil {
119
+ return handleErr
120
+ }
121
+ if handled {
122
+ continue
123
+ }
124
+ if err := WriteFrame(p.upstreamIn, body); err != nil {
125
+ return err
126
+ }
127
+ }
128
+ }
129
+
130
+ // closeUpstreamInput closes the upstream writer (if it is also an
131
+ // io.Closer) so the upstream process reads io.EOF and exits its read loop.
132
+ // We type-assert because the public ProxyOptions surface promises only
133
+ // io.Writer; RunLSPServer always passes an *io.PipeWriter in practice.
134
+ func (p *Proxy) closeUpstreamInput() {
135
+ if closer, ok := p.upstreamIn.(io.Closer); ok {
136
+ _ = closer.Close()
137
+ }
138
+ }
139
+
140
+ // handleEditorEnvelope returns true if the envelope was fully processed
141
+ // locally (responded to without forwarding upstream).
142
+ func (p *Proxy) handleEditorEnvelope(env Envelope) (bool, error) {
143
+ switch env.Method {
144
+ case methodExecuteCommand:
145
+ if env.IsRequest() {
146
+ return p.tryExecuteCommand(env)
147
+ }
148
+ case methodCodeAction:
149
+ if env.IsRequest() {
150
+ p.rememberCodeActionRequest(env)
151
+ }
152
+ case methodCancelRequest:
153
+ // $/cancelRequest names an in-flight id the editor has given up on.
154
+ // The proxy drops any pending codeAction entry for that id so the
155
+ // map cannot grow unbounded across long editor sessions, then lets
156
+ // the notification continue to upstream.
157
+ p.forgetCancelledRequest(env)
158
+ }
159
+ return false, nil
160
+ }
161
+
162
+ // forgetCancelledRequest removes any pending codeAction entry whose id
163
+ // the editor cancelled. The notification still flows to upstream so
164
+ // tsgo can respond with its own cancellation error. The id is keyed
165
+ // through the shared normalizer so a cancel for `1.0` deletes an entry
166
+ // stored under `1` (and vice versa for string-vs-numeric encodings).
167
+ func (p *Proxy) forgetCancelledRequest(env Envelope) {
168
+ var params struct {
169
+ ID json.RawMessage `json:"id"`
170
+ }
171
+ if err := json.Unmarshal(env.Params, &params); err != nil {
172
+ return
173
+ }
174
+ key := idKeyFromRaw(params.ID)
175
+ if key == "" {
176
+ return
177
+ }
178
+ p.pendingMu.Lock()
179
+ defer p.pendingMu.Unlock()
180
+ delete(p.pendingActions, key)
181
+ }
182
+
183
+ // rememberCodeActionRequest stores the request payload so the matching
184
+ // response from upstream can be augmented with ttsc-owned code actions
185
+ // for the same range.
186
+ func (p *Proxy) rememberCodeActionRequest(env Envelope) {
187
+ var params struct {
188
+ TextDocument struct {
189
+ URI string `json:"uri"`
190
+ } `json:"textDocument"`
191
+ Range LSPRange `json:"range"`
192
+ Context LSPCodeActionContext `json:"context"`
193
+ }
194
+ if err := json.Unmarshal(env.Params, &params); err != nil {
195
+ return
196
+ }
197
+ p.pendingMu.Lock()
198
+ defer p.pendingMu.Unlock()
199
+ p.pendingActions[env.IDKey()] = pendingCodeActionRequest{
200
+ uri: params.TextDocument.URI,
201
+ rng: params.Range,
202
+ ctx: params.Context,
203
+ }
204
+ }
205
+
206
+ // tryExecuteCommand handles workspace/executeCommand requests whose
207
+ // command id is registered with the PluginSource. Returns true on a
208
+ // successful local response; false when the command should fall through
209
+ // to upstream tsgo.
210
+ func (p *Proxy) tryExecuteCommand(env Envelope) (bool, error) {
211
+ var params struct {
212
+ Command string `json:"command"`
213
+ Arguments []json.RawMessage `json:"arguments,omitempty"`
214
+ }
215
+ if err := json.Unmarshal(env.Params, &params); err != nil {
216
+ return false, nil
217
+ }
218
+ if !p.ownsCommand(params.Command) {
219
+ return false, nil
220
+ }
221
+ edit, err := p.source.ExecuteCommand(params.Command, params.Arguments)
222
+ if errors.Is(err, ErrCommandNotHandled) {
223
+ return false, nil
224
+ }
225
+ if err != nil {
226
+ return true, p.writeError(env.ID, fmt.Sprintf("ttsc command %q failed: %v", params.Command, err))
227
+ }
228
+ // Cycle 1 returns the WorkspaceEdit inside the executeCommand response
229
+ // instead of sending workspace/applyEdit as a server→client request.
230
+ // ttsc owns both ends (its VSCode extension), so the extension applies
231
+ // the edit on its side. Sticking to one direction avoids tracking our
232
+ // own outgoing request ids in the proxy.
233
+ if edit == nil {
234
+ return true, p.writeResult(env.ID, nil)
235
+ }
236
+ return true, p.writeResult(env.ID, edit)
237
+ }
238
+
239
+ func (p *Proxy) ownsCommand(command string) bool {
240
+ for _, id := range p.source.CommandIDs() {
241
+ if id == command {
242
+ return true
243
+ }
244
+ }
245
+ return false
246
+ }
247
+
248
+ // pumpUpstreamToEditor reads frames from the upstream tsgo server,
249
+ // augments publishDiagnostics and codeAction responses, and forwards
250
+ // every other frame untouched. The loop terminates on Read error; ctx
251
+ // propagation happens through pipe closure by RunLSPServer's deferred
252
+ // cleanup goroutines.
253
+ func (p *Proxy) pumpUpstreamToEditor(_ context.Context) error {
254
+ fr := NewFrameReader(p.upstreamOut)
255
+ for {
256
+ _, body, err := fr.Read()
257
+ if err != nil {
258
+ return err
259
+ }
260
+ env, parseErr := ParseEnvelope(body)
261
+ if parseErr != nil {
262
+ if forwardErr := p.writeEditorFrame(body); forwardErr != nil {
263
+ return forwardErr
264
+ }
265
+ continue
266
+ }
267
+ augmented := p.augmentUpstream(env, body)
268
+ if err := p.writeEditorFrame(augmented); err != nil {
269
+ return err
270
+ }
271
+ }
272
+ }
273
+
274
+ // augmentUpstream returns the (possibly rewritten) body to forward. For
275
+ // publishDiagnostics it merges ttsc plugin diagnostics; for codeAction
276
+ // responses tied to a remembered request it appends ttsc actions.
277
+ //
278
+ // Cancellation and response are handled in two independent goroutines
279
+ // (pumpEditorToUpstream owns $/cancelRequest cleanup, pumpUpstreamToEditor
280
+ // owns response augmentation). When the response wins the race against
281
+ // a pending cancel, pendingActions[env.IDKey()] is still populated and
282
+ // the response is augmented before forgetCancelledRequest runs — the
283
+ // editor is expected to discard the late response per LSP $/cancelRequest
284
+ // semantics. When the cancel wins, the pending entry is gone and
285
+ // augmentation skips cleanly.
286
+ func (p *Proxy) augmentUpstream(env Envelope, body []byte) []byte {
287
+ if env.IsNotification() && env.Method == methodPublishDiagnostics {
288
+ if merged, ok := p.mergePublishDiagnostics(env); ok {
289
+ return merged
290
+ }
291
+ }
292
+ if env.IsResponse() {
293
+ p.pendingMu.Lock()
294
+ pending, ok := p.pendingActions[env.IDKey()]
295
+ if ok {
296
+ delete(p.pendingActions, env.IDKey())
297
+ }
298
+ p.pendingMu.Unlock()
299
+ if ok {
300
+ if augmented, augOk := p.appendCodeActions(env, pending); augOk {
301
+ return augmented
302
+ }
303
+ }
304
+ }
305
+ return body
306
+ }
307
+
308
+ // mergePublishDiagnostics decodes the publishDiagnostics params, asks
309
+ // the PluginSource for the URI's contributions, appends them, and
310
+ // re-encodes the envelope. Returns the rewritten body and true on
311
+ // success; ok=false means "nothing to merge — forward the upstream body
312
+ // verbatim." Re-encoding our own types is guaranteed by their
313
+ // definitions in lsp_plugin.go, so the marshal errors are not checked.
314
+ func (p *Proxy) mergePublishDiagnostics(env Envelope) ([]byte, bool) {
315
+ var params struct {
316
+ URI string `json:"uri"`
317
+ Version *int `json:"version,omitempty"`
318
+ Diagnostics []json.RawMessage `json:"diagnostics"`
319
+ }
320
+ if err := json.Unmarshal(env.Params, &params); err != nil {
321
+ return nil, false
322
+ }
323
+ extras := p.source.Diagnostics(LSPDocumentVersion{URI: params.URI, Version: params.Version})
324
+ if len(extras) == 0 {
325
+ return nil, false
326
+ }
327
+ for _, extra := range extras {
328
+ raw, _ := json.Marshal(extra)
329
+ params.Diagnostics = append(params.Diagnostics, raw)
330
+ }
331
+ env.Params, _ = json.Marshal(params)
332
+ body, _ := json.Marshal(env)
333
+ return body, true
334
+ }
335
+
336
+ // appendCodeActions adds ttsc-owned code actions to a forwarded
337
+ // codeAction response. The response body is either an array of actions
338
+ // or null; we preserve that shape so editors that special-case null
339
+ // keep working. The function refuses to splice into error responses
340
+ // (JSON-RPC §5.1 forbids both `result` and `error` on the same frame)
341
+ // and into non-array results (the LSP base protocol mandates an array
342
+ // or null for codeAction; anything else means upstream returned a
343
+ // shape ttsc cannot splice into, so the proxy forwards verbatim
344
+ // rather than corrupting it).
345
+ func (p *Proxy) appendCodeActions(env Envelope, pending pendingCodeActionRequest) ([]byte, bool) {
346
+ if env.IsErrorResponse() {
347
+ return nil, false
348
+ }
349
+ trimmed := bytes.TrimSpace(env.Result)
350
+ if len(trimmed) > 0 && trimmed[0] != '[' && !bytes.Equal(trimmed, []byte("null")) {
351
+ return nil, false
352
+ }
353
+ actions := p.source.CodeActions(pending.uri, pending.rng, pending.ctx)
354
+ if len(actions) == 0 {
355
+ return nil, false
356
+ }
357
+ var existing []json.RawMessage
358
+ if len(trimmed) > 0 && !bytes.Equal(trimmed, []byte("null")) {
359
+ _ = json.Unmarshal(trimmed, &existing)
360
+ }
361
+ for _, action := range actions {
362
+ raw, _ := json.Marshal(action)
363
+ existing = append(existing, raw)
364
+ }
365
+ env.Result, _ = json.Marshal(existing)
366
+ body, _ := json.Marshal(env)
367
+ return body, true
368
+ }
369
+
370
+ func (p *Proxy) writeEditorFrame(body []byte) error {
371
+ p.writeMu.Lock()
372
+ defer p.writeMu.Unlock()
373
+ return WriteFrame(p.editorOut, body)
374
+ }
375
+
376
+ func (p *Proxy) writeResult(id json.RawMessage, result any) error {
377
+ rawResult, _ := json.Marshal(result)
378
+ env := Envelope{JSONRPC: "2.0", ID: id, Result: rawResult}
379
+ body, _ := json.Marshal(env)
380
+ return p.writeEditorFrame(body)
381
+ }
382
+
383
+ func (p *Proxy) writeError(id json.RawMessage, message string) error {
384
+ errPayload, _ := json.Marshal(struct {
385
+ Code int `json:"code"`
386
+ Message string `json:"message"`
387
+ }{Code: -32603, Message: message})
388
+ env := Envelope{JSONRPC: "2.0", ID: id, Error: errPayload}
389
+ body, _ := json.Marshal(env)
390
+ return p.writeEditorFrame(body)
391
+ }
@@ -0,0 +1,231 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "io"
8
+ "os/exec"
9
+ "path/filepath"
10
+ "runtime/debug"
11
+ "sync"
12
+ "time"
13
+ )
14
+
15
+ // ErrLSPUpstreamPanic wraps a panic recovered from inside an upstream
16
+ // runner. The production runner is an external tsgo process, but tests
17
+ // and embedders can still install in-process runners through
18
+ // WithUpstreamRunnerForTest.
19
+ var ErrLSPUpstreamPanic = errors.New("ttscserver: tsgo upstream runner panicked")
20
+
21
+ // RecoverPanicAs runs fn and converts a panic into an
22
+ // ErrLSPUpstreamPanic-wrapped error. RunLSPServer uses it around the
23
+ // upstream runner seam; the recovered stack is attached for diagnostics.
24
+ //
25
+ // recover() per the Go spec catches panics but NOT runtime.Goexit, so
26
+ // a Goexit raised from inside fn surfaces as a clean nil return here
27
+ // (the upstream goroutine exits without an error). Hosting code that
28
+ // must turn Goexit into a typed error should run fn in a separate
29
+ // goroutine and join on a sentinel channel — outside this helper's
30
+ // scope today.
31
+ func RecoverPanicAs(fn func() error) (err error) {
32
+ defer func() {
33
+ if r := recover(); r != nil {
34
+ err = fmt.Errorf("%w: %v\n%s", ErrLSPUpstreamPanic, r, debug.Stack())
35
+ }
36
+ }()
37
+ return fn()
38
+ }
39
+
40
+ // LSPServerOptions wires ttscserver to its three channels of state:
41
+ // editor stdio for the LSP transport, an optional ttsc PluginSource for
42
+ // merging plugin diagnostics into the stream, and the tsgo binary that
43
+ // provides the upstream LSP server.
44
+ type LSPServerOptions struct {
45
+ // In is the editor-side reader; ttscserver reads JSON-RPC frames from
46
+ // it and forwards or handles them. RunLSPServer closes it on shutdown
47
+ // if it also implements io.Closer so blocked frame reads can unblock.
48
+ In io.Reader
49
+ // Out is the editor-side writer; ttscserver writes both upstream
50
+ // responses and locally-synthesized messages to it.
51
+ Out io.Writer
52
+ // Err is the upstream tsgo server's stderr sink. ttscserver does not
53
+ // log to it directly.
54
+ Err io.Writer
55
+
56
+ // Cwd is the project root used as the upstream tsgo process working
57
+ // directory. An empty string is rejected before any process starts.
58
+ Cwd string
59
+ // TsgoBinary is the absolute path to the project-selected
60
+ // @typescript/native-preview executable.
61
+ TsgoBinary string
62
+ // Source contributes ttsc plugin diagnostics / code actions /
63
+ // executeCommand handling. Nil falls back to NullPluginSource{}.
64
+ Source PluginSource
65
+ // ProgressDelay is accepted for CLI compatibility. The external tsgo
66
+ // LSP command does not currently expose a progress-delay flag.
67
+ ProgressDelay time.Duration
68
+ }
69
+
70
+ // ErrLSPCwdRequired is returned when LSPServerOptions.Cwd is empty.
71
+ // ttsc surfaces a clean error here instead of starting tsgo from an
72
+ // undefined project directory.
73
+ var ErrLSPCwdRequired = errors.New("ttscserver: cwd is required")
74
+
75
+ // ErrLSPTsgoBinaryRequired is returned when no upstream tsgo executable
76
+ // path was supplied by the JavaScript launcher or native caller.
77
+ var ErrLSPTsgoBinaryRequired = errors.New("ttscserver: tsgo binary is required")
78
+
79
+ // LSPUpstreamRunner is the seam tests use to substitute the external
80
+ // tsgo process with a controllable fake.
81
+ type LSPUpstreamRunner func(ctx context.Context, in io.Reader, out io.Writer, opts LSPServerOptions) error
82
+
83
+ // upstreamRunner is replaced in tests via WithUpstreamRunnerForTest.
84
+ var upstreamRunner LSPUpstreamRunner = defaultUpstreamRunner
85
+
86
+ // upstreamValidator checks production-only upstream requirements before any
87
+ // proxy goroutine starts. Test upstream runners replace it with a no-op.
88
+ var upstreamValidator = validateDefaultUpstreamOptions
89
+
90
+ // WithUpstreamRunnerForTest substitutes the upstream runner used by
91
+ // RunLSPServer. Returns a function the caller defers to restore the
92
+ // production runner. The seam stays in the public driver API because
93
+ // tests and embedders otherwise have no way to bypass tsgo.
94
+ func WithUpstreamRunnerForTest(runner LSPUpstreamRunner) func() {
95
+ prev := upstreamRunner
96
+ prevValidator := upstreamValidator
97
+ upstreamRunner = runner
98
+ upstreamValidator = func(LSPServerOptions) error { return nil }
99
+ return func() {
100
+ upstreamRunner = prev
101
+ upstreamValidator = prevValidator
102
+ }
103
+ }
104
+
105
+ func validateDefaultUpstreamOptions(opts LSPServerOptions) error {
106
+ if opts.TsgoBinary == "" {
107
+ return ErrLSPTsgoBinaryRequired
108
+ }
109
+ if !filepath.IsAbs(opts.TsgoBinary) {
110
+ return fmt.Errorf("ttscserver: tsgo binary must be absolute: %s", opts.TsgoBinary)
111
+ }
112
+ return nil
113
+ }
114
+
115
+ func defaultUpstreamRunner(ctx context.Context, in io.Reader, out io.Writer, opts LSPServerOptions) error {
116
+ cmd := exec.CommandContext(ctx, opts.TsgoBinary, "--lsp", "--stdio")
117
+ cmd.Dir = opts.Cwd
118
+ cmd.Stdin = in
119
+ cmd.Stdout = out
120
+ cmd.Stderr = opts.Err
121
+ if err := cmd.Run(); err != nil {
122
+ if ctx.Err() != nil {
123
+ return ctx.Err()
124
+ }
125
+ return fmt.Errorf("tsgo --lsp --stdio: %w", err)
126
+ }
127
+ return nil
128
+ }
129
+
130
+ // RunLSPServer starts an upstream `tsgo --lsp --stdio` process and the byte-level
131
+ // proxy, blocking until either side returns. The first non-graceful
132
+ // error wins; ErrFrameClosed and context cancellation are treated as
133
+ // clean shutdown so editor close sequences do not look like crashes.
134
+ //
135
+ // The lifecycle is:
136
+ //
137
+ // 1. Open two pipes around the tsgo process (editor->server, server->editor).
138
+ // 2. Spawn the upstream runner (real tsgo process or a test fake) reading/writing those pipes.
139
+ // 3. Run the proxy in parallel.
140
+ // 4. A watchdog cascades context cancellation by closing every pipe so both
141
+ // halves unblock; the goroutines' own defers close the rest.
142
+ func RunLSPServer(ctx context.Context, opts LSPServerOptions) error {
143
+ if opts.Cwd == "" {
144
+ return ErrLSPCwdRequired
145
+ }
146
+ if err := upstreamValidator(opts); err != nil {
147
+ return err
148
+ }
149
+ source := opts.Source
150
+ if source == nil {
151
+ source = NullPluginSource{}
152
+ }
153
+
154
+ upstreamInR, upstreamInW := io.Pipe()
155
+ upstreamOutR, upstreamOutW := io.Pipe()
156
+
157
+ proxy := NewProxy(ProxyOptions{
158
+ EditorIn: opts.In,
159
+ EditorOut: opts.Out,
160
+ UpstreamIn: upstreamInW,
161
+ UpstreamOut: upstreamOutR,
162
+ Source: source,
163
+ })
164
+
165
+ serverCtx, cancel := context.WithCancel(ctx)
166
+ defer cancel()
167
+
168
+ // Watchdog: on cancel, close the writer ends of the upstream pipes so
169
+ // both halves unblock with a clean io.EOF. Closing readers directly
170
+ // would surface as io.ErrClosedPipe and make ttsc's error fold
171
+ // ambiguous; closing writers preserves the ErrFrameClosed signal. The
172
+ // editor input is also closed when possible so an upstream process-start
173
+ // failure cannot leave the editor->upstream pump blocked on stdin.
174
+ go func() {
175
+ <-serverCtx.Done()
176
+ closeIfCloser(opts.In)
177
+ upstreamInW.Close()
178
+ upstreamOutW.Close()
179
+ }()
180
+
181
+ var wg sync.WaitGroup
182
+ var serverErr, proxyErr error
183
+ wg.Add(2)
184
+ go func() {
185
+ defer wg.Done()
186
+ defer cancel()
187
+ defer upstreamOutW.Close()
188
+ defer upstreamInR.Close()
189
+ serverErr = RecoverPanicAs(func() error {
190
+ return upstreamRunner(serverCtx, upstreamInR, upstreamOutW, opts)
191
+ })
192
+ }()
193
+ go func() {
194
+ defer wg.Done()
195
+ defer cancel()
196
+ defer upstreamInW.Close()
197
+ defer upstreamOutR.Close()
198
+ proxyErr = proxy.Run(serverCtx)
199
+ }()
200
+ wg.Wait()
201
+
202
+ for _, err := range []error{serverErr, proxyErr} {
203
+ if err == nil {
204
+ continue
205
+ }
206
+ if errors.Is(err, context.Canceled) {
207
+ continue
208
+ }
209
+ if errors.Is(err, ErrFrameClosed) {
210
+ continue
211
+ }
212
+ if errors.Is(err, io.ErrClosedPipe) {
213
+ continue
214
+ }
215
+ return err
216
+ }
217
+ return nil
218
+ }
219
+
220
+ // DenyNpmInstall is kept for source compatibility with older driver
221
+ // embedders that hosted tsgo in-process. The process wrapper cannot
222
+ // override tsgo's internal ATA callback.
223
+ func DenyNpmInstall(_ string, args []string) ([]byte, error) {
224
+ return nil, fmt.Errorf("ttscserver: npm install disabled in LSP host (args=%v)", args)
225
+ }
226
+
227
+ func closeIfCloser(value any) {
228
+ if closer, ok := value.(io.Closer); ok {
229
+ _ = closer.Close()
230
+ }
231
+ }
@@ -42,9 +42,9 @@ export declare class TtscCompiler {
42
42
  * Remove compiled cache artifacts for this compiler instance.
43
43
  *
44
44
  * When the constructor received `cacheDir`, this method removes exactly that
45
- * directory. Otherwise it removes the default ttsc cache locations for the
46
- * resolved project root, plus the `TTSC_CACHE_DIR/plugins` cache when that
47
- * environment variable is active.
45
+ * directory. When `TTSC_CACHE_DIR` is active, it removes that override's
46
+ * plugin cache plus legacy project-local caches. Otherwise it removes the
47
+ * default global plugin cache plus legacy project-local caches.
48
48
  *
49
49
  * The cache target comes from the constructor context. Create another
50
50
  * `TtscCompiler` instance to clean another project or another cache root.
@@ -10,6 +10,7 @@ const compileProjectInMemory_1 = require("./compiler/internal/compileProjectInMe
10
10
  const resolveProjectConfig_1 = require("./compiler/internal/project/resolveProjectConfig");
11
11
  const resolveBinary_1 = require("./compiler/internal/resolveBinary");
12
12
  const transformProjectInMemory_1 = require("./compiler/internal/transformProjectInMemory");
13
+ const buildSourcePlugin_1 = require("./plugin/internal/buildSourcePlugin");
13
14
  const loadProjectPlugins_1 = require("./plugin/internal/loadProjectPlugins");
14
15
  /**
15
16
  * Programmatic compiler host for the `ttsc` TypeScript-Go pipeline.
@@ -71,9 +72,9 @@ class TtscCompiler {
71
72
  * Remove compiled cache artifacts for this compiler instance.
72
73
  *
73
74
  * When the constructor received `cacheDir`, this method removes exactly that
74
- * directory. Otherwise it removes the default ttsc cache locations for the
75
- * resolved project root, plus the `TTSC_CACHE_DIR/plugins` cache when that
76
- * environment variable is active.
75
+ * directory. When `TTSC_CACHE_DIR` is active, it removes that override's
76
+ * plugin cache plus legacy project-local caches. Otherwise it removes the
77
+ * default global plugin cache plus legacy project-local caches.
77
78
  *
78
79
  * The cache target comes from the constructor context. Create another
79
80
  * `TtscCompiler` instance to clean another project or another cache root.
@@ -85,12 +86,25 @@ class TtscCompiler {
85
86
  if (cacheDir !== undefined) {
86
87
  return removeExistingDirectories([cacheDir]);
87
88
  }
88
- const envCacheDir = this.context.env?.TTSC_CACHE_DIR ?? process.env.TTSC_CACHE_DIR;
89
89
  const projectRoot = this.resolveCleanProjectRoot();
90
- return removeExistingDirectories([
91
- ...(envCacheDir ? [node_path_1.default.resolve(envCacheDir, "plugins")] : []),
90
+ const legacyTargets = [
92
91
  node_path_1.default.join(projectRoot, "node_modules", ".ttsc"),
93
92
  node_path_1.default.join(projectRoot, ".ttsc"),
93
+ ];
94
+ if (this.context.env?.TTSC_CACHE_DIR) {
95
+ return removeExistingDirectories([
96
+ node_path_1.default.resolve(projectRoot, this.context.env.TTSC_CACHE_DIR, "plugins"),
97
+ ...legacyTargets,
98
+ ]);
99
+ }
100
+ if (process.env.TTSC_CACHE_DIR) {
101
+ return removeExistingDirectories([
102
+ node_path_1.default.resolve(process.env.TTSC_CACHE_DIR, "plugins"),
103
+ ...legacyTargets,
104
+ ]);
105
+ }
106
+ return removeExistingDirectories([
107
+ ...(0, buildSourcePlugin_1.defaultPluginCacheCleanTargets)(projectRoot),
94
108
  ]);
95
109
  }
96
110
  /**