diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-16 23:29:37 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-16 23:29:37 +0300 |
| commit | 4974b40bd5126cb4215580c0d066057a973f50d1 (patch) | |
| tree | 0c0febd66e4a59ae713d927474b46fdc4f0592b7 | |
| parent | 765eda955eb811d08d867ff4d3914fc6d60c22dd (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.md | 6 | ||||
| -rw-r--r-- | README.md | 39 | ||||
| -rwxr-xr-x | hexai | bin | 8914340 -> 8930984 bytes | |||
| -rw-r--r-- | internal/lsp/handlers.go | 215 | ||||
| -rw-r--r-- | internal/lsp/handlers_test.go | 34 | ||||
| -rw-r--r-- | internal/lsp/types.go | 28 |
6 files changed, 288 insertions, 34 deletions
@@ -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;` @@ -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. Binary files differdiff --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. |
