diff --git a/README.md b/README.md index 962946c..b7acc76 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,12 @@ Configuration lives in a `thrift-ls.json` file, discovered by walking up from the file being formatted or the workspace root (like Biome). Set the `THRIFT_LS_CONFIG` env var to point at an explicit config file. +Layered from lowest to highest precedence: defaults, the config file, LSP +workspace settings, CLI flags. The VS Code extension exposes the formatter +options as `thrift-ls.*` settings (see `vscode/README.md`), which override +the config file for LSP formatting; `includePaths` and `logLevel` are not +available as settings — use the config file or flags for those. + ```json { "printWidth": 100, diff --git a/lsp/codeaction.go b/lsp/codeaction.go index 090aefc..c15f995 100644 --- a/lsp/codeaction.go +++ b/lsp/codeaction.go @@ -13,7 +13,7 @@ import ( // document when the range covers it, or the range when it is a selection. func (s *Server) codeAction(ctx context.Context, params *protocol.CodeActionParams) ([]protocol.CommandOrCodeAction, error) { return withFile(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot, fh cache.FileHandle) ([]protocol.CommandOrCodeAction, error) { - action, err := source.FormatDocumentAction(ctx, ss, fh, s.formatOpts) + action, err := source.FormatDocumentAction(ctx, ss, fh, s.formatOptions()) if err != nil { return nil, err } diff --git a/lsp/didchange_test.go b/lsp/didchange_test.go index 7280c46..33da5c4 100644 --- a/lsp/didchange_test.go +++ b/lsp/didchange_test.go @@ -12,8 +12,8 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/karitham/thrift-ls/formatter" "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/options" ) // recordingClient records PublishDiagnostics calls per URI; every other @@ -53,7 +53,7 @@ func (c *recordingClient) count(file uri.URI) int { } func newTestServer(client protocol.Client) *Server { - return NewServer(cache.New(nil), client, formatter.Options{}) + return NewServer(cache.New(nil), client, options.Patch{}) } func writeFile(t *testing.T, path, content string) { diff --git a/lsp/format.go b/lsp/format.go index 24fb4ab..8fd1c00 100644 --- a/lsp/format.go +++ b/lsp/format.go @@ -11,7 +11,7 @@ import ( func (s *Server) formatting(ctx context.Context, params *protocol.DocumentFormattingParams) (result []protocol.TextEdit, err error) { return withFile(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot, fh cache.FileHandle) ([]protocol.TextEdit, error) { - edit, err := source.FormatDocument(ctx, ss, fh, s.formatOpts) + edit, err := source.FormatDocument(ctx, ss, fh, s.formatOptions()) if err != nil { return nil, err } @@ -26,6 +26,6 @@ func (s *Server) formatting(ctx context.Context, params *protocol.DocumentFormat func (s *Server) rangeFormatting(ctx context.Context, params *protocol.DocumentRangeFormattingParams) (result []protocol.TextEdit, err error) { return withFile(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot, fh cache.FileHandle) ([]protocol.TextEdit, error) { - return source.FormatRange(ctx, ss, fh, s.formatOpts, params.Range) + return source.FormatRange(ctx, ss, fh, s.formatOptions(), params.Range) }) } diff --git a/lsp/format_range_server_test.go b/lsp/format_range_server_test.go index 0d74e1d..0a40f01 100644 --- a/lsp/format_range_server_test.go +++ b/lsp/format_range_server_test.go @@ -9,8 +9,8 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/karitham/thrift-ls/formatter" "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/options" ) func TestServerRangeFormatting(t *testing.T) { @@ -27,7 +27,7 @@ struct B { struct C { 3: i64 c } ` - srv := NewServer(cache.New(nil), nil, formatter.Options{}) + srv := NewServer(cache.New(nil), nil, options.Patch{}) require.NoError(t, srv.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{ TextDocument: protocol.TextDocumentItem{ diff --git a/lsp/impl_test.go b/lsp/impl_test.go index 06eb9e7..4e3ee3e 100644 --- a/lsp/impl_test.go +++ b/lsp/impl_test.go @@ -11,8 +11,8 @@ import ( "go.lsp.dev/protocol" "go.lsp.dev/uri" - "github.com/karitham/thrift-ls/formatter" "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/options" ) func Test_DidOpen(t *testing.T) { @@ -37,7 +37,7 @@ struct Test { } cache := cache.New(nil) - srv := NewServer(cache, nil, formatter.Options{}) + srv := NewServer(cache, nil, options.Patch{}) err = srv.DidOpen(ctx, params) assert.NoError(t, err) @@ -95,7 +95,7 @@ struct Test { } cache := cache.New(nil) - srv := NewServer(cache, nil, formatter.Options{}) + srv := NewServer(cache, nil, options.Patch{}) err = srv.DidOpen(ctx, openParams) assert.NoError(t, err) @@ -157,7 +157,7 @@ struct Test { } cache := cache.New(nil) - srv := NewServer(cache, nil, formatter.Options{}) + srv := NewServer(cache, nil, options.Patch{}) err = srv.DidOpen(ctx, openParams) assert.NoError(t, err) @@ -254,7 +254,7 @@ struct Test { } cache := cache.New([]string{"/tmp"}) - srv := NewServer(cache, nil, formatter.Options{}) + srv := NewServer(cache, nil, options.Patch{}) err = srv.DidOpen(ctx, baseParams) assert.NoError(t, err) @@ -360,7 +360,7 @@ struct Other { } cache := cache.New(nil) - srv := NewServer(cache, nil, formatter.Options{}) + srv := NewServer(cache, nil, options.Patch{}) err = srv.DidOpen(ctx, file1Params) assert.NoError(t, err) @@ -418,7 +418,7 @@ func Test_DidChangeWorkspaceFolders(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dirA, "a.thrift"), []byte("struct FromA {}"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dirB, "b.thrift"), []byte("struct FromB {}"), 0o644)) - srv := NewServer(cache.New(nil), nil, formatter.Options{}) + srv := NewServer(cache.New(nil), nil, options.Patch{}) // Adding folders walks them and registers their thrift files. err := srv.DidChangeWorkspaceFolders(ctx, &protocol.DidChangeWorkspaceFoldersParams{ @@ -482,7 +482,7 @@ func Test_InitializeDefersTheWorkspaceWalk(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(dir, "nested"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(dir, "nested", "b.thrift"), []byte("struct FromB {}"), 0o644)) - srv := NewServer(cache.New(nil), nil, formatter.Options{}) + srv := NewServer(cache.New(nil), nil, options.Patch{}) _, err := srv.Initialize(t.Context(), &protocol.InitializeParams{ WorkspaceFoldersInitializeParams: protocol.WorkspaceFoldersInitializeParams{ @@ -530,7 +530,7 @@ func Test_CodeActionFormatDocument(t *testing.T) { t.Run(tt.name, func(t *testing.T) { fileURI := uri.File("/tmp/format.thrift") - srv := NewServer(cache.New(nil), nil, formatter.Options{}) + srv := NewServer(cache.New(nil), nil, options.Patch{}) require.NoError(t, srv.DidOpen(t.Context(), &protocol.DidOpenTextDocumentParams{ TextDocument: protocol.TextDocumentItem{ URI: fileURI, @@ -594,7 +594,7 @@ struct StrikeRouge { }` testURI := uri.URI("file:///tmp/test.thrift") - srv := NewServer(cache.New([]string{"/tmp"}), nil, formatter.Options{}) + srv := NewServer(cache.New([]string{"/tmp"}), nil, options.Patch{}) require.NoError(t, srv.DidOpen(ctx, baseParams)) require.NoError(t, srv.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{ TextDocument: protocol.TextDocumentItem{ @@ -655,7 +655,7 @@ struct StrikeRouge { }` testURI := uri.URI("file:///tmp/test.thrift") - srv := NewServer(cache.New([]string{"/tmp"}), nil, formatter.Options{}) + srv := NewServer(cache.New([]string{"/tmp"}), nil, options.Patch{}) require.NoError(t, srv.DidOpen(ctx, baseParams)) require.NoError(t, srv.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{ TextDocument: protocol.TextDocumentItem{ diff --git a/lsp/include_paths_test.go b/lsp/include_paths_test.go index eb9c6bf..f7f8b13 100644 --- a/lsp/include_paths_test.go +++ b/lsp/include_paths_test.go @@ -8,8 +8,8 @@ import ( "github.com/stretchr/testify/assert" "go.lsp.dev/uri" - "github.com/karitham/thrift-ls/formatter" "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/options" ) // TestServerIncludePathsFlow verifies that include paths configured on the @@ -23,7 +23,7 @@ func TestServerIncludePathsFlow(t *testing.T) { assert.NoError(t, os.WriteFile(shared, []byte("struct Shared {}"), 0o644)) c := cache.New([]string{includeDir}) - srv := NewServer(c, nil, formatter.DefaultOptions()) + srv := NewServer(c, nil, options.Default()) // Views are created per workspace folder at initialization. srv.session.AddView(uri.File(dir)) diff --git a/lsp/initialize.go b/lsp/initialize.go index 0110bcf..ac17d0b 100644 --- a/lsp/initialize.go +++ b/lsp/initialize.go @@ -45,6 +45,16 @@ func (s *Server) initialize(ctx context.Context, params *protocol.InitializePara s.folders = folders + // Workspace settings (initializationOptions) overlay the base + // configuration; didChangeConfiguration updates them later. + if len(params.InitializationOptions) > 0 { + if patch, err := lspSettings(params.InitializationOptions); err != nil { + slog.Error("initializationOptions rejected", "err", err) + } else { + s.setWorkspaceSettings(*patch) + } + } + // Kick off the workspace walk immediately, off the request path, so // the workspace is indexed by the time the client makes its first // request. The walk is async (it parses every thrift file) and the diff --git a/lsp/server.go b/lsp/server.go index eed43d1..3e28527 100644 --- a/lsp/server.go +++ b/lsp/server.go @@ -13,13 +13,25 @@ import ( "github.com/karitham/thrift-ls/formatter" "github.com/karitham/thrift-ls/lsp/cache" "github.com/karitham/thrift-ls/lsp/source" + "github.com/karitham/thrift-ls/options" ) type Server struct { cache *cache.Cache session *cache.Session - client protocol.Client + client protocol.Client + + // base is the process configuration: defaults overlaid with the config + // file and CLI flags. It never changes; workspace settings from + // initializationOptions and didChangeConfiguration are overlaid on it. + base options.Patch + + // opts is the effective configuration (base with workspace settings + // applied) and formatOpts its resolved formatter options. Both are + // guarded by optsMu because settings can change between requests. + optsMu sync.RWMutex + opts options.Patch formatOpts formatter.Options // folders are the workspace folders from the initialize request; the @@ -36,13 +48,46 @@ type Server struct { dirWalkOnce sync.Once } -func NewServer(c *cache.Cache, client protocol.Client, formatOpts formatter.Options) *Server { - return &Server{ - cache: c, - session: cache.NewSession(c), - client: client, - formatOpts: formatOpts, +// NewServer returns a Server formatting with the base options. The base is +// expected to validate; workspace settings overlay it at initialize time. +func NewServer(c *cache.Cache, client protocol.Client, base options.Patch) *Server { + s := &Server{ + cache: c, + session: cache.NewSession(c), + client: client, + base: base, } + s.opts = base + s.formatOpts, _ = base.Formatter() + + return s +} + +// setWorkspaceSettings overlays workspace settings on the base +// configuration. Invalid settings are rejected: the previous configuration +// stays in effect and the error is logged. +func (s *Server) setWorkspaceSettings(overlay options.Patch) { + merged := overlay.Apply(s.base) + fopts, err := merged.Formatter() + if err != nil { + slog.Error("workspace settings rejected", "err", err) + return + } + + s.optsMu.Lock() + s.opts = merged + s.formatOpts = fopts + s.optsMu.Unlock() + + slog.Debug("workspace settings applied") +} + +// formatOptions returns the current effective formatter options. +func (s *Server) formatOptions() formatter.Options { + s.optsMu.RLock() + defer s.optsMu.RUnlock() + + return s.formatOpts } func (s *Server) Initialize(ctx context.Context, params *protocol.InitializeParams) (result *protocol.InitializeResult, err error) { @@ -129,6 +174,18 @@ func (s *Server) DidChange(ctx context.Context, params *protocol.DidChangeTextDo } func (s *Server) DidChangeConfiguration(ctx context.Context, params *protocol.DidChangeConfigurationParams) (err error) { + if len(params.Settings) == 0 { + return nil + } + + patch, err := lspSettings(params.Settings) + if err != nil { + slog.Error("didChangeConfiguration rejected", "err", err) + return nil + } + + s.setWorkspaceSettings(*patch) + return nil } diff --git a/lsp/settings.go b/lsp/settings.go new file mode 100644 index 0000000..537444e --- /dev/null +++ b/lsp/settings.go @@ -0,0 +1,28 @@ +package lsp + +import ( + "encoding/json" + "fmt" + + "github.com/karitham/thrift-ls/options" +) + +// lspSettings converts the settings document sent by an LSP client +// (initializationOptions or the settings of didChangeConfiguration) into an +// options patch. The `path` extension setting is not an options key and is +// dropped; unknown keys are rejected so typos fail loudly instead of +// formatting with silently stale options. +func lspSettings(data []byte) (*options.Patch, error) { + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("lsp settings: %w", err) + } + delete(m, "path") + + clean, err := json.Marshal(m) + if err != nil { + return nil, fmt.Errorf("lsp settings: %w", err) + } + + return options.Parse(clean) +} diff --git a/lsp/settings_test.go b/lsp/settings_test.go new file mode 100644 index 0000000..6f0e233 --- /dev/null +++ b/lsp/settings_test.go @@ -0,0 +1,81 @@ +package lsp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.lsp.dev/protocol" + + "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/options" +) + +func TestLSPSettings(t *testing.T) { + t.Run("parses options and drops the path key", func(t *testing.T) { + patch, err := lspSettings([]byte(`{"path":"/usr/bin/thrift-ls","printWidth":30,"align":"assign"}`)) + require.NoError(t, err) + require.NotNil(t, patch.PrintWidth) + assert.Equal(t, 30, *patch.PrintWidth) + assert.Equal(t, "assign", *patch.Align) + }) + + t.Run("rejects unknown keys", func(t *testing.T) { + _, err := lspSettings([]byte(`{"printWidth":30,"typoKey":1}`)) + assert.Error(t, err) + }) + + t.Run("rejects invalid values", func(t *testing.T) { + _, err := lspSettings([]byte(`{"align":"bogus"}`)) + assert.Error(t, err) + }) +} + +func TestWorkspaceSettings(t *testing.T) { + const file = "file:///tmp/settings.thrift" + content := "struct LongName{1: string fieldNameThatIsQuiteLong}\n" + + ctx := t.Context() + srv := NewServer(cache.New(nil), nil, options.Patch{}) + + require.NoError(t, srv.DidOpen(ctx, &protocol.DidOpenTextDocumentParams{ + TextDocument: protocol.TextDocumentItem{ + URI: file, + LanguageID: "thrift", + Version: 0, + Text: content, + }, + })) + + format := func() string { + edits, err := srv.Formatting(ctx, &protocol.DocumentFormattingParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: file}, + }) + require.NoError(t, err) + require.Len(t, edits, 1) + return edits[0].NewText + } + + // Default width 80 keeps the struct on one line. + assert.Equal(t, "struct LongName { 1: string fieldNameThatIsQuiteLong }\n", format()) + + // initializationOptions overlay the base configuration: width 30 breaks it. + _, err := srv.Initialize(ctx, &protocol.InitializeParams{ + InitializationOptions: protocol.LSPAny([]byte(`{"printWidth":30}`)), + }) + require.NoError(t, err) + broken := format() + assert.Equal(t, "struct LongName {\n 1: string fieldNameThatIsQuiteLong\n}\n", broken) + + // didChangeConfiguration replaces the overlay: width 80 folds again. + require.NoError(t, srv.DidChangeConfiguration(ctx, &protocol.DidChangeConfigurationParams{ + Settings: protocol.LSPAny([]byte(`{"printWidth":80}`)), + })) + assert.Equal(t, "struct LongName { 1: string fieldNameThatIsQuiteLong }\n", format()) + + // Invalid settings are rejected and leave the previous ones in effect. + require.NoError(t, srv.DidChangeConfiguration(ctx, &protocol.DidChangeConfigurationParams{ + Settings: protocol.LSPAny([]byte(`{"printWidth":30,"align":"bogus"}`)), + })) + assert.Equal(t, "struct LongName { 1: string fieldNameThatIsQuiteLong }\n", format()) +} diff --git a/lsp/stream.go b/lsp/stream.go index c9d40af..6233d8b 100644 --- a/lsp/stream.go +++ b/lsp/stream.go @@ -6,31 +6,34 @@ import ( "go.lsp.dev/jsonrpc2" "go.lsp.dev/protocol" - "github.com/karitham/thrift-ls/formatter" "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/options" ) type StreamServer struct { - cache *cache.Cache - formatOpts formatter.Options + cache *cache.Cache + config options.Patch } +// Options configures the stream server. Config is the base configuration — +// defaults overlaid with the config file and CLI flags — which workspace +// settings from the client overlay at initialize time. type Options struct { IncludePaths []string - Format formatter.Options + Config options.Patch } func NewStreamServer(opts *Options) *StreamServer { return &StreamServer{ - cache: cache.New(opts.IncludePaths), - formatOpts: opts.Format, + cache: cache.New(opts.IncludePaths), + config: opts.Config, } } func (s *StreamServer) ServeStream(ctx context.Context, conn jsonrpc2.Conn) error { client := protocol.ClientDispatcher(conn) - server := NewServer(s.cache, client, s.formatOpts) + server := NewServer(s.cache, client, s.config) // Clients may or may not send a shutdown message. Make sure the server is // shut down. defer func() { diff --git a/main.go b/main.go index 78458ec..84f179d 100644 --- a/main.go +++ b/main.go @@ -174,14 +174,15 @@ func lspAction(ctx context.Context, cmd *cli.Command) error { tlog.Init(logLevelValue) - fopts, err := patch.Formatter() - if err != nil { + // Validate the effective configuration early; the server re-resolves + // it per request, and workspace settings overlay it at initialize time. + if _, err := patch.Formatter(); err != nil { return err } lspOpts := &lsp.Options{ IncludePaths: derefStrings(patch.IncludePaths), - Format: fopts, + Config: patch, } ss := lsp.NewStreamServer(lspOpts) diff --git a/options/options.go b/options/options.go index 3900089..abd5ce7 100644 --- a/options/options.go +++ b/options/options.go @@ -287,26 +287,40 @@ func isWhitespaceOnly(s string) bool { return true } -// Load reads and parses a config file. Unknown keys are rejected so that -// typos and stale settings (e.g. the removed overrides feature) fail loudly. -func Load(path string) (*Patch, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - +// Parse reads and parses a config document. Unknown keys are rejected so +// that typos and stale settings (e.g. the removed overrides feature) fail +// loudly. Include paths in the document are left as written; Load resolves +// them against the config file's directory. +func Parse(data []byte) (*Patch, error) { var p Patch dec := json.NewDecoder(bytes.NewReader(data)) dec.DisallowUnknownFields() if err := dec.Decode(&p); err != nil { - return nil, fmt.Errorf("options: %s: %w", path, err) + return nil, err } if err := p.Validate(); err != nil { + return nil, err + } + + return &p, nil +} + +// Load reads and parses a config file. Unknown keys are rejected so that +// typos and stale settings (e.g. the removed overrides feature) fail loudly. +func Load(path string) (*Patch, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + p, err := Parse(data) + if err != nil { return nil, fmt.Errorf("options: %s: %w", path, err) } + // Include paths are relative to the config file, not the process CWD, // so resolution works the same for the CLI and the LSP no matter where // the server is launched from. @@ -323,7 +337,7 @@ func Load(path string) (*Patch, error) { p.IncludePaths = &abs } - return &p, nil + return p, nil } // FindConfig returns the config file path for dir: THRIFT_LS_CONFIG when set, diff --git a/vscode/README.md b/vscode/README.md index b462c34..792a652 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -38,6 +38,18 @@ All server features are negotiated over LSP; no editor-specific code beyond the client. The `thrift-ls.downloadServer` command re-downloads the binary (use it to update), and `thrift-ls.openReleases` opens the releases page. +## Settings + +The formatter options (`printWidth`, `indent`, `tabWidth`, `align`, +`separators.*`, `break.*`) are exposed as `thrift-ls.*` settings. They are +sent to the server on startup and re-sent via `didChangeConfiguration` when +they change, so formatting picks up new values without a restart. Settings +override the `thrift-ls.json` config file; CLI flags passed to the server +still win over both. + +`includePaths` and `logLevel` are not exposed as settings — use +`thrift-ls.json` or the `-I` / `-logLevel` flags when launching the server. + ## Development ```bash diff --git a/vscode/package.json b/vscode/package.json index fbc09fb..4989cb6 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -56,6 +56,175 @@ "type": "string", "default": "", "markdownDescription": "Absolute path to the `thrift-ls` binary. Empty (default): search `PATH`, then prompt to download the latest release binary into the extension's storage." + }, + "thrift-ls.printWidth": { + "type": "number", + "default": 80, + "markdownDescription": "Target line width for breaking decisions. Overrides the `thrift-ls.json` config file." + }, + "thrift-ls.indent": { + "type": "string", + "default": " ", + "markdownDescription": "Indentation: a literal string of spaces or tabs, e.g. `\" \"` or `\"\\t\"`." + }, + "thrift-ls.tabWidth": { + "type": "number", + "default": 4, + "markdownDescription": "Display width of a tab when measuring line width." + }, + "thrift-ls.align": { + "type": "string", + "default": "field", + "enum": [ + "field", + "assign", + "disable" + ], + "markdownDescription": "Column alignment of struct/union/exception fields and enum values: `field` (align ids/requiredness/types), `assign` (align `=`), `disable`." + }, + "thrift-ls.separators.structs": { + "type": "string", + "default": "preserve", + "enum": [ + "comma", + "semicolon", + "none", + "preserve" + ], + "markdownDescription": "Trailing separator for struct fields." + }, + "thrift-ls.separators.unions": { + "type": "string", + "default": "preserve", + "enum": [ + "comma", + "semicolon", + "none", + "preserve" + ], + "markdownDescription": "Trailing separator for union fields." + }, + "thrift-ls.separators.exceptions": { + "type": "string", + "default": "preserve", + "enum": [ + "comma", + "semicolon", + "none", + "preserve" + ], + "markdownDescription": "Trailing separator for exception fields." + }, + "thrift-ls.separators.enums": { + "type": "string", + "default": "preserve", + "enum": [ + "comma", + "semicolon", + "none", + "preserve" + ], + "markdownDescription": "Trailing separator for enum values." + }, + "thrift-ls.separators.arguments": { + "type": "string", + "default": "preserve", + "enum": [ + "comma", + "semicolon", + "none", + "preserve" + ], + "markdownDescription": "Trailing separator for function arguments." + }, + "thrift-ls.separators.throws": { + "type": "string", + "default": "preserve", + "enum": [ + "comma", + "semicolon", + "none", + "preserve" + ], + "markdownDescription": "Trailing separator for throws clauses." + }, + "thrift-ls.separators.lists": { + "type": "string", + "default": "preserve", + "enum": [ + "comma", + "semicolon", + "none", + "preserve" + ], + "markdownDescription": "Trailing separator for const list values." + }, + "thrift-ls.separators.maps": { + "type": "string", + "default": "preserve", + "enum": [ + "comma", + "semicolon", + "none", + "preserve" + ], + "markdownDescription": "Trailing separator for const map values." + }, + "thrift-ls.separators.sets": { + "type": "string", + "default": "preserve", + "enum": [ + "comma", + "semicolon", + "none", + "preserve" + ], + "markdownDescription": "Trailing separator for const set values." + }, + "thrift-ls.break.structs": { + "type": "boolean", + "default": false, + "markdownDescription": "Always break struct bodies onto multiple lines." + }, + "thrift-ls.break.unions": { + "type": "boolean", + "default": false, + "markdownDescription": "Always break union bodies onto multiple lines." + }, + "thrift-ls.break.exceptions": { + "type": "boolean", + "default": false, + "markdownDescription": "Always break exception bodies onto multiple lines." + }, + "thrift-ls.break.enums": { + "type": "boolean", + "default": false, + "markdownDescription": "Always break enum bodies onto multiple lines." + }, + "thrift-ls.break.arguments": { + "type": "boolean", + "default": false, + "markdownDescription": "Always break function arguments onto multiple lines." + }, + "thrift-ls.break.throws": { + "type": "boolean", + "default": false, + "markdownDescription": "Always break throws clauses onto multiple lines." + }, + "thrift-ls.break.lists": { + "type": "boolean", + "default": false, + "markdownDescription": "Always break const lists onto multiple lines." + }, + "thrift-ls.break.maps": { + "type": "boolean", + "default": false, + "markdownDescription": "Always break const maps onto multiple lines." + }, + "thrift-ls.break.sets": { + "type": "boolean", + "default": false, + "markdownDescription": "Always break const sets onto multiple lines." } } } diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index a572beb..1de841a 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -21,6 +21,19 @@ const REPO = 'karitham/thrift-ls'; const RELEASES_URL = `https://github.com/${REPO}/releases`; const DOWNLOAD_URL = `${RELEASES_URL}/latest/download`; +// The constructs with per-construct separator and break settings. +const CONSTRUCTS = [ + 'structs', + 'unions', + 'exceptions', + 'enums', + 'arguments', + 'throws', + 'lists', + 'maps', + 'sets', +] as const; + // The release asset and its checksum file for the running platform, or // undefined when no prebuilt binary exists for it. interface ReleaseTarget { @@ -47,6 +60,11 @@ export async function activate(context: ExtensionContext) { const serverOptions: ServerOptions = { command: bin, args: ['lsp'] }; const clientOptions: LanguageClientOptions = { documentSelector: [{ language: 'thrift' }], + // Formatter settings are sent as initializationOptions; the client + // re-sends them via didChangeConfiguration when settings change, so the + // server re-formats with the new values without a restart. + initializationOptions: formatSettings(), + synchronize: { configurationSection: 'thrift-ls' }, }; client = new LanguageClient( @@ -69,6 +87,44 @@ function deactivate(): Thenable | undefined { * offers to download the release binary. Undefined means no server this * session. */ +/** + * formatSettings returns the thrift-ls formatter settings as the options + * document the server expects (the thrift-ls.json schema). The `path` + * setting is not a server option and is left out; the server drops it + * defensively anyway. + */ +function formatSettings(): Record { + const cfg = workspace.getConfiguration('thrift-ls'); + const opts: Record = {}; + for (const key of ['printWidth', 'indent', 'tabWidth', 'align'] as const) { + const value = cfg.get(key); + if (value !== undefined) { + opts[key] = value; + } + } + + const separators: Record = {}; + const breaks: Record = {}; + for (const construct of CONSTRUCTS) { + const separator = cfg.get(`separators.${construct}`); + if (separator !== undefined) { + separators[construct] = separator; + } + const brk = cfg.get(`break.${construct}`); + if (brk !== undefined) { + breaks[construct] = brk; + } + } + if (Object.keys(separators).length > 0) { + opts.separators = separators; + } + if (Object.keys(breaks).length > 0) { + opts.break = breaks; + } + + return opts; +} + async function resolveBinary(context: ExtensionContext): Promise { const configured = workspace.getConfiguration('thrift-ls').get('path'); if (configured) {