first commit
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
// Package clipboard writes text to the system clipboard by shelling out to the
|
||||
// platform's clipboard utility (US-009, #125). It deliberately avoids any cgo or
|
||||
// third-party dependency: it probes for the standard command-line tools
|
||||
// (pbcopy on macOS, wl-copy / xclip / xsel on Linux) and pipes text to the first
|
||||
// one found. When no utility is available it reports ErrUnavailable so the
|
||||
// caller can degrade gracefully (e.g. print the text instead).
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrUnavailable is returned by Copy when no supported clipboard utility is
|
||||
// found on the host, so the caller can fall back to printing the content.
|
||||
var ErrUnavailable = errors.New("clipboard: no clipboard utility available")
|
||||
|
||||
// candidate is a clipboard-writing command: the executable plus the args that
|
||||
// make it read the payload from stdin.
|
||||
type candidate struct {
|
||||
name string
|
||||
args []string
|
||||
}
|
||||
|
||||
// candidates returns the clipboard-write commands to try, in priority order,
|
||||
// for the current OS. On macOS pbcopy is always present; on Linux the Wayland
|
||||
// tool is preferred, then the two common X11 tools.
|
||||
func candidates() []candidate {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return []candidate{{name: "pbcopy"}}
|
||||
case "windows":
|
||||
return []candidate{{name: "clip"}}
|
||||
default: // linux and other unixes
|
||||
return []candidate{
|
||||
{name: "wl-copy"},
|
||||
{name: "xclip", args: []string{"-selection", "clipboard"}},
|
||||
{name: "xsel", args: []string{"--clipboard", "--input"}},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy writes text to the system clipboard using the first available platform
|
||||
// utility. It returns ErrUnavailable if none is found (so callers can fall back
|
||||
// to printing), or the underlying exec error if a utility was found but failed.
|
||||
func Copy(text string) error {
|
||||
for _, c := range candidates() {
|
||||
path, err := exec.LookPath(c.name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cmd := exec.Command(path, c.args...)
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return ErrUnavailable
|
||||
}
|
||||
|
||||
// Available reports whether a clipboard utility is present, without writing
|
||||
// anything. Useful for a caller that wants to phrase its output differently when
|
||||
// it knows the copy will fail.
|
||||
func Available() bool {
|
||||
for _, c := range candidates() {
|
||||
if _, err := exec.LookPath(c.name); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package clipboard
|
||||
|
||||
// Tests for the clipboard helper (US-009, #125). Copy shells out to a platform
|
||||
// utility, so we cannot assert the OS clipboard actually changed in a hermetic
|
||||
// test. Instead we verify the graceful-degradation contract: with no utility on
|
||||
// PATH, Copy returns ErrUnavailable (so the REPL can fall back to printing) and
|
||||
// Available reports false. We control the environment by pointing PATH at an
|
||||
// empty temp dir.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCopyUnavailableWhenNoUtility verifies Copy returns ErrUnavailable and
|
||||
// Available returns false when PATH holds no clipboard utility. This is the
|
||||
// contract the REPL relies on to degrade to printing.
|
||||
func TestCopyUnavailableWhenNoUtility(t *testing.T) {
|
||||
// Point PATH at an empty dir so exec.LookPath finds no pbcopy/xclip/etc.
|
||||
empty := t.TempDir()
|
||||
t.Setenv("PATH", empty)
|
||||
|
||||
if Available() {
|
||||
t.Error("Available() = true with empty PATH, want false")
|
||||
}
|
||||
err := Copy("hello")
|
||||
if !errors.Is(err, ErrUnavailable) {
|
||||
t.Errorf("Copy err = %v, want ErrUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopyUsesUtilityOnPath verifies Copy invokes a discovered utility and
|
||||
// succeeds when the utility exits 0. We plant a fake executable named after the
|
||||
// current platform's first candidate on PATH and confirm Copy returns nil.
|
||||
func TestCopyUsesUtilityOnPath(t *testing.T) {
|
||||
cands := candidates()
|
||||
if len(cands) == 0 {
|
||||
t.Skip("no clipboard candidates for this platform")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
name := cands[0].name
|
||||
if os.PathSeparator == '\\' {
|
||||
t.Skip("fake-executable planting not supported on Windows in this test")
|
||||
}
|
||||
// A trivial script that drains stdin and exits 0, using only shell builtins
|
||||
// (the test empties PATH, so external commands like `cat` are unavailable).
|
||||
script := "#!/bin/sh\nwhile read _; do :; done\nexit 0\n"
|
||||
path := dir + string(os.PathSeparator) + name
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write fake %s: %v", name, err)
|
||||
}
|
||||
t.Setenv("PATH", dir)
|
||||
|
||||
if !Available() {
|
||||
t.Fatal("Available() = false after planting fake utility")
|
||||
}
|
||||
if err := Copy("payload"); err != nil {
|
||||
t.Errorf("Copy err = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user