diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e63da8..9ae407f 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 951a9aa..cc130c4 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 4df6946..eba0482 100644 --- a/doc/getting-started.inc.html +++ b/doc/getting-started.inc.html @@ -145,7 +145,8 @@
@ 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 c8ecadb..cd2b3ba 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 a7a3437..278258a 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 689d5a3..e21e1d0 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)