15 files changed,
382 insertions(+),
103 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-07-22 12:22:09 +0300
Authored at:
2026-07-22 12:13:49 +0300
Change ID:
kxmrmqylqrzwvtkpnxsxwmwumwwuzoxw
Parent:
63835f5
jump to
M
core/core.go
→ internal/viye/viye.go
··· 1 -package core 1 +package viye 2 2 3 3 import ( 4 4 "fmt" ··· 10 10 type Context struct { 11 11 Path []string // ancestor chain [root, ..., target] 12 12 Dir string // working directory 13 + 14 + Cmd string // first word of the leaf path component 15 + Args []string // remaining words of the leaf path component 13 16 14 17 dispatch func(*Context) (string, error) // set by [Viye.dispatch] for tool chaining 15 18 } ··· 47 50 os.Exit(0) 48 51 } 49 52 53 + cmd, cmdArgs := splitLeaf(path[len(path)-1]) 50 54 res, err := v.dispatch(&Context{ 51 55 Path: path, 52 56 Dir: ".", // TODO: get cwd 57 + Cmd: cmd, 58 + Args: cmdArgs, 53 59 }) 54 60 if err != nil { 55 61 return err ··· 81 87 return "", nil 82 88 } 83 89 90 +// splitLeaf splits a leaf path component into command name and args. 91 +// For "mkdir dir" returns ("mkdir", []string{"dir"}). 92 +// For "$ go run ." returns ("$", []string{"go", "run", "."}). 93 +// For "ip" returns ("ip", nil). 94 +func splitLeaf(leaf string) (cmd string, args []string) { 95 + parts := strings.Fields(leaf) 96 + if len(parts) == 0 { 97 + return "", nil 98 + } 99 + return parts[0], parts[1:] 100 +} 101 + 84 102 // splitArgs builds path by splitting each arg on "/" 85 103 // for absolute paths(/...), leading / is preserved as part of first component. 86 104 // for home paths(~...), ~ is the first comonents ··· 107 125 } 108 126 // parts[i] is the first non-empty segment → attach it to "/" 109 127 result := []string{"/" + parts[i]} 110 - result = append(result, parts[i+1:]...) 128 + i++ 129 + for ; i < len(parts); i++ { 130 + if parts[i] != "" { 131 + result = append(result, parts[i]) 132 + } 133 + } 111 134 return result 112 135 case strings.HasPrefix(arg, "~"): 113 - return strings.Split(arg, "/") 136 + parts := strings.Split(arg, "/") 137 + var result []string 138 + for _, p := range parts { 139 + if p != "" { 140 + result = append(result, p) 141 + } 142 + } 143 + return result 114 144 case strings.HasPrefix(arg, "http://") || strings.HasPrefix(arg, "https://"): 115 145 return []string{arg} 116 146 default: 117 - return strings.Split(arg, "/") 147 + parts := strings.Split(arg, "/") 148 + var result []string 149 + for _, p := range parts { 150 + if p != "" { 151 + result = append(result, p) 152 + } 153 + } 154 + return result 118 155 } 119 156 }
A
internal/plumbing/shell/shell.go
··· 1 +package shell 2 + 3 +import ( 4 + "fmt" 5 + "os" 6 + "os/exec" 7 + "path/filepath" 8 + "strconv" 9 + "strings" 10 + "syscall" 11 + 12 + "github.com/olexsmir/viye/internal/viye" 13 +) 14 + 15 +type Tool struct{} 16 + 17 +func (Tool) Name() string { return "shell($, $$, kill, mkdir)" } 18 +func (Tool) Match(ctx *viye.Context) bool { 19 + return isCmd(ctx) || isBGCmd(ctx) || isKill(ctx) || isMkdir(ctx) 20 +} 21 + 22 +func (Tool) Execute(ctx *viye.Context) (string, error) { 23 + switch { 24 + case isCmd(ctx): 25 + cmd := exec.Command("sh", "-c", strings.Join(ctx.Args, " ")) 26 + cmd.Dir = ctx.Dir 27 + out, _ := cmd.CombinedOutput() 28 + 29 + lines := strings.Split(string(out), "\n") 30 + for i, line := range lines { 31 + if line != "" { 32 + lines[i] = "| " + line 33 + } 34 + } 35 + return strings.Join(lines, "\n"), nil 36 + 37 + case isBGCmd(ctx): 38 + cmd := exec.Command("sh", "-c", strings.Join(ctx.Args, " ")) 39 + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 40 + cmd.Dir = ctx.Dir 41 + if err := cmd.Start(); err != nil { 42 + return "", err 43 + } 44 + return fmt.Sprintf("| pid: %d", cmd.Process.Pid), nil 45 + 46 + case isKill(ctx): 47 + pid, err := strconv.Atoi(ctx.Args[0]) 48 + if err != nil { 49 + return "", fmt.Errorf("kill: invalid pid %q", ctx.Args[0]) 50 + } 51 + p, err := os.FindProcess(pid) 52 + if err != nil { 53 + return "", fmt.Errorf("kill: %w", err) 54 + } 55 + if err := p.Kill(); err != nil { 56 + return "", fmt.Errorf("kill: %w", err) 57 + } 58 + return "| done\n", nil 59 + 60 + case isMkdir(ctx): 61 + path := filepath.Join(append([]string{ctx.Dir}, ctx.Args...)...) 62 + if err := os.MkdirAll(path, 0o755); err != nil { 63 + return "", fmt.Errorf("mkdir: %w", err) 64 + } 65 + return "| created " + path + "\n", nil 66 + 67 + default: 68 + panic("shouldn't have paniced") 69 + } 70 +} 71 + 72 +func isCmd(c *viye.Context) bool { return c.Cmd == "$" } 73 +func isBGCmd(c *viye.Context) bool { return c.Cmd == "$$" } 74 +func isKill(c *viye.Context) bool { return c.Cmd == "kill" && len(c.Args) == 1 } 75 +func isMkdir(c *viye.Context) bool { return c.Cmd == "mkdir" && len(c.Args) >= 1 }
A
internal/plumbing/shell/shell_test.go
··· 1 +package shell 2 + 3 +import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + 8 + "github.com/olexsmir/viye/internal/viye" 9 +) 10 + 11 +func TestMatch(t *testing.T) { 12 + tests := []struct { 13 + path string 14 + cmd string 15 + args []string 16 + want bool 17 + }{ 18 + {"$", "$", nil, true}, 19 + {"$ go run .", "$", []string{"go", "run", "."}, true}, 20 + {"$$", "$$", nil, true}, 21 + {"$$ sleep 10", "$$", []string{"sleep", "10"}, true}, 22 + {"kill 1234", "kill", []string{"1234"}, true}, 23 + {"kill abc", "kill", []string{"abc"}, true}, 24 + {"mkdir dir", "mkdir", []string{"dir"}, true}, 25 + {"mkdir dir1 dir2", "mkdir", []string{"dir1", "dir2"}, true}, 26 + {"", "", nil, false}, 27 + {"ip", "ip", nil, false}, 28 + {"kill", "kill", nil, false}, 29 + {"kill 1 2", "kill", []string{"1", "2"}, false}, 30 + {"mkdir", "mkdir", nil, false}, 31 + } 32 + for _, tt := range tests { 33 + got := (&Tool{}).Match(&viye.Context{ 34 + Path: []string{tt.path}, 35 + Cmd: tt.cmd, 36 + Args: tt.args, 37 + }) 38 + if got != tt.want { 39 + t.Errorf("Match(%q) = %v; want %v", tt.path, got, tt.want) 40 + } 41 + } 42 +} 43 + 44 +func TestExecute(t *testing.T) { 45 + t.Run("cmd", func(t *testing.T) { 46 + got, err := (&Tool{}).Execute(&viye.Context{ 47 + Path: []string{"$ echo hello"}, 48 + Cmd: "$", 49 + Args: []string{"echo", "hello"}, 50 + Dir: ".", 51 + }) 52 + if err != nil { 53 + t.Fatal(err) 54 + } 55 + if got != "| hello\n" { 56 + t.Errorf("Execute($ echo hello) = %q; want %q", got, "| hello\\n") 57 + } 58 + }) 59 + 60 + t.Run("bg cmd", func(t *testing.T) { 61 + got, err := (&Tool{}).Execute(&viye.Context{ 62 + Path: []string{"$$ sleep 0"}, 63 + Cmd: "$$", 64 + Args: []string{"sleep", "0"}, 65 + Dir: ".", 66 + }) 67 + if err != nil { 68 + t.Fatal(err) 69 + } 70 + if got != "| pid: " && len(got) < 10 { 71 + t.Errorf("Execute($$ sleep 0) = %q; want pid string", got) 72 + } 73 + }) 74 + 75 + t.Run("mkdir", func(t *testing.T) { 76 + dir := t.TempDir() 77 + got, err := (&Tool{}).Execute(&viye.Context{ 78 + Path: []string{"mkdir sub"}, 79 + Cmd: "mkdir", 80 + Args: []string{"sub"}, 81 + Dir: dir, 82 + }) 83 + if err != nil { 84 + t.Fatal(err) 85 + } 86 + if _, err := os.Stat(filepath.Join(dir, "sub")); err != nil { 87 + t.Errorf("dir not created: %v", err) 88 + } 89 + if got != "| created "+filepath.Join(dir, "sub")+"\n" { 90 + t.Errorf("output = %q; want %q", got, "| created "+filepath.Join(dir, "sub")+"\n") 91 + } 92 + }) 93 + 94 + t.Run("kill", func(t *testing.T) { 95 + // no reliable way to test kill without a real process, 96 + // just verify it doesn't panic with valid args structure 97 + if _, err := (&Tool{}).Execute(&viye.Context{ 98 + Path: []string{"kill 999999"}, 99 + Cmd: "kill", 100 + Args: []string{"999999"}, 101 + Dir: ".", 102 + }); err == nil { 103 + t.Log("kill on bogus pid returned nil (expected error or success)") 104 + } 105 + }) 106 +}
A
internal/plumbing/url/url.go
··· 1 +package url 2 + 3 +import ( 4 + "strings" 5 + 6 + "github.com/olexsmir/viye/internal/osutil" 7 + "github.com/olexsmir/viye/internal/viye" 8 +) 9 + 10 +type Tool struct{} 11 + 12 +func (Tool) Name() string { return "url(http)" } 13 +func (Tool) Match(ctx *viye.Context) bool { 14 + return strings.HasPrefix(ctx.Path[0], "http://") || strings.HasPrefix(ctx.Path[0], "https://") 15 +} 16 + 17 +func (Tool) Execute(ctx *viye.Context) (string, error) { 18 + return "", osutil.Open(ctx.Path[0]) 19 +}
A
internal/viye/viye_test.go
··· 1 +package viye 2 + 3 +import ( 4 + "testing" 5 +) 6 + 7 +func TestSplitLeaf(t *testing.T) { 8 + tests := []struct { 9 + leaf, cmd string 10 + args []string 11 + }{ 12 + {"", "", nil}, 13 + {"ip", "ip", nil}, 14 + {"$", "$", nil}, 15 + {"$$", "$$", nil}, 16 + {"mkdir", "mkdir", nil}, 17 + {"$ go run .", "$", []string{"go", "run", "."}}, 18 + {"$$ sleep 10", "$$", []string{"sleep", "10"}}, 19 + {"mkdir dir", "mkdir", []string{"dir"}}, 20 + {"mkdir dir1 dir2", "mkdir", []string{"dir1", "dir2"}}, 21 + {"kill 1234", "kill", []string{"1234"}}, 22 + {" spaced ", "spaced", nil}, 23 + {"mkdir spaced dir", "mkdir", []string{"spaced", "dir"}}, 24 + {" ", "", nil}, 25 + } 26 + for _, tt := range tests { 27 + cmd, args := splitLeaf(tt.leaf) 28 + if cmd != tt.cmd { 29 + t.Errorf("splitLeaf(%q) cmd = %q; want %q", tt.leaf, cmd, tt.cmd) 30 + } 31 + if len(args) != len(tt.args) { 32 + t.Errorf("splitLeaf(%q) len(args) = %d; want %d", tt.leaf, len(args), len(tt.args)) 33 + } else { 34 + for i := range args { 35 + if args[i] != tt.args[i] { 36 + t.Errorf("splitLeaf(%q) args[%d] = %q; want %q", tt.leaf, i, args[i], tt.args[i]) 37 + } 38 + } 39 + } 40 + } 41 +} 42 + 43 +func TestSplitArg(t *testing.T) { 44 + tests := []struct { 45 + arg string 46 + want []string 47 + }{ 48 + {"foo", []string{"foo"}}, 49 + {"foo/bar", []string{"foo", "bar"}}, 50 + {"foo/bar/", []string{"foo", "bar"}}, 51 + {"a/b/c", []string{"a", "b", "c"}}, 52 + 53 + // absolute paths 54 + {"/", []string{"/"}}, 55 + {"/tmp", []string{"/tmp"}}, 56 + {"/tmp/foo", []string{"/tmp", "foo"}}, 57 + {"/tmp/foo/", []string{"/tmp", "foo"}}, 58 + {"/a/b/c", []string{"/a", "b", "c"}}, 59 + 60 + // home paths 61 + {"~", []string{"~"}}, 62 + {"~/foo", []string{"~", "foo"}}, 63 + {"~/a/b", []string{"~", "a", "b"}}, 64 + 65 + // urls 66 + {"http://example.com", []string{"http://example.com"}}, 67 + {"https://example.com/path", []string{"https://example.com/path"}}, 68 + } 69 + for _, tt := range tests { 70 + got := splitArg(tt.arg) 71 + if len(got) != len(tt.want) { 72 + t.Errorf("splitArg(%q) = %v; want %v", tt.arg, got, tt.want) 73 + continue 74 + } 75 + for i := range got { 76 + if got[i] != tt.want[i] { 77 + t.Errorf("splitArg(%q) = %v; want %v", tt.arg, got, tt.want) 78 + break 79 + } 80 + } 81 + } 82 +} 83 + 84 +func TestSplitArgs(t *testing.T) { 85 + got := splitArgs([]string{"/tmp/foo", "bar/baz"}) 86 + want := []string{"/tmp", "foo", "bar", "baz"} 87 + if len(got) != len(want) { 88 + t.Errorf("splitArgs = %v; want %v", got, want) 89 + } else { 90 + for i := range got { 91 + if got[i] != want[i] { 92 + t.Errorf("splitArgs = %v; want %v", got, want) 93 + break 94 + } 95 + } 96 + } 97 +}
M
main.go
··· 4 4 "fmt" 5 5 "os" 6 6 7 - "github.com/olexsmir/viye/core" 8 - "github.com/olexsmir/viye/plumbing/files" 9 - "github.com/olexsmir/viye/plumbing/gobin" 10 - "github.com/olexsmir/viye/plumbing/ip" 11 - "github.com/olexsmir/viye/plumbing/shell" 12 - "github.com/olexsmir/viye/plumbing/url" 7 + "github.com/olexsmir/viye/internal/plumbing/files" 8 + "github.com/olexsmir/viye/internal/plumbing/ip" 9 + "github.com/olexsmir/viye/internal/plumbing/shell" 10 + "github.com/olexsmir/viye/internal/plumbing/url" 11 + "github.com/olexsmir/viye/internal/viye" 13 12 ) 14 13 15 14 func main() { 16 - v := core.New() 15 + v := viye.New() 17 16 v.Register(&files.Tool{}) 18 17 v.Register(&shell.Tool{}) 19 18 v.Register(&url.Tool{}) 20 19 v.Register(&ip.Tool{}) 21 - v.Register(&gobin.Tool{}) 22 20 23 21 if err := v.Run(os.Stdout, os.Args); err != nil { 24 22 fmt.Fprintf(os.Stderr, "viye: %v\n", err)
M
plumbing/files/files.go
→ internal/plumbing/files/files.go
··· 2 2 3 3 import ( 4 4 "os" 5 - "os/exec" 6 5 "path/filepath" 7 6 "strings" 8 7 9 - "github.com/olexsmir/viye/core" 10 - "github.com/olexsmir/viye/core/osutil" 8 + "github.com/olexsmir/viye/internal/osutil" 9 + "github.com/olexsmir/viye/internal/viye" 11 10 ) 12 11 13 12 type Tool struct{} 14 13 14 +// TODO: add ./ paths support 15 + 15 16 func (Tool) Name() string { return "files(~/..., /...)" } 16 -func (Tool) Match(ctx *core.Context) bool { 17 + 18 +func (Tool) Match(ctx *viye.Context) bool { 17 19 p := ctx.Path[0] 18 20 if p == "~" || strings.HasPrefix(p, "/") { 19 21 return true ··· 22 24 return err == nil 23 25 } 24 26 25 -func (Tool) Execute(ctx *core.Context) (string, error) { 27 +func (Tool) Execute(ctx *viye.Context) (string, error) { 26 28 path := resolve(ctx.Path[0], ctx.Dir) 27 29 28 30 info, err := os.Stat(path) ··· 39 41 ctx.Path = ctx.Path[1:] 40 42 return ctx.Next() 41 43 42 - case info.Mode()&0o111 != 0: 43 - return runExec(path) 44 + // case info.Mode()&0o111 != 0: 45 + // return runExec(path) 44 46 45 47 default: 46 48 return "", osutil.Open(path) ··· 79 81 return b.String(), nil 80 82 } 81 83 82 -func runExec(path string) (string, error) { 83 - cmd := exec.Command(path) 84 - out, err := cmd.Output() 85 - if err != nil { 86 - return "", err 87 - } 88 - return string(out), nil 89 -} 84 +// func runExec(path string) (string, error) { 85 +// cmd := exec.Command(path) 86 +// out, err := cmd.Output() 87 +// if err != nil { 88 +// return "", err 89 +// } 90 +// return string(out), nil 91 +// }
M
plumbing/files/files_test.go
→ internal/plumbing/files/files_test.go
··· 6 6 "strings" 7 7 "testing" 8 8 9 - "github.com/olexsmir/viye/core" 9 + "github.com/olexsmir/viye/internal/viye" 10 10 ) 11 11 12 12 func TestMatch(t *testing.T) { ··· 27 27 {"$ ls", dir, false}, 28 28 } 29 29 for _, tt := range tests { 30 - got := (&Tool{}).Match(&core.Context{Path: []string{tt.p}, Dir: tt.dir}) 30 + got := (&Tool{}).Match(&viye.Context{Path: []string{tt.p}, Dir: tt.dir}) 31 31 if got != tt.m { 32 32 t.Errorf("Match(%q, dir=%q) = %v; want %v", tt.p, tt.dir, got, tt.m) 33 33 } ··· 38 38 t.Run("list dir", func(t *testing.T) { 39 39 dir := t.TempDir() 40 40 os.WriteFile(filepath.Join(dir, "x"), nil, 0o644) 41 - ctx := &core.Context{Path: []string{dir}, Dir: "."} 41 + ctx := &viye.Context{Path: []string{dir}, Dir: "."} 42 42 got, err := (&Tool{}).Execute(ctx) 43 43 if err != nil { 44 44 t.Fatal(err) ··· 54 54 os.Mkdir(sub, 0o755) 55 55 os.WriteFile(filepath.Join(sub, "y"), nil, 0o644) 56 56 57 - v := core.New() 57 + v := viye.New() 58 58 v.Register(&Tool{}) 59 59 var out strings.Builder 60 60 if err := v.Run(&out, []string{"viye", dir, "sub"}); err != nil { ··· 66 66 }) 67 67 68 68 t.Run("non existent path", func(t *testing.T) { 69 - ctx := &core.Context{Path: []string{"/nonexistent_foobar"}, Dir: "."} 69 + ctx := &viye.Context{Path: []string{"/nonexistent_foobar"}, Dir: "."} 70 70 if _, err := (&Tool{}).Execute(ctx); err == nil { 71 71 t.Error("expected error") 72 72 }
M
plumbing/gobin/gobin.go
→ internal/plumbing/gobin/gobin.go
··· 1 1 package gobin 2 2 3 -import "github.com/olexsmir/viye/core" 3 +import "github.com/olexsmir/viye/internal/viye" 4 4 5 5 const menu = ` 6 6 - build ··· 14 14 type Tool struct{} 15 15 16 16 func (Tool) Name() string { return "go" } 17 -func (Tool) Match(ctx *core.Context) bool { 17 +func (Tool) Match(ctx *viye.Context) bool { 18 18 return ctx.Path[0] == "go" 19 19 } 20 20 21 -func (Tool) Execute(ctx *core.Context) (string, error) { 21 +func (Tool) Execute(ctx *viye.Context) (string, error) { 22 22 switch ctx.Path[1] { 23 23 case "build": 24 24 return "| will build", nil
M
plumbing/ip/ip.go
→ internal/plumbing/ip/ip.go
··· 7 7 "net/http" 8 8 "strings" 9 9 10 - "github.com/olexsmir/viye/core" 10 + "github.com/olexsmir/viye/internal/viye" 11 11 ) 12 12 13 13 type Tool struct{} 14 14 15 15 func (Tool) Name() string { return "ip" } 16 -func (Tool) Match(ctx *core.Context) bool { return len(ctx.Path) == 1 && ctx.Path[0] == "ip" } 17 -func (Tool) Execute(ctx *core.Context) (string, error) { 16 +func (Tool) Match(ctx *viye.Context) bool { return len(ctx.Path) == 1 && ctx.Path[0] == "ip" } 17 +func (Tool) Execute(ctx *viye.Context) (string, error) { 18 18 // TODO: if it's not possile to get public ip, report only local 19 19 20 20 var buf strings.Builder
D
plumbing/shell/shell.go
··· 1 -package shell 2 - 3 -import ( 4 - "fmt" 5 - "os/exec" 6 - "strings" 7 - "syscall" 8 - 9 - "github.com/olexsmir/viye/core" 10 -) 11 - 12 -type Tool struct{} 13 - 14 -func (Tool) Name() string { return "shell($)" } 15 -func (Tool) Match(ctx *core.Context) bool { return isCmd(ctx.Path[0]) || isBGCmd(ctx.Path[0]) } 16 -func (Tool) Execute(ctx *core.Context) (string, error) { 17 - switch { 18 - case isCmd(ctx.Path[0]): 19 - cmd := exec.Command("sh", "-c", strings.TrimPrefix(ctx.Path[0], "$ ")) 20 - cmd.Dir = ctx.Dir 21 - out, _ := cmd.CombinedOutput() 22 - 23 - lines := strings.Split(string(out), "\n") 24 - for i, line := range lines { 25 - if line != "" { 26 - lines[i] = "| " + line 27 - } 28 - } 29 - return strings.Join(lines, "\n"), nil 30 - 31 - case isBGCmd(ctx.Path[0]): 32 - cmd := exec.Command("sh", "-c", strings.TrimPrefix(ctx.Path[0], "$$ ")) 33 - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 34 - cmd.Dir = ctx.Dir 35 - if err := cmd.Start(); err != nil { 36 - return "", err 37 - } 38 - return fmt.Sprintf("| pid: %d", cmd.Process.Pid), nil 39 - 40 - default: 41 - panic("shouldn't have paniced") 42 - } 43 -} 44 - 45 -func isCmd(s string) bool { return strings.HasPrefix(s, "$ ") } 46 -func isBGCmd(s string) bool { return strings.HasPrefix(s, "$$ ") }
D
plumbing/url/url.go
··· 1 -package url 2 - 3 -import ( 4 - "strings" 5 - 6 - "github.com/olexsmir/viye/core" 7 - "github.com/olexsmir/viye/core/osutil" 8 -) 9 - 10 -type Tool struct{} 11 - 12 -func (Tool) Name() string { return "url(http)" } 13 -func (Tool) Match(ctx *core.Context) bool { 14 - return strings.HasPrefix(ctx.Path[0], "http://") || strings.HasPrefix(ctx.Path[0], "https://") 15 -} 16 - 17 -func (Tool) Execute(ctx *core.Context) (string, error) { 18 - return "", osutil.Open(ctx.Path[0]) 19 -}