diff --git a/go.mod b/go.mod index c3589a057..3d44c6125 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 github.com/cockroachdb/apd/v3 v3.2.1 + github.com/coder/websocket v1.8.14 github.com/emicklei/proto v1.14.3 github.com/go-quicktest/qt v1.101.0 github.com/google/go-cmp v0.7.0 diff --git a/go.sum b/go.sum index 789f3e50c..4fbdcc1ca 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 h1:Zh+Ur3OsoWp cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8= github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/emicklei/proto v1.14.3 h1:zEhlzNkpP8kN6utonKMzlPfIvy82t5Kb9mufaJxSe1Q= diff --git a/internal/golangorgx/gopls/cmd/serve.go b/internal/golangorgx/gopls/cmd/serve.go index f42d5c92d..91e5a787c 100644 --- a/internal/golangorgx/gopls/cmd/serve.go +++ b/internal/golangorgx/gopls/cmd/serve.go @@ -12,6 +12,7 @@ import ( "io" "log" "os" + "strings" "time" "cuelang.org/go/internal/golangorgx/gopls/lsprpc" @@ -19,6 +20,7 @@ import ( "cuelang.org/go/internal/golangorgx/tools/jsonrpc2" "cuelang.org/go/internal/golangorgx/tools/tool" "cuelang.org/go/internal/lsp/cache" + "cuelang.org/go/unstable/lspaux/validatorconfig" ) // Serve is a struct that exposes the configurable parts of the LSP server as @@ -31,6 +33,9 @@ type Serve struct { RemoteListenTimeout time.Duration `flag:"remote.listen.timeout" help:"when used with -remote=auto, the -listen.timeout value used to start the daemon"` + ExtConfigFile string `flag:"extconfig" help:"path to config file for external validators"` + ExtProfile string `flag:"extprofile" help:"profile name for external validators"` + app *Application } @@ -77,7 +82,11 @@ func (s *Serve) Run(ctx context.Context, args ...string) error { return fmt.Errorf("creating forwarder: %w", err) } } else { - cache, err := cache.New() + profile, err := s.externalValidatorProfile() + if err != nil { + return err + } + cache, err := cache.New(profile) if err != nil { return err } @@ -122,3 +131,27 @@ func (s *Serve) Run(ctx context.Context, args ...string) error { } return err } + +func (s *Serve) externalValidatorProfile() (*validatorconfig.Profile, error) { + if s.ExtConfigFile == "" { + if s.ExtProfile != "" { + return nil, fmt.Errorf("-extprofile can only be set in conjunction with -extconfig") + } + return nil, nil + } + cfg, err := validatorconfig.Parse(s.ExtConfigFile) + if err != nil { + return nil, fmt.Errorf("reading external config file: %w", err) + } + profileName := cfg.ActiveProfile + if s.ExtProfile != "" { + profileName = s.ExtProfile + } + profile, found := cfg.Profiles[profileName] + if !found { + return nil, fmt.Errorf("profile %q not found in config file %s", profileName, s.ExtConfigFile) + } + + profile.ServerURL = strings.TrimRight(profile.ServerURL, "/") + return profile, nil +} diff --git a/internal/golangorgx/gopls/settings/default.go b/internal/golangorgx/gopls/settings/default.go index 18ad93be6..ce0987975 100644 --- a/internal/golangorgx/gopls/settings/default.go +++ b/internal/golangorgx/gopls/settings/default.go @@ -10,7 +10,6 @@ import ( "cuelang.org/go/internal/golangorgx/gopls/file" "cuelang.org/go/internal/golangorgx/gopls/protocol" - "cuelang.org/go/internal/golangorgx/gopls/protocol/command" ) var ( @@ -18,15 +17,13 @@ var ( defaultOptions *Options ) +const ExternalValidateCommand = "cuelsp.externalvalidate" + // DefaultOptions is the options that are used for Gopls execution independent // of any externally provided configuration (LSP initialization, command // invocation, etc.). func DefaultOptions(overrides ...func(*Options)) *Options { optionsOnce.Do(func() { - var commands []string - for _, c := range command.Commands { - commands = append(commands, c.ID()) - } defaultOptions = &Options{ ClientOptions: ClientOptions{ InsertTextFormat: protocol.PlainTextTextFormat, @@ -45,7 +42,7 @@ func DefaultOptions(overrides ...func(*Options)) *Options { protocol.RefactorRewriteConvertFromStruct: true, }, }, - SupportedCommands: commands, + SupportedCommands: []string{ExternalValidateCommand}, }, UserOptions: UserOptions{ BuildOptions: BuildOptions{ @@ -84,12 +81,7 @@ func DefaultOptions(overrides ...func(*Options)) *Options { CompleteFunctionCalls: true, }, Codelenses: map[string]bool{ - string(command.Generate): true, - string(command.RegenerateCgo): true, - string(command.Tidy): true, - string(command.GCDetails): false, - string(command.UpgradeDependency): true, - string(command.Vendor): true, + ExternalValidateCommand: false, }, }, }, diff --git a/internal/golangorgx/gopls/test/integration/runner.go b/internal/golangorgx/gopls/test/integration/runner.go index f376e28c7..7d72e171a 100644 --- a/internal/golangorgx/gopls/test/integration/runner.go +++ b/internal/golangorgx/gopls/test/integration/runner.go @@ -346,9 +346,9 @@ func (r *Runner) forwardedServer(config runConfig, optsHook func(*settings.Optio func newCache(config runConfig) (*cache.Cache, error) { if config.reg == nil { - return cache.New() + return cache.New(nil) } else { - return cache.NewWithRegistry(config.reg), nil + return cache.NewWithRegistry(nil, config.reg), nil } } diff --git a/internal/lsp/cache/cache.go b/internal/lsp/cache/cache.go index b96a4b0e6..f50c0ee98 100644 --- a/internal/lsp/cache/cache.go +++ b/internal/lsp/cache/cache.go @@ -9,10 +9,11 @@ import ( "cuelang.org/go/internal/mod/modpkgload" "cuelang.org/go/internal/mod/modrequirements" "cuelang.org/go/mod/modconfig" + "cuelang.org/go/unstable/lspaux/validatorconfig" ) // New creates a new Cache. -func New() (*Cache, error) { +func New(extProfile *validatorconfig.Profile) (*Cache, error) { modcfg := &modconfig.Config{ ClientType: "cuelsp", } @@ -20,25 +21,28 @@ func New() (*Cache, error) { if err != nil { return nil, err } - return NewWithRegistry(reg), nil + return NewWithRegistry(extProfile, reg), nil } // NewWithRegistry creates a new cache, using the specified registry. -func NewWithRegistry(reg Registry) *Cache { +func NewWithRegistry(extProfile *validatorconfig.Profile, reg Registry) *Cache { if reg == nil { panic("nil registry") } + return &Cache{ - fs: fscache.NewCUECachedFS(), - registry: reg, + fs: fscache.NewCUECachedFS(), + registry: reg, + extProfile: extProfile, } } // A Cache holds content that is shared across multiple cuelsp // client/editor connections. type Cache struct { - fs *fscache.CUECacheFS - registry Registry + fs *fscache.CUECacheFS + registry Registry + extProfile *validatorconfig.Profile } type Registry interface { diff --git a/internal/lsp/cache/extvalidator.go b/internal/lsp/cache/extvalidator.go new file mode 100644 index 000000000..e3a326ec9 --- /dev/null +++ b/internal/lsp/cache/extvalidator.go @@ -0,0 +1,140 @@ +// Copyright 2026 The CUE Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cache + +import ( + "context" + "encoding/json" + "time" + + cueerrors "cuelang.org/go/cue/errors" + "cuelang.org/go/cue/token" + "cuelang.org/go/internal/golangorgx/gopls/protocol" + "cuelang.org/go/internal/golangorgx/gopls/settings" + "cuelang.org/go/internal/lsp/extvalidator" + extproto "cuelang.org/go/unstable/lspaux/protocol" +) + +func externalValidateCommand(uri protocol.DocumentURI) *protocol.Command { + return &protocol.Command{ + Title: "Run external validators", + Command: settings.ExternalValidateCommand, + Arguments: []json.RawMessage{json.RawMessage(`"` + uri + `"`)}, + } +} + +// CodeLensExternalValidate returns the currently available code lenses. +func (w *Workspace) CodeLensExternalValidate(ctx context.Context, params *protocol.CodeLensParams) *protocol.CodeLens { + f := w.GetFile(params.TextDocument.URI) + if f != nil && f.extValidator != nil && f.extValidator.IsDirty() { + return &protocol.CodeLens{ + Range: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 0}, + End: protocol.Position{Line: 0, Character: 0}, + }, + Command: externalValidateCommand(params.TextDocument.URI), + } + } + + return nil +} + +// CommandExternalValidate begins external validation of the given +// uri, if available. +func (w *Workspace) CommandExternalValidate(ctx context.Context, uri protocol.DocumentURI) error { + f := w.GetFile(uri) + if f == nil || f.extValidator == nil { + return nil + } + + validation := &externalValidation{workspace: w} + w.enqueue(func() { + w.debugLog("extValidator: requesting evaluation") + if err := f.extValidator.StartValidation(validation); err != nil { + w.debugLog(err.Error()) + } + }) + return nil +} + +type externalValidation struct { + workspace *Workspace + errsByFile map[protocol.DocumentURI][]error +} + +// Result implements [extvalidator.ResponseHandler] +func (v *externalValidation) Result(resultMsg *extproto.EvalResultMsg) { + w := v.workspace + w.enqueue(func() { + errsByFile := v.errsByFile + if errsByFile == nil { + errsByFile = make(map[protocol.DocumentURI][]error) + v.errsByFile = errsByFile + } + + for _, err := range resultMsg.Errors { + for _, coord := range err.Coordinates { + uri := protocol.DocumentURI(coord.Path) + f := w.GetFile(uri) + if f == nil || f.tokFile == nil { + continue + } + pos := f.tokFile.Pos(int(coord.ByteOffset), token.NoRelPos) + errsByFile[uri] = append(errsByFile[uri], cueerrors.Newf(pos, "%s", err.Message)) + } + } + + for uri, errs := range errsByFile { + f := w.GetFile(uri) + if f == nil { + continue + } + f.ensureUser(v, errs...) + } + + w.publishDiagnostics() + }) +} + +// Finished implements [extvalidator.ResponseHandler] +func (v *externalValidation) Finished(*extproto.EvalFinishedMsg) { +} + +// Clear implements [extvalidator.ResponseHandler] +func (v *externalValidation) Clear() { + w := v.workspace + w.enqueue(func() { + errsByFile := v.errsByFile + v.errsByFile = nil + + for fileUri := range errsByFile { + f := w.GetFile(fileUri) + if f == nil { + continue + } + f.removeUser(v) + } + }) +} + +func (w *Workspace) extValidatorOnDirtyChanged(*extvalidator.Validator) { + w.enqueue(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := w.client.CodeLensRefresh(ctx); err != nil { + w.debugLog(err.Error()) + } + }) +} diff --git a/internal/lsp/cache/file.go b/internal/lsp/cache/file.go index 795543a7e..f3c0ea85e 100644 --- a/internal/lsp/cache/file.go +++ b/internal/lsp/cache/file.go @@ -24,6 +24,7 @@ import ( "cuelang.org/go/cue/token" "cuelang.org/go/internal/filetypes" "cuelang.org/go/internal/golangorgx/gopls/protocol" + "cuelang.org/go/internal/lsp/extvalidator" ) // ensureFile returns an existing [File] associated with uri if it @@ -98,6 +99,8 @@ type File struct { mapper *protocol.Mapper symbols []protocol.DocumentSymbol + extValidator *extvalidator.Validator + // errors records both the current users of this File and any // errors they have reported. Because most of the time there will // be only a single user of a File, it is modelled using a slice @@ -109,7 +112,7 @@ type File struct { } type userErrors struct { - user packageOrModule + user any errors []error } @@ -117,7 +120,11 @@ type userErrors struct { // which may be nil, contains any errors which this user has // encountered with this file and which should be reported to the // client via diagonstic notifications. -func (f *File) ensureUser(user packageOrModule, errs ...error) { +// +// user can be any value of any type. It is simply a handle which can +// be reliably used to update or remove errors encountered by the same +// user. +func (f *File) ensureUser(user any, errs ...error) { for i := range f.errors { existing := &f.errors[i] if existing.user != user { @@ -137,7 +144,7 @@ func (f *File) ensureUser(user packageOrModule, errs ...error) { } // removeUser records that user is no longer using this File. -func (f *File) removeUser(user packageOrModule) { +func (f *File) removeUser(user any) { f.errors = slices.DeleteFunc(f.errors, func(existing userErrors) bool { if existing.user != user { return false @@ -323,7 +330,10 @@ func (f *File) publishErrors() { Version: f.tokFile.Revision(), Diagnostics: diags, } - f.workspace.client.PublishDiagnostics(context.Background(), params) + w := f.workspace + w.enqueue(func() { + w.client.PublishDiagnostics(context.Background(), params) + }) } // errorToDiagnostics converts cue errors to [protocol.Diagnostic] diff --git a/internal/lsp/cache/module.go b/internal/lsp/cache/module.go index 77f324234..e3863d9cb 100644 --- a/internal/lsp/cache/module.go +++ b/internal/lsp/cache/module.go @@ -27,6 +27,7 @@ import ( "cuelang.org/go/cue/ast" "cuelang.org/go/cue/parser" "cuelang.org/go/internal/golangorgx/gopls/protocol" + "cuelang.org/go/internal/lsp/extvalidator" "cuelang.org/go/internal/lsp/fscache" "cuelang.org/go/internal/mod/modpkgload" "cuelang.org/go/internal/mod/modrequirements" @@ -66,6 +67,8 @@ type Module struct { // care that all the dirty files are loaded by _some_ package // within the module. dirtyFiles map[protocol.DocumentURI]struct{} + + extValidator *extvalidator.Validator } // NewModule creates a new [Module] and adds it to the workspace. The @@ -127,6 +130,10 @@ func (m *Module) ReloadModule() error { } w := m.workspace + if extm := w.extValidatorMgr; extm != nil { + m.extValidator = extm.EnsureValidator(m.rootURI, w.extValidatorOnDirtyChanged) + } + fh, err := w.overlayFS.ReadFile(m.modFileURI) if err != nil { w.debugLogf("%v Error when reloading: %v", m, err) diff --git a/internal/lsp/cache/package.go b/internal/lsp/cache/package.go index 3c0181a6d..96c54a5eb 100644 --- a/internal/lsp/cache/package.go +++ b/internal/lsp/cache/package.go @@ -327,6 +327,7 @@ func (pkg *Package) update(modpkg *modpkgload.Package) error { filesSet := make(map[protocol.DocumentURI]*File, len(modpkgFiles)) isCue := true var embeddings map[token.Pos]*embedding + extv := m.extValidator for i, modpkgFile := range modpkgFiles { evalASTs[i] = modpkgFile.Syntax @@ -395,6 +396,7 @@ func (pkg *Package) update(modpkg *modpkgload.Package) error { file.ensureUser(pkg, errs...) w.standalone.deleteFile(fileUri) + file.extValidator = extv } pkg.isCue = isCue pkg.embeddings = embeddings @@ -407,6 +409,9 @@ func (pkg *Package) update(modpkg *modpkgload.Package) error { pkg.files = filesSet w.invalidateActiveFilesAndDirs() + if extv != nil { + extv.MarkDirty() + } config := eval.Config{ IP: pkg.importPath, diff --git a/internal/lsp/cache/standalone.go b/internal/lsp/cache/standalone.go index e9f3de8ea..46c42ceed 100644 --- a/internal/lsp/cache/standalone.go +++ b/internal/lsp/cache/standalone.go @@ -210,6 +210,13 @@ func (f *standaloneFile) reload() error { f.delete() return ErrBadFile } + if extm := w.extValidatorMgr; extm != nil { + extv := extm.EnsureValidator(f.uri, w.extValidatorOnDirtyChanged) + file.extValidator = extv + if extv != nil { + extv.MarkDirty() + } + } f.definitions = eval.New(eval.Config{}, syntax) w.debugLogf("%v Reloaded", f) diff --git a/internal/lsp/cache/workspace.go b/internal/lsp/cache/workspace.go index 8d0c662dc..cb6919552 100644 --- a/internal/lsp/cache/workspace.go +++ b/internal/lsp/cache/workspace.go @@ -25,6 +25,7 @@ import ( "cuelang.org/go/internal/golangorgx/gopls/protocol" "cuelang.org/go/internal/golangorgx/gopls/settings" "cuelang.org/go/internal/golangorgx/tools/jsonrpc2" + "cuelang.org/go/internal/lsp/extvalidator" "cuelang.org/go/internal/lsp/fscache" "cuelang.org/go/internal/mod/modpkgload" ) @@ -71,7 +72,8 @@ type Workspace struct { // enqueue allows for a function to be added to the incoming queue // of messages from the client. The enqueue function itself is // non-blocking. - enqueue func(func()) + enqueue func(func()) + extValidatorMgr *extvalidator.Manager } func NewWorkspace(cache *Cache, client protocol.Client, debugLog func(string), enqueue func(func())) *Workspace { @@ -91,6 +93,9 @@ func NewWorkspace(cache *Cache, client protocol.Client, debugLog func(string), e enqueue: enqueue, } w.standalone = NewStandalone(w) + if extProfile := cache.extProfile; extProfile != nil { + w.extValidatorMgr = extvalidator.NewManager(extProfile, overlayFS, debugLog) + } return w } diff --git a/internal/lsp/extvalidator/conn.go b/internal/lsp/extvalidator/conn.go new file mode 100644 index 000000000..9e290cbc0 --- /dev/null +++ b/internal/lsp/extvalidator/conn.go @@ -0,0 +1,220 @@ +// Copyright 2026 CUE Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package extvalidator + +import ( + "context" + "errors" + "fmt" + "io" + "math/rand/v2" + "net/http" + "sync" + "time" + + "cuelang.org/go/unstable/lspaux/protocol" + "cuelang.org/go/unstable/lspaux/validatorconfig" + "github.com/coder/websocket" +) + +// wsPath is the suffix added to the serverUrl to give the full URL to +// the external validator's websocket acceptor. +const wsPath = "/ws/lsp" + +type extValidatorClient interface { + // Called each time a connection to the server is established. + connected() + // Called when the server indicates some external change has + // occurred and re-evaluation is possible. + changeSignal(*protocol.ChangedMsg) error + // Called when the server sends a (possibly partial) result to an + // evaluation request. + evalResult(*protocol.EvalResultMsg) error + // Called when the server indicates no more results will occur for + // the indicated evaluation request. + evalFinished(*protocol.EvalFinishedMsg) error +} + +// conn models a connection to an external validator. +type conn struct { + profile *validatorconfig.Profile + ctx context.Context + client extValidatorClient + debugLog func(msg string) + + mu sync.Mutex + conn *websocket.Conn +} + +// connect creates a new connection and starts a go-routine to +// repeatedly connect to, and receive from the external validator. +func connect(profile *validatorconfig.Profile, ctx context.Context, client extValidatorClient, debugLog func(msg string)) *conn { + if ctx == nil { + ctx = context.Background() + } + + conn := &conn{ + profile: profile, + ctx: ctx, + client: client, + debugLog: debugLog, + } + go conn.connect() + return conn +} + +// connect repeatedly attempts to connect to the external +// validator. Whenever the connection closes, or fails to connect, the +// go-routine sleeps, following a randomised binary exponential +// backoff schedule. The go-routine will only exit if the context +// supplied to [connect] errors. +func (c *conn) connect() { + const minSleepDuration = 250 * time.Millisecond + const maxSleepDuration = 15 * time.Second + sleepDuration := minSleepDuration + + profile := c.profile + serverUrl := profile.ServerURL + ctx := c.ctx + + var dialOpts *websocket.DialOptions + + if profile.Token != "" { + dialOpts = &websocket.DialOptions{ + HTTPHeader: http.Header{ + "Authorization": {"Bearer " + profile.Token}, + }, + } + } + + for { + conn, resp, err := websocket.Dial(ctx, serverUrl+wsPath, dialOpts) + + if err == nil { + c.debugLogf("extValidator: connected to %s", serverUrl) + c.mu.Lock() + c.conn = conn + c.mu.Unlock() + + c.receive(conn) + + c.mu.Lock() + c.conn = nil + c.mu.Unlock() + sleepDuration = minSleepDuration + + } else if resp == nil { + c.debugLogf("extValidator: error when dialing %s: %v", serverUrl, err) + } else { + c.debugLogf("extValidator: error when dialing %s: %v, http status: %v", serverUrl, err, resp.StatusCode) + } + + if ctx.Err() != nil { + return + + } else { + time.Sleep(sleepDuration) + sleepDuration += time.Duration(rand.Int64N(int64(sleepDuration))) + sleepDuration = min(sleepDuration, maxSleepDuration) + } + } +} + +// requestEvaluation sends the supplied [protocol.EvalRequestMsg] to +// the external validator. If the connection to the external validator +// exists and writing to it returns no error, then true is returned; +// otherwise false. However, as normal, just because the message was +// sent does not mean that it was received. +func (c *conn) requestEvaluation(msg *protocol.EvalRequestMsg) bool { + // TODO: it would be better to use the websocket Writer as + // MarshalBytes is really just making another exact copy of msg. + data := msg.MarshalBytes() + + c.mu.Lock() + defer c.mu.Unlock() + + if c.conn != nil { + err := c.conn.Write(c.ctx, websocket.MessageBinary, data) + return err == nil + } + return false +} + +// receive is the connection's receive-loop. +func (c *conn) receive(conn *websocket.Conn) { + defer conn.Close(websocket.StatusNormalClosure, "") + const readLimit = 16 * 1024 * 1024 // 16MB + conn.SetReadLimit(readLimit) + + ctx := c.ctx + client := c.client + client.connected() + + var closeErr websocket.CloseError + for { + msgType, data, err := conn.Read(ctx) + if errors.Is(err, io.EOF) || errors.As(err, &closeErr) || ctx.Err() != nil { + return + } else if err != nil { + c.debugLogf("extValidator: error when reading from websocket: %v", err) + return + } else if msgType != websocket.MessageBinary { + c.debugLog("extValidator: websocket received non-binary message") + return + } + + msgTypeProto, err := protocol.PeekMessageType(data) + if err != nil { + c.debugLogf("extValidator: protocol violation: %v", err) + return + } + + switch msgTypeProto { + case protocol.MsgTypeChanged: + msg := &protocol.ChangedMsg{} + err = msg.UnmarshalBytes(data) + if err == nil { + err = client.changeSignal(msg) + } + + case protocol.MsgTypeEvalResult: + msg := &protocol.EvalResultMsg{} + err = msg.UnmarshalBytes(data) + if err == nil { + err = client.evalResult(msg) + } + + case protocol.MsgTypeEvalFinished: + msg := &protocol.EvalFinishedMsg{} + err = msg.UnmarshalBytes(data) + if err == nil { + err = client.evalFinished(msg) + } + + default: + c.debugLog("protocol violation") + return + } + + if err != nil { + c.debugLog(err.Error()) + return + } + } +} + +func (c *conn) debugLogf(format string, args ...any) { + c.debugLog(fmt.Sprintf(format, args...)) +} diff --git a/internal/lsp/extvalidator/extvalidator.go b/internal/lsp/extvalidator/extvalidator.go new file mode 100644 index 000000000..4a4a096ab --- /dev/null +++ b/internal/lsp/extvalidator/extvalidator.go @@ -0,0 +1,387 @@ +// Copyright 2026 CUE Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// extvalidator supports connecting to, and communicating with, +// external validation servers. +package extvalidator + +import ( + "archive/zip" + "bytes" + "encoding/hex" + "fmt" + iofs "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + + "cuelang.org/go/internal/golangorgx/gopls/protocol" + "cuelang.org/go/internal/lsp/fscache" + extproto "cuelang.org/go/unstable/lspaux/protocol" + "cuelang.org/go/unstable/lspaux/validatorconfig" +) + +type Manager struct { + profile *validatorconfig.Profile + fs *fscache.OverlayFS + debugLog func(msg string) + validators map[protocol.DocumentURI]*Validator +} + +// NewManager creates a new [Manager] which can be used to manage +// connections between repos within fs, and the external validator +// server indicated by profile. +func NewManager(profile *validatorconfig.Profile, fs *fscache.OverlayFS, debugLog func(msg string)) *Manager { + return &Manager{ + profile: profile, + fs: fs, + debugLog: debugLog, + validators: make(map[protocol.DocumentURI]*Validator), + } +} + +// EnsureValidator creates (if necessary) and returns an external +// validator, appropriate for the given fileUri. +// +// Currently, it tests to see if there is a ".git" directory in the +// file's directory or any parent directory. If no such directory is +// found, nil will be returned. +func (mgr *Manager) EnsureValidator(fileUri protocol.DocumentURI, onDirtyChange func(*Validator)) *Validator { + fs := mgr.fs.IoFS(string(os.PathSeparator)) + found := false + var oldUri protocol.DocumentURI + for ; fileUri != oldUri; oldUri, fileUri = fileUri, fileUri.Dir() { + if isDir(fs, fileUri+"/.git") { + found = true + break + } + } + if !found { + return nil + } + + v, found := mgr.validators[fileUri] + if found { + return v + } + + v = &Validator{ + mgr: mgr, + rootURI: fileUri, + onDirtyChange: onDirtyChange, + } + mgr.debugLog(fmt.Sprintf("extValidators: creating external validator for %v", fileUri)) + v.conn = connect(mgr.profile, nil, v, mgr.debugLog) + mgr.validators[fileUri] = v + v.MarkDirty() + return v +} + +func isDir(fs iofs.StatFS, uri protocol.DocumentURI) bool { + path := strings.TrimLeft(uri.Path(), "/") + info, err := fs.Stat(path) + return err == nil && info.IsDir() +} + +// Validator represents an external validator for an entire +// repository. +// +// A Validator can be clean or dirty. It can be marked dirty either by +// the external validator informing the LSP, or by the LSP choosing to +// mark the external validator as dirty. When a validation is started, +// the validator is marked clean. In all cases, as the validator +// transitions from clean to dirty, or dirty to clean, the +// onDirtyChange callback will be invoked, if supplied. +type Validator struct { + mgr *Manager + rootURI protocol.DocumentURI + conn *conn + onDirtyChange func(*Validator) + + mu sync.Mutex + requestId int + cur *validation + isDirty bool +} + +// IsDirty reports if the validator is currently dirty. +func (v *Validator) IsDirty() bool { + v.mu.Lock() + defer v.mu.Unlock() + return v.isDirty +} + +type ResponseHandler interface { + // Result is called for each validation result received from the + // server. NB this may never be called if a subsequent new + // validation request is made before any results are received. + Result(*extproto.EvalResultMsg) + // Finished is called once all validation results have been + // received by the server. NB this may never be called if a new + // validation request is made before the server indicates it has + // sent all the results. + Finished(*extproto.EvalFinishedMsg) + // Clear is called when this validation request is replaced with a + // new validation request. This will always be called, regardless + // of how many results have been received, at the point that a new + // validation request is made. + Clear() +} + +type validation struct { + handler ResponseHandler + versionedURIs map[protocol.DocumentURI]int32 + requestId string +} + +// StartValidation creates and sends a validation request to the +// external validator. If a validation already exists, its handler's +// [ResponseHandler.Clear] method is invoked. +func (v *Validator) StartValidation(handler ResponseHandler) error { + repoName, err := v.repoName() + if err != nil { + return err + } + + commitId, err := v.commitId() + if err != nil { + return err + } + + trackedFiles, err := v.trackedFiles() + if err != nil { + return err + } + + // TODO: switch to sending diffs. + var buf bytes.Buffer + w := zip.NewWriter(&buf) + versionedURIs, err := addFS(w, v.mgr.fs, v.rootURI, trackedFiles) + if err != nil { + return err + } + err = w.Close() + if err != nil { + return err + } + + v.mu.Lock() + v.requestId++ + requestId := fmt.Sprint(v.requestId) + + oldValidation := v.cur + v.cur = &validation{ + handler: handler, + versionedURIs: versionedURIs, + requestId: requestId, + } + v.mu.Unlock() + + if oldValidation != nil { + go oldValidation.handler.Clear() + } + + msg := &extproto.EvalRequestMsg{ + RequestID: requestId, + RepoName: repoName, + CommitID: commitId, + ZipData: buf.Bytes(), + } + v.conn.debugLogf("extValidator: sending validation request; id: %s; repo: %s; commit: %s", requestId, repoName, commitId) + if v.conn.requestEvaluation(msg) { + v.setDirty(false) + } + + return nil +} + +func (v *Validator) commitId() (string, error) { + data, err := v.runGit("rev-parse", "--verify", "HEAD") + if err != nil { + return "", err + } + data = bytes.TrimSpace(data) + out := make([]byte, hex.DecodedLen(len(data))) + _, err = hex.Decode(out, data) + if err != nil { + return "", err + } + return string(data), nil +} + +// repoName returns the url of the "origin" remote from the git repo. +// +// In reality there is no single reponame, and this current approach +// is only likely to work for a subset of use-cases. TODO: find a +// better solution for the naming of sources. +func (v *Validator) repoName() (string, error) { + data, err := v.runGit("config", "--local", "remote.origin.url") + if err != nil { + return "", err + } + url := string(bytes.TrimSpace(data)) + if withoutGitHub, wasCut := strings.CutPrefix(url, "git@github.com:"); wasCut { + // transform "git@github.com:foo/bar.git" into "github:foo/bar" + url = "github:" + strings.TrimSuffix(withoutGitHub, ".git") + } + return url, nil +} + +// trackedFiles returns all the tracked files within the validator's +// git-repo. All the paths returned are /-separated and relative to +// the repo's root. +func (v *Validator) trackedFiles() ([]string, error) { + data, err := v.runGit("ls-files", "--full-name", "-z", ":/") + if err != nil { + return nil, err + } + data = bytes.Trim(data, "\000") + return strings.Split(string(data), "\000"), nil +} + +func (v *Validator) runGit(args ...string) ([]byte, error) { + dir := v.rootURI.FilePath() + args = append([]string{"--git-dir=" + dir + "/.git", "--work-tree=" + dir}, args...) + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = []string{"GIT_CONFIG_NOSYSTEM=1"} // also nuke out all existing env + return cmd.Output() +} + +// MarkDirty ensures the [Validator] is considered dirty. If it was +// previously clean, the onDirtyChange callback will be invoked, if it +// was supplied to [EnsureExtValidator]. +func (v *Validator) MarkDirty() { + v.setDirty(true) +} + +func (v *Validator) setDirty(isDirty bool) { + v.mu.Lock() + var onDirtyChange func(*Validator) + if v.isDirty != isDirty { + v.isDirty = isDirty + onDirtyChange = v.onDirtyChange + } + v.mu.Unlock() + + if onDirtyChange != nil { + onDirtyChange(v) + } +} + +// connected implements [extValidatorClient] +func (v *Validator) connected() { + v.MarkDirty() +} + +// changeSignal implements [extValidatorClient] +func (v *Validator) changeSignal(*extproto.ChangedMsg) error { + v.conn.debugLog("extValidator: received change signal") + v.MarkDirty() + return nil +} + +// evalResult implements [extValidatorClient] +func (v *Validator) evalResult(msg *extproto.EvalResultMsg) error { + v.mu.Lock() + validation := v.cur + v.mu.Unlock() + + if validation == nil || msg.RequestID != validation.requestId { + return nil + } + + v.conn.debugLogf("extValidator: recevied validation result; id %s", validation.requestId) + + versionedURIs := validation.versionedURIs + + rootURI := v.rootURI + "/" + for i := range msg.Errors { + err := &msg.Errors[i] + coords := err.Coordinates[:0] + for _, coord := range err.Coordinates { + // TODO: coord.Path really needs to turn into a proper + // URI. Currently it could have raw spaces in it etc which + // would be problematic. + uri := rootURI + protocol.DocumentURI(coord.Path) + if _, found := versionedURIs[uri]; !found { + continue + } + coord.Path = string(uri) + coords = append(coords, coord) + } + err.Coordinates = coords + } + validation.handler.Result(msg) + + return nil +} + +// evalFinished implements [extValidatorClient] +func (v *Validator) evalFinished(msg *extproto.EvalFinishedMsg) error { + v.mu.Lock() + validation := v.cur + v.mu.Unlock() + + if validation == nil || msg.RequestID != validation.requestId { + return nil + } + + v.conn.debugLogf("extValidator: validation finished; id %s", validation.requestId) + + validation.handler.Finished(msg) + + return nil +} + +// addFS adds the trackedFiles (and their directories) to the supplied +// [zip.Writer]. trackedFiles must be /-separated paths, relative to +// rootURI. +func addFS(w *zip.Writer, fs *fscache.OverlayFS, rootURI protocol.DocumentURI, trackedFiles []string) (map[protocol.DocumentURI]int32, error) { + rootFilePath := rootURI.FilePath() + + versionedURIs := make(map[protocol.DocumentURI]int32) + + for _, name := range trackedFiles { + uri := protocol.URIFromPath(filepath.Join(rootFilePath, filepath.FromSlash(name))) + if _, found := versionedURIs[uri]; found { + continue + } + + fh, err := fs.ReadFile(uri) + if err != nil { + return nil, err + } + versionedURIs[uri] = fh.Version() + h := &zip.FileHeader{ + Name: name, + UncompressedSize64: uint64(len(fh.Content())), + Method: zip.Deflate, + Modified: fh.ModTime().UTC(), + } + + fw, err := w.CreateHeader(h) + if err != nil { + return nil, err + } + _, err = fw.Write(fh.Content()) + if err != nil { + return nil, err + } + } + + return versionedURIs, nil +} diff --git a/internal/lsp/fscache/fs_cache.go b/internal/lsp/fscache/fs_cache.go index ceb216c8b..04a1c5242 100644 --- a/internal/lsp/fscache/fs_cache.go +++ b/internal/lsp/fscache/fs_cache.go @@ -45,6 +45,8 @@ type FileHandle interface { // is a copy of the underlying file content, and thus safe to be // mutated. This matches the behaviour of [iofs.ReadFileFS]. Content() []byte + // ModTime returns modification time of the file. + ModTime() time.Time } type diskFileEntry struct { @@ -210,11 +212,14 @@ func RemovePhantomPackageDecl(file *ast.File) ast.Node { } // Version implements [FileHandle] -func (entry *diskFileEntry) Version() int32 { panic("Should never be called") } +func (entry *diskFileEntry) Version() int32 { return 0 } // Content implements [FileHandle] func (entry *diskFileEntry) Content() []byte { return slices.Clone(entry.content) } +// ModTime implements [FileHandle] +func (entry *diskFileEntry) ModTime() time.Time { return entry.modTime } + // CUECacheFS exists to cache [ast.File] values and thus amortize the // cost of parsing cue files. It is not an overlay in any way. Its // design is influenced by gopls's similar fs caching layer @@ -389,6 +394,9 @@ func readFile(uri protocol.DocumentURI, mtime time.Time) (*diskFileEntry, error) // IoFS implements [RootableFS] func (fs *CUECacheFS) IoFS(root string) CUEDirFS { root = strings.TrimRight(root, string(os.PathSeparator)) + if root == "" { + root = string(os.PathSeparator) + } return &rootedCUECacheFS{ cuecachefs: fs, delegatefs: os.DirFS(root).(DirFS), diff --git a/internal/lsp/server/codelens.go b/internal/lsp/server/codelens.go new file mode 100644 index 000000000..fe7f218b0 --- /dev/null +++ b/internal/lsp/server/codelens.go @@ -0,0 +1,28 @@ +// Copyright 2026 The CUE Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package server + +import ( + "context" + + "cuelang.org/go/internal/golangorgx/gopls/protocol" +) + +func (s *server) CodeLens(ctx context.Context, params *protocol.CodeLensParams) ([]protocol.CodeLens, error) { + if v := s.workspace.CodeLensExternalValidate(ctx, params); v != nil { + return []protocol.CodeLens{*v}, nil + } + return nil, nil +} diff --git a/internal/lsp/server/command.go b/internal/lsp/server/command.go new file mode 100644 index 000000000..7a4850a94 --- /dev/null +++ b/internal/lsp/server/command.go @@ -0,0 +1,44 @@ +// Copyright 2026 The CUE Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package server + +import ( + "context" + "encoding/json" + "fmt" + + "cuelang.org/go/internal/golangorgx/gopls/protocol" + "cuelang.org/go/internal/golangorgx/gopls/settings" +) + +func (s *server) ExecuteCommand(ctx context.Context, params *protocol.ExecuteCommandParams) (any, error) { + switch params.Command { + case settings.ExternalValidateCommand: + args := params.Arguments + if len(args) != 1 { + return nil, fmt.Errorf("unexpected argument count. Expected 1, got %d", len(args)) + } + var uri protocol.DocumentURI + err := json.Unmarshal(args[0], &uri) + if err != nil { + return nil, err + } + + err = s.workspace.CommandExternalValidate(ctx, uri) + return nil, err + + } + return nil, notImplemented("ExecuteCommand") +} diff --git a/internal/lsp/server/initialize.go b/internal/lsp/server/initialize.go index 39851e668..0578f8ba0 100644 --- a/internal/lsp/server/initialize.go +++ b/internal/lsp/server/initialize.go @@ -114,15 +114,19 @@ func (s *server) Initialize(ctx context.Context, params *protocol.ParamInitializ Capabilities: protocol.ServerCapabilities{ CodeActionProvider: codeActionProvider, + CodeLensProvider: &protocol.CodeLensOptions{}, // must be non-nil to enable the code lens capability CompletionProvider: &protocol.CompletionOptions{ TriggerCharacters: []string{"."}, }, DefinitionProvider: &protocol.Or_ServerCapabilities_definitionProvider{Value: true}, DocumentFormattingProvider: &protocol.Or_ServerCapabilities_documentFormattingProvider{Value: true}, DocumentSymbolProvider: &protocol.Or_ServerCapabilities_documentSymbolProvider{Value: true}, - HoverProvider: &protocol.Or_ServerCapabilities_hoverProvider{Value: true}, - ReferencesProvider: &protocol.Or_ServerCapabilities_referencesProvider{Value: true}, - RenameProvider: renameOpts, + ExecuteCommandProvider: &protocol.ExecuteCommandOptions{ + Commands: protocol.NonNilSlice(options.SupportedCommands), + }, + HoverProvider: &protocol.Or_ServerCapabilities_hoverProvider{Value: true}, + ReferencesProvider: &protocol.Or_ServerCapabilities_referencesProvider{Value: true}, + RenameProvider: renameOpts, TextDocumentSync: &protocol.TextDocumentSyncOptions{ Change: protocol.Incremental, OpenClose: true, diff --git a/internal/lsp/server/unimplemented.go b/internal/lsp/server/unimplemented.go index 41708f1b2..40feed34f 100644 --- a/internal/lsp/server/unimplemented.go +++ b/internal/lsp/server/unimplemented.go @@ -14,10 +14,6 @@ import ( "cuelang.org/go/internal/golangorgx/tools/jsonrpc2" ) -func (s *server) CodeLens(ctx context.Context, params *protocol.CodeLensParams) ([]protocol.CodeLens, error) { - return nil, notImplemented("CodeLens") -} - func (s *server) ColorPresentation(context.Context, *protocol.ColorPresentationParams) ([]protocol.ColorPresentation, error) { return nil, notImplemented("ColorPresentation") } @@ -74,10 +70,6 @@ func (s *server) DocumentLink(ctx context.Context, params *protocol.DocumentLink return nil, notImplemented("DocumentLink") } -func (s *server) ExecuteCommand(ctx context.Context, params *protocol.ExecuteCommandParams) (interface{}, error) { - return nil, notImplemented("ExecuteCommand") -} - func (s *server) FoldingRange(ctx context.Context, params *protocol.FoldingRangeParams) ([]protocol.FoldingRange, error) { return nil, notImplemented("FoldingRange") } diff --git a/unstable/lspaux/validatorconfig/config.go b/unstable/lspaux/validatorconfig/config.go new file mode 100644 index 000000000..cb4657281 --- /dev/null +++ b/unstable/lspaux/validatorconfig/config.go @@ -0,0 +1,86 @@ +// Copyright 2026 CUE Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// WARNING: THIS PACKAGE IS EXPERIMENTAL. +// ITS API MAY CHANGE AT ANY TIME. +// +// validatorconfig supports reading and parsing user configuration +// files for external validators. +package validatorconfig + +import ( + "os" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/ast" + "cuelang.org/go/cue/build" + "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/cue/errors" + "cuelang.org/go/cue/token" + "cuelang.org/go/internal/encoding" + "cuelang.org/go/internal/filetypes" +) + +type File struct { + ActiveProfile string `json:"activeProfile"` + Profiles map[string]*Profile `json:"profiles,omitempty"` +} + +type Profile struct { + ServerURL string `json:"serverURL"` + Name string `json:"name"` + Token string `json:"token"` +} + +// Parse reads the provided path as a data-only CUE file, and attempts +// to decode it into [File]. +func Parse(path string) (file *File, err error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + ctx := cuecontext.New() + astFile, err := parseDataOnlyCUE(ctx, data, path) + if err != nil { + return nil, errors.Wrapf(err, token.NoPos, "invalid config file syntax") + } + // TODO: unify with a closed schema so that we can detect spelling + // mistakes. + v := ctx.BuildFile(astFile) + if err := v.Validate(cue.Concrete(true)); err != nil { + return nil, errors.Wrapf(err, token.NoPos, "invalid module file value") + } + var mf File + if err := v.Decode(&mf); err != nil { + return nil, errors.Wrapf(err, token.NoPos, "internal error: cannot decode into modFile struct") + } + return &mf, nil +} + +func parseDataOnlyCUE(ctx *cue.Context, cueData []byte, filename string) (*ast.File, error) { + dec := encoding.NewDecoder(ctx, &build.File{ + Filename: filename, + Encoding: build.CUE, + Interpretation: build.Auto, + Form: build.Data, + Source: cueData, + }, &encoding.Config{ + Mode: filetypes.Export, + AllErrors: true, + }) + if err := dec.Err(); err != nil { + return nil, err + } + return dec.File(), nil +}