diff --git a/.doc/doki/doc/ideas/plugins.md b/.doc/doki/doc/ideas/plugins.md index 1dba1af..be1559d 100644 --- a/.doc/doki/doc/ideas/plugins.md +++ b/.doc/doki/doc/ideas/plugins.md @@ -3,7 +3,7 @@ - [Assign to me](#assign-to-me--plugin-action) - [Add tag to task](#add-tag-to-task--plugin-action) - [Custom status + reject action](#custom-status--reject-action) -- [Implement with Claude](#implement-with-claude--pipe-action) +- [Implement with Claude Code](#implement-with-claude-code--pipe-action) - [Search all tikis](#search-all-tikis--single-lane-plugin) - [Quick assign](#quick-assign--lane-based-assignment) - [Stale task detection](#stale-task-detection--time-trigger--plugin) diff --git a/.doc/doki/doc/ruki/examples.md b/.doc/doki/doc/ruki/examples.md index 7c55916..cbafdce 100644 --- a/.doc/doki/doc/ruki/examples.md +++ b/.doc/doki/doc/ruki/examples.md @@ -136,11 +136,12 @@ Use `call(...)` in a value: create title=call("echo hi") ``` -Pipe select results to a shell command: +Pipe select results to a shell command or clipboard: ```sql -select id where id = id() | run("echo $1 | pbcopy") select id, title where status = "done" | run("myscript $1 $2") +select id where id = id() | clipboard() +select description where id = id() | clipboard() ``` ## Before triggers diff --git a/.doc/doki/doc/ruki/operators-and-builtins.md b/.doc/doki/doc/ruki/operators-and-builtins.md index fafb0c6..e9f3234 100644 --- a/.doc/doki/doc/ruki/operators-and-builtins.md +++ b/.doc/doki/doc/ruki/operators-and-builtins.md @@ -246,7 +246,7 @@ after update where new.status = "in progress" run("echo hello") ## Shell-related forms -`ruki` includes three shell-related forms: +`ruki` includes four shell-related forms: **`call(...)`** — a string-returning expression @@ -267,11 +267,20 @@ after update where new.status = "in progress" run("echo hello") - `|` is a statement suffix, not an operator - Example: `select id, title where status = "done" | run("myscript $1 $2")` -Pipe `| run(...)` is distinct from trigger `run()`: +**`| clipboard()` on select** — a pipe suffix -| Form | Context | Field access | Substitution | +- Copies selected field values to the system clipboard +- Fields within a row are tab-separated; rows are newline-separated +- Takes no arguments — grammar enforces empty parentheses +- Cross-platform via `atotto/clipboard` (macOS, Linux, Windows) +- Example: `select id where id = id() | clipboard()` + +Comparison of pipe targets and trigger actions: + +| Form | Context | Field access | Behavior | |---|---|---|---| -| trigger `run()` | after-trigger action | `old.`, `new.` allowed | expression evaluation | -| pipe `| run()` | select suffix | field refs disallowed in command | positional args `$1`, `$2` | +| trigger `run()` | after-trigger action | `old.`, `new.` allowed | shell execution via expression evaluation | +| pipe `\| run()` | select suffix | field refs disallowed in command | shell execution with positional args `$1`, `$2` | +| pipe `\| clipboard()` | select suffix | n/a (no arguments) | writes rows to system clipboard | See [Semantics](semantics.md#pipe-actions-on-select) for pipe evaluation model. diff --git a/.doc/doki/doc/ruki/quick-start.md b/.doc/doki/doc/ruki/quick-start.md index c7afd79..05df155 100644 --- a/.doc/doki/doc/ruki/quick-start.md +++ b/.doc/doki/doc/ruki/quick-start.md @@ -63,13 +63,14 @@ delete where id = "TIKI-ABC123" delete where status = "cancelled" and "old" in tags ``` -`select` may pipe results to a shell command: +`select` may pipe results to a shell command or to the clipboard: ```sql select id, title where status = "done" | run("myscript $1 $2") +select id where id = id() | clipboard() ``` -Each row executes the command with field values as positional arguments (`$1`, `$2`). +`| run(...)` executes the command for each row with field values as positional arguments (`$1`, `$2`). `| clipboard()` copies the selected fields to the system clipboard. ## Conditions and expressions diff --git a/.doc/doki/doc/ruki/semantics.md b/.doc/doki/doc/ruki/semantics.md index 4190e2b..3361c97 100644 --- a/.doc/doki/doc/ruki/semantics.md +++ b/.doc/doki/doc/ruki/semantics.md @@ -173,8 +173,11 @@ For the detailed type rules and built-ins, see [Types And Values](types-and-valu ```text select where | run() +select where | clipboard() ``` +### `| run(...)` — shell execution + Evaluation model: - The `select` runs first, producing zero or more rows. @@ -203,3 +206,30 @@ myscript "TIKI-ABC123" "Fix bug" Pipe `| run(...)` on select is distinct from trigger `run()` actions. See [Triggers](triggers.md) for the difference. +### `| clipboard()` — copy to clipboard + +Evaluation model: + +- The `select` runs first, producing zero or more rows. +- The selected field values are written to the system clipboard. +- Fields within a row are **tab-separated**; rows are **newline-separated**. +- Uses `atotto/clipboard` internally — works on macOS, Linux (requires `xclip` or `xsel`), and Windows. + +Rules: + +- Explicit field names are required — same restriction as `| run(...)`. +- `clipboard()` takes no arguments — the grammar enforces empty parentheses. + +Examples: + +```sql +select id where id = id() | clipboard() +select id, title where status = "done" | clipboard() +``` + +For a single task with `id = "TIKI-ABC123"` and `title = "Fix bug"`, the clipboard receives: + +```text +TIKI-ABC123 Fix bug +``` + diff --git a/.doc/doki/doc/ruki/syntax.md b/.doc/doki/doc/ruki/syntax.md index 1adfed2..aadd84a 100644 --- a/.doc/doki/doc/ruki/syntax.md +++ b/.doc/doki/doc/ruki/syntax.md @@ -50,7 +50,8 @@ statement = selectStmt | createStmt | updateStmt | deleteStmt ; selectStmt = "select" [ fieldList | "*" ] [ "where" condition ] [ orderBy ] [ pipeAction ] ; fieldList = identifier { "," identifier } ; -pipeAction = "|" runAction ; +pipeAction = "|" ( runAction | clipboardAction ) ; +clipboardAction = "clipboard" "(" ")" ; createStmt = "create" assignment { assignment } ; orderBy = "order" "by" sortField { "," sortField } ; diff --git a/.doc/doki/doc/ruki/triggers.md b/.doc/doki/doc/ruki/triggers.md index de232f2..47baebe 100644 --- a/.doc/doki/doc/ruki/triggers.md +++ b/.doc/doki/doc/ruki/triggers.md @@ -248,7 +248,7 @@ The maximum cascade depth is **8**. Termination is graceful — a warning is log ## The run() action -**Note:** This section describes trigger `run()` actions. For the pipe syntax `| run(...)` on select statements, see [Semantics](semantics.md#pipe-actions-on-select) and [Operators And Built-ins](operators-and-builtins.md#shell-related-forms). +**Note:** This section describes trigger `run()` actions. For the pipe syntax `| run(...)` and `| clipboard()` on select statements, see [Semantics](semantics.md#pipe-actions-on-select) and [Operators And Built-ins](operators-and-builtins.md#shell-related-forms). Both `run()` and `clipboard()` are pipe-only targets — they cannot appear as trigger actions. When an after-trigger uses `run(...)`, the command expression is evaluated to a string, then executed: diff --git a/.doc/doki/doc/ruki/validation-and-errors.md b/.doc/doki/doc/ruki/validation-and-errors.md index 27f729c..9205352 100644 --- a/.doc/doki/doc/ruki/validation-and-errors.md +++ b/.doc/doki/doc/ruki/validation-and-errors.md @@ -218,21 +218,23 @@ select where count(select where status = "done" order by priority) >= 1 ## Pipe validation errors -Pipe actions on `select` have several restrictions: +Pipe actions (`| run(...)` and `| clipboard()`) on `select` have several restrictions: `select *` with pipe: ```sql select * where status = "done" | run("echo $1") +select * where status = "done" | clipboard() ``` Bare `select` with pipe: ```sql select | run("echo $1") +select | clipboard() ``` -Both are rejected because explicit field names are required when using a pipe. +Both are rejected because explicit field names are required when using a pipe. This applies to both `run()` and `clipboard()` targets. Non-string command: diff --git a/config/default_workflow.yaml b/config/default_workflow.yaml index 424d4fa..d53d832 100644 --- a/config/default_workflow.yaml +++ b/config/default_workflow.yaml @@ -25,6 +25,12 @@ views: - key: "a" label: "Assign to me" action: update where id = id() set assignee=user() + - key: "y" + label: "Copy ID" + action: select id where id = id() | clipboard() + - key: "Y" + label: "Copy content" + action: select title, description where id = id() | clipboard() plugins: - name: Kanban description: "Move tiki to new status, search, create or delete" @@ -129,3 +135,9 @@ triggers: before delete where old.status = "inProgress" deny "cannot delete an in-progress task — move to backlog or done first" + - description: spawn next occurrence when recurring task completes + ruki: > + after update + where new.status = "done" and old.recurrence is not empty + create title=old.title priority=old.priority tags=old.tags + recurrence=old.recurrence due=next_date(old.recurrence) status="backlog" diff --git a/controller/plugin.go b/controller/plugin.go index e8268ea..6a4769e 100644 --- a/controller/plugin.go +++ b/controller/plugin.go @@ -231,6 +231,17 @@ func (pc *PluginController) handlePluginAction(r rune) bool { // non-fatal: continue remaining rows } } + case result.Clipboard != nil: + if err := service.ExecuteClipboardPipe(result.Clipboard.Rows); err != nil { + slog.Error("clipboard pipe failed", "key", string(r), "error", err) + if pc.statusline != nil { + pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true) + } + return false + } + if pc.statusline != nil { + pc.statusline.SetMessage("copied to clipboard", model.MessageLevelInfo, true) + } } slog.Info("plugin action applied", "task_id", taskID, "key", string(r), "label", pa.Label, "plugin", pc.pluginDef.Name) diff --git a/go.mod b/go.mod index 1566c31..c13e011 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/alecthomas/chroma/v2 v2.14.0 github.com/alecthomas/participle/v2 v2.1.4 + github.com/atotto/clipboard v0.1.4 github.com/boolean-maybe/navidown v0.4.18 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 github.com/charmbracelet/bubbletea v1.3.10 @@ -25,7 +26,6 @@ require ( dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect diff --git a/ruki/ast.go b/ruki/ast.go index 394b30f..ebb8587 100644 --- a/ruki/ast.go +++ b/ruki/ast.go @@ -13,14 +13,24 @@ type Statement struct { Delete *DeleteStmt } -// SelectStmt represents "select [fields] [where ] [order by [asc|desc], ...] [| run(...)]". +// SelectStmt represents "select [fields] [where ] [order by [asc|desc], ...] [| run(...) | clipboard()]". type SelectStmt struct { Fields []string // nil = all ("select" or "select *"); non-nil = specific fields Where Condition // nil = select all OrderBy []OrderByClause // nil = unordered - Pipe *RunAction // optional pipe suffix: "| run(...)" + Pipe *PipeAction // optional pipe suffix: "| run(...)" or "| clipboard()" } +// PipeAction is a discriminated union for pipe targets. +// Exactly one variant is non-nil. +type PipeAction struct { + Run *RunAction + Clipboard *ClipboardAction +} + +// ClipboardAction represents "clipboard()" as a pipe target. +type ClipboardAction struct{} + // CreateStmt represents "create =...". type CreateStmt struct { Assignments []Assignment diff --git a/ruki/executor.go b/ruki/executor.go index a808847..e496479 100644 --- a/ruki/executor.go +++ b/ruki/executor.go @@ -34,11 +34,18 @@ func NewExecutor(schema Schema, userFunc func() string, runtime ExecutorRuntime) // Result holds the output of executing a statement. // Exactly one variant is non-nil. type Result struct { - Select *TaskProjection - Update *UpdateResult - Create *CreateResult - Delete *DeleteResult - Pipe *PipeResult + Select *TaskProjection + Update *UpdateResult + Create *CreateResult + Delete *DeleteResult + Pipe *PipeResult + Clipboard *ClipboardResult +} + +// ClipboardResult holds the row data from a clipboard-piped select. +// The service layer writes these to the system clipboard. +type ClipboardResult struct { + Rows [][]string } // PipeResult holds the shell command and per-row positional args from a piped select. @@ -147,7 +154,12 @@ func (e *Executor) executeSelect(sel *SelectStmt, tasks []*task.Task) (*Result, } if sel.Pipe != nil { - return e.buildPipeResult(sel.Pipe, sel.Fields, filtered, tasks) + switch { + case sel.Pipe.Run != nil: + return e.buildPipeResult(sel.Pipe.Run, sel.Fields, filtered, tasks) + case sel.Pipe.Clipboard != nil: + return e.buildClipboardResult(sel.Fields, filtered) + } } return &Result{ @@ -169,6 +181,18 @@ func (e *Executor) buildPipeResult(pipe *RunAction, fields []string, matched []* return nil, fmt.Errorf("pipe command must evaluate to string, got %T", cmdVal) } + rows := buildFieldRows(fields, matched) + return &Result{Pipe: &PipeResult{Command: cmdStr, Rows: rows}}, nil +} + +func (e *Executor) buildClipboardResult(fields []string, matched []*task.Task) (*Result, error) { + rows := buildFieldRows(fields, matched) + return &Result{Clipboard: &ClipboardResult{Rows: rows}}, nil +} + +// buildFieldRows extracts the requested fields from matched tasks as string rows. +// Shared by both run() and clipboard() pipe targets. +func buildFieldRows(fields []string, matched []*task.Task) [][]string { rows := make([][]string, len(matched)) for i, t := range matched { row := make([]string, len(fields)) @@ -177,8 +201,7 @@ func (e *Executor) buildPipeResult(pipe *RunAction, fields []string, matched []* } rows[i] = row } - - return &Result{Pipe: &PipeResult{Command: cmdStr, Rows: rows}}, nil + return rows } // pipeArgString space-joins list fields (tags, dependsOn) instead of using Go's diff --git a/ruki/grammar.go b/ruki/grammar.go index 6c98e26..db9eab7 100644 --- a/ruki/grammar.go +++ b/ruki/grammar.go @@ -23,7 +23,7 @@ type selectGrammar struct { Fields *fieldNamesGrammar `parser:" | @@ )?"` Where *orCond `parser:"( 'where' @@ )?"` OrderBy *orderByGrammar `parser:"@@?"` - Pipe *runGrammar `parser:"( Pipe @@ )?"` + Pipe *pipeTargetGrammar `parser:"( Pipe @@ )?"` } // --- order by grammar --- @@ -73,10 +73,19 @@ type actionGrammar struct { Delete *deleteGrammar `parser:"| @@"` } +type pipeTargetGrammar struct { + Run *runGrammar `parser:" @@"` + Clipboard *clipboardGrammar `parser:"| @@"` +} + type runGrammar struct { Command exprGrammar `parser:"'run' '(' @@ ')'"` } +type clipboardGrammar struct { + Keyword string `parser:"@'clipboard' '(' ')'"` +} + type denyGrammar struct { Message string `parser:"'deny' @String"` } diff --git a/ruki/keyword/keyword.go b/ruki/keyword/keyword.go index 948d97c..05b7042 100644 --- a/ruki/keyword/keyword.go +++ b/ruki/keyword/keyword.go @@ -8,7 +8,7 @@ var reserved = [...]string{ "where", "set", "order", "by", "asc", "desc", "before", "after", "deny", "run", - "every", + "every", "clipboard", "and", "or", "not", "is", "empty", "in", "any", "all", diff --git a/ruki/keyword/keyword_test.go b/ruki/keyword/keyword_test.go index 51d276b..e581b20 100644 --- a/ruki/keyword/keyword_test.go +++ b/ruki/keyword/keyword_test.go @@ -17,6 +17,8 @@ func TestIsReserved(t *testing.T) { {"and", true}, {"old", true}, {"new", true}, + {"clipboard", true}, + {"CLIPBOARD", true}, {"title", false}, {"priority", false}, {"foobar", false}, diff --git a/ruki/lower.go b/ruki/lower.go index 385c5ba..fef4cb9 100644 --- a/ruki/lower.go +++ b/ruki/lower.go @@ -18,11 +18,11 @@ func lowerStatement(g *statementGrammar) (*Statement, error) { return nil, err } if g.Select.Pipe != nil { - cmd, err := lowerExpr(&g.Select.Pipe.Command) + pipe, err := lowerPipeTarget(g.Select.Pipe) if err != nil { return nil, err } - s.Pipe = &RunAction{Command: cmd} + s.Pipe = pipe } return &Statement{Select: s}, nil case g.Create != nil: @@ -68,6 +68,21 @@ func lowerSelect(g *selectGrammar) (*SelectStmt, error) { return &SelectStmt{Fields: fields, Where: where, OrderBy: orderBy}, nil } +func lowerPipeTarget(g *pipeTargetGrammar) (*PipeAction, error) { + switch { + case g.Run != nil: + cmd, err := lowerExpr(&g.Run.Command) + if err != nil { + return nil, err + } + return &PipeAction{Run: &RunAction{Command: cmd}}, nil + case g.Clipboard != nil: + return &PipeAction{Clipboard: &ClipboardAction{}}, nil + default: + return nil, fmt.Errorf("empty pipe target") + } +} + func lowerCreate(g *createGrammar) (*CreateStmt, error) { assignments, err := lowerAssignments(g.Assignments) if err != nil { diff --git a/ruki/pipe_test.go b/ruki/pipe_test.go index beecf36..618798f 100644 --- a/ruki/pipe_test.go +++ b/ruki/pipe_test.go @@ -112,8 +112,11 @@ func TestLowerPipeProducesSelectAndPipe(t *testing.T) { if stmt.Select.Pipe == nil { t.Fatal("expected Select.Pipe non-nil") } - if stmt.Select.Pipe.Command == nil { - t.Fatal("expected Select.Pipe.Command non-nil") + if stmt.Select.Pipe.Run == nil { + t.Fatal("expected Select.Pipe.Run non-nil") + } + if stmt.Select.Pipe.Run.Command == nil { + t.Fatal("expected Select.Pipe.Run.Command non-nil") } } @@ -342,6 +345,178 @@ func TestExecutePipeListFieldSpaceJoined(t *testing.T) { } } +// --- clipboard pipe: parser --- + +func TestParseClipboardPipe(t *testing.T) { + p := newTestParser() + + tests := []struct { + name string + input string + }{ + {"basic clipboard", `select id where id = id() | clipboard()`}, + {"multi-field clipboard", `select id, title where status = "done" | clipboard()`}, + {"clipboard without where", `select id | clipboard()`}, + {"clipboard with order by", `select id, priority order by priority | clipboard()`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt, err := p.ParseAndValidateStatement(tt.input, ExecutorRuntimePlugin) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if !stmt.IsPipe() { + t.Fatal("expected IsPipe() true") + } + if !stmt.IsClipboardPipe() { + t.Fatal("expected IsClipboardPipe() true") + } + }) + } +} + +// --- clipboard pipe: lowering --- + +func TestLowerClipboardPipe(t *testing.T) { + p := newTestParser() + stmt, err := p.ParseStatement(`select id | clipboard()`) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if stmt.Select.Pipe == nil { + t.Fatal("expected Select.Pipe non-nil") + } + if stmt.Select.Pipe.Clipboard == nil { + t.Fatal("expected Select.Pipe.Clipboard non-nil") + } + if stmt.Select.Pipe.Run != nil { + t.Fatal("expected Select.Pipe.Run nil for clipboard pipe") + } +} + +// --- clipboard pipe: validation --- + +func TestClipboardPipeRejectSelectStar(t *testing.T) { + p := newTestParser() + _, err := p.ParseStatement(`select * | clipboard()`) + if err == nil { + t.Fatal("expected error for select * with clipboard pipe") + } + if !strings.Contains(err.Error(), "explicit field names") { + t.Errorf("expected 'explicit field names' error, got: %v", err) + } +} + +func TestClipboardPipeRejectBareSelect(t *testing.T) { + p := newTestParser() + _, err := p.ParseStatement(`select | clipboard()`) + if err == nil { + t.Fatal("expected error for bare select with clipboard pipe") + } + if !strings.Contains(err.Error(), "explicit field names") { + t.Errorf("expected 'explicit field names' error, got: %v", err) + } +} + +// --- clipboard pipe: semantic --- + +func TestIsClipboardPipeMethod(t *testing.T) { + p := newTestParser() + + clipStmt, err := p.ParseAndValidateStatement(`select id where id = id() | clipboard()`, ExecutorRuntimePlugin) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if !clipStmt.IsPipe() { + t.Error("expected IsPipe() = true") + } + if !clipStmt.IsClipboardPipe() { + t.Error("expected IsClipboardPipe() = true") + } + + runStmt, err := p.ParseAndValidateStatement(`select id where id = id() | run("echo $1")`, ExecutorRuntimePlugin) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if !runStmt.IsPipe() { + t.Error("expected IsPipe() = true for run pipe") + } + if runStmt.IsClipboardPipe() { + t.Error("expected IsClipboardPipe() = false for run pipe") + } + + plainStmt, err := p.ParseAndValidateStatement(`select where status = "done"`, ExecutorRuntimeCLI) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if plainStmt.IsClipboardPipe() { + t.Error("expected IsClipboardPipe() = false for plain select") + } +} + +// --- clipboard pipe: executor --- + +func TestExecuteClipboardPipeReturnsResult(t *testing.T) { + e := NewExecutor(testSchema{}, nil, ExecutorRuntime{Mode: ExecutorRuntimePlugin}) + p := newTestParser() + tasks := makeTasks() + + stmt, err := p.ParseStatement(`select id, title where status = "done" | clipboard()`) + if err != nil { + t.Fatalf("parse: %v", err) + } + + result, err := e.Execute(stmt, tasks, ExecutionInput{SelectedTaskID: "TIKI-000003"}) + if err != nil { + t.Fatalf("execute: %v", err) + } + if result.Clipboard == nil { + t.Fatal("expected Clipboard result") + } + if result.Pipe != nil { + t.Fatal("expected Pipe to be nil when clipboard is present") + } + if result.Select != nil { + t.Fatal("expected Select to be nil when clipboard is present") + } + if len(result.Clipboard.Rows) != 1 { + t.Fatalf("expected 1 row (status=done), got %d", len(result.Clipboard.Rows)) + } + row := result.Clipboard.Rows[0] + if len(row) != 2 { + t.Fatalf("expected 2 fields, got %d", len(row)) + } + if row[0] != "TIKI-000003" { + t.Errorf("row[0] (id) = %q, want %q", row[0], "TIKI-000003") + } + if row[1] != "Write docs" { + t.Errorf("row[1] (title) = %q, want %q", row[1], "Write docs") + } +} + +func TestExecuteClipboardMultipleRows(t *testing.T) { + e := NewExecutor(testSchema{}, nil, ExecutorRuntime{Mode: ExecutorRuntimeCLI}) + p := newTestParser() + tasks := makeTasks() + + stmt, err := p.ParseStatement(`select id, title | clipboard()`) + if err != nil { + t.Fatalf("parse: %v", err) + } + + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if result.Clipboard == nil { + t.Fatal("expected Clipboard result") + } + if len(result.Clipboard.Rows) != len(tasks) { + t.Fatalf("expected %d rows, got %d", len(tasks), len(result.Clipboard.Rows)) + } +} + // --- pipeArgString --- func TestPipeArgString(t *testing.T) { diff --git a/ruki/semantic_validate.go b/ruki/semantic_validate.go index a0ac297..e74c7a9 100644 --- a/ruki/semantic_validate.go +++ b/ruki/semantic_validate.go @@ -49,6 +49,9 @@ func (v *ValidatedStatement) IsDelete() bool { func (v *ValidatedStatement) IsPipe() bool { return v != nil && v.statement != nil && v.statement.Select != nil && v.statement.Select.Pipe != nil } +func (v *ValidatedStatement) IsClipboardPipe() bool { + return v.IsPipe() && v.statement.Select.Pipe.Clipboard != nil +} func (v *ValidatedStatement) mustBeSealed() error { if v == nil || v.seal != validatedSeal || v.statement == nil { @@ -391,8 +394,8 @@ func scanSelectSemantics(sel *SelectStmt) (usesID bool, hasCall bool, err error) if err != nil { return false, false, err } - if sel.Pipe != nil { - u2, c2, err := scanExprSemantics(sel.Pipe.Command) + if sel.Pipe != nil && sel.Pipe.Run != nil { + u2, c2, err := scanExprSemantics(sel.Pipe.Run.Command) if err != nil { return false, false, err } @@ -586,7 +589,13 @@ func cloneSelect(sel *SelectStmt) *SelectStmt { OrderBy: orderBy, } if sel.Pipe != nil { - out.Pipe = &RunAction{Command: cloneExpr(sel.Pipe.Command)} + out.Pipe = &PipeAction{} + if sel.Pipe.Run != nil { + out.Pipe.Run = &RunAction{Command: cloneExpr(sel.Pipe.Run.Command)} + } + if sel.Pipe.Clipboard != nil { + out.Pipe.Clipboard = &ClipboardAction{} + } } return out } diff --git a/ruki/validate.go b/ruki/validate.go index f0442c6..e5617b5 100644 --- a/ruki/validate.go +++ b/ruki/validate.go @@ -74,16 +74,19 @@ func (p *Parser) validateStatement(s *Statement) error { if len(s.Select.Fields) == 0 { return fmt.Errorf("pipe requires explicit field names in select (not select * or bare select)") } - typ, err := p.inferExprType(s.Select.Pipe.Command) - if err != nil { - return fmt.Errorf("pipe command: %w", err) - } - if typ != ValueString { - return fmt.Errorf("pipe command must be string, got %s", typeName(typ)) - } - if exprContainsFieldRef(s.Select.Pipe.Command) { - return fmt.Errorf("pipe command must not contain field references — use $1, $2 for positional args") + if s.Select.Pipe.Run != nil { + typ, err := p.inferExprType(s.Select.Pipe.Run.Command) + if err != nil { + return fmt.Errorf("pipe command: %w", err) + } + if typ != ValueString { + return fmt.Errorf("pipe command must be string, got %s", typeName(typ)) + } + if exprContainsFieldRef(s.Select.Pipe.Run.Command) { + return fmt.Errorf("pipe command must not contain field references — use $1, $2 for positional args") + } } + // clipboard() has no arguments — grammar enforces empty parens } return nil default: diff --git a/service/clipboard.go b/service/clipboard.go new file mode 100644 index 0000000..9137977 --- /dev/null +++ b/service/clipboard.go @@ -0,0 +1,17 @@ +package service + +import ( + "strings" + + "github.com/atotto/clipboard" +) + +// ExecuteClipboardPipe writes the given rows to the system clipboard. +// Fields within a row are tab-separated; rows are newline-separated. +func ExecuteClipboardPipe(rows [][]string) error { + lines := make([]string, len(rows)) + for i, row := range rows { + lines[i] = strings.Join(row, "\t") + } + return clipboard.WriteAll(strings.Join(lines, "\n")) +} diff --git a/service/clipboard_test.go b/service/clipboard_test.go new file mode 100644 index 0000000..ec52d00 --- /dev/null +++ b/service/clipboard_test.go @@ -0,0 +1,40 @@ +package service + +import ( + "testing" + + "github.com/atotto/clipboard" +) + +func TestExecuteClipboardPipe(t *testing.T) { + // skip on headless CI where clipboard is not available + if err := clipboard.WriteAll("test"); err != nil { + t.Skipf("clipboard not available: %v", err) + } + + tests := []struct { + name string + rows [][]string + want string + }{ + {"single field single row", [][]string{{"TIKI-000001"}}, "TIKI-000001"}, + {"multi-field single row", [][]string{{"TIKI-000001", "Fix bug"}}, "TIKI-000001\tFix bug"}, + {"multi-row", [][]string{{"TIKI-000001", "Fix bug"}, {"TIKI-000002", "Add tests"}}, "TIKI-000001\tFix bug\nTIKI-000002\tAdd tests"}, + {"empty rows", [][]string{}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := ExecuteClipboardPipe(tt.rows); err != nil { + t.Fatalf("ExecuteClipboardPipe() error: %v", err) + } + got, err := clipboard.ReadAll() + if err != nil { + t.Fatalf("clipboard.ReadAll() error: %v", err) + } + if got != tt.want { + t.Errorf("clipboard = %q, want %q", got, tt.want) + } + }) + } +}