summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-06 13:19:01 +0300
committerPaul Buetow <paul@buetow.org>2025-09-06 13:19:01 +0300
commit04f290dbeeee8a6fcbc70fed253a968336bcb2ab (patch)
tree3ee23a4ac4bcc5b43b43697cfb0e905735fc6331
parent5e966f50111adf6e2cb2683fe588f6fe033fa931 (diff)
more tests
-rw-r--r--cmd/hexai-lsp/main_test.go23
-rw-r--r--cmd/hexai/main_test.go23
-rw-r--r--cmd/internal/hexai-action/main.go16
-rw-r--r--internal/hexaiaction/parse.go69
-rw-r--r--internal/hexaiaction/parse_test.go121
-rw-r--r--internal/hexaiaction/prompts.go91
-rw-r--r--internal/hexaiaction/run.go74
-rw-r--r--internal/hexaiaction/run_test.go51
-rw-r--r--internal/hexaiaction/tui.go118
-rw-r--r--internal/hexaiaction/tui_delegate.go35
-rw-r--r--internal/hexaiaction/tui_delegate_test.go32
-rw-r--r--internal/hexaiaction/tui_test.go36
-rw-r--r--internal/hexaiaction/types.go19
-rw-r--r--internal/hexaicli/run_more_test.go44
-rw-r--r--internal/hexailsp/run_more_test.go42
-rw-r--r--internal/llm/provider_more2_test.go13
-rw-r--r--internal/llmutils/client.go35
-rw-r--r--internal/llmutils/client_test.go28
-rw-r--r--internal/lsp/codeaction_gotest_int_test.go26
-rw-r--r--internal/lsp/coverage_add_test.go103
-rw-r--r--internal/textutil/textutil.go114
-rw-r--r--internal/textutil/textutil_test.go87
22 files changed, 1200 insertions, 0 deletions
diff --git a/cmd/hexai-lsp/main_test.go b/cmd/hexai-lsp/main_test.go
new file mode 100644
index 0000000..987bfb4
--- /dev/null
+++ b/cmd/hexai-lsp/main_test.go
@@ -0,0 +1,23 @@
+package main
+
+import (
+ "bytes"
+ "log"
+ "os"
+ "testing"
+)
+
+func TestMain_Version(t *testing.T) {
+ oldArgs := os.Args
+ defer func() { os.Args = oldArgs }()
+ os.Args = []string{"hexai-lsp", "-version"}
+ var buf bytes.Buffer
+ old := log.Writer()
+ log.SetOutput(&buf)
+ defer log.SetOutput(old)
+ main()
+ if buf.Len() == 0 {
+ t.Fatalf("expected version log")
+ }
+}
+
diff --git a/cmd/hexai/main_test.go b/cmd/hexai/main_test.go
new file mode 100644
index 0000000..beb684b
--- /dev/null
+++ b/cmd/hexai/main_test.go
@@ -0,0 +1,23 @@
+package main
+
+import (
+ "io"
+ "os"
+ "testing"
+)
+
+func TestMain_Version(t *testing.T) {
+ oldArgs := os.Args
+ defer func() { os.Args = oldArgs }()
+ os.Args = []string{"hexai", "-version"}
+ r, w, _ := os.Pipe()
+ old := os.Stdout
+ os.Stdout = w
+ defer func() { os.Stdout = old }()
+ main()
+ w.Close()
+ b, _ := io.ReadAll(r)
+ if len(b) == 0 {
+ t.Fatalf("expected version output")
+ }
+}
diff --git a/cmd/internal/hexai-action/main.go b/cmd/internal/hexai-action/main.go
new file mode 100644
index 0000000..50e6774
--- /dev/null
+++ b/cmd/internal/hexai-action/main.go
@@ -0,0 +1,16 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "codeberg.org/snonux/hexai/internal/hexaiaction"
+)
+
+func main() {
+ if err := hexaiaction.Run(context.Background(), os.Stdin, os.Stdout, os.Stderr); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
diff --git a/internal/hexaiaction/parse.go b/internal/hexaiaction/parse.go
new file mode 100644
index 0000000..99e2b24
--- /dev/null
+++ b/internal/hexaiaction/parse.go
@@ -0,0 +1,69 @@
+package hexaiaction
+
+import (
+ "bufio"
+ "io"
+ "strings"
+
+ "codeberg.org/snonux/hexai/internal/textutil"
+)
+
+// ParseInput splits raw stdin into optional diagnostics and selection/code.
+// Format:
+//
+// Diagnostics:\n
+// <one per line>\n
+// <blank line> (optional)\n
+// <rest is selection/code>
+//
+// If the header is absent, the entire input is treated as selection.
+func ParseInput(r io.Reader) (InputParts, error) {
+ b, err := io.ReadAll(bufio.NewReader(r))
+ if err != nil {
+ return InputParts{}, err
+ }
+ raw := strings.TrimSpace(string(b))
+ if raw == "" {
+ return InputParts{Selection: ""}, nil
+ }
+ lines := strings.Split(raw, "\n")
+ // find a case-insensitive line equal to "diagnostics:"
+ diagsIdx := -1
+ for i, ln := range lines {
+ t := strings.TrimSpace(strings.ToLower(ln))
+ if t == "diagnostics:" {
+ diagsIdx = i
+ break
+ }
+ }
+ if diagsIdx < 0 {
+ return InputParts{Selection: raw}, nil
+ }
+ // collect diagnostics until a blank line or EOF
+ diags := []string{}
+ i := diagsIdx + 1
+ for ; i < len(lines); i++ {
+ t := strings.TrimSpace(lines[i])
+ if t == "" {
+ i++
+ break
+ }
+ diags = append(diags, t)
+ }
+ sel := strings.Join(lines[i:], "\n")
+ sel = strings.TrimSpace(sel)
+ return InputParts{Selection: sel, Diagnostics: diags}, nil
+}
+
+// ExtractInstruction mirrors the LSP instructionFromSelection behavior (subset),
+// scanning the first line for an instruction marker and removing it from the selection.
+func ExtractInstruction(sel string) (string, string) { return textutil.InstructionFromSelection(sel) }
+
+// findFirstInstructionInLine follows the same precedence as LSP:
+// - ;text; (strict)
+// - /* text */ (single-line)
+// - <!-- text --> (single-line)
+// - // text
+// - # text
+// - -- text
+// helpers moved to textutil
diff --git a/internal/hexaiaction/parse_test.go b/internal/hexaiaction/parse_test.go
new file mode 100644
index 0000000..f81ab54
--- /dev/null
+++ b/internal/hexaiaction/parse_test.go
@@ -0,0 +1,121 @@
+package hexaiaction
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llm"
+)
+
+func TestParseInput_NoDiagnostics(t *testing.T) {
+ in := "some code here"
+ parts, err := ParseInput(strings.NewReader(in))
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if parts.Selection != in || len(parts.Diagnostics) != 0 {
+ t.Fatalf("unexpected parse: %#v", parts)
+ }
+}
+
+func TestParseInput_WithDiagnostics(t *testing.T) {
+ in := "Diagnostics:\nmissing return\nuse of undefined: foo\n\nfunc a() {}"
+ parts, err := ParseInput(strings.NewReader(in))
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if parts.Selection != "func a() {}" {
+ t.Fatalf("selection wrong: %q", parts.Selection)
+ }
+ if len(parts.Diagnostics) != 2 || parts.Diagnostics[0] != "missing return" {
+ t.Fatalf("diags wrong: %#v", parts.Diagnostics)
+ }
+}
+
+func TestExtractInstruction_Variants(t *testing.T) {
+ cases := []struct{ in, wantInstr string }{
+ {";rewrite to X;\ncode", "rewrite to X"},
+ {"/* fix it */\ncode", "fix it"},
+ {"<!-- doc me -->\ncode", "doc me"},
+ {"// change it\ncode", "change it"},
+ {"# tweak\ncode", "tweak"},
+ {"-- fix\ncode", "fix"},
+ }
+ for _, c := range cases {
+ got, cleaned := ExtractInstruction(c.in)
+ if got != c.wantInstr {
+ t.Fatalf("instr mismatch: %q != %q", got, c.wantInstr)
+ }
+ if strings.Contains(cleaned, c.wantInstr) && strings.Contains(c.in, c.wantInstr) {
+ t.Fatalf("expected instruction removed from selection: %q", cleaned)
+ }
+ }
+}
+
+func TestRenderAndStrip(t *testing.T) {
+ tpl := "Hello, {{name}}"
+ out := Render(tpl, map[string]string{"name": "Hex"})
+ if out != "Hello, Hex" {
+ t.Fatalf("unexpected render: %q", out)
+ }
+ fenced := "```go\npackage x\n```"
+ if StripFences(fenced) != "package x" {
+ t.Fatalf("unexpected strip")
+ }
+}
+
+type fakeClient struct {
+ last []llm.Message
+ out string
+ err error
+}
+
+func (f *fakeClient) Chat(_ context.Context, msgs []llm.Message, _ ...llm.RequestOption) (string, error) {
+ f.last = msgs
+ return f.out, f.err
+}
+
+func TestRuners_Prompts(t *testing.T) {
+ cfg := appconfig.App{
+ PromptCodeActionRewriteSystem: "SYS-R",
+ PromptCodeActionRewriteUser: "R {{instruction}} :: {{selection}}",
+ PromptCodeActionDiagnosticsSystem: "SYS-D",
+ PromptCodeActionDiagnosticsUser: "D {{diagnostics}} :: {{selection}}",
+ PromptCodeActionDocumentSystem: "SYS-C",
+ PromptCodeActionDocumentUser: "C {{selection}}",
+ PromptCodeActionGoTestSystem: "SYS-T",
+ PromptCodeActionGoTestUser: "T {{function}}",
+ }
+ f := &fakeClient{out: "```\nDONE\n```"}
+ ctx := context.Background()
+ // rewrite
+ if out, err := runRewrite(ctx, cfg, f, "instr", "sel"); err != nil || out != "DONE" {
+ t.Fatalf("rewrite failed: %q %v", out, err)
+ }
+ if len(f.last) != 2 || f.last[0].Content != "SYS-R" || !strings.Contains(f.last[1].Content, "instr") {
+ t.Fatalf("rewrite prompts wrong: %#v", f.last)
+ }
+ // diagnostics
+ if out, err := runDiagnostics(ctx, cfg, f, []string{"a", "b"}, "sel"); err != nil || out != "DONE" {
+ t.Fatalf("diagnostics failed: %q %v", out, err)
+ }
+ if f.last[0].Content != "SYS-D" || !strings.Contains(f.last[1].Content, "a\nb") {
+ t.Fatalf("diagnostics prompts wrong: %#v", f.last)
+ }
+ // document
+ if out, err := runDocument(ctx, cfg, f, "sel"); err != nil || out != "DONE" {
+ t.Fatalf("document failed: %q %v", out, err)
+ }
+ if f.last[0].Content != "SYS-C" || !strings.Contains(f.last[1].Content, "sel") {
+ t.Fatalf("document prompts wrong: %#v", f.last)
+ }
+ // gotest
+ if out, err := runGoTest(ctx, cfg, f, "func A(){}"); err != nil || out != "DONE" {
+ t.Fatalf("gotest failed: %q %v", out, err)
+ }
+ if f.last[0].Content != "SYS-T" || !strings.Contains(f.last[1].Content, "func A(){") {
+ t.Fatalf("gotest prompts wrong: %#v", f.last)
+ }
+}
diff --git a/internal/hexaiaction/prompts.go b/internal/hexaiaction/prompts.go
new file mode 100644
index 0000000..2e0e4e2
--- /dev/null
+++ b/internal/hexaiaction/prompts.go
@@ -0,0 +1,91 @@
+package hexaiaction
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llm"
+ "codeberg.org/snonux/hexai/internal/textutil"
+)
+
+// Render performs simple {{var}} replacement like LSP.
+func Render(t string, vars map[string]string) string { return textutil.RenderTemplate(t, vars) }
+
+// StripFences removes surrounding markdown code fences.
+func StripFences(s string) string { return textutil.StripCodeFences(s) }
+
+type chatDoer interface {
+ Chat(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error)
+}
+
+func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) {
+ sys := cfg.PromptCodeActionRewriteSystem
+ user := Render(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": instruction, "selection": selection})
+ return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))
+}
+
+func runDiagnostics(ctx context.Context, cfg appconfig.App, client chatDoer, diags []string, selection string) (string, error) {
+ var b strings.Builder
+ for i, d := range diags {
+ if strings.TrimSpace(d) == "" {
+ continue
+ }
+ b.WriteString(strings.TrimSpace(d))
+ if i < len(diags)-1 {
+ b.WriteString("\n")
+ }
+ }
+ sys := cfg.PromptCodeActionDiagnosticsSystem
+ user := Render(cfg.PromptCodeActionDiagnosticsUser, map[string]string{"diagnostics": b.String(), "selection": selection})
+ return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))
+}
+
+func runDocument(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) {
+ sys := cfg.PromptCodeActionDocumentSystem
+ user := Render(cfg.PromptCodeActionDocumentUser, map[string]string{"selection": selection})
+ return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))
+}
+
+func runGoTest(ctx context.Context, cfg appconfig.App, client chatDoer, funcCode string) (string, error) {
+ sys := cfg.PromptCodeActionGoTestSystem
+ user := Render(cfg.PromptCodeActionGoTestUser, map[string]string{"function": funcCode})
+ return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))
+}
+
+func runOnce(ctx context.Context, client chatDoer, sys, user string) (string, error) {
+ msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
+ txt, err := client.Chat(ctx, msgs)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(StripFences(txt)), nil
+}
+
+func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opts []llm.RequestOption) (string, error) {
+ msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
+ txt, err := client.Chat(ctx, msgs, opts...)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(StripFences(txt)), nil
+}
+
+// reqOptsFrom builds LLM request options similar to LSP behavior.
+func reqOptsFrom(cfg appconfig.App) []llm.RequestOption {
+ opts := []llm.RequestOption{llm.WithMaxTokens(cfg.MaxTokens)}
+ if cfg.CodingTemperature != nil {
+ opts = append(opts, llm.WithTemperature(*cfg.CodingTemperature))
+ }
+ return opts
+}
+
+// Timeout helpers to mirror LSP behavior.
+func timeout10s(parent context.Context) (context.Context, context.CancelFunc) {
+ return context.WithTimeout(parent, 10*time.Second)
+}
+
+func timeout8s(parent context.Context) (context.Context, context.CancelFunc) {
+ return context.WithTimeout(parent, 8*time.Second)
+}
diff --git a/internal/hexaiaction/run.go b/internal/hexaiaction/run.go
new file mode 100644
index 0000000..2a67a58
--- /dev/null
+++ b/internal/hexaiaction/run.go
@@ -0,0 +1,74 @@
+package hexaiaction
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log"
+ "strings"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/logging"
+ "codeberg.org/snonux/hexai/internal/llmutils"
+)
+
+// Run executes the hexai-action command flow.
+func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error {
+ logger := log.New(stderr, "hexai-action ", log.LstdFlags|log.Lmsgprefix)
+ cfg := appconfig.Load(logger)
+ client, err := llmutils.NewClientFromApp(cfg)
+ if err != nil {
+ fmt.Fprintf(stderr, logging.AnsiBase+"hexai-action: LLM disabled: %v"+logging.AnsiReset+"\n", err)
+ return err
+ }
+ parts, err := ParseInput(stdin)
+ if err != nil {
+ fmt.Fprintln(stderr, logging.AnsiBase+"hexai-action: failed to read input"+logging.AnsiReset)
+ return err
+ }
+ if strings.TrimSpace(parts.Selection) == "" {
+ return fmt.Errorf("hexai-action: no input provided on stdin")
+ }
+ kind, err := RunTUI()
+ if err != nil {
+ return err
+ }
+ out, err := executeAction(ctx, kind, parts, cfg, client, stderr)
+ if err != nil {
+ return err
+ }
+ io.WriteString(stdout, out)
+ return nil
+}
+
+func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) {
+ switch kind {
+ case ActionSkip:
+ return parts.Selection, nil
+ case ActionRewrite:
+ instr, cleaned := ExtractInstruction(parts.Selection)
+ if strings.TrimSpace(instr) == "" {
+ fmt.Fprintln(stderr, logging.AnsiBase+"hexai-action: no inline instruction found; echoing input"+logging.AnsiReset)
+ return parts.Selection, nil
+ }
+ cctx, cancel := timeout10s(ctx)
+ defer cancel()
+ return runRewrite(cctx, cfg, client, instr, cleaned)
+ case ActionDiagnostics:
+ cctx, cancel := timeout10s(ctx)
+ defer cancel()
+ return runDiagnostics(cctx, cfg, client, parts.Diagnostics, parts.Selection)
+ case ActionDocument:
+ cctx, cancel := timeout10s(ctx)
+ defer cancel()
+ return runDocument(cctx, cfg, client, parts.Selection)
+ case ActionGoTest:
+ cctx, cancel := timeout8s(ctx)
+ defer cancel()
+ return runGoTest(cctx, cfg, client, parts.Selection)
+ default:
+ return parts.Selection, nil
+ }
+}
+
+// client construction is shared via internal/llmutils
diff --git a/internal/hexaiaction/run_test.go b/internal/hexaiaction/run_test.go
new file mode 100644
index 0000000..87fbfa8
--- /dev/null
+++ b/internal/hexaiaction/run_test.go
@@ -0,0 +1,51 @@
+package hexaiaction
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llm"
+)
+
+type fakeDoer struct{ out string }
+
+func (f fakeDoer) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) {
+ return f.out, nil
+}
+
+func TestExecuteAction_Skip(t *testing.T) {
+ cfg := appconfig.App{}
+ parts := InputParts{Selection: "data"}
+ out, err := executeAction(context.Background(), ActionSkip, parts, cfg, fakeDoer{"IGN"}, nil)
+ if err != nil || out != "data" {
+ t.Fatalf("skip failed: %q %v", out, err)
+ }
+}
+
+func TestExecuteAction_Rewrite_Document_GoTest(t *testing.T) {
+ cfg := appconfig.Load(nil) // defaults
+ // Use fenced output to exercise StripFences
+ client := fakeDoer{"```\nDONE\n```"}
+
+ // rewrite with inline instruction
+ sel := ";change;\ncode"
+ out, err := executeAction(context.Background(), ActionRewrite, InputParts{Selection: sel}, cfg, client, nil)
+ if err != nil || strings.TrimSpace(out) != "DONE" {
+ t.Fatalf("rewrite failed: %q %v", out, err)
+ }
+
+ // document
+ out, err = executeAction(context.Background(), ActionDocument, InputParts{Selection: "code"}, cfg, client, nil)
+ if err != nil || strings.TrimSpace(out) != "DONE" {
+ t.Fatalf("document failed: %q %v", out, err)
+ }
+
+ // go test
+ out, err = executeAction(context.Background(), ActionGoTest, InputParts{Selection: "func A(){}"}, cfg, client, nil)
+ if err != nil || strings.TrimSpace(out) != "DONE" {
+ t.Fatalf("gotest failed: %q %v", out, err)
+ }
+}
+
diff --git a/internal/hexaiaction/tui.go b/internal/hexaiaction/tui.go
new file mode 100644
index 0000000..16988c0
--- /dev/null
+++ b/internal/hexaiaction/tui.go
@@ -0,0 +1,118 @@
+package hexaiaction
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// item implements list.Item
+type item struct {
+ title, desc string
+ kind ActionKind
+ hotkey rune
+}
+
+func (i item) Title() string { return i.title }
+func (i item) Description() string { return i.desc }
+func (i item) FilterValue() string { return i.title }
+
+type model struct {
+ list list.Model
+ chosen ActionKind
+ done bool
+}
+
+func newModel() model {
+ items := []list.Item{
+ item{title: "Rewrite selection", desc: "", kind: ActionRewrite, hotkey: 'r'},
+ item{title: "Document code", desc: "", kind: ActionDocument, hotkey: 'c'},
+ item{title: "Generate Go unit test(s)", desc: "", kind: ActionGoTest, hotkey: 't'},
+ item{title: "Skip", desc: "", kind: ActionSkip, hotkey: 's'},
+ }
+ l := list.New(items, oneLineDelegate{}, 0, 0)
+ l.Title = "Select Hexai Action"
+ l.SetShowHelp(false)
+ l.SetShowStatusBar(false)
+ l.SetFilteringEnabled(false)
+ return model{list: l}
+}
+
+func (m model) Init() tea.Cmd { return nil }
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ return handleKey(m, msg)
+ case tea.WindowSizeMsg:
+ m.list.SetSize(msg.Width, msg.Height)
+ }
+ var cmd tea.Cmd
+ m.list, cmd = m.list.Update(msg)
+ return m, cmd
+}
+
+func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ raw := msg.String()
+ low := strings.ToLower(raw)
+ switch low {
+ case "esc", "q":
+ // Treat ESC and q as Skip/quit
+ m.chosen = ActionSkip
+ m.done = true
+ return m, tea.Quit
+ case "enter":
+ if it, ok := m.list.SelectedItem().(item); ok {
+ m.chosen = it.kind
+ m.done = true
+ return m, tea.Quit
+ }
+ case "j", "down":
+ m.list.CursorDown()
+ case "k", "up":
+ m.list.CursorUp()
+ case "g", "home":
+ m.list.Select(0)
+ case "end":
+ if n := len(m.list.Items()); n > 0 { m.list.Select(n - 1) }
+ case "s", "r", "c", "t":
+ items := m.list.Items()
+ for i := 0; i < len(items); i++ {
+ if it, ok := items[i].(item); ok && strings.ToLower(string(it.hotkey)) == low {
+ m.list.Select(i)
+ m.chosen = it.kind
+ m.done = true
+ return m, tea.Quit
+ }
+ }
+ }
+ if raw == "G" { // Shift+G jumps to end
+ if n := len(m.list.Items()); n > 0 { m.list.Select(n - 1) }
+ }
+ return m, nil
+}
+
+func (m model) View() string {
+ if m.done {
+ return ""
+ }
+ return m.list.View()
+}
+
+// RunTUI returns the chosen ActionKind.
+func RunTUI() (ActionKind, error) {
+ p := tea.NewProgram(newModel())
+ md, err := p.Run()
+ if err != nil {
+ return ActionSkip, err
+ }
+ if m, ok := md.(model); ok {
+ if m.chosen == "" {
+ return ActionSkip, nil
+ }
+ return m.chosen, nil
+ }
+ return ActionSkip, fmt.Errorf("unexpected model type")
+}
diff --git a/internal/hexaiaction/tui_delegate.go b/internal/hexaiaction/tui_delegate.go
new file mode 100644
index 0000000..0e5a68c
--- /dev/null
+++ b/internal/hexaiaction/tui_delegate.go
@@ -0,0 +1,35 @@
+package hexaiaction
+
+import (
+ "fmt"
+ "io"
+
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+// oneLineDelegate renders a single compact line per item, no spacing.
+type oneLineDelegate struct{}
+
+var (
+ hotStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205"))
+ cursorStyle = lipgloss.NewStyle().Bold(true)
+)
+
+func (oneLineDelegate) Height() int { return 1 }
+func (oneLineDelegate) Spacing() int { return 0 }
+func (oneLineDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil }
+func (oneLineDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+ title := listItem.FilterValue()
+ hk := '?'
+ if it, ok := listItem.(item); ok {
+ hk = it.hotkey
+ }
+ hot := hotStyle.Render(fmt.Sprintf(" (%c)", hk))
+ cursor := " "
+ if index == m.Index() {
+ cursor = cursorStyle.Render("> ")
+ }
+ fmt.Fprintf(w, "%s%s%s", cursor, title, hot)
+}
diff --git a/internal/hexaiaction/tui_delegate_test.go b/internal/hexaiaction/tui_delegate_test.go
new file mode 100644
index 0000000..27881e4
--- /dev/null
+++ b/internal/hexaiaction/tui_delegate_test.go
@@ -0,0 +1,32 @@
+package hexaiaction
+
+import (
+ "bytes"
+ "regexp"
+ "testing"
+
+ "github.com/charmbracelet/bubbles/list"
+)
+
+func stripANSI(s string) string {
+ re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
+ return re.ReplaceAllString(s, "")
+}
+
+func TestOneLineDelegate_Render(t *testing.T) {
+ items := []list.Item{item{title: "Rewrite selection", kind: ActionRewrite, hotkey: 'r'}}
+ m := list.New(items, oneLineDelegate{}, 0, 0)
+ m.Select(0)
+ var b bytes.Buffer
+ oneLineDelegate{}.Render(&b, m, 0, items[0])
+ out := stripANSI(b.String())
+ if !regexp.MustCompile(`> \w`).MatchString(out) {
+ t.Fatalf("expected cursor prefix in %q", out)
+ }
+ if !regexp.MustCompile(`Rewrite selection`).MatchString(out) {
+ t.Fatalf("expected title in %q", out)
+ }
+ if !regexp.MustCompile(`\(r\)`).MatchString(out) {
+ t.Fatalf("expected hotkey in %q", out)
+ }
+}
diff --git a/internal/hexaiaction/tui_test.go b/internal/hexaiaction/tui_test.go
new file mode 100644
index 0000000..0f7d091
--- /dev/null
+++ b/internal/hexaiaction/tui_test.go
@@ -0,0 +1,36 @@
+package hexaiaction
+
+import (
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestHandleKey_EscSkips(t *testing.T) {
+ m := newModel()
+ nm, _ := handleKey(m, tea.KeyMsg{Type: tea.KeyEsc})
+ got, ok := nm.(model)
+ if !ok || !got.done || got.chosen != ActionSkip {
+ t.Fatalf("esc should skip: ok=%v done=%v chosen=%v", ok, got.done, got.chosen)
+ }
+}
+
+func TestHandleKey_QuickHotkey(t *testing.T) {
+ m := newModel()
+ nm, _ := handleKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
+ got := nm.(model)
+ if !got.done || got.chosen != ActionRewrite {
+ t.Fatalf("r should choose rewrite: done=%v chosen=%v", got.done, got.chosen)
+ }
+}
+
+func TestHandleKey_JumpEndWithG(t *testing.T) {
+ m := newModel()
+ // raw 'G' rune should jump to end (special cased)
+ nm, _ := handleKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}})
+ got := nm.(model)
+ if idx := got.list.Index(); idx != len(got.list.Items())-1 {
+ t.Fatalf("G should jump to end, index=%d", idx)
+ }
+}
+
diff --git a/internal/hexaiaction/types.go b/internal/hexaiaction/types.go
new file mode 100644
index 0000000..5e01cfc
--- /dev/null
+++ b/internal/hexaiaction/types.go
@@ -0,0 +1,19 @@
+package hexaiaction
+
+// Summary: Core types and constants for hexai-action.
+
+type ActionKind string
+
+const (
+ ActionSkip ActionKind = "skip"
+ ActionRewrite ActionKind = "rewrite"
+ ActionDiagnostics ActionKind = "diagnostics"
+ ActionDocument ActionKind = "document"
+ ActionGoTest ActionKind = "gotest"
+)
+
+// InputParts represents parsed stdin input for actions.
+type InputParts struct {
+ Selection string
+ Diagnostics []string
+}
diff --git a/internal/hexaicli/run_more_test.go b/internal/hexaicli/run_more_test.go
new file mode 100644
index 0000000..ae29563
--- /dev/null
+++ b/internal/hexaicli/run_more_test.go
@@ -0,0 +1,44 @@
+package hexaicli
+
+import (
+ "bytes"
+ "context"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llm"
+)
+
+type streamClient struct{}
+
+func (streamClient) Chat(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error) {
+ return "X", nil
+}
+func (streamClient) Name() string { return "fake" }
+func (streamClient) DefaultModel() string { return "m" }
+func (streamClient) ChatStream(ctx context.Context, msgs []llm.Message, onDelta func(string), opts ...llm.RequestOption) error {
+ onDelta("A")
+ onDelta("B")
+ return nil
+}
+
+func TestRunChat_Streaming(t *testing.T) {
+ var out, errw bytes.Buffer
+ input := "hello"
+ msgs := []llm.Message{{Role: "user", Content: input}}
+ if err := runChat(context.Background(), streamClient{}, msgs, input, &out, &errw); err != nil {
+ t.Fatalf("runChat failed: %v", err)
+ }
+ if out.String() != "AB" {
+ t.Fatalf("unexpected stream output: %q", out.String())
+ }
+}
+
+func TestBuildMessagesFromConfig(t *testing.T) {
+ cfg := appconfig.App{PromptCLIDefaultSystem: "DEF", PromptCLIExplainSystem: "EXP"}
+ msgs := buildMessagesFromConfig(cfg, "tell me")
+ if msgs[0].Content != "DEF" { t.Fatalf("default system wrong: %q", msgs[0].Content) }
+ msgs = buildMessagesFromConfig(cfg, "please explain")
+ if msgs[0].Content != "EXP" { t.Fatalf("explain system wrong: %q", msgs[0].Content) }
+}
+
diff --git a/internal/hexailsp/run_more_test.go b/internal/hexailsp/run_more_test.go
new file mode 100644
index 0000000..01baa96
--- /dev/null
+++ b/internal/hexailsp/run_more_test.go
@@ -0,0 +1,42 @@
+package hexailsp
+
+import (
+ "bytes"
+ "io"
+ "log"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/lsp"
+)
+
+type recRunner struct{ ran bool }
+func (r *recRunner) Run() error { r.ran = true; return nil }
+
+func TestRunWithFactory_BuildsOptionsAndClient(t *testing.T) {
+ var captured lsp.ServerOptions
+ factory := func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.ServerOptions) ServerRunner {
+ captured = opts
+ return &recRunner{}
+ }
+ var in, out bytes.Buffer
+ logger := log.New(&out, "", 0)
+ cfg := appconfig.Load(logger)
+ // Use ollama to avoid API keys
+ cfg.Provider = "ollama"
+ cfg.MaxTokens = 123
+ cfg.PromptCodeActionRewriteSystem = "RSYS"
+ cfg.PromptCodeActionRewriteUser = "RUSER"
+ if err := RunWithFactory("", &in, &out, logger, cfg, nil, factory); err != nil {
+ t.Fatalf("RunWithFactory error: %v", err)
+ }
+ if captured.MaxTokens != 123 {
+ t.Fatalf("opts not applied: %+v", captured)
+ }
+ if captured.PromptRewriteSystem != "RSYS" || captured.PromptRewriteUser != "RUSER" {
+ t.Fatalf("prompts not mapped: %+v", captured)
+ }
+ if captured.Client == nil {
+ t.Fatalf("expected client to be constructed")
+ }
+}
diff --git a/internal/llm/provider_more2_test.go b/internal/llm/provider_more2_test.go
new file mode 100644
index 0000000..fd9b2c2
--- /dev/null
+++ b/internal/llm/provider_more2_test.go
@@ -0,0 +1,13 @@
+package llm
+
+import "testing"
+
+func TestNewFromConfig_Copilot(t *testing.T) {
+ t.Setenv("COPILOT_API_KEY", "x")
+ cfg := Config{Provider: "copilot", CopilotModel: "small"}
+ c, err := NewFromConfig(cfg, "", "x")
+ if err != nil || c == nil {
+ t.Fatalf("copilot provider failed: %v %v", c, err)
+ }
+}
+
diff --git a/internal/llmutils/client.go b/internal/llmutils/client.go
new file mode 100644
index 0000000..ae545c5
--- /dev/null
+++ b/internal/llmutils/client.go
@@ -0,0 +1,35 @@
+package llmutils
+
+import (
+ "os"
+ "strings"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llm"
+)
+
+// NewClientFromApp builds an llm.Client using app config and environment keys.
+func NewClientFromApp(cfg appconfig.App) (llm.Client, error) {
+ llmCfg := llm.Config{
+ Provider: cfg.Provider,
+ OpenAIBaseURL: cfg.OpenAIBaseURL,
+ OpenAIModel: cfg.OpenAIModel,
+ OpenAITemperature: cfg.OpenAITemperature,
+ OllamaBaseURL: cfg.OllamaBaseURL,
+ OllamaModel: cfg.OllamaModel,
+ OllamaTemperature: cfg.OllamaTemperature,
+ CopilotBaseURL: cfg.CopilotBaseURL,
+ CopilotModel: cfg.CopilotModel,
+ CopilotTemperature: cfg.CopilotTemperature,
+ }
+ oaKey := os.Getenv("HEXAI_OPENAI_API_KEY")
+ if strings.TrimSpace(oaKey) == "" {
+ oaKey = os.Getenv("OPENAI_API_KEY")
+ }
+ cpKey := os.Getenv("HEXAI_COPILOT_API_KEY")
+ if strings.TrimSpace(cpKey) == "" {
+ cpKey = os.Getenv("COPILOT_API_KEY")
+ }
+ return llm.NewFromConfig(llmCfg, oaKey, cpKey)
+}
+
diff --git a/internal/llmutils/client_test.go b/internal/llmutils/client_test.go
new file mode 100644
index 0000000..9bb7ea2
--- /dev/null
+++ b/internal/llmutils/client_test.go
@@ -0,0 +1,28 @@
+package llmutils
+
+import (
+ "os"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+)
+
+func TestNewClientFromApp_Ollama(t *testing.T) {
+ cfg := appconfig.App{Provider: "ollama"}
+ c, err := NewClientFromApp(cfg)
+ if err != nil || c == nil {
+ t.Fatalf("ollama client failed: %v %v", c, err)
+ }
+}
+
+func TestNewClientFromApp_OpenAI_WithKey(t *testing.T) {
+ t.Setenv("HEXAI_OPENAI_API_KEY", "test-key")
+ cfg := appconfig.App{Provider: "openai"}
+ c, err := NewClientFromApp(cfg)
+ if err != nil || c == nil {
+ t.Fatalf("openai client failed: %v %v", c, err)
+ }
+ // ensure env override precedence
+ _ = os.Unsetenv("OPENAI_API_KEY")
+}
+
diff --git a/internal/lsp/codeaction_gotest_int_test.go b/internal/lsp/codeaction_gotest_int_test.go
new file mode 100644
index 0000000..6bb1c45
--- /dev/null
+++ b/internal/lsp/codeaction_gotest_int_test.go
@@ -0,0 +1,26 @@
+package lsp
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestResolveGoTest_CreatesTestFile(t *testing.T) {
+ dir := t.TempDir()
+ src := filepath.Join(dir, "x.go")
+ if err := os.WriteFile(src, []byte("package x\n\nfunc Sum(a,b int) int { return a+b }\n"), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ s := &Server{} // minimal server with nil llmClient to trigger stub
+ uri := "file://" + src
+ we, jumpURI, jumpRange, ok := s.resolveGoTest(uri, Position{Line: 2})
+ if !ok || jumpURI == "" || jumpRange.Start.Line < 0 {
+ t.Fatalf("resolveGoTest failed: ok=%v uri=%q range=%v", ok, jumpURI, jumpRange)
+ }
+ // Expect documentChanges to include a create and an edit
+ if len(we.DocumentChanges) == 0 && len(we.Changes) == 0 {
+ t.Fatalf("expected edits to create or append test file: %+v", we)
+ }
+}
+
diff --git a/internal/lsp/coverage_add_test.go b/internal/lsp/coverage_add_test.go
new file mode 100644
index 0000000..f4b0f00
--- /dev/null
+++ b/internal/lsp/coverage_add_test.go
@@ -0,0 +1,103 @@
+package lsp
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestInParamListAndComputeWordStart(t *testing.T) {
+ line := "func add(a int, b int) int { return a + b }"
+ if !inParamList(line, 15) { // inside params
+ t.Fatalf("expected inParamList true")
+ }
+ if inParamList("not a func", 3) {
+ t.Fatalf("expected inParamList false")
+ }
+ if n := computeWordStart("helloWorld", 10); n != 0 {
+ t.Fatalf("computeWordStart wrong: %d", n)
+ }
+}
+
+func TestStripInlineAndLabel(t *testing.T) {
+ if got := stripInlineCodeSpan("`abc`def"); got != "abc" {
+ t.Fatalf("stripInlineCodeSpan: %q", got)
+ }
+ if lbl := labelForCompletion("First line\nSecond", "fir"); lbl != "First line" {
+ t.Fatalf("labelForCompletion: %q", lbl)
+ }
+ if lbl := labelForCompletion("Other", "zzz"); lbl != "zzz" {
+ t.Fatalf("label fallback: %q", lbl)
+ }
+}
+
+func TestRangeComparators(t *testing.T) {
+ a := Range{Start: Position{Line: 1, Character: 5}, End: Position{Line: 3, Character: 0}}
+ b := Range{Start: Position{Line: 2, Character: 0}, End: Position{Line: 4, Character: 0}}
+ if !rangesOverlap(a, b) {
+ t.Fatalf("expected overlap")
+ }
+ if !lessPos(Position{Line: 1, Character: 0}, Position{Line: 1, Character: 1}) {
+ t.Fatalf("lessPos")
+ }
+ if !greaterPos(Position{Line: 2, Character: 0}, Position{Line: 1, Character: 10}) {
+ t.Fatalf("greaterPos")
+ }
+ if !isIdentChar('A') || isIdentChar('-') {
+ t.Fatalf("isIdentChar")
+ }
+}
+
+func TestFindGoFunctionAtLine_NoBody(t *testing.T) {
+ lines := []string{"func X(a int)", "// comment"}
+ start, end := findGoFunctionAtLine(lines, 0)
+ if start != 0 || end != 0 {
+ t.Fatalf("expected single-line prototype, got %d,%d", start, end)
+ }
+}
+
+func TestLineHasInlinePrompt(t *testing.T) {
+ if !lineHasInlinePrompt(">do>") {
+ t.Fatalf("expected inline prompt")
+ }
+}
+
+func TestDiagnosticsInRange_Overlap(t *testing.T) {
+ s := &Server{}
+ ctx := CodeActionContext{Diagnostics: []Diagnostic{{
+ Range: Range{Start: Position{Line: 10, Character: 0}, End: Position{Line: 12, Character: 0}},
+ Message: "x",
+ }}}
+ raw, _ := json.Marshal(ctx)
+ sel := Range{Start: Position{Line: 11, Character: 0}, End: Position{Line: 11, Character: 1}}
+ out := s.diagnosticsInRange(raw, sel)
+ if len(out) != 1 {
+ t.Fatalf("expected 1 diag overlap, got %d", len(out))
+ }
+ // no diagnostics
+ var empty json.RawMessage
+ if o2 := s.diagnosticsInRange(empty, sel); len(o2) != 0 {
+ t.Fatalf("expected 0 with empty ctx")
+ }
+}
+
+func TestIndentHelpersAndPromptRemoval(t *testing.T) {
+ if ind := leadingIndent("\t ab"); ind == "" {
+ t.Fatalf("expected indent")
+ }
+ if out := applyIndent(" ", "x\ny"); out != " x\n y" {
+ t.Fatalf("applyIndent: %q", out)
+ }
+ // double-open trigger removes whole line
+ edits := promptRemovalEditsForLine(">>ask>", 3)
+ if len(edits) != 1 || edits[0].Range.Start.Line != 3 {
+ t.Fatalf("unexpected edits: %#v", edits)
+ }
+ // temporarily switch to semicolon tags and test collection
+ oldOpen, oldClose := inlineOpenChar, inlineCloseChar
+ inlineOpenChar, inlineCloseChar = ';', ';'
+ t.Cleanup(func() { inlineOpenChar, inlineCloseChar = oldOpen, oldClose })
+ edits2 := collectSemicolonMarkers("pre;do;post", 1)
+ if len(edits2) != 1 {
+ t.Fatalf("expected one semicolon edit, got %#v", edits2)
+ }
+}
diff --git a/internal/textutil/textutil.go b/internal/textutil/textutil.go
new file mode 100644
index 0000000..7ef2680
--- /dev/null
+++ b/internal/textutil/textutil.go
@@ -0,0 +1,114 @@
+package textutil
+
+import "strings"
+
+// RenderTemplate performs simple {{var}} replacement in a template string.
+func RenderTemplate(t string, vars map[string]string) string {
+ if t == "" || len(vars) == 0 {
+ return t
+ }
+ out := t
+ for k, v := range vars {
+ out = strings.ReplaceAll(out, "{{"+k+"}}", v)
+ }
+ return out
+}
+
+// StripCodeFences removes surrounding Markdown triple-backtick fences.
+func StripCodeFences(s string) string {
+ t := strings.TrimSpace(s)
+ if t == "" {
+ return t
+ }
+ lines := strings.Split(t, "\n")
+ start := 0
+ for start < len(lines) && strings.TrimSpace(lines[start]) == "" {
+ start++
+ }
+ end := len(lines) - 1
+ for end >= 0 && strings.TrimSpace(lines[end]) == "" {
+ end--
+ }
+ if start >= len(lines) || end < 0 || start > end {
+ return t
+ }
+ first := strings.TrimSpace(lines[start])
+ last := strings.TrimSpace(lines[end])
+ if strings.HasPrefix(first, "```") && last == "```" && end > start {
+ inner := strings.Join(lines[start+1:end], "\n")
+ return inner
+ }
+ return t
+}
+
+// InstructionFromSelection extracts the first inline instruction and returns
+// (instruction, cleanedSelection). It detects markers on the earliest position
+// per line in precedence: strict ;text;, /* */, <!-- -->, //, #, --.
+func InstructionFromSelection(sel string) (string, string) {
+ lines := strings.Split(sel, "\n")
+ 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 (instruction, cleaned, ok) for a single line.
+func FindFirstInstructionInLine(line string) (instr, cleaned string, ok bool) {
+ type cand struct{ start, end int; text string }
+ cands := []cand{}
+ if t, l, r, ok := FindStrictInlineTag(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 }
+ 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
+}
+
+// FindStrictInlineTag finds ;text; with no spaces after/before semicolons.
+func FindStrictInlineTag(line string) (text string, left, right int, ok bool) {
+ for i := 0; i < len(line); i++ {
+ if line[i] != ';' { continue }
+ if i+1 < len(line) && line[i+1] == ' ' { continue }
+ for j := i + 1; j < len(line); j++ {
+ if line[j] == ';' {
+ if j-1 >= 0 && line[j-1] == ' ' { continue }
+ inner := strings.TrimSpace(line[i+1 : j])
+ if inner != "" { return inner, i, j + 1, true }
+ }
+ }
+ }
+ return "", -1, -1, false
+}
+
diff --git a/internal/textutil/textutil_test.go b/internal/textutil/textutil_test.go
new file mode 100644
index 0000000..3a8cd90
--- /dev/null
+++ b/internal/textutil/textutil_test.go
@@ -0,0 +1,87 @@
+package textutil
+
+import (
+ "regexp"
+ "strings"
+ "testing"
+)
+
+func TestRenderTemplate_Basic(t *testing.T) {
+ out := RenderTemplate("Hello, {{name}}!", map[string]string{"name": "Hex"})
+ if out != "Hello, Hex!" {
+ t.Fatalf("render failed: %q", out)
+ }
+ // No vars
+ if RenderTemplate("x", nil) != "x" { t.Fatal("nil vars changed output") }
+}
+
+func TestStripCodeFences_Variants(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"```\ncode\n```", "code"},
+ {"```go\npackage x\n```", "package x"},
+ {"no fences", "no fences"},
+ {"\n\n```\ntrim\n```\n", "trim"},
+ }
+ for _, c := range cases {
+ if got := StripCodeFences(c.in); got != c.want {
+ t.Fatalf("strip mismatch: %q != %q", got, c.want)
+ }
+ }
+}
+
+func TestInstructionFromSelection_Markers(t *testing.T) {
+ inputs := []string{
+ ";do it;\ncode",
+ "/* fix */\ncode",
+ "<!-- doc -->\ncode",
+ "// change\ncode",
+ "# tweak\ncode",
+ "-- op\ncode",
+ }
+ for _, in := range inputs {
+ instr, cleaned := InstructionFromSelection(in)
+ if strings.TrimSpace(instr) == "" {
+ t.Fatalf("no instruction for input: %q", in)
+ }
+ // cleaned should not contain the instruction token
+ if strings.Contains(cleaned, instr) {
+ // Allow coincidence only if separated differently; require not exact match on same line
+ first := strings.Split(in, "\n")[0]
+ if strings.Contains(first, instr) {
+ t.Fatalf("instruction not removed: %q", cleaned)
+ }
+ }
+ }
+}
+
+func TestFindFirstInstructionInLine_EarliestWins(t *testing.T) {
+ // Both markers present, earliest should win (strict tag first)
+ line := ";first; // later"
+ instr, cleaned, ok := FindFirstInstructionInLine(line)
+ if !ok || instr != "first" {
+ t.Fatalf("expected 'first', got %q ok=%v", instr, ok)
+ }
+ if strings.Contains(cleaned, instr) {
+ t.Fatalf("expected cleaned line to remove instr: %q", cleaned)
+ }
+}
+
+func TestFindStrictInlineTag(t *testing.T) {
+ if txt, l, r, ok := FindStrictInlineTag("pre;do;post"); !ok || txt != "do" || l != 3 || r != 7 {
+ t.Fatalf("strict tag parse failed: %q %d %d %v", txt, l, r, ok)
+ }
+ if _, _, _, ok := FindStrictInlineTag("; spaced ;"); ok {
+ t.Fatalf("should reject spaced strict tag")
+ }
+}
+
+// optional: ensure no ANSI codes appear in plain helpers
+func TestNoANSIInHelpers(t *testing.T) {
+ ansi := regexp.MustCompile(`\x1b\[[0-9;]*m`)
+ if ansi.MatchString(RenderTemplate("x", nil)) {
+ t.Fatalf("unexpected ansi in RenderTemplate")
+ }
+ if ansi.MatchString(StripCodeFences("x")) {
+ t.Fatalf("unexpected ansi in StripCodeFences")
+ }
+}