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.
- package/README.md +8 -0
- package/cmd/ttsc/api_compile.go +106 -106
- package/cmd/ttsc/api_transform.go +54 -54
- package/cmd/ttsc/main.go +103 -103
- package/cmd/ttscserver/helpers_test.go +27 -0
- package/cmd/ttscserver/main.go +154 -0
- package/cmd/ttscserver/run_lsp_reports_getwd_error_test.go +34 -0
- package/cmd/ttscserver/run_lsp_reports_server_error_test.go +42 -0
- package/driver/lsp_envelope.go +136 -0
- package/driver/lsp_frame.go +121 -0
- package/driver/lsp_plugin.go +150 -0
- package/driver/lsp_proxy.go +394 -0
- package/driver/lsp_server.go +197 -0
- package/go.mod +4 -0
- package/go.sum +4 -0
- package/internal/cwd/cwd.go +8 -8
- package/lib/TtscCompiler.d.ts +15 -16
- package/lib/TtscCompiler.js +15 -16
- package/lib/TtscCompiler.js.map +1 -1
- package/lib/compiler/internal/compileProjectInMemory.d.ts +4 -1
- package/lib/compiler/internal/compileProjectInMemory.js +4 -1
- package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
- package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
- package/lib/compiler/internal/runBuild.js +43 -1
- package/lib/compiler/internal/runBuild.js.map +1 -1
- package/lib/compiler/internal/sharedHostHelpers.d.ts +12 -12
- package/lib/compiler/internal/sharedHostHelpers.js +12 -12
- package/lib/defineTtscPlugin.d.ts +18 -0
- package/lib/defineTtscPlugin.js +7 -0
- package/lib/defineTtscPlugin.js.map +1 -0
- package/lib/index.d.ts +4 -3
- package/lib/index.js +4 -3
- package/lib/index.js.map +1 -1
- package/lib/launcher/internal/prepareExecution.js +1 -1
- package/lib/launcher/internal/prepareExecution.js.map +1 -1
- package/lib/launcher/internal/resolveTtscserverBinary.d.ts +12 -0
- package/lib/launcher/internal/resolveTtscserverBinary.js +43 -0
- package/lib/launcher/internal/resolveTtscserverBinary.js.map +1 -0
- package/lib/launcher/internal/runTtsc.js +47 -8
- package/lib/launcher/internal/runTtsc.js.map +1 -1
- package/lib/launcher/internal/runTtscserver.d.ts +20 -0
- package/lib/launcher/internal/runTtscserver.js +125 -0
- package/lib/launcher/internal/runTtscserver.js.map +1 -0
- package/lib/launcher/internal/runTtsx.js +1 -1
- package/lib/launcher/internal/runTtsx.js.map +1 -1
- package/lib/launcher/ttscserver.d.ts +2 -0
- package/lib/launcher/ttscserver.js +9 -0
- package/lib/launcher/ttscserver.js.map +1 -0
- package/lib/plugin/internal/buildSourcePlugin.d.ts +9 -0
- package/lib/plugin/internal/buildSourcePlugin.js +197 -2
- package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
- package/lib/plugin/internal/loadProjectPlugins.js +86 -8
- package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
- package/lib/structures/ITtscCompilerContext.d.ts +1 -1
- package/lib/structures/ITtscCompilerDiagnostic.d.ts +3 -3
- package/lib/structures/ITtscCompilerResult.d.ts +10 -22
- package/lib/structures/ITtscCompilerTransformation.d.ts +2 -2
- package/lib/structures/ITtscPlugin.d.ts +37 -4
- package/lib/structures/ITtscPlugin.js +0 -4
- package/lib/structures/ITtscPlugin.js.map +1 -1
- package/lib/structures/ITtscPluginContributor.d.ts +26 -0
- package/lib/structures/ITtscPluginContributor.js +3 -0
- package/lib/structures/ITtscPluginContributor.js.map +1 -0
- package/lib/structures/ITtscPluginFactoryContext.d.ts +5 -5
- package/lib/structures/ITtscProjectPluginConfig.d.ts +2 -2
- package/lib/structures/TtscPluginStage.d.ts +4 -3
- package/lib/structures/index.d.ts +1 -0
- package/lib/structures/index.js +1 -0
- package/lib/structures/index.js.map +1 -1
- package/lib/structures/internal/ITtscLoadedNativePlugin.d.ts +3 -0
- package/lib/structures/internal/TtscBuildOptions.d.ts +13 -0
- package/lib/structures/internal/TtscCommonOptions.d.ts +5 -2
- package/package.json +10 -9
- package/shim/lsp/extra-shim.json +4 -0
- package/shim/lsp/go.mod +16 -0
- package/shim/lsp/go.sum +26 -0
- package/shim/lsp/shim.go +42 -0
- package/shim/printer/shim.go +5 -5
- package/src/TtscCompiler.ts +15 -16
- package/src/compiler/internal/compileProjectInMemory.ts +4 -1
- package/src/compiler/internal/project/readProjectConfig.ts +0 -1
- package/src/compiler/internal/runBuild.ts +43 -1
- package/src/compiler/internal/sharedHostHelpers.ts +12 -12
- package/src/defineTtscPlugin.ts +26 -0
- package/src/index.ts +4 -3
- package/src/launcher/internal/prepareExecution.ts +1 -1
- package/src/launcher/internal/resolveTtscserverBinary.ts +51 -0
- package/src/launcher/internal/runTtsc.ts +53 -11
- package/src/launcher/internal/runTtscserver.ts +92 -0
- package/src/launcher/internal/runTtsx.ts +1 -1
- package/src/launcher/ttscserver.ts +8 -0
- package/src/plugin/internal/buildSourcePlugin.ts +231 -12
- package/src/plugin/internal/loadProjectPlugins.ts +108 -8
- package/src/structures/ITtscCompilerContext.ts +1 -1
- package/src/structures/ITtscCompilerDiagnostic.ts +3 -3
- package/src/structures/ITtscCompilerResult.ts +10 -22
- package/src/structures/ITtscCompilerTransformation.ts +2 -2
- package/src/structures/ITtscPlugin.ts +37 -11
- package/src/structures/ITtscPluginContributor.ts +27 -0
- package/src/structures/ITtscPluginFactoryContext.ts +5 -5
- package/src/structures/ITtscProjectPluginConfig.ts +2 -2
- package/src/structures/TtscPluginStage.ts +4 -3
- package/src/structures/index.ts +1 -0
- package/src/structures/internal/ITtscLoadedNativePlugin.ts +3 -0
- package/src/structures/internal/TtscBuildOptions.ts +13 -0
- 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 }
|