diff --git a/commands/serve_command.go b/commands/serve_command.go index 9e33cdbaf..8b6d0d708 100644 --- a/commands/serve_command.go +++ b/commands/serve_command.go @@ -10,6 +10,7 @@ import ( lsctx "github.com/hashicorp/terraform-ls/internal/context" "github.com/hashicorp/terraform-ls/langserver" + "github.com/hashicorp/terraform-ls/langserver/handlers" "github.com/mitchellh/cli" ) @@ -35,14 +36,16 @@ func (c *ServeCommand) Run(args []string) int { syscall.SIGINT, syscall.SIGTERM) defer cancelFunc() - srv := langserver.NewLangServer(ctx, c.Logger) + hp := handlers.New() + srv := langserver.NewLangServer(ctx, hp) + srv.SetLogger(c.Logger) if port != -1 { srv.StartTCP(fmt.Sprintf("localhost:%d", port)) return 0 } - srv.Start(os.Stdin, os.Stdout) + srv.StartAndWait(os.Stdin, os.Stdout) return 0 } diff --git a/internal/terraform/exec/exec.go b/internal/terraform/exec/exec.go index 91314f86e..0e4e1d1e9 100644 --- a/internal/terraform/exec/exec.go +++ b/internal/terraform/exec/exec.go @@ -59,7 +59,7 @@ func (e *Executor) run(args ...string) ([]byte, error) { } var outBuf bytes.Buffer - var errBuf strings.Builder + var errBuf bytes.Buffer path, err := exec.LookPath("terraform") if err != nil { diff --git a/langserver/errors/errors.go b/langserver/errors/errors.go new file mode 100644 index 000000000..3095abf27 --- /dev/null +++ b/langserver/errors/errors.go @@ -0,0 +1,7 @@ +package errors + +import ( + "github.com/creachadair/jrpc2/code" +) + +const ServerNotInitialized code.Code = -32002 diff --git a/langserver/handlers/exit.go b/langserver/handlers/exit.go deleted file mode 100644 index 652939bb5..000000000 --- a/langserver/handlers/exit.go +++ /dev/null @@ -1,11 +0,0 @@ -package handlers - -import ( - "context" - - lsp "github.com/sourcegraph/go-lsp" -) - -func Exit(ctx context.Context, vs lsp.None) error { - return nil -} diff --git a/langserver/handlers.go b/langserver/handlers/handlers.go similarity index 64% rename from langserver/handlers.go rename to langserver/handlers/handlers.go index b105e768b..2f902b451 100644 --- a/langserver/handlers.go +++ b/langserver/handlers/handlers.go @@ -1,4 +1,4 @@ -package langserver +package handlers import ( "context" @@ -12,105 +12,116 @@ import ( lsctx "github.com/hashicorp/terraform-ls/internal/context" "github.com/hashicorp/terraform-ls/internal/filesystem" "github.com/hashicorp/terraform-ls/internal/terraform/exec" - "github.com/hashicorp/terraform-ls/langserver/handlers" + "github.com/hashicorp/terraform-ls/langserver/srvctl" "github.com/sourcegraph/go-lsp" ) -type handlerMap struct { +type handlerProvider struct { logger *log.Logger + srvCtl srvctl.ServerController +} + +func New() *handlerProvider { + return &handlerProvider{} +} - srv *server - srvStopFunc context.CancelFunc +func (hp *handlerProvider) SetLogger(logger *log.Logger) { + hp.logger = logger } -// Map builds out the jrpc2.Map according to the LSP protocol -// and passes related dependencies to methods via context -func (hm *handlerMap) Map() rpch.Map { +// Handlers builds out the jrpc2.Map according to the LSP protocol +// and passes related dependencies to handlers via context +func (hp *handlerProvider) Handlers(ctl srvctl.ServerController) jrpc2.Assigner { + hp.srvCtl = ctl fs := filesystem.NewFilesystem() - fs.SetLogger(hm.logger) - lh := handlers.LogHandler(hm.logger) + fs.SetLogger(hp.logger) + lh := LogHandler(hp.logger) cc := &lsp.ClientCapabilities{} m := map[string]rpch.Func{ "initialize": func(ctx context.Context, req *jrpc2.Request) (interface{}, error) { - err := hm.srv.Initialize(req) + err := hp.srvCtl.Initialize(req) if err != nil { return nil, err } ctx = lsctx.WithFilesystem(fs, ctx) ctx = lsctx.WithClientCapabilitiesSetter(cc, ctx) - return handle(ctx, req, handlers.Initialize) + return handle(ctx, req, Initialize) }, "initialized": func(ctx context.Context, req *jrpc2.Request) (interface{}, error) { - err := hm.srv.ConfirmInitialization(req) + err := hp.srvCtl.ConfirmInitialization(req) if err != nil { return nil, err } ctx = lsctx.WithFilesystem(fs, ctx) - return handle(ctx, req, handlers.Initialized) + return handle(ctx, req, Initialized) }, "textDocument/didChange": func(ctx context.Context, req *jrpc2.Request) (interface{}, error) { - if !hm.srv.IsInitializationConfirmed() { - return nil, SrvNotInitializedErr(hm.srv.State()) + err := hp.srvCtl.CheckInitializationIsConfirmed() + if err != nil { + return nil, err } ctx = lsctx.WithFilesystem(fs, ctx) - return handle(ctx, req, handlers.TextDocumentDidChange) + return handle(ctx, req, TextDocumentDidChange) }, "textDocument/didOpen": func(ctx context.Context, req *jrpc2.Request) (interface{}, error) { - if !hm.srv.IsInitializationConfirmed() { - return nil, SrvNotInitializedErr(hm.srv.State()) + err := hp.srvCtl.CheckInitializationIsConfirmed() + if err != nil { + return nil, err } ctx = lsctx.WithFilesystem(fs, ctx) - return handle(ctx, req, handlers.TextDocumentDidOpen) + return handle(ctx, req, TextDocumentDidOpen) }, "textDocument/didClose": func(ctx context.Context, req *jrpc2.Request) (interface{}, error) { - if !hm.srv.IsInitializationConfirmed() { - return nil, SrvNotInitializedErr(hm.srv.State()) + err := hp.srvCtl.CheckInitializationIsConfirmed() + if err != nil { + return nil, err } ctx = lsctx.WithFilesystem(fs, ctx) - return handle(ctx, req, handlers.TextDocumentDidClose) + return handle(ctx, req, TextDocumentDidClose) }, "textDocument/completion": func(ctx context.Context, req *jrpc2.Request) (interface{}, error) { - if !hm.srv.IsInitializationConfirmed() { - return nil, SrvNotInitializedErr(hm.srv.State()) + err := hp.srvCtl.CheckInitializationIsConfirmed() + if err != nil { + return nil, err } ctx = lsctx.WithFilesystem(fs, ctx) // TODO: Read-only FS ctx = lsctx.WithClientCapabilities(cc, ctx) tf := exec.NewExecutor(ctx) - tf.SetLogger(hm.logger) + tf.SetLogger(hp.logger) ctx = lsctx.WithTerraformExecutor(tf, ctx) return handle(ctx, req, lh.TextDocumentComplete) }, "shutdown": func(ctx context.Context, req *jrpc2.Request) (interface{}, error) { - err := hm.srv.Shutdown(req) + err := hp.srvCtl.Shutdown(req) if err != nil { return nil, err } ctx = lsctx.WithFilesystem(fs, ctx) // TODO: Exit the process after a timeout if `exit` method is not called // to prevent zombie processes (?) - return handle(ctx, req, handlers.Shutdown) + return handle(ctx, req, Shutdown) }, "exit": func(ctx context.Context, req *jrpc2.Request) (interface{}, error) { - if !hm.srv.IsDown() && !hm.srv.IsPrepared() { - return nil, fmt.Errorf("Cannot exit as server is %s", hm.srv.State()) + err := hp.srvCtl.Exit() + if err != nil { + return nil, err } - hm.srvStopFunc() - - return handle(ctx, req, handlers.Shutdown) + return handle(ctx, req, Shutdown) }, "$/cancelRequest": func(ctx context.Context, req *jrpc2.Request) (interface{}, error) { - if !hm.srv.IsInitializationConfirmed() { - return nil, SrvNotInitializedErr(hm.srv.State()) + err := hp.srvCtl.CheckInitializationIsConfirmed() + if err != nil { + return nil, err } - return handle(ctx, req, handlers.CancelRequest) + return handle(ctx, req, CancelRequest) }, } diff --git a/langserver/langserver.go b/langserver/langserver.go index fcb91c693..780b5b911 100644 --- a/langserver/langserver.go +++ b/langserver/langserver.go @@ -4,57 +4,65 @@ import ( "context" "fmt" "io" + "io/ioutil" "log" "net" "github.com/creachadair/jrpc2" "github.com/creachadair/jrpc2/channel" rpcServer "github.com/creachadair/jrpc2/server" + "github.com/hashicorp/terraform-ls/langserver/srvctl" ) type langServer struct { ctx context.Context - assigner jrpc2.Assigner + hp srvctl.HandlerProvider logger *log.Logger srvOptions *jrpc2.ServerOptions - server *server + srvCtl srvctl.ServerController stopFunc context.CancelFunc } -func NewLangServer(srvCtx context.Context, logger *log.Logger) *langServer { - srv := newServer() - +func NewLangServer(srvCtx context.Context, hp srvctl.HandlerProvider) *langServer { opts := &jrpc2.ServerOptions{ AllowPush: true, - Logger: logger, - RPCLog: &rpcLogger{logger}, } srvCtx, stopFunc := context.WithCancel(srvCtx) - hm := &handlerMap{logger: logger, srv: srv, srvStopFunc: stopFunc} return &langServer{ ctx: srvCtx, - assigner: hm.Map(), - logger: logger, + hp: hp, + logger: log.New(ioutil.Discard, "", 0), srvOptions: opts, - server: srv, + srvCtl: srvctl.NewServerController(), stopFunc: stopFunc, } } -func (ls *langServer) Start(reader io.Reader, writer io.WriteCloser) { - err := ls.server.Prepare() +func (ls *langServer) SetLogger(logger *log.Logger) { + ls.srvOptions.Logger = logger + ls.srvOptions.RPCLog = &rpcLogger{logger} + ls.hp.SetLogger(logger) + ls.logger = logger +} + +func (ls *langServer) start(reader io.Reader, writer io.WriteCloser) *jrpc2.Server { + err := ls.srvCtl.Prepare() if err != nil { ls.logger.Printf("Unable to prepare server: %s", err) ls.stopFunc() + return nil } ch := channel.LSP(reader, writer) - srv := jrpc2.NewServer(ls.assigner, ls.srvOptions).Start(ch) + return jrpc2.NewServer(ls.hp.Handlers(ls.srvCtl), ls.srvOptions).Start(ch) +} +func (ls *langServer) StartAndWait(reader io.Reader, writer io.WriteCloser) { + srv := ls.start(reader, writer) go func() { ls.logger.Println("Starting server ...") err := srv.Wait() @@ -73,7 +81,7 @@ func (ls *langServer) Start(reader io.Reader, writer io.WriteCloser) { } func (ls *langServer) StartTCP(address string) error { - err := ls.server.Prepare() + err := ls.srvCtl.Prepare() if err != nil { ls.logger.Printf("Unable to prepare server: %s", err) ls.stopFunc() @@ -88,7 +96,7 @@ func (ls *langServer) StartTCP(address string) error { go func() { ls.logger.Println("Starting loop server ...") - err = rpcServer.Loop(lst, ls.assigner, &rpcServer.LoopOptions{ + err = rpcServer.Loop(lst, ls.hp.Handlers(ls.srvCtl), &rpcServer.LoopOptions{ Framing: channel.LSP, ServerOptions: ls.srvOptions, }) diff --git a/langserver/srvctl/errors.go b/langserver/srvctl/errors.go new file mode 100644 index 000000000..8b7e08f7f --- /dev/null +++ b/langserver/srvctl/errors.go @@ -0,0 +1,43 @@ +package srvctl + +import ( + "fmt" + + "github.com/creachadair/jrpc2/code" + "github.com/hashicorp/terraform-ls/langserver/errors" +) + +type unexpectedSrvState struct { + ExpectedState serverState + CurrentState serverState +} + +func (e *unexpectedSrvState) Error() string { + return fmt.Sprintf("server is not %s, current state: %s", + e.ExpectedState, e.CurrentState) +} + +func srvNotInitializedErr(state serverState) error { + uss := &unexpectedSrvState{ + ExpectedState: stateInitializedConfirmed, + CurrentState: state, + } + if state < stateInitializedConfirmed { + return fmt.Errorf("%w: %s", errors.ServerNotInitialized.Err(), uss) + } + if state == stateDown { + return fmt.Errorf("%w: %s", code.InvalidRequest.Err(), uss) + } + + return uss +} + +func srvAlreadyInitializedErr(reqID string) error { + return fmt.Errorf("%w: Server was already initialized via request ID %s", + code.SystemError.Err(), reqID) +} + +func srvAlreadyDownErr(reqID string) error { + return fmt.Errorf("%w: server was already shut down via request %s", + code.InvalidRequest.Err(), reqID) +} diff --git a/langserver/server.go b/langserver/srvctl/server.go similarity index 66% rename from langserver/server.go rename to langserver/srvctl/server.go index 5fec6dfbd..968452dd9 100644 --- a/langserver/server.go +++ b/langserver/srvctl/server.go @@ -1,11 +1,11 @@ -package langserver +package srvctl import ( + "context" "fmt" "time" "github.com/creachadair/jrpc2" - "github.com/creachadair/jrpc2/code" ) // serverState represents state of the language server @@ -41,33 +41,6 @@ func (ss serverState) String() string { return "" } -type unexpectedSrvState struct { - expectedState serverState - currentState serverState -} - -func (e *unexpectedSrvState) Error() string { - return fmt.Sprintf("server is not %s, current state: %s", - e.expectedState, e.currentState) -} - -const serverNotInitialized code.Code = -32002 - -func SrvNotInitializedErr(state serverState) error { - uss := &unexpectedSrvState{ - expectedState: stateInitializedConfirmed, - currentState: state, - } - if state < stateInitializedConfirmed { - return fmt.Errorf("%w: %s", serverNotInitialized.Err(), uss) - } - if state == stateDown { - return fmt.Errorf("%w: %s", code.InvalidRequest.Err(), uss) - } - - return uss -} - type server struct { initializeReq *jrpc2.Request initializeReqTime time.Time @@ -75,21 +48,22 @@ type server struct { initializedReq *jrpc2.Request initializedReqTime time.Time - state serverState - downReq *jrpc2.Request downReqTime time.Time + + state serverState + exitFunc context.CancelFunc } -func (srv *server) IsPrepared() bool { +func (srv *server) isPrepared() bool { return srv.state == statePrepared } func (srv *server) Prepare() error { if srv.state != stateEmpty { return &unexpectedSrvState{ - expectedState: stateInitializedConfirmed, - currentState: srv.state, + ExpectedState: stateInitializedConfirmed, + CurrentState: srv.state, } } @@ -105,8 +79,7 @@ func (srv *server) IsInitializedUnconfirmed() bool { func (srv *server) Initialize(req *jrpc2.Request) error { if srv.state != statePrepared { if srv.IsInitializedUnconfirmed() { - return fmt.Errorf("Server was already initialized at %s via request %s", - srv.initializeReqTime, srv.initializeReq.ID()) + return srvAlreadyInitializedErr(srv.initializeReq.ID()) } return fmt.Errorf("Server is not ready to be initalized. State: %s", srv.state) @@ -119,13 +92,20 @@ func (srv *server) Initialize(req *jrpc2.Request) error { return nil } -func (srv *server) IsInitializationConfirmed() bool { +func (srv *server) isInitializationConfirmed() bool { return srv.state == stateInitializedConfirmed } +func (srv *server) CheckInitializationIsConfirmed() error { + if !srv.isInitializationConfirmed() { + return srvNotInitializedErr(srv.State()) + } + return nil +} + func (srv *server) ConfirmInitialization(req *jrpc2.Request) error { if srv.state != stateInitializedUnconfirmed { - if srv.IsInitializationConfirmed() { + if srv.isInitializationConfirmed() { return fmt.Errorf("Server was already confirmed as initalized at %s via request %s", srv.initializedReqTime, srv.initializedReq.ID()) } @@ -140,9 +120,8 @@ func (srv *server) ConfirmInitialization(req *jrpc2.Request) error { } func (srv *server) Shutdown(req *jrpc2.Request) error { - if srv.IsDown() { - return fmt.Errorf("%w: server was already shut down via request %s", - code.InvalidRequest.Err(), srv.downReq.ID()) + if srv.isDown() { + return srvAlreadyDownErr(srv.downReq.ID()) } srv.downReq = req @@ -152,7 +131,20 @@ func (srv *server) Shutdown(req *jrpc2.Request) error { return nil } -func (srv *server) IsDown() bool { +func (srv *server) Exit() error { + if !srv.isExitable() { + return fmt.Errorf("Cannot exit as server is %s", srv.State()) + } + srv.exitFunc() + + return nil +} + +func (srv *server) isExitable() bool { + return srv.isDown() || srv.isPrepared() +} + +func (srv *server) isDown() bool { return srv.state == stateDown } @@ -160,6 +152,6 @@ func (srv *server) State() serverState { return srv.state } -func newServer() *server { +func NewServerController() *server { return &server{state: stateEmpty} } diff --git a/langserver/srvctl/server_controller.go b/langserver/srvctl/server_controller.go new file mode 100644 index 000000000..42e488a95 --- /dev/null +++ b/langserver/srvctl/server_controller.go @@ -0,0 +1,21 @@ +package srvctl + +import ( + "log" + + "github.com/creachadair/jrpc2" +) + +type ServerController interface { + CheckInitializationIsConfirmed() error + ConfirmInitialization(*jrpc2.Request) error + Exit() error + Initialize(*jrpc2.Request) error + Prepare() error + Shutdown(*jrpc2.Request) error +} + +type HandlerProvider interface { + Handlers(ServerController) jrpc2.Assigner + SetLogger(*log.Logger) +}