13 files changed,
317 insertions(+),
53 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-07-23 12:27:11 +0300
Authored at:
2026-07-22 12:25:23 +0300
Change ID:
zqvxoxkxpnzqylskylqsommxpmkqqrzz
Parent:
3fb8292
jump to
M
internal/plumbing/files/files_test.go
··· 25 25 {"f", dir, true}, 26 26 {"nope", dir, false}, 27 27 {"$ ls", dir, false}, 28 + {".", dir, true}, 29 + {"..", dir, true}, 28 30 } 29 31 for _, tt := range tests { 30 32 got := (&Tool{}).Match(&viye.Context{Path: []string{tt.p}, Dir: tt.dir}) ··· 83 85 {"~/foo", ".", filepath.Join(home, "foo")}, 84 86 {"/abs/path", ".", "/abs/path"}, 85 87 {"rel", "/base", "/base/rel"}, 88 + {".", "/base", "/base"}, 89 + {"..", "/base/sub", "/base"}, 86 90 } 87 91 for _, tt := range tests { 88 92 got := resolve(tt.p, tt.dir)
M
internal/plumbing/gobin/gobin.go
··· 1 1 package gobin 2 2 3 -import "github.com/olexsmir/viye/internal/viye" 3 +import ( 4 + "os/exec" 5 + "strings" 6 + 7 + "github.com/olexsmir/viye/internal/viye" 8 +) 4 9 5 -const menu = ` 6 -- build 10 +const menu = `- build 7 11 - test 12 +- run 8 13 - generate 9 14 - doc 10 -- intall 11 -- mod 15 +- mod/ 12 16 ` 13 17 18 +const modMenu = `- tidy 19 +- download 20 +- verify 21 +- init` 22 + 14 23 type Tool struct{} 15 24 16 25 func (Tool) Name() string { return "go" } 17 26 func (Tool) Match(ctx *viye.Context) bool { 18 - return ctx.Path[0] == "go" 27 + return ctx.Cmd == "go" || (len(ctx.Path) > 0 && ctx.Path[0] == "go") 19 28 } 20 29 21 30 func (Tool) Execute(ctx *viye.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 31 + var subcmd string 32 + var args []string 33 + 34 + if ctx.Cmd == "go" { 35 + // cmd-based: 'go build ./...' → Cmd="go", Args=["build","./..."] 36 + if len(ctx.Args) == 0 { 37 + return menu, nil 38 + } 39 + subcmd = ctx.Args[0] 40 + args = ctx.Args[1:] 41 + } else { 42 + // path-based: join remaining path as a command line, then re-split 43 + if len(ctx.Path) < 2 { 44 + return menu, nil 45 + } 46 + parts := strings.Fields(strings.Join(ctx.Path[1:], " ")) 47 + if len(parts) == 0 { 48 + return menu, nil 49 + } 50 + subcmd = parts[0] 51 + args = parts[1:] 52 + } 53 + 54 + switch subcmd { 33 55 case "mod": 34 - return "| will mod", nil 56 + if len(args) == 0 { 57 + return modMenu, nil 58 + } 59 + case "build", "test", "generate", "doc", "install", "run", "fmt", "vet", "clean", "env", "fix", "list", "tool", "version": 35 60 default: 36 61 return menu, nil 37 62 } 63 + 64 + return runGo(ctx.Dir, subcmd, args...) 65 +} 66 + 67 +func runGo(dir, subcmd string, args ...string) (string, error) { 68 + cmd := exec.Command("go", append([]string{subcmd}, args...)...) 69 + cmd.Dir = dir 70 + out, _ := cmd.CombinedOutput() 71 + return viye.Indent(string(out)), nil 38 72 }
M
internal/plumbing/gobin/gobin_test.go
··· 1 1 package gobin 2 2 3 -import "testing" 3 +import ( 4 + "testing" 5 + 6 + "github.com/olexsmir/viye/internal/viye" 7 +) 4 8 5 9 func TestMatch(t *testing.T) { 10 + tests := []struct { 11 + path []string 12 + want bool 13 + }{ 14 + {[]string{"go"}, true}, 15 + {[]string{"go", "build"}, true}, 16 + {[]string{"go", "test", "./..."}, true}, 17 + {[]string{"other"}, false}, 18 + {[]string{}, false}, 19 + } 20 + for _, tt := range tests { 21 + got := (Tool{}).Match(&viye.Context{Path: tt.path}) 22 + if got != tt.want { 23 + t.Errorf("Match(%v) = %v; want %v", tt.path, got, tt.want) 24 + } 25 + } 6 26 } 7 27 8 28 func TestExecute(t *testing.T) { 29 + t.Skipf("TODO implement") 9 30 }
M
internal/plumbing/ip/ip.go
··· 1 1 package ip 2 2 3 3 import ( 4 + "context" 4 5 "errors" 5 6 "io" 6 7 "net" 7 8 "net/http" 8 9 "strings" 10 + "time" 9 11 10 12 "github.com/olexsmir/viye/internal/viye" 11 13 ) ··· 53 55 } 54 56 55 57 func getPublicIP() (string, error) { 56 - resp, err := http.Get("https://ifconfig.me/ip") 58 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 59 + defer cancel() 60 + 61 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://ifconfig.me/ip", nil) 62 + if err != nil { 63 + return "", err 64 + } 65 + 66 + resp, err := http.DefaultClient.Do(req) 57 67 if err != nil { 58 68 return "", err 59 69 }
A
internal/plumbing/json/json.go
··· 1 +package json 2 + 3 +import ( 4 + "encoding/json" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/olexsmir/viye/internal/viye" 9 +) 10 + 11 +type Tool struct{} 12 + 13 +func (Tool) Name() string { return "json" } 14 +func (Tool) Match(ctx *viye.Context) bool { return ctx.Cmd == "json" } 15 +func (Tool) Execute(ctx *viye.Context) (string, error) { 16 + // Phase 1: no body → return editable template 17 + if len(ctx.Body) == 0 { 18 + return ": name: your name\n: age: 0\n", nil 19 + } 20 + 21 + // Phase 2: body present → parse and produce JSON 22 + m := make(map[string]string) 23 + for _, line := range ctx.Body { 24 + // Strip ": " prefix 25 + line = strings.TrimPrefix(line, ": ") 26 + col := strings.Index(line, ":") 27 + if col < 0 { 28 + continue 29 + } 30 + key := strings.TrimSpace(line[:col]) 31 + val := strings.TrimSpace(line[col+1:]) 32 + if key != "" { 33 + m[key] = val 34 + } 35 + } 36 + 37 + b, err := json.Marshal(m) 38 + if err != nil { 39 + return "", fmt.Errorf("demo: %w", err) 40 + } 41 + return fmt.Sprintf("| %s\n", string(b)), nil 42 +}
M
internal/plumbing/shell/shell.go
··· 25 25 cmd := exec.Command("sh", "-c", strings.Join(ctx.Args, " ")) 26 26 cmd.Dir = ctx.Dir 27 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 28 + return viye.Indent(string(out)), nil 36 29 37 30 case isBGCmd(ctx): 38 31 cmd := exec.Command("sh", "-c", strings.Join(ctx.Args, " "))
A
internal/viye/format.go
··· 1 +package viye 2 + 3 +import "strings" 4 + 5 +func Indent(s string) string { 6 + s = strings.TrimSuffix(s, "\n") 7 + if s == "" { 8 + return "" 9 + } 10 + lines := strings.Split(s, "\n") 11 + for i, line := range lines { 12 + if line == "" { 13 + lines[i] = "|" 14 + } else { 15 + lines[i] = "| " + line 16 + } 17 + } 18 + return strings.Join(lines, "\n") + "\n" 19 +}
M
internal/viye/viye.go
··· 13 13 14 14 Cmd string // first word of the leaf path component 15 15 Args []string // remaining words of the leaf path component 16 + Body []string // body lines from : children (edited data for two-phase execution) 16 17 17 18 dispatch func(*Context) (string, error) // set by [Viye.dispatch] for tool chaining 18 19 } ··· 45 46 return v.showHelp(out) 46 47 } 47 48 48 - path := splitArgs(args[1:]) 49 + path, body := splitArgs(args[1:]) 49 50 if len(path) == 0 { 50 51 os.Exit(0) 51 52 } ··· 56 57 Dir: mustGetCwd(), 57 58 Cmd: cmd, 58 59 Args: cmdArgs, 60 + Body: body, 59 61 }) 60 62 if err != nil { 61 63 return err ··· 99 101 return parts[0], parts[1:] 100 102 } 101 103 102 -// splitArgs builds path by splitting each arg on "/" 104 +// splitArgs builds path by splitting each arg on "/" and body on "--". 105 +// Everything before "--" is the path (split into components). 106 +// Everything after "--" is the body (kept as-is, one element per line). 107 +// If there's no "--", body is nil. 103 108 // for absolute paths(/...), leading / is preserved as part of first component. 104 109 // for home paths(~...), ~ is the first comonents 105 110 // urls are kept as single component 106 -func splitArgs(args []string) []string { 107 - var path []string 111 +func splitArgs(args []string) (path []string, body []string) { 112 + sawSep := false 108 113 for _, arg := range args { 109 - path = append(path, splitArg(arg)...) 114 + if !sawSep && arg == "--" { 115 + sawSep = true 116 + continue 117 + } 118 + if !sawSep { 119 + path = append(path, splitArg(arg)...) 120 + } else { 121 + body = append(body, arg) 122 + } 110 123 } 111 - return path 124 + return 112 125 } 113 126 114 127 func splitArg(arg string) []string { ··· 144 157 case strings.HasPrefix(arg, "http://") || strings.HasPrefix(arg, "https://"): 145 158 return []string{arg} 146 159 default: 147 - parts := strings.Split(arg, "/") 160 + s := strings.TrimPrefix(arg, "./") 161 + if s != arg { 162 + if s == "" { 163 + return []string{"."} 164 + } 165 + // No "/" left to split on — keep original (e.g. "./..." stays "./...") 166 + if !strings.Contains(s, "/") { 167 + return []string{arg} 168 + } 169 + return splitArg(s) 170 + } 171 + parts := strings.Split(s, "/") 148 172 var result []string 149 173 for _, p := range parts { 150 174 if p != "" {
M
internal/viye/viye_test.go
··· 62 62 {"~/foo", []string{"~", "foo"}}, 63 63 {"~/a/b", []string{"~", "a", "b"}}, 64 64 65 + // ./ paths 66 + {"./", []string{"."}}, 67 + {"./foo", []string{"./foo"}}, 68 + {"./foo/bar", []string{"foo", "bar"}}, 69 + {"././foo", []string{"./foo"}}, 70 + {"./././", []string{"."}}, 71 + {".", []string{"."}}, 72 + {"..", []string{".."}}, 73 + {"../foo", []string{"..", "foo"}}, 74 + {"./../foo", []string{"..", "foo"}}, 75 + 65 76 // urls 66 77 {"http://example.com", []string{"http://example.com"}}, 67 78 {"https://example.com/path", []string{"https://example.com/path"}}, ··· 82 93 } 83 94 84 95 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 { 96 + tests := []struct { 97 + args []string 98 + want []string 99 + wantB []string 100 + }{ 101 + {[]string{"/tmp/foo", "bar/baz"}, []string{"/tmp", "foo", "bar", "baz"}, nil}, 102 + {[]string{"./foo", "bar"}, []string{"./foo", "bar"}, nil}, 103 + {[]string{"./"}, []string{"."}, nil}, 104 + {[]string{"././foo", "./bar"}, []string{"./foo", "./bar"}, nil}, 105 + {[]string{"demo"}, []string{"demo"}, nil}, 106 + {[]string{"demo", "--", ": name: olex"}, []string{"demo"}, []string{": name: olex"}}, 107 + {[]string{"demo", "--", ": name: olex", ": age: 30"}, []string{"demo"}, []string{": name: olex", ": age: 30"}}, 108 + {[]string{"--", ": body"}, nil, []string{": body"}}, 109 + } 110 + for _, tt := range tests { 111 + got, gotB := splitArgs(tt.args) 112 + if len(got) != len(tt.want) { 113 + t.Errorf("splitArgs(%v) path = %v; want %v", tt.args, got, tt.want) 114 + continue 115 + } 90 116 for i := range got { 91 - if got[i] != want[i] { 92 - t.Errorf("splitArgs = %v; want %v", got, want) 117 + if got[i] != tt.want[i] { 118 + t.Errorf("splitArgs(%v) path = %v; want %v", tt.args, got, tt.want) 119 + break 120 + } 121 + } 122 + if len(gotB) != len(tt.wantB) { 123 + t.Errorf("splitArgs(%v) body = %v; want %v", tt.args, gotB, tt.wantB) 124 + continue 125 + } 126 + for i := range gotB { 127 + if gotB[i] != tt.wantB[i] { 128 + t.Errorf("splitArgs(%v) body = %v; want %v", tt.args, gotB, tt.wantB) 93 129 break 94 130 } 95 131 }
M
lua/viye.lua
··· 39 39 return next_line ~= "" and indent(next_line) > depth 40 40 end 41 41 42 +-- Collect : body lines under the current line (siblings at depth+2) 43 +-- Returns list of stripped lines (with ": " prefix preserved) 44 +local function collect_body() 45 + local lnum = vim.fn.line "." 46 + local depth = indent(vim.fn.getline(lnum)) 47 + local body = {} 48 + local i = lnum + 1 49 + while i <= vim.fn.line "$" do 50 + local l = vim.fn.getline(i) 51 + if l == "" then break end 52 + local d = indent(l) 53 + if d <= depth then break end 54 + if d == depth + 2 and l:find("^%s*: ") then 55 + table.insert(body, vim.trim(l)) 56 + end 57 + i = i + 1 58 + end 59 + return body 60 +end 61 + 62 +-- Remove | output lines under the current line (siblings at depth+2) 63 +-- Returns the line number after the removed block, or nil if nothing removed 64 +local function remove_output() 65 + local lnum = vim.fn.line "." 66 + local depth = indent(vim.fn.getline(lnum)) 67 + local first = nil 68 + local last = nil 69 + local i = lnum + 1 70 + while i <= vim.fn.line "$" do 71 + local l = vim.fn.getline(i) 72 + if l == "" then break end 73 + local d = indent(l) 74 + if d <= depth then break end 75 + if d == depth + 2 and l:find("^%s*| ") then 76 + if first == nil then first = i end 77 + last = i 78 + end 79 + i = i + 1 80 + end 81 + if first then 82 + vim.cmd(string.format("%d,%ddelete _", first, last)) 83 + return first 84 + end 85 + return nil 86 +end 87 + 42 88 local function collapse() 43 89 local lnum = vim.fn.line "." 44 90 local depth = indent(vim.fn.getline(lnum)) ··· 64 110 if toggled ~= line then vim.fn.setline(lnum, toggled) end 65 111 end 66 112 67 -local function insert_output(output) 68 - local lnum = vim.fn.line "." 69 - local pad = string.rep(" ", indent(vim.fn.getline(lnum)) + 2) 113 +local function insert_output(output, after_lnum) 114 + after_lnum = after_lnum or vim.fn.line "." 115 + local pad = string.rep(" ", indent(vim.fn.getline(after_lnum)) + 2) 70 116 local lines = vim.split(output, "\n", { trimempty = true }) 71 117 for i, line in ipairs(lines) do lines[i] = pad .. line end 72 - vim.fn.append(lnum, lines) 118 + vim.fn.append(after_lnum, lines) 119 +end 120 + 121 +-- Find the last child line at depth+2 under the current line 122 +local function find_last_child() 123 + local lnum = vim.fn.line "." 124 + local depth = indent(vim.fn.getline(lnum)) 125 + local last = lnum 126 + local i = lnum + 1 127 + while i <= vim.fn.line "$" do 128 + local l = vim.fn.getline(i) 129 + if l == "" then break end 130 + local d = indent(l) 131 + if d <= depth then break end 132 + if d == depth + 2 then 133 + last = i 134 + end 135 + i = i + 1 136 + end 137 + return last 73 138 end 74 139 75 -local function exec() 140 +local function exec(reenter) 76 141 local line = vim.fn.getline "." 77 142 if is_data(line) then 78 143 local lnum = vim.fn.line "." ··· 82 147 local l = vim.fn.getline(i) 83 148 if not is_data(l) and indent(l) < depth and l:find "%S" then 84 149 vim.fn.cursor(i, 1) 85 - exec() 150 + exec(true) 86 151 return 87 152 end 88 153 i = i - 1 ··· 90 155 return 91 156 end 92 157 93 - if has_children() then 158 + if not reenter and has_children() then 94 159 collapse() 95 160 toggle_bullet() 96 161 return ··· 99 164 local path = build_path() 100 165 if path == "" then return end 101 166 167 + local body = reenter and collect_body() or {} 168 + 102 169 local cmd = "viye " .. vim.fn.shellescape(path) 170 + if #body > 0 then 171 + cmd = cmd .. " --" 172 + for _, line in ipairs(body) do 173 + cmd = cmd .. " " .. vim.fn.shellescape(line) 174 + end 175 + end 103 176 vim.notify("viye: " .. cmd, vim.log.levels.INFO) 104 177 105 178 local out = vim.fn.system(cmd) ··· 109 182 end 110 183 111 184 if out ~= "" then 112 - insert_output(out) 113 - toggle_bullet() 185 + remove_output() 186 + local target = reenter and find_last_child() or vim.fn.line "." 187 + insert_output(out, target) 188 + if not reenter then 189 + toggle_bullet() 190 + end 114 191 end 115 192 end 116 193
M
main.go
··· 4 4 "fmt" 5 5 "os" 6 6 7 + "github.com/olexsmir/viye/internal/plumbing/json" 7 8 "github.com/olexsmir/viye/internal/plumbing/files" 9 + "github.com/olexsmir/viye/internal/plumbing/gobin" 8 10 "github.com/olexsmir/viye/internal/plumbing/ip" 9 11 "github.com/olexsmir/viye/internal/plumbing/shell" 10 12 "github.com/olexsmir/viye/internal/plumbing/url" ··· 17 19 v.Register(&shell.Tool{}) 18 20 v.Register(&url.Tool{}) 19 21 v.Register(&ip.Tool{}) 22 + v.Register(&gobin.Tool{}) 23 + v.Register(&json.Tool{}) 20 24 21 25 if err := v.Run(os.Stdout, os.Args); err != nil { 22 26 fmt.Fprintf(os.Stderr, "viye: %v\n", err)