ttsc 0.10.2 → 0.11.0-dev.20260517

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 (106) hide show
  1. package/README.md +8 -0
  2. package/cmd/ttsc/api_compile.go +106 -106
  3. package/cmd/ttsc/api_transform.go +54 -54
  4. package/cmd/ttsc/main.go +103 -103
  5. package/cmd/ttscserver/helpers_test.go +27 -0
  6. package/cmd/ttscserver/main.go +154 -0
  7. package/cmd/ttscserver/run_lsp_reports_getwd_error_test.go +34 -0
  8. package/cmd/ttscserver/run_lsp_reports_server_error_test.go +42 -0
  9. package/driver/lsp_envelope.go +136 -0
  10. package/driver/lsp_frame.go +121 -0
  11. package/driver/lsp_plugin.go +150 -0
  12. package/driver/lsp_proxy.go +394 -0
  13. package/driver/lsp_server.go +197 -0
  14. package/go.mod +4 -0
  15. package/go.sum +4 -0
  16. package/internal/cwd/cwd.go +8 -8
  17. package/lib/TtscCompiler.d.ts +15 -16
  18. package/lib/TtscCompiler.js +15 -16
  19. package/lib/TtscCompiler.js.map +1 -1
  20. package/lib/compiler/internal/compileProjectInMemory.d.ts +4 -1
  21. package/lib/compiler/internal/compileProjectInMemory.js +4 -1
  22. package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
  23. package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
  24. package/lib/compiler/internal/runBuild.js +43 -1
  25. package/lib/compiler/internal/runBuild.js.map +1 -1
  26. package/lib/compiler/internal/sharedHostHelpers.d.ts +12 -12
  27. package/lib/compiler/internal/sharedHostHelpers.js +12 -12
  28. package/lib/defineTtscPlugin.d.ts +18 -0
  29. package/lib/defineTtscPlugin.js +7 -0
  30. package/lib/defineTtscPlugin.js.map +1 -0
  31. package/lib/index.d.ts +4 -3
  32. package/lib/index.js +4 -3
  33. package/lib/index.js.map +1 -1
  34. package/lib/launcher/internal/prepareExecution.js +1 -1
  35. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  36. package/lib/launcher/internal/resolveTtscserverBinary.d.ts +12 -0
  37. package/lib/launcher/internal/resolveTtscserverBinary.js +43 -0
  38. package/lib/launcher/internal/resolveTtscserverBinary.js.map +1 -0
  39. package/lib/launcher/internal/runTtsc.js +47 -8
  40. package/lib/launcher/internal/runTtsc.js.map +1 -1
  41. package/lib/launcher/internal/runTtscserver.d.ts +20 -0
  42. package/lib/launcher/internal/runTtscserver.js +125 -0
  43. package/lib/launcher/internal/runTtscserver.js.map +1 -0
  44. package/lib/launcher/internal/runTtsx.js +1 -1
  45. package/lib/launcher/internal/runTtsx.js.map +1 -1
  46. package/lib/launcher/ttscserver.d.ts +2 -0
  47. package/lib/launcher/ttscserver.js +9 -0
  48. package/lib/launcher/ttscserver.js.map +1 -0
  49. package/lib/plugin/internal/buildSourcePlugin.d.ts +9 -0
  50. package/lib/plugin/internal/buildSourcePlugin.js +197 -2
  51. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  52. package/lib/plugin/internal/loadProjectPlugins.js +86 -8
  53. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  54. package/lib/structures/ITtscCompilerContext.d.ts +1 -1
  55. package/lib/structures/ITtscCompilerDiagnostic.d.ts +3 -3
  56. package/lib/structures/ITtscCompilerResult.d.ts +10 -22
  57. package/lib/structures/ITtscCompilerTransformation.d.ts +2 -2
  58. package/lib/structures/ITtscPlugin.d.ts +37 -4
  59. package/lib/structures/ITtscPlugin.js +0 -4
  60. package/lib/structures/ITtscPlugin.js.map +1 -1
  61. package/lib/structures/ITtscPluginContributor.d.ts +26 -0
  62. package/lib/structures/ITtscPluginContributor.js +3 -0
  63. package/lib/structures/ITtscPluginContributor.js.map +1 -0
  64. package/lib/structures/ITtscPluginFactoryContext.d.ts +5 -5
  65. package/lib/structures/ITtscProjectPluginConfig.d.ts +2 -2
  66. package/lib/structures/TtscPluginStage.d.ts +4 -3
  67. package/lib/structures/index.d.ts +1 -0
  68. package/lib/structures/index.js +1 -0
  69. package/lib/structures/index.js.map +1 -1
  70. package/lib/structures/internal/ITtscLoadedNativePlugin.d.ts +3 -0
  71. package/lib/structures/internal/TtscBuildOptions.d.ts +13 -0
  72. package/lib/structures/internal/TtscCommonOptions.d.ts +5 -2
  73. package/package.json +10 -9
  74. package/shim/lsp/extra-shim.json +4 -0
  75. package/shim/lsp/go.mod +16 -0
  76. package/shim/lsp/go.sum +26 -0
  77. package/shim/lsp/shim.go +42 -0
  78. package/shim/printer/shim.go +5 -5
  79. package/src/TtscCompiler.ts +15 -16
  80. package/src/compiler/internal/compileProjectInMemory.ts +4 -1
  81. package/src/compiler/internal/project/readProjectConfig.ts +0 -1
  82. package/src/compiler/internal/runBuild.ts +43 -1
  83. package/src/compiler/internal/sharedHostHelpers.ts +12 -12
  84. package/src/defineTtscPlugin.ts +26 -0
  85. package/src/index.ts +4 -3
  86. package/src/launcher/internal/prepareExecution.ts +1 -1
  87. package/src/launcher/internal/resolveTtscserverBinary.ts +51 -0
  88. package/src/launcher/internal/runTtsc.ts +53 -11
  89. package/src/launcher/internal/runTtscserver.ts +92 -0
  90. package/src/launcher/internal/runTtsx.ts +1 -1
  91. package/src/launcher/ttscserver.ts +8 -0
  92. package/src/plugin/internal/buildSourcePlugin.ts +231 -12
  93. package/src/plugin/internal/loadProjectPlugins.ts +108 -8
  94. package/src/structures/ITtscCompilerContext.ts +1 -1
  95. package/src/structures/ITtscCompilerDiagnostic.ts +3 -3
  96. package/src/structures/ITtscCompilerResult.ts +10 -22
  97. package/src/structures/ITtscCompilerTransformation.ts +2 -2
  98. package/src/structures/ITtscPlugin.ts +37 -11
  99. package/src/structures/ITtscPluginContributor.ts +27 -0
  100. package/src/structures/ITtscPluginFactoryContext.ts +5 -5
  101. package/src/structures/ITtscProjectPluginConfig.ts +2 -2
  102. package/src/structures/TtscPluginStage.ts +4 -3
  103. package/src/structures/index.ts +1 -0
  104. package/src/structures/internal/ITtscLoadedNativePlugin.ts +3 -0
  105. package/src/structures/internal/TtscBuildOptions.ts +13 -0
  106. package/src/structures/internal/TtscCommonOptions.ts +5 -2
@@ -0,0 +1,154 @@
1
+ // Command ttscserver is the Go LSP host shipped by ttsc. It embeds
2
+ // TypeScript-Go's LSP server unmodified and proxies traffic between the
3
+ // editor and that server, splicing ttsc-plugin diagnostics, code actions,
4
+ // and ttsc-owned executeCommand handling into the same stream.
5
+ //
6
+ // The JavaScript launcher (`packages/ttsc/src/launcher/ttscserver.ts`)
7
+ // resolves the native binary and forwards stdio so editors can spawn
8
+ // `ttscserver --stdio` without worrying about platform helper packages.
9
+ //
10
+ // Everything here is deliberately small: flag parsing, version metadata,
11
+ // and a single delegation to driver.RunLSPServer.
12
+ package main
13
+
14
+ import (
15
+ "context"
16
+ "errors"
17
+ "flag"
18
+ "fmt"
19
+ "io"
20
+ "os"
21
+ "os/signal"
22
+ "runtime"
23
+ "strings"
24
+ "syscall"
25
+ "time"
26
+
27
+ "github.com/samchon/ttsc/packages/ttsc/driver"
28
+ )
29
+
30
+ // Build metadata; overwritten via -ldflags in release builds.
31
+ var (
32
+ version = "0.0.0-dev"
33
+ commit = "dev"
34
+ date = "unknown"
35
+ )
36
+
37
+ // Package-level writers so command tests can capture output without
38
+ // patching os.Stdout / os.Stderr globally.
39
+ var (
40
+ stdout io.Writer = os.Stdout
41
+ stderr io.Writer = os.Stderr
42
+ stdin io.Reader = os.Stdin
43
+ )
44
+
45
+ // runLSPServer is the seam command tests use to substitute a fake LSP
46
+ // host. Production wires it to driver.RunLSPServer.
47
+ var runLSPServer = driver.RunLSPServer
48
+
49
+ // notifyContext is the seam command tests use to substitute a
50
+ // deterministic context (no signal hookup) for the signal-aware default.
51
+ var notifyContext = signal.NotifyContext
52
+
53
+ // getwd is the seam command tests use to simulate os.Getwd failures
54
+ // (deleted working directory, sandbox restrictions, etc.) without
55
+ // modifying the test process cwd.
56
+ var getwd = os.Getwd
57
+
58
+ func main() {
59
+ os.Exit(run(os.Args[1:]))
60
+ }
61
+
62
+ func run(args []string) int {
63
+ if len(args) == 0 {
64
+ printHelp(stdout)
65
+ return 0
66
+ }
67
+ switch args[0] {
68
+ case "-h", "--help", "help":
69
+ printHelp(stdout)
70
+ return 0
71
+ case "-v", "--version", "version":
72
+ printVersion(stdout)
73
+ return 0
74
+ }
75
+ return runLSP(args)
76
+ }
77
+
78
+ func runLSP(args []string) int {
79
+ fs := flag.NewFlagSet("ttscserver", flag.ContinueOnError)
80
+ fs.SetOutput(stderr)
81
+ stdioFlag := fs.Bool("stdio", false, "communicate with the editor over stdin/stdout")
82
+ cwdFlag := fs.String("cwd", "", "project root (defaults to process cwd)")
83
+ progressDelayFlag := fs.Duration("progress-delay", 250*time.Millisecond, "delay before showing tsgo's progress UI")
84
+ if err := fs.Parse(args); err != nil {
85
+ return 2
86
+ }
87
+ if !*stdioFlag {
88
+ fmt.Fprintln(stderr, "ttscserver: only --stdio transport is supported")
89
+ return 2
90
+ }
91
+
92
+ cwd := strings.TrimSpace(*cwdFlag)
93
+ if cwd == "" {
94
+ resolved, err := getwd()
95
+ if err != nil {
96
+ fmt.Fprintf(stderr, "ttscserver: could not resolve working directory: %v\n", err)
97
+ return 2
98
+ }
99
+ cwd = resolved
100
+ }
101
+
102
+ ctx, stop := notifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
103
+ defer stop()
104
+
105
+ err := runLSPServer(ctx, driver.LSPServerOptions{
106
+ In: stdin,
107
+ Out: stdout,
108
+ Err: stderr,
109
+ Cwd: cwd,
110
+ Source: driver.NullPluginSource{},
111
+ ProgressDelay: *progressDelayFlag,
112
+ })
113
+ if err != nil && !errors.Is(err, context.Canceled) {
114
+ fmt.Fprintf(stderr, "ttscserver: %v\n", err)
115
+ return 1
116
+ }
117
+ return 0
118
+ }
119
+
120
+ func printVersion(w io.Writer) {
121
+ fmt.Fprintf(
122
+ w,
123
+ "ttscserver %s (commit %s, built %s, %s/%s, go %s)\n",
124
+ version,
125
+ commit,
126
+ date,
127
+ runtime.GOOS,
128
+ runtime.GOARCH,
129
+ runtime.Version(),
130
+ )
131
+ }
132
+
133
+ func printHelp(w io.Writer) {
134
+ fmt.Fprintln(w, strings.TrimSpace(`
135
+ ttscserver — Language Server Protocol host for ttsc.
136
+
137
+ Usage:
138
+ ttscserver --stdio
139
+ ttscserver --version
140
+ ttscserver --help
141
+
142
+ Options:
143
+ --stdio Communicate with the editor over stdin/stdout.
144
+ --cwd <dir> Project root passed to the embedded tsgo server.
145
+ --progress-delay D Delay before tsgo shows its progress UI (default 250ms).
146
+
147
+ Typical embedding:
148
+ Editors spawn ttscserver via the JavaScript launcher (resolves the
149
+ per-platform native binary) and exchange LSP messages over stdio. The
150
+ embedded tsgo server provides hover/completion/definitions/diagnostics;
151
+ ttsc merges plugin (lint/format) diagnostics, code actions, and ttsc-
152
+ owned executeCommand handlers into the same stream.
153
+ `))
154
+ }
@@ -0,0 +1,34 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "errors"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ // TestRunLSPReportsGetwdError pins the defensive Getwd error path. The
11
+ // host runs with an inherited working directory; if Getwd fails (e.g.
12
+ // the directory was removed mid-session) the launcher must surface a
13
+ // clean message and exit 2 instead of panicking inside tsgo.
14
+ //
15
+ // 1. Substitute the getwd seam to return an error.
16
+ // 2. Run runLSP with --stdio (no explicit --cwd).
17
+ // 3. Assert exit 2 and a stderr message mentioning the working directory.
18
+ func TestRunLSPReportsGetwdError(t *testing.T) {
19
+ prev := getwd
20
+ getwd = func() (string, error) { return "", errors.New("synthetic getwd failure") }
21
+ defer func() { getwd = prev }()
22
+
23
+ outBuf := &bytes.Buffer{}
24
+ errBuf := &bytes.Buffer{}
25
+ withIO(t, outBuf, errBuf, nil, func() {
26
+ if code := runLSP([]string{"--stdio"}); code != 2 {
27
+ t.Fatalf("expected exit 2, got %d", code)
28
+ }
29
+ })
30
+
31
+ if !strings.Contains(errBuf.String(), "could not resolve working directory") {
32
+ t.Fatalf("expected getwd error message, got: %q", errBuf.String())
33
+ }
34
+ }
@@ -0,0 +1,42 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "errors"
7
+ "io"
8
+ "strings"
9
+ "testing"
10
+
11
+ "github.com/samchon/ttsc/packages/ttsc/driver"
12
+ )
13
+
14
+ // TestRunLSPReportsServerError pins the non-cancellation error path in
15
+ // runLSP. When RunLSPServer reports a real failure (the embedded server
16
+ // crashed, an unrecoverable IO error, etc.) the launcher must exit 1
17
+ // and copy the error message to stderr.
18
+ //
19
+ // 1. Substitute the runLSPServer seam to return a sentinel error.
20
+ // 2. Run runLSP with --stdio --cwd <tempdir>.
21
+ // 3. Assert exit 1 and the sentinel text on stderr.
22
+ func TestRunLSPReportsServerError(t *testing.T) {
23
+ sentinel := errors.New("lsp host blew up")
24
+ prev := runLSPServer
25
+ runLSPServer = func(_ context.Context, _ driver.LSPServerOptions) error {
26
+ return sentinel
27
+ }
28
+ defer func() { runLSPServer = prev }()
29
+
30
+ outBuf := &bytes.Buffer{}
31
+ errBuf := &bytes.Buffer{}
32
+ withIO(t, outBuf, errBuf, nil, func() {
33
+ if code := runLSP([]string{"--stdio", "--cwd", t.TempDir()}); code != 1 {
34
+ t.Fatalf("expected exit 1, got %d", code)
35
+ }
36
+ })
37
+
38
+ if !strings.Contains(errBuf.String(), sentinel.Error()) {
39
+ t.Fatalf("expected sentinel on stderr, got: %q", errBuf.String())
40
+ }
41
+ _ = io.Discard
42
+ }
@@ -0,0 +1,136 @@
1
+ package driver
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "strconv"
9
+ "strings"
10
+ )
11
+
12
+ // ErrInvalidJSONRPC is returned by ParseEnvelope when the body has a
13
+ // `jsonrpc` field present and not equal to "2.0". A missing field is
14
+ // tolerated to stay compatible with editors that omit it.
15
+ var ErrInvalidJSONRPC = errors.New("lsp: jsonrpc field must be \"2.0\"")
16
+
17
+ // Envelope is the minimal JSON-RPC view ttscserver needs to dispatch
18
+ // messages without re-serializing them. id is decoded as json.RawMessage
19
+ // because LSP allows both numbers and strings and we must round-trip
20
+ // whichever shape the editor used in correlating responses.
21
+ type Envelope struct {
22
+ JSONRPC string `json:"jsonrpc,omitempty"`
23
+ ID json.RawMessage `json:"id,omitempty"`
24
+ Method string `json:"method,omitempty"`
25
+ Params json.RawMessage `json:"params,omitempty"`
26
+ Result json.RawMessage `json:"result,omitempty"`
27
+ Error json.RawMessage `json:"error,omitempty"`
28
+ }
29
+
30
+ // ParseEnvelope decodes the JSON-RPC envelope without consuming the
31
+ // inner params/result/error payloads. Unknown fields are ignored, which
32
+ // matches the LSP base protocol's forward-compatibility expectation. A
33
+ // non-empty `jsonrpc` value other than "2.0" is rejected.
34
+ func ParseEnvelope(body []byte) (Envelope, error) {
35
+ var env Envelope
36
+ if err := json.Unmarshal(body, &env); err != nil {
37
+ return Envelope{}, fmt.Errorf("lsp: envelope decode: %w", err)
38
+ }
39
+ if env.JSONRPC != "" && env.JSONRPC != "2.0" {
40
+ return Envelope{}, fmt.Errorf("%w (got %q)", ErrInvalidJSONRPC, env.JSONRPC)
41
+ }
42
+ return env, nil
43
+ }
44
+
45
+ // IsRequest reports whether the envelope represents a request expecting
46
+ // a response (id present, method present).
47
+ func (e Envelope) IsRequest() bool {
48
+ return len(e.ID) > 0 && e.Method != ""
49
+ }
50
+
51
+ // IsNotification reports whether the envelope is a one-way notification.
52
+ func (e Envelope) IsNotification() bool {
53
+ return len(e.ID) == 0 && e.Method != ""
54
+ }
55
+
56
+ // IsResponse reports whether the envelope is a response (id present,
57
+ // method absent — either result or error will be set).
58
+ func (e Envelope) IsResponse() bool {
59
+ return len(e.ID) > 0 && e.Method == ""
60
+ }
61
+
62
+ // IsErrorResponse reports whether a response envelope carries an error
63
+ // object. JSON-RPC §5.1 forbids `result` and `error` both being present
64
+ // on the same response, so the proxy uses this to skip merging plugin
65
+ // contributions into upstream failures.
66
+ func (e Envelope) IsErrorResponse() bool {
67
+ return e.IsResponse() && len(e.Error) > 0 && !bytes.Equal(bytes.TrimSpace(e.Error), []byte("null"))
68
+ }
69
+
70
+ // IDKey returns a stable string key for matching request and response
71
+ // envelopes. LSP allows integer or string ids, and peers sometimes
72
+ // disagree on the numeric encoding — `1` vs `1.0` is semantically the
73
+ // same id. The key normalizes both forms so the proxy correlates them.
74
+ // Strings remain quoted so a numeric id `42` never collides with a
75
+ // string id `"42"`. Ids whose JSON value is not a number or string
76
+ // (LSP forbids these as request ids) produce the empty key, which
77
+ // callers treat as "no entry".
78
+ func (e Envelope) IDKey() string {
79
+ return idKeyFromRaw(e.ID)
80
+ }
81
+
82
+ // idKeyFromRaw normalizes a raw id payload the same way IDKey does so
83
+ // every call site keyed on json.RawMessage produces a consistent key.
84
+ // Without this shared helper, $/cancelRequest handling and pending
85
+ // request bookkeeping would disagree on numeric form and the proxy
86
+ // would fail to correlate them.
87
+ //
88
+ // Numbers are decoded via json.Decoder.UseNumber() so the full int64
89
+ // range round-trips exactly: decoding into `any` would produce float64
90
+ // and any id past 2^53 would silently collide with its neighbor. LSP
91
+ // places no upper bound on numeric ids, so the helper preserves the
92
+ // high-bit fidelity peers may rely on. Strings are quoted so the proxy
93
+ // never confuses a numeric id `42` with a string id `"42"`. Anything
94
+ // the type switch below does not recognize as a JSON-RPC id shape
95
+ // (boolean, null, array, object — none of which LSP allows for ids)
96
+ // falls through to the empty key, which callers treat as "no entry".
97
+ func idKeyFromRaw(raw json.RawMessage) string {
98
+ if len(raw) == 0 {
99
+ return ""
100
+ }
101
+ dec := json.NewDecoder(bytes.NewReader(raw))
102
+ dec.UseNumber()
103
+ var decoded any
104
+ if err := dec.Decode(&decoded); err != nil {
105
+ return ""
106
+ }
107
+ switch v := decoded.(type) {
108
+ case json.Number:
109
+ lit := v.String()
110
+ if !strings.ContainsAny(lit, ".eE") {
111
+ // Integer-shaped literal. Use int64 when it fits so leading
112
+ // zeros and minus signs canonicalize, but preserve the literal
113
+ // for past-int64 ids — Float64 normalization would silently
114
+ // round 9999999999999999998 and 9999999999999999999 to the
115
+ // same key.
116
+ if n, err := v.Int64(); err == nil {
117
+ return strconv.FormatInt(n, 10)
118
+ }
119
+ return lit
120
+ }
121
+ // Float-shaped literal (`1.0`, `1.5`, `1e2`). Collapse to integer
122
+ // form only when the value is exactly representable as int64;
123
+ // 2^53 bounds the safe range because float64 loses unit precision
124
+ // past that magnitude.
125
+ if f, err := v.Float64(); err == nil {
126
+ const maxSafeFloat = float64(int64(1) << 53)
127
+ if f >= -maxSafeFloat && f <= maxSafeFloat && f == float64(int64(f)) {
128
+ return strconv.FormatInt(int64(f), 10)
129
+ }
130
+ }
131
+ return lit
132
+ case string:
133
+ return strconv.Quote(v)
134
+ }
135
+ return ""
136
+ }
@@ -0,0 +1,121 @@
1
+ // JSON-RPC Content-Length framing as used by LSP over stdio. ttscserver
2
+ // embeds tsgo's LSP server but sits between it and the editor so it can
3
+ // merge plugin diagnostics into outgoing publishDiagnostics and inject
4
+ // ttsc-owned code actions. Both legs of the proxy work on raw bytes.
5
+ //
6
+ // Headers are preserved when read so callers that inspect Content-Type
7
+ // or vendor-specific headers can react to them; outgoing frames written
8
+ // by WriteFrame include only Content-Length. That is intentional — the
9
+ // LSP base protocol marks every other header optional and editors in
10
+ // the wild do not require them on server output.
11
+ package driver
12
+
13
+ import (
14
+ "bufio"
15
+ "errors"
16
+ "fmt"
17
+ "io"
18
+ "strconv"
19
+ "strings"
20
+ )
21
+
22
+ // ErrFrameClosed reports a clean EOF between frames. Callers use it to
23
+ // shut down a pump loop without surfacing an error to the editor.
24
+ var ErrFrameClosed = errors.New("lsp: frame stream closed")
25
+
26
+ // ErrFrameTooLarge reports that a peer announced a Content-Length above
27
+ // the safety cap. ttscserver bounds the value defensively so a confused
28
+ // editor or compromised pipe cannot drive the proxy OOM with a single
29
+ // gigabyte-scale header.
30
+ var ErrFrameTooLarge = errors.New("lsp: frame body exceeds maximum size")
31
+
32
+ // MaxFrameBytes caps Content-Length to 64 MiB. The largest payload the
33
+ // LSP base protocol routinely produces is workspace symbol responses
34
+ // for very large monorepos; 64 MiB leaves a wide margin over any real
35
+ // editor traffic while still capping attacker-supplied lengths.
36
+ const MaxFrameBytes = 64 << 20
37
+
38
+ // FrameReader decodes Content-Length-framed JSON-RPC messages from r. It
39
+ // is intentionally permissive about extra headers (we forward whatever the
40
+ // peer sent) and strict about the Content-Length value because tsgo's
41
+ // upstream writer always emits one.
42
+ type FrameReader struct {
43
+ br *bufio.Reader
44
+ }
45
+
46
+ // NewFrameReader wraps r with an internal buffered reader. r is consumed
47
+ // lazily; the underlying reader is never closed by the FrameReader.
48
+ func NewFrameReader(r io.Reader) *FrameReader {
49
+ return &FrameReader{br: bufio.NewReader(r)}
50
+ }
51
+
52
+ // Read returns the next message body together with the raw header block
53
+ // (without trailing CRLFCRLF). The header block is preserved so callers
54
+ // that proxy traffic verbatim do not lose Content-Type or vendor headers.
55
+ func (fr *FrameReader) Read() (headers string, body []byte, err error) {
56
+ var headerBuf strings.Builder
57
+ contentLength := -1
58
+ for {
59
+ line, lineErr := fr.br.ReadString('\n')
60
+ if lineErr != nil {
61
+ if lineErr == io.EOF && headerBuf.Len() == 0 && line == "" {
62
+ return "", nil, ErrFrameClosed
63
+ }
64
+ return "", nil, fmt.Errorf("lsp: header read: %w", lineErr)
65
+ }
66
+ trimmed := strings.TrimRight(line, "\r\n")
67
+ if trimmed == "" {
68
+ break
69
+ }
70
+ headerBuf.WriteString(line)
71
+ if value, ok := parseContentLength(trimmed); ok {
72
+ contentLength = value
73
+ }
74
+ }
75
+ if contentLength < 0 {
76
+ return "", nil, errors.New("lsp: missing Content-Length header")
77
+ }
78
+ if contentLength > MaxFrameBytes {
79
+ return "", nil, fmt.Errorf("%w (got %d, max %d)", ErrFrameTooLarge, contentLength, MaxFrameBytes)
80
+ }
81
+ body = make([]byte, contentLength)
82
+ if _, err := io.ReadFull(fr.br, body); err != nil {
83
+ return "", nil, fmt.Errorf("lsp: body read: %w", err)
84
+ }
85
+ return headerBuf.String(), body, nil
86
+ }
87
+
88
+ // parseContentLength extracts the integer value of a Content-Length header.
89
+ // Header names are case-insensitive per the LSP base protocol; tsgo emits
90
+ // "Content-Length" but some clients downcase, so we normalize here.
91
+ func parseContentLength(line string) (int, bool) {
92
+ colon := strings.IndexByte(line, ':')
93
+ if colon < 0 {
94
+ return 0, false
95
+ }
96
+ name := strings.TrimSpace(line[:colon])
97
+ if !strings.EqualFold(name, "Content-Length") {
98
+ return 0, false
99
+ }
100
+ value := strings.TrimSpace(line[colon+1:])
101
+ n, err := strconv.Atoi(value)
102
+ if err != nil || n < 0 {
103
+ return 0, false
104
+ }
105
+ return n, true
106
+ }
107
+
108
+ // WriteFrame serializes body with a Content-Length header to w. The header
109
+ // uses CRLF line endings to match the LSP base protocol exactly so editors
110
+ // that strict-parse the header block (notably VSCode's client) accept the
111
+ // message without warnings.
112
+ func WriteFrame(w io.Writer, body []byte) error {
113
+ header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body))
114
+ if _, err := io.WriteString(w, header); err != nil {
115
+ return fmt.Errorf("lsp: header write: %w", err)
116
+ }
117
+ if _, err := w.Write(body); err != nil {
118
+ return fmt.Errorf("lsp: body write: %w", err)
119
+ }
120
+ return nil
121
+ }
@@ -0,0 +1,150 @@
1
+ package driver
2
+
3
+ import "encoding/json"
4
+
5
+ // LSPRange / LSPPosition mirror the wire shape of LSP Range/Position. The
6
+ // proxy keeps these local rather than importing lsproto so the shim
7
+ // surface stays narrow; the merger marshals them straight into the array
8
+ // the editor already expects.
9
+ type LSPPosition struct {
10
+ Line int `json:"line"`
11
+ Character int `json:"character"`
12
+ }
13
+
14
+ // LSPRange is the closed-open [start, end) interval LSP uses for ranges.
15
+ type LSPRange struct {
16
+ Start LSPPosition `json:"start"`
17
+ End LSPPosition `json:"end"`
18
+ }
19
+
20
+ // LSPDiagnosticSeverity values match the LSP enum exactly so editors
21
+ // pick the right color/icon without translation.
22
+ type LSPDiagnosticSeverity int
23
+
24
+ const (
25
+ // LSPDiagnosticSeverityError is the most prominent severity; ttsc maps
26
+ // build-blocking findings (lint errors, parse errors) to it.
27
+ LSPDiagnosticSeverityError LSPDiagnosticSeverity = 1
28
+ // LSPDiagnosticSeverityWarning is rendered as a yellow squiggle in
29
+ // typical editor themes; ttsc maps lint warnings to it.
30
+ LSPDiagnosticSeverityWarning LSPDiagnosticSeverity = 2
31
+ // LSPDiagnosticSeverityInformation is rendered as a blue squiggle.
32
+ LSPDiagnosticSeverityInformation LSPDiagnosticSeverity = 3
33
+ // LSPDiagnosticSeverityHint is rendered as a faint underline or dots.
34
+ LSPDiagnosticSeverityHint LSPDiagnosticSeverity = 4
35
+ )
36
+
37
+ // LSPDiagnostic is the minimal subset of the LSP Diagnostic type
38
+ // ttscserver injects into outgoing publishDiagnostics. omitempty is set
39
+ // on optional fields so the merged JSON stays close to what tsgo emits.
40
+ type LSPDiagnostic struct {
41
+ Range LSPRange `json:"range"`
42
+ Severity LSPDiagnosticSeverity `json:"severity,omitempty"`
43
+ Code any `json:"code,omitempty"`
44
+ Source string `json:"source,omitempty"`
45
+ Message string `json:"message"`
46
+ }
47
+
48
+ // LSPCodeAction is the minimal Code Action shape ttscserver returns from
49
+ // the textDocument/codeAction handler. ttsc actions are always command-
50
+ // driven (the server side does the heavy lifting) so Edit stays nil.
51
+ type LSPCodeAction struct {
52
+ Title string `json:"title"`
53
+ Kind string `json:"kind,omitempty"`
54
+ Command *LSPCommand `json:"command,omitempty"`
55
+ Edit json.RawMessage `json:"edit,omitempty"`
56
+ IsPreferred bool `json:"isPreferred,omitempty"`
57
+ }
58
+
59
+ // LSPCommand is the wire shape of a workspace/executeCommand target.
60
+ type LSPCommand struct {
61
+ Title string `json:"title"`
62
+ Command string `json:"command"`
63
+ Arguments []json.RawMessage `json:"arguments,omitempty"`
64
+ }
65
+
66
+ // LSPCodeActionContext mirrors the context object the editor sends with
67
+ // textDocument/codeAction. Only the diagnostics slice is consumed by
68
+ // ttsc; the rest is opaque and forwarded by Proxy verbatim.
69
+ type LSPCodeActionContext struct {
70
+ Diagnostics []json.RawMessage `json:"diagnostics,omitempty"`
71
+ Only []string `json:"only,omitempty"`
72
+ TriggerKind int `json:"triggerKind,omitempty"`
73
+ }
74
+
75
+ // LSPWorkspaceEdit is the wire shape ttscserver returns from custom
76
+ // executeCommand handlers. It maps URIs to ordered text edits.
77
+ type LSPWorkspaceEdit struct {
78
+ Changes map[string][]LSPTextEdit `json:"changes,omitempty"`
79
+ }
80
+
81
+ // LSPTextEdit is a single text edit in a workspace edit.
82
+ type LSPTextEdit struct {
83
+ Range LSPRange `json:"range"`
84
+ NewText string `json:"newText"`
85
+ }
86
+
87
+ // LSPDocumentVersion carries the LSP textDocument version associated
88
+ // with a publishDiagnostics notification. Plugin sources use it to drop
89
+ // stale findings (LSP guarantees that diagnostics whose version does
90
+ // not match the current document are discarded by the editor anyway,
91
+ // but plugins that cache work-in-progress benefit from seeing it).
92
+ //
93
+ // Version is nil when upstream omitted the field — that is legal in
94
+ // LSP and the plugin source should treat it as "version unknown".
95
+ type LSPDocumentVersion struct {
96
+ URI string
97
+ Version *int
98
+ }
99
+
100
+ // PluginSource is the seam between the LSP proxy and ttsc's plugin
101
+ // pipeline. Returning empty slices/nil is a valid "no contribution"
102
+ // answer; ttscserver still forwards the upstream tsgo response verbatim.
103
+ //
104
+ // The proxy never holds a PluginSource lock across upstream traffic, so
105
+ // implementations must be safe to call from multiple goroutines.
106
+ type PluginSource interface {
107
+ // Diagnostics returns ttsc plugin diagnostics for the document the
108
+ // proxy is about to publish. doc.Version is nil when upstream omitted
109
+ // the field. The proxy appends these to whatever upstream tsgo
110
+ // published, so duplicates between ttsc and tsgo must be deduplicated
111
+ // on the source side.
112
+ Diagnostics(doc LSPDocumentVersion) []LSPDiagnostic
113
+
114
+ // CodeActions contributes additional actions for the given range. The
115
+ // proxy appends them to the upstream tsgo response.
116
+ CodeActions(uri string, rng LSPRange, ctx LSPCodeActionContext) []LSPCodeAction
117
+
118
+ // ExecuteCommand handles workspace/executeCommand requests whose
119
+ // command id ttsc owns (CommandIDs reports the prefix). A nil edit
120
+ // means the command ran but produced no workspace changes; a non-nil
121
+ // error surfaces as an LSP error response.
122
+ ExecuteCommand(command string, args []json.RawMessage) (*LSPWorkspaceEdit, error)
123
+
124
+ // CommandIDs lists the workspace command ids ttsc handles locally so
125
+ // the proxy never forwards them to upstream tsgo.
126
+ CommandIDs() []string
127
+ }
128
+
129
+ // NullPluginSource is the zero-contribution PluginSource used when the
130
+ // LSP server is hosted without any ttsc plugin pipeline (smoke tests,
131
+ // docs build). Every method returns an empty result so the proxy still
132
+ // exercises its merge paths even with no plugin activity.
133
+ type NullPluginSource struct{}
134
+
135
+ // Diagnostics returns no plugin diagnostics.
136
+ func (NullPluginSource) Diagnostics(LSPDocumentVersion) []LSPDiagnostic { return nil }
137
+
138
+ // CodeActions returns no plugin code actions.
139
+ func (NullPluginSource) CodeActions(string, LSPRange, LSPCodeActionContext) []LSPCodeAction {
140
+ return nil
141
+ }
142
+
143
+ // ExecuteCommand reports that the command is not handled.
144
+ func (NullPluginSource) ExecuteCommand(string, []json.RawMessage) (*LSPWorkspaceEdit, error) {
145
+ return nil, ErrCommandNotHandled
146
+ }
147
+
148
+ // CommandIDs returns an empty slice so the proxy forwards every command
149
+ // to upstream tsgo.
150
+ func (NullPluginSource) CommandIDs() []string { return nil }