this repo has no description
0
fork

Configure Feed

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

internal/lsp: implement ConvertFromStruct code action

This is the opposite of https://cue.gerrithub.io/c/cue-lang/cue/+/1230699

Rather than converting

a: b: whatever

to

a: {
b: whatever
}

we now support the opposite: for a structlit with exactly 1 field where
the struct has braces, the new code action removes the braces, and
shuffles the field up so it starts at the location of the original {

Fixes #4073

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

+321 -73
+145 -1
cmd/cue/cmd/integration/workspace/codeaction_test.go
··· 27 27 } 28 28 `[1:], 29 29 }, 30 + 30 31 { 31 32 name: "multiline_field", 32 33 input: ` ··· 47 48 after: _ // weird indent 48 49 `[1:], 49 50 }, 51 + 50 52 { 51 53 name: "nested_fields", 52 54 input: `a: [{x: y: z: _, w: "foo"}.w]: {}`, ··· 58 60 w: "foo"}.w]: {} 59 61 `[1:], 60 62 }, 63 + 61 64 { 62 65 name: "comma_separated", 63 66 input: ` a: b: _, x: y: _`, ··· 70 73 x: y: _ 71 74 `[1:], 72 75 }, 76 + 77 + { 78 + name: "multiline_labels", 79 + input: ` 80 + a: 81 + b: _ 82 + `[1:], 83 + position: protocol.Position{Line: 1, Character: 1}, // b 84 + expected: ` 85 + a: 86 + { 87 + b: _ 88 + } 89 + `[1:], 90 + }, 73 91 } 74 92 75 93 for _, tc := range testCases { ··· 93 111 94 112 var action protocol.CodeAction 95 113 found := slices.ContainsFunc(actions, func(a protocol.CodeAction) bool { 96 - if a.Title == "Wrap field in struct" { 114 + if a.Title == "Add surrounding struct braces" { 97 115 action = a 98 116 return true 99 117 } ··· 110 128 }) 111 129 } 112 130 } 131 + 132 + func TestCodeActionConvertFromStruct(t *testing.T) { 133 + type testCase struct { 134 + name string 135 + input string 136 + position protocol.Position 137 + expected string 138 + } 139 + testCases := []testCase{ 140 + { 141 + name: "simple", 142 + input: ` 143 + foo: { 144 + bar: "baz" 145 + } 146 + `[1:], 147 + position: protocol.Position{Line: 1, Character: 1}, // bar 148 + expected: ` 149 + foo: bar: "baz" 150 + `[1:], 151 + }, 152 + 153 + { 154 + name: "multiline_field", 155 + input: ` 156 + before: _ 157 + foo: { 158 + bar: { 159 + "baz" 160 + } 161 + } 162 + after: _ // weird indent 163 + `[1:], 164 + position: protocol.Position{Line: 2, Character: 11}, // bar 165 + expected: ` 166 + before: _ 167 + foo: bar: { 168 + "baz" 169 + } 170 + after: _ // weird indent 171 + `[1:], 172 + }, 173 + 174 + { 175 + name: "nested_fields", 176 + input: ` 177 + a: [{x: { 178 + y: z: _ 179 + } 180 + w: "foo"}.w]: {} 181 + `[1:], 182 + position: protocol.Position{Line: 1, Character: 1}, // y 183 + expected: ` 184 + a: [{x: y: z: _ 185 + w: "foo"}.w]: {} 186 + `[1:], 187 + }, 188 + 189 + { 190 + name: "comma_separated", 191 + input: ` 192 + a: { 193 + b: _ 194 + }, x: y: _ 195 + `[1:], 196 + position: protocol.Position{Line: 1, Character: 3}, // b 197 + expected: ` 198 + a: b: _ 199 + x: y: _ 200 + `[1:], 201 + }, 202 + 203 + { 204 + name: "multiline_labels", 205 + input: ` 206 + a: 207 + { 208 + b: _ 209 + } 210 + `[1:], 211 + position: protocol.Position{Line: 2, Character: 2}, // b 212 + expected: ` 213 + a: 214 + b: _ 215 + `[1:], 216 + }, 217 + } 218 + 219 + for _, tc := range testCases { 220 + t.Run(tc.name, func(t *testing.T) { 221 + WithOptions(RootURIAsDefaultFolder()).Run(t, "-- input.cue --\n"+tc.input, func(t *testing.T, env *Env) { 222 + env.OpenFile("input.cue") 223 + env.Await(env.DoneWithOpen()) 224 + rootURI := env.Sandbox.Workdir.RootURI() 225 + 226 + cursor := protocol.Location{ 227 + URI: rootURI + "/input.cue", 228 + Range: protocol.Range{ 229 + Start: tc.position, 230 + }, 231 + } 232 + 233 + actions, err := env.Editor.CodeAction(env.Ctx, cursor, nil) 234 + if err != nil { 235 + qt.Assert(t, qt.IsNil(err)) 236 + } 237 + 238 + var action protocol.CodeAction 239 + found := slices.ContainsFunc(actions, func(a protocol.CodeAction) bool { 240 + if a.Title == "Remove surrounding struct braces" { 241 + action = a 242 + return true 243 + } 244 + return false 245 + }) 246 + if !found { 247 + t.Fatal("Failed to find ConvertFromStruct code action") 248 + } 249 + 250 + env.ApplyCodeAction(action) 251 + after := env.BufferText("input.cue") 252 + qt.Check(t, qt.Equals(after, tc.expected)) 253 + }) 254 + }) 255 + } 256 + }
+2 -1
internal/golangorgx/gopls/protocol/codeactionkind.go
··· 6 6 7 7 // Custom code actions that aren't explicitly stated in LSP 8 8 const ( 9 - RefactorRewriteConvertToStruct CodeActionKind = "refactor.rewrite.convertToStruct" 9 + RefactorRewriteConvertToStruct CodeActionKind = "refactor.rewrite.convertToStruct" 10 + RefactorRewriteConvertFromStruct CodeActionKind = "refactor.rewrite.convertFromStruct" 10 11 )
+2 -1
internal/golangorgx/gopls/settings/default.go
··· 41 41 ServerOptions: ServerOptions{ 42 42 SupportedCodeActions: map[file.Kind]map[protocol.CodeActionKind]bool{ 43 43 file.CUE: { 44 - protocol.RefactorRewriteConvertToStruct: true, 44 + protocol.RefactorRewriteConvertToStruct: true, 45 + protocol.RefactorRewriteConvertFromStruct: true, 45 46 }, 46 47 }, 47 48 SupportedCommands: commands,
+159 -69
internal/lsp/cache/codeaction.go
··· 34 34 35 35 offset, _, err := f.mapper.RangeOffsets(params.Range) 36 36 if err != nil { 37 - return nil, err 37 + w.debugLog(err.Error()) 38 + return nil, nil 38 39 } 39 40 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 { 41 + structLit, field := innermostStructFieldForOffset(f.syntax, offset) 42 + if structLit == nil || field == nil { 43 + return nil, nil 44 + } 45 + if len(structLit.Elts) != 1 && (structLit.Lbrace.HasAbsPos() || structLit.Rbrace.HasAbsPos()) { 90 46 return nil, nil 91 47 } 92 48 93 49 content := f.tokFile.Content() 94 50 lineStartOffsets := f.tokFile.Lines() // Lines is 0-based 95 - numLines := len(lineStartOffsets) 96 51 labelStart := field.Pos() 97 52 lineNo := labelStart.Line() - 1 // Line is 1-based, hence the -1 98 53 if lineNo < 0 || lineNo >= len(lineStartOffsets) { 99 54 return nil, nil 100 55 } 101 56 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 - } 57 + lineEnding := extractLineEnding(content, lineStartOffsets) 58 + indent := extractIndent(content, lineStartOffsets[lineNo]) 118 59 119 60 var contentBuilder strings.Builder 120 61 contentBuilder.Write(content[:labelStart.Offset()]) ··· 132 73 // The last field line may or may not have a trailing line 133 74 // ending (because it might be the last line in the file). 134 75 fieldLine = bytes.TrimRight(fieldLine, "\r\n") 135 - fieldLine = bytes.TrimRight(fieldLine, ", \t") 136 76 } 137 77 contentBuilder.Write(fieldLine) 138 78 if isLastLine { ··· 163 103 docChanges := protocol.TextEditsToDocumentChanges(params.TextDocument.URI, f.tokFile.Revision(), edits) 164 104 return &protocol.WorkspaceEdit{DocumentChanges: docChanges}, nil 165 105 } 106 + 107 + func (w *Workspace) CodeActionConvertFromStruct(ctx context.Context, params *protocol.CodeActionParams) (*protocol.WorkspaceEdit, error) { 108 + f := w.GetFile(params.TextDocument.URI) 109 + if f == nil || f.syntax == nil || f.mapper == nil || f.tokFile == nil { 110 + return nil, nil 111 + } 112 + 113 + offset, _, err := f.mapper.RangeOffsets(params.Range) 114 + if err != nil { 115 + w.debugLog(err.Error()) 116 + return nil, nil 117 + } 118 + 119 + structLit, field := innermostStructFieldForOffset(f.syntax, offset) 120 + if structLit == nil || field == nil { 121 + return nil, nil 122 + } 123 + if len(structLit.Elts) != 1 || !structLit.Lbrace.HasAbsPos() || !structLit.Rbrace.HasAbsPos() { 124 + return nil, nil 125 + } 126 + 127 + content := f.tokFile.Content() 128 + lineStartOffsets := f.tokFile.Lines() // Lines is 0-based 129 + lineNo := structLit.Pos().Line() - 1 // Line is 1-based, hence the -1 130 + if lineNo < 0 || lineNo >= len(lineStartOffsets) { 131 + return nil, nil 132 + } 133 + 134 + lineEnding := extractLineEnding(content, lineStartOffsets) 135 + indent := extractIndent(content, lineStartOffsets[lineNo]) 136 + 137 + var contentBuilder strings.Builder 138 + contentBuilder.Write(content[:structLit.Lbrace.Offset()]) 139 + 140 + fieldLines := slices.Collect(bytes.Lines(content[field.Pos().Offset():field.End().Offset()])) 141 + for i, fieldLine := range fieldLines { 142 + if i > 0 { 143 + contentBuilder.Write(indent) 144 + contentBuilder.WriteString("\t") 145 + } 146 + fieldLine = bytes.TrimPrefix(fieldLine, indent) 147 + fieldLine = bytes.TrimRight(fieldLine, "\r\n") 148 + contentBuilder.Write(fieldLine) 149 + if isLastLine := i+1 == len(fieldLines); !isLastLine { 150 + contentBuilder.WriteString(lineEnding) 151 + } 152 + } 153 + 154 + remaining := content[structLit.Rbrace.Offset()+1:] 155 + remaining = bytes.TrimLeft(remaining, ", \t") 156 + 157 + if bytes.HasPrefix(remaining, []byte(lineEnding)) { 158 + contentBuilder.Write(remaining) 159 + } else if len(remaining) > 0 { 160 + contentBuilder.WriteString(lineEnding) 161 + contentBuilder.Write(indent) 162 + contentBuilder.Write(remaining) 163 + } 164 + 165 + diffEdits := diff.Strings(string(content), contentBuilder.String()) 166 + edits, err := protocol.EditsFromDiffEdits(f.mapper, diffEdits) 167 + if err != nil { 168 + return nil, nil 169 + } 170 + 171 + docChanges := protocol.TextEditsToDocumentChanges(params.TextDocument.URI, f.tokFile.Revision(), edits) 172 + return &protocol.WorkspaceEdit{DocumentChanges: docChanges}, nil 173 + } 174 + 175 + // innermostStructFieldForOffset returns the innermost [ast.Field] 176 + // (and its enclosing [ast.StructLit]) where the field's label 177 + // contains the given offset. 178 + func innermostStructFieldForOffset(syntax ast.Node, offset int) (structLit *ast.StructLit, field *ast.Field) { 179 + ast.Walk(syntax, 180 + // Walk over the AST, traversing into whatever contains the cursor position. 181 + func(node ast.Node) bool { 182 + start := node.Pos() 183 + end := node.End() 184 + 185 + if !start.HasAbsPos() || !end.HasAbsPos() { 186 + return false 187 + } 188 + if !token.WithinInclusive(offset, start, end) { 189 + return false 190 + } 191 + return true 192 + }, 193 + 194 + // We want the inner-most matching field, so on the way back 195 + // out, capture the first suitable structlit+field. 196 + func(node ast.Node) { 197 + if structLit != nil { 198 + return 199 + } 200 + 201 + sl, ok := node.(*ast.StructLit) 202 + if !ok { 203 + return 204 + } 205 + 206 + for _, decl := range sl.Elts { 207 + f, ok := decl.(*ast.Field) 208 + if !ok { 209 + continue 210 + } 211 + 212 + lab := f.Label 213 + start := lab.Pos() 214 + end := lab.End() 215 + if !start.HasAbsPos() || !end.HasAbsPos() { 216 + continue 217 + } 218 + if !token.WithinInclusive(offset, start, end) { 219 + continue 220 + } 221 + 222 + structLit = sl 223 + field = f 224 + return 225 + } 226 + }) 227 + 228 + return structLit, field 229 + } 230 + 231 + // extractIndent returns the run of space and horizontal tab 232 + // characters in content that start at the lineStartOffset. 233 + func extractIndent(content []byte, lineStartOffset int) []byte { 234 + for i := lineStartOffset; i < len(content); i++ { 235 + c := content[i] 236 + if c != ' ' && c != '\t' { 237 + return content[lineStartOffset:i] 238 + } 239 + } 240 + return nil 241 + } 242 + 243 + // extractLineEnding detects the line ending used in content by 244 + // inspecting the end of the first line. If content only has one line 245 + // then \n is returned. 246 + func extractLineEnding(content []byte, lineStartOffsets []int) string { 247 + if len(lineStartOffsets) > 1 { 248 + // be careful: the very first line could be \n only 249 + offset := lineStartOffsets[1] - 2 250 + if offset > 0 && content[offset] == '\r' { 251 + return "\r\n" 252 + } 253 + } 254 + return "\n" 255 + }
+13 -1
internal/lsp/server/codeaction.go
··· 41 41 } 42 42 if convertToStructEdit != nil { 43 43 codeActions = append(codeActions, protocol.CodeAction{ 44 - Title: "Wrap field in struct", 44 + Title: "Add surrounding struct braces", 45 45 Kind: protocol.RefactorRewriteConvertToStruct, 46 46 Edit: convertToStructEdit, 47 + }) 48 + } 49 + 50 + convertFromStructEdit, err := s.workspace.CodeActionConvertFromStruct(ctx, params) 51 + if err != nil { 52 + return nil, err 53 + } 54 + if convertFromStructEdit != nil { 55 + codeActions = append(codeActions, protocol.CodeAction{ 56 + Title: "Remove surrounding struct braces", 57 + Kind: protocol.RefactorRewriteConvertFromStruct, 58 + Edit: convertFromStructEdit, 47 59 }) 48 60 } 49 61