Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion doc/getting-started.inc.html
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ <h1 id="getting-started-editor-support">Editor Support <a class="section-link" h

<p>
A language server is bundled with the CLI, providing builtin hover information, completion on <code>@</code> variables
and <code>$</code> environment variables, and scope-aware variable renaming.
and <code>$</code> 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.
</p>
6 changes: 6 additions & 0 deletions doc/mshell.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
195 changes: 193 additions & 2 deletions mshell/lsp.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strconv"
"strings"
"sync"
"unicode/utf16"
"unicode/utf8"

"go.lsp.dev/protocol"
Expand Down Expand Up @@ -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{"@", "$"},
},
Expand Down Expand Up @@ -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, &params); 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")
Expand Down Expand Up @@ -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
Expand Down
Loading