diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-06 13:19:01 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-06 13:19:01 +0300 |
| commit | 04f290dbeeee8a6fcbc70fed253a968336bcb2ab (patch) | |
| tree | 3ee23a4ac4bcc5b43b43697cfb0e905735fc6331 | |
| parent | 5e966f50111adf6e2cb2683fe588f6fe033fa931 (diff) | |
more tests
| -rw-r--r-- | cmd/hexai-lsp/main_test.go | 23 | ||||
| -rw-r--r-- | cmd/hexai/main_test.go | 23 | ||||
| -rw-r--r-- | cmd/internal/hexai-action/main.go | 16 | ||||
| -rw-r--r-- | internal/hexaiaction/parse.go | 69 | ||||
| -rw-r--r-- | internal/hexaiaction/parse_test.go | 121 | ||||
| -rw-r--r-- | internal/hexaiaction/prompts.go | 91 | ||||
| -rw-r--r-- | internal/hexaiaction/run.go | 74 | ||||
| -rw-r--r-- | internal/hexaiaction/run_test.go | 51 | ||||
| -rw-r--r-- | internal/hexaiaction/tui.go | 118 | ||||
| -rw-r--r-- | internal/hexaiaction/tui_delegate.go | 35 | ||||
| -rw-r--r-- | internal/hexaiaction/tui_delegate_test.go | 32 | ||||
| -rw-r--r-- | internal/hexaiaction/tui_test.go | 36 | ||||
| -rw-r--r-- | internal/hexaiaction/types.go | 19 | ||||
| -rw-r--r-- | internal/hexaicli/run_more_test.go | 44 | ||||
| -rw-r--r-- | internal/hexailsp/run_more_test.go | 42 | ||||
| -rw-r--r-- | internal/llm/provider_more2_test.go | 13 | ||||
| -rw-r--r-- | internal/llmutils/client.go | 35 | ||||
| -rw-r--r-- | internal/llmutils/client_test.go | 28 | ||||
| -rw-r--r-- | internal/lsp/codeaction_gotest_int_test.go | 26 | ||||
| -rw-r--r-- | internal/lsp/coverage_add_test.go | 103 | ||||
| -rw-r--r-- | internal/textutil/textutil.go | 114 | ||||
| -rw-r--r-- | internal/textutil/textutil_test.go | 87 |
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") + } +} |
