From df2b26beb57c34419d41ea03c9d88b59382a8173 Mon Sep 17 00:00:00 2001 From: Mitchell Paulus Date: Thu, 13 Aug 2026 20:59:13 -0500 Subject: [PATCH] Add code action to quote literals in lists --- CHANGELOG.md | 3 + code/README.md | 1 + doc/getting-started.inc.html | 3 +- doc/mshell.md | 6 + mshell/lsp.go | 195 ++++++++++++++++++++++++++++++- mshell/lsp_test.go | 215 +++++++++++++++++++++++++++++++++++ 6 files changed, 420 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e63da88..9ae407fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- The language server now offers a `Quote all literals in list` code action that + single-quotes every bare literal in the innermost list containing the cursor. + - Functions - `longestCommonPrefix`: Longest leading substring shared by every string in a list. `([str] -- str)` - `whenJust`: Run a quotation on the inner value for its side effects when the Maybe is Just; diff --git a/code/README.md b/code/README.md index 951a9aaf..cc130c40 100644 --- a/code/README.md +++ b/code/README.md @@ -9,6 +9,7 @@ This is the official extension for the concatenative shell-like programming lang - Hover documentation for built-in functions - Variable name completion (triggered by `@`) - Variable rename support + - A code action to quote all bare literals in the list containing the cursor - Run the active mshell file from the editor title bar in a VS Code terminal - Run the active mshell file with F5 via a minimal debug adapter diff --git a/doc/getting-started.inc.html b/doc/getting-started.inc.html index 4df6946f..eba0482e 100644 --- a/doc/getting-started.inc.html +++ b/doc/getting-started.inc.html @@ -145,7 +145,8 @@

Editor Support A language server is bundled with the CLI, providing builtin hover information, completion on @ variables -and $ environment variables, and scope-aware variable renaming. +and $ environment variables, scope-aware variable renaming, +and a code action that single-quotes every bare literal in the parsed list value containing the cursor. Environment variable completion draws from the actual process environment as well as any environment variables already referenced in the current file.

diff --git a/doc/mshell.md b/doc/mshell.md index c8ecadbc..cd2b3ba7 100644 --- a/doc/mshell.md +++ b/doc/mshell.md @@ -430,6 +430,12 @@ msh completions nushell | save --force $"($nu.default-config-dir)/completions/ms use $"($nu.default-config-dir)/completions/msh.nu" * ``` +### Language Server + +The bundled language server provides a code action named `Quote all literals in list` when the cursor is inside a parsed list value containing bare literal tokens. +The action single-quotes every bare literal in the innermost containing list, including literals in nested child lists. +Existing strings, numbers, variables, paths, and operators are unchanged. + ### Binary map overrides mshell supports a simple bin map file that overrides PATH lookups. The file lives alongside the history files (e.g. `$XDG_DATA_HOME/msh/msh_bins.txt` or `~/.local/share/msh/msh_bins.txt` on Linux/macOS, or `%LOCALAPPDATA%\msh\msh_bins.txt` on Windows). diff --git a/mshell/lsp.go b/mshell/lsp.go index a7a34377..278258ab 100644 --- a/mshell/lsp.go +++ b/mshell/lsp.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" "sync" + "unicode/utf16" "unicode/utf8" "go.lsp.dev/protocol" @@ -294,8 +295,9 @@ func (s *lspServer) handleMessage(msg *jsonrpcMessage) (bool, error) { } result := protocol.InitializeResult{ Capabilities: protocol.ServerCapabilities{ - TextDocumentSync: protocol.TextDocumentSyncKindFull, - HoverProvider: true, + TextDocumentSync: protocol.TextDocumentSyncKindFull, + HoverProvider: true, + CodeActionProvider: true, CompletionProvider: &protocol.CompletionOptions{ TriggerCharacters: []string{"@", "$"}, }, @@ -385,6 +387,17 @@ func (s *lspServer) handleMessage(msg *jsonrpcMessage) (bool, error) { return false, s.sendResult(msg.ID, []protocol.CompletionItem{}) } return false, s.sendResult(msg.ID, items) + case "textDocument/codeAction": + if msg.ID == nil { + logLSP("codeAction request missing id") + return false, nil + } + var params protocol.CodeActionParams + if err := json.Unmarshal(msg.Params, ¶ms); err != nil { + _ = s.sendErrorResponse(msg.ID, jsonrpcCodeInvalidParams, fmt.Sprintf("invalid codeAction params: %v", err)) + return false, nil + } + return false, s.sendResult(msg.ID, s.codeActions(params)) case "textDocument/prepareRename": if msg.ID == nil { logLSP("prepareRename request missing id") @@ -431,6 +444,184 @@ func (s *lspServer) handleMessage(msg *jsonrpcMessage) (bool, error) { } } +func (s *lspServer) codeActions(params protocol.CodeActionParams) []protocol.CodeAction { + doc, ok := s.documents[params.TextDocument.URI] + if !ok || !codeActionKindRequested(params.Context.Only, protocol.RefactorRewrite) { + return []protocol.CodeAction{} + } + + cursor, ok := lspPositionToRuneOffset(doc.Text, params.Range.Start) + if !ok { + return []protocol.CodeAction{} + } + + parser := NewMShellParser(NewLexer(doc.Text, nil)) + file, err := parser.ParseFile() + if err != nil { + return []protocol.CodeAction{} + } + lists := collectRuntimeLists(file) + + var selected *MShellParseList + for _, list := range lists { + start := list.StartToken.Start + end := list.EndToken.Start + utf8.RuneCountInString(list.EndToken.Lexeme) + if cursor < start || cursor >= end { + continue + } + if selected == nil || start > selected.StartToken.Start { + selected = list + } + } + if selected == nil { + return []protocol.CodeAction{} + } + + literals := collectListLiterals(selected) + if len(literals) == 0 { + return []protocol.CodeAction{} + } + edits := make([]protocol.TextEdit, 0, len(literals)) + for _, tok := range literals { + edits = append(edits, protocol.TextEdit{ + Range: tokenLSPRange(doc.Text, tok), + NewText: "'" + tok.Lexeme + "'", + }) + } + + return []protocol.CodeAction{{ + Title: "Quote all literals in list", + Kind: protocol.RefactorRewrite, + Edit: &protocol.WorkspaceEdit{ + Changes: map[protocol.DocumentURI][]protocol.TextEdit{ + params.TextDocument.URI: edits, + }, + }, + }} +} + +func collectRuntimeLists(file *MShellFile) []*MShellParseList { + lists := make([]*MShellParseList, 0) + collectRuntimeListsFromItems(&lists, file.Items) + for i := range file.Definitions { + collectRuntimeListsFromItems(&lists, file.Definitions[i].Items) + } + return lists +} + +func collectRuntimeListsFromItems(dst *[]*MShellParseList, items []MShellParseItem) { + for _, item := range items { + switch v := item.(type) { + case *MShellParseList: + *dst = append(*dst, v) + collectRuntimeListsFromItems(dst, v.Items) + case *MShellParseDict: + for _, kv := range v.Items { + collectRuntimeListsFromItems(dst, kv.Value) + } + case *MShellParseQuote: + collectRuntimeListsFromItems(dst, v.Items) + case *MShellParsePrefixQuote: + collectRuntimeListsFromItems(dst, v.Items) + case *MShellParseIfBlock: + collectRuntimeListsFromItems(dst, v.IfBody) + for _, elseIf := range v.ElseIfs { + collectRuntimeListsFromItems(dst, elseIf.Condition) + collectRuntimeListsFromItems(dst, elseIf.Body) + } + collectRuntimeListsFromItems(dst, v.ElseBody) + case *MShellParseMatchBlock: + for _, arm := range v.Arms { + collectRuntimeListsFromItems(dst, arm.Body) + } + case *MShellParseGrid: + for _, row := range v.Rows { + collectRuntimeListsFromItems(dst, row) + } + case *MShellIndexerList: + collectRuntimeListsFromItems(dst, v.Indexers) + } + } +} + +func collectListLiterals(list *MShellParseList) []Token { + literals := make([]Token, 0) + var collect func(*MShellParseList) + collect = func(current *MShellParseList) { + for _, item := range current.Items { + switch v := item.(type) { + case Token: + if v.Type == LITERAL { + literals = append(literals, v) + } + case *MShellParseList: + collect(v) + } + } + } + collect(list) + return literals +} + +func codeActionKindRequested(only []protocol.CodeActionKind, action protocol.CodeActionKind) bool { + if len(only) == 0 { + return true + } + for _, requested := range only { + if action == requested || strings.HasPrefix(string(action), string(requested)+".") { + return true + } + } + return false +} + +func lspPositionToRuneOffset(text string, position protocol.Position) (int, bool) { + line := uint32(0) + character := uint32(0) + runes := []rune(text) + for offset, r := range runes { + if line == position.Line && character == position.Character { + return offset, true + } + if r == '\n' { + if line == position.Line { + return 0, false + } + line++ + character = 0 + continue + } + character += uint32(utf16.RuneLen(r)) + } + if line == position.Line && character == position.Character { + return len(runes), true + } + return 0, false +} + +func tokenLSPRange(text string, tok Token) protocol.Range { + start := runeOffsetToLSPPosition(text, tok.Start) + end := runeOffsetToLSPPosition(text, tok.Start+utf8.RuneCountInString(tok.Lexeme)) + return protocol.Range{Start: start, End: end} +} + +func runeOffsetToLSPPosition(text string, target int) protocol.Position { + line := uint32(0) + character := uint32(0) + for offset, r := range []rune(text) { + if offset >= target { + break + } + if r == '\n' { + line++ + character = 0 + continue + } + character += uint32(utf16.RuneLen(r)) + } + return protocol.Position{Line: line, Character: character} +} + func (s *lspServer) sendResult(id *json.RawMessage, result any) error { if id == nil { return nil diff --git a/mshell/lsp_test.go b/mshell/lsp_test.go index 689d5a33..e21e1d03 100644 --- a/mshell/lsp_test.go +++ b/mshell/lsp_test.go @@ -1440,6 +1440,221 @@ func TestHoverForInFileUserDef(t *testing.T) { } } +func TestCodeActionQuotesAllLiteralsInInnermostList(t *testing.T) { + uri := protocol.DocumentURI("file:///quote-list.msh") + doc := "[outer arg [echo café😀 \"already quoted\" @value 3] tail]" + server := &lspServer{ + documents: map[protocol.DocumentURI]*lspDocument{ + uri: {Text: doc}, + }, + } + + actions := server.codeActions(protocol.CodeActionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri}, + Range: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 24}, + End: protocol.Position{Line: 0, Character: 24}, + }, + }) + + if len(actions) != 1 { + t.Fatalf("expected one code action, got %d", len(actions)) + } + if actions[0].Title != "Quote all literals in list" { + t.Fatalf("unexpected code action title: %q", actions[0].Title) + } + if actions[0].Kind != protocol.RefactorRewrite { + t.Fatalf("unexpected code action kind: %q", actions[0].Kind) + } + if actions[0].Edit == nil { + t.Fatal("expected code action to contain an edit") + } + + edits := actions[0].Edit.Changes[uri] + if len(edits) != 2 { + t.Fatalf("expected two edits for the inner list, got %+v", edits) + } + if edits[0].NewText != "'echo'" || edits[1].NewText != "'café😀'" { + t.Fatalf("unexpected edits: %+v", edits) + } + if edits[1].Range.Start.Character != 17 || edits[1].Range.End.Character != 23 { + t.Fatalf("unexpected UTF-16 range for café😀: %+v", edits[1].Range) + } +} + +func TestCodeActionQuotesNestedLiteralsFromOuterList(t *testing.T) { + uri := protocol.DocumentURI("file:///quote-outer-list.msh") + doc := "[outer [inner value] tail]" + server := &lspServer{ + documents: map[protocol.DocumentURI]*lspDocument{ + uri: {Text: doc}, + }, + } + + actions := server.codeActions(protocol.CodeActionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri}, + Range: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 6}, + End: protocol.Position{Line: 0, Character: 6}, + }, + }) + + if len(actions) != 1 || actions[0].Edit == nil { + t.Fatalf("expected one code action with edits, got %+v", actions) + } + edits := actions[0].Edit.Changes[uri] + want := []string{"'outer'", "'inner'", "'value'", "'tail'"} + if len(edits) != len(want) { + t.Fatalf("expected %d edits, got %+v", len(want), edits) + } + for i := range want { + if edits[i].NewText != want[i] { + t.Fatalf("edit %d = %q, want %q", i, edits[i].NewText, want[i]) + } + } +} + +func TestCodeActionUnavailableOutsideListOrForQuickFix(t *testing.T) { + uri := protocol.DocumentURI("file:///no-quote-list.msh") + doc := "word [echo arg]" + server := &lspServer{ + documents: map[protocol.DocumentURI]*lspDocument{ + uri: {Text: doc}, + }, + } + + outside := server.codeActions(protocol.CodeActionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri}, + Range: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 2}, + End: protocol.Position{Line: 0, Character: 2}, + }, + }) + if len(outside) != 0 { + t.Fatalf("expected no action outside a list, got %+v", outside) + } + + quickFixOnly := server.codeActions(protocol.CodeActionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri}, + Range: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 8}, + End: protocol.Position{Line: 0, Character: 8}, + }, + Context: protocol.CodeActionContext{Only: []protocol.CodeActionKind{protocol.QuickFix}}, + }) + if len(quickFixOnly) != 0 { + t.Fatalf("expected no refactor for a quick-fix-only request, got %+v", quickFixOnly) + } +} + +func TestCodeActionOnlyTargetsParsedListValues(t *testing.T) { + uri := protocol.DocumentURI("file:///parsed-lists.msh") + doc := strings.Join([]string{ + "type CustomType = str", + "type CustomTypes = [CustomType]", + "def f ([CustomType] -- [CustomType])", + " [] match", + " [head ...tail] : [echo arg],", + " _ : [],", + " end", + "end", + }, "\n") + server := &lspServer{ + documents: map[protocol.DocumentURI]*lspDocument{ + uri: {Text: doc}, + }, + } + + positionOf := func(needle string) protocol.Position { + offset := strings.Index(doc, needle) + if offset < 0 { + t.Fatalf("expected to find %q in document", needle) + } + return runeOffsetToLSPPosition(doc, len([]rune(doc[:offset]))) + } + actionsAt := func(needle string) []protocol.CodeAction { + position := positionOf(needle) + return server.codeActions(protocol.CodeActionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri}, + Range: protocol.Range{ + Start: position, + End: position, + }, + }) + } + + if actions := actionsAt("[CustomType]"); len(actions) != 0 { + t.Fatalf("expected no action in a type declaration, got %+v", actions) + } + if actions := actionsAt("[CustomType] --"); len(actions) != 0 { + t.Fatalf("expected no action in a definition signature, got %+v", actions) + } + if actions := actionsAt("[head ...tail]"); len(actions) != 0 { + t.Fatalf("expected no action in a list pattern, got %+v", actions) + } + + actions := actionsAt("[echo arg]") + if len(actions) != 1 || actions[0].Edit == nil { + t.Fatalf("expected an action in the match-arm list value, got %+v", actions) + } + edits := actions[0].Edit.Changes[uri] + if len(edits) != 2 || edits[0].NewText != "'echo'" || edits[1].NewText != "'arg'" { + t.Fatalf("unexpected list-value edits: %+v", edits) + } +} + +func TestCodeActionDoesNotCrossIntoOtherParseConstructs(t *testing.T) { + uri := protocol.DocumentURI("file:///list-children.msh") + doc := "[(danger) { 'key': risky } [inner arg] outer]" + server := &lspServer{ + documents: map[protocol.DocumentURI]*lspDocument{ + uri: {Text: doc}, + }, + } + + actions := server.codeActions(protocol.CodeActionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri}, + Range: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 1}, + End: protocol.Position{Line: 0, Character: 1}, + }, + }) + if len(actions) != 1 || actions[0].Edit == nil { + t.Fatalf("expected one code action with edits, got %+v", actions) + } + edits := actions[0].Edit.Changes[uri] + want := []string{"'inner'", "'arg'", "'outer'"} + if len(edits) != len(want) { + t.Fatalf("expected %d edits, got %+v", len(want), edits) + } + for i := range want { + if edits[i].NewText != want[i] { + t.Fatalf("edit %d = %q, want %q", i, edits[i].NewText, want[i]) + } + } +} + +func TestCodeActionUnavailableForUnclosedList(t *testing.T) { + uri := protocol.DocumentURI("file:///unclosed-list.msh") + doc := "[echo arg" + server := &lspServer{ + documents: map[protocol.DocumentURI]*lspDocument{ + uri: {Text: doc}, + }, + } + + actions := server.codeActions(protocol.CodeActionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri}, + Range: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 5}, + End: protocol.Position{Line: 0, Character: 5}, + }, + }) + if len(actions) != 0 { + t.Fatalf("expected no action without a parsed list node, got %+v", actions) + } +} + func sendLSPMessage(t *testing.T, w io.Writer, payload any) { t.Helper() data, err := json.Marshal(payload)