Skip to content

Commit c19d7da

Browse files
authored
refactor: make language server code reusable (#2255)
<!-- Thanks for sending a pull request! Here are some tips for you: 1. If this is your first time, please read our contributor guidelines: https://github.com/terramate-io/terramate/blob/main/CONTRIBUTING.md 2. If the PR is unfinished, mark it as draft: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/changing-the-stage-of-a-pull-request 3. Please update the PR title using the Conventional Commits convention: https://www.conventionalcommits.org/en/v1.0.0/ Example: feat: add support for XYZ. --> ## What this PR does / why we need it: This PR moves the code that runs the language server package so that it can be-reused. It also adds a mechanism to parametrize the parser used by the language server, so that it can be extended by the caller. ## Does this PR introduce a user-facing change? <!-- If no, just write "no" in the block below. If yes, please explain the change and update documentation and the CHANGELOG.md file accordingly. --> ``` no ``` <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Makes the language server runnable via a reusable RunServer and introduces option-based configuration (logger and HCL options). > > - **Language Server (ls)**: > - **Runner extraction**: New `ls/runner.go` with `RunServer(...)`, signal handling, flags, and logging; `cmd/terramate-ls/main.go` now delegates to it. > - **Configurable server**: Introduces `Option`, `WithLogger`, and `WithHCLOptions`; removes `ServerWithLogger`; tests updated to use `NewServer(..., WithLogger(...))`. > - **Parser flexibility**: Server aggregates experiments with provided HCL options and passes them to `hcl.NewTerramateParser`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ece9700. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
2 parents a18719b + ece9700 commit c19d7da

File tree

5 files changed

+146
-116
lines changed

5 files changed

+146
-116
lines changed

.gitignore

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,4 @@ cosign.pub
2727

2828
# Claude
2929
.claude/
30-
CLAUDE.md
31-
32-
# Terramate binaries
33-
terramate
34-
terramate-ls
30+
CLAUDE.md

cmd/terramate-ls/main.go

Lines changed: 1 addition & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -8,110 +8,9 @@
88
package main
99

1010
import (
11-
"context"
12-
"flag"
13-
"fmt"
14-
"io"
15-
16-
"os"
17-
"os/signal"
18-
"syscall"
19-
"time"
20-
21-
"github.com/rs/zerolog"
22-
"github.com/rs/zerolog/log"
23-
"github.com/terramate-io/terramate"
2411
tmls "github.com/terramate-io/terramate/ls"
25-
"go.lsp.dev/jsonrpc2"
26-
)
27-
28-
const (
29-
defaultLogLevel = "info"
30-
defaultLogFmt = "text"
31-
)
32-
33-
var (
34-
modeFlag = flag.String("mode", "stdio", "communication mode (stdio)")
35-
versionFlag = flag.Bool("version", false, "print version and exit")
36-
logLevelFlag = flag.String(
37-
"log-level", defaultLogLevel,
38-
"Log level to use: 'trace', 'debug', 'info', 'warn', 'error', or 'fatal'",
39-
)
40-
logFmtFlag = flag.String(
41-
"log-fmt", defaultLogFmt,
42-
"Log format to use: 'console', 'text', or 'json'.",
43-
)
44-
45-
defaultLogWriter = os.Stderr
4612
)
4713

4814
func main() {
49-
flag.Parse()
50-
51-
if *versionFlag {
52-
fmt.Println(terramate.Version())
53-
os.Exit(0)
54-
}
55-
56-
// TODO(i4k): implement other modes.
57-
if *modeFlag != "stdio" {
58-
fmt.Println("terramate-ls only supports stdio mode")
59-
os.Exit(1)
60-
}
61-
62-
configureLogging(*logLevelFlag, *logFmtFlag, defaultLogWriter)
63-
runServer(&readWriter{os.Stdin, os.Stdout})
64-
}
65-
66-
func runServer(conn io.ReadWriteCloser) {
67-
logger := log.With().
68-
Str("action", "main.runServer()").
69-
Logger()
70-
71-
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill, syscall.SIGTERM)
72-
defer stop()
73-
74-
logger.Info().
75-
Str("mode", *modeFlag).
76-
Msg("Starting Terramate Language Server")
77-
78-
rpcConn := jsonrpc2.NewConn(jsonrpc2.NewStream(conn))
79-
server := tmls.NewServer(rpcConn)
80-
81-
rpcConn.Go(ctx, server.Handler)
82-
<-rpcConn.Done()
83-
}
84-
85-
type readWriter struct {
86-
io.Reader
87-
io.Writer
88-
}
89-
90-
func (s *readWriter) Close() error { return nil }
91-
92-
func configureLogging(logLevel string, logFmt string, output io.Writer) {
93-
switch logLevel {
94-
case "trace", "debug", "info", "warn", "error", "fatal":
95-
zloglevel, err := zerolog.ParseLevel(logLevel)
96-
97-
if err != nil {
98-
_, _ = fmt.Fprintf(defaultLogWriter, "error: failed to parse -log-level=%s\n", logLevel)
99-
os.Exit(1)
100-
}
101-
102-
zerolog.SetGlobalLevel(zloglevel)
103-
default:
104-
_, _ = fmt.Fprintf(defaultLogWriter, "error: log level %q not supported\n", logLevel)
105-
os.Exit(1)
106-
}
107-
108-
switch logFmt {
109-
case "json":
110-
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
111-
log.Logger = log.Output(output)
112-
case "text": // no color
113-
log.Logger = log.Output(zerolog.ConsoleWriter{Out: output, NoColor: true, TimeFormat: time.RFC3339})
114-
default: // default: console mode using color
115-
log.Logger = log.Output(zerolog.ConsoleWriter{Out: output, NoColor: false, TimeFormat: time.RFC3339})
116-
}
15+
tmls.RunServer()
11716
}

ls/definition_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func newTestServer(t testingTB, workspaces ...string) *Server {
3939
logger = zerolog.Nop()
4040
}
4141

42-
srv := ServerWithLogger(conn, logger)
42+
srv := NewServer(conn, WithLogger(logger))
4343
srv.workspaces = workspaces
4444

4545
return srv

ls/ls.go

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ type Server struct {
3737
workspaces []string
3838
handlers handlers
3939

40+
hclOptions []hcl.Option
41+
4042
// documents stores open document content by file path
4143
documents map[string][]byte
4244
documentsMu sync.RWMutex
@@ -54,22 +56,39 @@ type handler = func(
5456

5557
type handlers map[string]handler
5658

57-
// NewServer creates a new language server.
58-
func NewServer(conn jsonrpc2.Conn) *Server {
59-
return ServerWithLogger(conn, log.Logger)
60-
}
59+
// Option type for the language server.
60+
type Option func(*Server)
6161

62-
// ServerWithLogger creates a new language server with a custom logger.
63-
func ServerWithLogger(conn jsonrpc2.Conn, l zerolog.Logger) *Server {
62+
// NewServer creates a new language server.
63+
func NewServer(conn jsonrpc2.Conn, opts ...Option) *Server {
6464
s := &Server{
6565
conn: conn,
6666
documents: make(map[string][]byte),
67-
log: l,
67+
log: log.Logger,
6868
}
69+
70+
for _, opt := range opts {
71+
opt(s)
72+
}
73+
6974
s.buildHandlers()
7075
return s
7176
}
7277

78+
// WithLogger sets a custom logger.
79+
func WithLogger(l zerolog.Logger) Option {
80+
return func(s *Server) {
81+
s.log = l
82+
}
83+
}
84+
85+
// WithHCLOptions sets the HCL parser options.
86+
func WithHCLOptions(hclOpts ...hcl.Option) Option {
87+
return func(s *Server) {
88+
s.hclOptions = hclOpts
89+
}
90+
}
91+
7392
// getDocumentContent returns the content of an open document, or reads from disk if not cached
7493
func (s *Server) getDocumentContent(fname string) ([]byte, error) {
7594
s.documentsMu.RLock()
@@ -474,8 +493,10 @@ func (s *Server) checkFiles(files []string, currentFile string, currentContent s
474493
} else if err == nil {
475494
experiments = root.Tree().Node.Experiments()
476495
}
496+
opts := []hcl.Option{hcl.WithExperiments(experiments...)}
497+
opts = append(opts, s.hclOptions...)
477498

478-
parser, err := hcl.NewTerramateParser(rootdir, dir, hcl.WithExperiments(experiments...))
499+
parser, err := hcl.NewTerramateParser(rootdir, dir, opts...)
479500
if err != nil {
480501
return errors.E(err, "failed to create terramate parser")
481502
}

ls/runner.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright 2025 Terramate GmbH
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package tmls
5+
6+
import (
7+
"context"
8+
"flag"
9+
"fmt"
10+
"io"
11+
12+
"os"
13+
"os/signal"
14+
"syscall"
15+
"time"
16+
17+
"github.com/rs/zerolog"
18+
"github.com/rs/zerolog/log"
19+
"github.com/terramate-io/terramate"
20+
"go.lsp.dev/jsonrpc2"
21+
)
22+
23+
const (
24+
defaultLogLevel = "info"
25+
defaultLogFmt = "text"
26+
)
27+
28+
var (
29+
modeFlag = flag.String("mode", "stdio", "communication mode (stdio)")
30+
versionFlag = flag.Bool("version", false, "print version and exit")
31+
logLevelFlag = flag.String(
32+
"log-level", defaultLogLevel,
33+
"Log level to use: 'trace', 'debug', 'info', 'warn', 'error', or 'fatal'",
34+
)
35+
logFmtFlag = flag.String(
36+
"log-fmt", defaultLogFmt,
37+
"Log format to use: 'console', 'text', or 'json'.",
38+
)
39+
40+
defaultLogWriter = os.Stderr
41+
)
42+
43+
// RunServer runs the server as a standalone binary. It should be invoked from main directly,
44+
// as it will parse arguments and set up global logging.
45+
func RunServer(opts ...Option) {
46+
flag.Parse()
47+
48+
if *versionFlag {
49+
fmt.Println(terramate.Version())
50+
os.Exit(0)
51+
}
52+
53+
// TODO(i4k): implement other modes.
54+
if *modeFlag != "stdio" {
55+
fmt.Println("terramate-ls only supports stdio mode")
56+
os.Exit(1)
57+
}
58+
59+
configureLogging(*logLevelFlag, *logFmtFlag, defaultLogWriter)
60+
runServer(&readWriter{os.Stdin, os.Stdout}, opts...)
61+
}
62+
63+
func runServer(conn io.ReadWriteCloser, opts ...Option) {
64+
logger := log.With().
65+
Str("action", "main.runServer()").
66+
Logger()
67+
68+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill, syscall.SIGTERM)
69+
defer stop()
70+
71+
logger.Info().
72+
Str("mode", *modeFlag).
73+
Msg("Starting Terramate Language Server")
74+
75+
rpcConn := jsonrpc2.NewConn(jsonrpc2.NewStream(conn))
76+
server := NewServer(rpcConn, opts...)
77+
78+
rpcConn.Go(ctx, server.Handler)
79+
<-rpcConn.Done()
80+
}
81+
82+
type readWriter struct {
83+
io.Reader
84+
io.Writer
85+
}
86+
87+
func (s *readWriter) Close() error { return nil }
88+
89+
func configureLogging(logLevel string, logFmt string, output io.Writer) {
90+
switch logLevel {
91+
case "trace", "debug", "info", "warn", "error", "fatal":
92+
zloglevel, err := zerolog.ParseLevel(logLevel)
93+
94+
if err != nil {
95+
_, _ = fmt.Fprintf(defaultLogWriter, "error: failed to parse -log-level=%s\n", logLevel)
96+
os.Exit(1)
97+
}
98+
99+
zerolog.SetGlobalLevel(zloglevel)
100+
default:
101+
_, _ = fmt.Fprintf(defaultLogWriter, "error: log level %q not supported\n", logLevel)
102+
os.Exit(1)
103+
}
104+
105+
switch logFmt {
106+
case "json":
107+
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
108+
log.Logger = log.Output(output)
109+
case "text": // no color
110+
log.Logger = log.Output(zerolog.ConsoleWriter{Out: output, NoColor: true, TimeFormat: time.RFC3339})
111+
default: // default: console mode using color
112+
log.Logger = log.Output(zerolog.ConsoleWriter{Out: output, NoColor: false, TimeFormat: time.RFC3339})
113+
}
114+
}

0 commit comments

Comments
 (0)