this repo has no description
0
fork

Configure Feed

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

internal/lsp: implement ConvertToStruct code action

With the cursor on `b`, we want to transform:

a: b: whatever

into

a: {
b: whatever
}

Fun complications:
* `whatever` can be multi-line
* field names can be nested. For example:

a: [{x: y: z: _, w: "foo"}.w]: {}

with the cursor on `y`, we want to transform the inner-most field name
(i.e. `y`). So this gets transformed into:

a: [{x: {
y: z: _
}
w: "foo"}.w]: {}

* in some circumstances, we need to remove commas and spaces. E.g.

a: b: _, x: y: _

becomes (with cursor on `b`):

a: {
b: _
}
x: y: _

Note the comma and space before `x` has gone away.

It turns out that parsing CUE into an AST is lossy: i.e. you can't
reconstruct the original []byte from the AST. Consequently, we can't
make this transformation by adjusting the parsed AST because doing so
would lead to canonically formatting the entire AST which is not what
the user expects. so we are forced into byte splicing.

Fixes #4073

Signed-off-by: Matthew Sackman <matthew@cue.works>
Change-Id: I1d56d5154993e7b28fbdc907672b97827e5ec9d4
Reviewed-on: https://cue.gerrithub.io/c/cue-lang/cue/+/1230699
TryBot-Result: CUEcueckoo <cueckoo@cuelang.org>
Unity-Result: CUE porcuepine <cue.porcuepine@gmail.com>
Reviewed-by: Daniel Martí <mvdan@mvdan.cc>

+361 -19
+112
cmd/cue/cmd/integration/workspace/codeaction_test.go
··· 1 + package workspace 2 + 3 + import ( 4 + "slices" 5 + "testing" 6 + 7 + "cuelang.org/go/internal/golangorgx/gopls/protocol" 8 + . "cuelang.org/go/internal/golangorgx/gopls/test/integration" 9 + "github.com/go-quicktest/qt" 10 + ) 11 + 12 + func TestCodeActionConvertToStruct(t *testing.T) { 13 + type testCase struct { 14 + name string 15 + input string 16 + position protocol.Position 17 + expected string 18 + } 19 + testCases := []testCase{ 20 + { 21 + name: "simple", 22 + input: `foo: bar: "baz"`, 23 + position: protocol.Position{Line: 0, Character: 5}, // bar 24 + expected: ` 25 + foo: { 26 + bar: "baz" 27 + } 28 + `[1:], 29 + }, 30 + { 31 + name: "multiline_field", 32 + input: ` 33 + before: _ 34 + foo: bar: { 35 + "baz" 36 + } 37 + after: _ // weird indent 38 + `[1:], 39 + position: protocol.Position{Line: 1, Character: 15}, // bar 40 + expected: ` 41 + before: _ 42 + foo: { 43 + bar: { 44 + "baz" 45 + } 46 + } 47 + after: _ // weird indent 48 + `[1:], 49 + }, 50 + { 51 + name: "nested_fields", 52 + input: `a: [{x: y: z: _, w: "foo"}.w]: {}`, 53 + position: protocol.Position{Line: 0, Character: 8}, // y 54 + expected: ` 55 + a: [{x: { 56 + y: z: _ 57 + } 58 + w: "foo"}.w]: {} 59 + `[1:], 60 + }, 61 + { 62 + name: "comma_separated", 63 + input: ` a: b: _, x: y: _`, 64 + position: protocol.Position{Line: 0, Character: 5}, // b 65 + // Note how the comma between _ and x: gets removed 66 + expected: ` 67 + a: { 68 + b: _ 69 + } 70 + x: y: _ 71 + `[1:], 72 + }, 73 + } 74 + 75 + for _, tc := range testCases { 76 + t.Run(tc.name, func(t *testing.T) { 77 + WithOptions(RootURIAsDefaultFolder()).Run(t, "-- input.cue --\n"+tc.input, func(t *testing.T, env *Env) { 78 + env.OpenFile("input.cue") 79 + env.Await(env.DoneWithOpen()) 80 + rootURI := env.Sandbox.Workdir.RootURI() 81 + 82 + cursor := protocol.Location{ 83 + URI: rootURI + "/input.cue", 84 + Range: protocol.Range{ 85 + Start: tc.position, 86 + }, 87 + } 88 + 89 + actions, err := env.Editor.CodeAction(env.Ctx, cursor, nil) 90 + if err != nil { 91 + qt.Assert(t, qt.IsNil(err)) 92 + } 93 + 94 + var action protocol.CodeAction 95 + found := slices.ContainsFunc(actions, func(a protocol.CodeAction) bool { 96 + if a.Title == "Wrap field in struct" { 97 + action = a 98 + return true 99 + } 100 + return false 101 + }) 102 + if !found { 103 + t.Fatal("Failed to find ConvertToStruct code action") 104 + } 105 + 106 + env.ApplyCodeAction(action) 107 + after := env.BufferText("input.cue") 108 + qt.Check(t, qt.Equals(after, tc.expected)) 109 + }) 110 + }) 111 + } 112 + }
+8
cue/token/position.go
··· 278 278 return (int(x) << relShift) 279 279 } 280 280 281 + // WithinInclusive reports whether offset lies within the range start 282 + // to end, inclusive on both ends. It is up to the caller to ensure 283 + // that start and end are from the same file, and start is before end, 284 + // and that offset is appropriate for the file. 285 + func WithinInclusive(offset int, start, end Pos) bool { 286 + return start.Offset() <= offset && offset <= end.Offset() 287 + } 288 + 281 289 // ----------------------------------------------------------------------------- 282 290 // File 283 291
+1 -2
internal/golangorgx/gopls/protocol/codeactionkind.go
··· 6 6 7 7 // Custom code actions that aren't explicitly stated in LSP 8 8 const ( 9 - GoTest CodeActionKind = "goTest" 10 - // TODO: Add GoGenerate, RegenerateCgo etc. 9 + RefactorRewriteConvertToStruct CodeActionKind = "refactor.rewrite.convertToStruct" 11 10 )
+6
internal/golangorgx/gopls/settings/default.go
··· 8 8 "sync" 9 9 "time" 10 10 11 + "cuelang.org/go/internal/golangorgx/gopls/file" 11 12 "cuelang.org/go/internal/golangorgx/gopls/protocol" 12 13 "cuelang.org/go/internal/golangorgx/gopls/protocol/command" 13 14 ) ··· 38 39 HierarchicalDocumentSymbolSupport: true, 39 40 }, 40 41 ServerOptions: ServerOptions{ 42 + SupportedCodeActions: map[file.Kind]map[protocol.CodeActionKind]bool{ 43 + file.CUE: { 44 + protocol.RefactorRewriteConvertToStruct: true, 45 + }, 46 + }, 41 47 SupportedCommands: commands, 42 48 }, 43 49 UserOptions: UserOptions{
+165
internal/lsp/cache/codeaction.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 + "bytes" 19 + "context" 20 + "slices" 21 + "strings" 22 + 23 + "cuelang.org/go/cue/ast" 24 + "cuelang.org/go/cue/token" 25 + "cuelang.org/go/internal/golangorgx/gopls/protocol" 26 + "cuelang.org/go/internal/golangorgx/tools/diff" 27 + ) 28 + 29 + func (w *Workspace) CodeActionConvertToStruct(ctx context.Context, params *protocol.CodeActionParams) (*protocol.WorkspaceEdit, error) { 30 + f := w.GetFile(params.TextDocument.URI) 31 + if f == nil || f.syntax == nil || f.mapper == nil || f.tokFile == nil { 32 + return nil, nil 33 + } 34 + 35 + offset, _, err := f.mapper.RangeOffsets(params.Range) 36 + if err != nil { 37 + return nil, err 38 + } 39 + 40 + var structLit *ast.StructLit 41 + var field *ast.Field 42 + 43 + ast.Walk(f.syntax, 44 + // Walk over the AST, traversing into whatever contains the cursor position. 45 + func(node ast.Node) bool { 46 + start := node.Pos() 47 + end := node.End() 48 + 49 + if !start.HasAbsPos() || !end.HasAbsPos() { 50 + return false 51 + } 52 + if !token.WithinInclusive(offset, start, end) { 53 + return false 54 + } 55 + return true 56 + }, 57 + 58 + // We want the inner-most matching field, so on the way back 59 + // out, capture the first suitable structlit+field+label. 60 + func(node ast.Node) { 61 + if structLit != nil { 62 + return 63 + } 64 + 65 + sl, ok := node.(*ast.StructLit) 66 + if !ok || sl.Lbrace.HasAbsPos() || sl.Rbrace.HasAbsPos() || len(sl.Elts) != 1 { 67 + return 68 + } 69 + 70 + f, ok := sl.Elts[0].(*ast.Field) 71 + if !ok { 72 + return 73 + } 74 + lab := f.Label 75 + start := lab.Pos() 76 + end := lab.End() 77 + 78 + if !start.HasAbsPos() || !end.HasAbsPos() { 79 + return 80 + } 81 + if !(token.WithinInclusive(offset, start, end) && start.Line() == sl.Pos().Line()) { 82 + return 83 + } 84 + 85 + structLit = sl 86 + field = f 87 + }) 88 + 89 + if structLit == nil { 90 + return nil, nil 91 + } 92 + 93 + content := f.tokFile.Content() 94 + lineStartOffsets := f.tokFile.Lines() // Lines is 0-based 95 + numLines := len(lineStartOffsets) 96 + labelStart := field.Pos() 97 + lineNo := labelStart.Line() - 1 // Line is 1-based, hence the -1 98 + if lineNo < 0 || lineNo >= len(lineStartOffsets) { 99 + return nil, nil 100 + } 101 + 102 + lineEnding := "\n" 103 + if numLines > 1 { 104 + if content[lineStartOffsets[1]-2] == '\r' { 105 + lineEnding = "\r\n" 106 + } 107 + } 108 + 109 + var indent []byte 110 + lineStartOffset := lineStartOffsets[lineNo] 111 + for i := lineStartOffset; i < len(content); i++ { 112 + c := content[i] 113 + if c != ' ' && c != '\t' { 114 + indent = content[lineStartOffset:i] 115 + break 116 + } 117 + } 118 + 119 + var contentBuilder strings.Builder 120 + contentBuilder.Write(content[:labelStart.Offset()]) 121 + 122 + contentBuilder.WriteString("{") 123 + contentBuilder.WriteString(lineEnding) 124 + 125 + fieldEndOffset := field.End().Offset() 126 + fieldLines := slices.Collect(bytes.Lines(content[field.Pos().Offset():fieldEndOffset])) 127 + for i, fieldLine := range fieldLines { 128 + contentBuilder.Write(indent) 129 + contentBuilder.WriteString("\t") 130 + isLastLine := i+1 == len(fieldLines) 131 + if isLastLine { 132 + // The last field line may or may not have a trailing line 133 + // ending (because it might be the last line in the file). 134 + fieldLine = bytes.TrimRight(fieldLine, "\r\n") 135 + fieldLine = bytes.TrimRight(fieldLine, ", \t") 136 + } 137 + contentBuilder.Write(fieldLine) 138 + if isLastLine { 139 + contentBuilder.WriteString(lineEnding) 140 + } 141 + } 142 + 143 + contentBuilder.Write(indent) 144 + contentBuilder.WriteString("}") 145 + 146 + remaining := content[fieldEndOffset:] 147 + remaining = bytes.TrimLeft(remaining, ", \t") 148 + 149 + if bytes.HasPrefix(remaining, []byte(lineEnding)) { 150 + contentBuilder.Write(remaining) 151 + } else if len(remaining) > 0 { 152 + contentBuilder.WriteString(lineEnding) 153 + contentBuilder.Write(indent) 154 + contentBuilder.Write(remaining) 155 + } 156 + 157 + diffEdits := diff.Strings(string(content), contentBuilder.String()) 158 + edits, err := protocol.EditsFromDiffEdits(f.mapper, diffEdits) 159 + if err != nil { 160 + return nil, nil 161 + } 162 + 163 + docChanges := protocol.TextEditsToDocumentChanges(params.TextDocument.URI, f.tokFile.Revision(), edits) 164 + return &protocol.WorkspaceEdit{DocumentChanges: docChanges}, nil 165 + }
+5 -13
internal/lsp/eval/eval.go
··· 678 678 // paths *after* explicitly testing the key ident of a 679 679 // fieldDecl. 680 680 fieldDecl, ok := fr.node.(*fieldDeclExpr) 681 - inFieldDecl := ok && withinInclusive(offset, fieldDecl.start, fieldDecl.end) 681 + inFieldDecl := ok && token.WithinInclusive(offset, fieldDecl.start, fieldDecl.end) 682 682 if inFieldDecl { 683 683 // Only make suggestions if we're really within the field key ident. 684 684 if keyIdent := fieldDecl.keyIdent; keyIdent != nil { 685 685 start := keyIdent.Pos() 686 686 end := keyIdent.End() 687 - if withinInclusive(offset, start, end) { 687 + if token.WithinInclusive(offset, start, end) { 688 688 // It's an existing field key ident, we don't care 689 689 // where the colon is; our suggestions replace the 690 690 // whole ident. ··· 800 800 801 801 node := fr.node 802 802 s, isStruct := node.(*ast.StructLit) 803 - if isStruct && s.Lbrace.IsValid() && s.Rbrace.IsValid() && !withinInclusive(offset, s.Lbrace, s.Rbrace) { 803 + if isStruct && s.Lbrace.IsValid() && s.Rbrace.IsValid() && !token.WithinInclusive(offset, s.Lbrace, s.Rbrace) { 804 804 continue 805 805 } 806 806 ··· 1549 1549 } else if f.fileEvaluator != fe { 1550 1550 return false 1551 1551 } else { 1552 - return withinInclusive(offset, start, end) 1552 + return token.WithinInclusive(offset, start, end) 1553 1553 } 1554 1554 } 1555 1555 ··· 2603 2603 pc := components[0] 2604 2604 start := pc.node.Pos() 2605 2605 end := pc.node.End() 2606 - if withinInclusive(offset, start, end) { 2606 + if token.WithinInclusive(offset, start, end) { 2607 2607 return 0, pc.unexpanded 2608 2608 } 2609 2609 return -1, nil ··· 2629 2629 return -1, nil 2630 2630 } 2631 2631 return i, components[i+1].unexpanded 2632 - } 2633 - 2634 - // withinInclusive reports whether offset lies within the range start 2635 - // to end, inclusive on both ends. It is up to the caller to ensure 2636 - // that start and end are from the same file, and start is before end, 2637 - // and that offset is appropriate for the file. 2638 - func withinInclusive(offset int, start, end token.Pos) bool { 2639 - return start.Offset() <= offset && offset <= end.Offset() 2640 2632 } 2641 2633 2642 2634 // frameStack is used when evaluating comprehensions. It allows a
+51
internal/lsp/server/codeaction.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 + "slices" 20 + 21 + "cuelang.org/go/internal/golangorgx/gopls/protocol" 22 + ) 23 + 24 + func (s *server) getSupportedCodeActions() []protocol.CodeActionKind { 25 + var result []protocol.CodeActionKind 26 + for _, kinds := range s.Options().SupportedCodeActions { 27 + for kind := range kinds { 28 + result = append(result, kind) 29 + } 30 + } 31 + slices.Sort(result) 32 + return slices.Compact(result) 33 + } 34 + 35 + func (s *server) CodeAction(ctx context.Context, params *protocol.CodeActionParams) ([]protocol.CodeAction, error) { 36 + var codeActions []protocol.CodeAction 37 + 38 + convertToStructEdit, err := s.workspace.CodeActionConvertToStruct(ctx, params) 39 + if err != nil { 40 + return nil, err 41 + } 42 + if convertToStructEdit != nil { 43 + codeActions = append(codeActions, protocol.CodeAction{ 44 + Title: "Wrap field in struct", 45 + Kind: protocol.RefactorRewriteConvertToStruct, 46 + Edit: convertToStructEdit, 47 + }) 48 + } 49 + 50 + return codeActions, nil 51 + }
+13
internal/lsp/server/initialize.go
··· 87 87 } 88 88 s.eventuallyUseWorkspaceFolders(validFolders) 89 89 90 + var codeActionProvider any = true 91 + if ca := params.Capabilities.TextDocument.CodeAction; len(ca.CodeActionLiteralSupport.CodeActionKind.ValueSet) > 0 { 92 + // If the client has specified CodeActionLiteralSupport, 93 + // send the code actions we support. 94 + // 95 + // Using CodeActionOptions is only valid if codeActionLiteralSupport is set. 96 + codeActionProvider = &protocol.CodeActionOptions{ 97 + CodeActionKinds: s.getSupportedCodeActions(), 98 + ResolveProvider: false, 99 + } 100 + } 101 + 90 102 var renameOpts any = true 91 103 if r := params.Capabilities.TextDocument.Rename; r != nil && r.PrepareSupport { 92 104 renameOpts = protocol.RenameOptions{ ··· 101 113 }, 102 114 103 115 Capabilities: protocol.ServerCapabilities{ 116 + CodeActionProvider: codeActionProvider, 104 117 CompletionProvider: &protocol.CompletionOptions{ 105 118 TriggerCharacters: []string{"."}, 106 119 },
-4
internal/lsp/server/unimplemented.go
··· 14 14 "cuelang.org/go/internal/golangorgx/tools/jsonrpc2" 15 15 ) 16 16 17 - func (s *server) CodeAction(ctx context.Context, params *protocol.CodeActionParams) ([]protocol.CodeAction, error) { 18 - return nil, notImplemented("CodeAction") 19 - } 20 - 21 17 func (s *server) CodeLens(ctx context.Context, params *protocol.CodeLensParams) ([]protocol.CodeLens, error) { 22 18 return nil, notImplemented("CodeLens") 23 19 }