this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

internal/lsp: add support for evaluation via external validators

A codelens is added which allows all the tracked files in the current
repo to be sent to an external remote server for validation.

For this feature to be enabled, the -extconfig command line option must
be provided, and the -extprofile may optionally be provided to specify
the config profile to use.

Signed-off-by: Matthew Sackman <matthew@cue.works>
Change-Id: Iec6e88df8028b2b7f3794d04d0845d9888d3fbe5
Reviewed-on: https://cue.gerrithub.io/c/cue-lang/cue/+/1233761
Reviewed-by: Roger Peppe <rogpeppe@gmail.com>
TryBot-Result: CUEcueckoo <cueckoo@cuelang.org>
Unity-Result: CUE porcuepine <cue.porcuepine@gmail.com>

+1014 -39
+1
go.mod
··· 5 5 require ( 6 6 cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 7 7 github.com/cockroachdb/apd/v3 v3.2.1 8 + github.com/coder/websocket v1.8.14 8 9 github.com/emicklei/proto v1.14.3 9 10 github.com/go-quicktest/qt v1.101.0 10 11 github.com/google/go-cmp v0.7.0
+2
go.sum
··· 2 2 cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8= 3 3 github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= 4 4 github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= 5 + github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= 6 + github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= 5 7 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 6 8 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 7 9 github.com/emicklei/proto v1.14.3 h1:zEhlzNkpP8kN6utonKMzlPfIvy82t5Kb9mufaJxSe1Q=
+34 -1
internal/golangorgx/gopls/cmd/serve.go
··· 12 12 "io" 13 13 "log" 14 14 "os" 15 + "strings" 15 16 "time" 16 17 17 18 "cuelang.org/go/internal/golangorgx/gopls/lsprpc" ··· 19 20 "cuelang.org/go/internal/golangorgx/tools/jsonrpc2" 20 21 "cuelang.org/go/internal/golangorgx/tools/tool" 21 22 "cuelang.org/go/internal/lsp/cache" 23 + "cuelang.org/go/unstable/lspaux/validatorconfig" 22 24 ) 23 25 24 26 // Serve is a struct that exposes the configurable parts of the LSP server as ··· 30 32 IdleTimeout time.Duration `flag:"listen.timeout" help:"when used with -listen, shut down the server when there are no connected clients for this duration"` 31 33 32 34 RemoteListenTimeout time.Duration `flag:"remote.listen.timeout" help:"when used with -remote=auto, the -listen.timeout value used to start the daemon"` 35 + 36 + ExtConfigFile string `flag:"extconfig" help:"path to config file for external validators"` 37 + ExtProfile string `flag:"extprofile" help:"profile name for external validators"` 33 38 34 39 app *Application 35 40 } ··· 77 82 return fmt.Errorf("creating forwarder: %w", err) 78 83 } 79 84 } else { 80 - cache, err := cache.New() 85 + profile, err := s.externalValidatorProfile() 86 + if err != nil { 87 + return err 88 + } 89 + cache, err := cache.New(profile) 81 90 if err != nil { 82 91 return err 83 92 } ··· 122 131 } 123 132 return err 124 133 } 134 + 135 + func (s *Serve) externalValidatorProfile() (*validatorconfig.Profile, error) { 136 + if s.ExtConfigFile == "" { 137 + if s.ExtProfile != "" { 138 + return nil, fmt.Errorf("-extprofile can only be set in conjunction with -extconfig") 139 + } 140 + return nil, nil 141 + } 142 + cfg, err := validatorconfig.Parse(s.ExtConfigFile) 143 + if err != nil { 144 + return nil, fmt.Errorf("reading external config file: %w", err) 145 + } 146 + profileName := cfg.ActiveProfile 147 + if s.ExtProfile != "" { 148 + profileName = s.ExtProfile 149 + } 150 + profile, found := cfg.Profiles[profileName] 151 + if !found { 152 + return nil, fmt.Errorf("profile %q not found in config file %s", profileName, s.ExtConfigFile) 153 + } 154 + 155 + profile.ServerURL = strings.TrimRight(profile.ServerURL, "/") 156 + return profile, nil 157 + }
+4 -12
internal/golangorgx/gopls/settings/default.go
··· 10 10 11 11 "cuelang.org/go/internal/golangorgx/gopls/file" 12 12 "cuelang.org/go/internal/golangorgx/gopls/protocol" 13 - "cuelang.org/go/internal/golangorgx/gopls/protocol/command" 14 13 ) 15 14 16 15 var ( ··· 18 17 defaultOptions *Options 19 18 ) 20 19 20 + const ExternalValidateCommand = "cuelsp.externalvalidate" 21 + 21 22 // DefaultOptions is the options that are used for Gopls execution independent 22 23 // of any externally provided configuration (LSP initialization, command 23 24 // invocation, etc.). 24 25 func DefaultOptions(overrides ...func(*Options)) *Options { 25 26 optionsOnce.Do(func() { 26 - var commands []string 27 - for _, c := range command.Commands { 28 - commands = append(commands, c.ID()) 29 - } 30 27 defaultOptions = &Options{ 31 28 ClientOptions: ClientOptions{ 32 29 InsertTextFormat: protocol.PlainTextTextFormat, ··· 45 42 protocol.RefactorRewriteConvertFromStruct: true, 46 43 }, 47 44 }, 48 - SupportedCommands: commands, 45 + SupportedCommands: []string{ExternalValidateCommand}, 49 46 }, 50 47 UserOptions: UserOptions{ 51 48 BuildOptions: BuildOptions{ ··· 84 81 CompleteFunctionCalls: true, 85 82 }, 86 83 Codelenses: map[string]bool{ 87 - string(command.Generate): true, 88 - string(command.RegenerateCgo): true, 89 - string(command.Tidy): true, 90 - string(command.GCDetails): false, 91 - string(command.UpgradeDependency): true, 92 - string(command.Vendor): true, 84 + ExternalValidateCommand: false, 93 85 }, 94 86 }, 95 87 },
+2 -2
internal/golangorgx/gopls/test/integration/runner.go
··· 346 346 347 347 func newCache(config runConfig) (*cache.Cache, error) { 348 348 if config.reg == nil { 349 - return cache.New() 349 + return cache.New(nil) 350 350 } else { 351 - return cache.NewWithRegistry(config.reg), nil 351 + return cache.NewWithRegistry(nil, config.reg), nil 352 352 } 353 353 } 354 354
+11 -7
internal/lsp/cache/cache.go
··· 9 9 "cuelang.org/go/internal/mod/modpkgload" 10 10 "cuelang.org/go/internal/mod/modrequirements" 11 11 "cuelang.org/go/mod/modconfig" 12 + "cuelang.org/go/unstable/lspaux/validatorconfig" 12 13 ) 13 14 14 15 // New creates a new Cache. 15 - func New() (*Cache, error) { 16 + func New(extProfile *validatorconfig.Profile) (*Cache, error) { 16 17 modcfg := &modconfig.Config{ 17 18 ClientType: "cuelsp", 18 19 } ··· 20 21 if err != nil { 21 22 return nil, err 22 23 } 23 - return NewWithRegistry(reg), nil 24 + return NewWithRegistry(extProfile, reg), nil 24 25 } 25 26 26 27 // NewWithRegistry creates a new cache, using the specified registry. 27 - func NewWithRegistry(reg Registry) *Cache { 28 + func NewWithRegistry(extProfile *validatorconfig.Profile, reg Registry) *Cache { 28 29 if reg == nil { 29 30 panic("nil registry") 30 31 } 32 + 31 33 return &Cache{ 32 - fs: fscache.NewCUECachedFS(), 33 - registry: reg, 34 + fs: fscache.NewCUECachedFS(), 35 + registry: reg, 36 + extProfile: extProfile, 34 37 } 35 38 } 36 39 37 40 // A Cache holds content that is shared across multiple cuelsp 38 41 // client/editor connections. 39 42 type Cache struct { 40 - fs *fscache.CUECacheFS 41 - registry Registry 43 + fs *fscache.CUECacheFS 44 + registry Registry 45 + extProfile *validatorconfig.Profile 42 46 } 43 47 44 48 type Registry interface {
+140
internal/lsp/cache/extvalidator.go
··· 1 + // Copyright 2026 The CUE Authors 2 + // 3 + // Licensed under the Apache License, Version 2.0 (the "License"); 4 + // you may not use this file except in compliance with the License. 5 + // You may obtain a copy of the License at 6 + // 7 + // http://www.apache.org/licenses/LICENSE-2.0 8 + // 9 + // Unless required by applicable law or agreed to in writing, software 10 + // distributed under the License is distributed on an "AS IS" BASIS, 11 + // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 + // See the License for the specific language governing permissions and 13 + // limitations under the License. 14 + 15 + package cache 16 + 17 + import ( 18 + "context" 19 + "encoding/json" 20 + "time" 21 + 22 + cueerrors "cuelang.org/go/cue/errors" 23 + "cuelang.org/go/cue/token" 24 + "cuelang.org/go/internal/golangorgx/gopls/protocol" 25 + "cuelang.org/go/internal/golangorgx/gopls/settings" 26 + "cuelang.org/go/internal/lsp/extvalidator" 27 + extproto "cuelang.org/go/unstable/lspaux/protocol" 28 + ) 29 + 30 + func externalValidateCommand(uri protocol.DocumentURI) *protocol.Command { 31 + return &protocol.Command{ 32 + Title: "Run external validators", 33 + Command: settings.ExternalValidateCommand, 34 + Arguments: []json.RawMessage{json.RawMessage(`"` + uri + `"`)}, 35 + } 36 + } 37 + 38 + // CodeLensExternalValidate returns the currently available code lenses. 39 + func (w *Workspace) CodeLensExternalValidate(ctx context.Context, params *protocol.CodeLensParams) *protocol.CodeLens { 40 + f := w.GetFile(params.TextDocument.URI) 41 + if f != nil && f.extValidator != nil && f.extValidator.IsDirty() { 42 + return &protocol.CodeLens{ 43 + Range: protocol.Range{ 44 + Start: protocol.Position{Line: 0, Character: 0}, 45 + End: protocol.Position{Line: 0, Character: 0}, 46 + }, 47 + Command: externalValidateCommand(params.TextDocument.URI), 48 + } 49 + } 50 + 51 + return nil 52 + } 53 + 54 + // CommandExternalValidate begins external validation of the given 55 + // uri, if available. 56 + func (w *Workspace) CommandExternalValidate(ctx context.Context, uri protocol.DocumentURI) error { 57 + f := w.GetFile(uri) 58 + if f == nil || f.extValidator == nil { 59 + return nil 60 + } 61 + 62 + validation := &externalValidation{workspace: w} 63 + w.enqueue(func() { 64 + w.debugLog("extValidator: requesting evaluation") 65 + if err := f.extValidator.StartValidation(validation); err != nil { 66 + w.debugLog(err.Error()) 67 + } 68 + }) 69 + return nil 70 + } 71 + 72 + type externalValidation struct { 73 + workspace *Workspace 74 + errsByFile map[protocol.DocumentURI][]error 75 + } 76 + 77 + // Result implements [extvalidator.ResponseHandler] 78 + func (v *externalValidation) Result(resultMsg *extproto.EvalResultMsg) { 79 + w := v.workspace 80 + w.enqueue(func() { 81 + errsByFile := v.errsByFile 82 + if errsByFile == nil { 83 + errsByFile = make(map[protocol.DocumentURI][]error) 84 + v.errsByFile = errsByFile 85 + } 86 + 87 + for _, err := range resultMsg.Errors { 88 + for _, coord := range err.Coordinates { 89 + uri := protocol.DocumentURI(coord.Path) 90 + f := w.GetFile(uri) 91 + if f == nil || f.tokFile == nil { 92 + continue 93 + } 94 + pos := f.tokFile.Pos(int(coord.ByteOffset), token.NoRelPos) 95 + errsByFile[uri] = append(errsByFile[uri], cueerrors.Newf(pos, "%s", err.Message)) 96 + } 97 + } 98 + 99 + for uri, errs := range errsByFile { 100 + f := w.GetFile(uri) 101 + if f == nil { 102 + continue 103 + } 104 + f.ensureUser(v, errs...) 105 + } 106 + 107 + w.publishDiagnostics() 108 + }) 109 + } 110 + 111 + // Finished implements [extvalidator.ResponseHandler] 112 + func (v *externalValidation) Finished(*extproto.EvalFinishedMsg) { 113 + } 114 + 115 + // Clear implements [extvalidator.ResponseHandler] 116 + func (v *externalValidation) Clear() { 117 + w := v.workspace 118 + w.enqueue(func() { 119 + errsByFile := v.errsByFile 120 + v.errsByFile = nil 121 + 122 + for fileUri := range errsByFile { 123 + f := w.GetFile(fileUri) 124 + if f == nil { 125 + continue 126 + } 127 + f.removeUser(v) 128 + } 129 + }) 130 + } 131 + 132 + func (w *Workspace) extValidatorOnDirtyChanged(*extvalidator.Validator) { 133 + w.enqueue(func() { 134 + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 135 + defer cancel() 136 + if err := w.client.CodeLensRefresh(ctx); err != nil { 137 + w.debugLog(err.Error()) 138 + } 139 + }) 140 + }
+14 -4
internal/lsp/cache/file.go
··· 24 24 "cuelang.org/go/cue/token" 25 25 "cuelang.org/go/internal/filetypes" 26 26 "cuelang.org/go/internal/golangorgx/gopls/protocol" 27 + "cuelang.org/go/internal/lsp/extvalidator" 27 28 ) 28 29 29 30 // ensureFile returns an existing [File] associated with uri if it ··· 98 99 mapper *protocol.Mapper 99 100 symbols []protocol.DocumentSymbol 100 101 102 + extValidator *extvalidator.Validator 103 + 101 104 // errors records both the current users of this File and any 102 105 // errors they have reported. Because most of the time there will 103 106 // be only a single user of a File, it is modelled using a slice ··· 109 112 } 110 113 111 114 type userErrors struct { 112 - user packageOrModule 115 + user any 113 116 errors []error 114 117 } 115 118 ··· 117 120 // which may be nil, contains any errors which this user has 118 121 // encountered with this file and which should be reported to the 119 122 // client via diagonstic notifications. 120 - func (f *File) ensureUser(user packageOrModule, errs ...error) { 123 + // 124 + // user can be any value of any type. It is simply a handle which can 125 + // be reliably used to update or remove errors encountered by the same 126 + // user. 127 + func (f *File) ensureUser(user any, errs ...error) { 121 128 for i := range f.errors { 122 129 existing := &f.errors[i] 123 130 if existing.user != user { ··· 137 144 } 138 145 139 146 // removeUser records that user is no longer using this File. 140 - func (f *File) removeUser(user packageOrModule) { 147 + func (f *File) removeUser(user any) { 141 148 f.errors = slices.DeleteFunc(f.errors, func(existing userErrors) bool { 142 149 if existing.user != user { 143 150 return false ··· 323 330 Version: f.tokFile.Revision(), 324 331 Diagnostics: diags, 325 332 } 326 - f.workspace.client.PublishDiagnostics(context.Background(), params) 333 + w := f.workspace 334 + w.enqueue(func() { 335 + w.client.PublishDiagnostics(context.Background(), params) 336 + }) 327 337 } 328 338 329 339 // errorToDiagnostics converts cue errors to [protocol.Diagnostic]
+7
internal/lsp/cache/module.go
··· 27 27 "cuelang.org/go/cue/ast" 28 28 "cuelang.org/go/cue/parser" 29 29 "cuelang.org/go/internal/golangorgx/gopls/protocol" 30 + "cuelang.org/go/internal/lsp/extvalidator" 30 31 "cuelang.org/go/internal/lsp/fscache" 31 32 "cuelang.org/go/internal/mod/modpkgload" 32 33 "cuelang.org/go/internal/mod/modrequirements" ··· 66 67 // care that all the dirty files are loaded by _some_ package 67 68 // within the module. 68 69 dirtyFiles map[protocol.DocumentURI]struct{} 70 + 71 + extValidator *extvalidator.Validator 69 72 } 70 73 71 74 // NewModule creates a new [Module] and adds it to the workspace. The ··· 127 130 } 128 131 129 132 w := m.workspace 133 + if extm := w.extValidatorMgr; extm != nil { 134 + m.extValidator = extm.EnsureValidator(m.rootURI, w.extValidatorOnDirtyChanged) 135 + } 136 + 130 137 fh, err := w.overlayFS.ReadFile(m.modFileURI) 131 138 if err != nil { 132 139 w.debugLogf("%v Error when reloading: %v", m, err)
+5
internal/lsp/cache/package.go
··· 327 327 filesSet := make(map[protocol.DocumentURI]*File, len(modpkgFiles)) 328 328 isCue := true 329 329 var embeddings map[token.Pos]*embedding 330 + extv := m.extValidator 330 331 331 332 for i, modpkgFile := range modpkgFiles { 332 333 evalASTs[i] = modpkgFile.Syntax ··· 395 396 396 397 file.ensureUser(pkg, errs...) 397 398 w.standalone.deleteFile(fileUri) 399 + file.extValidator = extv 398 400 } 399 401 pkg.isCue = isCue 400 402 pkg.embeddings = embeddings ··· 407 409 pkg.files = filesSet 408 410 409 411 w.invalidateActiveFilesAndDirs() 412 + if extv != nil { 413 + extv.MarkDirty() 414 + } 410 415 411 416 config := eval.Config{ 412 417 IP: pkg.importPath,
+7
internal/lsp/cache/standalone.go
··· 210 210 f.delete() 211 211 return ErrBadFile 212 212 } 213 + if extm := w.extValidatorMgr; extm != nil { 214 + extv := extm.EnsureValidator(f.uri, w.extValidatorOnDirtyChanged) 215 + file.extValidator = extv 216 + if extv != nil { 217 + extv.MarkDirty() 218 + } 219 + } 213 220 214 221 f.definitions = eval.New(eval.Config{}, syntax) 215 222 w.debugLogf("%v Reloaded", f)
+6 -1
internal/lsp/cache/workspace.go
··· 25 25 "cuelang.org/go/internal/golangorgx/gopls/protocol" 26 26 "cuelang.org/go/internal/golangorgx/gopls/settings" 27 27 "cuelang.org/go/internal/golangorgx/tools/jsonrpc2" 28 + "cuelang.org/go/internal/lsp/extvalidator" 28 29 "cuelang.org/go/internal/lsp/fscache" 29 30 "cuelang.org/go/internal/mod/modpkgload" 30 31 ) ··· 71 72 // enqueue allows for a function to be added to the incoming queue 72 73 // of messages from the client. The enqueue function itself is 73 74 // non-blocking. 74 - enqueue func(func()) 75 + enqueue func(func()) 76 + extValidatorMgr *extvalidator.Manager 75 77 } 76 78 77 79 func NewWorkspace(cache *Cache, client protocol.Client, debugLog func(string), enqueue func(func())) *Workspace { ··· 91 93 enqueue: enqueue, 92 94 } 93 95 w.standalone = NewStandalone(w) 96 + if extProfile := cache.extProfile; extProfile != nil { 97 + w.extValidatorMgr = extvalidator.NewManager(extProfile, overlayFS, debugLog) 98 + } 94 99 return w 95 100 } 96 101
+220
internal/lsp/extvalidator/conn.go
··· 1 + // Copyright 2026 CUE Authors 2 + // 3 + // Licensed under the Apache License, Version 2.0 (the "License"); 4 + // you may not use this file except in compliance with the License. 5 + // You may obtain a copy of the License at 6 + // 7 + // http://www.apache.org/licenses/LICENSE-2.0 8 + // 9 + // Unless required by applicable law or agreed to in writing, software 10 + // distributed under the License is distributed on an "AS IS" BASIS, 11 + // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 + // See the License for the specific language governing permissions and 13 + // limitations under the License. 14 + 15 + package extvalidator 16 + 17 + import ( 18 + "context" 19 + "errors" 20 + "fmt" 21 + "io" 22 + "math/rand/v2" 23 + "net/http" 24 + "sync" 25 + "time" 26 + 27 + "cuelang.org/go/unstable/lspaux/protocol" 28 + "cuelang.org/go/unstable/lspaux/validatorconfig" 29 + "github.com/coder/websocket" 30 + ) 31 + 32 + // wsPath is the suffix added to the serverUrl to give the full URL to 33 + // the external validator's websocket acceptor. 34 + const wsPath = "/ws/lsp" 35 + 36 + type extValidatorClient interface { 37 + // Called each time a connection to the server is established. 38 + connected() 39 + // Called when the server indicates some external change has 40 + // occurred and re-evaluation is possible. 41 + changeSignal(*protocol.ChangedMsg) error 42 + // Called when the server sends a (possibly partial) result to an 43 + // evaluation request. 44 + evalResult(*protocol.EvalResultMsg) error 45 + // Called when the server indicates no more results will occur for 46 + // the indicated evaluation request. 47 + evalFinished(*protocol.EvalFinishedMsg) error 48 + } 49 + 50 + // conn models a connection to an external validator. 51 + type conn struct { 52 + profile *validatorconfig.Profile 53 + ctx context.Context 54 + client extValidatorClient 55 + debugLog func(msg string) 56 + 57 + mu sync.Mutex 58 + conn *websocket.Conn 59 + } 60 + 61 + // connect creates a new connection and starts a go-routine to 62 + // repeatedly connect to, and receive from the external validator. 63 + func connect(profile *validatorconfig.Profile, ctx context.Context, client extValidatorClient, debugLog func(msg string)) *conn { 64 + if ctx == nil { 65 + ctx = context.Background() 66 + } 67 + 68 + conn := &conn{ 69 + profile: profile, 70 + ctx: ctx, 71 + client: client, 72 + debugLog: debugLog, 73 + } 74 + go conn.connect() 75 + return conn 76 + } 77 + 78 + // connect repeatedly attempts to connect to the external 79 + // validator. Whenever the connection closes, or fails to connect, the 80 + // go-routine sleeps, following a randomised binary exponential 81 + // backoff schedule. The go-routine will only exit if the context 82 + // supplied to [connect] errors. 83 + func (c *conn) connect() { 84 + const minSleepDuration = 250 * time.Millisecond 85 + const maxSleepDuration = 15 * time.Second 86 + sleepDuration := minSleepDuration 87 + 88 + profile := c.profile 89 + serverUrl := profile.ServerURL 90 + ctx := c.ctx 91 + 92 + var dialOpts *websocket.DialOptions 93 + 94 + if profile.Token != "" { 95 + dialOpts = &websocket.DialOptions{ 96 + HTTPHeader: http.Header{ 97 + "Authorization": {"Bearer " + profile.Token}, 98 + }, 99 + } 100 + } 101 + 102 + for { 103 + conn, resp, err := websocket.Dial(ctx, serverUrl+wsPath, dialOpts) 104 + 105 + if err == nil { 106 + c.debugLogf("extValidator: connected to %s", serverUrl) 107 + c.mu.Lock() 108 + c.conn = conn 109 + c.mu.Unlock() 110 + 111 + c.receive(conn) 112 + 113 + c.mu.Lock() 114 + c.conn = nil 115 + c.mu.Unlock() 116 + sleepDuration = minSleepDuration 117 + 118 + } else if resp == nil { 119 + c.debugLogf("extValidator: error when dialing %s: %v", serverUrl, err) 120 + } else { 121 + c.debugLogf("extValidator: error when dialing %s: %v, http status: %v", serverUrl, err, resp.StatusCode) 122 + } 123 + 124 + if ctx.Err() != nil { 125 + return 126 + 127 + } else { 128 + time.Sleep(sleepDuration) 129 + sleepDuration += time.Duration(rand.Int64N(int64(sleepDuration))) 130 + sleepDuration = min(sleepDuration, maxSleepDuration) 131 + } 132 + } 133 + } 134 + 135 + // requestEvaluation sends the supplied [protocol.EvalRequestMsg] to 136 + // the external validator. If the connection to the external validator 137 + // exists and writing to it returns no error, then true is returned; 138 + // otherwise false. However, as normal, just because the message was 139 + // sent does not mean that it was received. 140 + func (c *conn) requestEvaluation(msg *protocol.EvalRequestMsg) bool { 141 + // TODO: it would be better to use the websocket Writer as 142 + // MarshalBytes is really just making another exact copy of msg. 143 + data := msg.MarshalBytes() 144 + 145 + c.mu.Lock() 146 + defer c.mu.Unlock() 147 + 148 + if c.conn != nil { 149 + err := c.conn.Write(c.ctx, websocket.MessageBinary, data) 150 + return err == nil 151 + } 152 + return false 153 + } 154 + 155 + // receive is the connection's receive-loop. 156 + func (c *conn) receive(conn *websocket.Conn) { 157 + defer conn.Close(websocket.StatusNormalClosure, "") 158 + const readLimit = 16 * 1024 * 1024 // 16MB 159 + conn.SetReadLimit(readLimit) 160 + 161 + ctx := c.ctx 162 + client := c.client 163 + client.connected() 164 + 165 + var closeErr websocket.CloseError 166 + for { 167 + msgType, data, err := conn.Read(ctx) 168 + if errors.Is(err, io.EOF) || errors.As(err, &closeErr) || ctx.Err() != nil { 169 + return 170 + } else if err != nil { 171 + c.debugLogf("extValidator: error when reading from websocket: %v", err) 172 + return 173 + } else if msgType != websocket.MessageBinary { 174 + c.debugLog("extValidator: websocket received non-binary message") 175 + return 176 + } 177 + 178 + msgTypeProto, err := protocol.PeekMessageType(data) 179 + if err != nil { 180 + c.debugLogf("extValidator: protocol violation: %v", err) 181 + return 182 + } 183 + 184 + switch msgTypeProto { 185 + case protocol.MsgTypeChanged: 186 + msg := &protocol.ChangedMsg{} 187 + err = msg.UnmarshalBytes(data) 188 + if err == nil { 189 + err = client.changeSignal(msg) 190 + } 191 + 192 + case protocol.MsgTypeEvalResult: 193 + msg := &protocol.EvalResultMsg{} 194 + err = msg.UnmarshalBytes(data) 195 + if err == nil { 196 + err = client.evalResult(msg) 197 + } 198 + 199 + case protocol.MsgTypeEvalFinished: 200 + msg := &protocol.EvalFinishedMsg{} 201 + err = msg.UnmarshalBytes(data) 202 + if err == nil { 203 + err = client.evalFinished(msg) 204 + } 205 + 206 + default: 207 + c.debugLog("protocol violation") 208 + return 209 + } 210 + 211 + if err != nil { 212 + c.debugLog(err.Error()) 213 + return 214 + } 215 + } 216 + } 217 + 218 + func (c *conn) debugLogf(format string, args ...any) { 219 + c.debugLog(fmt.Sprintf(format, args...)) 220 + }
+387
internal/lsp/extvalidator/extvalidator.go
··· 1 + // Copyright 2026 CUE Authors 2 + // 3 + // Licensed under the Apache License, Version 2.0 (the "License"); 4 + // you may not use this file except in compliance with the License. 5 + // You may obtain a copy of the License at 6 + // 7 + // http://www.apache.org/licenses/LICENSE-2.0 8 + // 9 + // Unless required by applicable law or agreed to in writing, software 10 + // distributed under the License is distributed on an "AS IS" BASIS, 11 + // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 + // See the License for the specific language governing permissions and 13 + // limitations under the License. 14 + 15 + // extvalidator supports connecting to, and communicating with, 16 + // external validation servers. 17 + package extvalidator 18 + 19 + import ( 20 + "archive/zip" 21 + "bytes" 22 + "encoding/hex" 23 + "fmt" 24 + iofs "io/fs" 25 + "os" 26 + "os/exec" 27 + "path/filepath" 28 + "strings" 29 + "sync" 30 + 31 + "cuelang.org/go/internal/golangorgx/gopls/protocol" 32 + "cuelang.org/go/internal/lsp/fscache" 33 + extproto "cuelang.org/go/unstable/lspaux/protocol" 34 + "cuelang.org/go/unstable/lspaux/validatorconfig" 35 + ) 36 + 37 + type Manager struct { 38 + profile *validatorconfig.Profile 39 + fs *fscache.OverlayFS 40 + debugLog func(msg string) 41 + validators map[protocol.DocumentURI]*Validator 42 + } 43 + 44 + // NewManager creates a new [Manager] which can be used to manage 45 + // connections between repos within fs, and the external validator 46 + // server indicated by profile. 47 + func NewManager(profile *validatorconfig.Profile, fs *fscache.OverlayFS, debugLog func(msg string)) *Manager { 48 + return &Manager{ 49 + profile: profile, 50 + fs: fs, 51 + debugLog: debugLog, 52 + validators: make(map[protocol.DocumentURI]*Validator), 53 + } 54 + } 55 + 56 + // EnsureValidator creates (if necessary) and returns an external 57 + // validator, appropriate for the given fileUri. 58 + // 59 + // Currently, it tests to see if there is a ".git" directory in the 60 + // file's directory or any parent directory. If no such directory is 61 + // found, nil will be returned. 62 + func (mgr *Manager) EnsureValidator(fileUri protocol.DocumentURI, onDirtyChange func(*Validator)) *Validator { 63 + fs := mgr.fs.IoFS(string(os.PathSeparator)) 64 + found := false 65 + var oldUri protocol.DocumentURI 66 + for ; fileUri != oldUri; oldUri, fileUri = fileUri, fileUri.Dir() { 67 + if isDir(fs, fileUri+"/.git") { 68 + found = true 69 + break 70 + } 71 + } 72 + if !found { 73 + return nil 74 + } 75 + 76 + v, found := mgr.validators[fileUri] 77 + if found { 78 + return v 79 + } 80 + 81 + v = &Validator{ 82 + mgr: mgr, 83 + rootURI: fileUri, 84 + onDirtyChange: onDirtyChange, 85 + } 86 + mgr.debugLog(fmt.Sprintf("extValidators: creating external validator for %v", fileUri)) 87 + v.conn = connect(mgr.profile, nil, v, mgr.debugLog) 88 + mgr.validators[fileUri] = v 89 + v.MarkDirty() 90 + return v 91 + } 92 + 93 + func isDir(fs iofs.StatFS, uri protocol.DocumentURI) bool { 94 + path := strings.TrimLeft(uri.Path(), "/") 95 + info, err := fs.Stat(path) 96 + return err == nil && info.IsDir() 97 + } 98 + 99 + // Validator represents an external validator for an entire 100 + // repository. 101 + // 102 + // A Validator can be clean or dirty. It can be marked dirty either by 103 + // the external validator informing the LSP, or by the LSP choosing to 104 + // mark the external validator as dirty. When a validation is started, 105 + // the validator is marked clean. In all cases, as the validator 106 + // transitions from clean to dirty, or dirty to clean, the 107 + // onDirtyChange callback will be invoked, if supplied. 108 + type Validator struct { 109 + mgr *Manager 110 + rootURI protocol.DocumentURI 111 + conn *conn 112 + onDirtyChange func(*Validator) 113 + 114 + mu sync.Mutex 115 + requestId int 116 + cur *validation 117 + isDirty bool 118 + } 119 + 120 + // IsDirty reports if the validator is currently dirty. 121 + func (v *Validator) IsDirty() bool { 122 + v.mu.Lock() 123 + defer v.mu.Unlock() 124 + return v.isDirty 125 + } 126 + 127 + type ResponseHandler interface { 128 + // Result is called for each validation result received from the 129 + // server. NB this may never be called if a subsequent new 130 + // validation request is made before any results are received. 131 + Result(*extproto.EvalResultMsg) 132 + // Finished is called once all validation results have been 133 + // received by the server. NB this may never be called if a new 134 + // validation request is made before the server indicates it has 135 + // sent all the results. 136 + Finished(*extproto.EvalFinishedMsg) 137 + // Clear is called when this validation request is replaced with a 138 + // new validation request. This will always be called, regardless 139 + // of how many results have been received, at the point that a new 140 + // validation request is made. 141 + Clear() 142 + } 143 + 144 + type validation struct { 145 + handler ResponseHandler 146 + versionedURIs map[protocol.DocumentURI]int32 147 + requestId string 148 + } 149 + 150 + // StartValidation creates and sends a validation request to the 151 + // external validator. If a validation already exists, its handler's 152 + // [ResponseHandler.Clear] method is invoked. 153 + func (v *Validator) StartValidation(handler ResponseHandler) error { 154 + repoName, err := v.repoName() 155 + if err != nil { 156 + return err 157 + } 158 + 159 + commitId, err := v.commitId() 160 + if err != nil { 161 + return err 162 + } 163 + 164 + trackedFiles, err := v.trackedFiles() 165 + if err != nil { 166 + return err 167 + } 168 + 169 + // TODO: switch to sending diffs. 170 + var buf bytes.Buffer 171 + w := zip.NewWriter(&buf) 172 + versionedURIs, err := addFS(w, v.mgr.fs, v.rootURI, trackedFiles) 173 + if err != nil { 174 + return err 175 + } 176 + err = w.Close() 177 + if err != nil { 178 + return err 179 + } 180 + 181 + v.mu.Lock() 182 + v.requestId++ 183 + requestId := fmt.Sprint(v.requestId) 184 + 185 + oldValidation := v.cur 186 + v.cur = &validation{ 187 + handler: handler, 188 + versionedURIs: versionedURIs, 189 + requestId: requestId, 190 + } 191 + v.mu.Unlock() 192 + 193 + if oldValidation != nil { 194 + go oldValidation.handler.Clear() 195 + } 196 + 197 + msg := &extproto.EvalRequestMsg{ 198 + RequestID: requestId, 199 + RepoName: repoName, 200 + CommitID: commitId, 201 + ZipData: buf.Bytes(), 202 + } 203 + v.conn.debugLogf("extValidator: sending validation request; id: %s; repo: %s; commit: %s", requestId, repoName, commitId) 204 + if v.conn.requestEvaluation(msg) { 205 + v.setDirty(false) 206 + } 207 + 208 + return nil 209 + } 210 + 211 + func (v *Validator) commitId() (string, error) { 212 + data, err := v.runGit("rev-parse", "--verify", "HEAD") 213 + if err != nil { 214 + return "", err 215 + } 216 + data = bytes.TrimSpace(data) 217 + out := make([]byte, hex.DecodedLen(len(data))) 218 + _, err = hex.Decode(out, data) 219 + if err != nil { 220 + return "", err 221 + } 222 + return string(data), nil 223 + } 224 + 225 + // repoName returns the url of the "origin" remote from the git repo. 226 + // 227 + // In reality there is no single reponame, and this current approach 228 + // is only likely to work for a subset of use-cases. TODO: find a 229 + // better solution for the naming of sources. 230 + func (v *Validator) repoName() (string, error) { 231 + data, err := v.runGit("config", "--local", "remote.origin.url") 232 + if err != nil { 233 + return "", err 234 + } 235 + url := string(bytes.TrimSpace(data)) 236 + if withoutGitHub, wasCut := strings.CutPrefix(url, "git@github.com:"); wasCut { 237 + // transform "git@github.com:foo/bar.git" into "github:foo/bar" 238 + url = "github:" + strings.TrimSuffix(withoutGitHub, ".git") 239 + } 240 + return url, nil 241 + } 242 + 243 + // trackedFiles returns all the tracked files within the validator's 244 + // git-repo. All the paths returned are /-separated and relative to 245 + // the repo's root. 246 + func (v *Validator) trackedFiles() ([]string, error) { 247 + data, err := v.runGit("ls-files", "--full-name", "-z", ":/") 248 + if err != nil { 249 + return nil, err 250 + } 251 + data = bytes.Trim(data, "\000") 252 + return strings.Split(string(data), "\000"), nil 253 + } 254 + 255 + func (v *Validator) runGit(args ...string) ([]byte, error) { 256 + dir := v.rootURI.FilePath() 257 + args = append([]string{"--git-dir=" + dir + "/.git", "--work-tree=" + dir}, args...) 258 + cmd := exec.Command("git", args...) 259 + cmd.Dir = dir 260 + cmd.Env = []string{"GIT_CONFIG_NOSYSTEM=1"} // also nuke out all existing env 261 + return cmd.Output() 262 + } 263 + 264 + // MarkDirty ensures the [Validator] is considered dirty. If it was 265 + // previously clean, the onDirtyChange callback will be invoked, if it 266 + // was supplied to [EnsureExtValidator]. 267 + func (v *Validator) MarkDirty() { 268 + v.setDirty(true) 269 + } 270 + 271 + func (v *Validator) setDirty(isDirty bool) { 272 + v.mu.Lock() 273 + var onDirtyChange func(*Validator) 274 + if v.isDirty != isDirty { 275 + v.isDirty = isDirty 276 + onDirtyChange = v.onDirtyChange 277 + } 278 + v.mu.Unlock() 279 + 280 + if onDirtyChange != nil { 281 + onDirtyChange(v) 282 + } 283 + } 284 + 285 + // connected implements [extValidatorClient] 286 + func (v *Validator) connected() { 287 + v.MarkDirty() 288 + } 289 + 290 + // changeSignal implements [extValidatorClient] 291 + func (v *Validator) changeSignal(*extproto.ChangedMsg) error { 292 + v.conn.debugLog("extValidator: received change signal") 293 + v.MarkDirty() 294 + return nil 295 + } 296 + 297 + // evalResult implements [extValidatorClient] 298 + func (v *Validator) evalResult(msg *extproto.EvalResultMsg) error { 299 + v.mu.Lock() 300 + validation := v.cur 301 + v.mu.Unlock() 302 + 303 + if validation == nil || msg.RequestID != validation.requestId { 304 + return nil 305 + } 306 + 307 + v.conn.debugLogf("extValidator: recevied validation result; id %s", validation.requestId) 308 + 309 + versionedURIs := validation.versionedURIs 310 + 311 + rootURI := v.rootURI + "/" 312 + for i := range msg.Errors { 313 + err := &msg.Errors[i] 314 + coords := err.Coordinates[:0] 315 + for _, coord := range err.Coordinates { 316 + // TODO: coord.Path really needs to turn into a proper 317 + // URI. Currently it could have raw spaces in it etc which 318 + // would be problematic. 319 + uri := rootURI + protocol.DocumentURI(coord.Path) 320 + if _, found := versionedURIs[uri]; !found { 321 + continue 322 + } 323 + coord.Path = string(uri) 324 + coords = append(coords, coord) 325 + } 326 + err.Coordinates = coords 327 + } 328 + validation.handler.Result(msg) 329 + 330 + return nil 331 + } 332 + 333 + // evalFinished implements [extValidatorClient] 334 + func (v *Validator) evalFinished(msg *extproto.EvalFinishedMsg) error { 335 + v.mu.Lock() 336 + validation := v.cur 337 + v.mu.Unlock() 338 + 339 + if validation == nil || msg.RequestID != validation.requestId { 340 + return nil 341 + } 342 + 343 + v.conn.debugLogf("extValidator: validation finished; id %s", validation.requestId) 344 + 345 + validation.handler.Finished(msg) 346 + 347 + return nil 348 + } 349 + 350 + // addFS adds the trackedFiles (and their directories) to the supplied 351 + // [zip.Writer]. trackedFiles must be /-separated paths, relative to 352 + // rootURI. 353 + func addFS(w *zip.Writer, fs *fscache.OverlayFS, rootURI protocol.DocumentURI, trackedFiles []string) (map[protocol.DocumentURI]int32, error) { 354 + rootFilePath := rootURI.FilePath() 355 + 356 + versionedURIs := make(map[protocol.DocumentURI]int32) 357 + 358 + for _, name := range trackedFiles { 359 + uri := protocol.URIFromPath(filepath.Join(rootFilePath, filepath.FromSlash(name))) 360 + if _, found := versionedURIs[uri]; found { 361 + continue 362 + } 363 + 364 + fh, err := fs.ReadFile(uri) 365 + if err != nil { 366 + return nil, err 367 + } 368 + versionedURIs[uri] = fh.Version() 369 + h := &zip.FileHeader{ 370 + Name: name, 371 + UncompressedSize64: uint64(len(fh.Content())), 372 + Method: zip.Deflate, 373 + Modified: fh.ModTime().UTC(), 374 + } 375 + 376 + fw, err := w.CreateHeader(h) 377 + if err != nil { 378 + return nil, err 379 + } 380 + _, err = fw.Write(fh.Content()) 381 + if err != nil { 382 + return nil, err 383 + } 384 + } 385 + 386 + return versionedURIs, nil 387 + }
+9 -1
internal/lsp/fscache/fs_cache.go
··· 45 45 // is a copy of the underlying file content, and thus safe to be 46 46 // mutated. This matches the behaviour of [iofs.ReadFileFS]. 47 47 Content() []byte 48 + // ModTime returns modification time of the file. 49 + ModTime() time.Time 48 50 } 49 51 50 52 type diskFileEntry struct { ··· 210 212 } 211 213 212 214 // Version implements [FileHandle] 213 - func (entry *diskFileEntry) Version() int32 { panic("Should never be called") } 215 + func (entry *diskFileEntry) Version() int32 { return 0 } 214 216 215 217 // Content implements [FileHandle] 216 218 func (entry *diskFileEntry) Content() []byte { return slices.Clone(entry.content) } 217 219 220 + // ModTime implements [FileHandle] 221 + func (entry *diskFileEntry) ModTime() time.Time { return entry.modTime } 222 + 218 223 // CUECacheFS exists to cache [ast.File] values and thus amortize the 219 224 // cost of parsing cue files. It is not an overlay in any way. Its 220 225 // design is influenced by gopls's similar fs caching layer ··· 389 394 // IoFS implements [RootableFS] 390 395 func (fs *CUECacheFS) IoFS(root string) CUEDirFS { 391 396 root = strings.TrimRight(root, string(os.PathSeparator)) 397 + if root == "" { 398 + root = string(os.PathSeparator) 399 + } 392 400 return &rootedCUECacheFS{ 393 401 cuecachefs: fs, 394 402 delegatefs: os.DirFS(root).(DirFS),
+28
internal/lsp/server/codelens.go
··· 1 + // Copyright 2026 The CUE Authors 2 + // 3 + // Licensed under the Apache License, Version 2.0 (the "License"); 4 + // you may not use this file except in compliance with the License. 5 + // You may obtain a copy of the License at 6 + // 7 + // http://www.apache.org/licenses/LICENSE-2.0 8 + // 9 + // Unless required by applicable law or agreed to in writing, software 10 + // distributed under the License is distributed on an "AS IS" BASIS, 11 + // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 + // See the License for the specific language governing permissions and 13 + // limitations under the License. 14 + 15 + package server 16 + 17 + import ( 18 + "context" 19 + 20 + "cuelang.org/go/internal/golangorgx/gopls/protocol" 21 + ) 22 + 23 + func (s *server) CodeLens(ctx context.Context, params *protocol.CodeLensParams) ([]protocol.CodeLens, error) { 24 + if v := s.workspace.CodeLensExternalValidate(ctx, params); v != nil { 25 + return []protocol.CodeLens{*v}, nil 26 + } 27 + return nil, nil 28 + }
+44
internal/lsp/server/command.go
··· 1 + // Copyright 2026 The CUE Authors 2 + // 3 + // Licensed under the Apache License, Version 2.0 (the "License"); 4 + // you may not use this file except in compliance with the License. 5 + // You may obtain a copy of the License at 6 + // 7 + // http://www.apache.org/licenses/LICENSE-2.0 8 + // 9 + // Unless required by applicable law or agreed to in writing, software 10 + // distributed under the License is distributed on an "AS IS" BASIS, 11 + // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 + // See the License for the specific language governing permissions and 13 + // limitations under the License. 14 + 15 + package server 16 + 17 + import ( 18 + "context" 19 + "encoding/json" 20 + "fmt" 21 + 22 + "cuelang.org/go/internal/golangorgx/gopls/protocol" 23 + "cuelang.org/go/internal/golangorgx/gopls/settings" 24 + ) 25 + 26 + func (s *server) ExecuteCommand(ctx context.Context, params *protocol.ExecuteCommandParams) (any, error) { 27 + switch params.Command { 28 + case settings.ExternalValidateCommand: 29 + args := params.Arguments 30 + if len(args) != 1 { 31 + return nil, fmt.Errorf("unexpected argument count. Expected 1, got %d", len(args)) 32 + } 33 + var uri protocol.DocumentURI 34 + err := json.Unmarshal(args[0], &uri) 35 + if err != nil { 36 + return nil, err 37 + } 38 + 39 + err = s.workspace.CommandExternalValidate(ctx, uri) 40 + return nil, err 41 + 42 + } 43 + return nil, notImplemented("ExecuteCommand") 44 + }
+7 -3
internal/lsp/server/initialize.go
··· 114 114 115 115 Capabilities: protocol.ServerCapabilities{ 116 116 CodeActionProvider: codeActionProvider, 117 + CodeLensProvider: &protocol.CodeLensOptions{}, // must be non-nil to enable the code lens capability 117 118 CompletionProvider: &protocol.CompletionOptions{ 118 119 TriggerCharacters: []string{"."}, 119 120 }, 120 121 DefinitionProvider: &protocol.Or_ServerCapabilities_definitionProvider{Value: true}, 121 122 DocumentFormattingProvider: &protocol.Or_ServerCapabilities_documentFormattingProvider{Value: true}, 122 123 DocumentSymbolProvider: &protocol.Or_ServerCapabilities_documentSymbolProvider{Value: true}, 123 - HoverProvider: &protocol.Or_ServerCapabilities_hoverProvider{Value: true}, 124 - ReferencesProvider: &protocol.Or_ServerCapabilities_referencesProvider{Value: true}, 125 - RenameProvider: renameOpts, 124 + ExecuteCommandProvider: &protocol.ExecuteCommandOptions{ 125 + Commands: protocol.NonNilSlice(options.SupportedCommands), 126 + }, 127 + HoverProvider: &protocol.Or_ServerCapabilities_hoverProvider{Value: true}, 128 + ReferencesProvider: &protocol.Or_ServerCapabilities_referencesProvider{Value: true}, 129 + RenameProvider: renameOpts, 126 130 TextDocumentSync: &protocol.TextDocumentSyncOptions{ 127 131 Change: protocol.Incremental, 128 132 OpenClose: true,
-8
internal/lsp/server/unimplemented.go
··· 14 14 "cuelang.org/go/internal/golangorgx/tools/jsonrpc2" 15 15 ) 16 16 17 - func (s *server) CodeLens(ctx context.Context, params *protocol.CodeLensParams) ([]protocol.CodeLens, error) { 18 - return nil, notImplemented("CodeLens") 19 - } 20 - 21 17 func (s *server) ColorPresentation(context.Context, *protocol.ColorPresentationParams) ([]protocol.ColorPresentation, error) { 22 18 return nil, notImplemented("ColorPresentation") 23 19 } ··· 72 68 73 69 func (s *server) DocumentLink(ctx context.Context, params *protocol.DocumentLinkParams) (links []protocol.DocumentLink, err error) { 74 70 return nil, notImplemented("DocumentLink") 75 - } 76 - 77 - func (s *server) ExecuteCommand(ctx context.Context, params *protocol.ExecuteCommandParams) (interface{}, error) { 78 - return nil, notImplemented("ExecuteCommand") 79 71 } 80 72 81 73 func (s *server) FoldingRange(ctx context.Context, params *protocol.FoldingRangeParams) ([]protocol.FoldingRange, error) {
+86
unstable/lspaux/validatorconfig/config.go
··· 1 + // Copyright 2026 CUE Authors 2 + // 3 + // Licensed under the Apache License, Version 2.0 (the "License"); 4 + // you may not use this file except in compliance with the License. 5 + // You may obtain a copy of the License at 6 + // 7 + // http://www.apache.org/licenses/LICENSE-2.0 8 + // 9 + // Unless required by applicable law or agreed to in writing, software 10 + // distributed under the License is distributed on an "AS IS" BASIS, 11 + // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 + // See the License for the specific language governing permissions and 13 + // limitations under the License. 14 + 15 + // WARNING: THIS PACKAGE IS EXPERIMENTAL. 16 + // ITS API MAY CHANGE AT ANY TIME. 17 + // 18 + // validatorconfig supports reading and parsing user configuration 19 + // files for external validators. 20 + package validatorconfig 21 + 22 + import ( 23 + "os" 24 + 25 + "cuelang.org/go/cue" 26 + "cuelang.org/go/cue/ast" 27 + "cuelang.org/go/cue/build" 28 + "cuelang.org/go/cue/cuecontext" 29 + "cuelang.org/go/cue/errors" 30 + "cuelang.org/go/cue/token" 31 + "cuelang.org/go/internal/encoding" 32 + "cuelang.org/go/internal/filetypes" 33 + ) 34 + 35 + type File struct { 36 + ActiveProfile string `json:"activeProfile"` 37 + Profiles map[string]*Profile `json:"profiles,omitempty"` 38 + } 39 + 40 + type Profile struct { 41 + ServerURL string `json:"serverURL"` 42 + Name string `json:"name"` 43 + Token string `json:"token"` 44 + } 45 + 46 + // Parse reads the provided path as a data-only CUE file, and attempts 47 + // to decode it into [File]. 48 + func Parse(path string) (file *File, err error) { 49 + data, err := os.ReadFile(path) 50 + if err != nil { 51 + return nil, err 52 + } 53 + ctx := cuecontext.New() 54 + astFile, err := parseDataOnlyCUE(ctx, data, path) 55 + if err != nil { 56 + return nil, errors.Wrapf(err, token.NoPos, "invalid config file syntax") 57 + } 58 + // TODO: unify with a closed schema so that we can detect spelling 59 + // mistakes. 60 + v := ctx.BuildFile(astFile) 61 + if err := v.Validate(cue.Concrete(true)); err != nil { 62 + return nil, errors.Wrapf(err, token.NoPos, "invalid module file value") 63 + } 64 + var mf File 65 + if err := v.Decode(&mf); err != nil { 66 + return nil, errors.Wrapf(err, token.NoPos, "internal error: cannot decode into modFile struct") 67 + } 68 + return &mf, nil 69 + } 70 + 71 + func parseDataOnlyCUE(ctx *cue.Context, cueData []byte, filename string) (*ast.File, error) { 72 + dec := encoding.NewDecoder(ctx, &build.File{ 73 + Filename: filename, 74 + Encoding: build.CUE, 75 + Interpretation: build.Auto, 76 + Form: build.Data, 77 + Source: cueData, 78 + }, &encoding.Config{ 79 + Mode: filetypes.Export, 80 + AllErrors: true, 81 + }) 82 + if err := dec.Err(); err != nil { 83 + return nil, err 84 + } 85 + return dec.File(), nil 86 + }