From c5aebd16ae81601a9c318638a0014d8fdc442a6a Mon Sep 17 00:00:00 2001 From: MattHandzel Date: Wed, 8 Jul 2026 18:28:18 -0700 Subject: [PATCH] fix: robust task-CLI I/O, prefix-preserving completion, wrap option, focusable confirm picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes three classes of user-reported bugs: Issue #5 — :Tw opened a blank buffer with no error. vim.fn.system merges stderr into stdout, so any Taskwarrior chatter around the export JSON (the `task news` nag, "Configuration override" notices, hook output, forced-color ANSI escapes) made the parse fail — and every parse site silently degraded to "zero tasks". A zero-task render is only the concealed header comment, so the buffer looked blank and :TwRefresh looked like a no-op. Now: - READ_RC adds rc.verbose=nothing + rc.color=off to every read-only invocation, suppressing chatter at the source (mutations keep verbose output — tw_add parses "Created task " from it); - decode_json_array strips ANSI, scans prioritized bracket candidates, and falls back to line-filtering for chatter interleaved between TW3's line-per-task output; - the ten hand-rolled `find("%[")` slices (buffer, graph, inbox, views, delegate, modify, statusline, telescope, api.export) are replaced by one shared taskmd.shell_export that also redirects stderr away from the parse stream; - tw_export raises a diagnosable error instead of silently returning {} on unparseable output; - a zero-task render shows a virt-lines empty-state hint instead of a blank buffer; - _udas/_projects/_tags line parsing rejects lines containing whitespace (valid names never do; chatter always does). Issue #4 — in the filter prompt wiped the already-typed filter. input() completion replaces the ENTIRE line, so candidates now carry the untouched leading tokens. Also defines _complete_modify, which the gm prompt referenced but which never existed (E117 on ), and adds a lint spec asserting every v:lua completion reference resolves. Issue #3 — adds setup{ wrap = false } to disable line wrap in task buffers, and vim.schedule()s the Apply/Cancel confirm picker out of the BufWriteCmd context so float-based vim.ui.select backends (dressing, snacks, telescope) can take focus. Verified: 19 new unit specs (decode robustness, completion prefixing, empty state, wrap validation, v:lua lint) and 6 new e2e specs (wrap window state, empty-state extmark on a real render, render survival under a real noisy on-launch hook, confirm-picker drain) — all green against a real Taskwarrior DB. Co-Authored-By: Claude Fable 5 --- README.md | 1 + doc/taskwarrior.txt | 1 + lua/taskwarrior/apply.lua | 22 ++- lua/taskwarrior/buffer.lua | 59 +++--- lua/taskwarrior/config.lua | 5 + lua/taskwarrior/delegate.lua | 24 +-- lua/taskwarrior/graph.lua | 10 +- lua/taskwarrior/inbox.lua | 10 +- lua/taskwarrior/init.lua | 38 ++-- lua/taskwarrior/modify.lua | 27 +-- lua/taskwarrior/statusline.lua | 8 +- lua/taskwarrior/taskmd.lua | 137 +++++++++++-- lua/taskwarrior/validate.lua | 3 +- lua/taskwarrior/views.lua | 9 +- lua/telescope/_extensions/task.lua | 10 +- tests/e2e/spec/e2e_spec.lua | 96 ++++++++++ tests/lua/spec/issue_fixes_spec.lua | 287 ++++++++++++++++++++++++++++ 17 files changed, 610 insertions(+), 137 deletions(-) create mode 100644 tests/lua/spec/issue_fixes_spec.lua diff --git a/README.md b/README.md index 94a0108..570eefe 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,7 @@ require("taskwarrior").setup({ confirm = true, -- show confirmation dialog before applying sort = "urgency-", -- default sort (field+ for asc, field- for desc) group = nil, -- default group field (nil to disable) + wrap = true, -- line-wrap task buffers (false = one line per task) fields = nil, -- fields to show (nil = all) capture_key = "ta", -- global quick-capture (nil to disable) open_key = "tt", -- global open-task-buffer (nil to disable) diff --git a/doc/taskwarrior.txt b/doc/taskwarrior.txt index 6f7fd0d..f79fdc9 100644 --- a/doc/taskwarrior.txt +++ b/doc/taskwarrior.txt @@ -374,6 +374,7 @@ Full schema with defaults: > confirm = true, sort = "urgency-", group = nil, + wrap = true, -- false = don't line-wrap task buffers fields = nil, capture_key = "ta", open_key = "tt", diff --git a/lua/taskwarrior/apply.lua b/lua/taskwarrior/apply.lua index 8afeda2..3251058 100644 --- a/lua/taskwarrior/apply.lua +++ b/lua/taskwarrior/apply.lua @@ -194,14 +194,20 @@ function M.on_write(bufnr, refresh_fn, do_apply_fn) prompt = string.format("Apply %d change(s)?", #actions) end - vim.ui.select(choices, { prompt = prompt }, function(choice) - if not choice or choice == "Cancel" then - vim.notify("taskwarrior.nvim: cancelled") - vim.fn.delete(tmpfile) - return - end - local apply_force = choice == "Apply force (overwrite external changes)" - do_apply_fn(bufnr, tmpfile, on_delete, { force = apply_force }) + -- Deferred via vim.schedule: on_write runs inside the BufWriteCmd + -- handler, and float-based vim.ui.select backends (dressing, snacks, + -- telescope, …) opened from an autocmd context can't take focus — the + -- picker renders but the cursor stays in the task buffer (issue #3). + vim.schedule(function() + vim.ui.select(choices, { prompt = prompt }, function(choice) + if not choice or choice == "Cancel" then + vim.notify("taskwarrior.nvim: cancelled") + vim.fn.delete(tmpfile) + return + end + local apply_force = choice == "Apply force (overwrite external changes)" + do_apply_fn(bufnr, tmpfile, on_delete, { force = apply_force }) + end) end) else if not force then diff --git a/lua/taskwarrior/buffer.lua b/lua/taskwarrior/buffer.lua index 0ffa7c4..6d3f90f 100644 --- a/lua/taskwarrior/buffer.lua +++ b/lua/taskwarrior/buffer.lua @@ -112,13 +112,8 @@ local function apply_custom_sort(bufnr) -- Export tasks to get full data for the custom function local filter = vim.b[bufnr].task_filter or "" local export_filter = filter ~= "" and filter or "status:pending" - local cmd = string.format("task rc.bulk=0 rc.confirmation=off rc.json.array=on %s export", export_filter) - local out, ok = run(cmd) - if not ok or not out or out == "" then return end - local json_start = out:find("%[") - if json_start and json_start > 1 then out = out:sub(json_start) end - local parsed_ok, tasks = pcall(vim.fn.json_decode, out) - if not parsed_ok or type(tasks) ~= "table" then return end + local tasks = require("taskwarrior.taskmd").shell_export(export_filter) + if not tasks then return end -- Apply multiplicative urgency coefficients (same logic as taskmd.lua render). -- Non-numeric values go through user-configurable mappers. @@ -332,23 +327,38 @@ end M._relative_date = relative_date -- exported for e2e testing +local es_ns = vim.api.nvim_create_namespace("taskwarrior_empty_state") + +-- Visible empty-state hint. A zero-task render is just the header comment, +-- which conceallevel=3 hides entirely — the user sees a blank buffer with no +-- explanation of why, and refresh looks like a no-op (issue #5). Rendered as +-- virt_lines, not buffer text, so `:w` round-trips untouched. +local function apply_empty_state(bufnr) + vim.api.nvim_buf_clear_namespace(bufnr, es_ns, 0, -1) + for _, line in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) do + if uuid_from_line(line) then return end + end + local cfg_ok, cfg = pcall(require, "taskwarrior.config") + local prefix = (cfg_ok and cfg.options.command_prefix) or "Tw" + local filter = vim.b[bufnr].task_filter + local shown = (filter and filter ~= "") and filter or "(all pending)" + pcall(vim.api.nvim_buf_set_extmark, bufnr, es_ns, 0, 0, { + virt_lines = { + { { "", "Comment" } }, + { { " No tasks match filter: " .. shown, "Comment" } }, + { { (" :%sFilter to change it · :%sAdd to create a task"):format(prefix, prefix), "Comment" } }, + }, + }) +end +M._apply_empty_state = apply_empty_state -- exported for testing + local function apply_virtual_text(bufnr) vim.api.nvim_buf_clear_namespace(bufnr, vt_ns, 0, -1) + apply_empty_state(bufnr) local filter = vim.b[bufnr].task_filter or "" local export_filter = filter ~= "" and filter or "status:pending" - local cmd = string.format( - "task rc.bulk=0 rc.confirmation=off rc.json.array=on %s export", export_filter) - local out, ok = run(cmd) - if not ok or not out or out == "" then return end - - -- Handle warnings before JSON - local json_start = out:find("%[") - if json_start and json_start > 1 then - out = out:sub(json_start) - end - - local parsed_ok, tasks = pcall(vim.fn.json_decode, out) - if not parsed_ok or type(tasks) ~= "table" then return end + local tasks = require("taskwarrior.taskmd").shell_export(export_filter) + if not tasks then return end local config = require("taskwarrior.config") local icons = require("taskwarrior.icons") @@ -1107,7 +1117,8 @@ function M.setup_buf_autocmds(bufnr, on_write_fn) -- Wrap prevents horizontal-scroll disorientation on j/k with long task -- lines. Without wrap, curswant preservation causes the viewport to -- shift horizontally, making it look like j "doesn't work". - vim.wo[0].wrap = true + -- Overridable via setup{ wrap = false } (issue #3). + vim.wo[0].wrap = require("taskwarrior.config").options.wrap ~= false end, }) @@ -1252,7 +1263,7 @@ function M.open_task_buf(filter_str, on_write_fn, detect_project_fn) vim.api.nvim_win_set_buf(0, b) vim.wo[0].conceallevel = 3 vim.wo[0].concealcursor = "nvic" - vim.wo[0].wrap = true + vim.wo[0].wrap = config.options.wrap ~= false -- Reserve two sign cells for priority + status glyphs. `auto:2` only -- shows the column when a sign is present, so tasks with no priority -- or status don't waste horizontal space. @@ -1286,7 +1297,7 @@ function M.open_task_buf(filter_str, on_write_fn, detect_project_fn) vim.api.nvim_win_set_buf(0, bufnr) vim.wo[0].conceallevel = 3 vim.wo[0].concealcursor = "nvic" - vim.wo[0].wrap = true + vim.wo[0].wrap = config.options.wrap ~= false -- Set name safely — wipe stale buffer with same name if needed local buf_name = "Tasks: " .. filter_str @@ -1350,7 +1361,7 @@ function M.open_float(filter_str) }) vim.wo[win].conceallevel = 3 vim.wo[win].concealcursor = "nvic" - vim.wo[win].wrap = true + vim.wo[win].wrap = config.options.wrap ~= false vim.keymap.set("n", "q", function() pcall(vim.api.nvim_win_close, win, true) diff --git a/lua/taskwarrior/config.lua b/lua/taskwarrior/config.lua index 4630ff2..6bfd837 100644 --- a/lua/taskwarrior/config.lua +++ b/lua/taskwarrior/config.lua @@ -19,6 +19,11 @@ M.defaults = { confirm = true, -- show confirmation dialog before applying sort = "urgency-", -- default sort group = nil, -- default group field (nil to disable) + -- Line wrap in task buffers/floats. Default true: without wrap, curswant + -- preservation on long task lines makes j/k shift the viewport + -- horizontally ("j doesn't work"). Set false for one-line-per-task + -- (issue #3); long lines then extend past the right edge. + wrap = true, fields = nil, -- fields to show (nil = all) capture_key = "ta", -- global keybind for quick capture (nil to disable) open_key = "tt", -- global keybind to open task buffer (nil to disable) diff --git a/lua/taskwarrior/delegate.lua b/lua/taskwarrior/delegate.lua index e233a46..92e0040 100644 --- a/lua/taskwarrior/delegate.lua +++ b/lua/taskwarrior/delegate.lua @@ -23,19 +23,11 @@ function M.delegate_one() vim.notify("taskwarrior.nvim: no UUID on this line", vim.log.levels.WARN) return end - local out, ok = run( - string.format("task rc.bulk=0 rc.confirmation=off rc.json.array=on %s export", short_uuid)) - if not ok or not out or out == "" then + local tasks = require("taskwarrior.taskmd").shell_export(short_uuid) + if not tasks or #tasks == 0 then vim.notify("taskwarrior.nvim: failed to export task", vim.log.levels.ERROR) return end - local json_start = out:find("%[") - if json_start and json_start > 1 then out = out:sub(json_start) end - local parsed_ok, tasks = pcall(vim.fn.json_decode, out) - if not parsed_ok or type(tasks) ~= "table" or #tasks == 0 then - vim.notify("taskwarrior.nvim: failed to parse task", vim.log.levels.ERROR) - return - end return tasks[1], short_uuid end @@ -213,19 +205,11 @@ function M.collect(range) end local filter = table.concat(short_uuids, " ") - local out, ok = run( - string.format("task rc.bulk=0 rc.confirmation=off rc.json.array=on %s export", filter)) - if not ok or not out or out == "" then + local tasks = require("taskwarrior.taskmd").shell_export(filter) + if not tasks or #tasks == 0 then vim.notify("taskwarrior.nvim: failed to export tasks", vim.log.levels.ERROR) return nil end - local json_start = out:find("%[") - if json_start and json_start > 1 then out = out:sub(json_start) end - local parsed_ok, tasks = pcall(vim.fn.json_decode, out) - if not parsed_ok or type(tasks) ~= "table" or #tasks == 0 then - vim.notify("taskwarrior.nvim: failed to parse tasks", vim.log.levels.ERROR) - return nil - end -- Preserve the order in which short_uuids appeared in the buffer. local by_short = {} diff --git a/lua/taskwarrior/graph.lua b/lua/taskwarrior/graph.lua index 57a8f26..9eb5832 100644 --- a/lua/taskwarrior/graph.lua +++ b/lua/taskwarrior/graph.lua @@ -47,14 +47,8 @@ end function M.render(filter) filter = filter or "status:pending" - local cmd = string.format( - "task rc.bulk=0 rc.confirmation=off rc.json.array=on %s export", filter) - local out, ok = run(cmd) - if not ok or not out or out == "" then return nil end - local js = out:find("%[") - if js and js > 1 then out = out:sub(js) end - local parsed_ok, tasks = pcall(vim.fn.json_decode, out) - if not parsed_ok or type(tasks) ~= "table" then return nil end + local tasks = require("taskwarrior.taskmd").shell_export(filter) + if not tasks then return nil end local lines = { "# taskwarrior.nvim — dependency graph", diff --git a/lua/taskwarrior/inbox.lua b/lua/taskwarrior/inbox.lua index 364ff46..70c3d9a 100644 --- a/lua/taskwarrior/inbox.lua +++ b/lua/taskwarrior/inbox.lua @@ -20,18 +20,12 @@ function M.run(hours) -- Taskwarrior's entry.after: expects an ISO-like date. `now-Nh` is simpler. local filter = string.format( "status:pending entry.after:now-%dh project: -TAGGED", hours) - local cmd = string.format( - "task rc.bulk=0 rc.confirmation=off rc.json.array=on %s export", filter) - local out, ok = run(cmd) - if not ok then + local tasks = require("taskwarrior.taskmd").shell_export(filter) + if not tasks then require("taskwarrior.notify")("error", "taskwarrior.nvim: failed to fetch inbox", vim.log.levels.ERROR) return end - local js = out:find("%[") - if js and js > 1 then out = out:sub(js) end - local parsed_ok, tasks = pcall(vim.fn.json_decode, out) - if not parsed_ok or type(tasks) ~= "table" then tasks = {} end -- Guard: also filter client-side, because TW's `project:` (empty) filter -- can be finicky across TW versions. diff --git a/lua/taskwarrior/init.lua b/lua/taskwarrior/init.lua index beefa43..cb42472 100644 --- a/lua/taskwarrior/init.lua +++ b/lua/taskwarrior/init.lua @@ -199,9 +199,31 @@ function M._setup_commands() end -- Completion callbacks (ArgLead, CmdLine, CursorPos) -> list of strings -function M._complete_filter(arg_lead, cmd_line, _cursor_pos) - local words = vim.split(cmd_line, "%s+") - return complete_filter(words[#words] or "") +-- +-- These back vim.fn.input() prompts, where replaces the ENTIRE typed +-- text with the chosen candidate (input() has no per-word completion). Each +-- candidate must therefore carry the untouched leading tokens as a prefix — +-- returning bare last-token candidates wipes the rest of the filter +-- (issue #4). +local function complete_last_token(cmd_line, complete_fn) + local prefix, last = cmd_line:match("^(.*%s)(%S*)$") + if not prefix then prefix, last = "", cmd_line end + local results = complete_fn(last or "") + if prefix ~= "" then + for i, r in ipairs(results) do results[i] = prefix .. r end + end + return results +end + +function M._complete_filter(_arg_lead, cmd_line, _cursor_pos) + return complete_last_token(cmd_line or "", complete_filter) +end + +-- Completion for the `gm` modify prompt (buffer.lua). Modify args share the +-- filter vocabulary (+tag, project:, priority:, due:, …). Registered as +-- "custom," completion, so return newline-separated matches. +function M._complete_modify(_arg_lead, cmd_line, _cursor_pos) + return table.concat(complete_last_token(cmd_line or "", complete_filter), "\n") end function M._complete_sort(arg_lead, _cmd_line, _cursor_pos) @@ -228,15 +250,7 @@ M.api = {} M.api.export = function(filter_args) filter_args = filter_args or {} local filter_str = type(filter_args) == "table" and table.concat(filter_args, " ") or filter_args - local cmd = string.format("task rc.bulk=0 rc.confirmation=off rc.json.array=on %s export", - filter_str) - local out, ok = run(cmd) - if not ok or not out or out == "" then return {} end - local json_start = out:find("%[") - if json_start and json_start > 1 then out = out:sub(json_start) end - local parsed_ok, tasks = pcall(vim.fn.json_decode, out) - if not parsed_ok or type(tasks) ~= "table" then return {} end - return tasks + return require("taskwarrior.taskmd").shell_export(filter_str) or {} end M.api.get_task_on_cursor = function() diff --git a/lua/taskwarrior/modify.lua b/lua/taskwarrior/modify.lua index 1f7b324..3256737 100644 --- a/lua/taskwarrior/modify.lua +++ b/lua/taskwarrior/modify.lua @@ -141,13 +141,8 @@ end -- --------------------------------------------------------------------------- local function get_task_json(uuid) - local out, ok = run(string.format( - "task rc.bulk=0 rc.confirmation=off rc.json.array=on %s export", uuid)) - if not ok or not out or out == "" then return nil end - local json_start = out:find("%[") - if json_start and json_start > 1 then out = out:sub(json_start) end - local parsed_ok, arr = pcall(vim.fn.json_decode, out) - if not parsed_ok or type(arr) ~= "table" or not arr[1] then return nil end + local arr = require("taskwarrior.taskmd").shell_export(uuid) + if not arr or not arr[1] then return nil end return arr[1] end @@ -201,13 +196,8 @@ end --- Collect existing project names from pending tasks (de-duplicated, sorted). local function existing_projects() - local out, ok = run( - "task rc.bulk=0 rc.confirmation=off rc.json.array=on status:pending export") - if not ok or not out or out == "" then return {} end - local json_start = out:find("%[") - if json_start and json_start > 1 then out = out:sub(json_start) end - local parsed_ok, tasks = pcall(vim.fn.json_decode, out) - if not parsed_ok or type(tasks) ~= "table" then return {} end + local tasks = require("taskwarrior.taskmd").shell_export("status:pending") + if not tasks then return {} end local seen, names = {}, {} for _, t in ipairs(tasks) do if t.project and not seen[t.project] then @@ -221,13 +211,8 @@ end --- Collect existing tags from pending tasks. local function existing_tags() - local out, ok = run( - "task rc.bulk=0 rc.confirmation=off rc.json.array=on status:pending export") - if not ok or not out or out == "" then return {} end - local json_start = out:find("%[") - if json_start and json_start > 1 then out = out:sub(json_start) end - local parsed_ok, tasks = pcall(vim.fn.json_decode, out) - if not parsed_ok or type(tasks) ~= "table" then return {} end + local tasks = require("taskwarrior.taskmd").shell_export("status:pending") + if not tasks then return {} end local seen, names = {}, {} for _, t in ipairs(tasks) do for _, tag in ipairs(t.tags or {}) do diff --git a/lua/taskwarrior/statusline.lua b/lua/taskwarrior/statusline.lua index 8b64f17..0d805fb 100644 --- a/lua/taskwarrior/statusline.lua +++ b/lua/taskwarrior/statusline.lua @@ -17,12 +17,8 @@ end local function compute() local today = os.date("!%Y-%m-%d") - local out = vim.fn.system( - "task rc.bulk=0 rc.confirmation=off rc.json.array=on status:pending export") - local json_start = out:find("%[") - if json_start and json_start > 1 then out = out:sub(json_start) end - local ok, tasks = pcall(vim.fn.json_decode, out) - if not ok or type(tasks) ~= "table" then return "" end + local tasks = require("taskwarrior.taskmd").shell_export("status:pending") + if not tasks then return "" end local overdue = 0 local next_due, next_due_date, next_desc diff --git a/lua/taskwarrior/taskmd.lua b/lua/taskwarrior/taskmd.lua index 241028e..460bf21 100644 --- a/lua/taskwarrior/taskmd.lua +++ b/lua/taskwarrior/taskmd.lua @@ -30,6 +30,20 @@ local DATE_FIELDS = M.DATE_FIELDS local BASE_RC = { "rc.bulk=0", "rc.confirmation=off" } +-- rc overrides for READ-ONLY invocations whose stdout gets parsed (export +-- JSON, _udas/_projects/_tags line lists). vim.fn.system merges stderr into +-- stdout, so chatter must be suppressed at the source (issue #5): +-- rc.verbose=nothing — silences footnotes, the `task news` nag, and +-- "Configuration override" notices (which our own +-- rc.* args would otherwise trigger for users with +-- verbose=override). +-- rc.color=off — guards against forced-color configs injecting ANSI +-- escapes into the parse stream. +-- Hook output can still leak through; decode_json_array tolerates that. +-- Mutation paths keep BASE_RC — tw_add parses "Created task " from +-- verbose output, so silencing there would break it. +local READ_RC = { "rc.bulk=0", "rc.confirmation=off", "rc.verbose=nothing", "rc.color=off" } + -- --------------------------------------------------------------------------- -- Small helpers -- --------------------------------------------------------------------------- @@ -276,9 +290,93 @@ local function normalize_duration_minutes(args) return out end +-- Extract + decode the JSON array from `task … export` output. +-- +-- vim.fn.system merges stderr into stdout, so Taskwarrior chatter (the +-- `task news` nag, config-override notices, hook output) can precede or +-- follow the JSON — and may itself contain `[`/`]`. Slicing from the first +-- `[` (the pre-1.5.1 behavior) then fails to decode and callers silently +-- treated that as "zero tasks", rendering a blank buffer (issue #5). +-- Instead, try progressively narrower start/end bracket candidates until +-- one decodes. Returns a table, or nil if no slice parses. +function M.decode_json_array(text) + if not text then return nil end + -- Strip ANSI escape sequences first — forced-color configs put `\27[…m` + -- on every line, which both defeats bracket scanning and breaks decode. + text = text:gsub("\27%[[%d;:]*%a", "") + + local ok, parsed = pcall(vim.fn.json_decode, text) + if ok and type(parsed) == "table" then return parsed end + + local function try(s) + local o, p = pcall(vim.fn.json_decode, s) + if o and type(p) == "table" then return p end + return nil + end + + -- Candidate start positions. Prefer brackets that actually look like the + -- head of a task array — `[` followed by `{` or `]` (possibly across a + -- newline; TW3 prints one task per line) — over arbitrary `[`s inside + -- chatter. Candidate ends: every `]`, tried right-to-left (noise after + -- the JSON is rarer than noise before it, so the last `]` usually wins). + local starts = {} + for pos in text:gmatch("()%[%s*[{%]]") do starts[#starts + 1] = pos end + for pos in text:gmatch("()%[") do + if #starts >= 24 then break end + if not vim.tbl_contains(starts, pos) then starts[#starts + 1] = pos end + end + local ends = {} + for pos in text:gmatch("()%]") do ends[#ends + 1] = pos end + + local attempts = 0 + for _, i in ipairs(starts) do + for e = #ends, 1, -1 do + local last = ends[e] + if last > i then + attempts = attempts + 1 + local result = try(text:sub(i, last)) + if result then return result end + if attempts >= 64 then break end + end + end + if attempts >= 64 then break end + end + + -- Last resort for chatter interleaved *between* JSON lines (e.g. a hook + -- that prints once per task): both streams are line-buffered, so keep + -- only lines whose first character can appear in TW3's line-per-task + -- export format and re-join. + local kept = {} + for line in text:gmatch("[^\r\n]+") do + local head = line:match("^%s*(.)") + if head == "[" or head == "]" or head == "{" then kept[#kept + 1] = line end + end + if #kept > 0 then + return try(table.concat(kept, "\n")) + end + return nil +end + +-- Shell-string export for callers that assemble their filter as a plain +-- string (graph, inbox, views, telescope, …). The shell form lets us +-- redirect stderr away from the parse stream entirely — combined with +-- READ_RC suppression and decode_json_array this is the hardened +-- replacement for the ten hand-rolled `find("%[")` slices that issue #5 +-- exposed. Returns a list (possibly empty), or nil when the command failed +-- or its output was unparseable. +function M.shell_export(filter_str) + if not require("taskwarrior.runtime").ensure_available() then return nil end + local cmd = string.format( + "task %s rc.json.array=on %s export 2>/dev/null", + table.concat(READ_RC, " "), filter_str or "") + local out = vim.fn.system(cmd) + if vim.v.shell_error ~= 0 and vim.v.shell_error ~= 1 then return nil end + return M.decode_json_array(out) +end + function M.tw_export(filter_args) local argv = { "task" } - for _, a in ipairs(BASE_RC) do argv[#argv + 1] = a end + for _, a in ipairs(READ_RC) do argv[#argv + 1] = a end argv[#argv + 1] = "rc.json.array=on" for _, a in ipairs(normalize_tag_filters(normalize_duration_minutes(filter_args))) do argv[#argv + 1] = a end argv[#argv + 1] = "export" @@ -286,12 +384,14 @@ function M.tw_export(filter_args) if rc ~= 0 and rc ~= 1 then error("task export failed: " .. tostring(text)) end - if not text or text == "" then return {} end - local i = text:find("%[") - if i and i > 1 then text = text:sub(i) end - if not i then return {} end - local ok, parsed = pcall(vim.fn.json_decode, text) - if not ok or type(parsed) ~= "table" then return {} end + if not text or vim.trim(text) == "" then return {} end + local parsed = M.decode_json_array(text) + if not parsed then + -- Don't degrade to "zero tasks": that renders as a blank buffer with no + -- error and makes refresh a silent no-op (issue #5). Surface what task + -- actually printed so the failure is diagnosable. + error("task export returned unparseable output:\n" .. text:sub(1, 500)) + end return parsed end @@ -370,25 +470,38 @@ M.tw_start = simple_tw("start") M.tw_stop = simple_tw("stop") function M.tw_udas() - local out, _ = run({ "task", "_udas" }) + local argv = { "task" } + for _, a in ipairs(READ_RC) do argv[#argv + 1] = a end + argv[#argv + 1] = "_udas" + local out, _ = run(argv) local known = list_to_set(KNOWN_FIELDS) known["priority"] = true local result = {} for line in (out or ""):gmatch("[^\r\n]+") do local u = trim(line) - if u ~= "" and not known[u] then result[#result + 1] = u end + -- UDA names cannot contain whitespace — a line with spaces is stray + -- chatter (news nag, hook output) merged into stdout, not a UDA. + if u ~= "" and not u:find("%s") and not known[u] then result[#result + 1] = u end end return result end function M.tw_completions() - local p_out = run({ "task", "_projects" }) - local t_out = run({ "task", "_tags" }) + local function helper(cmd) + local argv = { "task" } + for _, a in ipairs(READ_RC) do argv[#argv + 1] = a end + argv[#argv + 1] = cmd + return run(argv) + end + local p_out = helper("_projects") + local t_out = helper("_tags") local function lines(s) local out = {} for line in (s or ""):gmatch("[^\r\n]+") do local v = trim(line) - if v ~= "" then out[#out + 1] = v end + -- Project and tag names cannot contain whitespace — lines with spaces + -- are stray chatter merged into stdout, not completion candidates. + if v ~= "" and not v:find("%s") then out[#out + 1] = v end end return out end diff --git a/lua/taskwarrior/validate.lua b/lua/taskwarrior/validate.lua index 973669d..15fba07 100644 --- a/lua/taskwarrior/validate.lua +++ b/lua/taskwarrior/validate.lua @@ -6,7 +6,7 @@ local M = {} -- All valid top-level setup keys (explicit list — includes nil-defaulted keys -- that pairs(defaults) would skip). local KNOWN_KEYS = { - "on_delete", "confirm", "sort", "group", "fields", + "on_delete", "confirm", "sort", "group", "fields", "wrap", "capture_key", "open_key", "filter_key", "sort_key", "group_key", "project_add_key", "filters", "projects", "icons", "border_style", "capture_width", "capture_height", "capture_confirm_close", @@ -30,6 +30,7 @@ local KNOWN_DELEGATE_KEYS = { local TOP_LEVEL_TYPES = { on_delete = "string", confirm = "boolean", + wrap = "boolean", sort = "string", group = "string", -- nil OK fields = "table", -- nil OK diff --git a/lua/taskwarrior/views.lua b/lua/taskwarrior/views.lua index 61d3625..6347c83 100644 --- a/lua/taskwarrior/views.lua +++ b/lua/taskwarrior/views.lua @@ -12,14 +12,7 @@ end local function export_tasks(filter) filter = filter or "status:pending or status:completed" - local cmd = string.format("task rc.bulk=0 rc.confirmation=off rc.json.array=on %s export", filter) - local out, ok = run(cmd) - if not ok or not out or out == "" then return {} end - local json_start = out:find("%[") - if json_start and json_start > 1 then out = out:sub(json_start) end - local parsed_ok, tasks = pcall(vim.fn.json_decode, out) - if not parsed_ok or type(tasks) ~= "table" then return {} end - return tasks + return require("taskwarrior.taskmd").shell_export(filter) or {} end -- Highlight groups for views diff --git a/lua/telescope/_extensions/task.lua b/lua/telescope/_extensions/task.lua index 84e9901..bcd9f03 100644 --- a/lua/telescope/_extensions/task.lua +++ b/lua/telescope/_extensions/task.lua @@ -15,15 +15,7 @@ local action_state = require("telescope.actions.state") local previewers = require("telescope.previewers") local function tw_export(filter) - local cmd = string.format( - "task rc.bulk=0 rc.confirmation=off rc.json.array=on %s export", - filter or "status:pending") - local out = vim.fn.system(cmd) - local json_start = out:find("%[") - if json_start and json_start > 1 then out = out:sub(json_start) end - local parsed_ok, tasks = pcall(vim.fn.json_decode, out) - if not parsed_ok or type(tasks) ~= "table" then return {} end - return tasks + return require("taskwarrior.taskmd").shell_export(filter or "status:pending") or {} end local function entry_for(task) diff --git a/tests/e2e/spec/e2e_spec.lua b/tests/e2e/spec/e2e_spec.lua index 3b8bf15..c7cf55d 100644 --- a/tests/e2e/spec/e2e_spec.lua +++ b/tests/e2e/spec/e2e_spec.lua @@ -1192,6 +1192,13 @@ describe("e2e confirm-dialog apply path", function() stub_select("Apply") vim.api.nvim_buf_call(bufnr, function() vim.cmd("silent! write") end) + -- The confirm picker is vim.schedule()d out of the BufWriteCmd context + -- (issue #3: float-based ui.select backends can't take focus inside an + -- autocmd) — drain the loop until the stubbed answer has been applied. + vim.wait(2000, function() + local cur = task_export("uuid:" .. t.uuid:sub(1, 8))[1] + return cur and cur.status == "completed" + end, 10) restore_ui() local t_after = task_export("uuid:" .. t.uuid:sub(1, 8))[1] @@ -1216,6 +1223,8 @@ describe("e2e confirm-dialog apply path", function() stub_select("Cancel") vim.api.nvim_buf_call(bufnr, function() vim.cmd("silent! write") end) + -- Drain the vim.schedule()d confirm picker (see the Apply test above). + vim.wait(300, function() return false end, 10) restore_ui() local t_after = task_export("uuid:" .. t.uuid:sub(1, 8))[1] @@ -1224,6 +1233,93 @@ describe("e2e confirm-dialog apply path", function() end) end) +-- ========================================================================= +-- Issue #5 — render must survive a hook that pollutes export output +-- ========================================================================= + +describe("e2e noisy-hook resilience (issue #5)", function() + local hooks_dir = os.getenv("TASKDATA") .. "/hooks" + local hook_path = hooks_dir .. "/on-launch.99-noise.sh" + + local function write_hook() + vim.fn.mkdir(hooks_dir, "p") + local f = assert(io.open(hook_path, "w")) + f:write("#!/bin/sh\necho 'E2E NOISE [hook chatter] before JSON'\nexit 0\n") + f:close() + vim.fn.system("chmod +x " .. vim.fn.shellescape(hook_path)) + end + + local function remove_hook() + vim.fn.delete(hook_path) + end + + it("renders tasks even when an on-launch hook prints to stdout", function() + run_shell('task rc.confirmation=off rc.bulk=0 add "Noise survivor" project:hooknoise') + write_hook() + -- Sanity probe: does the hook actually pollute the raw export stream on + -- this Taskwarrior version? That's the exact condition of issue #5. + -- (Recorded into the assert message; the render must survive either way.) + local raw = vim.fn.system( + "task rc.bulk=0 rc.confirmation=off rc.json.array=on status:pending export") + local polluted = raw:find("E2E NOISE", 1, true) ~= nil + + -- Fresh window on an empty scratch buffer so a failed open can't + -- inherit task lines from a previous test's buffer (spurious pass). + vim.cmd("enew") + local ok, err = pcall(function() + require("taskwarrior").open("project:hooknoise") + end) + local bufnr = vim.api.nvim_get_current_buf() + local has_task = false + for _, l in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) do + if l:find("uuid:", 1, true) then has_task = true break end + end + remove_hook() + + assert.is_true(ok, "render must not error under hook noise: " .. tostring(err)) + assert.is_true(has_task, + ("render must show tasks despite hook noise (raw stream polluted: %s)"):format( + tostring(polluted))) + end) +end) + +-- ========================================================================= +-- Issue #3 — wrap option / Issue #5 — visible empty state (real render) +-- ========================================================================= + +describe("e2e wrap option + empty state", function() + it("setup{ wrap = false } opens the task buffer with wrap off", function() + local config = require("taskwarrior.config") + local saved = config.options.wrap + config.options.wrap = false + require("taskwarrior").open("project:demo") + assert.is_false(vim.wo[0].wrap, "wrap=false must disable line wrap (issue #3)") + config.options.wrap = saved + end) + + it("default keeps wrap on", function() + require("taskwarrior").open("project:demo") + assert.is_true(vim.wo[0].wrap) + end) + + it("a filter matching zero tasks shows the empty-state hint, not a blank buffer", function() + require("taskwarrior").open("project:definitely-does-not-exist-xyz") + local bufnr = vim.api.nvim_get_current_buf() + local es_ns = vim.api.nvim_create_namespace("taskwarrior_empty_state") + local marks = vim.api.nvim_buf_get_extmarks(bufnr, es_ns, 0, -1, { details = true }) + assert.is_true(#marks > 0, + "zero-task render must show a visible hint instead of a concealed header (issue #5)") + assert.truthy(vim.inspect(marks):find("definitely%-does%-not%-exist")) + end) + + it("a filter matching tasks shows no empty-state hint", function() + require("taskwarrior").open("project:demo") + local bufnr = vim.api.nvim_get_current_buf() + local es_ns = vim.api.nvim_create_namespace("taskwarrior_empty_state") + assert.are.same({}, vim.api.nvim_buf_get_extmarks(bufnr, es_ns, 0, -1, {})) + end) +end) + -- ========================================================================= -- Tier 1: :TaskUndo -- ========================================================================= diff --git a/tests/lua/spec/issue_fixes_spec.lua b/tests/lua/spec/issue_fixes_spec.lua new file mode 100644 index 0000000..b763087 --- /dev/null +++ b/tests/lua/spec/issue_fixes_spec.lua @@ -0,0 +1,287 @@ +-- Regression specs for GitHub issues #3, #4, #5. +-- +-- #5 — :Tw opened a blank buffer with no error. Root cause chain: +-- vim.fn.system merges stderr into stdout, so any Taskwarrior chatter +-- (news nag, override notices, hook output) around the JSON made +-- tw_export's parse fail, which it silently treated as "zero tasks"; +-- a zero-task render is only the concealed header comment → visually +-- blank, and :TwRefresh re-renders the same thing → "no effect". +-- #4 — in the :TwFilter/tf input() replaced the ENTIRE typed +-- filter with the completed token (input() completion is whole-line). +-- Bonus: the gm modify prompt referenced _complete_modify which was +-- never defined, so there raised E117. +-- #3 — wrap is now configurable (setup{ wrap = false }); the Apply/Cancel +-- picker focus fix is exercised by the e2e confirm-mode tests. + +local eq = assert.are.same + +describe("issue #5 — decode_json_array robustness", function() + local tm = require("taskwarrior.taskmd") + local TASK = '{"uuid":"abcd1234-0000-0000-0000-000000000000","description":"noisy","status":"pending"}' + + it("decodes clean output", function() + local t = tm.decode_json_array("[" .. TASK .. "]") + eq(1, #t) + eq("noisy", t[1].description) + end) + + it("decodes an empty array", function() + eq({}, tm.decode_json_array("[]")) + end) + + it("decodes with leading noise (task news nag)", function() + local out = "Recently upgraded. Please run 'task news'.\n[" .. TASK .. "]\n" + eq("noisy", tm.decode_json_array(out)[1].description) + end) + + it("decodes with trailing noise", function() + local out = "[" .. TASK .. "]\nThere are 3 local changes. Sync required.\n" + eq("noisy", tm.decode_json_array(out)[1].description) + end) + + it("decodes when surrounding noise itself contains brackets", function() + local out = "warning [hook on-launch] something\n[" + .. TASK .. "]\nnote: see task(1) [manual]\n" + eq("noisy", tm.decode_json_array(out)[1].description) + end) + + it("returns nil on garbage instead of a bogus table", function() + eq(nil, tm.decode_json_array("task: unexpected error [code 5]")) + eq(nil, tm.decode_json_array("no brackets at all")) + end) + + it("decodes output wrapped in ANSI color escapes (forced-color configs)", function() + local out = "\27[33mPlease run 'task news'.\27[0m\n\27[32m[" + .. TASK .. "]\27[0m\n" + eq("noisy", tm.decode_json_array(out)[1].description) + end) + + it("decodes when many bracketed noise lines precede the JSON", function() + local noise = {} + for i = 1, 15 do noise[#noise + 1] = ("[warn %d] chatter line"):format(i) end + local out = table.concat(noise, "\n") .. "\n[" .. TASK .. "]\n" + eq("noisy", tm.decode_json_array(out)[1].description) + end) + + it("decodes TW3 line-per-task output with chatter interleaved between lines", function() + local out = table.concat({ + "hook says hi", + "[", + TASK .. ",", + "hook says hi again", + '{"uuid":"beef0002-0000-0000-0000-000000000000","description":"second","status":"pending"}', + "]", + "trailing chatter", + }, "\n") + local t = tm.decode_json_array(out) + assert.is_not_nil(t, "interleaved chatter must not defeat the decoder") + eq(2, #t) + eq("second", t[2].description) + end) +end) + +describe("issue #5 — line-list helpers reject chatter lines", function() + local runtime = require("taskwarrior.runtime") + local orig_system, orig_executable + + before_each(function() + orig_system, orig_executable = vim.fn.system, vim.fn.executable + runtime._reset_for_tests() + vim.fn.executable = function(_) return 1 end + end) + + after_each(function() + vim.fn.system, vim.fn.executable = orig_system, orig_executable + runtime._reset_for_tests() + end) + + local function stub_task_output(text) + vim.fn.system = function(_) + orig_system("true") + return text + end + end + + it("tw_completions drops nag lines from _projects/_tags", function() + stub_task_output("Please run 'task news'.\nwork\nhome\n") + local c = require("taskwarrior.taskmd").tw_completions() + eq({ "work", "home" }, c.projects) + eq({ "work", "home" }, c.tags) + end) + + it("tw_udas drops nag lines from _udas", function() + stub_task_output("There are 3 local changes. Sync required.\nutility\nbrainpower\n") + eq({ "utility", "brainpower" }, require("taskwarrior.taskmd").tw_udas()) + end) +end) + +describe("issue #5 — tw_export must not silently return zero tasks", function() + local runtime = require("taskwarrior.runtime") + local orig_system, orig_executable + + before_each(function() + orig_system, orig_executable = vim.fn.system, vim.fn.executable + runtime._reset_for_tests() + vim.fn.executable = function(_) return 1 end + end) + + after_each(function() + vim.fn.system, vim.fn.executable = orig_system, orig_executable + runtime._reset_for_tests() + end) + + -- Sets vim.v.shell_error to 0 (it is read-only, so run a real command), + -- then returns the fake merged stdout+stderr. + local function stub_task_output(text) + vim.fn.system = function(_) + orig_system("true") + return text + end + end + + it("parses tasks despite stderr noise merged into the output", function() + stub_task_output("Configuration override rc.json.array:on\n" + .. "Please run 'task news'.\n" + .. '[{"uuid":"abcd1234-0000-0000-0000-000000000000","description":"survives","status":"pending"}]\n' + .. "TASKRC override: /tmp/x\n") + local tasks = require("taskwarrior.taskmd").tw_export({}) + eq(1, #tasks) + eq("survives", tasks[1].description) + end) + + it("raises a diagnosable error on unparseable output (was: silent {})", function() + stub_task_output("task: something exploded [see log]\n") + local ok, err = pcall(require("taskwarrior.taskmd").tw_export, {}) + assert.is_false(ok) + assert.truthy(tostring(err):find("unparseable", 1, true)) + end) +end) + +describe("issue #5 — visible empty state", function() + local buffer = require("taskwarrior.buffer") + local es_ns = vim.api.nvim_create_namespace("taskwarrior_empty_state") + + local function make_buf(lines, filter) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.b[bufnr].task_filter = filter + return bufnr + end + + it("adds a virt_lines hint when the render has no task lines", function() + local bufnr = make_buf({ + "", + "", + }, "project:nope") + buffer._apply_empty_state(bufnr) + local marks = vim.api.nvim_buf_get_extmarks(bufnr, es_ns, 0, -1, { details = true }) + assert.is_true(#marks > 0, "empty render must show a visible hint (issue #5)") + local text = vim.inspect(marks) + assert.truthy(text:find("project:nope", 1, true)) + end) + + it("adds no hint when a task line is present", function() + local bufnr = make_buf({ + "", + "", + "- [ ] real task ", + }, "") + buffer._apply_empty_state(bufnr) + eq({}, vim.api.nvim_buf_get_extmarks(bufnr, es_ns, 0, -1, {})) + end) + + it("clears a stale hint once tasks appear", function() + local bufnr = make_buf({ "" }, "x") + buffer._apply_empty_state(bufnr) + vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { "- [ ] now here " }) + buffer._apply_empty_state(bufnr) + eq({}, vim.api.nvim_buf_get_extmarks(bufnr, es_ns, 0, -1, {})) + end) +end) + +describe("issue #4 — input() completion preserves the typed prefix", function() + local completion = require("taskwarrior.completion") + local orig_get + + before_each(function() + orig_get = completion.get_tw_completions + completion.get_tw_completions = function() + return { projects = { "home", "work" }, tags = { "next", "later" }, fields = {} } + end + end) + + after_each(function() + completion.get_tw_completions = orig_get + end) + + it("completes the last token and keeps everything before it", function() + local results = require("taskwarrior")._complete_filter("", "project:home +n", 15) + eq({ "project:home +next" }, results) + end) + + it("completes a bare single token unchanged", function() + local results = require("taskwarrior")._complete_filter("", "+la", 3) + eq({ "+later" }, results) + end) + + it("offers all candidates after a trailing space, prefixed", function() + local results = require("taskwarrior")._complete_filter("", "project:home ", 13) + assert.is_true(#results > 0) + for _, r in ipairs(results) do + assert.truthy(r:find("project:home ", 1, true) == 1, + "candidate lost the typed prefix: " .. r) + end + end) + + it("_complete_modify is defined (gm raised E117 before) and prefixes", function() + local tw = require("taskwarrior") + assert.is_function(tw._complete_modify) + local out = tw._complete_modify("", "due:tomorrow pr", 15) + assert.is_string(out) -- "custom," completion → newline-separated string + assert.truthy(out:find("due:tomorrow pr", 1, true) == 1) + end) +end) + +describe("lint — completion= v:lua references resolve", function() + -- The gm prompt shipped pointing at _complete_modify, which didn't exist; + -- raised E117 (part of issue #4). Nothing type-checks a string + -- completion= spec, so scan the source for every such reference and + -- assert the target function is actually defined. + it("every v:lua.require'taskwarrior'.X used in a completion spec exists", function() + local root = vim.fn.fnamemodify( + vim.api.nvim_get_runtime_file("lua/taskwarrior/init.lua", false)[1], ":h") + local tw = require("taskwarrior") + local checked = 0 + for _, path in ipairs(vim.fn.glob(root .. "/**/*.lua", false, true)) do + local f = assert(io.open(path, "r")) + local src = f:read("*a") + f:close() + for name in src:gmatch("v:lua%.require'taskwarrior'%.([%w_]+)") do + checked = checked + 1 + assert.is_function(tw[name], + ("%s references v:lua taskwarrior.%s which is not a function"):format(path, name)) + end + end + assert.is_true(checked >= 4, "expected to find completion specs, found " .. checked) + end) +end) + +describe("issue #3 — wrap config option", function() + local config = require("taskwarrior.config") + + it("defaults to true", function() + config.setup({}) + eq(true, config.options.wrap) + end) + + it("accepts wrap = false", function() + config.setup({ wrap = false }) + eq(false, config.options.wrap) + end) + + it("rejects a non-boolean wrap", function() + local ok = pcall(config.setup, { wrap = "no" }) + assert.is_false(ok) + config.setup({}) -- restore sane defaults for later specs + end) +end)