11 files changed,
656 insertions(+),
0 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-07-22 00:21:05 +0300
Authored at:
2026-07-21 18:11:47 +0300
Change ID:
swmmotpymxtnpxlmkznsvlzrvstzpxqm
A
core/core.go
··· 1 +package core 2 + 3 +import ( 4 + "fmt" 5 + "io" 6 + "os" 7 + "strings" 8 +) 9 + 10 +type Context struct { 11 + Path []string // ancestor chain [root, ..., target] 12 + Dir string // working directory 13 + 14 + dispatch func(*Context) (string, error) // set by [Viye.dispatch] for tool chaining 15 +} 16 + 17 +// Next continues dispatch with currect [Context] state. 18 +// Call after consuming .Path[0] and updating .Dir. 19 +func (c *Context) Next() (string, error) { return c.dispatch(c) } 20 + 21 +type Tool interface { 22 + Name() string 23 + Match(*Context) bool 24 + Execute(*Context) (string, error) 25 +} 26 + 27 +type Viye struct { 28 + tools []Tool 29 +} 30 + 31 +func New() *Viye { return &Viye{} } 32 + 33 +func (v *Viye) Register(tool Tool) { 34 + v.tools = append(v.tools, tool) 35 +} 36 + 37 +func (v *Viye) Run(out io.Writer, args []string) error { 38 + if len(args) <= 1 { 39 + return nil 40 + } 41 + if args[1] == "--help" { 42 + return v.showHelp(out) 43 + } 44 + 45 + path := splitArgs(args[1:]) 46 + if len(path) == 0 { 47 + os.Exit(0) 48 + } 49 + 50 + res, err := v.dispatch(&Context{ 51 + Path: path, 52 + Dir: ".", // TODO: get cwd 53 + }) 54 + if err != nil { 55 + return err 56 + } 57 + 58 + _, err = fmt.Fprint(out, res) 59 + return err 60 +} 61 + 62 +func (v *Viye) showHelp(out io.Writer) error { 63 + fmt.Fprintln(out, "Available tools:") 64 + for _, tool := range v.tools { 65 + fmt.Fprintf(out, " - %s\n", tool.Name()) 66 + } 67 + return nil 68 +} 69 + 70 +// dispatch executes the path against registered tools, first-match-wins 71 +func (v *Viye) dispatch(ctx *Context) (string, error) { 72 + if len(ctx.Path) == 0 { 73 + return "", nil 74 + } 75 + ctx.dispatch = v.dispatch 76 + for _, tool := range v.tools { 77 + if tool.Match(ctx) { 78 + return tool.Execute(ctx) 79 + } 80 + } 81 + return "", nil 82 +} 83 + 84 +// splitArgs builds path by splitting each arg on "/" 85 +// for absolute paths(/...), leading / is preserved as part of first component. 86 +// for home paths(~...), ~ is the first comonents 87 +// urls are kept as single component 88 +func splitArgs(args []string) []string { 89 + var path []string 90 + for _, arg := range args { 91 + path = append(path, splitArg(arg)...) 92 + } 93 + return path 94 +} 95 + 96 +func splitArg(arg string) []string { 97 + switch { 98 + case strings.HasPrefix(arg, "/"): 99 + parts := strings.Split(arg, "/") 100 + // parts[0] is always "" for absolute paths 101 + i := 0 102 + for i < len(parts) && parts[i] == "" { 103 + i++ 104 + } 105 + if i >= len(parts) { 106 + return []string{"/"} 107 + } 108 + // parts[i] is the first non-empty segment → attach it to "/" 109 + result := []string{"/" + parts[i]} 110 + result = append(result, parts[i+1:]...) 111 + return result 112 + case strings.HasPrefix(arg, "~"): 113 + return strings.Split(arg, "/") 114 + case strings.HasPrefix(arg, "http://") || strings.HasPrefix(arg, "https://"): 115 + return []string{arg} 116 + default: 117 + return strings.Split(arg, "/") 118 + } 119 +}
A
core/osutil/osutil_linux.go
··· 1 +//go:build linux 2 + 3 +package osutil 4 + 5 +import ( 6 + "os/exec" 7 + "syscall" 8 +) 9 + 10 +func Open(path string) error { 11 + cmd := exec.Command("xdg-open", path) 12 + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 13 + cmd.Start() 14 + return nil 15 +}
A
lua/viye.lua
··· 1 +local M = {} 2 + 3 +local function indent(text) return #text:match "^%s*" end 4 +local function is_data(text) return text:find "^%s*[|:] " ~= nil end 5 +local function strip_bullet(text) return (text:gsub("^[+-] ", "", 1)) end 6 + 7 +local function build_path() 8 + local lnum = vim.fn.line "." 9 + local line = vim.fn.getline(lnum) 10 + local depth = indent(line) 11 + local parts = { strip_bullet(vim.trim(line)) } 12 + 13 + local i = lnum - 1 14 + while i >= 1 do 15 + local l = vim.fn.getline(i) 16 + local d = indent(l) 17 + if d == 0 and l:find "%S" and depth > 0 then 18 + table.insert(parts, 1, strip_bullet(vim.trim(l))) 19 + break 20 + end 21 + if d < depth and l:find "%S" then 22 + table.insert(parts, 1, strip_bullet(vim.trim(l))) 23 + depth = d 24 + end 25 + i = i - 1 26 + end 27 + 28 + for idx = 1, #parts - 1 do 29 + parts[idx] = parts[idx]:gsub("/+$", "") 30 + end 31 + 32 + return table.concat(parts, "/") 33 +end 34 + 35 +local function has_children() 36 + local lnum = vim.fn.line "." 37 + local depth = indent(vim.fn.getline(lnum)) 38 + local next_line = vim.fn.getline(lnum + 1) 39 + return next_line ~= "" and indent(next_line) > depth 40 +end 41 + 42 +local function collapse() 43 + local lnum = vim.fn.line "." 44 + local depth = indent(vim.fn.getline(lnum)) 45 + local s = lnum + 1 46 + local e = s 47 + while e <= vim.fn.line "$" do 48 + local l = vim.fn.getline(e) 49 + if l == "" or indent(l) <= depth then break end 50 + e = e + 1 51 + end 52 + if e > s then vim.cmd(string.format("%d,%ddelete _", s, e - 1)) end 53 +end 54 + 55 +local function toggle_bullet() 56 + local lnum = vim.fn.line "." 57 + local line = vim.fn.getline(lnum) 58 + local toggled = line:gsub("^%+ ", "- ", 1) 59 + if toggled ~= line then 60 + vim.fn.setline(lnum, toggled) 61 + return 62 + end 63 + toggled = line:gsub("^%- ", "+ ", 1) 64 + if toggled ~= line then vim.fn.setline(lnum, toggled) end 65 +end 66 + 67 +local function insert_output(output) 68 + local lnum = vim.fn.line "." 69 + local pad = string.rep(" ", indent(vim.fn.getline(lnum)) + 2) 70 + local lines = vim.split(output, "\n", { trimempty = true }) 71 + for i, line in ipairs(lines) do lines[i] = pad .. line end 72 + vim.fn.append(lnum, lines) 73 +end 74 + 75 +local function exec() 76 + local line = vim.fn.getline "." 77 + if is_data(line) then 78 + local lnum = vim.fn.line "." 79 + local depth = indent(line) 80 + local i = lnum - 1 81 + while i >= 1 do 82 + local l = vim.fn.getline(i) 83 + if not is_data(l) and indent(l) < depth and l:find "%S" then 84 + vim.fn.cursor(i, 1) 85 + exec() 86 + return 87 + end 88 + i = i - 1 89 + end 90 + return 91 + end 92 + 93 + if has_children() then 94 + collapse() 95 + toggle_bullet() 96 + return 97 + end 98 + 99 + local path = build_path() 100 + if path == "" then return end 101 + 102 + local cmd = "viye " .. vim.fn.shellescape(path) 103 + vim.notify("viye: " .. cmd, vim.log.levels.INFO) 104 + 105 + local out = vim.fn.system(cmd) 106 + if vim.v.shell_error ~= 0 then 107 + vim.notify("viye: " .. out:gsub("\n$", ""), vim.log.levels.ERROR) 108 + return 109 + end 110 + 111 + if out ~= "" then 112 + insert_output(out) 113 + toggle_bullet() 114 + end 115 +end 116 + 117 +function M.setup() 118 + vim.filetype.add { extension = { viye = "viye" } } 119 + vim.api.nvim_create_autocmd("FileType", { 120 + pattern = "viye", 121 + group = vim.api.nvim_create_augroup("viye_plugin", { clear = true }), 122 + callback = function() vim.keymap.set("n", "<CR>", exec, { buffer = true, silent = true }) end, 123 + }) 124 +end 125 + 126 +return M
A
main.go
··· 1 +package main 2 + 3 +import ( 4 + "fmt" 5 + "os" 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" 13 +) 14 + 15 +func main() { 16 + v := core.New() 17 + v.Register(&files.Tool{}) 18 + v.Register(&shell.Tool{}) 19 + v.Register(&url.Tool{}) 20 + v.Register(&ip.Tool{}) 21 + v.Register(&gobin.Tool{}) 22 + 23 + if err := v.Run(os.Stdout, os.Args); err != nil { 24 + fmt.Fprintf(os.Stderr, "viye: %v\n", err) 25 + os.Exit(1) 26 + } 27 +}
A
plumbing/files/files.go
··· 1 +package files 2 + 3 +import ( 4 + "os" 5 + "os/exec" 6 + "path/filepath" 7 + "strings" 8 + 9 + "github.com/olexsmir/viye/core" 10 + "github.com/olexsmir/viye/core/osutil" 11 +) 12 + 13 +type Tool struct{} 14 + 15 +func (Tool) Name() string { return "files(~/..., /...)" } 16 +func (Tool) Match(ctx *core.Context) bool { 17 + p := ctx.Path[0] 18 + if p == "~" || strings.HasPrefix(p, "/") { 19 + return true 20 + } 21 + _, err := os.Stat(filepath.Join(ctx.Dir, p)) 22 + return err == nil 23 +} 24 + 25 +func (Tool) Execute(ctx *core.Context) (string, error) { 26 + path := resolve(ctx.Path[0], ctx.Dir) 27 + 28 + info, err := os.Stat(path) 29 + if err != nil { 30 + return "", err 31 + } 32 + 33 + switch { 34 + case info.IsDir(): 35 + if len(ctx.Path) == 1 { 36 + return listDir(path) 37 + } 38 + ctx.Dir = path 39 + ctx.Path = ctx.Path[1:] 40 + return ctx.Next() 41 + 42 + case info.Mode()&0o111 != 0: 43 + return runExec(path) 44 + 45 + default: 46 + return "", osutil.Open(path) 47 + } 48 +} 49 + 50 +func resolve(p, dir string) string { 51 + if p == "~" { 52 + home, _ := os.UserHomeDir() 53 + return home 54 + } 55 + if strings.HasPrefix(p, "~/") { 56 + home, _ := os.UserHomeDir() 57 + return filepath.Join(home, p[2:]) 58 + } 59 + if filepath.IsAbs(p) { 60 + return filepath.Clean(p) 61 + } 62 + return filepath.Join(dir, p) 63 +} 64 + 65 +func listDir(dir string) (string, error) { 66 + entries, err := os.ReadDir(dir) 67 + if err != nil { 68 + return "", err 69 + } 70 + var b strings.Builder 71 + for _, e := range entries { 72 + name := e.Name() 73 + if e.IsDir() { 74 + name += "/" 75 + } 76 + b.WriteString(name) 77 + b.WriteByte('\n') 78 + } 79 + return b.String(), nil 80 +} 81 + 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 +}
A
plumbing/files/files_test.go
··· 1 +package files 2 + 3 +import ( 4 + "os" 5 + "path/filepath" 6 + "strings" 7 + "testing" 8 + 9 + "github.com/olexsmir/viye/core" 10 +) 11 + 12 +func TestMatch(t *testing.T) { 13 + dir := t.TempDir() 14 + os.Mkdir(filepath.Join(dir, "sub"), 0o755) 15 + os.WriteFile(filepath.Join(dir, "f"), nil, 0o644) 16 + 17 + tests := []struct { 18 + p, dir string 19 + m bool 20 + }{ 21 + {"~", ".", true}, 22 + {"/tmp", ".", true}, 23 + {dir, ".", true}, 24 + {"sub", dir, true}, 25 + {"f", dir, true}, 26 + {"nope", dir, false}, 27 + {"$ ls", dir, false}, 28 + } 29 + for _, tt := range tests { 30 + got := (&Tool{}).Match(&core.Context{Path: []string{tt.p}, Dir: tt.dir}) 31 + if got != tt.m { 32 + t.Errorf("Match(%q, dir=%q) = %v; want %v", tt.p, tt.dir, got, tt.m) 33 + } 34 + } 35 +} 36 + 37 +func TestExecute(t *testing.T) { 38 + t.Run("list dir", func(t *testing.T) { 39 + dir := t.TempDir() 40 + os.WriteFile(filepath.Join(dir, "x"), nil, 0o644) 41 + ctx := &core.Context{Path: []string{dir}, Dir: "."} 42 + got, err := (&Tool{}).Execute(ctx) 43 + if err != nil { 44 + t.Fatal(err) 45 + } 46 + if got != "x\n" { 47 + t.Errorf("Execute = %q; want %q", got, "x\\n") 48 + } 49 + }) 50 + 51 + t.Run("navigate into subdir", func(t *testing.T) { 52 + dir := t.TempDir() 53 + sub := filepath.Join(dir, "sub") 54 + os.Mkdir(sub, 0o755) 55 + os.WriteFile(filepath.Join(sub, "y"), nil, 0o644) 56 + 57 + v := core.New() 58 + v.Register(&Tool{}) 59 + var out strings.Builder 60 + if err := v.Run(&out, []string{"viye", dir, "sub"}); err != nil { 61 + t.Fatal(err) 62 + } 63 + if out.String() != "y\n" { 64 + t.Errorf("Run = %q; want %q", out.String(), "y\\n") 65 + } 66 + }) 67 + 68 + t.Run("non existent path", func(t *testing.T) { 69 + ctx := &core.Context{Path: []string{"/nonexistent_foobar"}, Dir: "."} 70 + if _, err := (&Tool{}).Execute(ctx); err == nil { 71 + t.Error("expected error") 72 + } 73 + }) 74 +} 75 + 76 +func TestResolve(t *testing.T) { 77 + home, _ := os.UserHomeDir() 78 + tests := []struct { 79 + p, dir string 80 + want string 81 + }{ 82 + {"~", ".", home}, 83 + {"~/foo", ".", filepath.Join(home, "foo")}, 84 + {"/abs/path", ".", "/abs/path"}, 85 + {"rel", "/base", "/base/rel"}, 86 + } 87 + for _, tt := range tests { 88 + got := resolve(tt.p, tt.dir) 89 + if got != tt.want { 90 + t.Errorf("resolve(%q, %q) = %q; want %q", tt.p, tt.dir, got, tt.want) 91 + } 92 + } 93 +} 94 + 95 +func TestListDir(t *testing.T) { 96 + dir := t.TempDir() 97 + os.Mkdir(filepath.Join(dir, "sub"), 0o755) 98 + os.WriteFile(filepath.Join(dir, "a.txt"), nil, 0o644) 99 + 100 + got, err := listDir(dir) 101 + if err != nil { 102 + t.Fatal(err) 103 + } 104 + if got != "a.txt\nsub/\n" { 105 + t.Errorf("listDir = %q; want %q", got, "a.txt\\nsub/\\n") 106 + } 107 +}
A
plumbing/gobin/gobin.go
··· 1 +package gobin 2 + 3 +import "github.com/olexsmir/viye/core" 4 + 5 +const menu = ` 6 +- build 7 +- test 8 +- generate 9 +- doc 10 +- intall 11 +- mod 12 +` 13 + 14 +type Tool struct{} 15 + 16 +func (Tool) Name() string { return "go" } 17 +func (Tool) Match(ctx *core.Context) bool { 18 + return ctx.Path[0] == "go" 19 +} 20 + 21 +func (Tool) Execute(ctx *core.Context) (string, error) { 22 + switch ctx.Path[1] { 23 + case "build": 24 + return "| will build", nil 25 + case "test": 26 + return "| will test", nil 27 + case "generate": 28 + return "| will generate", nil 29 + case "doc": 30 + return "| will doc", nil 31 + case "install": 32 + return "| will install", nil 33 + case "mod": 34 + return "| will mod", nil 35 + default: 36 + return menu, nil 37 + } 38 +}
A
plumbing/ip/ip.go
··· 1 +package ip 2 + 3 +import ( 4 + "errors" 5 + "io" 6 + "net" 7 + "net/http" 8 + "strings" 9 + 10 + "github.com/olexsmir/viye/core" 11 +) 12 + 13 +type Tool struct{} 14 + 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) { 18 + // TODO: if it's not possile to get public ip, report only local 19 + 20 + var buf strings.Builder 21 + buf.WriteString("| ") 22 + 23 + pip, err := getPublicIP() 24 + if err != nil { 25 + return "", err 26 + } 27 + _, _ = buf.WriteString(pip) 28 + _, _ = buf.WriteString(" / ") 29 + 30 + lip, err := getLocalIP() 31 + if err != nil { 32 + return "", err 33 + } 34 + _, _ = buf.WriteString(lip) 35 + 36 + _ = buf.WriteByte('\n') 37 + return buf.String(), nil 38 +} 39 + 40 +func getLocalIP() (string, error) { 41 + addrs, err := net.InterfaceAddrs() 42 + if err != nil { 43 + return "", err 44 + } 45 + for _, addr := range addrs { 46 + if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { 47 + if ipnet.IP.To4() != nil { 48 + return ipnet.IP.String(), nil 49 + } 50 + } 51 + } 52 + return "", errors.New("couldn't get local error") 53 +} 54 + 55 +func getPublicIP() (string, error) { 56 + resp, err := http.Get("https://ifconfig.me/ip") 57 + if err != nil { 58 + return "", err 59 + } 60 + defer resp.Body.Close() 61 + 62 + ip, err := io.ReadAll(resp.Body) 63 + if err != nil { 64 + return "", err 65 + } 66 + return strings.TrimSpace(string(ip)), nil 67 +}
A
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, "$$ ") }
A
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 +}