6 files changed,
174 insertions(+),
306 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-07-30 23:38:41 +0300
Authored at:
2026-07-30 21:58:23 +0300
Change ID:
lrtuvzslqopvsktwvruuuyzttptnzqym
Parent:
fa602cd
M
internal/plumbing/config/config.go
··· 11 11 type Tool struct{} 12 12 13 13 func (Tool) Name() string { return "config" } 14 -func (Tool) Match(c *viye.Context) bool { return c.Path[0] == "config" } 14 +func (Tool) Match(c *viye.Context) bool { return c.Cmd == "config" } 15 15 func (Tool) Execute(c *viye.Context) (string, error) { 16 16 cfg, err := config.Load() 17 17 if err != nil {
M
internal/plumbing/files/files.go
··· 12 12 13 13 type Tool struct{} 14 14 15 -func (Tool) Name() string { return "files(~/..., /...)" } 15 +func (Tool) Name() string { return "files(~/..., /..., ./...)" } 16 16 17 17 func (Tool) Match(ctx *viye.Context) bool { 18 - p := ctx.Path[0] 19 - if p == "~" || strings.HasPrefix(p, "/") { 20 - return true 21 - } 22 - _, err := os.Stat(filepath.Join(ctx.Dir, p)) 23 - return err == nil 18 + return strings.HasPrefix(ctx.Path[0], "/") || strings.HasPrefix(ctx.Path[0], "./") || 19 + strings.HasPrefix(ctx.Path[0], "~/") 24 20 } 25 21 26 22 func (Tool) Execute(ctx *viye.Context) (string, error) {
M
internal/plumbing/files/files_test.go
··· 10 10 ) 11 11 12 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 13 tests := []struct { 18 - p, dir string 19 - m bool 14 + p string 15 + m bool 20 16 }{ 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 - {".", dir, true}, 29 - {"..", dir, true}, 17 + {"~/foo", true}, 18 + {"/tmp", true}, 19 + {"/tmp/foo", true}, 20 + {"./foo", true}, 21 + {"./foo/bar", true}, 22 + 23 + {"nope", false}, 24 + {"$ ls", false}, 25 + {"~", false}, 26 + {".", false}, 27 + {"..", false}, 30 28 } 31 29 for _, tt := range tests { 32 - got := (&Tool{}).Match(&viye.Context{Path: []string{tt.p}, Dir: tt.dir}) 30 + got := (&Tool{}).Match(&viye.Context{Path: []string{tt.p}}) 33 31 if got != tt.m { 34 - t.Errorf("Match(%q, dir=%q) = %v; want %v", tt.p, tt.dir, got, tt.m) 32 + t.Errorf("Match(%q) = %v; want %v", tt.p, got, tt.m) 35 33 } 36 34 } 37 35 } ··· 51 49 } 52 50 }) 53 51 54 - t.Run("navigate into subdir", func(t *testing.T) { 52 + t.Run("navigate into subdir via full path", func(t *testing.T) { 55 53 dir := t.TempDir() 56 54 sub := filepath.Join(dir, "sub") 57 55 os.Mkdir(sub, 0o755) ··· 60 58 v := viye.New() 61 59 v.Register(&Tool{}) 62 60 var out strings.Builder 63 - if err := v.Run(&out, []string{"viye", dir, "sub"}); err != nil { 61 + if err := v.Run(&out, []string{"viye", sub}); err != nil { 64 62 t.Fatal(err) 65 63 } 66 64 want := "| y\n"
M
internal/viye/viye.go
··· 107 107 return parts[0], parts[1:] 108 108 } 109 109 110 -// splitArgs builds path by splitting each arg on "/" and body on "--". 111 -// Everything before "--" is the path (split into components). 110 +// splitArgs builds path and body from args. 111 +// Everything before "--" is the path (each arg is one component). 112 112 // Everything after "--" is the body (kept as-is, one element per line). 113 113 // If there's no "--", body is nil. 114 -// for absolute paths(/...), leading / is preserved as part of first component. 115 -// for home paths(~...), ~ is the first comonents 116 -// urls are kept as single component 117 114 func splitArgs(args []string) (path []string, body []string) { 118 115 sawSep := false 119 116 for _, arg := range args { ··· 122 119 continue 123 120 } 124 121 if !sawSep { 125 - path = append(path, splitArg(arg)...) 122 + path = append(path, arg) 126 123 } else { 127 124 body = append(body, arg) 128 125 } 129 126 } 130 127 return 131 -} 132 - 133 -func splitArg(arg string) []string { 134 - switch { 135 - case strings.HasPrefix(arg, "/"): 136 - parts := strings.Split(arg, "/") 137 - // parts[0] is always "" for absolute paths 138 - i := 0 139 - for i < len(parts) && parts[i] == "" { 140 - i++ 141 - } 142 - if i >= len(parts) { 143 - return []string{"/"} 144 - } 145 - // parts[i] is the first non-empty segment → attach it to "/" 146 - result := []string{"/" + parts[i]} 147 - i++ 148 - for ; i < len(parts); i++ { 149 - if parts[i] != "" { 150 - result = append(result, parts[i]) 151 - } 152 - } 153 - return result 154 - case strings.HasPrefix(arg, "~"): 155 - parts := strings.Split(arg, "/") 156 - var result []string 157 - for _, p := range parts { 158 - if p != "" { 159 - result = append(result, p) 160 - } 161 - } 162 - return result 163 - case strings.HasPrefix(arg, "http://") || strings.HasPrefix(arg, "https://"): 164 - return []string{arg} 165 - default: 166 - s := strings.TrimPrefix(arg, "./") 167 - if s != arg { 168 - if s == "" { 169 - return []string{"."} 170 - } 171 - // No "/" left to split on — keep original (e.g. "./..." stays "./...") 172 - if !strings.Contains(s, "/") { 173 - return []string{arg} 174 - } 175 - return splitArg(s) 176 - } 177 - parts := strings.Split(s, "/") 178 - var result []string 179 - for _, p := range parts { 180 - if p != "" { 181 - result = append(result, p) 182 - } 183 - } 184 - return result 185 - } 186 128 } 187 129 188 130 func mustGetCwd() string {
M
internal/viye/viye_test.go
··· 40 40 } 41 41 } 42 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 - // ./ 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 - 76 - // urls 77 - {"http://example.com", []string{"http://example.com"}}, 78 - {"https://example.com/path", []string{"https://example.com/path"}}, 79 - } 80 - for _, tt := range tests { 81 - got := splitArg(tt.arg) 82 - if len(got) != len(tt.want) { 83 - t.Errorf("splitArg(%q) = %v; want %v", tt.arg, got, tt.want) 84 - continue 85 - } 86 - for i := range got { 87 - if got[i] != tt.want[i] { 88 - t.Errorf("splitArg(%q) = %v; want %v", tt.arg, got, tt.want) 89 - break 90 - } 91 - } 92 - } 93 -} 94 - 95 43 func TestSplitArgs(t *testing.T) { 96 44 tests := []struct { 97 - args []string 98 - want []string 99 - wantB []string 45 + args []string 46 + want []string 47 + wantB []string 100 48 }{ 101 - {[]string{"/tmp/foo", "bar/baz"}, []string{"/tmp", "foo", "bar", "baz"}, nil}, 49 + {[]string{"/tmp/foo", "bar/baz"}, []string{"/tmp/foo", "bar/baz"}, nil}, 102 50 {[]string{"./foo", "bar"}, []string{"./foo", "bar"}, nil}, 103 - {[]string{"./"}, []string{"."}, nil}, 104 - {[]string{"././foo", "./bar"}, []string{"./foo", "./bar"}, nil}, 51 + {[]string{"./"}, []string{"./"}, nil}, 52 + {[]string{"././foo", "./bar"}, []string{"././foo", "./bar"}, nil}, 105 53 {[]string{"demo"}, []string{"demo"}, nil}, 106 54 {[]string{"demo", "--", ": name: olex"}, []string{"demo"}, []string{": name: olex"}}, 107 55 {[]string{"demo", "--", ": name: olex", ": age: 30"}, []string{"demo"}, []string{": name: olex", ": age: 30"}},
M
lua/viye.lua
··· 1 1 local M = {} 2 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 3 +local function indent(text) return #text:match("^%s*") end 4 +local function is_data(line) return line:find("^%s*[|:]") ~= nil end 6 5 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 - local i = lnum - 1 13 - while i >= 1 do 6 +local function children_of(cmd_lnum) 7 + local depth = indent(vim.fn.getline(cmd_lnum)) 8 + local first = cmd_lnum + 1 9 + local last = nil 10 + local i = first 11 + while i <= vim.fn.line("$") do 14 12 local l = vim.fn.getline(i) 15 - local d = indent(l) 16 - if d == 0 and l:find "%S" and depth > 0 then 17 - table.insert(parts, 1, strip_bullet(vim.trim(l))) 18 - break 19 - end 20 - if d < depth and l:find "%S" then 21 - table.insert(parts, 1, strip_bullet(vim.trim(l))) 22 - depth = d 23 - end 24 - i = i - 1 13 + if l == "" then break end 14 + if indent(l) <= depth then break end 15 + last = i 16 + i = i + 1 25 17 end 26 - return table.concat(parts, "/") 18 + return first, last 27 19 end 28 20 29 -local function exec() 30 - local ok, err = pcall(function() 31 - local line = vim.fn.getline "." 21 +local function parent_of(lnum) 22 + local d = indent(vim.fn.getline(lnum)) 23 + while lnum >= 1 do 24 + local l = vim.fn.getline(lnum) 25 + if not is_data(l) and l:find("%S") and indent(l) < d then return lnum end 26 + lnum = lnum - 1 27 + end 28 + return nil 29 +end 32 30 33 - -- Data line: | means collapse, : means re-execute with body 34 - if is_data(line) then 35 - local depth = indent(line) 36 - local i = vim.fn.line "." - 1 37 - while i >= 1 do 38 - local l = vim.fn.getline(i) 39 - if not is_data(l) and indent(l) < depth and l:find "%S" then 40 - vim.fn.cursor(i, 1) 41 - break 42 - end 43 - i = i - 1 31 +local function ancestors(cmd_lnum) 32 + local parts = {} 33 + local depth = math.huge 34 + local i = cmd_lnum 35 + while i >= 1 do 36 + local l = vim.fn.getline(i) 37 + if not is_data(l) and l:find("%S") then 38 + local d = indent(l) 39 + if d < depth then 40 + local text = l:match("^%s*[+-] (.*)") 41 + if not text then text = vim.trim(l) end 42 + table.insert(parts, 1, text) 43 + depth = d 44 + if d == 0 then break end 44 45 end 46 + end 47 + i = i - 1 48 + end 49 + return parts 50 +end 45 51 46 - local lnum = vim.fn.line "." 47 - local depth = indent(vim.fn.getline(lnum)) 48 - local first_child = lnum + 1 49 - local body = {} 50 - local last_child = nil 51 - local i = first_child 52 - while i <= vim.fn.line "$" do 53 - local l = vim.fn.getline(i) 54 - if l == "" then break end 55 - local d = indent(l) 56 - if d <= depth then break end 57 - if d == depth + 2 then 58 - if l:find "^%s*: " then table.insert(body, vim.trim(l)) end 59 - last_child = i 60 - end 61 - i = i + 1 52 +local function body_of(cmd_lnum) 53 + local depth = indent(vim.fn.getline(cmd_lnum)) 54 + local body = {} 55 + local i = cmd_lnum + 1 56 + while i <= vim.fn.line("$") do 57 + local l = vim.fn.getline(i) 58 + if l == "" then break end 59 + local d = indent(l) 60 + if d <= depth then break end 61 + if d == depth + 2 then 62 + local val = l:match("^%s*: (.*)") 63 + if val then 64 + table.insert(body, ": " .. val) 62 65 end 66 + end 67 + i = i + 1 68 + end 69 + return body 70 +end 63 71 64 - if line:find "^%s*|" then 65 - -- | line: collapse (delete children, toggle to +) 66 - if last_child then vim.cmd(string.format("%d,%ddelete _", first_child, last_child)) end 67 - vim.fn.setline(lnum, (vim.fn.getline(lnum):gsub("^%- ", "+ ", 1))) 68 - return 69 - end 72 +local function run(cmd_lnum) 73 + local parts = ancestors(cmd_lnum) 74 + if #parts == 0 then return nil end 70 75 71 - -- : line: re-execute with body (phase 2) 72 - local path = build_path() 73 - if path == "" then return end 76 + local cmd = "viye" 77 + for _, p in ipairs(parts) do 78 + cmd = cmd .. " " .. vim.fn.shellescape(p) 79 + end 80 + local body = body_of(cmd_lnum) 81 + if #body > 0 then 82 + cmd = cmd .. " --" 83 + for _, b in ipairs(body) do 84 + cmd = cmd .. " " .. vim.fn.shellescape(b) 85 + end 86 + end 74 87 75 - local cmd = "viye " .. vim.fn.shellescape(path) 76 - if #body > 0 then 77 - cmd = cmd .. " --" 78 - for _, b in ipairs(body) do 79 - cmd = cmd .. " " .. vim.fn.shellescape(b) 80 - end 81 - end 88 + vim.notify("ex'ing: " .. cmd, vim.log.levels.INFO) 89 + local out = vim.fn.system(cmd) 90 + if vim.v.shell_error ~= 0 then 91 + vim.notify("viye: " .. (vim.trim(out or "")), vim.log.levels.ERROR) 92 + return nil 93 + end 94 + return out 95 +end 82 96 83 - local out = vim.fn.system(cmd) 84 - if vim.v.shell_error ~= 0 then 85 - vim.notify("viye: " .. (out:gsub("\n$", "")), vim.log.levels.ERROR) 86 - return 87 - end 97 +local function delete_range(first, last) 98 + if last then vim.cmd(string.format("%d,%ddelete _", first, last)) end 99 +end 88 100 89 - if last_child then vim.cmd(string.format("%d,%ddelete _", first_child, last_child)) end 101 +local function insert_output(cmd_lnum, out) 102 + local pad = string.rep(" ", indent(vim.fn.getline(cmd_lnum)) + 2) 103 + local lines = vim.split(out, "\n", { trimempty = true }) 104 + for i, l in ipairs(lines) do 105 + lines[i] = pad .. l 106 + end 107 + vim.fn.append(cmd_lnum, lines) 108 +end 90 109 91 - if out ~= "" then 92 - local pad = string.rep(" ", depth + 2) 93 - local lines = vim.split(out, "\n", { trimempty = true }) 94 - for j, l in ipairs(lines) do lines[j] = pad .. l end 95 - vim.fn.append(lnum, lines) 96 - end 97 - return 98 - end 110 +local function toggle_bullet(cmd_lnum, to) 111 + vim.fn.setline(cmd_lnum, (vim.fn.getline(cmd_lnum):gsub("^[+-] ", to .. " ", 1))) 112 +end 99 113 100 - -- Command line: toggle expand/collapse 101 - local lnum = vim.fn.line "." 102 - local depth = indent(vim.fn.getline(lnum)) 114 +local function exec() 115 + local lnum = vim.fn.line(".") 116 + local line = vim.fn.getline(lnum) 103 117 104 - local first_child = lnum + 1 105 - local last_child = nil 106 - local i = first_child 107 - while i <= vim.fn.line "$" do 108 - local l = vim.fn.getline(i) 109 - if l == "" then break end 110 - local d = indent(l) 111 - if d <= depth then break end 112 - if d == depth + 2 then last_child = i end 113 - i = i + 1 114 - end 118 + -- find the enclosing command line 119 + local cmd_lnum 120 + if is_data(line) then 121 + cmd_lnum = parent_of(lnum) 122 + else 123 + cmd_lnum = lnum 124 + end 125 + if not cmd_lnum then return end 115 126 116 - if last_child then 117 - -- Expanded: collapse 118 - vim.cmd(string.format("%d,%ddelete _", first_child, last_child)) 119 - vim.fn.setline(lnum, (vim.fn.getline(lnum):gsub("^%- ", "+ ", 1))) 120 - return 121 - end 127 + local first, last = children_of(cmd_lnum) 128 + local on_cmd = cmd_lnum == lnum 129 + local has_bullet = line:find("^%s*[+-] ") 122 130 123 - -- Collapsed: run CLI and insert output 124 - local path = build_path() 125 - if path == "" then return end 131 + if on_cmd and last and has_bullet then 132 + -- expanded togglable command -> collapse 133 + delete_range(first, last) 134 + toggle_bullet(cmd_lnum, "+") 135 + return 136 + end 126 137 127 - local cmd = "viye " .. vim.fn.shellescape(path) 128 - local out = vim.fn.system(cmd) 129 - if vim.v.shell_error ~= 0 then 130 - vim.notify("viye: " .. (out:gsub("\n$", "")), vim.log.levels.ERROR) 131 - return 138 + -- | output line: collapse without re-running 139 + if line:find("^%s*|") then 140 + if last then 141 + delete_range(first, last) 142 + toggle_bullet(cmd_lnum, "+") 132 143 end 144 + return 145 + end 133 146 134 - if out ~= "" then 135 - local pad = string.rep(" ", depth + 2) 136 - local lines = vim.split(out, "\n", { trimempty = true }) 137 - for j, l in ipairs(lines) do lines[j] = pad .. l end 138 - vim.fn.append(lnum, lines) 139 - vim.fn.setline(lnum, (vim.fn.getline(lnum):gsub("^%+ ", "- ", 1))) 140 - else 141 - vim.fn.setline(lnum, (vim.fn.getline(lnum):gsub("^%- ", "+ ", 1))) 142 - end 143 - end) 144 - if not ok then 145 - vim.notify("viye: " .. tostring(err), vim.log.levels.ERROR) 147 + -- collapsed command, or Enter on a child: run and (re-)expand 148 + local out = run(cmd_lnum) 149 + if not out then return end 150 + 151 + delete_range(first, last) 152 + if out ~= "" then 153 + insert_output(cmd_lnum, out) 146 154 end 155 + toggle_bullet(cmd_lnum, "-") 147 156 end 148 157 149 158 function M.setup() 150 - vim.filetype.add { extension = { viye = "viye" } } 159 + vim.filetype.add({ extension = { viye = "viye" } }) 160 + local group = vim.api.nvim_create_augroup("viye", { clear = true }) 151 161 vim.api.nvim_create_autocmd("FileType", { 152 162 pattern = "viye", 153 - group = vim.api.nvim_create_augroup("viye_plugin", { clear = true }), 154 - callback = function() vim.keymap.set("n", "<CR>", exec, { buffer = true, silent = true }) end, 163 + group = group, 164 + callback = function() 165 + vim.keymap.set("n", "<CR>", exec, { buffer = true, silent = true }) 166 + end, 155 167 }) 156 -end 157 - 158 -if false then -- tests 159 - local ok, fail = 0, 0 160 - local function check(got, want, msg) 161 - if got == want then 162 - ok = ok + 1 163 - else 164 - fail = fail + 1 165 - print("FAIL " .. msg .. ": got " .. vim.inspect(got) .. " want " .. vim.inspect(want)) 166 - end 167 - end 168 - 169 - check(indent "", 0, "indent empty") 170 - check(indent "foo", 0, "indent no indent") 171 - check(indent " foo", 2, "indent 2 spaces") 172 - check(indent " foo", 4, "indent 4 spaces") 173 - 174 - check(is_data ": hello", true, "is_data colon") 175 - check(is_data " : hello", true, "is_data colon indent") 176 - check(is_data "| hello", true, "is_data pipe") 177 - check(is_data " |", true, "is_data pipe alone") 178 - check(is_data "+ demo", false, "is_data not +") 179 - check(is_data "- demo", false, "is_data not -") 180 - check(is_data "hello", false, "is_data plain") 181 - 182 - check(strip_bullet "+ demo", "demo", "strip +") 183 - check(strip_bullet "- demo", "demo", "strip -") 184 - check(strip_bullet "demo", "demo", "strip none") 185 - check(strip_bullet "+ hello world", "hello world", "strip + multiword") 186 - 187 - if fail == 0 then 188 - print("viye.lua: " .. ok .. "/" .. (ok + fail) .. " tests passed") 189 - else 190 - print("viye.lua: " .. fail .. " failures out of " .. (ok + fail) .. " tests") 191 - end 168 + vim.api.nvim_create_autocmd("BufWinEnter", { 169 + group = group, 170 + callback = function() 171 + if vim.fn.expand("%") == "" and vim.bo.buftype == "" then 172 + vim.bo.filetype = "viye" 173 + end 174 + end, 175 + }) 192 176 end 193 177 194 178 return M