summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-16 23:29:37 +0300
committerPaul Buetow <paul@buetow.org>2025-08-16 23:29:37 +0300
commit4974b40bd5126cb4215580c0d066057a973f50d1 (patch)
tree0c0febd66e4a59ae713d927474b46fdc4f0592b7
parent765eda955eb811d08d867ff4d3914fc6d60c22dd (diff)
feat(lsp): code action to rewrite selection with instruction detection
- Adds textDocument/codeAction handler that rewrites the selected range.\n- Instruction preference: strict ;text; marker first, then //, #, -- line comments, then single-line block comments (/* */ and <!-- -->). Earliest in the selection wins.\n- Removes the matched instruction from the selection before sending to LLM.\n- README: document code action workflow and instruction formats.
-rw-r--r--IDEAS.md6
-rw-r--r--README.md39
-rwxr-xr-xhexaibin8914340 -> 8930984 bytes
-rw-r--r--internal/lsp/handlers.go215
-rw-r--r--internal/lsp/handlers_test.go34
-rw-r--r--internal/lsp/types.go28
6 files changed, 288 insertions, 34 deletions
diff --git a/IDEAS.md b/IDEAS.md
index d7ef0d0..ba9b204 100644
--- a/IDEAS.md
+++ b/IDEAS.md
@@ -61,9 +61,5 @@ language-servers = [ "gopls", "golangci-lint-lsp", "hexai" ]
[language-server.hexai]
command = "hexai"
-`
+```
-## Prompting
-
-* Write a new function: `;Implement a function that adds two numbers;`
-* Replace a whole line: `some other text here ;Implement a function that adds two numbers;`
diff --git a/README.md b/README.md
index 40102e4..6cd0827 100644
--- a/README.md
+++ b/README.md
@@ -92,3 +92,42 @@ Ensure `OPENAI_API_KEY` is set in your environment.
### Environment
- Only `OPENAI_API_KEY` is read from the environment when `provider` is `openai`.
+
+## Inline triggers
+
+Hexai supports inline trigger tags you can type in your code to request an
+action from the LLM and then clean up the tag automatically.
+
+- `;text;`: Do what is written in `text`, then remove just the `;text;` marker.
+ - Strict form: no space after the first `;` and no space before the last `;`.
+ - An optional single space immediately after the closing `;` is also removed.
+ - Multiple markers per line are supported.
+ - Example: `// TODO ;rename this function to add;` removes only the marker.
+
+- `;;text;`: Do what is written in `text`, then remove the entire line.
+ - Strict form: no space after `;;` and no space before the closing `;`.
+ - Any line containing such a marker is deleted after processing.
+ - Example:
+ ```
+ some() ;;extract helper; // this entire line is removed
+ ```
+
+- Spaced variants such as `; text ;` or `;; spaced ;` are ignored.
+
+## Code actions
+
+Hexai provides a code action for working with the current selection in Helix:
+
+- Rewrite selection: Select code and invoke code actions. Hexai looks for the
+ first instruction inside the selection and rewrites the selection accordingly.
+
+Instruction sources (first one found wins):
+- Strict marker: `;text;` (no space after first `;`, none before last `;`).
+- Line comments: `// text`, `# text`, `-- text`.
+- Single-line block comments: `/* text */`, `<!-- text -->`.
+
+Notes:
+- Only the earliest instruction in the selection is used; Hexai removes that
+ marker/comment from the selection before sending it to the LLM.
+- The action returns only the transformed code and replaces exactly the
+ selected range.
diff --git a/hexai b/hexai
index ccf2c06..dda2339 100755
--- a/hexai
+++ b/hexai
Binary files differ
diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go
index 8edfbb6..a89f02b 100644
--- a/internal/lsp/handlers.go
+++ b/internal/lsp/handlers.go
@@ -13,11 +13,11 @@ import (
)
func (s *Server) handle(req Request) {
- switch req.Method {
- case "initialize":
- s.handleInitialize(req)
- case "initialized":
- s.handleInitialized()
+ switch req.Method {
+ case "initialize":
+ s.handleInitialize(req)
+ case "initialized":
+ s.handleInitialized()
case "shutdown":
s.handleShutdown(req)
case "exit":
@@ -28,13 +28,15 @@ func (s *Server) handle(req Request) {
s.handleDidChange(req)
case "textDocument/didClose":
s.handleDidClose(req)
- case "textDocument/completion":
- s.handleCompletion(req)
- default:
- if len(req.ID) != 0 {
- s.reply(req.ID, nil, &RespError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)})
- }
- }
+ case "textDocument/completion":
+ s.handleCompletion(req)
+ case "textDocument/codeAction":
+ s.handleCodeAction(req)
+ default:
+ if len(req.ID) != 0 {
+ s.reply(req.ID, nil, &RespError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)})
+ }
+ }
}
func (s *Server) handleInitialize(req Request) {
@@ -42,18 +44,183 @@ func (s *Server) handleInitialize(req Request) {
if s.llmClient != nil {
version = version + " [" + s.llmClient.Name() + ":" + s.llmClient.DefaultModel() + "]"
}
- res := InitializeResult{
- Capabilities: ServerCapabilities{
- TextDocumentSync: 1, // 1 = TextDocumentSyncKindFull
- CompletionProvider: &CompletionOptions{
- ResolveProvider: false,
- // TODO: Make the trigger characters configurable
- TriggerCharacters: []string{".", ":", "/", "_"},
- },
- },
- ServerInfo: &ServerInfo{Name: "hexai", Version: version},
- }
- s.reply(req.ID, res, nil)
+ res := InitializeResult{
+ Capabilities: ServerCapabilities{
+ TextDocumentSync: 1, // 1 = TextDocumentSyncKindFull
+ CompletionProvider: &CompletionOptions{
+ ResolveProvider: false,
+ // TODO: Make the trigger characters configurable
+ TriggerCharacters: []string{".", ":", "/", "_"},
+ },
+ CodeActionProvider: true,
+ },
+ ServerInfo: &ServerInfo{Name: "hexai", Version: version},
+ }
+ s.reply(req.ID, res, nil)
+}
+
+func (s *Server) handleCodeAction(req Request) {
+ var p CodeActionParams
+ if err := json.Unmarshal(req.Params, &p); err != nil {
+ if len(req.ID) != 0 { s.reply(req.ID, []CodeAction{}, nil) }
+ return
+ }
+ if s.llmClient == nil {
+ if len(req.ID) != 0 { s.reply(req.ID, []CodeAction{}, nil) }
+ return
+ }
+ // Extract selected text
+ d := s.getDocument(p.TextDocument.URI)
+ if d == nil || len(d.lines) == 0 {
+ if len(req.ID) != 0 { s.reply(req.ID, []CodeAction{}, nil) }
+ return
+ }
+ sel := extractRangeText(d, p.Range)
+ if strings.TrimSpace(sel) == "" {
+ if len(req.ID) != 0 { s.reply(req.ID, []CodeAction{}, nil) }
+ return
+ }
+ // Derive instruction from selection comments (prefer first), including ;text; marker
+ instr, cleaned := instructionFromSelection(sel)
+ if strings.TrimSpace(instr) == "" {
+ // No instruction; do not offer an action
+ if len(req.ID) != 0 { s.reply(req.ID, []CodeAction{}, nil) }
+ return
+ }
+ // Build prompt for rewrite of cleaned selection according to instruction
+ sys := "You are a precise code refactoring engine. Rewrite the given code strictly according to the instruction. Return only the updated code with no prose or backticks. Preserve formatting where reasonable."
+ user := fmt.Sprintf("Instruction: %s\n\nSelected code to transform:\n%s", instr, cleaned)
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
+ text, err := s.llmClient.Chat(ctx, messages, llm.WithMaxTokens(s.maxTokens), llm.WithTemperature(0.1))
+ if err != nil {
+ logging.Logf("lsp ", "codeAction llm error: %v", err)
+ if len(req.ID) != 0 { s.reply(req.ID, []CodeAction{}, nil) }
+ return
+ }
+ out := strings.TrimSpace(text)
+ if out == "" {
+ if len(req.ID) != 0 { s.reply(req.ID, []CodeAction{}, nil) }
+ return
+ }
+ edit := WorkspaceEdit{Changes: map[string][]TextEdit{p.TextDocument.URI: {{Range: p.Range, NewText: out}}}}
+ action := CodeAction{Title: "Hexai: rewrite selection", Kind: "refactor.rewrite", Edit: &edit}
+ if len(req.ID) != 0 { s.reply(req.ID, []CodeAction{action}, nil) }
+}
+
+// instructionFromSelection extracts the first instruction from selection text.
+// Preference order on each line: strict ;text; marker (no inner spaces), then
+// a line comment (//, #, --). Returns the instruction string and the selection
+// text cleaned of the matched instruction marker or comment.
+func instructionFromSelection(sel string) (string, string) {
+ lines := splitLines(sel)
+ for idx, line := range lines {
+ if instr, cleaned, ok := findFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" {
+ lines[idx] = cleaned
+ return instr, strings.Join(lines, "\n")
+ }
+ }
+ return "", sel
+}
+
+// findFirstInstructionInLine returns the earliest instruction marker on the
+// line and the line with that marker removed. Supported markers, ordered by
+// earliest byte offset in the line:
+// - ;text; (strict, no space after first ';' or before last ';')
+// - /* text */ (single-line only)
+// - <!-- text --> (single-line only)
+// - // text
+// - # text
+// - -- text
+func findFirstInstructionInLine(line string) (instr string, cleaned string, ok bool) {
+ type cand struct{ start, end int; text string }
+ cands := []cand{}
+ if t, l, r, ok := findStrictSemicolonTag(line); ok {
+ cands = append(cands, cand{start: l, end: r, text: t})
+ }
+ if i := strings.Index(line, "/*"); i >= 0 {
+ if j := strings.Index(line[i+2:], "*/"); j >= 0 {
+ start := i
+ end := i + 2 + j + 2
+ text := strings.TrimSpace(line[i+2 : i+2+j])
+ cands = append(cands, cand{start: start, end: end, text: text})
+ }
+ }
+ if i := strings.Index(line, "<!--"); i >= 0 {
+ if j := strings.Index(line[i+4:], "-->"); j >= 0 {
+ start := i
+ end := i + 4 + j + 3
+ text := strings.TrimSpace(line[i+4 : i+4+j])
+ cands = append(cands, cand{start: start, end: end, text: text})
+ }
+ }
+ if i := strings.Index(line, "//"); i >= 0 { cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) }
+ if i := strings.Index(line, "#"); i >= 0 { cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+1:])}) }
+ if i := strings.Index(line, "--"); i >= 0 { cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) }
+ if len(cands) == 0 { return "", line, false }
+ // pick earliest start index
+ best := cands[0]
+ for _, c := range cands[1:] {
+ if c.start >= 0 && (best.start < 0 || c.start < best.start) {
+ best = c
+ }
+ }
+ cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t")
+ return best.text, cleaned, true
+}
+
+// findStrictSemicolonTag finds ;text; with no space after first ';' and no space
+// before the last ';' on the given line. Returns the text between semicolons,
+// the start index of the opening ';', the end index just after the closing ';',
+// and whether it was found.
+func findStrictSemicolonTag(line string) (string, int, int, bool) {
+ pos := 0
+ for pos < len(line) {
+ j := strings.Index(line[pos:], ";")
+ if j < 0 { return "", 0, 0, false }
+ j += pos
+ // ensure single ';' (not ';;') and non-space after
+ if j+1 >= len(line) || line[j+1] == ';' || line[j+1] == ' ' { pos = j + 1; continue }
+ k := strings.Index(line[j+1:], ";")
+ if k < 0 { return "", 0, 0, false }
+ closeIdx := j + 1 + k
+ if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { pos = closeIdx + 1; continue }
+ inner := strings.TrimSpace(line[j+1 : closeIdx])
+ if inner == "" { pos = closeIdx + 1; continue }
+ end := closeIdx + 1
+ return inner, j, end, true
+ }
+ return "", 0, 0, false
+}
+
+// extractRangeText returns the exact text within the given document range.
+func extractRangeText(d *document, r Range) string {
+ if r.Start.Line == r.End.Line {
+ line := d.lines[r.Start.Line]
+ if r.Start.Character < 0 { r.Start.Character = 0 }
+ if r.End.Character > len(line) { r.End.Character = len(line) }
+ if r.Start.Character > r.End.Character { return "" }
+ return line[r.Start.Character:r.End.Character]
+ }
+ var b strings.Builder
+ // first line
+ first := d.lines[r.Start.Line]
+ if r.Start.Character < 0 { r.Start.Character = 0 }
+ if r.Start.Character > len(first) { r.Start.Character = len(first) }
+ b.WriteString(first[r.Start.Character:])
+ b.WriteString("\n")
+ // middle lines
+ for i := r.Start.Line + 1; i < r.End.Line; i++ {
+ b.WriteString(d.lines[i])
+ if i+1 <= r.End.Line { b.WriteString("\n") }
+ }
+ // last line
+ last := d.lines[r.End.Line]
+ if r.End.Character < 0 { r.End.Character = 0 }
+ if r.End.Character > len(last) { r.End.Character = len(last) }
+ b.WriteString(last[:r.End.Character])
+ return b.String()
}
func (s *Server) handleInitialized() {
diff --git a/internal/lsp/handlers_test.go b/internal/lsp/handlers_test.go
index 3ebddfb..0b12611 100644
--- a/internal/lsp/handlers_test.go
+++ b/internal/lsp/handlers_test.go
@@ -204,3 +204,37 @@ func TestCollectPromptRemovalEdits_SkipSpacedDouble(t *testing.T) {
t.Fatalf("expected 0 edits for spaced double-semicolon trigger, got %d", len(edits))
}
}
+
+func TestInstructionFromSelection_OrderPreference(t *testing.T) {
+ // Earliest wins within a line
+ line := "code /*block first*/ // later ;tag;"
+ instr, cleaned := instructionFromSelection(line)
+ if instr != "block first" {
+ t.Fatalf("want block comment instr, got %q", instr)
+ }
+ if strings.Contains(cleaned, "block first") {
+ t.Fatalf("cleaned should not contain the block comment")
+ }
+}
+
+func TestInstructionFromSelection_SemicolonBeatsCommentIfEarlier(t *testing.T) {
+ line := ";do this;// later"
+ instr, cleaned := instructionFromSelection(line)
+ if instr != "do this" {
+ t.Fatalf("want semicolon instr, got %q", instr)
+ }
+ if strings.Contains(cleaned, ";do this;") {
+ t.Fatalf("cleaned should have semicolon tag removed")
+ }
+}
+
+func TestInstructionFromSelection_HTMLAndLineComments(t *testing.T) {
+ line := "prefix <!-- html note --> suffix"
+ instr, cleaned := instructionFromSelection(line)
+ if instr != "html note" {
+ t.Fatalf("want html note, got %q", instr)
+ }
+ if strings.Contains(cleaned, "<!--") || strings.Contains(cleaned, "-->") {
+ t.Fatalf("cleaned should remove html comment markers")
+ }
+}
diff --git a/internal/lsp/types.go b/internal/lsp/types.go
index 9338e4d..e41371d 100644
--- a/internal/lsp/types.go
+++ b/internal/lsp/types.go
@@ -34,8 +34,9 @@ type ServerInfo struct {
}
type ServerCapabilities struct {
- TextDocumentSync any `json:"textDocumentSync,omitempty"`
- CompletionProvider *CompletionOptions `json:"completionProvider,omitempty"`
+ TextDocumentSync any `json:"textDocumentSync,omitempty"`
+ CompletionProvider *CompletionOptions `json:"completionProvider,omitempty"`
+ CodeActionProvider bool `json:"codeActionProvider,omitempty"`
}
type CompletionOptions struct {
@@ -103,9 +104,26 @@ type Position struct {
}
type CompletionParams struct {
- TextDocument TextDocumentIdentifier `json:"textDocument"`
- Position Position `json:"position"`
- Context any `json:"context,omitempty"`
+ TextDocument TextDocumentIdentifier `json:"textDocument"`
+ Position Position `json:"position"`
+ Context any `json:"context,omitempty"`
+}
+
+// Code actions
+type CodeActionParams struct {
+ TextDocument TextDocumentIdentifier `json:"textDocument"`
+ Range Range `json:"range"`
+ Context any `json:"context,omitempty"`
+}
+
+type WorkspaceEdit struct {
+ Changes map[string][]TextEdit `json:"changes,omitempty"`
+}
+
+type CodeAction struct {
+ Title string `json:"title"`
+ Kind string `json:"kind,omitempty"`
+ Edit *WorkspaceEdit `json:"edit,omitempty"`
}
// Range defines a text range in a document.