diff options
Diffstat (limited to 'internal')
39 files changed, 1427 insertions, 1262 deletions
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index 87b5a29..2c4cee3 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -74,14 +74,14 @@ type App struct { // Code actions PromptCodeActionRewriteSystem string `json:"-" toml:"-"` PromptCodeActionDiagnosticsSystem string `json:"-" toml:"-"` - PromptCodeActionDocumentSystem string `json:"-" toml:"-"` - PromptCodeActionRewriteUser string `json:"-" toml:"-"` - PromptCodeActionDiagnosticsUser string `json:"-" toml:"-"` - PromptCodeActionDocumentUser string `json:"-" toml:"-"` - PromptCodeActionGoTestSystem string `json:"-" toml:"-"` - PromptCodeActionGoTestUser string `json:"-" toml:"-"` - PromptCodeActionSimplifySystem string `json:"-" toml:"-"` - PromptCodeActionSimplifyUser string `json:"-" toml:"-"` + PromptCodeActionDocumentSystem string `json:"-" toml:"-"` + PromptCodeActionRewriteUser string `json:"-" toml:"-"` + PromptCodeActionDiagnosticsUser string `json:"-" toml:"-"` + PromptCodeActionDocumentUser string `json:"-" toml:"-"` + PromptCodeActionGoTestSystem string `json:"-" toml:"-"` + PromptCodeActionGoTestUser string `json:"-" toml:"-"` + PromptCodeActionSimplifySystem string `json:"-" toml:"-"` + PromptCodeActionSimplifyUser string `json:"-" toml:"-"` // CLI PromptCLIDefaultSystem string `json:"-" toml:"-"` PromptCLIExplainSystem string `json:"-" toml:"-"` @@ -129,10 +129,10 @@ func newDefaultConfig() App { PromptCodeActionRewriteUser: "Instruction: {{instruction}}\n\nSelected code to transform:\n{{selection}}", PromptCodeActionDiagnosticsUser: "Diagnostics to resolve (selection only):\n{{diagnostics}}\n\nSelected code:\n{{selection}}", PromptCodeActionDocumentUser: "Add documentation comments to this code:\n{{selection}}", - PromptCodeActionGoTestSystem: "You are a precise Go unit test generator. Given a Go function, write one or more Test* functions using the testing package. Do NOT include package or imports, only the test function(s). Prefer table-driven tests. Keep it minimal and idiomatic.", - PromptCodeActionGoTestUser: "Function under test:\n{{function}}", - PromptCodeActionSimplifySystem: "You are a precise code improvement engine. Simplify and improve the given code while preserving behavior. Return only the improved code with no prose or backticks.", - PromptCodeActionSimplifyUser: "Improve this code:\n{{selection}}", + PromptCodeActionGoTestSystem: "You are a precise Go unit test generator. Given a Go function, write one or more Test* functions using the testing package. Do NOT include package or imports, only the test function(s). Prefer table-driven tests. Keep it minimal and idiomatic.", + PromptCodeActionGoTestUser: "Function under test:\n{{function}}", + PromptCodeActionSimplifySystem: "You are a precise code improvement engine. Simplify and improve the given code while preserving behavior. Return only the improved code with no prose or backticks.", + PromptCodeActionSimplifyUser: "Improve this code:\n{{selection}}", PromptCLIDefaultSystem: "You are Hexai CLI. Default to very short, concise answers. If the user asks for commands, output only the commands (one per line) with no commentary or explanation. Only when the word 'explain' appears in the prompt, produce a verbose explanation.", PromptCLIExplainSystem: "You are Hexai CLI. The user requested an explanation. Provide a clear, verbose explanation with reasoning and details. If commands are needed, include them with brief context.", @@ -260,16 +260,16 @@ type sectionPromptsChat struct { } type sectionPromptsCodeAction struct { - RewriteSystem string `toml:"rewrite_system"` - DiagnosticsSystem string `toml:"diagnostics_system"` - DocumentSystem string `toml:"document_system"` - RewriteUser string `toml:"rewrite_user"` - DiagnosticsUser string `toml:"diagnostics_user"` - DocumentUser string `toml:"document_user"` - GoTestSystem string `toml:"go_test_system"` - GoTestUser string `toml:"go_test_user"` - SimplifySystem string `toml:"simplify_system"` - SimplifyUser string `toml:"simplify_user"` + RewriteSystem string `toml:"rewrite_system"` + DiagnosticsSystem string `toml:"diagnostics_system"` + DocumentSystem string `toml:"document_system"` + RewriteUser string `toml:"rewrite_user"` + DiagnosticsUser string `toml:"diagnostics_user"` + DocumentUser string `toml:"document_user"` + GoTestSystem string `toml:"go_test_system"` + GoTestUser string `toml:"go_test_user"` + SimplifySystem string `toml:"simplify_system"` + SimplifyUser string `toml:"simplify_user"` } type sectionPromptsCLI struct { @@ -393,7 +393,7 @@ func (fc *fileConfig) toApp() App { out.PromptChatSystem = fc.Prompts.Chat.System } // code action - if (fc.Prompts.CodeAction != sectionPromptsCodeAction{}) { + if (fc.Prompts.CodeAction != sectionPromptsCodeAction{}) { if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" { out.PromptCodeActionRewriteSystem = fc.Prompts.CodeAction.RewriteSystem } @@ -415,16 +415,16 @@ func (fc *fileConfig) toApp() App { if strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" { out.PromptCodeActionGoTestSystem = fc.Prompts.CodeAction.GoTestSystem } - if strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" { - out.PromptCodeActionGoTestUser = fc.Prompts.CodeAction.GoTestUser - } - if strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" { - out.PromptCodeActionSimplifySystem = fc.Prompts.CodeAction.SimplifySystem - } - if strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" { - out.PromptCodeActionSimplifyUser = fc.Prompts.CodeAction.SimplifyUser - } - } + if strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" { + out.PromptCodeActionGoTestUser = fc.Prompts.CodeAction.GoTestUser + } + if strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" { + out.PromptCodeActionSimplifySystem = fc.Prompts.CodeAction.SimplifySystem + } + if strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" { + out.PromptCodeActionSimplifyUser = fc.Prompts.CodeAction.SimplifyUser + } + } // cli if (fc.Prompts.CLI != sectionPromptsCLI{}) { if strings.TrimSpace(fc.Prompts.CLI.DefaultSystem) != "" { @@ -623,15 +623,15 @@ func (a *App) mergePrompts(other *App) { if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" { a.PromptCodeActionGoTestSystem = other.PromptCodeActionGoTestSystem } - if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" { - a.PromptCodeActionGoTestUser = other.PromptCodeActionGoTestUser - } - if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" { - a.PromptCodeActionSimplifySystem = other.PromptCodeActionSimplifySystem - } - if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" { - a.PromptCodeActionSimplifyUser = other.PromptCodeActionSimplifyUser - } + if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" { + a.PromptCodeActionGoTestUser = other.PromptCodeActionGoTestUser + } + if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" { + a.PromptCodeActionSimplifySystem = other.PromptCodeActionSimplifySystem + } + if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" { + a.PromptCodeActionSimplifyUser = other.PromptCodeActionSimplifyUser + } // CLI if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" { a.PromptCLIDefaultSystem = other.PromptCLIDefaultSystem diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go index 5cb79a0..dc8c39c 100644 --- a/internal/appconfig/config_test.go +++ b/internal/appconfig/config_test.go @@ -25,15 +25,15 @@ func writeFile(t *testing.T, path, content string) { // clearHexaiEnv removes any HEXAI_* variables to prevent environment leakage // into tests that expect file-only configuration. func clearHexaiEnv(t *testing.T) { - t.Helper() - for _, e := range os.Environ() { - if strings.HasPrefix(e, "HEXAI_") { - kv := strings.SplitN(e, "=", 2) - if len(kv) > 0 { - t.Setenv(kv[0], "") - } - } - } + t.Helper() + for _, e := range os.Environ() { + if strings.HasPrefix(e, "HEXAI_") { + kv := strings.SplitN(e, "=", 2) + if len(kv) > 0 { + t.Setenv(kv[0], "") + } + } + } } func withEnv(t *testing.T, k, v string) { @@ -54,10 +54,10 @@ func TestLoad_Defaults_NoLogger(t *testing.T) { } func TestLoad_Defaults_WithLogger_NoFile_NoEnv(t *testing.T) { - clearHexaiEnv(t) - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - logger := newLogger() - cfg := Load(logger) + clearHexaiEnv(t) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + logger := newLogger() + cfg := Load(logger) def := newDefaultConfig() if cfg.MaxTokens != def.MaxTokens || cfg.ContextMode != def.ContextMode || cfg.ContextWindowLines != def.ContextWindowLines { t.Fatalf("expected defaults; got %+v want %+v", cfg, def) @@ -207,9 +207,9 @@ func TestLoadFromFile_InvalidTOML(t *testing.T) { } func TestLoad_FileTables_Sectioned(t *testing.T) { - clearHexaiEnv(t) - dir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", dir) + clearHexaiEnv(t) + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) cfgPath := filepath.Join(dir, "hexai", "config.toml") content := ` [general] @@ -290,9 +290,9 @@ temperature = 0.0 } func TestLoad_FileTables_Prompts_AllSections(t *testing.T) { - clearHexaiEnv(t) - dir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", dir) + clearHexaiEnv(t) + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) cfgPath := filepath.Join(dir, "hexai", "config.toml") content := ` [prompts.completion] diff --git a/internal/editor/editor.go b/internal/editor/editor.go index 44aa1d4..a1af1be 100644 --- a/internal/editor/editor.go +++ b/internal/editor/editor.go @@ -1,70 +1,70 @@ package editor import ( - "errors" - "os" - "os/exec" - "path/filepath" - "strings" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" ) // Resolve returns the editor command from HEXAI_EDITOR or EDITOR. func Resolve() (string, error) { - ed := strings.TrimSpace(os.Getenv("HEXAI_EDITOR")) - if ed == "" { - ed = strings.TrimSpace(os.Getenv("EDITOR")) - } - if ed == "" { - return "", errors.New("no editor configured (set HEXAI_EDITOR or EDITOR)") - } - return ed, nil + ed := strings.TrimSpace(os.Getenv("HEXAI_EDITOR")) + if ed == "" { + ed = strings.TrimSpace(os.Getenv("EDITOR")) + } + if ed == "" { + return "", errors.New("no editor configured (set HEXAI_EDITOR or EDITOR)") + } + return ed, nil } // RunEditor is the seam that invokes the editor on the given file path. // Override in tests to avoid launching a real editor. var RunEditor = func(editor, path string) error { - cmd := exec.Command(editor, path) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + cmd := exec.Command(editor, path) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() } // OpenTempAndEdit creates a temporary .md file, writes initial content if provided, // opens it in the resolved editor, then reads the final content and removes the file. // Returns the trimmed content. func OpenTempAndEdit(initial []byte) (string, error) { - ed, err := Resolve() - if err != nil { - return "", err - } - // Create temp file under system temp dir; ensure .md suffix - dir := os.TempDir() - f, err := os.CreateTemp(dir, "hexai-*.md") - if err != nil { - return "", err - } - path := f.Name() - defer func() { _ = os.Remove(path) }() - if len(initial) > 0 { - if _, err := f.Write(initial); err != nil { - _ = f.Close() - return "", err - } - } - if err := f.Sync(); err != nil { - _ = f.Close() - return "", err - } - if err := f.Close(); err != nil { - return "", err - } - if err := RunEditor(ed, path); err != nil { - return "", err - } - b, err := os.ReadFile(filepath.Clean(path)) - if err != nil { - return "", err - } - return strings.TrimSpace(string(b)), nil + ed, err := Resolve() + if err != nil { + return "", err + } + // Create temp file under system temp dir; ensure .md suffix + dir := os.TempDir() + f, err := os.CreateTemp(dir, "hexai-*.md") + if err != nil { + return "", err + } + path := f.Name() + defer func() { _ = os.Remove(path) }() + if len(initial) > 0 { + if _, err := f.Write(initial); err != nil { + _ = f.Close() + return "", err + } + } + if err := f.Sync(); err != nil { + _ = f.Close() + return "", err + } + if err := f.Close(); err != nil { + return "", err + } + if err := RunEditor(ed, path); err != nil { + return "", err + } + b, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return "", err + } + return strings.TrimSpace(string(b)), nil } diff --git a/internal/editor/editor_test.go b/internal/editor/editor_test.go index df6dec7..06cc165 100644 --- a/internal/editor/editor_test.go +++ b/internal/editor/editor_test.go @@ -1,39 +1,44 @@ package editor import ( - "os" - "path/filepath" - "testing" + "os" + "path/filepath" + "testing" ) func TestResolve_EnvPriority(t *testing.T) { - t.Setenv("HEXAI_EDITOR", "ed1") - t.Setenv("EDITOR", "ed2") - ed, err := Resolve() - if err != nil || ed != "ed1" { - t.Fatalf("Resolve failed: %v %q", err, ed) - } - t.Setenv("HEXAI_EDITOR", "") - ed, err = Resolve() - if err != nil || ed != "ed2" { - t.Fatalf("Resolve fallback failed: %v %q", err, ed) - } + t.Setenv("HEXAI_EDITOR", "ed1") + t.Setenv("EDITOR", "ed2") + ed, err := Resolve() + if err != nil || ed != "ed1" { + t.Fatalf("Resolve failed: %v %q", err, ed) + } + t.Setenv("HEXAI_EDITOR", "") + ed, err = Resolve() + if err != nil || ed != "ed2" { + t.Fatalf("Resolve fallback failed: %v %q", err, ed) + } } func TestOpenTempAndEdit_UsesRunEditor(t *testing.T) { - old := RunEditor - t.Cleanup(func(){ RunEditor = old }) - // Ensure Resolve() succeeds - t.Setenv("HEXAI_EDITOR", "dummy") - var capturedPath string - RunEditor = func(editor, path string) error { - capturedPath = path - // simulate user writing content - return os.WriteFile(path, []byte("Hello\nWorld\n"), 0o600) - } - out, err := OpenTempAndEdit([]byte("# Start\n\n")) - if err != nil { t.Fatalf("OpenTempAndEdit: %v", err) } - if out != "Hello\nWorld" { t.Fatalf("unexpected content: %q", out) } - if filepath.Ext(capturedPath) != ".md" { t.Fatalf("expected .md suffix: %s", capturedPath) } + old := RunEditor + t.Cleanup(func() { RunEditor = old }) + // Ensure Resolve() succeeds + t.Setenv("HEXAI_EDITOR", "dummy") + var capturedPath string + RunEditor = func(editor, path string) error { + capturedPath = path + // simulate user writing content + return os.WriteFile(path, []byte("Hello\nWorld\n"), 0o600) + } + out, err := OpenTempAndEdit([]byte("# Start\n\n")) + if err != nil { + t.Fatalf("OpenTempAndEdit: %v", err) + } + if out != "Hello\nWorld" { + t.Fatalf("unexpected content: %q", out) + } + if filepath.Ext(capturedPath) != ".md" { + t.Fatalf("expected .md suffix: %s", capturedPath) + } } - diff --git a/internal/hexaiaction/cmdentry.go b/internal/hexaiaction/cmdentry.go index cf72495..ca33443 100644 --- a/internal/hexaiaction/cmdentry.go +++ b/internal/hexaiaction/cmdentry.go @@ -1,149 +1,183 @@ package hexaiaction import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - "time" + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" - "codeberg.org/snonux/hexai/internal/tmux" - "golang.org/x/term" + "codeberg.org/snonux/hexai/internal/tmux" + "golang.org/x/term" ) // Options configures the command-line orchestration for hexai-tmux-action. type Options struct { - Infile string - Outfile string - UIChild bool - TmuxTarget string - TmuxSplit string // "v" or "h" - TmuxPercent int // 1-100 + Infile string + Outfile string + UIChild bool + TmuxTarget string + TmuxSplit string // "v" or "h" + TmuxPercent int // 1-100 } // RunCommand is the CLI orchestrator used by cmd/hexai-tmux-action. It runs in tmux // split-pane mode by default, or child mode when -ui-child is set. func RunCommand(ctx context.Context, opts Options, stdin io.Reader, stdout, stderr io.Writer) error { - if opts.UIChild { - return runChild(ctx, opts.Infile, opts.Outfile, stdout, stderr) - } - // Always use tmux path - return runInTmuxParent(stdin, stdout, opts.TmuxTarget, opts.TmuxSplit, opts.TmuxPercent) + if opts.UIChild { + return runChild(ctx, opts.Infile, opts.Outfile, stdout, stderr) + } + // Always use tmux path + return runInTmuxParent(stdin, stdout, opts.TmuxTarget, opts.TmuxSplit, opts.TmuxPercent) } // seams for unit tests -var isTTYFn = func(fd uintptr) bool { return term.IsTerminal(int(fd)) } -var splitRunFn = tmux.SplitRun -var osExecutableFn = os.Executable -var runFn = Run +var ( + isTTYFn = func(fd uintptr) bool { return term.IsTerminal(int(fd)) } + splitRunFn = tmux.SplitRun + osExecutableFn = os.Executable + runFn = Run +) // openIO returns readers/writers for infile/outfile flags with deferred closers. func openIO(infile, outfile string) (io.Reader, io.Writer, func(), func(), error) { - in := io.Reader(os.Stdin) - out := io.Writer(os.Stdout) - closeIn := func() {} - closeOut := func() {} - if path := infile; path != "" { - f, err := os.Open(path) - if err != nil { return nil, nil, func(){}, func(){}, fmt.Errorf("hexai-tmux-action: cannot open infile: %w", err) } - in = f - closeIn = func() { _ = f.Close() } - } - if path := outfile; path != "" { - f, err := os.Create(path) - if err != nil { return nil, nil, func(){}, func(){}, fmt.Errorf("hexai-tmux-action: cannot open outfile: %w", err) } - out = f - closeOut = func() { _ = f.Close() } - } - return in, out, closeIn, closeOut, nil + in := io.Reader(os.Stdin) + out := io.Writer(os.Stdout) + closeIn := func() {} + closeOut := func() {} + if path := infile; path != "" { + f, err := os.Open(path) + if err != nil { + return nil, nil, func() {}, func() {}, fmt.Errorf("hexai-tmux-action: cannot open infile: %w", err) + } + in = f + closeIn = func() { _ = f.Close() } + } + if path := outfile; path != "" { + f, err := os.Create(path) + if err != nil { + return nil, nil, func() {}, func() {}, fmt.Errorf("hexai-tmux-action: cannot open outfile: %w", err) + } + out = f + closeOut = func() { _ = f.Close() } + } + return in, out, closeIn, closeOut, nil } // runChild runs the interactive flow and writes the final output atomically when outfile is set. func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Writer) error { - if outfile == "" { - // No atomic handoff needed; just run normally to provided stdout - var in io.Reader = os.Stdin - if infile != "" { - f, err := os.Open(infile) - if err != nil { return err } - defer func(){ _ = f.Close() }() - in = f - } - return runFn(ctx, in, stdout, stderr) - } - tmp := outfile + ".tmp" - in, out, closeIn, closeOut, err := openIO(infile, tmp) - if err != nil { return err } - defer closeIn() - if err := runFn(ctx, in, out, stderr); err != nil { - closeOut() - if copyErr := echoThrough(infile, tmp, os.Stdin, stdout); copyErr != nil { - return fmt.Errorf("hexai-tmux-action child: %v; echo failed: %v", err, copyErr) - } - } else { - closeOut() - } - return os.Rename(tmp, outfile) + if outfile == "" { + // No atomic handoff needed; just run normally to provided stdout + var in io.Reader = os.Stdin + if infile != "" { + f, err := os.Open(infile) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + in = f + } + return runFn(ctx, in, stdout, stderr) + } + tmp := outfile + ".tmp" + in, out, closeIn, closeOut, err := openIO(infile, tmp) + if err != nil { + return err + } + defer closeIn() + if err := runFn(ctx, in, out, stderr); err != nil { + closeOut() + if copyErr := echoThrough(infile, tmp, os.Stdin, stdout); copyErr != nil { + return fmt.Errorf("hexai-tmux-action child: %v; echo failed: %v", err, copyErr) + } + } else { + closeOut() + } + return os.Rename(tmp, outfile) } func runInTmuxParent(stdin io.Reader, stdout io.Writer, target, split string, percent int) error { - dir, err := os.MkdirTemp("", "hexai-tmux-action-") - if err != nil { return err } - defer func() { _ = os.RemoveAll(dir) }() - inPath := filepath.Join(dir, "input.txt") - outPath := filepath.Join(dir, "reply.txt") - if err := persistStdin(inPath, stdin); err != nil { return err } - exe, err := osExecutableFn() - if err != nil { return err } - argv := []string{exe, "-ui-child", "-infile", inPath, "-outfile", outPath} - opts := tmux.SplitOpts{Target: target, Vertical: split != "h", Percent: percent} - if err := splitRunFn(opts, argv); err != nil { return err } - if err := waitForFile(outPath, 60*time.Second); err != nil { return err } - return catFileTo(stdout, outPath) + dir, err := os.MkdirTemp("", "hexai-tmux-action-") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(dir) }() + inPath := filepath.Join(dir, "input.txt") + outPath := filepath.Join(dir, "reply.txt") + if err := persistStdin(inPath, stdin); err != nil { + return err + } + exe, err := osExecutableFn() + if err != nil { + return err + } + argv := []string{exe, "-ui-child", "-infile", inPath, "-outfile", outPath} + opts := tmux.SplitOpts{Target: target, Vertical: split != "h", Percent: percent} + if err := splitRunFn(opts, argv); err != nil { + return err + } + if err := waitForFile(outPath, 60*time.Second); err != nil { + return err + } + return catFileTo(stdout, outPath) } func persistStdin(path string, stdin io.Reader) error { - f, err := os.Create(path) - if err != nil { return err } - defer func() { _ = f.Close() }() - if _, err := io.Copy(f, stdin); err != nil { return err } - return f.Sync() + f, err := os.Create(path) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + if _, err := io.Copy(f, stdin); err != nil { + return err + } + return f.Sync() } func waitForFile(path string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - for { - if _, err := os.Stat(path); err == nil { return nil } - if time.Now().After(deadline) { return fmt.Errorf("hexai-tmux-action: timeout waiting for reply file") } - time.Sleep(200 * time.Millisecond) - } + deadline := time.Now().Add(timeout) + for { + if _, err := os.Stat(path); err == nil { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("hexai-tmux-action: timeout waiting for reply file") + } + time.Sleep(200 * time.Millisecond) + } } func catFileTo(w io.Writer, path string) error { - f, err := os.Open(path) - if err != nil { return err } - defer func() { _ = f.Close() }() - _, err = io.Copy(w, f) - return err + f, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + _, err = io.Copy(w, f) + return err } // echoThrough no longer used in tmux-only flow, but kept for potential reuse. func echoThrough(infile, outfile string, stdin io.Reader, stdout io.Writer) error { - var in io.Reader = stdin - var out io.Writer = stdout - if infile != "" { - f, err := os.Open(infile) - if err != nil { return err } - defer func() { _ = f.Close() }() - in = f - } - if outfile != "" { - f, err := os.Create(outfile) - if err != nil { return err } - defer func() { _ = f.Close() }() - out = f - } - _, err := io.Copy(out, in) - return err + var in io.Reader = stdin + var out io.Writer = stdout + if infile != "" { + f, err := os.Open(infile) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + in = f + } + if outfile != "" { + f, err := os.Create(outfile) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + out = f + } + _, err := io.Copy(out, in) + return err } diff --git a/internal/hexaiaction/cmdentry_runcommand_test.go b/internal/hexaiaction/cmdentry_runcommand_test.go index 092e43b..b139bb3 100644 --- a/internal/hexaiaction/cmdentry_runcommand_test.go +++ b/internal/hexaiaction/cmdentry_runcommand_test.go @@ -1,53 +1,60 @@ package hexaiaction import ( - "bytes" - "context" - "io" - "os" - "path/filepath" - "testing" + "bytes" + "context" + "io" + "os" + "path/filepath" + "testing" - "codeberg.org/snonux/hexai/internal/tmux" + "codeberg.org/snonux/hexai/internal/tmux" ) func TestRunCommand_UIChild(t *testing.T) { - dir := t.TempDir() - in := filepath.Join(dir, "in.txt") - out := filepath.Join(dir, "out.txt") - _ = os.WriteFile(in, []byte("sel"), 0o600) - old := runFn - runFn = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error { _, _ = io.WriteString(w, "OK"); return nil } - t.Cleanup(func(){ runFn = old }) - opts := Options{Infile: in, Outfile: out, UIChild: true} - if err := RunCommand(context.Background(), opts, bytes.NewBuffer(nil), io.Discard, io.Discard); err != nil { - t.Fatalf("RunCommand UIChild: %v", err) - } - b, _ := os.ReadFile(out) - if string(b) != "OK" { t.Fatalf("outfile: %q", string(b)) } + dir := t.TempDir() + in := filepath.Join(dir, "in.txt") + out := filepath.Join(dir, "out.txt") + _ = os.WriteFile(in, []byte("sel"), 0o600) + old := runFn + runFn = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error { + _, _ = io.WriteString(w, "OK") + return nil + } + t.Cleanup(func() { runFn = old }) + opts := Options{Infile: in, Outfile: out, UIChild: true} + if err := RunCommand(context.Background(), opts, bytes.NewBuffer(nil), io.Discard, io.Discard); err != nil { + t.Fatalf("RunCommand UIChild: %v", err) + } + b, _ := os.ReadFile(out) + if string(b) != "OK" { + t.Fatalf("outfile: %q", string(b)) + } } func TestRunCommand_Tmux(t *testing.T) { - oldTTY := isTTYFn - oldExec := osExecutableFn - oldSplit := splitRunFn - isTTYFn = func(_ uintptr) bool { return false } - osExecutableFn = func() (string, error) { return "/bin/hexai-tmux-action", nil } - splitRunFn = func(_ tmux.SplitOpts, argv []string) error { - for i := 0; i < len(argv)-1; i++ { - if argv[i] == "-outfile" && i+1 < len(argv) { - _ = os.WriteFile(argv[i+1], []byte("OUT"), 0o600) - break - } - } - return nil - } - defer func(){ isTTYFn = oldTTY; osExecutableFn = oldExec; splitRunFn = oldSplit }() - var out bytes.Buffer - if err := RunCommand(context.Background(), Options{}, bytes.NewBufferString("X"), &out, io.Discard); err != nil { - t.Fatalf("RunCommand tmux: %v", err) - } - if out.String() != "OUT" { t.Fatalf("stdout: %q", out.String()) } + oldTTY := isTTYFn + oldExec := osExecutableFn + oldSplit := splitRunFn + isTTYFn = func(_ uintptr) bool { return false } + osExecutableFn = func() (string, error) { return "/bin/hexai-tmux-action", nil } + splitRunFn = func(_ tmux.SplitOpts, argv []string) error { + for i := 0; i < len(argv)-1; i++ { + if argv[i] == "-outfile" && i+1 < len(argv) { + _ = os.WriteFile(argv[i+1], []byte("OUT"), 0o600) + break + } + } + return nil + } + defer func() { isTTYFn = oldTTY; osExecutableFn = oldExec; splitRunFn = oldSplit }() + var out bytes.Buffer + if err := RunCommand(context.Background(), Options{}, bytes.NewBufferString("X"), &out, io.Discard); err != nil { + t.Fatalf("RunCommand tmux: %v", err) + } + if out.String() != "OUT" { + t.Fatalf("stdout: %q", out.String()) + } } // Inline TTY path is exercised implicitly via other helpers; testing it directly diff --git a/internal/hexaiaction/cmdentry_test.go b/internal/hexaiaction/cmdentry_test.go index de8b5dd..9c896f6 100644 --- a/internal/hexaiaction/cmdentry_test.go +++ b/internal/hexaiaction/cmdentry_test.go @@ -1,135 +1,183 @@ package hexaiaction import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "testing" - "time" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" - "codeberg.org/snonux/hexai/internal/tmux" + "codeberg.org/snonux/hexai/internal/tmux" ) // tmux-only flow: decision helpers removed. func TestPersistStdin_WritesFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "in.txt") - // Point stdin to content - src := filepath.Join(dir, "src.txt") - if err := os.WriteFile(src, []byte("hello world"), 0o600); err != nil { t.Fatalf("write src: %v", err) } - f, _ := os.Open(src) - defer f.Close() - if err := persistStdin(path, f); err != nil { t.Fatalf("persistStdin: %v", err) } - b, _ := os.ReadFile(path) - if string(b) != "hello world" { t.Fatalf("unexpected content %q", string(b)) } + dir := t.TempDir() + path := filepath.Join(dir, "in.txt") + // Point stdin to content + src := filepath.Join(dir, "src.txt") + if err := os.WriteFile(src, []byte("hello world"), 0o600); err != nil { + t.Fatalf("write src: %v", err) + } + f, _ := os.Open(src) + defer f.Close() + if err := persistStdin(path, f); err != nil { + t.Fatalf("persistStdin: %v", err) + } + b, _ := os.ReadFile(path) + if string(b) != "hello world" { + t.Fatalf("unexpected content %q", string(b)) + } } func TestEchoThrough(t *testing.T) { - dir := t.TempDir() - in := filepath.Join(dir, "in.txt") - out := filepath.Join(dir, "out.txt") - _ = os.WriteFile(in, []byte("hello"), 0o600) - if err := echoThrough(in, out, os.Stdin, os.Stdout); err != nil { t.Fatalf("echoThrough: %v", err) } - b, _ := os.ReadFile(out) - if string(b) != "hello" { t.Fatalf("unexpected: %q", string(b)) } + dir := t.TempDir() + in := filepath.Join(dir, "in.txt") + out := filepath.Join(dir, "out.txt") + _ = os.WriteFile(in, []byte("hello"), 0o600) + if err := echoThrough(in, out, os.Stdin, os.Stdout); err != nil { + t.Fatalf("echoThrough: %v", err) + } + b, _ := os.ReadFile(out) + if string(b) != "hello" { + t.Fatalf("unexpected: %q", string(b)) + } } func TestEchoThrough_StdinStdout(t *testing.T) { - // set stdin - rIn, wIn, _ := os.Pipe() - _, _ = wIn.Write([]byte("PIPE")) - _ = wIn.Close() - // capture stdout - r, w, _ := os.Pipe() - if err := echoThrough("", "", rIn, w); err != nil { t.Fatalf("echoThrough: %v", err) } - _ = w.Close() - data, _ := io.ReadAll(r) - if string(data) != "PIPE" { t.Fatalf("stdout: %q", string(data)) } + // set stdin + rIn, wIn, _ := os.Pipe() + _, _ = wIn.Write([]byte("PIPE")) + _ = wIn.Close() + // capture stdout + r, w, _ := os.Pipe() + if err := echoThrough("", "", rIn, w); err != nil { + t.Fatalf("echoThrough: %v", err) + } + _ = w.Close() + data, _ := io.ReadAll(r) + if string(data) != "PIPE" { + t.Fatalf("stdout: %q", string(data)) + } } func TestRunInTmuxParent_Stubbed(t *testing.T) { - dir := t.TempDir() - // set stdin content - r, w, _ := os.Pipe() - _, _ = w.Write([]byte("input")) - _ = w.Close() - // capture stdout - rout, wout, _ := os.Pipe() - oldExec := osExecutableFn - oldSplit := splitRunFn - osExecutableFn = func() (string, error) { return "/bin/hexai-tmux-action", nil } - splitRunFn = func(opts tmux.SplitOpts, argv []string) error { - for i := 0; i < len(argv)-1; i++ { - if argv[i] == "-outfile" && i+1 < len(argv) { - _ = os.WriteFile(argv[i+1], []byte("OUT:"+strings.Join(argv, ",")), 0o600) - break - } - } - return nil - } - t.Cleanup(func() { osExecutableFn = oldExec; splitRunFn = oldSplit }) - if err := runInTmuxParent(r, wout, "", "v", 33); err != nil { t.Fatalf("runInTmuxParent: %v", err) } - _ = wout.Close() - got, _ := io.ReadAll(rout) - if !strings.HasPrefix(string(got), "OUT:") { t.Fatalf("unexpected stdout: %q", string(got)) } - _ = dir + dir := t.TempDir() + // set stdin content + r, w, _ := os.Pipe() + _, _ = w.Write([]byte("input")) + _ = w.Close() + // capture stdout + rout, wout, _ := os.Pipe() + oldExec := osExecutableFn + oldSplit := splitRunFn + osExecutableFn = func() (string, error) { return "/bin/hexai-tmux-action", nil } + splitRunFn = func(opts tmux.SplitOpts, argv []string) error { + for i := 0; i < len(argv)-1; i++ { + if argv[i] == "-outfile" && i+1 < len(argv) { + _ = os.WriteFile(argv[i+1], []byte("OUT:"+strings.Join(argv, ",")), 0o600) + break + } + } + return nil + } + t.Cleanup(func() { osExecutableFn = oldExec; splitRunFn = oldSplit }) + if err := runInTmuxParent(r, wout, "", "v", 33); err != nil { + t.Fatalf("runInTmuxParent: %v", err) + } + _ = wout.Close() + got, _ := io.ReadAll(rout) + if !strings.HasPrefix(string(got), "OUT:") { + t.Fatalf("unexpected stdout: %q", string(got)) + } + _ = dir } func TestRunInTmuxParent_ExecutableError(t *testing.T) { - old := osExecutableFn - osExecutableFn = func() (string, error) { return "", fmt.Errorf("no exe") } - t.Cleanup(func() { osExecutableFn = old }) - r, w, _ := os.Pipe(); _, _ = w.Write([]byte("x")); _ = w.Close() - if err := runInTmuxParent(r, io.Discard, "", "v", 33); err == nil { t.Fatal("expected error from missing executable") } + old := osExecutableFn + osExecutableFn = func() (string, error) { return "", fmt.Errorf("no exe") } + t.Cleanup(func() { osExecutableFn = old }) + r, w, _ := os.Pipe() + _, _ = w.Write([]byte("x")) + _ = w.Close() + if err := runInTmuxParent(r, io.Discard, "", "v", 33); err == nil { + t.Fatal("expected error from missing executable") + } } func TestRunInTmuxParent_SplitError(t *testing.T) { - oldExec := osExecutableFn - osExecutableFn = func() (string, error) { return "/bin/hexai-tmux-action", nil } - oldSplit := splitRunFn - splitRunFn = func(_ tmux.SplitOpts, _ []string) error { return fmt.Errorf("split failed") } - t.Cleanup(func() { osExecutableFn = oldExec; splitRunFn = oldSplit }) - r, w, _ := os.Pipe(); _, _ = w.Write([]byte("x")); _ = w.Close() - if err := runInTmuxParent(r, io.Discard, "", "v", 33); err == nil { t.Fatal("expected split error") } + oldExec := osExecutableFn + osExecutableFn = func() (string, error) { return "/bin/hexai-tmux-action", nil } + oldSplit := splitRunFn + splitRunFn = func(_ tmux.SplitOpts, _ []string) error { return fmt.Errorf("split failed") } + t.Cleanup(func() { osExecutableFn = oldExec; splitRunFn = oldSplit }) + r, w, _ := os.Pipe() + _, _ = w.Write([]byte("x")) + _ = w.Close() + if err := runInTmuxParent(r, io.Discard, "", "v", 33); err == nil { + t.Fatal("expected split error") + } } func TestRunChild_StdoutAndOutfile(t *testing.T) { - // Outfile mode - dir := t.TempDir() - in := filepath.Join(dir, "in.txt") - out := filepath.Join(dir, "out.txt") - _ = os.WriteFile(in, []byte("sel"), 0o600) - oldRun := runFn - runFn = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error { _, _ = io.WriteString(w, "RESULT"); return nil } - t.Cleanup(func(){ runFn = oldRun }) - if err := runChild(context.Background(), in, out, io.Discard, io.Discard); err != nil { t.Fatalf("runChild: %v", err) } - b, _ := os.ReadFile(out) - if len(b) == 0 { t.Fatalf("expected some output") } - // Stdout mode - r, w, _ := os.Pipe() - if err := runChild(context.Background(), in, "", w, io.Discard); err != nil { t.Fatalf("runChild: %v", err) } - _ = w.Close(); buf, _ := io.ReadAll(r) - if len(buf) == 0 { t.Fatalf("expected stdout output") } + // Outfile mode + dir := t.TempDir() + in := filepath.Join(dir, "in.txt") + out := filepath.Join(dir, "out.txt") + _ = os.WriteFile(in, []byte("sel"), 0o600) + oldRun := runFn + runFn = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error { + _, _ = io.WriteString(w, "RESULT") + return nil + } + t.Cleanup(func() { runFn = oldRun }) + if err := runChild(context.Background(), in, out, io.Discard, io.Discard); err != nil { + t.Fatalf("runChild: %v", err) + } + b, _ := os.ReadFile(out) + if len(b) == 0 { + t.Fatalf("expected some output") + } + // Stdout mode + r, w, _ := os.Pipe() + if err := runChild(context.Background(), in, "", w, io.Discard); err != nil { + t.Fatalf("runChild: %v", err) + } + _ = w.Close() + buf, _ := io.ReadAll(r) + if len(buf) == 0 { + t.Fatalf("expected stdout output") + } } func TestWaitForFile_Timeout(t *testing.T) { - dir := t.TempDir() - p := filepath.Join(dir, "nope") - if err := waitForFile(p, 10*time.Millisecond); err == nil { t.Fatal("expected timeout error") } + dir := t.TempDir() + p := filepath.Join(dir, "nope") + if err := waitForFile(p, 10*time.Millisecond); err == nil { + t.Fatal("expected timeout error") + } } func TestOpenIO_InfileOutfile(t *testing.T) { - dir := t.TempDir() - in := filepath.Join(dir, "i"); out := filepath.Join(dir, "o") - _ = os.WriteFile(in, []byte("X"), 0o600) - r, w, ci, co, err := openIO(in, out) - if err != nil { t.Fatalf("openIO: %v", err) } - defer ci(); defer co() - if _, err := io.Copy(w, r); err != nil { t.Fatalf("copy: %v", err) } - b, _ := os.ReadFile(out) - if string(b) != "X" { t.Fatalf("got %q", string(b)) } + dir := t.TempDir() + in := filepath.Join(dir, "i") + out := filepath.Join(dir, "o") + _ = os.WriteFile(in, []byte("X"), 0o600) + r, w, ci, co, err := openIO(in, out) + if err != nil { + t.Fatalf("openIO: %v", err) + } + defer ci() + defer co() + if _, err := io.Copy(w, r); err != nil { + t.Fatalf("copy: %v", err) + } + b, _ := os.ReadFile(out) + if string(b) != "X" { + t.Fatalf("got %q", string(b)) + } } diff --git a/internal/hexaiaction/custom_action_test.go b/internal/hexaiaction/custom_action_test.go index 451a313..72cfbc4 100644 --- a/internal/hexaiaction/custom_action_test.go +++ b/internal/hexaiaction/custom_action_test.go @@ -1,39 +1,46 @@ package hexaiaction import ( - "bytes" - "context" - "testing" + "bytes" + "context" + "os" + "testing" - "codeberg.org/snonux/hexai/internal/appconfig" - "codeberg.org/snonux/hexai/internal/editor" - "codeberg.org/snonux/hexai/internal/llm" - "os" + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/editor" + "codeberg.org/snonux/hexai/internal/llm" ) type llmFake2 struct{} -func (llmFake2) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { return "DONE", nil } -func (llmFake2) Name() string { return "fake" } + +func (llmFake2) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { + return "DONE", nil +} +func (llmFake2) Name() string { return "fake" } func (llmFake2) DefaultModel() string { return "m" } func TestActionCustom_UsesEditorPrompt(t *testing.T) { - // Seam: choose custom, fake client, and fake editor - oldChoose := chooseActionFn - oldNew := newClientFromApp - chooseActionFn = func() (ActionKind, error) { return ActionCustom, nil } - newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return llmFake2{}, nil } - t.Cleanup(func(){ chooseActionFn = oldChoose; newClientFromApp = oldNew }) + // Seam: choose custom, fake client, and fake editor + oldChoose := chooseActionFn + oldNew := newClientFromApp + chooseActionFn = func() (ActionKind, error) { return ActionCustom, nil } + newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return llmFake2{}, nil } + t.Cleanup(func() { chooseActionFn = oldChoose; newClientFromApp = oldNew }) - oldRunEd := editor.RunEditor - editor.RunEditor = func(_ string, path string) error { - return os.WriteFile(path, []byte("make it done"), 0o600) - } - t.Cleanup(func(){ editor.RunEditor = oldRunEd }) - t.Setenv("HEXAI_EDITOR", "dummy") + oldRunEd := editor.RunEditor + editor.RunEditor = func(_ string, path string) error { + return os.WriteFile(path, []byte("make it done"), 0o600) + } + t.Cleanup(func() { editor.RunEditor = oldRunEd }) + t.Setenv("HEXAI_EDITOR", "dummy") - in := bytes.NewBufferString("some code") - var out bytes.Buffer - var errb bytes.Buffer - if err := Run(context.Background(), in, &out, &errb); err != nil { t.Fatalf("Run: %v", err) } - if out.String() == "" { t.Fatalf("expected output") } + in := bytes.NewBufferString("some code") + var out bytes.Buffer + var errb bytes.Buffer + if err := Run(context.Background(), in, &out, &errb); err != nil { + t.Fatalf("Run: %v", err) + } + if out.String() == "" { + t.Fatalf("expected output") + } } diff --git a/internal/hexaiaction/parse.go b/internal/hexaiaction/parse.go index 99e2b24..33fc4af 100644 --- a/internal/hexaiaction/parse.go +++ b/internal/hexaiaction/parse.go @@ -1,11 +1,11 @@ package hexaiaction import ( - "bufio" - "io" - "strings" + "bufio" + "io" + "strings" - "codeberg.org/snonux/hexai/internal/textutil" + "codeberg.org/snonux/hexai/internal/textutil" ) // ParseInput splits raw stdin into optional diagnostics and selection/code. diff --git a/internal/hexaiaction/parse_test.go b/internal/hexaiaction/parse_test.go index f81ab54..ba5cd96 100644 --- a/internal/hexaiaction/parse_test.go +++ b/internal/hexaiaction/parse_test.go @@ -77,6 +77,8 @@ func (f *fakeClient) Chat(_ context.Context, msgs []llm.Message, _ ...llm.Reques return f.out, f.err } +func (f *fakeClient) DefaultModel() string { return "m" } + func TestRuners_Prompts(t *testing.T) { cfg := appconfig.App{ PromptCodeActionRewriteSystem: "SYS-R", diff --git a/internal/hexaiaction/prompts_more_test.go b/internal/hexaiaction/prompts_more_test.go index 62abc97..9f5d6cb 100644 --- a/internal/hexaiaction/prompts_more_test.go +++ b/internal/hexaiaction/prompts_more_test.go @@ -1,19 +1,26 @@ package hexaiaction import ( - "context" - "strings" - "testing" + "context" + "strings" + "testing" - "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/llm" ) type simpleDoer struct{ s string } -func (d simpleDoer) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { return d.s, nil } +func (d simpleDoer) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { + return d.s, nil +} +func (d simpleDoer) DefaultModel() string { return "m" } func TestRunOnce_StripsFences(t *testing.T) { - got, err := runOnce(context.Background(), simpleDoer{"```\nok\n```"}, "SYS", "USER") - if err != nil { t.Fatalf("runOnce: %v", err) } - if strings.TrimSpace(got) != "ok" { t.Fatalf("got %q", got) } + got, err := runOnce(context.Background(), simpleDoer{"```\nok\n```"}, "SYS", "USER") + if err != nil { + t.Fatalf("runOnce: %v", err) + } + if strings.TrimSpace(got) != "ok" { + t.Fatalf("got %q", got) + } } diff --git a/internal/hexaiaction/run_more_test.go b/internal/hexaiaction/run_more_test.go index d7ab025..1c0eb51 100644 --- a/internal/hexaiaction/run_more_test.go +++ b/internal/hexaiaction/run_more_test.go @@ -1,26 +1,25 @@ package hexaiaction import ( - "bytes" - "context" - "os" - "testing" + "bytes" + "context" + "os" + "testing" ) // Covers the early error path in Run when no API key is available for the default provider. func TestRun_MissingAPIKey(t *testing.T) { - // Ensure no provider API keys in env - for _, k := range []string{"HEXAI_OPENAI_API_KEY", "OPENAI_API_KEY", "HEXAI_COPILOT_API_KEY", "COPILOT_API_KEY"} { - t.Setenv(k, "") - } - // Provide minimal stdin to get past empty input check (if reached) - in := bytes.NewBufferString("some selection text") - var out bytes.Buffer - var errBuf bytes.Buffer - // Expect an error due to missing OPENAI_API_KEY (default provider is openai) - if err := Run(context.Background(), in, &out, &errBuf); err == nil { - t.Fatal("expected error when API key is missing") - } - _ = os.Stderr + // Ensure no provider API keys in env + for _, k := range []string{"HEXAI_OPENAI_API_KEY", "OPENAI_API_KEY", "HEXAI_COPILOT_API_KEY", "COPILOT_API_KEY"} { + t.Setenv(k, "") + } + // Provide minimal stdin to get past empty input check (if reached) + in := bytes.NewBufferString("some selection text") + var out bytes.Buffer + var errBuf bytes.Buffer + // Expect an error due to missing OPENAI_API_KEY (default provider is openai) + if err := Run(context.Background(), in, &out, &errBuf); err == nil { + t.Fatal("expected error when API key is missing") + } + _ = os.Stderr } - diff --git a/internal/hexaiaction/run_seam_test.go b/internal/hexaiaction/run_seam_test.go index 0b8761f..bbec858 100644 --- a/internal/hexaiaction/run_seam_test.go +++ b/internal/hexaiaction/run_seam_test.go @@ -1,36 +1,46 @@ package hexaiaction import ( - "bytes" - "context" - "testing" + "bytes" + "context" + "testing" - "codeberg.org/snonux/hexai/internal/appconfig" - "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/llm" ) type llmFake struct{} -func (llmFake) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { return "OK", nil } +func (llmFake) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { + return "OK", nil +} func (llmFake) Name() string { return "fake" } func (llmFake) DefaultModel() string { return "model" } func TestRun_WithSeams_SkipAndRewrite(t *testing.T) { - // Seam: choose action to Skip first, then Rewrite - oldChoose := chooseActionFn - oldNew := newClientFromApp - t.Cleanup(func(){ chooseActionFn = oldChoose; newClientFromApp = oldNew }) - // 1) Skip -> echoes selection - chooseActionFn = func() (ActionKind, error) { return ActionSkip, nil } - newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return llmFake{}, nil } - var out bytes.Buffer - in := bytes.NewBufferString("some code") - if err := Run(context.Background(), in, &out, &out); err != nil { t.Fatalf("Run skip: %v", err) } - if out.String() != "some code" { t.Fatalf("skip out: %q", out.String()) } - // 2) Rewrite -> requires inline instruction - chooseActionFn = func() (ActionKind, error) { return ActionRewrite, nil } - out.Reset() - in = bytes.NewBufferString(";upper;\nhello") - if err := Run(context.Background(), in, &out, &out); err != nil { t.Fatalf("Run rewrite: %v", err) } - if out.String() == "" { t.Fatalf("expected non-empty rewrite output") } + // Seam: choose action to Skip first, then Rewrite + oldChoose := chooseActionFn + oldNew := newClientFromApp + t.Cleanup(func() { chooseActionFn = oldChoose; newClientFromApp = oldNew }) + // 1) Skip -> echoes selection + chooseActionFn = func() (ActionKind, error) { return ActionSkip, nil } + newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return llmFake{}, nil } + var out bytes.Buffer + in := bytes.NewBufferString("some code") + if err := Run(context.Background(), in, &out, &out); err != nil { + t.Fatalf("Run skip: %v", err) + } + if out.String() != "some code" { + t.Fatalf("skip out: %q", out.String()) + } + // 2) Rewrite -> requires inline instruction + chooseActionFn = func() (ActionKind, error) { return ActionRewrite, nil } + out.Reset() + in = bytes.NewBufferString(";upper;\nhello") + if err := Run(context.Background(), in, &out, &out); err != nil { + t.Fatalf("Run rewrite: %v", err) + } + if out.String() == "" { + t.Fatalf("expected non-empty rewrite output") + } } diff --git a/internal/hexaiaction/run_test.go b/internal/hexaiaction/run_test.go index 87fbfa8..e28bceb 100644 --- a/internal/hexaiaction/run_test.go +++ b/internal/hexaiaction/run_test.go @@ -1,51 +1,51 @@ package hexaiaction import ( - "context" - "strings" - "testing" + "context" + "strings" + "testing" - "codeberg.org/snonux/hexai/internal/appconfig" - "codeberg.org/snonux/hexai/internal/llm" + "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 + return f.out, nil } +func (f fakeDoer) DefaultModel() string { return "m" } 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) - } + 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) - } + 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 index 317a991..d07bb78 100644 --- a/internal/hexaiaction/tui.go +++ b/internal/hexaiaction/tui.go @@ -1,11 +1,11 @@ package hexaiaction import ( - "fmt" - "strings" + "fmt" + "strings" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" ) // item implements list.Item @@ -26,20 +26,20 @@ type model struct { } func newModel() model { - items := []list.Item{ - item{title: "Rewrite selection", desc: "", kind: ActionRewrite, hotkey: 'r'}, - item{title: "Simplify and improve", desc: "", kind: ActionSimplify, hotkey: 'i'}, - item{title: "Document code", desc: "", kind: ActionDocument, hotkey: 'c'}, - item{title: "Generate Go unit test(s)", desc: "", kind: ActionGoTest, hotkey: 't'}, - item{title: "Custom prompt", desc: "", kind: ActionCustom, hotkey: 'p'}, - item{title: "Skip", desc: "", kind: ActionSkip, hotkey: 's'}, - } - l := list.New(items, oneLineDelegate{}, 0, 0) - l.SetShowTitle(false) - l.SetShowHelp(false) - l.SetShowStatusBar(false) - l.SetFilteringEnabled(false) - return model{list: l} + items := []list.Item{ + item{title: "Rewrite selection", desc: "", kind: ActionRewrite, hotkey: 'r'}, + item{title: "Simplify and improve", desc: "", kind: ActionSimplify, hotkey: 'i'}, + item{title: "Document code", desc: "", kind: ActionDocument, hotkey: 'c'}, + item{title: "Generate Go unit test(s)", desc: "", kind: ActionGoTest, hotkey: 't'}, + item{title: "Custom prompt", desc: "", kind: ActionCustom, hotkey: 'p'}, + item{title: "Skip", desc: "", kind: ActionSkip, hotkey: 's'}, + } + l := list.New(items, oneLineDelegate{}, 0, 0) + l.SetShowTitle(false) + l.SetShowHelp(false) + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + return model{list: l} } func (m model) Init() tea.Cmd { return nil } @@ -57,43 +57,47 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.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", "i", "p": - 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 + 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", "i", "p": + 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 { diff --git a/internal/hexaiaction/tui_delegate.go b/internal/hexaiaction/tui_delegate.go index 0e5a68c..46d40cb 100644 --- a/internal/hexaiaction/tui_delegate.go +++ b/internal/hexaiaction/tui_delegate.go @@ -1,35 +1,35 @@ package hexaiaction import ( - "fmt" - "io" + "fmt" + "io" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "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) + 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) + 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 index 27881e4..4bdb359 100644 --- a/internal/hexaiaction/tui_delegate_test.go +++ b/internal/hexaiaction/tui_delegate_test.go @@ -1,32 +1,32 @@ package hexaiaction import ( - "bytes" - "regexp" - "testing" + "bytes" + "regexp" + "testing" - "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/list" ) func stripANSI(s string) string { - re := regexp.MustCompile(`\x1b\[[0-9;]*m`) - return re.ReplaceAllString(s, "") + 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) - } + 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 index 6f1debc..f467e53 100644 --- a/internal/hexaiaction/tui_test.go +++ b/internal/hexaiaction/tui_test.go @@ -1,57 +1,57 @@ package hexaiaction import ( - "testing" + "testing" - tea "github.com/charmbracelet/bubbletea" + 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) - } + 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) - } + 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) - } + 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) + } } func TestItemMethods(t *testing.T) { - it := item{title: "T", desc: "D", kind: ActionRewrite, hotkey: 'r'} - if it.Title() != "T" || it.Description() != "D" || it.FilterValue() != "T" { - t.Fatalf("item methods wrong: %+v", it) - } + it := item{title: "T", desc: "D", kind: ActionRewrite, hotkey: 'r'} + if it.Title() != "T" || it.Description() != "D" || it.FilterValue() != "T" { + t.Fatalf("item methods wrong: %+v", it) + } } func TestModelInitAndViewAndUpdate(t *testing.T) { - m := newModel() - if m.Init() != nil { - t.Fatalf("Init should return nil cmd") - } - if v := m.View(); v == "" { - t.Fatalf("View should not be empty before done") - } - // Window resize - nm, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) - if _, ok := nm.(model); !ok { - t.Fatalf("expected model after WindowSizeMsg") - } + m := newModel() + if m.Init() != nil { + t.Fatalf("Init should return nil cmd") + } + if v := m.View(); v == "" { + t.Fatalf("View should not be empty before done") + } + // Window resize + nm, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + if _, ok := nm.(model); !ok { + t.Fatalf("expected model after WindowSizeMsg") + } } diff --git a/internal/hexaiaction/types.go b/internal/hexaiaction/types.go index 7bc292e..d3cda4e 100644 --- a/internal/hexaiaction/types.go +++ b/internal/hexaiaction/types.go @@ -5,13 +5,13 @@ package hexaiaction type ActionKind string const ( - ActionSkip ActionKind = "skip" - ActionRewrite ActionKind = "rewrite" - ActionDiagnostics ActionKind = "diagnostics" - ActionDocument ActionKind = "document" - ActionGoTest ActionKind = "gotest" - ActionSimplify ActionKind = "simplify" - ActionCustom ActionKind = "custom" + ActionSkip ActionKind = "skip" + ActionRewrite ActionKind = "rewrite" + ActionDiagnostics ActionKind = "diagnostics" + ActionDocument ActionKind = "document" + ActionGoTest ActionKind = "gotest" + ActionSimplify ActionKind = "simplify" + ActionCustom ActionKind = "custom" ) // InputParts represents parsed stdin input for actions. diff --git a/internal/hexaicli/editor_integration_test.go b/internal/hexaicli/editor_integration_test.go index 1bebf75..7d53d1f 100644 --- a/internal/hexaicli/editor_integration_test.go +++ b/internal/hexaicli/editor_integration_test.go @@ -1,51 +1,62 @@ package hexaicli import ( - "bytes" - "context" - "os" - "testing" + "bytes" + "context" + "os" + "testing" - "codeberg.org/snonux/hexai/internal/appconfig" - "codeberg.org/snonux/hexai/internal/editor" - "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/editor" + "codeberg.org/snonux/hexai/internal/llm" ) type cliFake struct{} -func (cliFake) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { return "OUT", nil } -func (cliFake) Name() string { return "fake" } + +func (cliFake) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { + return "OUT", nil +} +func (cliFake) Name() string { return "fake" } func (cliFake) DefaultModel() string { return "m" } -func (cliFake) CodeCompletion(context.Context, string, string, int, string, float64) ([]string, error) { return nil, nil } +func (cliFake) CodeCompletion(context.Context, string, string, int, string, float64) ([]string, error) { + return nil, nil +} func TestRun_NoArgs_OpensEditor(t *testing.T) { - // Seam: fake client and editor - oldNew := newClientFromApp - newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return cliFake{}, nil } - t.Cleanup(func(){ newClientFromApp = oldNew }) - oldRun := editor.RunEditor - editor.RunEditor = func(_ string, path string) error { return os.WriteFile(path, []byte("PROMPT"), 0o600) } - t.Cleanup(func(){ editor.RunEditor = oldRun }) - t.Setenv("HEXAI_EDITOR", "dummy") + // Seam: fake client and editor + oldNew := newClientFromApp + newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return cliFake{}, nil } + t.Cleanup(func() { newClientFromApp = oldNew }) + oldRun := editor.RunEditor + editor.RunEditor = func(_ string, path string) error { return os.WriteFile(path, []byte("PROMPT"), 0o600) } + t.Cleanup(func() { editor.RunEditor = oldRun }) + t.Setenv("HEXAI_EDITOR", "dummy") - // Provide stdin selection - var stdout, stderr bytes.Buffer - if err := Run(context.Background(), nil, bytes.NewBufferString("SELECTION"), &stdout, &stderr); err != nil { - t.Fatalf("Run: %v", err) - } - if stdout.String() == "" { t.Fatalf("expected some output") } + // Provide stdin selection + var stdout, stderr bytes.Buffer + if err := Run(context.Background(), nil, bytes.NewBufferString("SELECTION"), &stdout, &stderr); err != nil { + t.Fatalf("Run: %v", err) + } + if stdout.String() == "" { + t.Fatalf("expected some output") + } } func TestRun_WithArgs_DoesNotOpenEditor(t *testing.T) { - // Provide args; still use fake client - oldNew := newClientFromApp - newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return cliFake{}, nil } - t.Cleanup(func(){ newClientFromApp = oldNew }) - // Stub editor and detect if called (should not be) - called := false - oldRun := editor.RunEditor - editor.RunEditor = func(_ string, _ string) error { called = true; return nil } - t.Cleanup(func(){ editor.RunEditor = oldRun }) - var stdout, stderr bytes.Buffer - if err := Run(context.Background(), []string{"ARG"}, bytes.NewBufferString("SEL"), &stdout, &stderr); err != nil { t.Fatalf("Run: %v", err) } - if called { t.Fatalf("editor should not be invoked when args provided") } + // Provide args; still use fake client + oldNew := newClientFromApp + newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return cliFake{}, nil } + t.Cleanup(func() { newClientFromApp = oldNew }) + // Stub editor and detect if called (should not be) + called := false + oldRun := editor.RunEditor + editor.RunEditor = func(_ string, _ string) error { called = true; return nil } + t.Cleanup(func() { editor.RunEditor = oldRun }) + var stdout, stderr bytes.Buffer + if err := Run(context.Background(), []string{"ARG"}, bytes.NewBufferString("SEL"), &stdout, &stderr); err != nil { + t.Fatalf("Run: %v", err) + } + if called { + t.Fatalf("editor should not be invoked when args provided") + } } diff --git a/internal/hexaicli/run_more_test.go b/internal/hexaicli/run_more_test.go index ae29563..bd88d56 100644 --- a/internal/hexaicli/run_more_test.go +++ b/internal/hexaicli/run_more_test.go @@ -1,44 +1,47 @@ package hexaicli import ( - "bytes" - "context" - "testing" + "bytes" + "context" + "testing" - "codeberg.org/snonux/hexai/internal/appconfig" - "codeberg.org/snonux/hexai/internal/llm" + "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 + 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 + 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()) - } + 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) } + 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 index 01baa96..00b79c1 100644 --- a/internal/hexailsp/run_more_test.go +++ b/internal/hexailsp/run_more_test.go @@ -1,42 +1,43 @@ package hexailsp import ( - "bytes" - "io" - "log" - "testing" + "bytes" + "io" + "log" + "testing" - "codeberg.org/snonux/hexai/internal/appconfig" - "codeberg.org/snonux/hexai/internal/lsp" + "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") - } + 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 index fd9b2c2..465be82 100644 --- a/internal/llm/provider_more2_test.go +++ b/internal/llm/provider_more2_test.go @@ -3,11 +3,10 @@ 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) - } + 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 index ae545c5..9bd39ee 100644 --- a/internal/llmutils/client.go +++ b/internal/llmutils/client.go @@ -1,35 +1,34 @@ package llmutils import ( - "os" - "strings" + "os" + "strings" - "codeberg.org/snonux/hexai/internal/appconfig" - "codeberg.org/snonux/hexai/internal/llm" + "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) + 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 index 9bb7ea2..2e20db3 100644 --- a/internal/llmutils/client_test.go +++ b/internal/llmutils/client_test.go @@ -1,28 +1,27 @@ package llmutils import ( - "os" - "testing" + "os" + "testing" - "codeberg.org/snonux/hexai/internal/appconfig" + "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) - } + 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") + 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/logging/logging_test.go b/internal/logging/logging_test.go index 716781e..31603f0 100644 --- a/internal/logging/logging_test.go +++ b/internal/logging/logging_test.go @@ -1,24 +1,23 @@ package logging import ( - "bytes" - "log" - "testing" + "bytes" + "log" + "testing" ) func TestPreviewAndLogfAndChatLogger(t *testing.T) { - var buf bytes.Buffer - Bind(log.New(&buf, "", 0)) - SetLogPreviewLimit(3) - if got := PreviewForLog("abcdef"); got != "abc…" { - t.Fatalf("preview wrong: %q", got) - } - Logf("unit ", "hello %s", "x") - cl := NewChatLogger("p") - cl.LogStart(true, "m", 0.5, 100, []string{"stop"}, []struct{ Role, Content string }{{"user", "hello"}}) - out := buf.String() - if out == "" || !bytes.Contains([]byte(out), []byte("start")) { - t.Fatalf("expected logged content, got %q", out) - } + var buf bytes.Buffer + Bind(log.New(&buf, "", 0)) + SetLogPreviewLimit(3) + if got := PreviewForLog("abcdef"); got != "abc…" { + t.Fatalf("preview wrong: %q", got) + } + Logf("unit ", "hello %s", "x") + cl := NewChatLogger("p") + cl.LogStart(true, "m", 0.5, 100, []string{"stop"}, []struct{ Role, Content string }{{"user", "hello"}}) + out := buf.String() + if out == "" || !bytes.Contains([]byte(out), []byte("start")) { + t.Fatalf("expected logged content, got %q", out) + } } - diff --git a/internal/lsp/codeaction_gotest_int_test.go b/internal/lsp/codeaction_gotest_int_test.go index 6bb1c45..04a73e0 100644 --- a/internal/lsp/codeaction_gotest_int_test.go +++ b/internal/lsp/codeaction_gotest_int_test.go @@ -1,26 +1,25 @@ package lsp import ( - "os" - "path/filepath" - "testing" + "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) - } + 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 index f4b0f00..7701a5e 100644 --- a/internal/lsp/coverage_add_test.go +++ b/internal/lsp/coverage_add_test.go @@ -1,103 +1,103 @@ package lsp import ( - "encoding/json" - "testing" + "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) - } + 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) - } + 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") - } + 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) - } + 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") - } + 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") - } + 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) - } + 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/lsp/handlers.go b/internal/lsp/handlers.go index 420a694..e85065b 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -2,9 +2,9 @@ package lsp import ( - "encoding/json" - "fmt" - "strings" + "encoding/json" + "fmt" + "strings" ) func (s *Server) handle(req Request) { @@ -26,14 +26,14 @@ func (s *Server) handle(req Request) { // 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 + 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 @@ -46,51 +46,51 @@ func instructionFromSelection(sel string) (string, string) { // - # 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 := 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 - } - // 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 + 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 + } + // 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 } // diagnosticsInRange parses the CodeAction context and returns diagnostics diff --git a/internal/lsp/handlers_codeaction.go b/internal/lsp/handlers_codeaction.go index d8dba38..e1c2720 100644 --- a/internal/lsp/handlers_codeaction.go +++ b/internal/lsp/handlers_codeaction.go @@ -31,40 +31,40 @@ func (s *Server) handleCodeAction(req Request) { } sel := extractRangeText(d, p.Range) - actions := make([]CodeAction, 0, 5) + actions := make([]CodeAction, 0, 5) if a := s.buildRewriteCodeAction(p, sel); a != nil { actions = append(actions, *a) } if a := s.buildDiagnosticsCodeAction(p, sel); a != nil { actions = append(actions, *a) } - if a := s.buildDocumentCodeAction(p, sel); a != nil { - actions = append(actions, *a) - } - if a := s.buildGoUnitTestCodeAction(p); a != nil { - actions = append(actions, *a) - } - if a := s.buildSimplifyCodeAction(p, sel); a != nil { - actions = append(actions, *a) - } + if a := s.buildDocumentCodeAction(p, sel); a != nil { + actions = append(actions, *a) + } + if a := s.buildGoUnitTestCodeAction(p); a != nil { + actions = append(actions, *a) + } + if a := s.buildSimplifyCodeAction(p, sel); a != nil { + actions = append(actions, *a) + } if len(req.ID) != 0 { s.reply(req.ID, actions, nil) } } func (s *Server) buildSimplifyCodeAction(p CodeActionParams, sel string) *CodeAction { - if strings.TrimSpace(sel) == "" { - return nil - } - payload := struct { - Type string `json:"type"` - URI string `json:"uri"` - Range Range `json:"range"` - Selection string `json:"selection"` - }{Type: "simplify", URI: p.TextDocument.URI, Range: p.Range, Selection: sel} - raw, _ := json.Marshal(payload) - ca := CodeAction{Title: "Hexai: simplify and improve", Kind: "refactor", Data: raw} - return &ca + if strings.TrimSpace(sel) == "" { + return nil + } + payload := struct { + Type string `json:"type"` + URI string `json:"uri"` + Range Range `json:"range"` + Selection string `json:"selection"` + }{Type: "simplify", URI: p.TextDocument.URI, Range: p.Range, Selection: sel} + raw, _ := json.Marshal(payload) + ca := CodeAction{Title: "Hexai: simplify and improve", Kind: "refactor", Data: raw} + return &ca } func (s *Server) buildRewriteCodeAction(p CodeActionParams, sel string) *CodeAction { @@ -115,7 +115,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { if err := json.Unmarshal(ca.Data, &payload); err != nil { return ca, false } - switch payload.Type { + switch payload.Type { case "rewrite": sys := s.promptRewriteSystem user := renderTemplate(s.promptRewriteUser, map[string]string{"instruction": payload.Instruction, "selection": payload.Selection}) @@ -123,7 +123,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { defer cancel() messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} opts := s.llmRequestOpts() - if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { + if text, err := s.chatWithStats(ctx, messages, opts...); err == nil { if out := stripCodeFences(strings.TrimSpace(text)); out != "" { edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} ca.Edit = &edit @@ -148,7 +148,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { defer cancel() messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} opts := s.llmRequestOpts() - if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { + if text, err := s.chatWithStats(ctx, messages, opts...); err == nil { if out := stripCodeFences(strings.TrimSpace(text)); out != "" { edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} ca.Edit = &edit @@ -164,7 +164,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { defer cancel() messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} opts := s.llmRequestOpts() - if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { + if text, err := s.chatWithStats(ctx, messages, opts...); err == nil { if out := stripCodeFences(strings.TrimSpace(text)); out != "" { edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} ca.Edit = &edit @@ -173,34 +173,34 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { } else { logging.Logf("lsp ", "codeAction document llm error: %v", err) } - case "go_test": - if edit, jumpURI, jumpRange, ok := s.resolveGoTest(payload.URI, payload.Range.Start); ok { - ca.Edit = &edit - // After edit is applied, ask client to jump to new test function - ca.Command = &Command{Title: "Jump to generated test", Command: "hexai.showDocument", Arguments: []any{jumpURI, jumpRange}} - // Also send a server-initiated showDocument shortly after resolve to cover - // clients that do not execute commands from code actions. - s.deferShowDocument(jumpURI, jumpRange) - return ca, true - } - case "simplify": - sys := s.promptRewriteSystem - // Reuse rewrite user template with a fixed instruction - user := renderTemplate(s.promptRewriteUser, map[string]string{"instruction": "Simplify and improve the code while preserving behavior. Return only the improved code.", "selection": payload.Selection}) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - opts := s.llmRequestOpts() - if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { - if out := stripCodeFences(strings.TrimSpace(text)); out != "" { - edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} - ca.Edit = &edit - return ca, true - } - } else { - logging.Logf("lsp ", "codeAction simplify llm error: %v", err) - } - } + case "go_test": + if edit, jumpURI, jumpRange, ok := s.resolveGoTest(payload.URI, payload.Range.Start); ok { + ca.Edit = &edit + // After edit is applied, ask client to jump to new test function + ca.Command = &Command{Title: "Jump to generated test", Command: "hexai.showDocument", Arguments: []any{jumpURI, jumpRange}} + // Also send a server-initiated showDocument shortly after resolve to cover + // clients that do not execute commands from code actions. + s.deferShowDocument(jumpURI, jumpRange) + return ca, true + } + case "simplify": + sys := s.promptRewriteSystem + // Reuse rewrite user template with a fixed instruction + user := renderTemplate(s.promptRewriteUser, map[string]string{"instruction": "Simplify and improve the code while preserving behavior. Return only the improved code.", "selection": payload.Selection}) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + opts := s.llmRequestOpts() + if text, err := s.chatWithStats(ctx, messages, opts...); err == nil { + if out := stripCodeFences(strings.TrimSpace(text)); out != "" { + edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} + ca.Edit = &edit + return ca, true + } + } else { + logging.Logf("lsp ", "codeAction simplify llm error: %v", err) + } + } return ca, false } @@ -508,7 +508,7 @@ func (s *Server) generateGoTestFunction(funcCode string) string { defer cancel() messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} opts := s.llmRequestOpts() - if out, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { + if out, err := s.chatWithStats(ctx, messages, opts...); err == nil { cleaned := strings.TrimSpace(stripCodeFences(out)) if cleaned != "" { return cleaned diff --git a/internal/lsp/handlers_completion.go b/internal/lsp/handlers_completion.go index 06c44fb..14c5f3e 100644 --- a/internal/lsp/handlers_completion.go +++ b/internal/lsp/handlers_completion.go @@ -250,8 +250,14 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, if !s.waitForThrottle(ctx2) { return nil, false } + // Count approximate payload sizes: prompt+after sent; first suggestion received + sentBytes := len(prompt) + len(after) suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, lang, temp) if err == nil && len(suggestions) > 0 { + // Update counters and heartbeat + s.incSentCounters(sentBytes) + s.incRecvCounters(len(suggestions[0])) + s.logLLMStats() cleaned := strings.TrimSpace(suggestions[0]) if cleaned != "" { cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned) @@ -272,6 +278,9 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, } } else if err != nil { logging.Logf("lsp ", "completion path=codex error=%v (falling back to chat)", err) + // Still emit a heartbeat for visibility, even on error + s.incSentCounters(sentBytes) + s.logLLMStats() } return nil, false } diff --git a/internal/lsp/handlers_document.go b/internal/lsp/handlers_document.go index f3648b2..14642c7 100644 --- a/internal/lsp/handlers_document.go +++ b/internal/lsp/handlers_document.go @@ -162,7 +162,7 @@ func (s *Server) detectAndHandleChat(uri string) { msgs := append([]llm.Message{{Role: "system", Content: sys}}, history...) opts := s.llmRequestOpts() logging.Logf("lsp ", "chat llm=requesting model=%s", s.llmClient.DefaultModel()) - text, err := s.llmClient.Chat(ctx, msgs, opts...) + text, err := s.chatWithStats(ctx, msgs, opts...) if err != nil { logging.Logf("lsp ", "chat llm error: %v", err) return diff --git a/internal/lsp/handlers_init_more_test.go b/internal/lsp/handlers_init_more_test.go index 230c773..8b8aa55 100644 --- a/internal/lsp/handlers_init_more_test.go +++ b/internal/lsp/handlers_init_more_test.go @@ -1,15 +1,14 @@ package lsp import ( - "bytes" - "log" - "testing" + "bytes" + "log" + "testing" ) func TestHandleInitialized_Logs(t *testing.T) { - // Minimal server with a logger; just ensure it doesn't panic. - var buf bytes.Buffer - s := NewServer(bytes.NewBuffer(nil), &buf, log.New(&buf, "", 0), ServerOptions{}) - s.handleInitialized() + // Minimal server with a logger; just ensure it doesn't panic. + var buf bytes.Buffer + s := NewServer(bytes.NewBuffer(nil), &buf, log.New(&buf, "", 0), ServerOptions{}) + s.handleInitialized() } - diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 796d6f4..97d7de7 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -78,11 +78,11 @@ type Server struct { promptDocumentSystem string promptRewriteUser string promptDiagnosticsUser string - promptDocumentUser string - promptGoTestSystem string - promptGoTestUser string - promptSimplifySystem string - promptSimplifyUser string + promptDocumentUser string + promptGoTestSystem string + promptGoTestUser string + promptSimplifySystem string + promptSimplifyUser string } // ServerOptions collects configuration for NewServer to avoid long parameter lists. @@ -121,10 +121,10 @@ type ServerOptions struct { PromptRewriteUser string PromptDiagnosticsUser string PromptDocumentUser string - PromptGoTestSystem string - PromptGoTestUser string - PromptSimplifySystem string - PromptSimplifyUser string + PromptGoTestSystem string + PromptGoTestUser string + PromptSimplifySystem string + PromptSimplifyUser string } func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server { @@ -203,11 +203,11 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) s.promptDocumentSystem = opts.PromptDocumentSystem s.promptRewriteUser = opts.PromptRewriteUser s.promptDiagnosticsUser = opts.PromptDiagnosticsUser - s.promptDocumentUser = opts.PromptDocumentUser - s.promptGoTestSystem = opts.PromptGoTestSystem - s.promptGoTestUser = opts.PromptGoTestUser - s.promptSimplifySystem = opts.PromptSimplifySystem - s.promptSimplifyUser = opts.PromptSimplifyUser + s.promptDocumentUser = opts.PromptDocumentUser + s.promptGoTestSystem = opts.PromptGoTestSystem + s.promptGoTestUser = opts.PromptGoTestUser + s.promptSimplifySystem = opts.PromptSimplifySystem + s.promptSimplifyUser = opts.PromptSimplifyUser // Assign package-level inline trigger chars for free helper functions if s.inlineOpen != "" { diff --git a/internal/testutil/fixtures_test.go b/internal/testutil/fixtures_test.go index 8c603d8..7c7f239 100644 --- a/internal/testutil/fixtures_test.go +++ b/internal/testutil/fixtures_test.go @@ -3,7 +3,10 @@ package testutil import "testing" func TestFixtures_ZeroCovTargets(t *testing.T) { - if MarkdownCodeFence() == "" { t.Fatal("MarkdownCodeFence empty") } - if MalformedJSON() == "" { t.Fatal("MalformedJSON empty") } + if MarkdownCodeFence() == "" { + t.Fatal("MarkdownCodeFence empty") + } + if MalformedJSON() == "" { + t.Fatal("MalformedJSON empty") + } } - diff --git a/internal/textutil/textutil.go b/internal/textutil/textutil.go index 7ef2680..1e9da3c 100644 --- a/internal/textutil/textutil.go +++ b/internal/textutil/textutil.go @@ -4,111 +4,125 @@ 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 + 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 + 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 + 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 + 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 + 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 index 3a8cd90..cfe8de8 100644 --- a/internal/textutil/textutil_test.go +++ b/internal/textutil/textutil_test.go @@ -1,87 +1,89 @@ package textutil import ( - "regexp" - "strings" - "testing" + "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") } + 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) - } - } + 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) - } - } - } + 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) - } + // 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") - } + 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") - } + 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") + } } diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 63b5660..6d75a44 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -1,18 +1,20 @@ package tmux import ( - "os" - "os/exec" - "strconv" - "strings" + "os" + "os/exec" + "strconv" + "strings" ) // Available reports whether tmux is available and we appear to be in a tmux session. func Available() bool { return HasBinary() && InSession() } // HasBinary reports whether the tmux binary is on PATH. -var lookPath = exec.LookPath -var command = exec.Command +var ( + lookPath = exec.LookPath + command = exec.Command +) func HasBinary() bool { _, err := lookPath("tmux"); return err == nil } @@ -47,8 +49,8 @@ func SplitRun(opts SplitOpts, argv []string) error { // tmux takes a single command string. Use a conservative shell join. cmdStr := shellJoin(argv) args = append(args, cmdStr) - c := command("tmux", args...) - return c.Run() + c := command("tmux", args...) + return c.Run() } // shellJoin quotes argv elements for safe use in a single shell command string. diff --git a/internal/tmux/tmux_test.go b/internal/tmux/tmux_test.go index b18c8a3..4db2e4a 100644 --- a/internal/tmux/tmux_test.go +++ b/internal/tmux/tmux_test.go @@ -1,82 +1,85 @@ package tmux import ( - "errors" - "os" - "os/exec" - "testing" + "errors" + "os" + "os/exec" + "testing" ) func TestInSession(t *testing.T) { - t.Setenv("TMUX", "/tmp/tmux-123,123,0") - if !InSession() { - t.Fatal("expected InSession true when TMUX is set") - } - t.Setenv("TMUX", "") - if InSession() { - t.Fatal("expected InSession false when TMUX is empty") - } + t.Setenv("TMUX", "/tmp/tmux-123,123,0") + if !InSession() { + t.Fatal("expected InSession true when TMUX is set") + } + t.Setenv("TMUX", "") + if InSession() { + t.Fatal("expected InSession false when TMUX is empty") + } } func TestHasBinary_UsesLookPath(t *testing.T) { - old := lookPath - t.Cleanup(func() { lookPath = old }) - lookPath = func(file string) (string, error) { return "/bin/tmux", nil } - if !HasBinary() { - t.Fatal("expected HasBinary true when lookPath succeeds") - } - lookPath = func(file string) (string, error) { return "", errors.New("nope") } - if HasBinary() { - t.Fatal("expected HasBinary false when lookPath fails") - } + old := lookPath + t.Cleanup(func() { lookPath = old }) + lookPath = func(file string) (string, error) { return "/bin/tmux", nil } + if !HasBinary() { + t.Fatal("expected HasBinary true when lookPath succeeds") + } + lookPath = func(file string) (string, error) { return "", errors.New("nope") } + if HasBinary() { + t.Fatal("expected HasBinary false when lookPath fails") + } } func TestSplitRun_AssemblesArgs(t *testing.T) { - captured := struct{ name string; args []string }{} - oldCmd := command - t.Cleanup(func() { command = oldCmd }) - command = func(name string, args ...string) *exec.Cmd { - captured.name = name - captured.args = append([]string(nil), args...) - // Use a benign command that exits 0 - return exec.Command("true") - } - opts := SplitOpts{Target: ":.", Vertical: true, Percent: 40} - argv := []string{"/path/to/bin", "-flag", "value with spaces", "and'quote"} - if err := SplitRun(opts, argv); err != nil { - t.Fatalf("SplitRun error: %v", err) - } - if captured.name != "tmux" { - t.Fatalf("expected tmux, got %q", captured.name) - } - wantFlags := map[string]bool{"split-window": true, "-v": true, "-p": true, "40": true, "-t": true, ":.": true} - for _, a := range captured.args[:len(captured.args)-1] { - if wantFlags[a] { - delete(wantFlags, a) - } - } - if len(wantFlags) != 0 { - t.Fatalf("missing expected flags: %v", wantFlags) - } - last := captured.args[len(captured.args)-1] - if last == "" || last == argv[0] { - t.Fatalf("expected last arg to be joined command string, got %q", last) - } - _ = os.Unsetenv("TMUX") + captured := struct { + name string + args []string + }{} + oldCmd := command + t.Cleanup(func() { command = oldCmd }) + command = func(name string, args ...string) *exec.Cmd { + captured.name = name + captured.args = append([]string(nil), args...) + // Use a benign command that exits 0 + return exec.Command("true") + } + opts := SplitOpts{Target: ":.", Vertical: true, Percent: 40} + argv := []string{"/path/to/bin", "-flag", "value with spaces", "and'quote"} + if err := SplitRun(opts, argv); err != nil { + t.Fatalf("SplitRun error: %v", err) + } + if captured.name != "tmux" { + t.Fatalf("expected tmux, got %q", captured.name) + } + wantFlags := map[string]bool{"split-window": true, "-v": true, "-p": true, "40": true, "-t": true, ":.": true} + for _, a := range captured.args[:len(captured.args)-1] { + if wantFlags[a] { + delete(wantFlags, a) + } + } + if len(wantFlags) != 0 { + t.Fatalf("missing expected flags: %v", wantFlags) + } + last := captured.args[len(captured.args)-1] + if last == "" || last == argv[0] { + t.Fatalf("expected last arg to be joined command string, got %q", last) + } + _ = os.Unsetenv("TMUX") } func TestAvailable(t *testing.T) { - oldLook := lookPath - t.Cleanup(func() { lookPath = oldLook }) - // Present binary + TMUX set -> available - lookPath = func(file string) (string, error) { return "/bin/tmux", nil } - t.Setenv("TMUX", "/tmp/tmux-1,1,1") - if !Available() { - t.Fatal("expected Available true with TMUX + binary") - } - // No binary -> not available - lookPath = func(file string) (string, error) { return "", errors.New("nope") } - if Available() { - t.Fatal("expected Available false without binary") - } + oldLook := lookPath + t.Cleanup(func() { lookPath = oldLook }) + // Present binary + TMUX set -> available + lookPath = func(file string) (string, error) { return "/bin/tmux", nil } + t.Setenv("TMUX", "/tmp/tmux-1,1,1") + if !Available() { + t.Fatal("expected Available true with TMUX + binary") + } + // No binary -> not available + lookPath = func(file string) (string, error) { return "", errors.New("nope") } + if Available() { + t.Fatal("expected Available false without binary") + } } |
