diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4be4384f..a0aecd79 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ on: jobs: luacheck: name: Luacheck - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -27,37 +27,34 @@ jobs: stylua: name: StyLua - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - name: Stylua uses: JohnnyMorganz/stylua-action@v4 with: token: ${{ secrets.GITHUB_TOKEN }} - version: v2.0.2 + version: v2.3.0 args: --check lua tests typecheck: name: typecheck - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: stevearc/nvim-typecheck-action@v2 with: path: lua - libraries: https://github.com/nvim-neotest/neotest https://github.com/mfussenegger/nvim-dap https://github.com/akinsho/toggleterm.nvim + libraries: https://github.com/nvim-neotest/neotest https://github.com/mfussenegger/nvim-dap run_tests: strategy: matrix: nvim_tag: - - v0.8.3 - - v0.9.4 - - v0.10.0 - - v0.10.4 + - v0.11.0 name: Run tests - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 env: NVIM_TAG: ${{ matrix.nvim_tag }} steps: @@ -73,7 +70,7 @@ jobs: update_docs: name: Update docs - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -110,7 +107,7 @@ jobs: - typecheck - run_tests - update_docs - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - uses: googleapis/release-please-action@v4 id: release diff --git a/Makefile b/Makefile index 9d8607ca..b76c2882 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,37 @@ -.PHONY: all doc test lint fastlint clean - +## help: print this help message +.PHONY: help +help: + @echo 'Usage:' + @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' + +## all: generate docs, lint, and run tests +.PHONY: all all: doc lint test -doc: scripts/nvim_doc_tools - python scripts/main.py generate - python scripts/main.py lint +venv: + python3 -m venv venv + venv/bin/pip install -r scripts/requirements.txt + +## doc: generate documentation +.PHONY: doc +doc: scripts/nvim_doc_tools venv + venv/bin/python scripts/main.py generate + venv/bin/python scripts/main.py lint +## test: run tests +.PHONY: test test: ./run_tests.sh +## lint: run linters and LuaLS typechecking +.PHONY: lint lint: scripts/nvim-typecheck-action fastlint - ./scripts/nvim-typecheck-action/typecheck.sh --lib https://github.com/nvim-neotest/neotest --lib https://github.com/mfussenegger/nvim-dap --lib https://github.com/akinsho/toggleterm.nvim --workdir scripts/nvim-typecheck-action lua + ./scripts/nvim-typecheck-action/typecheck.sh --lib https://github.com/nvim-neotest/neotest --lib https://github.com/mfussenegger/nvim-dap --workdir scripts/nvim-typecheck-action lua -fastlint: scripts/nvim_doc_tools - python scripts/main.py lint +## fastlint: run only fast linters +.PHONY: fastlint +fastlint: scripts/nvim_doc_tools venv + venv/bin/python scripts/main.py lint luacheck lua tests --formatter plain stylua --check lua tests @@ -23,5 +41,7 @@ scripts/nvim_doc_tools: scripts/nvim-typecheck-action: git clone https://github.com/stevearc/nvim-typecheck-action scripts/nvim-typecheck-action +## clean: reset the repository to a clean state +.PHONY: clean clean: rm -rf scripts/nvim_doc_tools scripts/nvim-typecheck-action venv .testenv diff --git a/README.md b/README.md index b833898a..1f48e203 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,9 @@ A task runner and job management plugin for Neovim - [Custom tasks](doc/guides.md#custom-tasks) - [Actions](doc/guides.md#actions) - [Custom components](doc/guides.md#custom-components) + - [Task events](doc/guides.md#task-events) - [Customizing built-in tasks](doc/guides.md#customizing-built-in-tasks) + - [Customizing the task appearance in the task list](doc/guides.md#customizing-the-task-appearance-in-the-task-list) - [Parsing output](doc/guides.md#parsing-output) - [Running tasks sequentially](doc/guides.md#running-tasks-sequentially) - [VS Code tasks](doc/guides.md#vs-code-tasks) @@ -30,14 +32,15 @@ A task runner and job management plugin for Neovim - [Heirline](doc/third_party.md#heirline) - [Neotest](doc/third_party.md#neotest) - [DAP](doc/third_party.md#dap) - - [ToggleTerm](doc/third_party.md#toggleterm) - [Session managers](doc/third_party.md#session-managers) - [Recipes](#recipes) + - [Run a quick command like with `:!` or `:term`](doc/recipes.md#run-a-quick-command-like-with--or-term) - [Restart last task](doc/recipes.md#restart-last-task) - [Run shell scripts in the current directory](doc/recipes.md#run-shell-scripts-in-the-current-directory) - [Directory-local tasks with exrc](doc/recipes.md#directory-local-tasks-with-exrc) - - [:Make similar to vim-dispatch](doc/recipes.md#make-similar-to-vim-dispatch) + - [Asynchronous :Make similar to vim-dispatch](doc/recipes.md#asynchronous-make-similar-to-vim-dispatch) - [Asynchronous :Grep command](doc/recipes.md#asynchronous-grep-command) + - [Create a window that displays the most recent task output](doc/recipes.md#create-a-window-that-displays-the-most-recent-task-output) - [Reference](#reference) - [Setup options](doc/reference.md#setup-options) - [Commands](doc/reference.md#commands) @@ -45,7 +48,6 @@ A task runner and job management plugin for Neovim - [Lua API](doc/reference.md#lua-api) - [Components](doc/reference.md#components) - [Strategies](doc/reference.md#strategies) - - [Parsers](doc/reference.md#parsers) - [Parameters](doc/reference.md#parameters) - [Screenshots](#screenshots) @@ -54,7 +56,7 @@ A task runner and job management plugin for Neovim ## Features - Built-in support for many task frameworks (make, npm, cargo, `.vscode/tasks.json`, etc) -- Simple integration with vim.diagnostics and quickfix +- Simple integration with `vim.diagnostic` and quickfix - UI for viewing and managing tasks - Quick controls for common actions (restart task, rerun on save, or user-defined functions) - Extreme customizability. Very easy to attach custom logic to tasks @@ -63,7 +65,7 @@ A task runner and job management plugin for Neovim ## Requirements -- Neovim 0.8+ (for older versions, use the [nvim-0.7 branch](https://github.com/stevearc/overseer.nvim/tree/nvim-0.7)) +- Neovim 0.11+ (for older versions, use a [nvim-0.x branch](https://github.com/stevearc/overseer.nvim/branches)) ## Installation @@ -75,6 +77,8 @@ overseer supports all the usual plugin managers ```lua { 'stevearc/overseer.nvim', + ---@module 'overseer' + ---@type overseer.SetupOpts opts = {}, } ``` @@ -85,11 +89,13 @@ overseer supports all the usual plugin managers Packer ```lua -require('packer').startup(function() - use { - 'stevearc/overseer.nvim', - config = function() require('overseer').setup() end - } +require("packer").startup(function() + use({ + "stevearc/overseer.nvim", + config = function() + require("overseer").setup() + end, + }) end) ``` @@ -99,9 +105,9 @@ end) Paq ```lua -require "paq" { - {'stevearc/overseer.nvim'}; -} +require("paq")({ + { "stevearc/overseer.nvim" }, +}) ``` @@ -148,14 +154,14 @@ git clone --depth=1 https://github.com/stevearc/overseer.nvim.git \ Add the following to your init.lua ```lua -require('overseer').setup() +require("overseer").setup() ``` To get started, all you need to know is `:OverseerRun` to select and start a task, and `:OverseerToggle` to open the task list. https://user-images.githubusercontent.com/506791/189036898-05edcd62-42e7-4bbb-ace2-746b7c8c567b.mp4 -If you don't see any tasks from `:OverseerRun`, it might mean that your task runner is not yet supported. There is currently support for VS Code tasks, make, npm, cargo, and some others. If yours is not supported, ([request support here](https://github.com/stevearc/overseer.nvim/issues/new/choose)). +If you don't see any tasks from `:OverseerRun`, it might mean that your task runner is not yet supported. There is currently support for VS Code tasks, make, npm, cargo, and some others. If you want to define custom tasks for your project, I'd recommend starting with [the tutorials](doc/tutorials.md). @@ -173,7 +179,9 @@ If you want to define custom tasks for your project, I'd recommend starting with - [Custom components](doc/guides.md#custom-components) - [Component aliases](doc/guides.md#component-aliases) - [Task result](doc/guides.md#task-result) +- [Task events](doc/guides.md#task-events) - [Customizing built-in tasks](doc/guides.md#customizing-built-in-tasks) +- [Customizing the task appearance in the task list](doc/guides.md#customizing-the-task-appearance-in-the-task-list) - [Parsing output](doc/guides.md#parsing-output) - [Running tasks sequentially](doc/guides.md#running-tasks-sequentially) - [VS Code tasks](doc/guides.md#vs-code-tasks) @@ -183,6 +191,7 @@ If you want to define custom tasks for your project, I'd recommend starting with - [Architecture](doc/explanation.md#architecture) - [Tasks](doc/explanation.md#tasks) - [Components](doc/explanation.md#components) + - [Serializability](doc/explanation.md#serializability) - [Templates](doc/explanation.md#templates) - [Task list](doc/explanation.md#task-list) - [Task editor](doc/explanation.md#task-editor) @@ -195,18 +204,19 @@ If you want to define custom tasks for your project, I'd recommend starting with - [Heirline](doc/third_party.md#heirline) - [Neotest](doc/third_party.md#neotest) - [DAP](doc/third_party.md#dap) -- [ToggleTerm](doc/third_party.md#toggleterm) - [Session managers](doc/third_party.md#session-managers) - [resession.nvim](doc/third_party.md#resessionnvim) - [Other session managers](doc/third_party.md#other-session-managers) ## Recipes +- [Run a quick command like with `:!` or `:term`](doc/recipes.md#run-a-quick-command-like-with--or-term) - [Restart last task](doc/recipes.md#restart-last-task) - [Run shell scripts in the current directory](doc/recipes.md#run-shell-scripts-in-the-current-directory) - [Directory-local tasks with exrc](doc/recipes.md#directory-local-tasks-with-exrc) -- [:Make similar to vim-dispatch](doc/recipes.md#make-similar-to-vim-dispatch) +- [Asynchronous :Make similar to vim-dispatch](doc/recipes.md#asynchronous-make-similar-to-vim-dispatch) - [Asynchronous :Grep command](doc/recipes.md#asynchronous-grep-command) +- [Create a window that displays the most recent task output](doc/recipes.md#create-a-window-that-displays-the-most-recent-task-output) ## Reference @@ -215,30 +225,49 @@ If you want to define custom tasks for your project, I'd recommend starting with - [Highlight groups](doc/reference.md#highlight-groups) - [Lua API](doc/reference.md#lua-api) - [setup(opts)](doc/reference.md#setupopts) - - [on_setup(callback)](doc/reference.md#on_setupcallback) - [new_task(opts)](doc/reference.md#new_taskopts) - [toggle(opts)](doc/reference.md#toggleopts) - [open(opts)](doc/reference.md#openopts) - [close()](doc/reference.md#close) - - [list_task_bundles()](doc/reference.md#list_task_bundles) - - [load_task_bundle(name, opts)](doc/reference.md#load_task_bundlename-opts) - - [save_task_bundle(name, tasks, opts)](doc/reference.md#save_task_bundlename-tasks-opts) - - [delete_task_bundle(name)](doc/reference.md#delete_task_bundlename) - [list_tasks(opts)](doc/reference.md#list_tasksopts) - - [run_template(opts, callback)](doc/reference.md#run_templateopts-callback) + - [run_task(opts, callback)](doc/reference.md#run_taskopts-callback) - [preload_task_cache(opts, cb)](doc/reference.md#preload_task_cacheopts-cb) - [clear_task_cache(opts)](doc/reference.md#clear_task_cacheopts) - [run_action(task, name)](doc/reference.md#run_actiontask-name) - - [wrap_template(base, override, default_params)](doc/reference.md#wrap_templatebase-override-default_params) - [add_template_hook(opts, hook)](doc/reference.md#add_template_hookopts-hook) - [remove_template_hook(opts, hook)](doc/reference.md#remove_template_hookopts-hook) - [register_template(defn)](doc/reference.md#register_templatedefn) - - [load_template(name)](doc/reference.md#load_templatename) - - [debug_parser()](doc/reference.md#debug_parser) - - [register_alias(name, components)](doc/reference.md#register_aliasname-components) + - [register_alias(name, components, override)](doc/reference.md#register_aliasname-components-override) + - [create_task_output_view(winid, opts)](doc/reference.md#create_task_output_viewwinid-opts) + - [overseer.Task](doc/reference.md#overseertask) + - [Task:serialize()](doc/reference.md#taskserialize) + - [Task:clone()](doc/reference.md#taskclone) + - [Task:add_component(comp)](doc/reference.md#taskadd_componentcomp) + - [Task:add_components(components)](doc/reference.md#taskadd_componentscomponents) + - [Task:set_component(comp)](doc/reference.md#taskset_componentcomp) + - [Task:set_components(components)](doc/reference.md#taskset_componentscomponents) + - [Task:get_component(name)](doc/reference.md#taskget_componentname) + - [Task:remove_component(name)](doc/reference.md#taskremove_componentname) + - [Task:remove_components(names)](doc/reference.md#taskremove_componentsnames) + - [Task:has_component(name)](doc/reference.md#taskhas_componentname) + - [Task:subscribe(event, callback)](doc/reference.md#tasksubscribeevent-callback) + - [Task:unsubscribe(event, callback)](doc/reference.md#taskunsubscribeevent-callback) + - [Task:is_pending()](doc/reference.md#taskis_pending) + - [Task:is_running()](doc/reference.md#taskis_running) + - [Task:is_complete()](doc/reference.md#taskis_complete) + - [Task:is_disposed()](doc/reference.md#taskis_disposed) + - [Task:get_bufnr()](doc/reference.md#taskget_bufnr) + - [Task:open_output(direction)](doc/reference.md#taskopen_outputdirection) + - [Task:broadcast(name)](doc/reference.md#taskbroadcastname) + - [Task:dispatch(name)](doc/reference.md#taskdispatchname) + - [Task:inc_reference()](doc/reference.md#taskinc_reference) + - [Task:dec_reference()](doc/reference.md#taskdec_reference) + - [Task:dispose(force)](doc/reference.md#taskdisposeforce) + - [Task:restart(force_stop)](doc/reference.md#taskrestartforce_stop) + - [Task:start()](doc/reference.md#taskstart) + - [Task:stop()](doc/reference.md#taskstop) - [Components](doc/reference.md#components) - [Strategies](doc/reference.md#strategies) -- [Parsers](doc/reference.md#parsers) - [Parameters](doc/reference.md#parameters) ## Screenshots diff --git a/autoload/overseer.vim b/autoload/overseer.vim deleted file mode 100644 index 268310ff..00000000 --- a/autoload/overseer.vim +++ /dev/null @@ -1,3 +0,0 @@ -function! overseer#task_bundle_completelist(arglead, cmdline, cursorpos) abort - return luaeval('require("overseer.task_bundle").list_task_bundles()') -endfunction diff --git a/doc/components.md b/doc/components.md index 8dbe08c2..a6871ac7 100644 --- a/doc/components.md +++ b/doc/components.md @@ -3,14 +3,13 @@ - [dependencies](#dependencies) -- [display_duration](#display_duration) - [on_complete_dispose](#on_complete_dispose) - [on_complete_notify](#on_complete_notify) - [on_complete_restart](#on_complete_restart) - [on_exit_set_status](#on_exit_set_status) +- [on_output_notify](#on_output_notify) - [on_output_parse](#on_output_parse) - [on_output_quickfix](#on_output_quickfix) -- [on_output_summarize](#on_output_summarize) - [on_output_write_file](#on_output_write_file) - [on_result_diagnostics](#on_result_diagnostics) - [on_result_diagnostics_quickfix](#on_result_diagnostics_quickfix) @@ -30,22 +29,12 @@ Set dependencies for task -| Param | Type | Default | Desc | -| ----------- | -------------- | ------- | ---------------------------------- | -| *task_names | `list[string]` | | Names of dependency task templates | -| sequential | `boolean` | `false` | | +| Param | Type | Default | Desc | +| ---------- | -------------- | ------- | ---------------------------------- | +| sequential | `boolean` | `false` | | +| tasks | `list[string]` | | Names of dependency task templates | -- **task_names:** This can be a list of strings (template names, e.g. {"cargo build"}), tables (name with params, e.g. {"shell", cmd = "sleep 10"}), or tables (raw task params, e.g. {cmd = "sleep 10"}) - -## display_duration - -[display_duration.lua](../lua/overseer/component/display_duration.lua) - -Display the run duration - -| Param | Type | Default | Desc | -| ------------ | --------- | ------- | -------------------------------------- | -| detail_level | `integer` | `1` | Show the duration at this detail level | +- **tasks:** This can be a list of strings (template names, e.g. "cargo build"), tables (template name with params, e.g. {"mytask", foo = "bar"}), or tables (raw task params, e.g. {cmd = "sleep 10"}) ## on_complete_dispose @@ -94,6 +83,27 @@ Sets final task status based on exit code | ------------- | --------------- | -------------------------------------------- | | success_codes | `list[integer]` | Additional exit codes to consider as success | +## on_output_notify + +[on_output_notify.lua](../lua/overseer/component/on_output_notify.lua) + +Use nvim-notify to show notification with task output summary for long-running tasks + +Works like on_complete_notify but, for long-running commands, also shows real-time output summary. +Requires nvim-notify to modify the last notification window when new output arrives instead of +creating new notification. + +| Param | Type | Default | Desc | +| ------------------ | --------- | ------- | ---------------------------------------------------------------------------------------- | +| delay_ms | `number` | `2000` | Time in milliseconds to wait before displaying the notification during task runtime | +| max_lines | `integer` | `1` | Number of lines of output to show | +| max_width | `integer` | `49` | Maximum output width | +| output_on_complete | `boolean` | `false` | Show the last lines of task output and status on completion (instead of only the status) | +| trim | `boolean` | `true` | Remove whitespace from both sides of each line | + +- **output_on_complete:** When output_on_complete==true: shows status + last output lines during task runtime and after completion. +When output_on_complete==false: shows status + last output lines during task runtime and only status after completion. + ## on_output_parse [on_output_parse.lua](../lua/overseer/component/on_output_parse.lua) @@ -102,11 +112,15 @@ Parses task output and sets task result | Param | Type | Desc | | ------------------ | -------- | -------------------------------------------------------------------- | -| parser | `opaque` | Parser definition to extract values from output | +| parser | `opaque` | Parse function or overseer.OutputParser | | problem_matcher | `opaque` | VS Code-style problem matcher | -| relative_file_root | `string` | Relative filepaths will be joined to this root (instead of task cwd) | +| errorformat | `opaque` | Errorformat string | | precalculated_vars | `opaque` | Precalculated VS Code task variables | +| relative_file_root | `string` | Relative filepaths will be joined to this root (instead of task cwd) | +- **parser:** This can be a function that takes a line of output and (optionally) returns a quickfix-list item (see :help |setqflist-what|). For more complex parsing, this should be a class of type overseer.OutputParser. +- **problem_matcher:** Only one of 'parser', 'problem_matcher', or 'errorformat' is allowed. +- **errorformat:** Only one of 'parser', 'problem_matcher', or 'errorformat' is allowed. - **precalculated_vars:** Tasks that are started from the VS Code provider precalculate certain interpolated variables (e.g. ${workspaceFolder}). We pass those in as params so they will remain stable even if Neovim's state changes in between creating and running (or restarting) the task. ## on_output_quickfix @@ -130,16 +144,6 @@ Set all task output into the quickfix (on complete) - **tail:** This may cause unexpected results for commands that produce "fancy" output using terminal escape codes (e.g. animated progress indicators) -## on_output_summarize - -[on_output_summarize.lua](../lua/overseer/component/on_output_summarize.lua) - -Summarize task output in the task list - -| Param | Type | Default | Desc | -| --------- | --------- | ------- | ------------------------------------------------- | -| max_lines | `integer` | `4` | Number of lines of output to show when detail > 1 | - ## on_output_write_file [on_output_write_file.lua](../lua/overseer/component/on_output_write_file.lua) @@ -227,12 +231,12 @@ Open task output Restart on any buffer :write -| Param | Type | Default | Desc | -| --------- | -------------- | ----------- | ------------------------------------------------------------------------- | -| delay | `number` | `500` | How long to wait (in ms) before triggering restart | -| interrupt | `boolean` | `true` | Interrupt running tasks | -| mode | `enum` | `"autocmd"` | How to watch the paths (`"autocmd"\|"uv"`) | -| paths | `list[string]` | | Only restart when writing files in these paths (can be directory or file) | +| Param | Type | Default | Desc | +| --------- | -------------- | ----------- | ----------------------------------------------------------------------------------- | +| delay | `number` | `500` | How long to wait (in ms) before triggering restart | +| interrupt | `boolean` | `true` | Interrupt running tasks. If false, will wait for task to complete before restarting | +| mode | `enum` | `"autocmd"` | How to watch the paths (`"autocmd"\|"uv"`) | +| paths | `list[string]` | | Only restart when writing files in these paths (can be directory or file) | - **mode:** 'autocmd' will set autocmds on BufWritePost. 'uv' will use a libuv file watcher (recursive watching may not be supported on all platforms). @@ -242,14 +246,14 @@ Restart on any buffer :write Run other tasks after this task completes -| Param | Type | Default | Desc | -| ----------- | -------------- | ------------- | ------------------------------------------------------------- | -| *task_names | `list[string]` | | Names of dependency task templates | -| detach | `boolean` | `false` | Tasks created will not be linked to the parent task | -| statuses | `list[enum]` | `["SUCCESS"]` | Only run successive tasks if the final status is in this list | +| Param | Type | Default | Desc | +| -------- | -------------- | ------------- | ------------------------------------------------------------- | +| detach | `boolean` | `false` | Tasks created will not be linked to the parent task | +| statuses | `list[enum]` | `["SUCCESS"]` | Only run successive tasks if the final status is in this list | +| tasks | `list[string]` | | Names of dependency task templates | -- **task_names:** This can be a list of strings (template names, e.g. {"cargo build"}), tables (name with params, e.g. {"shell", cmd = "sleep 10"}), or tables (raw task params, e.g. {cmd = "sleep 10"}) - **detach:** This means they will not restart when the parent restarts, and will not be disposed when the parent is disposed +- **tasks:** This can be a list of strings (template names, e.g. "cargo build"), tables (template name with params, e.g. {"mytask", foo = "bar"}), or tables (raw task params, e.g. {cmd = "sleep 10"}) ## timeout @@ -271,6 +275,7 @@ Ensure that this task does not have any duplicates | ------------------ | --------- | ------- | ----------------------------------------------------------------------------------------------------------- | | replace | `boolean` | `true` | If a prior task exists, replace it. When false, will restart the existing task and dispose the current task | | restart_interrupts | `boolean` | `true` | When replace = false, should restarting the existing task interrupt it | +| soft | `boolean` | `false` | Only dispose duplicate tasks if they are completed. Implies replace = true. | - **replace:** Note that when this is false a new task that is created will restart the existing one and _dispose itself_. This can lead to unexpected behavior if you are creating a task and then trying to use that reference (to run actions on it, use it as a dependency, etc) diff --git a/doc/explanation.md b/doc/explanation.md index b13d0861..0007e897 100644 --- a/doc/explanation.md +++ b/doc/explanation.md @@ -5,6 +5,7 @@ - [Architecture](#architecture) - [Tasks](#tasks) - [Components](#components) + - [Serializability](#serializability) - [Templates](#templates) - [Task list](#task-list) - [Task editor](#task-editor) @@ -18,19 +19,17 @@ ### Tasks Tasks represent a single command that is run. They appear in the [task list](#task-list), where you -can manage them (start/stop/restart/edit/open terminal). You can create them directly, either with -`:OverseerBuild` or via the API `require('overseer.task').new()`. - -Most of the time, however, you will find it most convenient to create them using -[templates](#templates). +can manage them (start/stop/restart/edit/open terminal). You can create them directly with the +[new_task()](reference.md#new_taskopts) method. Most of the time, however, you will find it most +convenient to create them using [templates](#templates). ### Components Tasks are built using an [entity component system](https://en.wikipedia.org/wiki/Entity_component_system). By itself, all a task does is run a command in a terminal. Components are used to add more functionality. There are components to -display a summary of the output in the [task list](#task-list), to show a notification when the task -finishes running, and to set the task results into neovim diagnostics. +parse and handle output, to show a notification when the task +finishes running, and to re-run the task when files change. Components are designed to be easy to remove, customize, or replace. If you want to customize some aspect or behavior of a task, it's likely that it will be done through components. @@ -38,8 +37,15 @@ aspect or behavior of a task, it's likely that it will be done through component See [custom components](guides.md#custom-components) for how to customize them or define your own, and [components](components.md) for a list of built-in components. -**Note**: both tasks and components are designed to be serializable. They avoid putting things like -functions in their constructors, and as a result can easily be serialized and saved to disk. +### Serializability + +Both tasks and components are designed to be serializable. They generally avoid putting +unserializable structures like functions in their constructors, and as a result can easily be +serialized and saved to disk. However, even if they use functions they can still sometimes be +serializable. For tasks that are created from a template, instead of serializing the raw task and +component data, we serialize the values that will find and create the same task when passed to +`overseer.run_task`. So if a task template passes a function to a task, that task _can_ still be +serialized and saved to disk. ### Templates @@ -57,9 +63,8 @@ tasks](guides.md#custom-tasks) for more. Control the task list with `:OverseerOpen`, `:OverseerClose`, and `:OverseerToggle`. The task list displays all tasks that have been created. It shows the task status, name, and a -summary of the task output (controlled by the `on_output_summarize` component). You can show more or -less detail for a single task with `` and `` (by default), or for all tasks with `L` and -`H`. +summary of the task output. You can customize the display by passing in a custom `task_list.render` +function in the setup opts. `?` will show you a list of all the keybindings, and `` will open up a menu of all [actions](guides.md#actions) that you can perform on the selected task. @@ -75,8 +80,8 @@ The task editor allows you to change the components on a task by hand. You shoul often (if you find yourself frequently making the same edits, consider turning that into an [action](guides.md#actions)), but it can be useful for experimentation and tweaking values on the fly. -There are two ways to get to the task editor: `:OverseerBuild` will open it on a new task, and for -existing tasks (that are not running) you can use the `edit` action. +To open the editor for a task, use the `edit` action (open the overseer task list, `` on the +task, select `edit`). For the most part you can edit the values like a normal buffer, but there is a lot of magic involved to produce a "form-like" experience. For enum fields, you can autocomplete the possible values with @@ -113,19 +118,13 @@ disposed. **Q: How can I debug when something goes wrong?** -Run `:OverseerInfo` to view the available tasks and information about why certain tasks are not -available. It will also show you the location of the log file. If you need, you can crank up the +Run `:checkhealth overseer` to view the available tasks and information about why certain tasks are +not available. It will also show you the location of the log file. If you need, you can crank up the detail of the logs by adjusting the level: ```lua overseer.setup({ - log = { - { - type = "file", - filename = "overseer.log", - level = vim.log.levels.DEBUG, -- or TRACE for max verbosity - }, - }, + log_level = vim.log.levels.TRACE, }) ``` diff --git a/doc/extending_vscode.md b/doc/extending_vscode.md index 521fb09d..cee69a08 100644 --- a/doc/extending_vscode.md +++ b/doc/extending_vscode.md @@ -4,10 +4,10 @@ VS Code extensions can add new task types, problem matchers, and patterns. This ## Task types -To define a custom task type, simply add a new module to the neovim path. For a new type called "cowsay", you would add it to `lua/overseer/template/vscode/provider/cowsay.lua`. The format of the module is as follows: +To define a custom task type, simply add a new module to the neovim path. For a new type called "cowsay", you would add it to `lua/overseer/vscode/provider/cowsay.lua`. The format of the module is as follows: ```lua --- lua/overseer/template/vscode/provider/cowsay.lua +-- lua/overseer/vscode/provider/cowsay.lua local M = {} ---@param defn table This is the decoded JSON data for the task @@ -28,7 +28,7 @@ end return M ``` -You can see how the existing task types were implemented in the [overseer/template/vscode/provider](../lua/overseer/template/vscode/provider) folder. +You can see how the existing task types were implemented in the [overseer/vscode/provider](../lua/overseer/vscode/provider) folder. ## Problem matchers and patterns @@ -67,4 +67,4 @@ M.problem_matchers = { return M ``` -You can see the existing patterns and problem matchers in [problem_matcher.lua](../lua/overseer/template/vscode/problem_matcher.lua) +You can see the existing patterns and problem matchers in [problem_matcher.lua](../lua/overseer/vscode/problem_matcher.lua) diff --git a/doc/guides.md b/doc/guides.md index 6eb84ffe..4f901b60 100644 --- a/doc/guides.md +++ b/doc/guides.md @@ -9,7 +9,9 @@ - [Custom components](#custom-components) - [Component aliases](#component-aliases) - [Task result](#task-result) +- [Task events](#task-events) - [Customizing built-in tasks](#customizing-built-in-tasks) +- [Customizing the task appearance in the task list](#customizing-the-task-appearance-in-the-task-list) - [Parsing output](#parsing-output) - [Running tasks sequentially](#running-tasks-sequentially) - [VS Code tasks](#vs-code-tasks) @@ -30,39 +32,24 @@ overseer.register_template({ **2) as a module** -Similar to [custom components](#custom-components), templates can be lazy-loaded from a module in the `overseer.template` namespace. It is recommended that you namespace your tasks inside of a folder (e.g. `overseer/template/myplugin/first_task.lua`, referenced as `myplugin.first_task`). To load them, you would pass the require path in setup: - -```lua -overseer.setup({ - templates = { "builtin", "myplugin.first_task" }, -}) --- You can also load them separately from setup -overseer.load_template("myplugin.second_task") -``` - -If you have multiple templates that you would like to expose as a bundle, you can create an alias module. For example, put the following into `overseer/template/myplugin/init.lua`: - -```lua -return { "myplugin.first_task", "myplugin.second_task" } -``` - -This is how `builtin` references all of the different built-in templates. +Similar to [custom components](#custom-components), templates can be lazy-loaded from a module in the `overseer.template` namespace. So if you put a task inside `/lua/overseer/template/first_task.lua`, overseer will automatically detect and load it. ### Template definition The definition of a template looks like this: ```lua -{ +---@type overseer.TemplateFileDefinition +return { -- Required fields name = "Some Task", builder = function(params) -- This must return an overseer.TaskDefinition return { - -- cmd is the only required field - cmd = {'echo'}, - -- additional arguments for the cmd - args = {"hello", "world"}, + -- cmd is the only required field. It can be a list or a string. + cmd = { "echo", "hello", "world" }, + -- additional arguments for the cmd (usually only useful if cmd is a string) + args = {}, -- the name of the task (defaults to the cmd of the task) name = "Greet", -- set the working directory for the task @@ -72,7 +59,7 @@ The definition of a template looks like this: VAR = "FOO", }, -- the list of components or component aliases to add to the task - components = {"my_custom_component", "default"}, + components = { "my_custom_component", "default" }, -- arbitrary table of data for your own personal use metadata = { foo = "bar", @@ -81,64 +68,82 @@ The definition of a template looks like this: end, -- Optional fields desc = "Optional description of task", - -- Tags can be used in overseer.run_template() + -- Tags can be used in overseer.run_task() tags = {overseer.TAG.BUILD}, params = { -- See :help overseer-params }, - -- Determines sort order when choosing tasks. Lower comes first. - priority = 50, -- Add requirements for this template. If they are not met, the template will not be visible. -- All fields are optional. condition = { -- A string or list of strings -- Only matches when current buffer is one of the listed filetypes - filetype = {"c", "cpp"}, + filetype = { "c", "cpp" }, -- A string or list of strings -- Only matches when cwd is inside one of the listed dirs dir = "/home/user/my_project", - -- Arbitrary logic for determining if task is available - callback = function(search) - print(vim.inspect(search)) - return true - end, }, } ``` ### Template providers -Template providers are used to generate multiple templates dynamically. The main use case is generating one task per target (e.g. for a makefile), but can be used for any situation where you want the templates themselves to be generated at runtime. +Template providers are used to generate multiple templates dynamically. The main use case is +generating one task per target (e.g. for a makefile), but can be used for any situation where you +want the templates themselves to be generated at runtime. -Providers are created the same way templates are (with `overseer.register_template`, or by putting them in a module). The structure is as follows: +Providers are created the same way templates are (with `overseer.register_template`, or by putting +them in a lua file). The structure is as follows: ```lua -{ - generator = function(search, cb) - -- Pass a list of templates to the callback - -- See the built-in providers for make or npm for an example - cb({...}) +---@type overseer.TemplateFileProvider +return { + generator = function(search) + if not is_task_available() then + return "Task is not available for reason X" + end + -- return a list of tasks + return {...} end, -- Optional. Same as template.condition condition = { - callback = function(search) - return true - end, + filetype = { "c" }, }, - -- Optional. Overrides the default cache key of `opts.dir` - -- Additionally, if the returned value is an absolute file path, - -- whenever that file is written overseer will automatically clear the cache + -- Optional. Some task generators may be slow and thus you may want to cache the results. + -- By providing a cache key (usually a config file or root directory), overseer will automatically + -- cache results from slow providers and will clear the cache when that file is written. cache_key = function(opts) - return vim.fs.find('Makefile', { upward = true, type = "file", path = opts.dir })[1] + return vim.fs.find("Makefile", { upward = true, type = "file", path = opts.dir })[1] + end, +} +``` + +If you want to do some asynchronous work while listing tasks (such as running a command with +`vim.system`), you can use the `callback` argument to the generator function. + +```lua +---@type overseer.TemplateFileProvider +return { + generator = function(search, callback) + do_some_work(function(err) + if err then + callback(err) + return + end + -- Pass a list of tasks to the callback + callback({...}) + end) end, } ``` ## Actions -Actions can be performed on tasks by using the `RunAction` keybinding in the task list, or by the `OverseerQuickAction` and `OverseerTaskAction` commands. They are simply a custom function that will do something to or with a task. +Actions can be performed on tasks by using the `keymap.run_action` keybinding in the task list, or +by the `OverseerTaskAction` command. Actions are simply custom functions that will do something to +or with a task. -Browse the set of built-in actions at [lua/overseer/task_list/actions.lua](../lua/overseer/task_list/actions.lua) +Browse the set of built-in actions at [lua/overseer/task_list/actions.lua](../lua/overseer/task_list/actions.lua). You can define your own or disable any of the built-in actions in the call to setup(): @@ -167,8 +172,8 @@ overseer.setup({ -- It will always be available in the "RunAction" menu, but it may be -- worth mapping it directly if you use it often. task_list = { - bindings = { - ["P"] = "OverseerQuickAction My custom action", + keymaps = { + ["P"] = { "keymap.run_action", opts = { action = "my action" }, desc = "Do something cool" }, }, }, }) @@ -185,6 +190,7 @@ Paths given are all relative to any runtimepath (`:help rtp`), so in practice it The component definition should look like the following example: ```lua +---@type overseer.ComponentFileDefinition return { desc = "Include a description of your component", -- Define parameters that can be passed in to the component @@ -254,15 +260,6 @@ return { -- Will be called IFF on_init was called, and will be called exactly once. -- This is a good place to free resources (e.g. timers, files, etc) end, - ---@param lines string[] The list of lines to render into - ---@param highlights table[] List of highlights to apply after rendering - ---@param detail number The detail level of the task. Ranges from 1 to 3. - render = function(self, task, lines, highlights, detail) - -- Called from the task list. This can be used to display information there. - table.insert(lines, "Here is a line of output") - -- The format is {highlight_group, lnum, col_start, col_end} - table.insert(highlights, { "Title", #lines, 0, -1 }) - end, } end, } @@ -294,6 +291,37 @@ A note on the Task result table: there is technically no schema for it, as the o **diagnostics**: This key is used for diagnostics. It should be a list of quickfix items (see `:help setqflist`) \ **error**: This key will be set when there is an internal overseer error when running the task +## Task events + +A lighter-weight alternative to custom components is directly subscribing to task events. Once you create a task you can call `task:subscribe("event", function() ... end)` to process the same events that get handled by components. For example, to run a function when a task completes: + +```lua +local task = overseer.new_task({ cmd = {"echo", "hello", "world"} }) +-- on_complete gets called with the same arguments as it does for components +task:subscribe("on_complete", function(_task, status, result) + print("Task", task.name, "finished with status", status) +end) +task:start() +``` + +To unsubscribe from an event, you can either pass the same function in to `task:unsubscribe()` or you can return a truthy value from the function. + +```lua +local task = overseer.new_task({ cmd = { "build_and_serve.sh" } }) +task:subscribe("on_output_lines", function(_task, lines) + for _, line in ipairs(lines) do + local address = line:match("^Serving at (http.*)") + if address then + vim.ui.open(address) + return true + end + end +end) +task:start() +``` + +Note that when a task is serialized it cannot save the subscriptions. + ## Customizing built-in tasks You may wish to customize the built-in task definitions, or tasks from another plugin. The simplest way to do this is using the [add_template_hook](reference.md#add_template_hookopts-hook) function. This allows you to run a function on the task definition (the arguments passed to [new_task](reference.md#new_taskopts)) and process it however you like. A common use case would be to add a component or modify the environment variables while in a specific project: @@ -303,46 +331,77 @@ overseer.add_template_hook({ dir = "/path/to/my/project", module = "^cargo$", }, function(task_defn, util) + -- The `util` parameter is just a namespace that exposes some useful functions + -- for mutating a task definition util.add_component(task_defn, { "on_output_quickfix", open = true }) + util.remove_component(task_defn, "on_complete_dispose") + if util.has_component(task_defn, "timeout") then + -- ... + end end) ``` +## Customizing the task appearance in the task list + +The task appearance can be customized via the `task_list.render` function in the [config](reference.md#setupopts). The render function is just a function that takes a task and returns a list of lines, where each line is a list of `[text, hl_group]` "chunks" (`:help nvim_echo` uses the same format). + +```lua +require("overseer").setup({ + task_list = { + render = function(task) + -- There are a few different built-in format functions + -- return require("overseer.render").format_compact(task) + -- return require("overseer.render").format_verbose(task) + return require("overseer.render").format_standard(task) + end, + }, +}) +``` + + +See more detailed documentation about rendering in [the rendering doc](rendering.md). + ## Parsing output -The primary way of parsing output with overseer is the `on_output_parse` component. +The primary way of parsing output with overseer is the [on_output_parse](components.md#on_output_parse) component. This can use a VS Code-style problem matcher, a function, or a vim errorformat to parse the output. ```lua --- Definition of a component that parses output in the form of: --- /path/to/file.txt:123: This is a message --- You would typically use this in the components list of a task definition returned by a template -{"on_output_parse", parser = { - -- Put the parser results into the 'diagnostics' field on the task result - diagnostics = { - -- Extract fields using lua patterns - -- To integrate with other components, items in the "diagnostics" result should match - -- vim's quickfix item format (:help setqflist) - { "extract", "^([^%s].+):(%d+): (.+)$", "filename", "lnum", "text" }, - } -}} +-- Using vim errorformat +{ "on_output_parse", errorformat = "%f:%l: %m" } + +-- Using VSCode problem matcher +{ "on_output_parse", problem_matcher = "$tsc" } + +-- Using a function +{ "on_output_parse", parser = function(line) + local fname, lnum, msg = line:match("^(.*):(%d+): (.*)$") + if fname then + return { + filename = fname, + lnum = tonumber(lnum), + text = msg + } + end +end } ``` -This is a simple example, but the parser library is flexible enough to parse nearly any output format. See more detailed documentation in [the parsers doc](parsers.md). +See more detailed documentation about parsers and `on_output_parse` in [the parsers doc](parsers.md). -You can of course create your own components to parse output leveraging the `on_output` or `on_output_lines` methods. The integration should be straightforward; see [on_output_parse.lua](../lua/overseer/component/on_output_parse.lua) to see how the built-in component leverages these methods. +You can also create your own components to parse output leveraging the `on_output` or `on_output_lines` methods. The integration should be straightforward; see [on_output_parse.lua](../lua/overseer/component/on_output_parse.lua) to see how the built-in component leverages these methods. ## Running tasks sequentially There are currently two ways to get tasks to run sequentially. The first is by using the [dependencies](components.md#dependencies) component. For example, if you wanted to create a `npm serve` task that runs `npm build` first, you could create it like so: ```lua -overseer.run_template({ name = "npm serve", autostart = false }, function(task) +overseer.run_task({ name = "npm serve", autostart = false }, function(task) if task then task:add_component({ "dependencies", - task_names = { + tasks = { "npm build", - -- You can also pass in params to the task - { "shell", cmd = "sleep 10" }, + -- You can also pass in a task object + { cmd = "sleep 10" }, }, sequential = true, }) @@ -362,7 +421,7 @@ local task = overseer.new_task({ "make clean", -- Step 1: clean { -- Step 2: build js and css in parallel "npm build", - { "shell", cmd = "lessc styles.less styles.css" }, + { cmd = { "lessc", "styles.less", "styles.css" }, }, "npm serve", -- Step 3: serve }, diff --git a/doc/overseer.txt b/doc/overseer.txt index 38b9e593..6efbe533 100644 --- a/doc/overseer.txt +++ b/doc/overseer.txt @@ -7,11 +7,10 @@ CONTENTS *overseer-content 2. Options |overseer-options| 3. Highlights |overseer-highlights| 4. Api |overseer-api| - 5. Components |overseer-components| - 6. Strategies |overseer-strategies| - 7. Parsers |overseer-parsers| - 8. Parameters |overseer-params| - 9. Actions |overseer-actions| + 5. Keymaps |overseer-keymaps| + 6. Components |overseer-components| + 7. Parameters |overseer-params| + 8. Actions |overseer-actions| -------------------------------------------------------------------------------- COMMANDS *overseer-commands* @@ -25,191 +24,108 @@ OverseerClose *:OverseerClos OverseerToggle[!] `left/right/bottom` *:OverseerToggle* Toggle the overseer window. With `!` cursor stays in current window -OverseerSaveBundle `[name]` *:OverseerSaveBundle* - Serialize and save the current tasks to disk - -OverseerLoadBundle[!] `[name]` *:OverseerLoadBundle* - Load tasks that were saved to disk. With `!` tasks will not be started - -OverseerDeleteBundle `[name]` *:OverseerDeleteBundle* - Delete a saved task bundle - -OverseerRunCmd `[command]` *:OverseerRunCmd* - Run a raw shell command - OverseerRun `[name/tags]` *:OverseerRun* Run a task from a template -OverseerInfo *:OverseerInfo* - Display diagnostic information about overseer - -OverseerBuild *:OverseerBuild* - Open the task builder - -OverseerQuickAction `[action]` *:OverseerQuickAction* - Run an action on the most recent task, or the task under the cursor +OverseerShell[!] `[command]` *:OverseerShell* + Run a shell command as an overseer task. With `!` the task is created but + not started OverseerTaskAction *:OverseerTaskAction* Select a task to run an action on -OverseerClearCache *:OverseerClearCache* - Clear the task cache - -------------------------------------------------------------------------------- OPTIONS *overseer-options* >lua require("overseer").setup({ - -- Default task strategy - strategy = "terminal", - -- Template modules to load - templates = { "builtin" }, - -- Directories where overseer will look for template definitions (relative to rtp) - template_dirs = { "overseer.template" }, - -- When true, tries to detect a green color from your colorscheme to use for success highlight - auto_detect_success_color = true, -- Patch nvim-dap to support preLaunchTask and postDebugTask dap = true, + -- Configure the task output buffer and window + output = { + -- Use a terminal buffer to display output. If false, a normal buffer is used + use_terminal = true, + -- If true, don't clear the buffer when a task restarts + preserve_output = false, + }, -- Configure the task list task_list = { - -- Default detail level for tasks. Can be 1-3. - default_detail = 1, + -- Default direction. Can be "left", "right", or "bottom" + direction = "bottom", -- Width dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%) -- min_width and max_width can be a single value or a list of mixed integer/float types. -- max_width = {100, 0.2} means "the lesser of 100 columns or 20% of total" max_width = { 100, 0.2 }, -- min_width = {40, 0.1} means "the greater of 40 columns or 10% of total" min_width = { 40, 0.1 }, - -- optionally define an integer/float for the exact width of the task list - width = nil, - max_height = { 20, 0.1 }, + max_height = { 20, 0.2 }, min_height = 8, - height = nil, -- String that separates tasks - separator = "────────────────────────────────────────", - -- Default direction. Can be "left", "right", or "bottom" - direction = "bottom", + separator = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + -- Indentation for child tasks + child_indent = { "┃ ", "┣━", "┗━" }, + -- Function that renders tasks. See lua/overseer/render.lua for built-in options + -- and for useful functions if you want to build your own. + render = function(task) + return require("overseer.render").format_standard(task) + end, + -- The sort function for tasks + sort = function(a, b) + return require("overseer.task_list").default_sort(a, b) + end, -- Set keymap to false to remove default behavior -- You can add custom keymaps here as well (anything vim.keymap.set accepts) - bindings = { - ["?"] = "ShowHelp", - ["g?"] = "ShowHelp", - [""] = "RunAction", - [""] = "Edit", - ["o"] = "Open", - [""] = "OpenVsplit", - [""] = "OpenSplit", - [""] = "OpenFloat", - [""] = "OpenQuickFix", - ["p"] = "TogglePreview", - [""] = "IncreaseDetail", - [""] = "DecreaseDetail", - ["L"] = "IncreaseAllDetail", - ["H"] = "DecreaseAllDetail", - ["["] = "DecreaseWidth", - ["]"] = "IncreaseWidth", - ["{"] = "PrevTask", - ["}"] = "NextTask", - [""] = "ScrollOutputUp", - [""] = "ScrollOutputDown", - ["q"] = "Close", + keymaps = { + ["?"] = "keymap.show_help", + ["g?"] = "keymap.show_help", + [""] = "keymap.run_action", + ["dd"] = { "keymap.run_action", opts = { action = "dispose" }, desc = "Dispose task" }, + [""] = { "keymap.run_action", opts = { action = "edit" }, desc = "Edit task" }, + ["o"] = "keymap.open", + [""] = { "keymap.open", opts = { dir = "vsplit" }, desc = "Open task output in vsplit" }, + [""] = { "keymap.open", opts = { dir = "split" }, desc = "Open task output in split" }, + [""] = { "keymap.open", opts = { dir = "tab" }, desc = "Open task output in tab" }, + [""] = { "keymap.open", opts = { dir = "float" }, desc = "Open task output in float" }, + [""] = { + "keymap.run_action", + opts = { action = "open output in quickfix" }, + desc = "Open task output in the quickfix", + }, + ["p"] = "keymap.toggle_preview", + ["{"] = "keymap.prev_task", + ["}"] = "keymap.next_task", + [""] = "keymap.scroll_output_up", + [""] = "keymap.scroll_output_down", + ["g."] = "keymap.toggle_show_wrapped", + ["q"] = { "close", desc = "Close task list" }, }, }, - -- See :help overseer-actions + -- Custom actions for tasks. See :help overseer-actions actions = {}, -- Configure the floating window used for task templates that require input -- and the floating window used for editing tasks form = { - border = "rounded", zindex = 40, -- Dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%) -- min_X and max_X can be a single value or a list of mixed integer/float types. min_width = 80, max_width = 0.9, - width = nil, min_height = 10, max_height = 0.9, - height = nil, - -- Set any window options here (e.g. winhighlight) - win_opts = { - winblend = 0, - }, - }, - task_launcher = { - -- Set keymap to false to remove default behavior - -- You can add custom keymaps here as well (anything vim.keymap.set accepts) - bindings = { - i = { - [""] = "Submit", - [""] = "Cancel", - }, - n = { - [""] = "Submit", - [""] = "Submit", - ["q"] = "Cancel", - ["?"] = "ShowHelp", - }, - }, - }, - task_editor = { - -- Set keymap to false to remove default behavior - -- You can add custom keymaps here as well (anything vim.keymap.set accepts) - bindings = { - i = { - [""] = "NextOrSubmit", - [""] = "Submit", - [""] = "Next", - [""] = "Prev", - [""] = "Cancel", - }, - n = { - [""] = "NextOrSubmit", - [""] = "Submit", - [""] = "Next", - [""] = "Prev", - ["q"] = "Cancel", - ["?"] = "ShowHelp", - }, - }, - }, - -- Configure the floating window used for confirmation prompts - confirm = { - border = "rounded", - zindex = 40, - -- Dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%) - -- min_X and max_X can be a single value or a list of mixed integer/float types. - min_width = 20, - max_width = 0.5, - width = nil, - min_height = 6, - max_height = 0.9, - height = nil, -- Set any window options here (e.g. winhighlight) - win_opts = { - winblend = 0, - }, + win_opts = {}, }, - -- Configuration for task floating windows + -- Configuration for task floating output windows task_win = { -- How much space to leave around the floating window padding = 2, - border = "rounded", -- Set any window options here (e.g. winhighlight) - win_opts = { - winblend = 0, - }, - }, - -- Configuration for mapping help floating windows - help_win = { - border = "rounded", win_opts = {}, }, -- Aliases for bundles of components. Redefine the builtins, or create your own. component_aliases = { -- Most tasks are initialized with the default components default = { - { "display_duration", detail_level = 2 }, - "on_output_summarize", "on_exit_set_status", "on_complete_notify", { "on_complete_dispose", require_view = { "SUCCESS", "FAILURE" } }, @@ -219,44 +135,30 @@ OPTIONS *overseer-option "default", "on_result_diagnostics", }, - }, - bundles = { - -- When saving a bundle with OverseerSaveBundle or save_task_bundle(), filter the tasks with - -- these options (passed to list_tasks()) - save_task_opts = { - bundleable = true, + -- Tasks created from experimental_wrap_builtins + default_builtin = { + "on_exit_set_status", + "on_complete_dispose", + { "unique", soft = true }, }, - -- Autostart tasks when they are loaded from a bundle - autostart_on_load = true, }, - -- A list of components to preload on setup. - -- Only matters if you want them to show up in the task editor. - preload_components = {}, - -- Controls when the parameter prompt is shown when running a template - -- always Show when template has any params - -- missing Show when template has any params not explicitly passed in - -- allow Only show when a required param is missing - -- avoid Only show when a required param with no default value is missing - -- never Never show prompt (error if required param missing) - default_template_prompt = "allow", - -- For template providers, how long to wait (in ms) before timing out. - -- Set to 0 to disable timeouts. - template_timeout = 3000, + -- List of other directories to search for task templates. + -- This will search under the runtimepath, so for example + -- "foo/bar" will search "/lua/foo/bar/*" + template_dirs = {}, + -- For template providers, how long to wait before timing out. + -- Set to 0 to wait forever. + template_timeout_ms = 3000, -- Cache template provider results if the provider takes longer than this to run. - -- Time is in ms. Set to 0 to disable caching. - template_cache_threshold = 100, - -- Configure where the logs go and what level to use - -- Types are "echo", "notify", and "file" - log = { - { - type = "echo", - level = vim.log.levels.WARN, - }, - { - type = "file", - filename = "overseer.log", - level = vim.log.levels.WARN, - }, + -- Set to 0 to disable caching. + template_cache_threshold_ms = 200, + log_level = vim.log.levels.WARN, + -- Overseer can wrap any call to vim.system and vim.fn.jobstart as a task. + experimental_wrap_builtins = { + enabled = false, + condition = function(cmd, caller, opts) + return true + end, }, }) < @@ -301,13 +203,7 @@ setup({opts}) *overseer.setu Initialize overseer Parameters: - {opts} `overseer.Config|nil` Configuration options - -on_setup({callback}) *overseer.on_setup* - Add a callback to run after overseer lazy setup - - Parameters: - {callback} `fun()` + {opts} `overseer.SetupOpts|nil` Configuration options new_task({opts}): overseer.Task *overseer.new_task* Create a new Task @@ -327,11 +223,12 @@ new_task({opts}): overseer.Task *overseer.new_tas component params {components} `nil|overseer.Serialized[]` List of components to attach. Defaults to `{"default"}` + {ephemeral} `nil|boolean` Indicates that this task was generated by + another task (e.g. with run_after) Examples: >lua local task = overseer.new_task({ - cmd = { "./build.sh" }, - args = { "all" }, + cmd = { "./build.sh", "all" }, components = { { "on_output_quickfix", open = true }, "default" } }) task:start() @@ -342,7 +239,8 @@ toggle({opts}) *overseer.toggl Parameters: {opts} `nil|overseer.WindowOpts` - {enter} `nil|boolean` + {enter} `nil|boolean` Focus the task list window after opening + (default true) {direction} `nil|"left"|"right"|"bottom"` {winid} `nil|integer` Use this existing window instead of opening a new window @@ -353,127 +251,87 @@ open({opts}) *overseer.ope Parameters: {opts} `nil|overseer.WindowOpts` - {enter} `boolean|nil` If false, stay in current window. Default - true - {direction} `nil|"left"|"right"` Which direction to open the task list + {enter} `nil|boolean` Focus the task list window after opening + (default true) + {direction} `nil|"left"|"right"|"bottom"` + {winid} `nil|integer` Use this existing window instead of + opening a new window + {focus_task_id} `nil|integer` After opening, focus this task close() *overseer.close* Close the task list -list_task_bundles(): string[] *overseer.list_task_bundles* - Get the list of saved task bundles - - Returns: - `string[]` Names of task bundles - -load_task_bundle({name}, {opts}) *overseer.load_task_bundle* - Load tasks from a saved bundle - - Parameters: - {name} `nil|string` - {opts} `nil|table` - {ignore_missing} `nil|boolean` When true, don't notify if bundle - doesn't exist - {autostart} `nil|boolean` When true, start the tasks after - loading (default true) - -save_task_bundle({name}, {tasks}, {opts}) *overseer.save_task_bundle* - Save tasks to a bundle on disk - - Parameters: - {name} `string|nil` Name of bundle. If nil, will prompt user. - {tasks} `nil|overseer.Task[]` Specific tasks to save. If nil, uses - config.bundles.save_task_opts - {opts} `table|nil` - {on_conflict} `nil|"overwrite"|"append"|"cancel"` - -delete_task_bundle({name}) *overseer.delete_task_bundle* - Delete a saved task bundle - - Parameters: - {name} `string|nil` - list_tasks({opts}): overseer.Task[] *overseer.list_tasks* List all tasks Parameters: {opts} `nil|overseer.ListTaskOpts` - {unique} `nil|boolean` Deduplicates non-running tasks by name - {name} `nil|string|string[]` Only list tasks with this name or - names - {name_not} `nil|boolean` Invert the name search (tasks *without* - that name) - {status} `nil|overseer.Status|overseer.Status[]` Only list tasks - with this status or statuses - {status_not} `nil|boolean` Invert the status search - {recent_first} `nil|boolean` The most recent tasks are first in the - list - {bundleable} `nil|boolean` Only list tasks that should be included - in a bundle - {filter} `nil|fun(task: overseer.Task): boolean` - -run_template({opts}, {callback}) *overseer.run_template* + {unique} `nil|boolean` Deduplicates non-running tasks by name + {status} `nil|overseer.Status|overseer.Status[]` Only list tasks with + this status or statuses + {include_ephemeral} `nil|boolean` Include ephemeral tasks + {wrapped} `nil|boolean` Include tasks that were created by the + jobstart/vim.system wrappers + {filter} `nil|fun(task: overseer.Task): boolean` Only include tasks + where this function returns true + {sort} `nil|fun(a: overseer.Task, b: overseer.Task): boolean` Funct + ion that sorts tasks + +run_task({opts}, {callback}) *overseer.run_task* Run a task from a template Parameters: {opts} `overseer.TemplateRunOpts` - {name} `nil|string` The name of the template to run - {tags} `nil|string[]` List of tags used to filter when searching - for template - {autostart} `nil|boolean` When true, start the task after creating it - (default true) - {first} `nil|boolean` When true, take first result and never show - the task picker. Default behavior will auto-set this based - on presence of name and tags - {prompt} `nil|"always"|"missing"|"allow"|"avoid"|"never"` Controls - when to prompt user for parameter input - {params} `nil|table` Parameters to pass to template - {cwd} `nil|string` Working directory for the task - {env} `nil|table` Additional environment - variables for the task + {name} `nil|string` The name of the template to run + {tags} `nil|string[]` List of tags used to filter when + searching for template + {autostart} `nil|boolean` When true, start the task after + creating it (default true) + {first} `nil|boolean` When true, take first result and never + show the task picker. Default behavior will auto-set + this based on presence of name and tags + {params} `nil|table` Parameters to pass to template + {cwd} `nil|string` Working directory for the task + {env} `nil|table` Additional environment + variables for the task + {disallow_prompt} `nil|boolean` When true, if any required parameters + are missing return an error instead of prompting the + user for them + {on_build} `nil|fun(task_defn: overseer.TaskDefinition, util: overseer.TaskUtil)` + callback that is called after the task definition is + built but before the task is created. {callback} `nil|fun(task: overseer.Task|nil, err: string|nil)` - Note: - The prompt option will control when the user is presented a popup dialog to input template - parameters. The possible values are: - always Show when template has any params - missing Show when template has any params not explicitly passed in - allow Only show when a required param is missing - avoid Only show when a required param with no default value is missing - never Never show prompt (error if required param missing) - The default is controlled by the default_template_prompt config option. - Examples: >lua -- Run the task named "make all" -- equivalent to :OverseerRun make\ all - overseer.run_template({name = "make all"}) + overseer.run_task({name = "make all"}) -- Run the default "build" task -- equivalent to :OverseerRun BUILD - overseer.run_template({tags = {overseer.TAG.BUILD}}) + overseer.run_task({tags = {overseer.TAG.BUILD}}) -- Run the task named "serve" with some default parameters - overseer.run_template({name = "serve", params = {port = 8080}}) + overseer.run_task({name = "serve", params = {port = 8080}}) -- Create a task but do not start it - overseer.run_template({name = "make", autostart = false}, function(task) + overseer.run_task({name = "make", autostart = false}, function(task) -- do something with the task end) -- Run a task and immediately open the floating window - overseer.run_template({name = "make"}, function(task) + overseer.run_task({name = "make"}, function(task) if task then overseer.run_action(task, 'open float') end end) - -- Run a task and always show the parameter prompt - overseer.run_template({name = "npm watch", prompt = "always"}) < preload_task_cache({opts}, {cb}) *overseer.preload_task_cache* - Preload templates for run_template + Preload templates for run_task Parameters: - {opts} `nil|table` - {dir} `string` - {ft} `nil|string` + {opts} `nil|overseer.SearchParams` + {filetype} `nil|string` + {tags} `nil|string[]` + {dir} `string` {cb} `nil|fun()` Called when preloading is complete Note: @@ -489,82 +347,30 @@ preload_task_cache({opts}, {cb}) *overseer.preload_task_cach < clear_task_cache({opts}) *overseer.clear_task_cache* - Clear cached templates for run_template + Clear cached templates for run_task Parameters: - {opts} `nil|table` - {dir} `string` - {ft} `nil|string` + {opts} `nil|overseer.SearchParams` + {filetype} `nil|string` + {tags} `nil|string[]` + {dir} `string` run_action({task}, {name}) *overseer.run_action* Run an action on a task Parameters: {task} `overseer.Task` - {name} `string|nil` Name of action. When omitted, prompt user to pick. - -wrap_template({base}, {override}, {default_params}): overseer.TemplateFileDefinition *overseer.wrap_template* - Create a new template by overriding fields on another - - Parameters: - {base} `overseer.TemplateFileDefinition` The base template - definition to wrap - {module} `nil|string` The name of the module this was loaded from - {aliases} `nil|string[]` - {desc} `nil|string` - {tags} `nil|string[]` - {params} `nil|overseer.Params|fun(): overseer.Params` - {priority} `nil|number` - {condition} `nil|overseer.SearchCondition` - {filetype} `nil|string|string[]` - {dir} `nil|string|string[]` - {callback} `nil|fun(search: overseer.SearchParams): boolean` , nil - |string - {builder} `fun(params: table): overseer.TaskDefinition` - {hide} `nil|boolean` Hide from the template list - {override} `nil|table` Override any fields on the base - {default_params} `nil|table` Provide default values for any - parameters on the base - - Note: - This is typically used for a TemplateProvider, to define the task a single time and generate - multiple templates based on the available args. - - Examples: >lua - local tmpl = { - params = { - args = { type = 'list', delimiter = ' ' } - }, - builder = function(params) - return { - cmd = { 'make' }, - args = params.args, - } - } - local template_provider = { - name = "Some provider", - generator = function(opts, cb) - cb({ - overseer.wrap_template(tmpl, nil, { args = { 'all' } }), - overseer.wrap_template(tmpl, {name = 'make clean'}, { args = { 'clean' } }), - }) - end - } -< + {name} `nil|string` Name of action. When omitted, prompt user to pick. add_template_hook({opts}, {hook}) *overseer.add_template_hook* Add a hook that runs on a TaskDefinition before the task is created Parameters: {opts} `nil|overseer.HookOptions` When nil, run the hook on all templates - {name} `nil|string` Only run if the template name matches this - pattern (using string.match) - {module} `nil|string` Only run if the template module matches this - pattern (using string.match) - {filetype} `nil|string|string[]` Only run if the current file is one - of these filetypes - {dir} `nil|string|string[]` Only run if inside one of these - directories + {module} `nil|string` Only run if the template module matches this + pattern (using string.match) + {name} `nil|string` Only run if the template name matches this + pattern (using string.match) {hook} `fun(task_defn: overseer.TaskDefinition, util: overseer.TaskUtil)` Examples: >lua @@ -589,8 +395,10 @@ remove_template_hook({opts}, {hook}) *overseer.remove_template_hoo Parameters: {opts} `nil|overseer.HookOptions` Same as for add_template_hook - {module} `nil|string` - {name} `nil|string` + {module} `nil|string` Only run if the template module matches this + pattern (using string.match) + {name} `nil|string` Only run if the template name matches this + pattern (using string.match) {hook} `fun(task_defn: overseer.TaskDefinition, util: overseer.TaskUtil)` Examples: >lua @@ -620,27 +428,14 @@ register_template({defn}) *overseer.register_templat }) < -load_template({name}) *overseer.load_template* - Load a template definition from its module location - - Parameters: - {name} `string` - - Examples: >lua - -- This will load the template in lua/overseer/template/mytask.lua - overseer.load_template('mytask') -< - -debug_parser() *overseer.debug_parser* - Open a tab with windows laid out for debugging a parser - - -register_alias({name}, {components}) *overseer.register_alias* +register_alias({name}, {components}, {override}) *overseer.register_alias* Register a new component alias. Parameters: {name} `string` {components} `overseer.Serialized[]` + {override} `nil|boolean` When true, override any existing alias with the + same name Note: This is intended to be used by plugin authors that wish to build on top of overseer. They do not @@ -651,25 +446,301 @@ register_alias({name}, {components}) *overseer.register_alia require("overseer").register_alias("my_plugin", { "default", "on_output_quickfix" }) < +create_task_output_view({winid}, {opts}) *overseer.create_task_output_view* + Set a window to display the output of a dynamically-chosen task + + Parameters: + {winid} `nil|integer` The window to use for displaying the task output + {opts} `nil|overseer.TaskViewOpts` + {select} `nil|fun(self: overseer.TaskView, tasks: overseer.Task[], task_under_cursor?: overseer.Task): nil|overseer.Task` + Select which task in the task list to display the + output of + {close_on_list_close} `nil|boolean` Close the window when the task + list is closed + {list_task_opts} `nil|overseer.ListTaskOpts` Passed to list_tasks() to + get the list of tasks to pass to the select() + function + {unique} `nil|boolean` Deduplicates non-running tasks by name + {status} `nil|overseer.Status|overseer.Status[]` Only list tasks + with this status or statuses + {include_ephemeral} `nil|boolean` Include ephemeral tasks + {wrapped} `nil|boolean` Include tasks that were created by the + jobstart/vim.system wrappers + {filter} `nil|fun(task: overseer.Task): boolean` Only include + tasks where this function returns true + {sort} `nil|fun(a: overseer.Task, b: overseer.Task): boolean` F + unction that sorts tasks + + Examples: >lua + -- Always show the output from the most recent Neotest task in this window. + -- Close it automatically when all test tasks are disposed. + overseer.create_task_output_view(0, { + select = function(self, tasks, task_under_cursor) + for _, task in ipairs(tasks) do + if task.metadata.neotest_group_id then + return task + end + end + self:dispose() + end, + }) +< + +overseer.Task *overseer.Task* + + Fields: + {id} `integer` Unique ID for this task + {result} `nil|table` For successful tasks, arbitrary + key-value mapping of data produced by components + {metadata} `table` Arbitrary key-value mapping passed by + the user during construction + {status} `overseer.Status` Current task status + {cmd} `string|string[]` Command to run. If it's a string it is + run in the shell + {cwd} `string` Working directory the task is run in + {env} `nil|table` Additional environment + variables for the task + {name} `string` Name of the task + {ephemeral} `boolean` Indicates that this task was generated + indirectly (e.g. with run_after) + {source} `nil|overseer.Caller` If this task was created by wrapping + jobstart/vim.system, this contains information about the + callsite + {exit_code} `nil|integer` Exit code of the task process + {parent_id} `nil|integer` ID of parent task. Used only to visually + group tasks in the task list + {time_start} `nil|integer` Timestamp when the task was started + (os.time()) + {time_end} `nil|integer` Timestamp when the task ended (os.time()) + + + +Task:clone(): overseer.Task *overseer.Task:clone* + Create a deep copy of this task + + +Task:add_component({comp}) *overseer.Task:add_component* + Add a component, no-op if it already exists + + Parameters: + {comp} `overseer.Serialized` + +Task:add_components({components}) *overseer.Task:add_components* + Add components, skipping any that already exist + + Parameters: + {components} `overseer.Serialized[]` + +Task:set_component({comp}) *overseer.Task:set_component* + Add component, overwriting any existing + + Parameters: + {comp} `overseer.Serialized` + +Task:set_components({components}) *overseer.Task:set_components* + Add components, overwriting any existing + + Parameters: + {components} `overseer.Serialized[]` + +Task:get_component({name}): nil|overseer.Component *overseer.Task:get_component* + + Parameters: + {name} `string` + +Task:remove_component({name}): nil|overseer.Component *overseer.Task:remove_component* + + Parameters: + {name} `string` + +Task:remove_components({names}): overseer.Component[] *overseer.Task:remove_components* + + Parameters: + {names} `string[]` + +Task:has_component({name}): boolean *overseer.Task:has_component* + + Parameters: + {name} `string` + +Task:subscribe({event}, {callback}) *overseer.Task:subscribe* + Subscribe to events on this task + + Parameters: + {event} `string` + {callback} `fun(task: overseer.Task, ...: any): nil|boolean` Callback can + return a truthy value to unsubscribe itself + + Note: + Listeners cannot be serialized, so will not be saved when saving task + to disk and will not be copied when cloning the task. + +Task:unsubscribe({event}, {callback}) *overseer.Task:unsubscribe* + Unsubscribe from an event that was previously subscribed to + + Parameters: + {event} `string` + {callback} `fun(task: overseer.Task, ...: any)` + +Task:is_pending(): boolean *overseer.Task:is_pending* + Returns true if the task is PENDING + + +Task:is_running(): boolean *overseer.Task:is_running* + Returns true if the task is RUNNING + + +Task:is_complete(): boolean *overseer.Task:is_complete* + Returns true if the task is complete (not PENDING or RUNNING) + + +Task:is_disposed(): boolean *overseer.Task:is_disposed* + Returns true if the task is DISPOSED + + +Task:get_bufnr(): number|nil *overseer.Task:get_bufnr* + Get the buffer containing the task output. Will be nil if task is PENDING. + + +Task:open_output({direction}) *overseer.Task:open_output* + Open the task output in a window + + Parameters: + {direction} `nil|"float"|"tab"|"vertical"|"horizontal"` + + Note: + You can also use get_bufnr() to get the buffer and open it however you like. + +Task:broadcast({name}) *overseer.Task:broadcast* + Dispatch an event to all other tasks + + Parameters: + {name} `string` + +Task:dispatch({name}): any[] *overseer.Task:dispatch* + Dispatch an event to all components + + Parameters: + {name} `string` + +Task:inc_reference() *overseer.Task:inc_reference* + Increment the refcount for this Task, preventing it from being disposed + (unless force=true) + + +Task:dec_reference() *overseer.Task:dec_reference* + Decrement the refcount for this Task + + +Task:dispose({force}): boolean *overseer.Task:dispose* + Cleans up resources, removes from task list, and deletes buffer. + + Parameters: + {force} `nil|boolean` When true, will dispose even with a nonzero refcount + or when buffer is visible + Returns: + `boolean` disposed True if task was disposed + +Task:restart({force_stop}): boolean *overseer.Task:restart* + Reset and re-run the task + + Parameters: + {force_stop} `nil|boolean` If true, restart the Task even if it is + currently running + +Task:start() *overseer.Task:start* + Start a pending task + + +Task:stop(): boolean *overseer.Task:stop* + Stop a running task + + Returns: + `boolean` stopped True if the task was stopped + -------------------------------------------------------------------------------- -COMPONENTS *overseer-components* +KEYMAPS *overseer-keymaps* -dependencies *dependencies* - Set dependencies for task +The `task_list.keymaps` option in `overseer.setup` allow you to create mappings +using all the same parameters as |vim.keymap.set|. +>lua + keymaps = { + -- Mappings can be a string + [""] = "lua require('overseer').run_action()", + -- Mappings can be a function + gd = function() + for _, task in ipairs(require("overseer").list_tasks()) do + task:dispose() + end + end, + -- You can pass additional opts to vim.keymap.set by using + -- a table with the mapping as the first element. + gd = { + function() + for _, task in ipairs(require("overseer").list_tasks()) do + task:dispose() + end + end, + mode = "n", + nowait = true, + desc = "Dispose all tasks" + }, + -- Mappings that are a string starting with "keymap." will be + -- one of the built-in keymaps, documented below. + p = "keymap.toggle_preview", + -- Some keymaps have parameters. These are passed in via the `opts` key. + dd = { "keymap.run_action", opts = { action = "dispose" }, desc = "Dispose task" }, + } + +Below are the mappings that can be used in the `keymaps` section of config +options. You can refer to them as strings (e.g. "keymaps.") + +next_task *keymaps.next_task* + Jump to next task + +open *keymaps.open* + Open task output Parameters: - {*task_names} `list[string]` Names of dependency task templates This can - be a list of strings (template names, e.g. {"cargo build"}), - tables (name with params, e.g. {"shell", cmd = "sleep 10"}), - or tables (raw task params, e.g. {cmd = "sleep 10"}) - {sequential} `boolean` (default `false`) + {dir} `"split"|"vsplit"|"tab"|"float"` type of window to open the task + output in + +prev_task *keymaps.prev_task* + Jump to previous task -display_duration *display_duration* - Display the run duration +run_action *keymaps.run_action* + Run an action on the current task Parameters: - {detail_level} `integer` Show the duration at this detail level (default - `1`) + {action} `string` Run an action on the current task + +scroll_output_down *keymaps.scroll_output_down* + Scroll down in the task output window + +scroll_output_up *keymaps.scroll_output_up* + Scroll up in the task output window + +show_help *keymaps.show_help* + Show default keymaps + +toggle_preview *keymaps.toggle_preview* + Toggle task output in a preview floating window + +toggle_show_wrapped *keymaps.toggle_show_wrapped* + Toggle showing wrapped builtin jobstart/vim.system tasks + +-------------------------------------------------------------------------------- +COMPONENTS *overseer-components* + +dependencies *dependencies* + Set dependencies for task + + Parameters: + {sequential} `boolean` (default `false`) + {tasks} `list[string]` Names of dependency task templates This can be + a list of strings (template names, e.g. "cargo build"), + tables (template name with params, e.g. {"mytask", foo = + "bar"}), or tables (raw task params, e.g. {cmd = "sleep 10"}) on_complete_dispose *on_complete_dispose* After task is completed, dispose it after a timeout @@ -711,20 +782,49 @@ on_exit_set_status *on_exit_set_statu {success_codes} `list[integer]` Additional exit codes to consider as success +on_output_notify *on_output_notify* + Use nvim-notify to show notification with task output summary for long-running tasks + Works like on_complete_notify but, for long-running commands, also shows + real-time output summary. Requires nvim-notify to modify the last + notification window when new output arrives instead of creating new + notification. + + Parameters: + {delay_ms} `number` Time in milliseconds to wait before displaying the + notification during task runtime (default `2000`) + {max_lines} `integer` Number of lines of output to show (default `1`) + {max_width} `integer` Maximum output width (default `49`) + {output_on_complete} `boolean` Show the last lines of task output and + status on completion (instead of only the status) (default + `false`) When output_on_complete==true: shows status + last + output lines during task runtime and after completion. When + output_on_complete==false: shows status + last output lines + during task runtime and only status after completion. + {trim} `boolean` Remove whitespace from both sides of each line + (default `true`) + on_output_parse *on_output_parse* Parses task output and sets task result Parameters: - {parser} `opaque` Parser definition to extract values from output - {problem_matcher} `opaque` VS Code-style problem matcher - {relative_file_root} `string` Relative filepaths will be joined to this - root (instead of task cwd) + {parser} `opaque` Parse function or overseer.OutputParser This + can be a function that takes a line of output and + (optionally) returns a quickfix-list item (see :help + |setqflist-what|). For more complex parsing, this should + be a class of type overseer.OutputParser. + {problem_matcher} `opaque` VS Code-style problem matcher Only one of + 'parser', 'problem_matcher', or 'errorformat' is + allowed. + {errorformat} `opaque` Errorformat string Only one of 'parser', + 'problem_matcher', or 'errorformat' is allowed. {precalculated_vars} `opaque` Precalculated VS Code task variables Tasks that are started from the VS Code provider precalculate certain interpolated variables (e.g. ${workspaceFolder}). We pass those in as params so they will remain stable even if Neovim's state changes in between creating and running (or restarting) the task. + {relative_file_root} `string` Relative filepaths will be joined to this + root (instead of task cwd) on_output_quickfix *on_output_quickfix* Set all task output into the quickfix (on complete) @@ -751,13 +851,6 @@ on_output_quickfix *on_output_quickfi that produce "fancy" output using terminal escape codes (e.g. animated progress indicators) -on_output_summarize *on_output_summarize* - Summarize task output in the task list - - Parameters: - {max_lines} `integer` Number of lines of output to show when detail > 1 - (default `4`) - on_output_write_file *on_output_write_file* Write task output to a file @@ -839,7 +932,8 @@ restart_on_save *restart_on_sav Parameters: {delay} `number` How long to wait (in ms) before triggering restart (default `500`) - {interrupt} `boolean` Interrupt running tasks (default `true`) + {interrupt} `boolean` Interrupt running tasks. If false, will wait for + task to complete before restarting (default `true`) {mode} `enum` How to watch the paths (default `"autocmd"`) 'autocmd' will set autocmds on BufWritePost. 'uv' will use a libuv file watcher (recursive watching may not be supported on all @@ -851,16 +945,16 @@ run_after *run_afte Run other tasks after this task completes Parameters: - {*task_names} `list[string]` Names of dependency task templates This can - be a list of strings (template names, e.g. {"cargo build"}), - tables (name with params, e.g. {"shell", cmd = "sleep 10"}), - or tables (raw task params, e.g. {cmd = "sleep 10"}) - {detach} `boolean` Tasks created will not be linked to the parent - task (default `false`) This means they will not restart when - the parent restarts, and will not be disposed when the - parent is disposed - {statuses} `list[enum]` Only run successive tasks if the final status - is in this list (default `["SUCCESS"]`) + {detach} `boolean` Tasks created will not be linked to the parent task + (default `false`) This means they will not restart when the + parent restarts, and will not be disposed when the parent is + disposed + {statuses} `list[enum]` Only run successive tasks if the final status is + in this list (default `["SUCCESS"]`) + {tasks} `list[string]` Names of dependency task templates This can be a + list of strings (template names, e.g. "cargo build"), tables + (template name with params, e.g. {"mytask", foo = "bar"}), or + tables (raw task params, e.g. {cmd = "sleep 10"}) timeout *timeout* Cancel task if it exceeds a timeout @@ -882,398 +976,8 @@ unique *uniqu dependency, etc) {restart_interrupts} `boolean` When replace = false, should restarting the existing task interrupt it (default `true`) - --------------------------------------------------------------------------------- -STRATEGIES *overseer-strategies* - -jobstart({opts}): overseer.Strategy *strategy.jobstart* - Run tasks using jobstart() - - Parameters: - {opts} `nil|table` - {preserve_output} `boolean` If true, don't clear the buffer when tasks - restart - {use_terminal} `boolean` If false, use a normal non-terminal buffer - to store the output. This may produce unwanted - results if the task outputs terminal escape - sequences. - -orchestrator({opts}): overseer.Strategy *strategy.orchestrator* - Strategy for a meta-task that manage a sequence of other tasks - - Parameters: - {opts} `table` - {tasks} `table` A list of task definitions to run. Can include sub- - lists that will be run in parallel - - Examples: >lua - overseer.new_task({ - name = "Build and serve app", - strategy = { - "orchestrator", - tasks = { - "make clean", -- Step 1: clean - { -- Step 2: build js and css in parallel - "npm build", - { cmd = {"lessc", "styles.less", "styles.css"} }, - }, - "npm serve", -- Step 3: serve - }, - }, - }) -< - -terminal(): overseer.Strategy *strategy.terminal* - Run tasks using termopen() - - -test(): overseer.Strategy *strategy.test* - Strategy used for unit testing - - -toggleterm({opts}): overseer.Strategy *strategy.toggleterm* - Run tasks using the toggleterm plugin - - Parameters: - {opts} `nil|overseeer.ToggleTermStrategyOpts` - {use_shell} `nil|boolean` load user shell before running task - {size} `nil|number` the size of the split if direction is - vertical or horizontal - {direction} `nil|"vertical"|"horizontal"|"tab"|"float"` - {highlights} `nil|table` map to a highlight group name and a table - of it's values - {auto_scroll} `nil|boolean` automatically scroll to the bottom on - task output - {close_on_exit} `nil|boolean` close the terminal and delete terminal - buffer (if open) after task exits - {quit_on_exit} `nil|"never"|"always"|"success"` close the terminal - window (if open) after task exits - {open_on_start} `nil|boolean` toggle open the terminal automatically - when task starts - {hidden} `nil|boolean` cannot be toggled with normal ToggleTerm - commands - {on_create} `nil|fun(term: table)` function to execute on terminal - creation - --------------------------------------------------------------------------------- -PARSERS *overseer-parsers* - -always *parser.always* - A decorator that always returns SUCCESS - - Parameters: - {succeed} `boolean` Set to false to always return FAILURE (default true) - {child} `parser` The child parser node - - Examples: - An extract node that returns SUCCESS even when it fails -> - {"always", - {"extract", "^([^%s].+):(%d+): (.+)$", "filename", "lnum", "text" } - } -< - -append *parser.append* - Append the current item to the results list - - Parameters: - {opts} `object` Configuration options - {postprocess} `function` Call this function to do post-extraction - processing on the values - -dispatch *parser.dispatch* - Dispatch an event - - Parameters: - {name} `string` Event name - {arg} `any|fun()` A value to send with the event, or a function that - creates a value - - Examples: - clear_results will clear all current results from the parser. Pass `true` to - only clear the results under the current key -> - {"dispatch", "clear_results"} -< - - Examples: - set_results is used by the on_output_parse component to immediately set the - current results on the task -> - {"dispatch", "set_results"} -< - -ensure *parser.ensure* - Decorator that runs a child until it succeeds - - Parameters: - {succeed} `boolean` Set to false to run child until failure (default true) - {child} `parser` The child parser node - - Examples: - An extract node that runs until it successfully parses -> - {"ensure", - {"extract", "^([^%s].+):(%d+): (.+)$", "filename", "lnum", "text" } - } -< - -extract *parser.extract* - Parse a line into an object and append it to the results - - Parameters: - {opts} `object` Configuration options - {consume} `boolean` Consumes the line of input, blocking execution - until the next line is fed in (default true) - {append} `boolean` After parsing, append the item to the results - list. When false, the pending item will stick around. - (default true) - {regex} `boolean` Use vim regex instead of lua pattern (see - :help pattern) (default false) - {postprocess} `function` Call this function to do post-extraction - processing on the values - {pattern} `string|function|string[]` The lua pattern to use for matching. - Must have the same number of capture groups as there are field - arguments. - {field} `string` The name of the extracted capture group. Use `"_"` to - discard. - - Examples: - Convert a line in the format of `/path/to/file.txt:123: This is a message` - into an item `{filename = "/path/to/file.txt", lnum = 123, text = "This is a - message"}` -> - {"extract", "^([^%s].+):(%d+): (.+)$", "filename", "lnum", "text" } -< - - Examples: - The same logic, but using a vim regex -> - {"extract", {regex = true}, "\\v^([^:space:].+):(\\d+): (.+)$", "filename", "lnum", "text" } -< - -extract_efm *parser.extract_efm* - Parse a line using vim's errorformat and append it to the results - - Parameters: - {opts} `object` Configuration options - {efm} `string` The errorformat string to use. Defaults to - current option value. - {consume} `boolean` Consumes the line of input, blocking execution - until the next line is fed in (default true) - {append} `boolean` After parsing, append the item to the results - list. When false, the pending item will stick around. - (default true) - {test} `function` A function that operates on the parsed value - and returns true/false for SUCCESS/FAILURE - {postprocess} `function` Call this function to do post-extraction - processing on the values - -extract_json *parser.extract_json* - Parse a line as json and append it to the results - - Parameters: - {opts} `object` Configuration options - {consume} `boolean` Consumes the line of input, blocking execution - until the next line is fed in (default true) - {append} `boolean` After parsing, append the item to the results - list. When false, the pending item will stick around. - (default true) - {test} `function` A function that operates on the parsed value - and returns true/false for SUCCESS/FAILURE - {postprocess} `function` Call this function to do post-extraction - processing on the values - -extract_multiline *parser.extract_multiline* - Extract a multiline string as a single field on an item - - Parameters: - {opts} `object` Configuration options - {append} `boolean` After parsing, append the item to the results list. - When false, the pending item will stick around. (default - true) - {pattern} `string|function` The lua pattern to use for matching. As long - as the pattern matches, lines will continue to be appended to - the field. - {field} `string` The name of the field to add to the item - - Examples: - Extract all indented lines as a message -> - {"extract_multiline", "^( .+)", "message"} -< - -extract_nested *parser.extract_nested* - Run a subparser and put the extracted results on the field of an item - - Parameters: - {opts} `object` Configuration options - {append} `boolean` After parsing, append the item to the - results list. When false, the pending item will stick - around. (default true) - {fail_on_empty} `boolean` Return FAILURE if there are no results from - the child (default true) - {field} `string` The name of the field to add to the item - {child} `parser` The child parser node - - Examples: - Extract a golang test failure, then add the stacktrace to it (if present) -> - {"extract", - { - regex = true, - append = false, - }, - "\\v^--- (FAIL|PASS|SKIP): ([^[:space:] ]+) \\(([0-9\\.]+)s\\)", - "status", - "name", - "duration", - }, - {"always", - {"sequence", - {"test", "^panic:"}, - {"skip_until", "^goroutine%s"}, - {"extract_nested", - { append = false }, - "stacktrace", - {"loop", - {"sequence", - {"extract",{ append = false }, { "^(.+)%(.*%)$", "^created by (.+)$" }, "text"}, - {"extract","^%s+([^:]+.go):([0-9]+)", "filename", "lnum"} - } - } - } - } - } -< - -invert *parser.invert* - A decorator that inverts the child's return value - - Parameters: - {child} `parser` The child parser node - - Examples: - An extract node that returns SUCCESS when it fails, and vice-versa -> - {"invert", - {"extract", "^([^%s].+):(%d+): (.+)$", "filename", "lnum", "text" } - } -< - -loop *parser.loop* - A decorator that repeats the child - - Parameters: - {opts} `object` Configuration options - {ignore_failure} `boolean` Keep looping even when the child fails - (default false) - {repetitions} `integer` When set, loop a set number of times then - return SUCCESS - {child} `parser` The child parser node - -parallel *parser.parallel* - Run the child nodes in parallel - - Parameters: - {opts} `object` Configuration options - {break_on_first_failure} `boolean` Stop executing as soon as a child - returns FAILURE (default true) - {break_on_first_success} `boolean` Stop executing as soon as a child - returns SUCCESS (default false) - {reset_children} `boolean` Reset all children at the beginning of each - iteration (default false) - {child} `parser` The child parser nodes. Can be passed in as varargs or as - a list. - -sequence *parser.sequence* - Run the child nodes sequentially - - Parameters: - {opts} `object` Configuration options - {break_on_first_failure} `boolean` Stop executing as soon as a child - returns FAILURE (default true) - {break_on_first_success} `boolean` Stop executing as soon as a child - returns SUCCESS (default false) - {child} `parser` The child parser nodes. Can be passed in as varargs or as - a list. - - Examples: - Extract the message text from one line, then the filename and lnum from the - next line -> - {"sequence", - {"extract", { append = false }, { "^(.+)%(.*%)$", "^created by (.+)$" }, "text"}, - {"extract", "^%s+([^:]+.go):([0-9]+)", "filename", "lnum"} - } -< - -set_defaults *parser.set_defaults* - A decorator that adds values to any items extracted by the child - - Parameters: - {opts} `object` Configuration options - {values} `object` Hardcoded key-value pairs to set as default - values - {hoist_item} `boolean` Take the current pending item, and use its - fields as the default key-value pairs (default true) - {child} `parser` The child parser node - - Examples: - Extract the filename from a header line, then for each line of output - beneath it parse the test name + status, and also add the filename to each - item -> - {"sequence", - {"extract", {append = false}, "^Test result (.+)$", "filename"} - {"set_defaults", - {"loop", - {"extract", "^Test (.+): (.+)$", "test_name", "status"} - } - } - } -< - -skip_lines *parser.skip_lines* - Skip over a set number of lines - - Parameters: - {count} `integer` How many lines to skip - -skip_until *parser.skip_until* - Skip over lines until one matches - - Parameters: - {opts} `object` Configuration options - {skip_matching_line} `boolean` Consumes the line that matches. Later - nodes will only see the next line. (default true) - {regex} `boolean` Use vim regex instead of lua pattern (see :help - pattern) (default true) - {pattern} `string|string[]|fun(line: string): string` The lua pattern to - use for matching. The node succeeds if any of these patterns - match. - - Examples: - Skip input until we see "Error" or "Warning" -> - {"skip_until", "^Error:", "^Warning:"} -< - -test *parser.test* - Returns SUCCESS when the line matches the pattern - - Parameters: - {opts} `object` Configuration options - {regex} `boolean` Use vim regex instead of lua pattern (see :help - pattern) (default true) - {pattern} `string|fun(line: string): string` The lua pattern to use for - matching, or test function - - Examples: - Fail until a line starts with "panic:" -> - {"test", "^panic:"} -< + {soft} `boolean` Only dispose duplicate tasks if they are completed. + Implies replace = true. (default `false`) -------------------------------------------------------------------------------- PARAMETERS *overseer-params* @@ -1290,7 +994,7 @@ and templates to expose customization options. desc = "A detailed description", order = 1, -- determines order of parameters in the UI validate = function(value) - return true, + return true end, optional = true, default = "foobar", @@ -1363,11 +1067,13 @@ params. -------------------------------------------------------------------------------- ACTIONS *overseer-actions* -Actions can be performed on tasks by using the `RunAction` keybinding in the -task list, or by the `OverseerQuickAction` and `OverseerTaskAction` commands. -They are simply a custom function that will do something to or with a task. +Actions can be performed on tasks by using the `keymap.run_action` keybinding in +the task list, or +by the `OverseerTaskAction` command. Actions are simply custom functions that +will do something to +or with a task. -Browse the set of built-in actions at lua/overseer/task_list/actions.lua +Browse the set of built-in actions at lua/overseer/task_list/actions.lua. You can define your own or disable any of the built-in actions in the call to setup(): @@ -1397,8 +1103,8 @@ setup(): -- It will always be available in the "RunAction" menu, but it may be -- worth mapping it directly if you use it often. task_list = { - bindings = { - ["P"] = "OverseerQuickAction My custom action", + keymaps = { + ["P"] = { "keymap.run_action", opts = { action = "my action" }, desc = "Do something cool" }, }, }, }) diff --git a/doc/parsers.md b/doc/parsers.md index 215d2f05..24382cea 100644 --- a/doc/parsers.md +++ b/doc/parsers.md @@ -2,671 +2,320 @@ -- [Writing parsers](#writing-parsers) -- [Examples](#examples) - - [Parsing a Golang stack trace](#parsing-a-golang-stack-trace) - - [Parsing output from a background "watch" task](#parsing-output-from-a-background-watch-task) +- [Errorformat](#errorformat) +- [Function](#function) +- [Parser](#parser) +- [make_lua_match_fn(pattern)](#make_lua_match_fnpattern) +- [make_lua_test_fn(pattern)](#make_lua_test_fnpattern) +- [make_regex_match_fn(pattern)](#make_regex_match_fnpattern) +- [match_to_test_fn(match)](#match_to_test_fnmatch) +- [make_parse_fn(match, fields)](#make_parse_fnmatch-fields) +- [parser_from_errorformat(errorformat)](#parser_from_errorformaterrorformat) +- [make_parser(parse_fn, results_key)](#make_parserparse_fn-results_key) +- [combine_parsers(parsers)](#combine_parsersparsers) +- [wrap_background_parser(parser, opts)](#wrap_background_parserparser-opts) - [Problem matchers](#problem-matchers) - [Built-in problem matchers](#built-in-problem-matchers) -- [Parser nodes](#parser-nodes) - - [always](#always) - - [append](#append) - - [dispatch](#dispatch) - - [ensure](#ensure) - - [extract](#extract) - - [extract_efm](#extract_efm) - - [extract_json](#extract_json) - - [extract_multiline](#extract_multiline) - - [extract_nested](#extract_nested) - - [invert](#invert) - - [loop](#loop) - - [parallel](#parallel) - - [sequence](#sequence) - - [set_defaults](#set_defaults) - - [skip_lines](#skip_lines) - - [skip_until](#skip_until) - - [test](#test) -The parser library is designed to be a flexible way of parsing many different output formats. The -structure of it is largely inspired by the design of [behavior -trees]() -for game AI. This allows for composition of trees of logic that can handle more complex output -formats than a pure line-by-line parser. +## Errorformat -## Writing parsers - -Writing a complicated parser can be tricky. To help, there is an interactive tool for iterating on a parser and debugging its logic. You can open the tool with `:lua require('overseer').debug_parser()`. This should open up a view that looks like this: - -![parser debugger](https://user-images.githubusercontent.com/506791/180116805-bc230406-b99c-4bb7-a78c-3e4bb9458629.png) - -The upper left window contains the parser definition, the lower left window contains the sample output that we want to try to parse, and the right window contains the debug view. Paste your sample output into the lower left window, and start making changes to the parser definition. As you save the new parser, it should recalculate the debug results in the right window. - -If you focus the example output window, the debug window will display the state of the parser tree after it ingests that particular line. When you move your cursor around, it should live update to show the new state. This allows you to effectively step through the execution of the parser while inspecting the internal state at every point. - -https://user-images.githubusercontent.com/506791/180116685-eaee5876-8692-4834-9916-647c2a1ae98d.mp4 - -## Examples - -### Parsing a Golang stack trace +Vim has its own custom format for defining how to parse diagnostics from output. See `:help errorformat` for very thorough and complete documentation. A simple example is below: ```lua -{"on_output_parse", parser = { - stacktrace = { - -- Skip lines until we hit panic: - {"test", "^panic:"}, - -- Skip lines until we hit goroutine - {"skip_until", "^goroutine%s"}, - -- Repeat this parsing sequence - {"loop", - {"sequence", - -- First extract the text of the item, but don't append it to the results yet - {"extract", { append = false }, { "^(.+)%(.*%)$", "^created by (.+)$" }, "text"}, - -- Extract the filename and lnum, add to the existing item, then append it to the results - {"extract", "^%s+([^:]+.go):([0-9]+)", "filename", "lnum"} - } - } - } -}} +-- Match lines that look like +-- foo/bar/baz.c:28: this is an error +{ "on_output_parse", errorformat = "%f:%l: %m" } ``` -### Parsing output from a background "watch" task - -Some tasks are intended to continue running in the background, watching for file changes and printing out new output when they do. An example would be `tsc --watch`. - -```lua -{ - "on_output_parse", - parser = { - diagnostics = { - { - "always", -- When the loop exits, proceed to the next node - { - "loop", -- Extract errors until exit - { - "parallel", - { - "invert", -- Exit the loop when we detect the end of the output - { "test", "Watching for file changes%.$" }, - }, - { - "always", -- Don't exit the loop if extraction fails - { - "extract", - { regex = true }, - "\\v^([^[:space:]].*)[\\(:](\\d+)[,:](\\d+)(\\):\\s+|\\s+-\\s+)(error|warning|info)\\s+TS(\\d+)\\s*:\\s*(.*)$", - "filename", - "lnum", - "col", - "_", - "type", - "code", - "text", - }, - }, - -- Prevent spin-looping when extraction fails - { "skip_lines", 1 }, - }, - }, - }, - -- We've reached the end of the output, so set the task results - { "dispatch", "set_results" }, - -- Wait until we see that the watcher has restarted - { - "skip_until", - { skip_matching_line = true }, - "File change detected%. Starting incremental compilation%.%.%.$", - }, - -- Clear the previous results, then we loop back to the start - { "dispatch", "clear_results" }, - }, - }, -} -``` - -A lot of this logic is common to any watch-style task. The common structure has been extracted into a helper method, so the above is equivalent to this: - -```lua -{ - "on_output_parse", - parser = { - diagnostics = require("overseer.parser.lib").watcher_output( - "File change detected%. Starting incremental compilation%.%.%.$", - "Watching for file changes%.$", - { - "extract", - { regex = true }, - "\\v^([^[:space:]].*)[\\(:](\\d+)[,:](\\d+)(\\):\\s+|\\s+-\\s+)(error|warning|info)\\s+TS(\\d+)\\s*:\\s*(.*)$", - "filename", - "lnum", - "col", - "_", - "type", - "code", - "text", - } - ), - }, -} -``` - -A final example, there is a built-in VS Code [problem matcher](#problem-matchers) for `tsc --watch` specifically, so the most concise version is this: - -```lua -{ "on_output_parse", problem_matcher = "$tsc-watch" } -``` - -## Problem matchers - -Since Overseer supports VS Code's task format, it also has support for parsing output using a [VS Code problem matcher](https://code.visualstudio.com/Docs/editor/tasks#_defining-a-problem-matcher). You can pass these in to the same `on_output_parse` component. - -```lua -{"on_output_parse", problem_matcher = { - owner = 'typescript', - fileLocation = { "relative", "${cwd}" }, - pattern = { - regexp = "^([^\\s].*)[\\(:](\\d+)[,:](\\d+)(?:\\):\\s+|\\s+-\\s+)(error|warning|info)\\s+TS(\\d+)\\s*:\\s*(.*)$", - -- Optionally specify a vim-compatible regex for matching: - vim_regexp = "\\v^([^[:space:]].*)[\\(:](\\d+)[,:](\\d+)(\\):\\s+|\\s+-\\s+)(error|warning|info)\\s+TS(\\d+)\\s*:\\s*(.*)$", - -- Optionally specify a lua pattern for matching: - lua_pat = "^([^%s].*)[\\(:](%d+)[,:](%d+)[^%a]*(%a+)%s+TS(%d+)%s*:%s*(.*)$", - file = 1, - line = 2, - column = 3, - severity = 5, - code = 6, - message = 7, - }, -}} -``` +Note that this is only useful if you don't want the result to be put in the quickfix. If you plan to put them in the quickfix, you should just use the [on_output_quickfix](components.md#on_output_quickfix) component. -Note that the structure of the problem matcher is the same as the VS Code definition, with the exception that it supports a `vim_regexp` key and/or a `lua_pat` key. Because JS regexes are slightly different from vim regexes (e.g. vim regex doesn't support non-capturing groups `(?:text)`), sometimes the regex from the VS Code definition will not work. The fix for this is to rewrite it as a vim-compatible regex or as a lua pattern. When either of these two keys are present, overseer will use them to perform the matching. If not, it will attempt to convert the `regexp` into a vim-compatible regex and use that. +## Function -For convenience, you can also use the built-in problem matcher definitions in `on_output_parse`: +For simple single-line formats, you can pass in a function to do the parsing. ```lua -{"on_output_parse", problem_matcher = "$tsc-watch"} -``` - -## Built-in problem matchers - -Patterns: - - - -- `$cpp` -- `$csc` -- `$eslint-compact` -- `$eslint-stylish` -- `$go` -- `$gulp-tsc` -- `$jshint` -- `$jshint-stylish` -- `$lessCompile` -- `$msCompile` -- `$nvcc-location` -- `$tsc` -- `$vb` - - -Problem matchers: - - - -- `$eslint-compact` -- `$eslint-stylish` -- `$gcc` -- `$go` -- `$gulp-tsc` -- `$jshint` -- `$jshint-stylish` -- `$lessCompile` -- `$lessc` -- `$msCompile` -- `$node-sass` -- `$nvcc` -- `$tsc` -- `$tsc-watch` - - -## Parser nodes - -This is a list of the parser nodes that are built-in to overseer. They can be found in [lua/overseer/parser](../lua/overseer/parser) - -### always - -[always.lua](../lua/overseer/parser/always.lua) - -A decorator that always returns SUCCESS - -```lua -{"always", child} -{"always", succeed, child} -``` - -| Param | Type | Desc | -| ------- | --------- | ---------------------------------------------------- | -| succeed | `boolean` | Set to false to always return FAILURE (default true) | -| child | `parser` | The child parser node | - -#### Examples - -An extract node that returns SUCCESS even when it fails - -```lua -{"always", - {"extract", "^([^%s].+):(%d+): (.+)$", "filename", "lnum", "text" } +{ "on_output_parse", parser = function(line) + local fname, lnum, msg = line:match("^(.*):(%d+): (.*)$") + -- Return a table in the format of :help setqflist-what + -- or return nil if no match + if fname then + return { + filename = fname, + lnum = tonumber(lnum), + text = msg + } + end +end } +``` + +## Parser + +The most complex type of custom parser you can create with the most control is the `overseer.OutputParser` class. If a simple function can't handle your use case, this should be able to. + +```lua +local parser = { + _result = {}, + ---Parse a single line of output + ---@param line string + parse = function(self, line) + local fname, lnum, msg = line:match("^(.*):(%d+): (.*)$") + if fname + table.insert(self._result, { + filename = fname, + lnum = tonumber(lnum), + text = msg + }) + end + end, + ---Get the results for the task + ---@return table + get_result = function(self) + -- The task result is an arbitrary key-value table, but most of the time for output parsing + -- you will want to set the `diagnostics` key. This is the special key that interacts with + -- all of the diagnostics-related components. + -- Note that the other parser types (function, problem matcher, errorformat) automatically put + -- their results in the `diagnostics` key. + return { diagnostics = self._result } + end, + ---This is called when the task is reset + reset = function(self) + self._result = {} + end, } ``` -### append - -[append.lua](../lua/overseer/parser/append.lua) - -Append the current item to the results list - -```lua -{"append"} -{"append", opts} -``` - -| Param | Type | Desc | | -| ----- | ----------- | --------------------- | ----------------------------------------------------------------- | -| opts | `object` | Configuration options | | -| | postprocess | `function` | Call this function to do post-extraction processing on the values | - -### dispatch - -[dispatch.lua](../lua/overseer/parser/dispatch.lua) - -Dispatch an event - -```lua -{"dispatch", name, arg...} -``` - -| Param | Type | Desc | -| ----- | ------------ | ------------------------------------------------------------------ | -| name | `string` | Event name | -| arg | `any\|fun()` | A value to send with the event, or a function that creates a value | +If you want to build and use custom parsers, there are some helpful methods available in [overseer.parselib](../lua/overseer/parselib.lua). -#### Examples + -clear_results will clear all current results from the parser. Pass `true` to only clear the results under the current key +## make_lua_match_fn(pattern) -```lua -{"dispatch", "clear_results"} -``` +`make_lua_match_fn(pattern): overseer.MatchFn` \ +Create a match function from a lua pattern -set_results is used by the on_output_parse component to immediately set the current results on the task +| Param | Type | Desc | +| ------- | -------- | ----------- | +| pattern | `string` | lua pattern | +**Examples:** ```lua -{"dispatch", "set_results"} +local match_fn = parselib.make_lua_match_fn("^(%S+):(%d+):(%d+): (.+)$") +local parse_fn = parselib.make_parse_fn(match_fn, {"filename", "lnum", "col", "text"}) +local parser = parselib.make_parser(parse_fn) ``` -### ensure +## make_lua_test_fn(pattern) -[ensure.lua](../lua/overseer/parser/ensure.lua) +`make_lua_test_fn(pattern): overseer.TestFn` \ +Create a test function (returns true/false) from a lua pattern -Decorator that runs a child until it succeeds +| Param | Type | Desc | +| ------- | -------- | ----------- | +| pattern | `string` | lua pattern | +**Examples:** ```lua -{"ensure", child} -{"ensure", succeed, child} +local test_fn = parselib.make_lua_test_fn("^File change detected") ``` -| Param | Type | Desc | -| ------- | --------- | ------------------------------------------------------ | -| succeed | `boolean` | Set to false to run child until failure (default true) | -| child | `parser` | The child parser node | +## make_regex_match_fn(pattern) -#### Examples +`make_regex_match_fn(pattern): overseer.MatchFn` \ +Create a match function from a vim regex -An extract node that runs until it successfully parses +| Param | Type | Desc | +| ------- | -------- | ------------------------------------- | +| pattern | `string` | vim regex, passed to vim.fn.matchlist | +**Examples:** ```lua -{"ensure", - {"extract", "^([^%s].+):(%d+): (.+)$", "filename", "lnum", "text" } -} +local match_fn = parselib.make_regex_match_fn("\\v^(\\S+):(\\d+):(\\d+): (.+)$") +local parse_fn = parselib.make_parse_fn(match_fn, {"filename", "lnum", "col", "text"}) +local parser = parselib.make_parser(parse_fn) ``` -### extract +## match_to_test_fn(match) -[extract.lua](../lua/overseer/parser/extract.lua) +`match_to_test_fn(match): overseer.TestFn` \ +Create a test function (returns true/false) from a match function -Parse a line into an object and append it to the results +| Param | Type | Desc | +| ----- | ------------------ | ------------------------------------------------- | +| match | `overseer.MatchFn` | function that parses a line into a list of values | +**Examples:** ```lua -{"extract", pattern, field...} -{"extract", opts, pattern, field...} +local match_fn = parselib.make_lua_match_fn("^(%S+):(%d+):(%d+): (.+)$") +local test_fn = parselib.match_to_test_fn(match_fn) ``` -| Param | Type | Desc | | -| ------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| opts | `object` | Configuration options | | -| | consume | `boolean` | Consumes the line of input, blocking execution until the next line is fed in (default true) | -| | append | `boolean` | After parsing, append the item to the results list. When false, the pending item will stick around. (default true) | -| | regex | `boolean` | Use vim regex instead of lua pattern (see :help pattern) (default false) | -| | postprocess | `function` | Call this function to do post-extraction processing on the values | -| pattern | `string\|function\|string[]` | The lua pattern to use for matching. Must have the same number of capture groups as there are field arguments. | | -| field | `string` | The name of the extracted capture group. Use `"_"` to discard. | | - -#### Examples +## make_parse_fn(match, fields) -Convert a line in the format of `/path/to/file.txt:123: This is a message` into an item `{filename = "/path/to/file.txt", lnum = 123, text = "This is a message"}` +`make_parse_fn(match, fields): overseer.ParseFn` \ +Create a function that parses a line into a quickfix entry -```lua -{"extract", "^([^%s].+):(%d+): (.+)$", "filename", "lnum", "text" } -``` - -The same logic, but using a vim regex +| Param | Type | Desc | +| ------ | ----------------------- | ----------------------------------------------------------- | +| match | `overseer.MatchFn` | function that parses a line into a list of values | +| fields | `overseer.ParseField[]` | list of field names, or {field_name, postprocess_fn} tuples | +**Examples:** ```lua -{"extract", {regex = true}, "\\v^([^:space:].+):(\\d+): (.+)$", "filename", "lnum", "text" } +local match_fn = parselib.make_lua_match_fn("^(%S+):(%d+):(%d+): (.+)$") +local parse_fn = parselib.make_parse_fn(match_fn, {"filename", "lnum", "col", "text"}) +local parser = parselib.make_parser(parse_fn) ``` -### extract_efm +## parser_from_errorformat(errorformat) -[extract_efm.lua](../lua/overseer/parser/extract_efm.lua) +`parser_from_errorformat(errorformat): overseer.OutputParser` \ +Create a parser from a vim errorformat -Parse a line using vim's errorformat and append it to the results +| Param | Type | Desc | +| ----------- | -------- | ---------------------- | +| errorformat | `string` | vim errorformat string | +**Examples:** ```lua -{"extract_efm"} -{"extract_efm", opts} +local parser = parselib.parser_from_errorformat("%f:%l: %m") ``` -| Param | Type | Desc | | -| ----- | ----------- | --------------------- | ------------------------------------------------------------------------------------------------------------------ | -| opts | `object` | Configuration options | | -| | efm | `string` | The errorformat string to use. Defaults to current option value. | -| | consume | `boolean` | Consumes the line of input, blocking execution until the next line is fed in (default true) | -| | append | `boolean` | After parsing, append the item to the results list. When false, the pending item will stick around. (default true) | -| | test | `function` | A function that operates on the parsed value and returns true/false for SUCCESS/FAILURE | -| | postprocess | `function` | Call this function to do post-extraction processing on the values | - -### extract_json +## make_parser(parse_fn, results_key) -[extract_json.lua](../lua/overseer/parser/extract_json.lua) +`make_parser(parse_fn, results_key): overseer.OutputParser` \ +Create a parser from a parse function -Parse a line as json and append it to the results +| Param | Type | Desc | +| ----------- | ------------------ | ---------------------------------------------------------------------- | +| parse_fn | `overseer.ParseFn` | function that parses a line into a quickfix entry | +| results_key | `nil\|string` | The key to put matches in the results table. defaults to "diagnostics" | +**Examples:** ```lua -{"extract_json"} -{"extract_json", opts} +local match_fn = parselib.make_lua_match_fn("^(%S+):(%d+):(%d+): (.+)$") +local parse_fn = parselib.make_parse_fn(match_fn, {"filename", "lnum", "col", "text"}) +local parser = parselib.make_parser(parse_fn) ``` -| Param | Type | Desc | | -| ----- | ----------- | --------------------- | ------------------------------------------------------------------------------------------------------------------ | -| opts | `object` | Configuration options | | -| | consume | `boolean` | Consumes the line of input, blocking execution until the next line is fed in (default true) | -| | append | `boolean` | After parsing, append the item to the results list. When false, the pending item will stick around. (default true) | -| | test | `function` | A function that operates on the parsed value and returns true/false for SUCCESS/FAILURE | -| | postprocess | `function` | Call this function to do post-extraction processing on the values | +## combine_parsers(parsers) -### extract_multiline +`combine_parsers(parsers): overseer.OutputParser` \ +Combine multiple parsers into a single one (will merge the results) -[extract_multiline.lua](../lua/overseer/parser/extract_multiline.lua) - -Extract a multiline string as a single field on an item +| Param | Type | Desc | +| ------- | ------------------------- | ---- | +| parsers | `overseer.OutputParser[]` | | +**Examples:** ```lua -{"extract_multiline", pattern, field} -{"extract_multiline", opts, pattern, field} +local match_fn = parselib.make_lua_match_fn("^(%S+):(%d+):(%d+): (.+)$") +local parse_fn = parselib.make_parse_fn(match_fn, {"filename", "lnum", "col", "text"}) +local parser1 = parselib.make_parser(parse_fn) +local parser2 = parselib.parser_from_errorformat("%f:%l: %m") +local combined_parser = parselib.combine_parsers({parser1, parser2}) ``` -| Param | Type | Desc | | -| ------- | ------------------ | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| opts | `object` | Configuration options | | -| | append | `boolean` | After parsing, append the item to the results list. When false, the pending item will stick around. (default true) | -| pattern | `string\|function` | The lua pattern to use for matching. As long as the pattern matches, lines will continue to be appended to the field. | | -| field | `string` | The name of the field to add to the item | | +## wrap_background_parser(parser, opts) -#### Examples +`wrap_background_parser(parser, opts): overseer.OutputParser` \ +Wrap a parser and only activate it in between a matching start and end lines -Extract all indented lines as a message +| Param | Type | Desc | +| ---------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| parser | `overseer.OutputParser` | | +| >parse | `fun(self: overseer.OutputParser, line: string)` | Called repeatedly with each line of output | +| >get_result | `fun(self: overseer.OutputParser): table` | Mapping of result keys to parsed values. Usually contains a "diagnostics" key with a list of quickfix entries. | +| >reset | `fun(self: overseer.OutputParser)` | Reset the parser to its initial state | +| >result_version | `nil\|number` | For background parsers only, this number should be bumped to indicate that the task should set the result while the process is still running | +| opts | `overseer.BackgroundParserOpts` | | +| >active_on_start | `nil\|boolean` | Whether the parser should be active immediately or wait for the start_fn to begin parsing | +| >start_fn | `nil\|overseer.TestFn` | Function that tests whether to start parsing | +| >end_fn | `nil\|overseer.TestFn` | Function that tests whether to stop parsing | +**Examples:** ```lua -{"extract_multiline", "^( .+)", "message"} +local base_parser = parselib.parser_from_errorformat("%f:%l: %m") +local parser = parselib.wrap_background_parser(base_parser, { + start_fn = parselib.make_lua_test_fn("^Starting analysis...$"), + end_fn = parselib.make_lua_test_fn("^Analysis complete.$"), +}) ``` -### extract_nested - -[extract_nested.lua](../lua/overseer/parser/extract_nested.lua) -Run a subparser and put the extracted results on the field of an item + -```lua -{"extract_nested", field, child} -{"extract_nested", opts, field, child} -``` - -| Param | Type | Desc | | -| ----- | ------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| opts | `object` | Configuration options | | -| | append | `boolean` | After parsing, append the item to the results list. When false, the pending item will stick around. (default true) | -| | fail_on_empty | `boolean` | Return FAILURE if there are no results from the child (default true) | -| field | `string` | The name of the field to add to the item | | -| child | `parser` | The child parser node | | - -#### Examples +## Problem matchers -Extract a golang test failure, then add the stacktrace to it (if present) +Since Overseer supports VS Code's task format, it also has support for parsing output using a [VS Code problem matcher](https://code.visualstudio.com/Docs/editor/tasks#_defining-a-problem-matcher). You can pass these in to the same [on_output_parse](components.md#on_output_parse) component. ```lua -{"extract", - { - regex = true, - append = false, +{"on_output_parse", problem_matcher = { + owner = 'typescript', + fileLocation = { "relative", "${cwd}" }, + pattern = { + regexp = "^([^\\s].*)[\\(:](\\d+)[,:](\\d+)(?:\\):\\s+|\\s+-\\s+)(error|warning|info)\\s+TS(\\d+)\\s*:\\s*(.*)$", + -- It is recommended to specify either vim_regexp or lua_pat because vim doesn't fully support javascript regex format + -- Optionally specify a vim-compatible regex for matching: + vim_regexp = "\\v^([^[:space:]].*)[\\(:](\\d+)[,:](\\d+)(\\):\\s+|\\s+-\\s+)(error|warning|info)\\s+TS(\\d+)\\s*:\\s*(.*)$", + -- Optionally specify a lua pattern for matching: + lua_pat = "^([^%s].*)[\\(:](%d+)[,:](%d+)[^%a]*(%a+)%s+TS(%d+)%s*:%s*(.*)$", + file = 1, + line = 2, + column = 3, + severity = 5, + code = 6, + message = 7, }, - "\\v^--- (FAIL|PASS|SKIP): ([^[:space:] ]+) \\(([0-9\\.]+)s\\)", - "status", - "name", - "duration", -}, -{"always", - {"sequence", - {"test", "^panic:"}, - {"skip_until", "^goroutine%s"}, - {"extract_nested", - { append = false }, - "stacktrace", - {"loop", - {"sequence", - {"extract",{ append = false }, { "^(.+)%(.*%)$", "^created by (.+)$" }, "text"}, - {"extract","^%s+([^:]+.go):([0-9]+)", "filename", "lnum"} - } - } - } - } -} -``` - -### invert - -[invert.lua](../lua/overseer/parser/invert.lua) - -A decorator that inverts the child's return value - -```lua -{"invert", child} -``` - -| Param | Type | Desc | -| ----- | -------- | --------------------- | -| child | `parser` | The child parser node | - -#### Examples - -An extract node that returns SUCCESS when it fails, and vice-versa - -```lua -{"invert", - {"extract", "^([^%s].+):(%d+): (.+)$", "filename", "lnum", "text" } -} -``` - -### loop - -[loop.lua](../lua/overseer/parser/loop.lua) - -A decorator that repeats the child - -```lua -{"loop", child} -{"loop", opts, child} -``` - -| Param | Type | Desc | | -| ----- | -------------- | --------------------- | -------------------------------------------------------- | -| opts | `object` | Configuration options | | -| | ignore_failure | `boolean` | Keep looping even when the child fails (default false) | -| | repetitions | `integer` | When set, loop a set number of times then return SUCCESS | -| child | `parser` | The child parser node | | - -### parallel - -[parallel.lua](../lua/overseer/parser/parallel.lua) - -Run the child nodes in parallel - -```lua -{"parallel", child...} -{"parallel", opts, child...} -``` - -| Param | Type | Desc | | -| ----- | ---------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------- | -| opts | `object` | Configuration options | | -| | break_on_first_failure | `boolean` | Stop executing as soon as a child returns FAILURE (default true) | -| | break_on_first_success | `boolean` | Stop executing as soon as a child returns SUCCESS (default false) | -| | reset_children | `boolean` | Reset all children at the beginning of each iteration (default false) | -| child | `parser` | The child parser nodes. Can be passed in as varargs or as a list. | | - -### sequence - -[sequence.lua](../lua/overseer/parser/sequence.lua) - -Run the child nodes sequentially - -```lua -{"sequence", child...} -{"sequence", opts, child...} -``` - -| Param | Type | Desc | | -| ----- | ---------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------- | -| opts | `object` | Configuration options | | -| | break_on_first_failure | `boolean` | Stop executing as soon as a child returns FAILURE (default true) | -| | break_on_first_success | `boolean` | Stop executing as soon as a child returns SUCCESS (default false) | -| child | `parser` | The child parser nodes. Can be passed in as varargs or as a list. | | - -#### Examples - -Extract the message text from one line, then the filename and lnum from the next line - -```lua -{"sequence", - {"extract", { append = false }, { "^(.+)%(.*%)$", "^created by (.+)$" }, "text"}, - {"extract", "^%s+([^:]+.go):([0-9]+)", "filename", "lnum"} -} -``` - -### set_defaults - -[set_defaults.lua](../lua/overseer/parser/set_defaults.lua) - -A decorator that adds values to any items extracted by the child - -```lua -{"set_defaults", child} -{"set_defaults", opts, child} -``` - -| Param | Type | Desc | | -| ----- | ---------- | --------------------- | ----------------------------------------------------------------------------------------------- | -| opts | `object` | Configuration options | | -| | values | `object` | Hardcoded key-value pairs to set as default values | -| | hoist_item | `boolean` | Take the current pending item, and use its fields as the default key-value pairs (default true) | -| child | `parser` | The child parser node | | - -#### Examples - -Extract the filename from a header line, then for each line of output beneath it parse the test name + status, and also add the filename to each item - -```lua -{"sequence", - {"extract", {append = false}, "^Test result (.+)$", "filename"} - {"set_defaults", - {"loop", - {"extract", "^Test (.+): (.+)$", "test_name", "status"} - } - } -} -``` - -### skip_lines - -[skip_lines.lua](../lua/overseer/parser/skip_lines.lua) - -Skip over a set number of lines - -```lua -{"skip_lines", count} -``` - -| Param | Type | Desc | -| ----- | --------- | ---------------------- | -| count | `integer` | How many lines to skip | - -### skip_until - -[skip_until.lua](../lua/overseer/parser/skip_until.lua) - -Skip over lines until one matches - -```lua -{"skip_until", pattern...} -{"skip_until", opts, pattern...} +}} ``` -| Param | Type | Desc | | -| ------- | --------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| opts | `object` | Configuration options | | -| | skip_matching_line | `boolean` | Consumes the line that matches. Later nodes will only see the next line. (default true) | -| | regex | `boolean` | Use vim regex instead of lua pattern (see :help pattern) (default true) | -| pattern | `string\|string[]\|fun(line: string): string` | The lua pattern to use for matching. The node succeeds if any of these patterns match. | | +Note that the structure of the problem matcher is the same as the VS Code definition, with the exception that it supports a `vim_regexp` key and/or a `lua_pat` key. Because JS regexes are slightly different from vim regexes (e.g. vim regex uses `%()` for non-capturing groups instead of `(?:)`), sometimes the regex from the VS Code definition will not work. The fix for this is to rewrite it as a vim-compatible regex or as a lua pattern. When either of these two keys are present, overseer will use them to perform the matching. If not, it will attempt to convert the `regexp` into a vim-compatible regex and use that, which might work some of the time. -#### Examples - -Skip input until we see "Error" or "Warning" +For convenience, you can also use the built-in problem matcher definitions in `on_output_parse`: ```lua -{"skip_until", "^Error:", "^Warning:"} +{"on_output_parse", problem_matcher = "$tsc-watch"} ``` -### test - -[test.lua](../lua/overseer/parser/test.lua) +## Built-in problem matchers -Returns SUCCESS when the line matches the pattern +Problem matchers: -```lua -{"test", pattern} -{"test", opts, pattern} -``` + -| Param | Type | Desc | | -| ------- | ----------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- | -| opts | `object` | Configuration options | | -| | regex | `boolean` | Use vim regex instead of lua pattern (see :help pattern) (default true) | -| pattern | `string\|fun(line: string): string` | The lua pattern to use for matching, or test function | | +- `$eslint-compact` +- `$eslint-stylish` +- `$gcc` +- `$go` +- `$gulp-tsc` +- `$jshint` +- `$jshint-stylish` +- `$lessCompile` +- `$lessc` +- `$msCompile` +- `$node-sass` +- `$nvcc` +- `$tsc` +- `$tsc-watch` + -#### Examples +Patterns: -Fail until a line starts with "panic:" + -```lua -{"test", "^panic:"} -``` +- `$cpp` +- `$csc` +- `$eslint-compact` +- `$eslint-stylish` +- `$go` +- `$gulp-tsc` +- `$jshint` +- `$jshint-stylish` +- `$lessCompile` +- `$msCompile` +- `$nvcc-location` +- `$tsc` +- `$vb` + diff --git a/doc/recipes.md b/doc/recipes.md index ec4e3491..f6f1e756 100644 --- a/doc/recipes.md +++ b/doc/recipes.md @@ -4,14 +4,26 @@ Have a cool recipe to share? Open a pull request and add it to this doc! +- [Run a quick command like with `:!` or `:term`](#run-a-quick-command-like-with--or-term) - [Restart last task](#restart-last-task) - [Run shell scripts in the current directory](#run-shell-scripts-in-the-current-directory) - [Directory-local tasks with exrc](#directory-local-tasks-with-exrc) -- [:Make similar to vim-dispatch](#make-similar-to-vim-dispatch) +- [Asynchronous :Make similar to vim-dispatch](#asynchronous-make-similar-to-vim-dispatch) - [Asynchronous :Grep command](#asynchronous-grep-command) +- [Create a window that displays the most recent task output](#create-a-window-that-displays-the-most-recent-task-output) +## Run a quick command like with `:!` or `:term` + +The `:OverseerShell` command allows you to run a shell command as an overseer task. It's a bit much to type, so we can create an abbreviation for that: + +```lua +vim.cmd.cnoreabbrev("OS OverseerShell") +``` + +Now you can easily start a new task by simply typing `:OS ` + ## Restart last task This command restarts the most recent overseer task @@ -19,11 +31,17 @@ This command restarts the most recent overseer task ```lua vim.api.nvim_create_user_command("OverseerRestartLast", function() local overseer = require("overseer") - local tasks = overseer.list_tasks({ recent_first = true }) + local task_list = require("overseer.task_list") + local tasks = overseer.list_tasks({ status = { + overseer.STATUS.SUCCESS, + overseer.STATUS.FAILURE, + overseer.STATUS.CANCELED, + }, sort = task_list.sort_finished_recently }) if vim.tbl_isempty(tasks) then vim.notify("No tasks found", vim.log.levels.WARN) else - overseer.run_action(tasks[1], "restart") + local most_recent = tasks[1] + overseer.run_action(most_recent, "restart") end end, {}) ``` @@ -35,8 +53,9 @@ This template will find all shell scripts in the current directory and create ta ```lua local files = require("overseer.files") +---@type overseer.TemplateFileProvider return { - generator = function(opts, cb) + generator = function(opts) local scripts = vim.tbl_filter(function(filename) return filename:match("%.sh$") end, files.list_files(opts.dir)) @@ -44,19 +63,15 @@ return { for _, filename in ipairs(scripts) do table.insert(ret, { name = filename, - params = { - args = { optional = true, type = "list", delimiter = " " }, - }, builder = function(params) return { - cmd = { files.join(opts.dir, filename) }, - args = params.args, + cmd = { vim.fs.joinpath(opts.dir, filename) }, } end, }) end - cb(ret) + return ret end, } ``` @@ -69,7 +84,6 @@ You can add directory-local tasks by setting the exrc option (`vim.o.exrc = true -- /path/to/dir/.nvim.lua require("overseer").register_template({ name = "My project task", - params = {}, condition = { -- This makes the template only available in the current directory -- In case you :cd out later @@ -77,14 +91,14 @@ require("overseer").register_template({ }, builder = function() return { - cmd = {"echo"}, - args = {"Hello", "world"}, + cmd = { "echo" }, + args = { "Hello", "world" }, } end, }) ``` -## :Make similar to vim-dispatch +## Asynchronous :Make similar to vim-dispatch The venerable vim-dispatch provides several commands, but the main `:Make` command can be mimicked fairly easily: @@ -139,3 +153,24 @@ vim.api.nvim_create_user_command("Grep", function(params) task:start() end, { nargs = "*", bang = true, complete = "file" }) ``` + +## Create a window that displays the most recent task output + +You can use `overseer.create_task_output_view` to create a dynamic view of task output based on any +criteria. This example will show the output of the most recently started task. + +```lua +overseer.create_task_output_view(0, { + list_task_opts = { + filter = function(task) + return task.time_start ~= nil + end, + } + select = function(self, tasks, task_under_cursor) + table.sort(tasks, function(a, b) + return a.time_start > b.time_start + end) + return tasks[1] + end, +}) +``` diff --git a/doc/reference.md b/doc/reference.md index 14f52084..b6446147 100644 --- a/doc/reference.md +++ b/doc/reference.md @@ -7,37 +7,56 @@ - [Highlight groups](#highlight-groups) - [Lua API](#lua-api) - [setup(opts)](#setupopts) - - [on_setup(callback)](#on_setupcallback) - [new_task(opts)](#new_taskopts) - [toggle(opts)](#toggleopts) - [open(opts)](#openopts) - [close()](#close) - - [list_task_bundles()](#list_task_bundles) - - [load_task_bundle(name, opts)](#load_task_bundlename-opts) - - [save_task_bundle(name, tasks, opts)](#save_task_bundlename-tasks-opts) - - [delete_task_bundle(name)](#delete_task_bundlename) - [list_tasks(opts)](#list_tasksopts) - - [run_template(opts, callback)](#run_templateopts-callback) + - [run_task(opts, callback)](#run_taskopts-callback) - [preload_task_cache(opts, cb)](#preload_task_cacheopts-cb) - [clear_task_cache(opts)](#clear_task_cacheopts) - [run_action(task, name)](#run_actiontask-name) - - [wrap_template(base, override, default_params)](#wrap_templatebase-override-default_params) - [add_template_hook(opts, hook)](#add_template_hookopts-hook) - [remove_template_hook(opts, hook)](#remove_template_hookopts-hook) - [register_template(defn)](#register_templatedefn) - - [load_template(name)](#load_templatename) - - [debug_parser()](#debug_parser) - - [register_alias(name, components)](#register_aliasname-components) + - [register_alias(name, components, override)](#register_aliasname-components-override) + - [create_task_output_view(winid, opts)](#create_task_output_viewwinid-opts) + - [overseer.Task](#overseertask) + - [Task:serialize()](#taskserialize) + - [Task:clone()](#taskclone) + - [Task:add_component(comp)](#taskadd_componentcomp) + - [Task:add_components(components)](#taskadd_componentscomponents) + - [Task:set_component(comp)](#taskset_componentcomp) + - [Task:set_components(components)](#taskset_componentscomponents) + - [Task:get_component(name)](#taskget_componentname) + - [Task:remove_component(name)](#taskremove_componentname) + - [Task:remove_components(names)](#taskremove_componentsnames) + - [Task:has_component(name)](#taskhas_componentname) + - [Task:subscribe(event, callback)](#tasksubscribeevent-callback) + - [Task:unsubscribe(event, callback)](#taskunsubscribeevent-callback) + - [Task:is_pending()](#taskis_pending) + - [Task:is_running()](#taskis_running) + - [Task:is_complete()](#taskis_complete) + - [Task:is_disposed()](#taskis_disposed) + - [Task:get_bufnr()](#taskget_bufnr) + - [Task:open_output(direction)](#taskopen_outputdirection) + - [Task:broadcast(name)](#taskbroadcastname) + - [Task:dispatch(name)](#taskdispatchname) + - [Task:inc_reference()](#taskinc_reference) + - [Task:dec_reference()](#taskdec_reference) + - [Task:dispose(force)](#taskdisposeforce) + - [Task:restart(force_stop)](#taskrestartforce_stop) + - [Task:start()](#taskstart) + - [Task:stop()](#taskstop) - [Components](#components) - [dependencies](components.md#dependencies) - - [display_duration](components.md#display_duration) - [on_complete_dispose](components.md#on_complete_dispose) - [on_complete_notify](components.md#on_complete_notify) - [on_complete_restart](components.md#on_complete_restart) - [on_exit_set_status](components.md#on_exit_set_status) + - [on_output_notify](components.md#on_output_notify) - [on_output_parse](components.md#on_output_parse) - [on_output_quickfix](components.md#on_output_quickfix) - - [on_output_summarize](components.md#on_output_summarize) - [on_output_write_file](components.md#on_output_write_file) - [on_result_diagnostics](components.md#on_result_diagnostics) - [on_result_diagnostics_quickfix](components.md#on_result_diagnostics_quickfix) @@ -51,27 +70,8 @@ - [Strategies](#strategies) - [jobstart(opts)](strategies.md#jobstartopts) - [orchestrator(opts)](strategies.md#orchestratoropts) - - [terminal()](strategies.md#terminal) + - [system(opts)](strategies.md#systemopts) - [test()](strategies.md#test) - - [toggleterm(opts)](strategies.md#toggletermopts) -- [Parsers](#parsers) - - [always](parsers.md#always) - - [append](parsers.md#append) - - [dispatch](parsers.md#dispatch) - - [ensure](parsers.md#ensure) - - [extract](parsers.md#extract) - - [extract_efm](parsers.md#extract_efm) - - [extract_json](parsers.md#extract_json) - - [extract_multiline](parsers.md#extract_multiline) - - [extract_nested](parsers.md#extract_nested) - - [invert](parsers.md#invert) - - [loop](parsers.md#loop) - - [parallel](parsers.md#parallel) - - [sequence](parsers.md#sequence) - - [set_defaults](parsers.md#set_defaults) - - [skip_lines](parsers.md#skip_lines) - - [skip_until](parsers.md#skip_until) - - [test](parsers.md#test) - [Parameters](#parameters) @@ -82,156 +82,93 @@ For speed tweakers: don't worry about lazy loading; overseer lazy-loads itself! ```lua require("overseer").setup({ - -- Default task strategy - strategy = "terminal", - -- Template modules to load - templates = { "builtin" }, - -- Directories where overseer will look for template definitions (relative to rtp) - template_dirs = { "overseer.template" }, - -- When true, tries to detect a green color from your colorscheme to use for success highlight - auto_detect_success_color = true, -- Patch nvim-dap to support preLaunchTask and postDebugTask dap = true, + -- Configure the task output buffer and window + output = { + -- Use a terminal buffer to display output. If false, a normal buffer is used + use_terminal = true, + -- If true, don't clear the buffer when a task restarts + preserve_output = false, + }, -- Configure the task list task_list = { - -- Default detail level for tasks. Can be 1-3. - default_detail = 1, + -- Default direction. Can be "left", "right", or "bottom" + direction = "bottom", -- Width dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%) -- min_width and max_width can be a single value or a list of mixed integer/float types. -- max_width = {100, 0.2} means "the lesser of 100 columns or 20% of total" max_width = { 100, 0.2 }, -- min_width = {40, 0.1} means "the greater of 40 columns or 10% of total" min_width = { 40, 0.1 }, - -- optionally define an integer/float for the exact width of the task list - width = nil, - max_height = { 20, 0.1 }, + max_height = { 20, 0.2 }, min_height = 8, - height = nil, -- String that separates tasks - separator = "────────────────────────────────────────", - -- Default direction. Can be "left", "right", or "bottom" - direction = "bottom", + separator = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + -- Indentation for child tasks + child_indent = { "┃ ", "┣━", "┗━" }, + -- Function that renders tasks. See lua/overseer/render.lua for built-in options + -- and for useful functions if you want to build your own. + render = function(task) + return require("overseer.render").format_standard(task) + end, + -- The sort function for tasks + sort = function(a, b) + return require("overseer.task_list").default_sort(a, b) + end, -- Set keymap to false to remove default behavior -- You can add custom keymaps here as well (anything vim.keymap.set accepts) - bindings = { - ["?"] = "ShowHelp", - ["g?"] = "ShowHelp", - [""] = "RunAction", - [""] = "Edit", - ["o"] = "Open", - [""] = "OpenVsplit", - [""] = "OpenSplit", - [""] = "OpenFloat", - [""] = "OpenQuickFix", - ["p"] = "TogglePreview", - [""] = "IncreaseDetail", - [""] = "DecreaseDetail", - ["L"] = "IncreaseAllDetail", - ["H"] = "DecreaseAllDetail", - ["["] = "DecreaseWidth", - ["]"] = "IncreaseWidth", - ["{"] = "PrevTask", - ["}"] = "NextTask", - [""] = "ScrollOutputUp", - [""] = "ScrollOutputDown", - ["q"] = "Close", + keymaps = { + ["?"] = "keymap.show_help", + ["g?"] = "keymap.show_help", + [""] = "keymap.run_action", + ["dd"] = { "keymap.run_action", opts = { action = "dispose" }, desc = "Dispose task" }, + [""] = { "keymap.run_action", opts = { action = "edit" }, desc = "Edit task" }, + ["o"] = "keymap.open", + [""] = { "keymap.open", opts = { dir = "vsplit" }, desc = "Open task output in vsplit" }, + [""] = { "keymap.open", opts = { dir = "split" }, desc = "Open task output in split" }, + [""] = { "keymap.open", opts = { dir = "tab" }, desc = "Open task output in tab" }, + [""] = { "keymap.open", opts = { dir = "float" }, desc = "Open task output in float" }, + [""] = { + "keymap.run_action", + opts = { action = "open output in quickfix" }, + desc = "Open task output in the quickfix", + }, + ["p"] = "keymap.toggle_preview", + ["{"] = "keymap.prev_task", + ["}"] = "keymap.next_task", + [""] = "keymap.scroll_output_up", + [""] = "keymap.scroll_output_down", + ["g."] = "keymap.toggle_show_wrapped", + ["q"] = { "close", desc = "Close task list" }, }, }, - -- See :help overseer-actions + -- Custom actions for tasks. See :help overseer-actions actions = {}, -- Configure the floating window used for task templates that require input -- and the floating window used for editing tasks form = { - border = "rounded", zindex = 40, -- Dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%) -- min_X and max_X can be a single value or a list of mixed integer/float types. min_width = 80, max_width = 0.9, - width = nil, min_height = 10, max_height = 0.9, - height = nil, - -- Set any window options here (e.g. winhighlight) - win_opts = { - winblend = 0, - }, - }, - task_launcher = { - -- Set keymap to false to remove default behavior - -- You can add custom keymaps here as well (anything vim.keymap.set accepts) - bindings = { - i = { - [""] = "Submit", - [""] = "Cancel", - }, - n = { - [""] = "Submit", - [""] = "Submit", - ["q"] = "Cancel", - ["?"] = "ShowHelp", - }, - }, - }, - task_editor = { - -- Set keymap to false to remove default behavior - -- You can add custom keymaps here as well (anything vim.keymap.set accepts) - bindings = { - i = { - [""] = "NextOrSubmit", - [""] = "Submit", - [""] = "Next", - [""] = "Prev", - [""] = "Cancel", - }, - n = { - [""] = "NextOrSubmit", - [""] = "Submit", - [""] = "Next", - [""] = "Prev", - ["q"] = "Cancel", - ["?"] = "ShowHelp", - }, - }, - }, - -- Configure the floating window used for confirmation prompts - confirm = { - border = "rounded", - zindex = 40, - -- Dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%) - -- min_X and max_X can be a single value or a list of mixed integer/float types. - min_width = 20, - max_width = 0.5, - width = nil, - min_height = 6, - max_height = 0.9, - height = nil, -- Set any window options here (e.g. winhighlight) - win_opts = { - winblend = 0, - }, + win_opts = {}, }, - -- Configuration for task floating windows + -- Configuration for task floating output windows task_win = { -- How much space to leave around the floating window padding = 2, - border = "rounded", -- Set any window options here (e.g. winhighlight) - win_opts = { - winblend = 0, - }, - }, - -- Configuration for mapping help floating windows - help_win = { - border = "rounded", win_opts = {}, }, -- Aliases for bundles of components. Redefine the builtins, or create your own. component_aliases = { -- Most tasks are initialized with the default components default = { - { "display_duration", detail_level = 2 }, - "on_output_summarize", "on_exit_set_status", "on_complete_notify", { "on_complete_dispose", require_view = { "SUCCESS", "FAILURE" } }, @@ -241,65 +178,44 @@ require("overseer").setup({ "default", "on_result_diagnostics", }, - }, - bundles = { - -- When saving a bundle with OverseerSaveBundle or save_task_bundle(), filter the tasks with - -- these options (passed to list_tasks()) - save_task_opts = { - bundleable = true, + -- Tasks created from experimental_wrap_builtins + default_builtin = { + "on_exit_set_status", + "on_complete_dispose", + { "unique", soft = true }, }, - -- Autostart tasks when they are loaded from a bundle - autostart_on_load = true, }, - -- A list of components to preload on setup. - -- Only matters if you want them to show up in the task editor. - preload_components = {}, - -- Controls when the parameter prompt is shown when running a template - -- always Show when template has any params - -- missing Show when template has any params not explicitly passed in - -- allow Only show when a required param is missing - -- avoid Only show when a required param with no default value is missing - -- never Never show prompt (error if required param missing) - default_template_prompt = "allow", - -- For template providers, how long to wait (in ms) before timing out. - -- Set to 0 to disable timeouts. - template_timeout = 3000, + -- List of other directories to search for task templates. + -- This will search under the runtimepath, so for example + -- "foo/bar" will search "/lua/foo/bar/*" + template_dirs = {}, + -- For template providers, how long to wait before timing out. + -- Set to 0 to wait forever. + template_timeout_ms = 3000, -- Cache template provider results if the provider takes longer than this to run. - -- Time is in ms. Set to 0 to disable caching. - template_cache_threshold = 100, - -- Configure where the logs go and what level to use - -- Types are "echo", "notify", and "file" - log = { - { - type = "echo", - level = vim.log.levels.WARN, - }, - { - type = "file", - filename = "overseer.log", - level = vim.log.levels.WARN, - }, + -- Set to 0 to disable caching. + template_cache_threshold_ms = 200, + log_level = vim.log.levels.WARN, + -- Overseer can wrap any call to vim.system and vim.fn.jobstart as a task. + experimental_wrap_builtins = { + enabled = false, + condition = function(cmd, caller, opts) + return true + end, }, }) ``` ## Commands -| Command | Args | Description | -| ----------------------- | ------------------- | ---------------------------------------------------------------------- | -| `OverseerOpen[!]` | `left/right/bottom` | Open the overseer window. With `!` cursor stays in current window | -| `OverseerClose` | | Close the overseer window | -| `OverseerToggle[!]` | `left/right/bottom` | Toggle the overseer window. With `!` cursor stays in current window | -| `OverseerSaveBundle` | `[name]` | Serialize and save the current tasks to disk | -| `OverseerLoadBundle[!]` | `[name]` | Load tasks that were saved to disk. With `!` tasks will not be started | -| `OverseerDeleteBundle` | `[name]` | Delete a saved task bundle | -| `OverseerRunCmd` | `[command]` | Run a raw shell command | -| `OverseerRun` | `[name/tags]` | Run a task from a template | -| `OverseerInfo` | | Display diagnostic information about overseer | -| `OverseerBuild` | | Open the task builder | -| `OverseerQuickAction` | `[action]` | Run an action on the most recent task, or the task under the cursor | -| `OverseerTaskAction` | | Select a task to run an action on | -| `OverseerClearCache` | | Clear the task cache | +| Command | Args | Description | +| -------------------- | ------------------- | ------------------------------------------------------------------------------------- | +| `OverseerOpen[!]` | `left/right/bottom` | Open the overseer window. With `!` cursor stays in current window | +| `OverseerClose` | | Close the overseer window | +| `OverseerToggle[!]` | `left/right/bottom` | Toggle the overseer window. With `!` cursor stays in current window | +| `OverseerRun` | `[name/tags]` | Run a task from a template | +| `OverseerShell[!]` | `[command]` | Run a shell command as an overseer task. With `!` the task is created but not started | +| `OverseerTaskAction` | | Select a task to run an action on | ## Highlight groups @@ -328,7 +244,6 @@ The official API surface includes: - Config options passed to `setup()` - [Components](explanation.md#components), including names and parameters - [Commands](#commands) -- [Parsers](guides.md#parsing-output), including names and parameters @@ -337,18 +252,9 @@ The official API surface includes: `setup(opts)` \ Initialize overseer -| Param | Type | Desc | -| ----- | ---------------------- | --------------------- | -| opts | `overseer.Config\|nil` | Configuration options | - -### on_setup(callback) - -`on_setup(callback)` \ -Add a callback to run after overseer lazy setup - -| Param | Type | Desc | -| -------- | ------- | ---- | -| callback | `fun()` | | +| Param | Type | Desc | +| ----- | ------------------------- | --------------------- | +| opts | `overseer.SetupOpts\|nil` | Configuration options | ### new_task(opts) @@ -367,12 +273,12 @@ Create a new Task | >metadata | `nil\|table` | Arbitrary metadata for your own use | | >default_component_params | `nil\|table` | Default values for component params | | >components | `nil\|overseer.Serialized[]` | List of components to attach. Defaults to `{"default"}` | +| >ephemeral | `nil\|boolean` | Indicates that this task was generated by another task (e.g. with run_after) | **Examples:** ```lua local task = overseer.new_task({ - cmd = { "./build.sh" }, - args = { "all" }, + cmd = { "./build.sh", "all" }, components = { { "on_output_quickfix", open = true }, "default" } }) task:start() @@ -386,7 +292,7 @@ Open or close the task list | Param | Type | Desc | | -------------- | -------------------------------- | -------------------------------------------------------- | | opts | `nil\|overseer.WindowOpts` | | -| >enter | `nil\|boolean` | | +| >enter | `nil\|boolean` | Focus the task list window after opening (default true) | | >direction | `nil\|"left"\|"right"\|"bottom"` | | | >winid | `nil\|integer` | Use this existing window instead of opening a new window | | >focus_task_id | `nil\|integer` | After opening, focus this task | @@ -396,11 +302,13 @@ Open or close the task list `open(opts)` \ Open the task list -| Param | Type | Desc | -| ---------- | -------------------------- | ---------------------------------------------- | -| opts | `nil\|overseer.WindowOpts` | | -| >enter | `boolean\|nil` | If false, stay in current window. Default true | -| >direction | `nil\|"left"\|"right"` | Which direction to open the task list | +| Param | Type | Desc | +| -------------- | -------------------------------- | -------------------------------------------------------- | +| opts | `nil\|overseer.WindowOpts` | | +| >enter | `nil\|boolean` | Focus the task list window after opening (default true) | +| >direction | `nil\|"left"\|"right"\|"bottom"` | | +| >winid | `nil\|integer` | Use this existing window instead of opening a new window | +| >focus_task_id | `nil\|integer` | After opening, focus this task | ### close() @@ -408,133 +316,74 @@ Open the task list Close the task list -### list_task_bundles() - -`list_task_bundles(): string[]` \ -Get the list of saved task bundles - - -Returns: - -| Type | Desc | -| -------- | --------------------- | -| string[] | Names of task bundles | - -### load_task_bundle(name, opts) - -`load_task_bundle(name, opts)` \ -Load tasks from a saved bundle - -| Param | Type | Desc | -| --------------- | -------------- | ------------------------------------------------------- | -| name | `nil\|string` | | -| opts | `nil\|table` | | -| >ignore_missing | `nil\|boolean` | When true, don't notify if bundle doesn't exist | -| >autostart | `nil\|boolean` | When true, start the tasks after loading (default true) | - -### save_task_bundle(name, tasks, opts) - -`save_task_bundle(name, tasks, opts)` \ -Save tasks to a bundle on disk - -| Param | Type | Desc | -| ------------ | -------------------------------------- | ------------------------------------------------------------------ | -| name | `string\|nil` | Name of bundle. If nil, will prompt user. | -| tasks | `nil\|overseer.Task[]` | Specific tasks to save. If nil, uses config.bundles.save_task_opts | -| opts | `table\|nil` | | -| >on_conflict | `nil\|"overwrite"\|"append"\|"cancel"` | | - -### delete_task_bundle(name) - -`delete_task_bundle(name)` \ -Delete a saved task bundle - -| Param | Type | Desc | -| ----- | ------------- | ---- | -| name | `string\|nil` | | - ### list_tasks(opts) `list_tasks(opts): overseer.Task[]` \ List all tasks -| Param | Type | Desc | -| ------------- | ----------------------------------------- | --------------------------------------------------- | -| opts | `nil\|overseer.ListTaskOpts` | | -| >unique | `nil\|boolean` | Deduplicates non-running tasks by name | -| >name | `nil\|string\|string[]` | Only list tasks with this name or names | -| >name_not | `nil\|boolean` | Invert the name search (tasks *without* that name) | -| >status | `nil\|overseer.Status\|overseer.Status[]` | Only list tasks with this status or statuses | -| >status_not | `nil\|boolean` | Invert the status search | -| >recent_first | `nil\|boolean` | The most recent tasks are first in the list | -| >bundleable | `nil\|boolean` | Only list tasks that should be included in a bundle | -| >filter | `nil\|fun(task: overseer.Task): boolean` | | - -### run_template(opts, callback) - -`run_template(opts, callback)` \ -Run a task from a template +| Param | Type | Desc | +| ------------------ | ------------------------------------------------------- | ------------------------------------------------------------------- | +| opts | `nil\|overseer.ListTaskOpts` | | +| >unique | `nil\|boolean` | Deduplicates non-running tasks by name | +| >status | `nil\|overseer.Status\|overseer.Status[]` | Only list tasks with this status or statuses | +| >include_ephemeral | `nil\|boolean` | Include ephemeral tasks | +| >wrapped | `nil\|boolean` | Include tasks that were created by the jobstart/vim.system wrappers | +| >filter | `nil\|fun(task: overseer.Task): boolean` | Only include tasks where this function returns true | +| >sort | `nil\|fun(a: overseer.Task, b: overseer.Task): boolean` | Function that sorts tasks | -| Param | Type | Desc | -| ---------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | -| opts | `overseer.TemplateRunOpts` | | -| >name | `nil\|string` | The name of the template to run | -| >tags | `nil\|string[]` | List of tags used to filter when searching for template | -| >autostart | `nil\|boolean` | When true, start the task after creating it (default true) | -| >first | `nil\|boolean` | When true, take first result and never show the task picker. Default behavior will auto-set this based on presence of name and tags | -| >prompt | `nil\|"always"\|"missing"\|"allow"\|"avoid"\|"never"` | Controls when to prompt user for parameter input | -| >params | `nil\|table` | Parameters to pass to template | -| >cwd | `nil\|string` | Working directory for the task | -| >env | `nil\|table` | Additional environment variables for the task | -| callback | `nil\|fun(task: overseer.Task\|nil, err: string\|nil)` | | +### run_task(opts, callback) -**Note:** -
-The prompt option will control when the user is presented a popup dialog to input template
-parameters. The possible values are:
-   always    Show when template has any params
-   missing   Show when template has any params not explicitly passed in
-   allow     Only show when a required param is missing
-   avoid     Only show when a required param with no default value is missing
-   never     Never show prompt (error if required param missing)
-The default is controlled by the default_template_prompt config option.
-
+`run_task(opts, callback)` \ +Run a task from a template + +| Param | Type | Desc | +| ---------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| opts | `overseer.TemplateRunOpts` | | +| >name | `nil\|string` | The name of the template to run | +| >tags | `nil\|string[]` | List of tags used to filter when searching for template | +| >autostart | `nil\|boolean` | When true, start the task after creating it (default true) | +| >first | `nil\|boolean` | When true, take first result and never show the task picker. Default behavior will auto-set this based on presence of name and tags | +| >params | `nil\|table` | Parameters to pass to template | +| >cwd | `nil\|string` | Working directory for the task | +| >env | `nil\|table` | Additional environment variables for the task | +| >disallow_prompt | `nil\|boolean` | When true, if any required parameters are missing return an error instead of prompting the user for them | +| >on_build | `nil\|fun(task_defn: overseer.TaskDefinition, util: overseer.TaskUtil)` | callback that is called after the task definition is built but before the task is created. | +| callback | `nil\|fun(task: overseer.Task\|nil, err: string\|nil)` | | **Examples:** ```lua -- Run the task named "make all" -- equivalent to :OverseerRun make\ all -overseer.run_template({name = "make all"}) +overseer.run_task({name = "make all"}) -- Run the default "build" task -- equivalent to :OverseerRun BUILD -overseer.run_template({tags = {overseer.TAG.BUILD}}) +overseer.run_task({tags = {overseer.TAG.BUILD}}) -- Run the task named "serve" with some default parameters -overseer.run_template({name = "serve", params = {port = 8080}}) +overseer.run_task({name = "serve", params = {port = 8080}}) -- Create a task but do not start it -overseer.run_template({name = "make", autostart = false}, function(task) +overseer.run_task({name = "make", autostart = false}, function(task) -- do something with the task end) -- Run a task and immediately open the floating window -overseer.run_template({name = "make"}, function(task) +overseer.run_task({name = "make"}, function(task) if task then overseer.run_action(task, 'open float') end end) --- Run a task and always show the parameter prompt -overseer.run_template({name = "npm watch", prompt = "always"}) ``` ### preload_task_cache(opts, cb) `preload_task_cache(opts, cb)` \ -Preload templates for run_template +Preload templates for run_task -| Param | Type | Desc | -| ----- | ------------- | ---------------------------------- | -| opts | `nil\|table` | | -| >dir | `string` | | -| >ft | `nil\|string` | | -| cb | `nil\|fun()` | Called when preloading is complete | +| Param | Type | Desc | +| --------- | ---------------------------- | ---------------------------------- | +| opts | `nil\|overseer.SearchParams` | | +| >filetype | `nil\|string` | | +| >tags | `nil\|string[]` | | +| >dir | `string` | | +| cb | `nil\|fun()` | Called when preloading is complete | **Note:**
@@ -554,13 +403,14 @@ vim.api.nvim_create_autocmd({"VimEnter", "DirChanged"}, {
 ### clear_task_cache(opts)
 
 `clear_task_cache(opts)` \
-Clear cached templates for run_template
+Clear cached templates for run_task
 
-| Param | Type          | Desc |
-| ----- | ------------- | ---- |
-| opts  | `nil\|table`  |      |
-| >dir  | `string`      |      |
-| >ft   | `nil\|string` |      |
+| Param     | Type                         | Desc |
+| --------- | ---------------------------- | ---- |
+| opts      | `nil\|overseer.SearchParams` |      |
+| >filetype | `nil\|string`                |      |
+| >tags     | `nil\|string[]`              |      |
+| >dir      | `string`                     |      |
 
 ### run_action(task, name)
 
@@ -570,73 +420,19 @@ Run an action on a task
 | Param | Type            | Desc                                               |
 | ----- | --------------- | -------------------------------------------------- |
 | task  | `overseer.Task` |                                                    |
-| name  | `string\|nil`   | Name of action. When omitted, prompt user to pick. |
-
-### wrap_template(base, override, default_params)
-
-`wrap_template(base, override, default_params): overseer.TemplateFileDefinition` \
-Create a new template by overriding fields on another
-
-| Param          | Type                                               | Desc                                                  |
-| -------------- | -------------------------------------------------- | ----------------------------------------------------- |
-| base           | `overseer.TemplateFileDefinition`                  | The base template definition to wrap                  |
-| >module        | `nil\|string`                                      | The name of the module this was loaded from           |
-| >aliases       | `nil\|string[]`                                    |                                                       |
-| >desc          | `nil\|string`                                      |                                                       |
-| >tags          | `nil\|string[]`                                    |                                                       |
-| >params        | `nil\|overseer.Params\|fun(): overseer.Params`     |                                                       |
-| >priority      | `nil\|number`                                      |                                                       |
-| >condition     | `nil\|overseer.SearchCondition`                    |                                                       |
-| >>filetype     | `nil\|string\|string[]`                            |                                                       |
-| >>dir          | `nil\|string\|string[]`                            |                                                       |
-| >>callback     | `nil\|fun(search: overseer.SearchParams): boolean` | , nil|string                                          |
-| >builder       | `fun(params: table): overseer.TaskDefinition`      |                                                       |
-| >hide          | `nil\|boolean`                                     | Hide from the template list                           |
-| override       | `nil\|table`                          | Override any fields on the base                       |
-| default_params | `nil\|table`                          | Provide default values for any parameters on the base |
-
-**Note:**
-
-This is typically used for a TemplateProvider, to define the task a single time and generate
-multiple templates based on the available args.
-
- -**Examples:** -```lua -local tmpl = { - params = { - args = { type = 'list', delimiter = ' ' } - }, - builder = function(params) - return { - cmd = { 'make' }, - args = params.args, - } -} -local template_provider = { - name = "Some provider", - generator = function(opts, cb) - cb({ - overseer.wrap_template(tmpl, nil, { args = { 'all' } }), - overseer.wrap_template(tmpl, {name = 'make clean'}, { args = { 'clean' } }), - }) - end -} -``` +| name | `nil\|string` | Name of action. When omitted, prompt user to pick. | ### add_template_hook(opts, hook) `add_template_hook(opts, hook)` \ Add a hook that runs on a TaskDefinition before the task is created -| Param | Type | Desc | -| --------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------- | -| opts | `nil\|overseer.HookOptions` | When nil, run the hook on all templates | -| >name | `nil\|string` | Only run if the template name matches this pattern (using string.match) | -| >module | `nil\|string` | Only run if the template module matches this pattern (using string.match) | -| >filetype | `nil\|string\|string[]` | Only run if the current file is one of these filetypes | -| >dir | `nil\|string\|string[]` | Only run if inside one of these directories | -| hook | `fun(task_defn: overseer.TaskDefinition, util: overseer.TaskUtil)` | | +| Param | Type | Desc | +| ------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------- | +| opts | `nil\|overseer.HookOptions` | When nil, run the hook on all templates | +| >module | `nil\|string` | Only run if the template module matches this pattern (using string.match) | +| >name | `nil\|string` | Only run if the template name matches this pattern (using string.match) | +| hook | `fun(task_defn: overseer.TaskDefinition, util: overseer.TaskUtil)` | | **Examples:** ```lua @@ -661,12 +457,12 @@ end) `remove_template_hook(opts, hook)` \ Remove a hook that was added with add_template_hook -| Param | Type | Desc | -| ------- | ------------------------------------------------------------------ | ----------------------------- | -| opts | `nil\|overseer.HookOptions` | Same as for add_template_hook | -| >module | `nil\|string` | | -| >name | `nil\|string` | | -| hook | `fun(task_defn: overseer.TaskDefinition, util: overseer.TaskUtil)` | | +| Param | Type | Desc | +| ------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------- | +| opts | `nil\|overseer.HookOptions` | Same as for add_template_hook | +| >module | `nil\|string` | Only run if the template module matches this pattern (using string.match) | +| >name | `nil\|string` | Only run if the template name matches this pattern (using string.match) | +| hook | `fun(task_defn: overseer.TaskDefinition, util: overseer.TaskUtil)` | | **Examples:** ```lua @@ -700,65 +496,325 @@ overseer.register_template({ }) ``` -### load_template(name) +### register_alias(name, components, override) -`load_template(name)` \ -Load a template definition from its module location +`register_alias(name, components, override)` \ +Register a new component alias. -| Param | Type | Desc | -| ----- | -------- | ---- | -| name | `string` | | +| Param | Type | Desc | +| ---------- | ----------------------- | --------------------------------------------------------- | +| name | `string` | | +| components | `overseer.Serialized[]` | | +| override | `nil\|boolean` | When true, override any existing alias with the same name | + +**Note:** +
+This is intended to be used by plugin authors that wish to build on top of overseer. They do not
+have control over the call to overseer.setup(), so this provides an alternative method of
+setting a component alias that they can then use when creating tasks.
+
+ +**Examples:** +```lua +require("overseer").register_alias("my_plugin", { "default", "on_output_quickfix" }) +``` + +### create_task_output_view(winid, opts) + +`create_task_output_view(winid, opts)` \ +Set a window to display the output of a dynamically-chosen task + +| Param | Type | Desc | +| -------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | +| winid | `nil\|integer` | The window to use for displaying the task output | +| opts | `nil\|overseer.TaskViewOpts` | | +| >select | `nil\|fun(self: overseer.TaskView, tasks: overseer.Task[], task_under_cursor?: overseer.Task): nil\|overseer.Task` | Select which task in the task list to display the output of | +| >close_on_list_close | `nil\|boolean` | Close the window when the task list is closed | +| >list_task_opts | `nil\|overseer.ListTaskOpts` | Passed to list_tasks() to get the list of tasks to pass to the select() function | +| >>unique | `nil\|boolean` | Deduplicates non-running tasks by name | +| >>status | `nil\|overseer.Status\|overseer.Status[]` | Only list tasks with this status or statuses | +| >>include_ephemeral | `nil\|boolean` | Include ephemeral tasks | +| >>wrapped | `nil\|boolean` | Include tasks that were created by the jobstart/vim.system wrappers | +| >>filter | `nil\|fun(task: overseer.Task): boolean` | Only include tasks where this function returns true | +| >>sort | `nil\|fun(a: overseer.Task, b: overseer.Task): boolean` | Function that sorts tasks | **Examples:** ```lua --- This will load the template in lua/overseer/template/mytask.lua -overseer.load_template('mytask') +-- Always show the output from the most recent Neotest task in this window. +-- Close it automatically when all test tasks are disposed. +overseer.create_task_output_view(0, { + select = function(self, tasks, task_under_cursor) + for _, task in ipairs(tasks) do + if task.metadata.neotest_group_id then + return task + end + end + self:dispose() + end, +}) ``` -### debug_parser() -`debug_parser()` \ -Open a tab with windows laid out for debugging a parser + + +### overseer.Task -### register_alias(name, components) +| Field | Type | Desc | +| ---------- | ---------------------------- | ------------------------------------------------------------------------------------------------------ | +| id | `integer` | Unique ID for this task | +| result | `nil\|table` | For successful tasks, arbitrary key-value mapping of data produced by components | +| metadata | `table` | Arbitrary key-value mapping passed by the user during construction | +| status | `overseer.Status` | Current task status | +| cmd | `string\|string[]` | Command to run. If it's a string it is run in the shell | +| cwd | `string` | Working directory the task is run in | +| env | `nil\|table` | Additional environment variables for the task | +| name | `string` | Name of the task | +| ephemeral | `boolean` | Indicates that this task was generated indirectly (e.g. with run_after) | +| source | `nil\|overseer.Caller` | If this task was created by wrapping jobstart/vim.system, this contains information about the callsite | +| exit_code | `nil\|integer` | Exit code of the task process | +| parent_id | `nil\|integer` | ID of parent task. Used only to visually group tasks in the task list | +| time_start | `nil\|integer` | Timestamp when the task was started (os.time()) | +| time_end | `nil\|integer` | Timestamp when the task ended (os.time()) | + + +#### Task:serialize() + +`Task:serialize(): overseer.TaskDefinition` \ +Returns the arguments require to create a clone of this task when passed to overseer.new_task + + +#### Task:clone() + +`Task:clone(): overseer.Task` \ +Create a deep copy of this task -`register_alias(name, components)` \ -Register a new component alias. + +#### Task:add_component(comp) + +`Task:add_component(comp)` \ +Add a component, no-op if it already exists + +| Param | Type | Desc | +| ----- | --------------------- | ---- | +| comp | `overseer.Serialized` | | + +#### Task:add_components(components) + +`Task:add_components(components)` \ +Add components, skipping any that already exist + +| Param | Type | Desc | +| ---------- | ----------------------- | ---- | +| components | `overseer.Serialized[]` | | + +#### Task:set_component(comp) + +`Task:set_component(comp)` \ +Add component, overwriting any existing + +| Param | Type | Desc | +| ----- | --------------------- | ---- | +| comp | `overseer.Serialized` | | + +#### Task:set_components(components) + +`Task:set_components(components)` \ +Add components, overwriting any existing | Param | Type | Desc | | ---------- | ----------------------- | ---- | -| name | `string` | | | components | `overseer.Serialized[]` | | +#### Task:get_component(name) + +`Task:get_component(name): nil|overseer.Component` + +| Param | Type | Desc | +| ----- | -------- | ---- | +| name | `string` | | + +#### Task:remove_component(name) + +`Task:remove_component(name): nil|overseer.Component` + +| Param | Type | Desc | +| ----- | -------- | ---- | +| name | `string` | | + +#### Task:remove_components(names) + +`Task:remove_components(names): overseer.Component[]` + +| Param | Type | Desc | +| ----- | ---------- | ---- | +| names | `string[]` | | + +#### Task:has_component(name) + +`Task:has_component(name): boolean` + +| Param | Type | Desc | +| ----- | -------- | ---- | +| name | `string` | | + +#### Task:subscribe(event, callback) + +`Task:subscribe(event, callback)` \ +Subscribe to events on this task + +| Param | Type | Desc | +| -------- | -------------------------------------------------- | -------------------------------------------------------- | +| event | `string` | | +| callback | `fun(task: overseer.Task, ...: any): nil\|boolean` | Callback can return a truthy value to unsubscribe itself | + **Note:**
-This is intended to be used by plugin authors that wish to build on top of overseer. They do not
-have control over the call to overseer.setup(), so this provides an alternative method of
-setting a component alias that they can then use when creating tasks.
+Listeners cannot be serialized, so will not be saved when saving task
+to disk and will not be copied when cloning the task.
 
-**Examples:** -```lua -require("overseer").register_alias("my_plugin", { "default", "on_output_quickfix" }) -``` +#### Task:unsubscribe(event, callback) +`Task:unsubscribe(event, callback)` \ +Unsubscribe from an event that was previously subscribed to - +| Param | Type | Desc | +| -------- | ------------------------------------ | ---- | +| event | `string` | | +| callback | `fun(task: overseer.Task, ...: any)` | | + +#### Task:is_pending() + +`Task:is_pending(): boolean` \ +Returns true if the task is PENDING + + +#### Task:is_running() + +`Task:is_running(): boolean` \ +Returns true if the task is RUNNING + + +#### Task:is_complete() + +`Task:is_complete(): boolean` \ +Returns true if the task is complete (not PENDING or RUNNING) + + +#### Task:is_disposed() + +`Task:is_disposed(): boolean` \ +Returns true if the task is DISPOSED + + +#### Task:get_bufnr() + +`Task:get_bufnr(): number|nil` \ +Get the buffer containing the task output. Will be nil if task is PENDING. + + +#### Task:open_output(direction) + +`Task:open_output(direction)` \ +Open the task output in a window + +| Param | Type | Desc | +| --------- | ----------------------------------------------- | ---- | +| direction | `nil\|"float"\|"tab"\|"vertical"\|"horizontal"` | | + +**Note:** +
+You can also use get_bufnr() to get the buffer and open it however you like.
+
+ +#### Task:broadcast(name) + +`Task:broadcast(name)` \ +Dispatch an event to all other tasks + +| Param | Type | Desc | +| ----- | -------- | ---- | +| name | `string` | | + +#### Task:dispatch(name) + +`Task:dispatch(name): any[]` \ +Dispatch an event to all components + +| Param | Type | Desc | +| ----- | -------- | ---- | +| name | `string` | | + +#### Task:inc_reference() + +`Task:inc_reference()` \ +Increment the refcount for this Task, preventing it from being disposed (unless force=true) + + +#### Task:dec_reference() + +`Task:dec_reference()` \ +Decrement the refcount for this Task + + +#### Task:dispose(force) + +`Task:dispose(force): boolean` \ +Cleans up resources, removes from task list, and deletes buffer. + +| Param | Type | Desc | +| ----- | -------------- | ------------------------------------------------------------------------------ | +| force | `nil\|boolean` | When true, will dispose even with a nonzero refcount or when buffer is visible | + +Returns: + +| Type | Desc | +| ------- | ---------------------------------- | +| boolean | disposed True if task was disposed | + +#### Task:restart(force_stop) + +`Task:restart(force_stop): boolean` \ +Reset and re-run the task + +| Param | Type | Desc | +| ---------- | -------------- | --------------------------------------------------------- | +| force_stop | `nil\|boolean` | If true, restart the Task even if it is currently running | + +#### Task:start() + +`Task:start()` \ +Start a pending task + + +#### Task:stop() + +`Task:stop(): boolean` \ +Stop a running task + + +Returns: + +| Type | Desc | +| ------- | ------------------------------------ | +| boolean | stopped True if the task was stopped | + + + ## Components - [dependencies](components.md#dependencies) -- [display_duration](components.md#display_duration) - [on_complete_dispose](components.md#on_complete_dispose) - [on_complete_notify](components.md#on_complete_notify) - [on_complete_restart](components.md#on_complete_restart) - [on_exit_set_status](components.md#on_exit_set_status) +- [on_output_notify](components.md#on_output_notify) - [on_output_parse](components.md#on_output_parse) - [on_output_quickfix](components.md#on_output_quickfix) -- [on_output_summarize](components.md#on_output_summarize) - [on_output_write_file](components.md#on_output_write_file) - [on_result_diagnostics](components.md#on_result_diagnostics) - [on_result_diagnostics_quickfix](components.md#on_result_diagnostics_quickfix) @@ -778,36 +834,11 @@ require("overseer").register_alias("my_plugin", { "default", "on_output_quickfix - [jobstart(opts)](strategies.md#jobstartopts) - [orchestrator(opts)](strategies.md#orchestratoropts) -- [terminal()](strategies.md#terminal) +- [system(opts)](strategies.md#systemopts) - [test()](strategies.md#test) -- [toggleterm(opts)](strategies.md#toggletermopts) -## Parsers - - - - - [always](parsers.md#always) - - [append](parsers.md#append) - - [dispatch](parsers.md#dispatch) - - [ensure](parsers.md#ensure) - - [extract](parsers.md#extract) - - [extract_efm](parsers.md#extract_efm) - - [extract_json](parsers.md#extract_json) - - [extract_multiline](parsers.md#extract_multiline) - - [extract_nested](parsers.md#extract_nested) - - [invert](parsers.md#invert) - - [loop](parsers.md#loop) - - [parallel](parsers.md#parallel) - - [sequence](parsers.md#sequence) - - [set_defaults](parsers.md#set_defaults) - - [skip_lines](parsers.md#skip_lines) - - [skip_until](parsers.md#skip_until) - - [test](parsers.md#test) - - - ## Parameters Parameters are a schema-defined set of options. They are used by both [components](explanation.md#components) and [templates](explanation.md#templates) to expose customization options. @@ -821,7 +852,7 @@ local params = { desc = "A detailed description", order = 1, -- determines order of parameters in the UI validate = function(value) - return true, + return true end, optional = true, default = "foobar", diff --git a/doc/rendering.md b/doc/rendering.md new file mode 100644 index 00000000..0bf35169 --- /dev/null +++ b/doc/rendering.md @@ -0,0 +1,394 @@ +# Rendering + +These are some built-in task formatting functions and a library of useful pieces that you can use to build your own formats. + + + +- [format_standard(task)](#format_standardtask) +- [format_compact(task)](#format_compacttask) +- [format_verbose(task)](#format_verbosetask) +- [status(task)](#statustask) +- [name(task)](#nametask) +- [status_and_name(task)](#status_and_nametask) +- [cmd(task)](#cmdtask) +- [result_lines(task, opts)](#result_linestask-opts) +- [duration(task, opts)](#durationtask-opts) +- [time_since_completed(task, opts)](#time_since_completedtask-opts) +- [output_lines(task, opts)](#output_linestask-opts) +- [source_lines(task, opts)](#source_linestask-opts) +- [join(a, b, sep)](#joina-b-sep) +- [remove_empty_lines(lines)](#remove_empty_lineslines) + + + + + +## format_standard(task) + +`format_standard(task): overseer.TextChunk[]` \ +The default format for tasks in the task list + +| Param | Type | Desc | +| ----- | --------------- | ---- | +| task | `overseer.Task` | | + +Returns: + +| Type | Desc | +| -------------------- | ---- | +| overseer.TextChunk[] | [] | + +**Examples:** +```lua +require("overseer").setup({ + task_list = { + render = function(task) + return require("overseer.render").format_standard(task) + end, + }, +}) +``` + +## format_compact(task) + +`format_compact(task): overseer.TextChunk[]` \ +A more compact format for tasks + +| Param | Type | Desc | +| ----- | --------------- | ---- | +| task | `overseer.Task` | | + +Returns: + +| Type | Desc | +| -------------------- | ---- | +| overseer.TextChunk[] | [] | + +**Examples:** +```lua +require("overseer").setup({ + task_list = { + render = function(task) + return require("overseer.render").format_compact(task) + end, + }, +}) +``` + +## format_verbose(task) + +`format_verbose(task): overseer.TextChunk[]` \ +A more verbose format for tasks + +| Param | Type | Desc | +| ----- | --------------- | ---- | +| task | `overseer.Task` | | + +Returns: + +| Type | Desc | +| -------------------- | ---- | +| overseer.TextChunk[] | [] | + +**Examples:** +```lua +require("overseer").setup({ + task_list = { + render = function(task) + return require("overseer.render").format_verbose(task) + end, + }, +}) +``` + +## status(task) + +`status(task): overseer.TextChunk[]` \ +Text chunks that display the status of a task + +| Param | Type | Desc | +| ----- | --------------- | ---- | +| task | `overseer.Task` | | + +**Examples:** +```lua +require("overseer").setup({ + task_list = { + render = function(task) + local render = require("overseer.render") + return { + render.status(task), + { { task.name, "OverseerTask" } }, + } + end, + }, +}) +``` + +## name(task) + +`name(task): overseer.TextChunk[]` \ +Text chunks that display the name of a task + +| Param | Type | Desc | +| ----- | --------------- | ---- | +| task | `overseer.Task` | | + +**Examples:** +```lua +require("overseer").setup({ + task_list = { + render = function(task) + local render = require("overseer.render") + return { + render.name(task), + } + end, + }, +}) +``` + +## status_and_name(task) + +`status_and_name(task): overseer.TextChunk[]` \ +Text chunks that display the status and name of a task + +| Param | Type | Desc | +| ----- | --------------- | ---- | +| task | `overseer.Task` | | + +**Examples:** +```lua +require("overseer").setup({ + task_list = { + render = function(task) + local render = require("overseer.render") + return { + render.status_and_name(task), + } + end, + }, +}) +``` + +## cmd(task) + +`cmd(task): overseer.TextChunk[]` \ +Text chunks that display the command that was run + +| Param | Type | Desc | +| ----- | --------------- | ---- | +| task | `overseer.Task` | | + +**Examples:** +```lua +require("overseer").setup({ + task_list = { + render = function(task) + local render = require("overseer.render") + return { + { { task.name, "OverseerTask" } }, + render.cmd(task), + } + end, + }, +}) +``` + +## result_lines(task, opts) + +`result_lines(task, opts): overseer.TextChunk[]` \ +Lines that display the result of a task + +| Param | Type | Desc | +| ----- | -------------------------- | ---- | +| task | `overseer.Task` | | +| opts | `nil\|{oneline?: boolean}` | | + +Returns: + +| Type | Desc | +| -------------------- | ---- | +| overseer.TextChunk[] | [] | + +**Examples:** +```lua +require("overseer").setup({ + task_list = { + render = function(task) + local render = require("overseer.render") + return vim.list_extend({ + { { task.name, "OverseerTask" } }, + }, render.result_lines(task, { oneline = false })) + end, + }, +}) +``` + +## duration(task, opts) + +`duration(task, opts): overseer.TextChunk[]` \ +Text chunks that display how long a task has been running / ran for + +| Param | Type | Desc | +| ----- | -------------------------- | ---- | +| task | `overseer.Task` | | +| opts | `nil\|{hl_group?: string}` | | + +**Examples:** +```lua +require("overseer").setup({ + task_list = { + render = function(task) + local render = require("overseer.render") + return { + { { task.name, "OverseerTask" } }, + render.duration(task), + } + end, + }, +}) +``` + +## time_since_completed(task, opts) + +`time_since_completed(task, opts): overseer.TextChunk[]` \ +Text chunks that display the time since a task was completed + +| Param | Type | Desc | +| ----- | -------------------------- | ---- | +| task | `overseer.Task` | | +| opts | `nil\|{hl_group?: string}` | | + +**Examples:** +```lua +require("overseer").setup({ + task_list = { + render = function(task) + local render = require("overseer.render") + return { + { { task.name, "OverseerTask" } }, + render.time_since_completed(task), + } + end, + }, +}) +``` + +## output_lines(task, opts) + +`output_lines(task, opts): overseer.TextChunk[]` \ +Lines that display the last few lines of output from a task + +| Param | Type | Desc | +| ----- | ----------------------------------------------------------------------- | ---- | +| task | `overseer.Task` | | +| opts | `nil\|{num_lines?: integer, prefix?: string, prefix_hl_group?: string}` | | + +Returns: + +| Type | Desc | +| -------------------- | ---- | +| overseer.TextChunk[] | [] | + +**Examples:** +```lua +require("overseer").setup({ + task_list = { + render = function(task) + local render = require("overseer.render") + return vim.list_extend({ + { { task.name, "OverseerTask" } }, + }, render.output_lines(task, { num_lines = 3, prefix = "$ " })) + end, + }, +}) +``` + +## source_lines(task, opts) + +`source_lines(task, opts): overseer.TextChunk[]` \ +Lines that display the source of a wrapped vim.system or vim.fn.jobstart task + +| Param | Type | Desc | +| ----- | -------------------------- | ---- | +| task | `overseer.Task` | | +| opts | `nil\|{hl_group?: string}` | | + +Returns: + +| Type | Desc | +| -------------------- | ---- | +| overseer.TextChunk[] | [] | + +**Examples:** +```lua +require("overseer").setup({ + task_list = { + render = function(task) + local render = require("overseer.render") + return vim.list_extend({ + { { task.name, "OverseerTask" } }, + }, render.source_lines(task, { num_lines = 3, prefix = "$ " })) + end, + }, +}) +``` + +## join(a, b, sep) + +`join(a, b, sep): overseer.TextChunk[]` \ +Join two lists of text chunks together with a separator + +| Param | Type | Desc | +| ----- | --------------------------------- | ---- | +| a | `overseer.TextChunk[]` | | +| b | `overseer.TextChunk[]` | | +| sep | `nil\|string\|overseer.TextChunk` | | + +**Examples:** +```lua +require("overseer").setup({ + task_list = { + render = function(task) + local render = require("overseer.render") + return { + render.join(render.status(task), render.name(task), ": "), + } + end, + }, +}) +``` + +## remove_empty_lines(lines) + +`remove_empty_lines(lines): overseer.TextChunk[]` \ +Removes empty lines from a list of lines (each line is a list of text chunks) + +| Param | Type | Desc | +| ----- | ---------------------- | ---- | +| lines | `overseer.TextChunk[]` | [] | + +Returns: + +| Type | Desc | +| -------------------- | ---- | +| overseer.TextChunk[] | [] | + +**Examples:** +```lua +require("overseer").setup({ + task_list = { + render = function(task) + local render = require("overseer.render") + local ret = vim.list_extend({ + { { task.name, "OverseerTask" } }, + }, render.output_lines(task, { num_lines = 3, prefix = "$ " })) + return render.remove_empty_lines(ret) + end, + }, +}) +``` + + + diff --git a/doc/strategies.md b/doc/strategies.md index abaabbee..cdf5daf8 100644 --- a/doc/strategies.md +++ b/doc/strategies.md @@ -6,9 +6,8 @@ The strategy is what controls how a task is actually run. The default, `terminal - [jobstart(opts)](#jobstartopts) - [orchestrator(opts)](#orchestratoropts) -- [terminal()](#terminal) +- [system(opts)](#systemopts) - [test()](#test) -- [toggleterm(opts)](#toggletermopts) @@ -19,11 +18,12 @@ The strategy is what controls how a task is actually run. The default, `terminal `jobstart(opts): overseer.Strategy` \ Run tasks using jobstart() -| Param | Type | Desc | -| ---------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| opts | `nil\|table` | | -| >preserve_output | `boolean` | If true, don't clear the buffer when tasks restart | -| >use_terminal | `boolean` | If false, use a normal non-terminal buffer to store the output. This may produce unwanted results if the task outputs terminal escape sequences. | +| Param | Type | Desc | +| ---------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| opts | `nil\|overseer.JobstartStrategyOpts` | | +| >preserve_output | `nil\|boolean` | If true, don't clear the buffer when tasks restart | +| >use_terminal | `nil\|boolean` | If false, use a normal non-terminal buffer to store the output. This may produce unwanted results if the task outputs terminal escape sequences. | +| >wrap_opts | `nil\|table` | Opts that were passed to jobstart(). We should wrap them | ## orchestrator(opts) @@ -53,11 +53,15 @@ overseer.new_task({ }) ``` -## terminal() +## system(opts) -`terminal(): overseer.Strategy` \ -Run tasks using termopen() +`system(opts): overseer.Strategy` +| Param | Type | Desc | +| ---------- | ------------------------------------ | ---------------------------------------------------------- | +| opts | `nil\|overseer.SystemStrategyOpts` | | +| >wrap_opts | `nil\|vim.SystemOpts` | Opts that were passed to vim.system(). We should wrap them | +| >wrap_exit | `nil\|fun(out: vim.SystemCompleted)` | | ## test() @@ -65,24 +69,5 @@ Run tasks using termopen() Strategy used for unit testing -## toggleterm(opts) - -`toggleterm(opts): overseer.Strategy` \ -Run tasks using the toggleterm plugin - -| Param | Type | Desc | -| -------------- | ----------------------------------------------- | ------------------------------------------------------------------------ | -| opts | `nil\|overseeer.ToggleTermStrategyOpts` | | -| >use_shell | `nil\|boolean` | load user shell before running task | -| >size | `nil\|number` | the size of the split if direction is vertical or horizontal | -| >direction | `nil\|"vertical"\|"horizontal"\|"tab"\|"float"` | | -| >highlights | `nil\|table` | map to a highlight group name and a table of it's values | -| >auto_scroll | `nil\|boolean` | automatically scroll to the bottom on task output | -| >close_on_exit | `nil\|boolean` | close the terminal and delete terminal buffer (if open) after task exits | -| >quit_on_exit | `nil\|"never"\|"always"\|"success"` | close the terminal window (if open) after task exits | -| >open_on_start | `nil\|boolean` | toggle open the terminal automatically when task starts | -| >hidden | `nil\|boolean` | cannot be toggled with normal ToggleTerm commands | -| >on_create | `nil\|fun(term: table)` | function to execute on terminal creation | - diff --git a/doc/third_party.md b/doc/third_party.md index 52ed7b98..8ce802d6 100644 --- a/doc/third_party.md +++ b/doc/third_party.md @@ -6,7 +6,6 @@ - [Heirline](#heirline) - [Neotest](#neotest) - [DAP](#dap) -- [ToggleTerm](#toggleterm) - [Session managers](#session-managers) - [resession.nvim](#resessionnvim) - [Other session managers](#other-session-managers) @@ -42,10 +41,8 @@ require("lualine").setup({ [overseer.STATUS.RUNNING] = "R:", }, unique = false, -- Unique-ify non-running task count by name - name = nil, -- List of task names to search for - name_not = false, -- When true, invert the name search status = nil, -- List of task statuses to display - status_not = false, -- When true, invert the status search + filter = nil, -- Function to filter out tasks you don't wish to display }, }, }, @@ -86,7 +83,7 @@ local Overseer = { return package.loaded.overseer end, init = function(self) - local tasks = require("overseer.task_list").list_tasks({ unique = true }) + local tasks = require("overseer.task_list").list_tasks({ unique = true, include_ephemeral = true }) local tasks_by_status = require("overseer.util").tbl_group_by(tasks, "status") self.tasks = tasks_by_status end, @@ -142,7 +139,6 @@ You can customize the default components of neotest tasks by setting the `defaul require("overseer").setup({ component_aliases = { default_neotest = { - "on_output_summarize", "on_exit_set_status", "on_complete_notify", "on_complete_dispose", @@ -159,8 +155,8 @@ require("neotest").setup({ overseer = { components = function(run_spec) return { - { "dependencies", task_names = { - { "shell", cmd = "sleep 4" }, + { "dependencies", tasks = { + { cmd = "sleep 4" }, } }, "default_neotest", } @@ -188,52 +184,6 @@ And enable the integration manually later, such as when `nvim-dap` is loaded: require("overseer").enable_dap() ``` -## ToggleTerm - -If you use [toggleterm](https://github.com/akinsho/toggleterm.nvim), you can use the built-in "toggleterm" strategy to allow your tasks to be in a terminal buffer owned by toggleterm. You can use your existing toggleterm keybinds to pull up long-running tasks started with overseer. You can set it up with defaults using: - -```lua -require("overseer").setup({ - strategy = "toggleterm", -}) -``` - -You can also configure the behavior a bit more: - -```lua -require("overseer").setup({ - strategy = { - "toggleterm", - -- load your default shell before starting the task - use_shell = false, - -- overwrite the default toggleterm "direction" parameter - direction = nil, - -- overwrite the default toggleterm "highlights" parameter - highlights = nil, - -- overwrite the default toggleterm "auto_scroll" parameter - auto_scroll = nil, - -- have the toggleterm window close and delete the terminal buffer - -- automatically after the task exits - close_on_exit = false, - -- have the toggleterm window close without deleting the terminal buffer - -- automatically after the task exits - -- can be "never, "success", or "always". "success" will close the window - -- only if the exit code is 0. - quit_on_exit = "never", - -- open the toggleterm window when a task starts - open_on_start = true, - -- mirrors the toggleterm "hidden" parameter, and keeps the task from - -- being rendered in the toggleable window - hidden = false, - -- command to run when the terminal is created. Combine with `use_shell` - -- to run a terminal command before starting the task - on_create = nil, - }, -}) -``` - -More documentation on this strategy can be found [here](strategies.md#toggletermopts). - ## Session managers ### resession.nvim @@ -254,41 +204,35 @@ The configuration options will be passed to [list_tasks](reference.md#list_tasks ### Other session managers -For other session managers, task bundles should make it convenient to load/save tasks. These are exposed to the user with the commands `:OverseerSaveBundle` and `:OverseerLoadBundle`, but you can use the lua API directly for a nicer integration. You essentially just need to get the session name and add some hooks using your plugin's API to handle overseer tasks on session save/restore. +For other session managers, the API allows you to list and serialize tasks. As long as your session +manager has some way to store auxiliary data, you can use this to save and restore tasks. For example, to integrate with [auto-session](https://github.com/rmagatti/auto-session) ```lua --- Convert the cwd to a simple file name -local function get_cwd_as_name() - local dir = vim.fn.getcwd(0) - return dir:gsub("[^A-Za-z0-9]", "_") -end -local overseer = require("overseer") require("auto-session").setup({ pre_save_cmds = { function() - overseer.save_task_bundle( - get_cwd_as_name(), - -- Passing nil will use config.opts.save_task_opts. You can call list_tasks() explicitly and - -- pass in the results if you want to save specific tasks. - nil, - { on_conflict = "overwrite" } -- Overwrite existing bundle, if any - ) + local tasks = require("overseer.task_list").list_tasks() + local cmds = {} + for _, task in ipairs(tasks) do + local json = vim.json.encode(task:serialize()) + -- For some reason, vim.json.encode encodes / as \/. + json = string.gsub(json, "\\/", "/") + -- Escape single quotes so we can put this inside single quotes + json = string.gsub(json, "'", "\\'") + table.insert(cmds, string.format("lua require('overseer').new_task(vim.json.decode('%s')):start()", json)) + end + return cmds end, }, -- Optionally get rid of all previous tasks when restoring a session pre_restore_cmds = { function() - for _, task in ipairs(overseer.list_tasks({})) do + for _, task in ipairs(require("overseer").list_tasks({})) do task:dispose(true) end end, }, - post_restore_cmds = { - function() - overseer.load_task_bundle(get_cwd_as_name(), { ignore_missing = true }) - end, - }, }) ``` diff --git a/doc/tutorials.md b/doc/tutorials.md index 29cda940..1b0165c1 100644 --- a/doc/tutorials.md +++ b/doc/tutorials.md @@ -15,15 +15,7 @@ If you're simply looking for the easiest way to define custom tasks, overseer su In this tutorial, you will create a custom task that builds a C++ file. -First, change your call to `setup()` to include the following option: - -```lua -require("overseer").setup({ - templates = { "builtin", "user.cpp_build" }, -}) -``` - -Next, create the file `lua/overseer/template/user/cpp_build.lua` inside your neovim config directory (`:echo stdpath('config')`). Add the following content: +First, create the file `lua/overseer/template/user/cpp_build.lua` inside your neovim config directory (`:echo stdpath("config")`). Add the following content: ```lua -- /home/stevearc/.config/nvim/lua/overseer/template/user/cpp_build.lua @@ -33,11 +25,14 @@ return { -- Full path to current file (see :help expand()) local file = vim.fn.expand("%:p") return { - cmd = { "g++" }, - args = { file }, + cmd = { "g++", file }, + -- attach a component to the task that will pipe the output to the quickfix. + -- components customize the behavior of a task. + -- see :help overseer-components for a list of all components. components = { { "on_output_quickfix", open = true }, "default" }, } end, + -- provide a condition so the task will only be available when you are in a c++ file condition = { filetype = { "cpp" }, }, @@ -52,15 +47,7 @@ Now when you are editing a cpp file, you can run `:OverseerRun` and select "g++ In this tutorial, you will create a task that re-runs a script every time it's saved, and view the output in a split. If there are errors, they will be displayed inline. -First, change your call to `setup()` to include the following option: - -```lua -require("overseer").setup({ - templates = { "builtin", "user.run_script" }, -}) -``` - -Next, create the file `lua/overseer/template/user/run_script.lua` inside your neovim config directory (`:echo stdpath('config')`). Add the following content: +First, create the file `lua/overseer/template/user/run_script.lua` inside your neovim config directory (`:echo stdpath('config')`). Add the following content: ```lua -- /home/stevearc/.config/nvim/lua/overseer/template/user/run_script.lua @@ -71,9 +58,13 @@ return { local cmd = { file } if vim.bo.filetype == "go" then cmd = { "go", "run", file } + elseif vim.bo.filetype == "python" then + cmd = { "python", file } end return { cmd = cmd, + -- add some components that will pipe the output to quickfix, + -- parse it using errorformat, and display any matching lines as diagnostics. components = { { "on_output_quickfix", set_diagnostics = true }, "on_result_diagnostics", @@ -87,7 +78,7 @@ return { } ``` -Now open up a shell script or go file and run `:OverseerRun`. Select "run script". +Now open up a shell script, go, or python file and run `:OverseerRun`. Select "run script". If you want a test file to use, try the following go script: @@ -111,27 +102,32 @@ set -e echo "Hello world" ``` -The next step is to display the output in a vertical split. For that, we are going to use [actions](guides.md#actions). Run `:OverseerQuickAction` and select "open vsplit". This will open the output in a vertical split next to your file. +The next step is to display the output in a vertical split. For that, we are going to use +[actions](guides.md#actions). Run `:OverseerTaskAction`, select your task, and select "open vsplit". This will open +the output in a vertical split next to your file. ![Screenshot from 2022-09-04 12-40-04](https://user-images.githubusercontent.com/506791/188330767-d680d200-0938-48d1-86ab-8e993745551d.png) -Try changing your script to have an error, then restart the task. The output should be updated, and you should see inline diagnostics for the error (see `:help vim.diagnostic`). +Try changing your script to have an error, then restart the task (`:OverseerTaskAction` > task > restart). +The output should be updated, and you should see inline diagnostics for the error (see `:help vim.diagnostic`). ![Screenshot from 2022-09-04 12-41-51](https://user-images.githubusercontent.com/506791/188330827-d54af448-aedb-4652-a5f2-8d3d94e1cb31.png) -Lastly, we would like to restart this task every time we save the file. Once more use `:OverseerQuickAction` and this time select "watch". It will prompt you for a path to watch, you should enter the path to the file. Now every time you `:w` the file it should re-run and update the output! +Lastly, we would like to restart this task every time we save the file. Once more use +`:OverseerTaskAction` and this time select "watch". It will prompt you for a path to watch, you +should enter the path to the file. Now every time you `:w` the file it should re-run and update the +output! Finally, you can create a custom command to do all of these steps at once: ```lua vim.api.nvim_create_user_command("WatchRun", function() local overseer = require("overseer") - overseer.run_template({ name = "run script" }, function(task) + overseer.run_task({ name = "run script", autostart = false }, function(task) if task then - task:add_component({ "restart_on_save", paths = {vim.fn.expand("%:p")} }) - local main_win = vim.api.nvim_get_current_win() - overseer.run_action(task, "open vsplit") - vim.api.nvim_set_current_win(main_win) + task:add_component({ "restart_on_save", paths = { vim.fn.expand("%:p") } }) + task:start() + task:open_output("vertical") else vim.notify("WatchRun not supported for filetype " .. vim.bo.filetype, vim.log.levels.ERROR) end @@ -141,4 +137,4 @@ end, {}) Now you can do `:WatchRun` on any supported file and it will run the file, open the output in a split, and re-run on save. -Final note: to stop watching the file use the "dispose" action from `:OverseerQuickAction`. +Final note: to stop watching the file use the "dispose" action from `:OverseerTaskAction`. diff --git a/justfile b/justfile index fb824084..9a4bae67 100644 --- a/justfile +++ b/justfile @@ -12,7 +12,7 @@ _private-task: echo "Private task" doc: - python .github/main.py generate + venv/bin/python scripts/main.py generate lint: python .github/main.py lint diff --git a/lua/lualine/components/overseer.lua b/lua/lualine/components/overseer.lua index a1343e8c..369207c7 100644 --- a/lua/lualine/components/overseer.lua +++ b/lua/lualine/components/overseer.lua @@ -27,21 +27,14 @@ -- *unique* (default: false) -- If true, ignore tasks with duplicate names. -- --- *name* (default: nil) --- String or list of strings. Only count tasks with this name or names. --- --- *name_not* (default: false) --- When true, count all tasks that do *not* match the 'name' param. --- -- *status* (default: nil) -- String or list of strings. Only count tasks with this status. -- --- *status_not* (default: false) --- When true, count all tasks that do *not* match the 'status' param. +-- *filter* (default: nil) +-- A filter function to apply to tasks. Only count tasks that pass the filter. local M = require("lualine.component"):extend() local constants = require("overseer.constants") -local overseer = require("overseer") local task_list = require("overseer.task_list") local util = require("overseer.util") local utils = require("lualine.utils.utils") @@ -63,15 +56,11 @@ local default_no_icons = { function M:init(options) M.super.init(self, options) + self.options.include_ephemeral = self.options.include_ephemeral ~= false self.options.label = self.options.label or "" if self.options.colored == nil then self.options.colored = true end - if self.options.colored then - overseer.on_setup(function() - self:update_colors() - end) - end self.symbols = vim.tbl_extend( "keep", self.options.symbols or {}, diff --git a/lua/neotest/client/strategies/overseer.lua b/lua/neotest/client/strategies/overseer.lua index 3c6a0596..425d1499 100644 --- a/lua/neotest/client/strategies/overseer.lua +++ b/lua/neotest/client/strategies/overseer.lua @@ -1,3 +1,4 @@ +local component = require("overseer.component") local lib = require("neotest.lib") local log = require("overseer.log") local nio = require("nio") @@ -20,7 +21,7 @@ M.recycle_group = function(group_id) if not pool[group_id] then pool[group_id] = {} end - log:debug("Recycling neotest task group %s", group_id) + log.debug("Recycling neotest task group %s", group_id) vim.list_extend(pool[group_id], tasks_by_group[group_id]) tasks_by_group[group_id] = {} end @@ -41,7 +42,7 @@ local function get_or_create_task(spec, context, output_path) end if task then -- Reset the task - log:debug("Using pooled neotest task %s from group %s", task.id, current_group_id) + log.debug("Using pooled neotest task %s from group %s", task.id, current_group_id) task:reset(false) task:remove_components({ "on_output_write_file", "neotest.link_with_neotest" }) task:add_components({ @@ -75,12 +76,13 @@ local function get_or_create_task(spec, context, output_path) opts.cmd = spec.command opts.env = spec.env opts.cwd = spec.cwd + opts.ephemeral = true opts.metadata = { neotest_group_id = current_group_id, } + ---@cast opts overseer.TaskDefinition task = overseer.new_task(opts) - log:debug("Created new neotest task %s group %s", task.id, current_group_id) - task:set_include_in_bundle(false) + log.debug("Created new neotest task %s group %s", task.id, current_group_id) task:subscribe("on_dispose", function(disposed_task) local tasks = tasks_by_group[disposed_task.metadata.group_id] if tasks then @@ -99,8 +101,8 @@ end ---@param context neotest.StrategyContext ---@return neotest.Process local function get_strategy(spec, context) - if not overseer.component.get_alias("default_neotest") then - overseer.component.alias("default_neotest", { "default" }) + if not component.get_alias("default_neotest") then + component.alias("default_neotest", { "default" }) end local finish_future = nio.control.future() diff --git a/lua/overseer/action_util.lua b/lua/overseer/action_util.lua index 553af668..672d851a 100644 --- a/lua/overseer/action_util.lua +++ b/lua/overseer/action_util.lua @@ -1,50 +1,46 @@ +local actions = require("overseer.task_list.actions") local config = require("overseer.config") -local log = require("overseer.log") local task_list = require("overseer.task_list") local util = require("overseer.util") local M = {} +---@param opts {name?: string, pre_action: fun(task: overseer.Task), post_action: fun(task: overseer.Task)} ---@param task overseer.Task ----@param name? string -M.run_task_action = function(task, name) - M.run_action({ - actions = config.actions, - name = name, - prompt = string.format("Actions: %s", task.name), - pre_action = function() - task:inc_reference() - end, - post_action = function() - task:dec_reference() - task_list.update(task) - end, - }, task) -end +local function run_action(opts, task) + vim.validate("name", opts.name, "string", true) + vim.validate("pre_action", opts.post_action, "function", true) + vim.validate("post_action", opts.post_action, "function", true) + + -- First merge the config actions with the builtins + local all_actions = {} + for k, v in pairs(actions) do + all_actions[k] = v + end + for k, v in pairs(config.actions) do + if v then + all_actions[k] = v + else + -- If the user set the action to false, remove it from the list + all_actions[k] = nil + end + end -M.run_action = function(opts, ...) - vim.validate({ - actions = { opts.actions, "t" }, - name = { opts.name, "s", true }, - prompt = { opts.prompt, "s" }, - pre_action = { opts.post_action, "f", true }, - post_action = { opts.post_action, "f", true }, - }) - local args = vim.F.pack_len(...) local viable = {} local longest_name = 1 - for k, action in pairs(opts.actions) do - if action.condition == nil or action.condition(...) then + for k, action in pairs(all_actions) do + if action.condition == nil or action.condition(task) then if k == opts.name then if opts.pre_action then - opts.pre_action(...) + opts.pre_action(task) end - action.run(...) + action.run(task) if opts.post_action then - opts.post_action(...) + opts.post_action(task) end return end + action.name = k local name_len = vim.api.nvim_strwidth(k) if name_len > longest_name then @@ -54,7 +50,7 @@ M.run_action = function(opts, ...) end end if opts.name then - log:warn("Cannot perform action '%s'", opts.name) + vim.notify(string.format("Cannot perform action '%s'", opts.name), vim.log.levels.ERROR) return end table.sort(viable, function(a, b) @@ -62,10 +58,10 @@ M.run_action = function(opts, ...) end) if opts.pre_action then - opts.pre_action(...) + opts.pre_action(task) end vim.ui.select(viable, { - prompt = opts.prompt, + prompt = string.format("Actions: %s", task.name), kind = "overseer_task_options", format_item = function(action) if action.desc then @@ -76,16 +72,34 @@ M.run_action = function(opts, ...) end, }, function(action) if action then - if action.condition == nil or action.condition(vim.F.unpack_len(args)) then - action.run(vim.F.unpack_len(args)) + if action.condition == nil or action.condition(task) then + action.run(task) else - log:warn("Can no longer perform action '%s' on task", action.name) - end - if opts.post_action then - opts.post_action(vim.F.unpack_len(args)) + vim.notify( + string.format("Can no longer perform action '%s' on task", action.name), + vim.log.levels.ERROR + ) end end + if opts.post_action then + opts.post_action(task) + end end) end +---@param task overseer.Task +---@param name? string +M.run_task_action = function(task, name) + run_action({ + name = name, + pre_action = function() + task:inc_reference() + end, + post_action = function() + task:dec_reference() + task_list.touch(task) + end, + }, task) +end + return M diff --git a/lua/overseer/binding_util.lua b/lua/overseer/binding_util.lua deleted file mode 100644 index 78c94e5c..00000000 --- a/lua/overseer/binding_util.lua +++ /dev/null @@ -1,133 +0,0 @@ -local config = require("overseer.config") -local layout = require("overseer.layout") -local util = require("overseer.util") -local M = {} - ----@diagnostic disable: undefined-field - -M.create_plug_bindings = function(bufnr, plug_bindings, ...) - local args = vim.F.pack_len(...) - for _, binding in ipairs(plug_bindings) do - local rhs = binding.rhs - if type(binding.rhs) == "function" then - rhs = function() - binding.rhs(vim.F.unpack_len(args)) - end - end - vim.keymap.set("", binding.plug, rhs, { buffer = bufnr, desc = binding.desc }) - end -end - ----@param bufnr number ----@param mode string ----@param bindings table ----@param prefix string -M.create_bindings_to_plug = function(bufnr, mode, bindings, prefix) - local maps - if mode == "i" then - maps = vim.api.nvim_buf_get_keymap(bufnr, "") - end - for lhs, rhs in pairs(bindings) do - -- Prefix with unless this is a or :Cmd mapping - if rhs then - if type(rhs) == "string" and not rhs:match("[<:]") then - rhs = "" .. prefix .. rhs - end - if mode == "i" then - -- HACK for some reason I can't get plug mappings to work in insert mode - for _, map in ipairs(maps) do - if map.lhs == rhs then - ---@diagnostic disable-next-line: cast-local-type - rhs = map.callback or map.rhs - break - end - end - end - vim.keymap.set(mode, lhs, rhs, { buffer = bufnr, remap = true }) - end - end -end - ----@param prefix string -M.show_bindings = function(prefix) - prefix = "" .. prefix - local plug_to_bindings = {} - local descriptions = {} - local max_left = 1 - for _, keymap in ipairs(vim.api.nvim_buf_get_keymap(0, "n")) do - if vim.startswith(keymap.lhs, prefix) then - descriptions[keymap.lhs] = keymap.desc - elseif keymap.rhs and vim.startswith(keymap.rhs, prefix) then - max_left = math.max(max_left, vim.api.nvim_strwidth(keymap.lhs)) - local bindings = plug_to_bindings[keymap.rhs] - if not bindings then - bindings = {} - plug_to_bindings[keymap.rhs] = bindings - end - table.insert(bindings, keymap.lhs) - table.sort(bindings) - end - end - - local bindings_to_plug = {} - local highlights = {} - for plug, bindings in pairs(plug_to_bindings) do - local binding_str = table.concat(bindings, "/") - bindings_to_plug[binding_str] = plug - local hl = {} - highlights[binding_str] = hl - local col_start = 0 - for _, binding in ipairs(bindings) do - local col_end = col_start + binding:len() + 1 - table.insert(hl, { col_start, col_end }) - col_start = col_end + 1 - end - end - - local lhs = vim.tbl_keys(bindings_to_plug) - table.sort(lhs) - - local lines = {} - local max_line = 1 - for _, left in ipairs(lhs) do - local right = descriptions[bindings_to_plug[left]] - local line = string.format(" %s %s", util.ljust(left, max_left), right) - max_line = math.max(max_line, vim.api.nvim_strwidth(line)) - table.insert(lines, line) - end - - local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines) - local ns = vim.api.nvim_create_namespace("overseer") - for i, left in ipairs(lhs) do - for _, hl in ipairs(highlights[left]) do - local start_col, end_col = unpack(hl) - vim.api.nvim_buf_set_extmark(bufnr, ns, i - 1, start_col, { - end_col = end_col, - hl_group = "Special", - }) - end - end - vim.keymap.set("n", "q", "close", { buffer = bufnr }) - vim.keymap.set("n", "", "close", { buffer = bufnr }) - vim.bo[bufnr].modifiable = false - vim.bo[bufnr].bufhidden = "wipe" - - local width = layout.calculate_width(max_line + 1, { min_width = 20 }) - local height = layout.calculate_height(#lines, { min_height = 10 }) - local win = vim.api.nvim_open_win(bufnr, true, { - relative = "editor", - row = math.floor((layout.get_editor_height() - height) / 2), - col = math.floor((layout.get_editor_width() - width) / 2), - width = width, - height = height, - style = "minimal", - border = config.help_win.border, - zindex = config.help_win.zindex, - }) - for opt, value in pairs(config.help_win.win_opts or {}) do - vim.wo[win][opt] = value - end -end - -return M diff --git a/lua/overseer/commands.lua b/lua/overseer/commands.lua index 74894d68..7249f3da 100644 --- a/lua/overseer/commands.lua +++ b/lua/overseer/commands.lua @@ -1,16 +1,8 @@ -local Task = require("overseer.task") local action_util = require("overseer.action_util") -local config = require("overseer.config") local constants = require("overseer.constants") -local files = require("overseer.files") -local layout = require("overseer.layout") local log = require("overseer.log") -local sidebar = require("overseer.task_list.sidebar") -local task_bundle = require("overseer.task_bundle") -local task_editor = require("overseer.task_editor") local task_list = require("overseer.task_list") local template = require("overseer.template") -local util = require("overseer.util") local window = require("overseer.window") local M = {} @@ -31,121 +23,24 @@ M._toggle = function(params) window.toggle({ enter = not params.bang, direction = args_or_nil(params.args) }) end -M._save_bundle = function(params) - task_bundle.save_task_bundle(args_or_nil(params.args)) -end - -M._load_bundle = function(params) - task_bundle.load_task_bundle(args_or_nil(params.args), { autostart = not params.bang }) -end - -M._delete_bundle = function(params) - task_bundle.delete_task_bundle(args_or_nil(params.args)) -end - -M._info = function(params) - M.info(function(info) - local lines = {} - local highlights = {} - if info.log.file then - table.insert(lines, string.format("Log file: %s", info.log.file)) - end - if info.log.level then - table.insert(lines, string.format("Log level: %s", info.log.level)) - end - if not vim.tbl_isempty(info.templates.templates) then - table.insert(lines, "Individual templates") - table.insert(highlights, { "Title", #lines, 0, -1 }) - end - for name, tmpl_report in pairs(info.templates.templates) do - if tmpl_report.is_present then - table.insert(lines, string.format("%s: available", name)) - else - table.insert(lines, string.format("%s: %s", name, tmpl_report.message)) - end - table.insert( - highlights, - { tmpl_report.is_present and "OverseerSUCCESS" or "OverseerFAILURE", #lines, 0, name:len() } - ) - end - if not vim.tbl_isempty(info.templates.providers) then - table.insert(lines, "Template providers") - table.insert(highlights, { "Title", #lines, 0, -1 }) +M._run_shell = function(params) + if params.args and params.args ~= "" then + local task = require("overseer.task").new({ + cmd = params.args, + }) + if not params.bang then + task:start() end - for name, provider_report in pairs(info.templates.providers) do - if provider_report.is_present then - if provider_report.from_cache then - name = name .. " (cached)" + else + vim.ui.input({ prompt = "command", completion = "shellcmdline" }, function(cmd) + if cmd then + local task = require("overseer.task").new({ cmd = cmd }) + if not params.bang then + task:start() end - table.insert( - lines, - string.format( - "%s: %d/%d tasks available", - name, - provider_report.available_tasks, - provider_report.total_tasks - ) - ) - else - table.insert(lines, string.format("%s: %s", name, provider_report.message)) end - table.insert(highlights, { - provider_report.is_present and provider_report.available_tasks > 0 and "OverseerSUCCESS" - or "OverseerFAILURE", - #lines, - 0, - name:len(), - }) - end - - local max_width = 0 - for _, line in ipairs(lines) do - max_width = math.max(max_width, vim.api.nvim_strwidth(line)) - end - - local width = layout.calculate_width(max_width, { min_width = 80, max_width = 0.9 }) - local height = layout.calculate_height(#lines, { min_height = 10, max_height = 0.9 }) - local bufnr = vim.api.nvim_create_buf(false, true) - local winid = vim.api.nvim_open_win(bufnr, true, { - relative = "editor", - border = config.form.border, - zindex = config.form.zindex, - width = width, - height = height, - col = math.floor((layout.get_editor_width() - width) / 2), - row = math.floor((layout.get_editor_height() - height) / 2), - style = "minimal", - }) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines) - vim.bo[bufnr].modifiable = false - vim.bo[bufnr].modified = false - vim.bo[bufnr].bufhidden = "wipe" - vim.keymap.set("n", "q", "close", { buffer = bufnr }) - vim.keymap.set("n", "", "close", { buffer = bufnr }) - vim.api.nvim_create_autocmd("BufLeave", { - desc = "Close info window when leaving buffer", - buffer = bufnr, - once = true, - nested = true, - callback = function() - if vim.api.nvim_win_is_valid(winid) then - vim.api.nvim_win_close(winid, true) - end - end, - }) - local ns = vim.api.nvim_create_namespace("overseer") - util.add_highlights(bufnr, ns, highlights) - end) -end - -M._run_command = function(params) - local tmpl_params = { - cmd = params.args ~= "" and params.args or nil, - } - M.run_template({ - name = "shell", - params = tmpl_params, - }) + end) + end end M._run_template = function(params) @@ -161,7 +56,7 @@ M._run_template = function(params) end end if name and not vim.tbl_isempty(tags) then - log:error("Cannot find template: %s is not a tag", name) + log.error("Cannot find template: %s is not a tag", name) return end local opts = { @@ -175,26 +70,10 @@ M._run_template = function(params) end) end -M._build_task = function(_params) - M.build_task() -end - -M._quick_action = function(params) - local action_name = params.fargs[1] - if action_name == "" then - action_name = nil - end - M.quick_action(action_name) -end - M._task_action = function(params) M.task_action() end -M._clear_cache = function(_params) - M.clear_cache() -end - ---@return overseer.SearchParams local function get_search_params() -- If we have a file open, use its parent dir as the search dir. @@ -226,38 +105,30 @@ end -- TEMPLATE LOADING/RUNNING ---Options for running a template ----Values for prompt: ---- always Show when template has any params ---- missing Show when template has any params not explicitly passed in ---- allow Only show when a required param is missing ---- avoid Only show when a required param with no default value is missing ---- never Never show prompt (error if required param missing) ---@class overseer.TemplateRunOpts ---@field name? string The name of the template to run ---@field tags? string[] List of tags used to filter when searching for template ---@field autostart? boolean When true, start the task after creating it (default true) ---@field first? boolean When true, take first result and never show the task picker. Default behavior will auto-set this based on presence of name and tags ----@field prompt? "always"|"missing"|"allow"|"avoid"|"never" Controls when to prompt user for parameter input ---@field params? table Parameters to pass to template ---@field cwd? string Working directory for the task ---@field env? table Additional environment variables for the task +---@field disallow_prompt? boolean When true, if any required parameters are missing return an error instead of prompting the user for them +---@field on_build? fun(task_defn: overseer.TaskDefinition, util: overseer.TaskUtil) callback that is called after the task definition is built but before the task is created. ---@param opts overseer.TemplateRunOpts ---@param callback? fun(task: overseer.Task|nil, err: string|nil) M.run_template = function(opts, callback) opts = vim.tbl_deep_extend("keep", opts or {}, { autostart = true, - prompt = config.default_template_prompt, - }) - vim.validate({ - name = { opts.name, "s", true }, - tags = { opts.tags, "t", true }, - autostart = { opts.autostart, "b", true }, - first = { opts.first, "b", true }, - prompt = { opts.prompt, "s", true }, - params = { opts.params, "t", true }, - callback = { callback, "f", true }, }) + vim.validate("name", opts.name, "string", true) + vim.validate("tags", opts.tags, "table", true) + vim.validate("autostart", opts.autostart, "boolean", true) + vim.validate("first", opts.first, "boolean", true) + vim.validate("disallow_prompt", opts.disallow_prompt, "boolean", true) + vim.validate("params", opts.params, "table", true) + vim.validate("callback", callback, "function", true) if opts.first == nil then opts.first = opts.name ~= nil or not vim.tbl_isempty(opts.tags or {}) end @@ -277,23 +148,16 @@ M.run_template = function(opts, callback) return end local build_opts = { - prompt = opts.prompt, params = opts.params or {}, search = search_opts, + disallow_prompt = opts.disallow_prompt, + on_build = opts.on_build, + env = opts.env, + cwd = opts.cwd, } - template.build_task_args(tmpl, build_opts, function(task_defn) - local task = nil - if task_defn then - if opts.cwd then - task_defn.cwd = opts.cwd - end - if task_defn.env or opts.env then - task_defn.env = vim.tbl_deep_extend("force", task_defn.env or {}, opts.env or {}) - end - task = Task.new(task_defn) - if opts.autostart then - task:start() - end + template.build_task(tmpl, build_opts, function(_, task) + if task and opts.autostart then + task:start() end if callback then callback(task) @@ -310,7 +174,7 @@ M.run_template = function(opts, callback) end, templates) if #templates == 0 then - log:error("Could not find any matching task templates for opts %s", opts) + log.error("Could not find any matching task templates for opts %s", opts) elseif #templates == 1 or opts.first then handle_tmpl(templates[1]) else @@ -334,46 +198,15 @@ M.run_template = function(opts, callback) end end -M.build_task = function() - local task = Task.new({ - name = "New task", - cmd = { "ls" }, - }) - task_editor.open(task, function(result) - if result then - task:start() - else - task:dispose() - end - end) -end - ----@param name? string Name of action to run -M.quick_action = function(name) - if vim.bo.filetype == "OverseerList" then - local sb = sidebar.get_or_create() - sb:run_action(name) - return - end - local tasks = task_list.list_tasks({ recent_first = true }) - local task - if #tasks == 0 then - vim.notify("No tasks available", vim.log.levels.WARN) - return - else - task = tasks[1] - end - action_util.run_task_action(task, name) -end - M.task_action = function() - local tasks = task_list.list_tasks({ unique = true, recent_first = true }) + local tasks = task_list.list_tasks({ + unique = true, + sort = task_list.sort_finished_recently, + include_ephemeral = true, + }) if #tasks == 0 then vim.notify("No tasks available", vim.log.levels.WARN) return - elseif #tasks == 1 then - action_util.run_task_action(tasks[1]) - return end local task_summaries = vim.tbl_map(function(task) @@ -393,38 +226,11 @@ M.task_action = function() end) end +---@param callback fun(info: overseer.Report) M.info = function(callback) local search_opts = get_search_params() - local info = { - log = { - file = nil, - level = nil, - }, - templates = { - templates = {}, - providers = {}, - }, - } - local levels_reverse = {} - for k, v in pairs(vim.log.levels) do - levels_reverse[v] = k - end - for _, log_conf in ipairs(config.log) do - if log_conf.type == "file" then - local ok, stdpath = pcall(vim.fn.stdpath, "log") - if not ok then - stdpath = vim.fn.stdpath("cache") - end - info.log = { - file = files.join(stdpath, log_conf.filename), - level = levels_reverse[log_conf.level], - } - break - end - end template.list(search_opts, function(_, report) - info.templates = report - callback(info) + callback(report) end) end diff --git a/lua/overseer/component/init.lua b/lua/overseer/component.lua similarity index 68% rename from lua/overseer/component/init.lua rename to lua/overseer/component.lua index 962c4fed..bae9ebc1 100644 --- a/lua/overseer/component/init.lua +++ b/lua/overseer/component.lua @@ -9,33 +9,32 @@ local M = {} ---`_*` means "triggers under some condition" ---`on__*` means "does something when is fired ---@class overseer.ComponentFileDefinition ----@field desc string ----@field long_desc? string ----@field params? overseer.Params ----@field editable? boolean ----@field serializable? boolean ----@field constructor fun(params: table): overseer.ComponentSkeleton ----@field deprecated_message? string +---@field desc string description of component +---@field long_desc? string extended description for documentation generation +---@field params? overseer.Params parameters that can customize the component +---@field constructor fun(params: table): overseer.ComponentSkeleton creates the component from the params +---@field editable? boolean when true, component can be live-edited in the task editor +---@field serializable? boolean when true, will be serialized when serializing a task +---@field deprecated_message? string when present, overseer will warn the user when this component is used ---@class overseer.ComponentDefinition : overseer.ComponentFileDefinition ---@field name string ---The intermediate form of a component returned by the constructor ---@class overseer.ComponentSkeleton ----@field on_init? fun(self: overseer.Component, task: overseer.Task) ----@field on_pre_start? fun(self: overseer.Component, task: overseer.Task): nil|boolean ----@field on_start? fun(self: overseer.Component, task: overseer.Task) ----@field on_reset? fun(self: overseer.Component, task: overseer.Task) ----@field on_pre_result? fun(self: overseer.Component, task: overseer.Task): nil|table ----@field on_preprocess_result? fun(self: overseer.Component, task: overseer.Task, result: table) ----@field on_result? fun(self: overseer.Component, task: overseer.Task, result: table) ----@field on_complete? fun(self: overseer.Component, task: overseer.Task, status: overseer.Status, result: table) ----@field on_output? fun(self: overseer.Component, task: overseer.Task, data: string[]) ----@field on_output_lines? fun(self: overseer.Component, task: overseer.Task, lines: string[]) ----@field on_exit? fun(self: overseer.Component, task: overseer.Task, code: number) ----@field on_dispose? fun(self: overseer.Component, task: overseer.Task) +---@field on_init? fun(self: overseer.Component, task: overseer.Task) called when the component is first created. +---@field on_pre_start? fun(self: overseer.Component, task: overseer.Task): nil|boolean called when a task is attempting to start. Can return false to prevent the task from starting. +---@field on_start? fun(self: overseer.Component, task: overseer.Task) called when the task has started +---@field on_reset? fun(self: overseer.Component, task: overseer.Task) called when the task is reset +---@field on_pre_result? fun(self: overseer.Component, task: overseer.Task): nil|table called when the task is generating results. Can return a table that will be merged into the task's results. +---@field on_preprocess_result? fun(self: overseer.Component, task: overseer.Task, result: table) called after on_pre_result and before on_result. Can modify the result table. +---@field on_result? fun(self: overseer.Component, task: overseer.Task, result: table) called after the task result has been created +---@field on_complete? fun(self: overseer.Component, task: overseer.Task, status: overseer.Status, result: table) called when the task completes (successful or not) +---@field on_output? fun(self: overseer.Component, task: overseer.Task, data: string[]) called with the raw output from jobstart on_stdout callback +---@field on_output_lines? fun(self: overseer.Component, task: overseer.Task, lines: string[]) called with lines of text from the output. This is easier to work with than the raw data from on_output. +---@field on_exit? fun(self: overseer.Component, task: overseer.Task, code: number) called when the process exits +---@field on_dispose? fun(self: overseer.Component, task: overseer.Task) called when the task is disposed or the component is removed. Guaranteed to be called if on_init was called. ---@field on_status? fun(self: overseer.Component, task: overseer.Task, status: overseer.Status) Called when the task status changes ----@field render? fun(self: overseer.Component, task: overseer.Task, lines: string[], highlights: table[], detail: number) ---An instantiated component that is attached to a Task ---@class overseer.Component : overseer.ComponentSkeleton @@ -46,41 +45,16 @@ local M = {} ---@field editable boolean local registry = {} -local aliases = {} - -local builtin_components = { - "dependencies", - "display_duration", - "on_complete_dispose", - "on_complete_notify", - "on_complete_restart", - "on_exit_set_status", - "on_output_parse", - "on_output_quickfix", - "on_output_summarize", - "on_output_write_file", - "on_result_diagnostics", - "on_result_diagnostics_quickfix", - "on_result_diagnostics_trouble", - "on_result_notify", - "open_output", - "restart_on_save", - "run_after", - "timeout", - "unique", -} ---@param name string ---@param opts overseer.ComponentDefinition ---@return overseer.Component local function validate_component(name, opts) - vim.validate({ - desc = { opts.desc, "s", true }, - params = { opts.params, "t", true }, - constructor = { opts.constructor, "f" }, - editable = { opts.editable, "b", true }, - serializable = { opts.serializable, "b", true }, - }) + vim.validate("opts.desc", opts.desc, "string", true) + vim.validate("opts.params", opts.params, "table", true) + vim.validate("opts.constructor", opts.constructor, "function") + vim.validate("opts.editable", opts.editable, "boolean", true) + vim.validate("opts.serializable", opts.serializable, "boolean", true) ---@type overseer.Component local comp = vim.deepcopy(opts) ---@diagnostic disable-line: assign-type-mismatch if comp.serializable == nil then @@ -104,39 +78,52 @@ local function validate_component(name, opts) comp.editable = true end comp.name = name - if opts.deprecated_message then - log:warn("Overseer component %s is deprecated: %s", name, opts.deprecated_message) - end return comp end ---@param name string ---@param components string[] -M.alias = function(name, components) - vim.validate({ - name = { name, "s" }, - components = { components, "t" }, - }) +---@param override? boolean +M.alias = function(name, components, override) + if override or not config.component_aliases[name] then + config.component_aliases[name] = components + end +end - aliases[name] = components +---@param name string +local function load(name) + if not registry[name] then + local ok, mod = pcall(require, string.format("overseer.component.%s", name)) + if ok then + registry[name] = validate_component(name, mod) + end + end end ---@param name string ---@return overseer.ComponentDefinition? M.get = function(name) if not registry[name] then + load(name) local ok, mod = pcall(require, string.format("overseer.component.%s", name)) if ok then registry[name] = validate_component(name, mod) end end - return registry[name] + local comp = registry[name] + if comp and comp.deprecated_message then + vim.notify_once( + string.format("Overseer component %s is deprecated: %s", name, comp.deprecated_message), + vim.log.levels.WARN + ) + end + return comp end ---@param name string ---@return string[]? M.get_alias = function(name) - return aliases[name] + return config.component_aliases[name] end ---@param comp_params overseer.Serialized @@ -150,23 +137,31 @@ end ---@return string M.stringify_alias = function(name) local strings = {} - for _, comp in ipairs(aliases[name]) do + for _, comp in ipairs(M.get_alias(name) or {}) do table.insert(strings, getname(comp)) end return table.concat(strings, ", ") end +local preloaded = false +local function preload_components() + if preloaded then + return + end + preloaded = true + local comp_files = vim.api.nvim_get_runtime_file("lua/overseer/component/*.lua", true) + for _, abspath in ipairs(comp_files) do + local module_name = abspath:match("^.*overseer/component/(.*)%.lua$") + load(module_name) + end +end + ---@return string[] M.list_editable = function() local ret = {} - for _, v in ipairs(config.preload_components) do - M.get(v) - end - for _, v in ipairs(builtin_components) do - M.get(v) - end + preload_components() for k, v in pairs(registry) do - if v.editable then + if v.editable and not v.deprecated_message then table.insert(ret, k) end end @@ -175,7 +170,7 @@ end ---@return string[] M.list_aliases = function() - return vim.tbl_keys(aliases) + return vim.tbl_keys(config.component_aliases) end M.params_should_replace = function(new_params, existing) @@ -197,8 +192,9 @@ local function resolve(seen, resolved, names) -- Let's not get stuck if there are cycles if not seen[name] then seen[name] = true - if aliases[name] then - resolve(seen, resolved, aliases[name]) + local alias_components = M.get_alias(name) + if alias_components then + resolve(seen, resolved, alias_components) else table.insert(resolved, comp_params) end @@ -231,7 +227,7 @@ local function validate_params(params, schema, ignore_errors) end for name in pairs(params) do if type(name) == "string" and (not schema or schema[name] == nil) then - log:warn("Component '%s' passed unknown param '%s'", getname(params), name) + log.warn("Component '%s' passed unknown param '%s'", getname(params), name) params[name] = nil end end @@ -275,10 +271,8 @@ end ---@param existing nil|overseer.Serialized[] A list of instantiated components or component params ---@return overseer.Serialized[] M.resolve = function(components, existing) - vim.validate({ - components = { components, "t" }, - existing = { existing, "t", true }, - }) + vim.validate("components", components, "table") + vim.validate("existing", existing, "table", true) local seen = {} if existing then for _, comp in ipairs(existing) do @@ -338,14 +332,19 @@ end ---@private M.get_all_descriptions = function() local ret = {} - for _, name in ipairs(builtin_components) do - local defn = assert(M.get(name)) - table.insert(ret, { - name = name, - desc = defn.desc, - long_desc = defn.long_desc, - params = simplify_params(defn.params), - }) + preload_components() + local names = vim.tbl_keys(registry) + table.sort(names) + for _, name in ipairs(names) do + local defn = registry[name] + if not defn.deprecated_message then + table.insert(ret, { + name = name, + desc = defn.desc, + long_desc = defn.long_desc, + params = simplify_params(defn.params), + }) + end end return ret end diff --git a/lua/overseer/component/dependencies.lua b/lua/overseer/component/dependencies.lua index 1a62d567..abad8f18 100644 --- a/lua/overseer/component/dependencies.lua +++ b/lua/overseer/component/dependencies.lua @@ -4,14 +4,24 @@ local task_list = require("overseer.task_list") local util = require("overseer.util") local STATUS = constants.STATUS +---@type overseer.ComponentFileDefinition return { desc = "Set dependencies for task", params = { + tasks = { + desc = "Names of dependency task templates", + long_desc = 'This can be a list of strings (template names, e.g. "cargo build"), tables (template name with params, e.g. {"mytask", foo = "bar"}), or tables (raw task params, e.g. {cmd = "sleep 10"})', + -- TODO Can't input dependencies WITH params in the task launcher b/c the type is too complex + type = "list", + optional = true, + }, task_names = { + deprecated = true, desc = "Names of dependency task templates", - long_desc = 'This can be a list of strings (template names, e.g. {"cargo build"}), tables (name with params, e.g. {"shell", cmd = "sleep 10"}), or tables (raw task params, e.g. {cmd = "sleep 10"})', + long_desc = 'This can be a list of strings (template names, e.g. "cargo build"), tables (template name with params, e.g. {"mytask", foo = "bar"}), or tables (raw task params, e.g. {cmd = "sleep 10"})', -- TODO Can't input dependencies WITH params in the task launcher b/c the type is too complex type = "list", + optional = true, }, sequential = { type = "boolean", @@ -23,14 +33,14 @@ return { task_lookup = {}, on_pre_start = function(self, task) local started_any = false - for i, name_or_config in ipairs(params.task_names) do + for i, name_or_config in ipairs(params.tasks or params.task_names or {}) do local task_id = self.task_lookup[i] local dep_task = task_id and task_list.get(task_id) if not dep_task then -- If no task ID found, start the dependency util.run_template_or_task(name_or_config, function(new_task) if not new_task then - log:error( + log.error( "Task(%s)[dependencies] could not find template %s", task.name, name_or_config @@ -41,13 +51,12 @@ return { new_task.env = new_task.env or task.env new_task.parent_id = task.parent_id or task.id self.task_lookup[i] = new_task.id - new_task:add_component({ "on_success_complete_dependency", task_id = task.id }) - -- Don't include child tasks when saving to bundle. - -- We will re-create them when this task runs again - new_task:set_include_in_bundle(false) + new_task:add_component({ + "dependencies.on_success_complete_dependency", + task_id = task.id, + }) + new_task.ephemeral = true new_task:start() - -- Ensure this task is marked as more recent than its dependencies - task_list.touch_task(task) end) started_any = true if params.sequential then diff --git a/lua/overseer/component/on_success_complete_dependency.lua b/lua/overseer/component/dependencies/on_success_complete_dependency.lua similarity index 92% rename from lua/overseer/component/on_success_complete_dependency.lua rename to lua/overseer/component/dependencies/on_success_complete_dependency.lua index 33009046..1c228d3c 100644 --- a/lua/overseer/component/on_success_complete_dependency.lua +++ b/lua/overseer/component/dependencies/on_success_complete_dependency.lua @@ -4,7 +4,7 @@ local task_list = require("overseer.task_list") local STATUS = constants.STATUS ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "Run another task on status change", params = { task_id = { @@ -28,7 +28,7 @@ local comp = { if next then next:dispatch("on_dependency_complete", task.id) else - log:warn("Could not find task %s", params.task_id) + log.warn("Could not find task %s", params.task_id) end if params.once then vim.defer_fn(function() @@ -39,5 +39,3 @@ local comp = { } end, } - -return comp diff --git a/lua/overseer/component/display_duration.lua b/lua/overseer/component/display_duration.lua index 757c196d..b6682d42 100644 --- a/lua/overseer/component/display_duration.lua +++ b/lua/overseer/component/display_duration.lua @@ -1,54 +1,8 @@ -local task_list = require("overseer.task_list") -local util = require("overseer.util") - -local timer - ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "Display the run duration", - params = { - detail_level = { - desc = "Show the duration at this detail level", - type = "integer", - default = 1, - validate = function(v) - return v >= 1 and v <= 3 - end, - }, - }, - constructor = function(params) - return { - duration = nil, - start_time = nil, - on_reset = function(self, task) - self.duration = nil - self.start_time = nil - end, - on_start = function(self) - if not timer then - timer = assert(vim.loop.new_timer()) - timer:start( - 1000, - 1000, - vim.schedule_wrap(function() - task_list.rerender() - end) - ) - end - self.start_time = os.time() - end, - on_complete = function(self) - self.duration = os.time() - self.start_time - end, - render = function(self, task, lines, highlights, detail) - if detail < params.detail_level or (not self.duration and not self.start_time) then - return - end - local duration = self.duration or os.time() - self.start_time - table.insert(lines, util.format_duration(duration)) - end, - } + deprecated_message = "Components are no longer used to customize task rendering", + constructor = function() + return {} end, } - -return comp diff --git a/lua/overseer/component/on_complete_dispose.lua b/lua/overseer/component/on_complete_dispose.lua index 4ba7ff7d..b234ed7c 100644 --- a/lua/overseer/component/on_complete_dispose.lua +++ b/lua/overseer/component/on_complete_dispose.lua @@ -1,4 +1,3 @@ -local uv = vim.uv or vim.loop local constants = require("overseer.constants") local log = require("overseer.log") local STATUS = constants.STATUS @@ -15,7 +14,7 @@ local function is_buffer_visible(bufnr) end ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "After task is completed, dispose it after a timeout", params = { timeout = { @@ -47,9 +46,7 @@ local comp = { }, constructor = function(opts) opts = opts or {} - vim.validate({ - timeout = { opts.timeout, "n" }, - }) + vim.validate("timeout", opts.timeout, "number") return { timer = nil, @@ -67,12 +64,12 @@ local comp = { end, _start_timer = function(self, task) self:_stop_timer() - log:debug( + log.debug( "task(%s)[on_complete_dispose] starting dispose timer for %ds", task.id, opts.timeout ) - self.timer = uv.new_timer() + self.timer = vim.uv.new_timer() -- Start a repeating timer because the dispose could fail with a -- temporary reason (e.g. the task buffer is open, or the action menu is -- displayed for the task) @@ -80,7 +77,7 @@ local comp = { 1000 * opts.timeout, 1000 * opts.timeout, vim.schedule_wrap(function() - log:debug("task(%s)[on_complete_dispose] attempt dispose", task.id) + log.debug("task(%s)[on_complete_dispose] attempt dispose", task.id) task:dispose() end) ) @@ -88,7 +85,7 @@ local comp = { on_complete = function(self, task, status) if not vim.tbl_contains(opts.statuses, task.status) then - log:debug( + log.debug( "task(%s)[on_complete_dispose] complete, not auto-disposing task of status %s", task.id, status @@ -103,7 +100,7 @@ local comp = { then self:_start_timer(task) else - log:debug( + log.debug( "task(%s)[on_complete_dispose] complete, waiting for output view", task.id, status @@ -132,5 +129,3 @@ local comp = { } end, } - -return comp diff --git a/lua/overseer/component/on_complete_notify.lua b/lua/overseer/component/on_complete_notify.lua index cd307e2c..1cb78334 100644 --- a/lua/overseer/component/on_complete_notify.lua +++ b/lua/overseer/component/on_complete_notify.lua @@ -4,7 +4,7 @@ local util = require("overseer.util") local STATUS = constants.STATUS ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "vim.notify when task is completed", params = { statuses = { @@ -57,4 +57,3 @@ local comp = { } end, } -return comp diff --git a/lua/overseer/component/on_complete_restart.lua b/lua/overseer/component/on_complete_restart.lua index 32ffa065..97a10d77 100644 --- a/lua/overseer/component/on_complete_restart.lua +++ b/lua/overseer/component/on_complete_restart.lua @@ -4,7 +4,7 @@ local util = require("overseer.util") local STATUS = constants.STATUS ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "Restart task when it completes", params = { statuses = { @@ -44,4 +44,3 @@ local comp = { } end, } -return comp diff --git a/lua/overseer/component/on_exit_set_status.lua b/lua/overseer/component/on_exit_set_status.lua index 360bb9f2..646d2539 100644 --- a/lua/overseer/component/on_exit_set_status.lua +++ b/lua/overseer/component/on_exit_set_status.lua @@ -2,7 +2,7 @@ local constants = require("overseer.constants") local STATUS = constants.STATUS ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "Sets final task status based on exit code", params = { success_codes = { @@ -25,4 +25,3 @@ local comp = { } end, } -return comp diff --git a/lua/overseer/component/on_output_notify.lua b/lua/overseer/component/on_output_notify.lua index a3561d86..2fd89a96 100644 --- a/lua/overseer/component/on_output_notify.lua +++ b/lua/overseer/component/on_output_notify.lua @@ -1,5 +1,4 @@ local util = require("overseer.util") -local uv = vim.uv or vim.loop local function has_nvim_notify() return not not pcall(require, "notify") @@ -13,11 +12,11 @@ local function get_notify_config(setting, default) end ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "Use nvim-notify to show notification with task output summary for long-running tasks", long_desc = vim.trim([[ -Works like on_complete_notify but, for long-running commands, also shows real-time output summary (like on_output_summarize). +Works like on_complete_notify but, for long-running commands, also shows real-time output summary. Requires nvim-notify to modify the last notification window when new output arrives instead of creating new notification. ]]), @@ -122,11 +121,11 @@ When output_on_complete==false: shows status + last output lines during task run end, on_start = function(self) - self.start_time = uv.now() + self.start_time = vim.uv.now() end, on_output = function(self, task, _data) - local elapsed = uv.now() - self.start_time + local elapsed = vim.uv.now() - self.start_time if elapsed < params.delay_ms then return end @@ -146,5 +145,3 @@ When output_on_complete==false: shows status + last output lines during task run } end, } - -return comp diff --git a/lua/overseer/component/on_output_parse.lua b/lua/overseer/component/on_output_parse.lua index 634b2b27..350dbcc8 100644 --- a/lua/overseer/component/on_output_parse.lua +++ b/lua/overseer/component/on_output_parse.lua @@ -1,28 +1,46 @@ local files = require("overseer.files") local log = require("overseer.log") -local parser = require("overseer.parser") -local problem_matcher = require("overseer.template.vscode.problem_matcher") +local parselib = require("overseer.parselib") +local problem_matcher = require("overseer.vscode.problem_matcher") + +---@param cwd string +---@param result table +---@return table +local function fix_relative_filenames(cwd, result) + if result.diagnostics then + -- Ensure that all relative filenames are rooted at the task cwd, not vim's current cwd + for _, diag in ipairs(result.diagnostics) do + if diag.filename and not files.is_absolute(diag.filename) then + diag.filename = vim.fs.joinpath(cwd, diag.filename) + end + end + end + return result +end ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "Parses task output and sets task result", params = { parser = { - desc = "Parser definition to extract values from output", + desc = "Parse function or overseer.OutputParser", + long_desc = "This can be a function that takes a line of output and (optionally) returns a quickfix-list item (see :help |setqflist-what|). For more complex parsing, this should be a class of type overseer.OutputParser.", type = "opaque", optional = true, order = 1, }, problem_matcher = { desc = "VS Code-style problem matcher", + long_desc = "Only one of 'parser', 'problem_matcher', or 'errorformat' is allowed.", type = "opaque", optional = true, order = 2, }, - relative_file_root = { - desc = "Relative filepaths will be joined to this root (instead of task cwd)", + errorformat = { + desc = "Errorformat string", + long_desc = "Only one of 'parser', 'problem_matcher', or 'errorformat' is allowed.", + type = "opaque", optional = true, - default_from_task = true, order = 3, }, precalculated_vars = { @@ -32,70 +50,68 @@ local comp = { optional = true, order = 4, }, + relative_file_root = { + desc = "Relative filepaths will be joined to this root (instead of task cwd)", + optional = true, + default_from_task = true, + order = 5, + }, }, constructor = function(params) - if params.parser and params.problem_matcher then - log:warn("on_output_parse: cannot specify both 'parser' and 'problem_matcher'") - elseif not params.parser and not params.problem_matcher then - log:error("on_output_parse: one of 'parser', 'problem_matcher' is required") + local p = { params.parser, params.problem_matcher, params.errorformat } + local num_parse_opts = #vim.tbl_keys(p) + if num_parse_opts == 0 then + log.error("on_output_parse: one of 'parser', 'problem_matcher', 'errorformat' is required") + return {} + elseif num_parse_opts > 1 then + log.error( + "on_output_parse: only one of 'parser', 'problem_matcher', 'errorformat' is allowed" + ) return {} end - local parser_defn = params.parser + + local parser if params.problem_matcher then - local pm = problem_matcher.resolve_problem_matcher(params.problem_matcher) - if pm then - parser_defn = problem_matcher.get_parser_from_problem_matcher(pm, params.precalculated_vars) - if parser_defn then - parser_defn = { diagnostics = parser_defn } - end - end + parser = problem_matcher.get_parser_from_problem_matcher( + params.problem_matcher, + params.precalculated_vars + ) + elseif type(params.parser) == "function" then + parser = parselib.make_parser(params.parser) + elseif params.parser then + parser = params.parser + else + parser = parselib.parser_from_errorformat(params.errorformat) end - if not parser_defn then + if not parser then + log.error( + "Could not create output parser from %s", + params.problem_matcher or params.parser or params.errorformat + ) return {} end + ---@cast parser overseer.OutputParser + + local version = parser.result_version return { - on_init = function(self, task) - self.parser = parser.new(parser_defn) - self.parser_sub = function(key, result) - -- TODO reconsider this API for dispatching partial results - -- task:dispatch("on_stream_result", key, result) - end - self.parser:subscribe("new_item", self.parser_sub) - self.set_results_sub = function() - local result = self.parser:get_result() - if result.diagnostics then - -- Ensure that all relative filenames are rooted at the task cwd, not vim's current cwd - for _, diag in ipairs(result.diagnostics) do - if diag.filename and not files.is_absolute(diag.filename) then - diag.filename = files.join(params.relative_file_root or task.cwd, diag.filename) - end - end - end - task:set_result(result) - end - self.parser:subscribe("set_results", self.set_results_sub) - end, - on_dispose = function(self) - if self.parser_sub then - self.parser:unsubscribe("new_item", self.parser_sub) - self.parser_sub = nil - end - if self.set_results_sub then - self.parser:unsubscribe("set_results", self.set_results_sub) - self.set_results_sub = nil - end - end, on_reset = function(self) - self.parser:reset() + parser:reset() + version = parser.result_version end, on_output_lines = function(self, task, lines) - self.parser:ingest(lines) + for _, line in ipairs(lines) do + parser:parse(line) + end + if version ~= parser.result_version then + task:set_result( + fix_relative_filenames(params.relative_file_root or task.cwd, parser:get_result()) + ) + version = parser.result_version + end end, on_pre_result = function(self, task) - return self.parser:get_result() + return fix_relative_filenames(params.relative_file_root or task.cwd, parser:get_result()) end, } end, } - -return comp diff --git a/lua/overseer/component/on_output_quickfix.lua b/lua/overseer/component/on_output_quickfix.lua index 16671f86..51713283 100644 --- a/lua/overseer/component/on_output_quickfix.lua +++ b/lua/overseer/component/on_output_quickfix.lua @@ -34,7 +34,7 @@ local function copen(self, height) end ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "Set all task output into the quickfix (on complete)", params = { errorformat = { @@ -119,7 +119,7 @@ local comp = { if params.tail then -- If we have been tailing the output, we should just keep the quickfix as it is -- because we've exceeded the scrollback limit and will lose the earlier data. - log:warn( + log.warn( "Task(%d) '%s' exceeded the output scrollback limit (%d lines). Keeping tail output instead of doing a large replace operation upon completion.", task.id, task.name, @@ -127,7 +127,7 @@ local comp = { ) return else - log:warn( + log.warn( "Task(%d) '%s' exceeded the output scrollback limit (%d lines). Only the last lines will be processed for the quickfix.", task.id, task.name, @@ -238,5 +238,3 @@ local comp = { return comp end, } - -return comp diff --git a/lua/overseer/component/on_output_summarize.lua b/lua/overseer/component/on_output_summarize.lua index fab8aa34..a042f64e 100644 --- a/lua/overseer/component/on_output_summarize.lua +++ b/lua/overseer/component/on_output_summarize.lua @@ -1,60 +1,8 @@ -local task_list = require("overseer.task_list") -local util = require("overseer.util") - ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "Summarize task output in the task list", - params = { - max_lines = { - desc = "Number of lines of output to show when detail > 1", - type = "integer", - default = 4, - validate = function(v) - return v > 0 - end, - }, - }, - constructor = function(params) - return { - lines = {}, - defer_update_lines = util.debounce(function(self, task, bufnr, num_lines) - if vim.api.nvim_buf_is_valid(bufnr) then - self.lines = util.get_last_output_lines(bufnr, num_lines) - task_list.update(task) - end - end, { - delay = 10, - reset_timer_on_call = true, - }), - on_reset = function(self) - self.lines = {} - end, - on_output = function(self, task, data) - local bufnr = task:get_bufnr() - self.lines = util.get_last_output_lines(bufnr, params.max_lines) - -- Update again after delay because the terminal buffer takes a few millis to be updated - -- after output is received - self.defer_update_lines(self, task, bufnr, params.max_lines) - end, - render = function(self, task, lines, highlights, detail) - local prefix = "out: " - if detail == 1 then - local last_line = self.lines[#self.lines] - if last_line and last_line ~= "" then - table.insert(lines, prefix .. last_line) - table.insert(highlights, { "Comment", #lines, 0, 4 }) - table.insert(highlights, { "OverseerOutput", #lines, 4, -1 }) - end - else - for _, line in ipairs(self.lines) do - table.insert(lines, prefix .. line) - table.insert(highlights, { "Comment", #lines, 0, 4 }) - table.insert(highlights, { "OverseerOutput", #lines, 4, -1 }) - end - end - end, - } + deprecated_message = "Components are no longer used to customize task rendering", + constructor = function() + return {} end, } - -return comp diff --git a/lua/overseer/component/on_output_write_file.lua b/lua/overseer/component/on_output_write_file.lua index 90ec2c52..7c66b3fd 100644 --- a/lua/overseer/component/on_output_write_file.lua +++ b/lua/overseer/component/on_output_write_file.lua @@ -1,5 +1,5 @@ ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "Write task output to a file", params = { filename = { @@ -36,5 +36,3 @@ local comp = { } end, } - -return comp diff --git a/lua/overseer/component/on_result_diagnostics.lua b/lua/overseer/component/on_result_diagnostics.lua index f6aa6993..d9660750 100644 --- a/lua/overseer/component/on_result_diagnostics.lua +++ b/lua/overseer/component/on_result_diagnostics.lua @@ -13,7 +13,7 @@ local type_to_severity = { -- Looks for a result value of 'diagnostics' that is a list of quickfix items ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "If task result contains diagnostics, display them", params = { virtual_text = { @@ -109,5 +109,3 @@ local comp = { } end, } - -return comp diff --git a/lua/overseer/component/on_result_diagnostics_quickfix.lua b/lua/overseer/component/on_result_diagnostics_quickfix.lua index df70782e..036b0aba 100644 --- a/lua/overseer/component/on_result_diagnostics_quickfix.lua +++ b/lua/overseer/component/on_result_diagnostics_quickfix.lua @@ -1,6 +1,8 @@ +local util = require("overseer.util") + -- Looks for a result value of 'diagnostics' that is a list of quickfix items ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "If task result contains diagnostics, add them to the quickfix", params = { use_loclist = { @@ -67,12 +69,12 @@ local comp = { end elseif params.open then local winid = vim.api.nvim_get_current_win() - vim.cmd(conf.open_cmd) - vim.api.nvim_set_current_win(winid) + util.eventignore_call(function() + vim.cmd(conf.open_cmd) + vim.api.nvim_set_current_win(winid) + end) end end, } end, } - -return comp diff --git a/lua/overseer/component/on_result_diagnostics_trouble.lua b/lua/overseer/component/on_result_diagnostics_trouble.lua index 6029ae4b..baa42226 100644 --- a/lua/overseer/component/on_result_diagnostics_trouble.lua +++ b/lua/overseer/component/on_result_diagnostics_trouble.lua @@ -1,5 +1,5 @@ ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "If task result contains diagnostics, open trouble.nvim", params = { args = { @@ -37,5 +37,3 @@ local comp = { } end, } - -return comp diff --git a/lua/overseer/component/on_result_notify.lua b/lua/overseer/component/on_result_notify.lua index 1c74ffe9..c2a051c2 100644 --- a/lua/overseer/component/on_result_notify.lua +++ b/lua/overseer/component/on_result_notify.lua @@ -4,7 +4,7 @@ local util = require("overseer.util") local STATUS = constants.STATUS ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "vim.notify when task receives results", long_desc = "Normally you will want to use on_complete_notify. If you have a long-running watch task (e.g. `tsc --watch`) that produces new results periodically, then this is the component you want.", params = { @@ -50,5 +50,3 @@ local comp = { } end, } - -return comp diff --git a/lua/overseer/component/open_output.lua b/lua/overseer/component/open_output.lua index 92f83584..9fdd3c43 100644 --- a/lua/overseer/component/open_output.lua +++ b/lua/overseer/component/open_output.lua @@ -23,7 +23,7 @@ local function open_output(task, direction, focus) end ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "Open task output", params = { on_start = { @@ -110,5 +110,3 @@ local comp = { return methods end, } - -return comp diff --git a/lua/overseer/component/restart_on_save.lua b/lua/overseer/component/restart_on_save.lua index 3d01f10e..64319a27 100644 --- a/lua/overseer/component/restart_on_save.lua +++ b/lua/overseer/component/restart_on_save.lua @@ -2,7 +2,7 @@ local files = require("overseer.files") local log = require("overseer.log") ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "Restart on any buffer :write", params = { paths = { @@ -31,15 +31,13 @@ local comp = { long_desc = "'autocmd' will set autocmds on BufWritePost. 'uv' will use a libuv file watcher (recursive watching may not be supported on all platforms).", }, interrupt = { - desc = "Interrupt running tasks", + desc = "Interrupt running tasks. If false, will wait for task to complete before restarting", type = "boolean", default = true, }, }, constructor = function(opts) - vim.validate({ - delay = { opts.delay, "n" }, - }) + vim.validate("delay", opts.delay, "number") local function is_watching_file(path) if not opts.paths then @@ -97,13 +95,13 @@ local comp = { }) elseif opts.mode == "uv" then for _, path in ipairs(opts.paths) do - local fs_event = assert(vim.loop.new_fs_event()) + local fs_event = assert(vim.uv.new_fs_event()) fs_event:start( path, { recursive = true }, vim.schedule_wrap(function(err, filename, events) if err then - log:warn("Overseer[restart_on_save] watch error: %s", err) + log.warn("Overseer[restart_on_save] watch error: %s", err) else trigger_restart(task) end @@ -140,5 +138,3 @@ local comp = { } end, } - -return comp diff --git a/lua/overseer/component/run_after.lua b/lua/overseer/component/run_after.lua index d84f2aa3..1f788362 100644 --- a/lua/overseer/component/run_after.lua +++ b/lua/overseer/component/run_after.lua @@ -5,14 +5,23 @@ local util = require("overseer.util") local STATUS = constants.STATUS ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "Run other tasks after this task completes", params = { + tasks = { + desc = "Names of dependency task templates", + long_desc = 'This can be a list of strings (template names, e.g. "cargo build"), tables (template name with params, e.g. {"mytask", foo = "bar"}), or tables (raw task params, e.g. {cmd = "sleep 10"})', + -- TODO Can't input dependencies WITH params in the task launcher b/c the type is too complex + type = "list", + optional = true, + }, task_names = { + deprecated = true, desc = "Names of dependency task templates", - long_desc = 'This can be a list of strings (template names, e.g. {"cargo build"}), tables (name with params, e.g. {"shell", cmd = "sleep 10"}), or tables (raw task params, e.g. {cmd = "sleep 10"})', + long_desc = 'This can be a list of strings (template names, e.g. "cargo build"), tables (template name with params, e.g. {"mytask", foo = "bar"}), or tables (raw task params, e.g. {cmd = "sleep 10"})', -- TODO Can't input dependencies WITH params in the task launcher b/c the type is too complex type = "list", + optional = true, }, statuses = { desc = "Only run successive tasks if the final status is in this list", @@ -38,7 +47,7 @@ local comp = { if not vim.tbl_contains(params.statuses, task.status) then return end - for i, name_or_config in ipairs(params.task_names) do + for i, name_or_config in ipairs(params.tasks or params.task_names or {}) do local task_id = self.task_lookup[i] local after_task = task_id and task_list.get(task_id) if after_task then @@ -49,7 +58,7 @@ local comp = { else util.run_template_or_task(name_or_config, function(new_task) if not new_task then - log:error( + log.error( "Task(%s)[run_after] could not find template %s", task.name, name_or_config @@ -62,9 +71,7 @@ local comp = { self.task_lookup[i] = new_task.id table.insert(self.all_tasks, new_task.id) end - -- Don't include after tasks when saving to bundle. - -- We will re-create them when this task runs again - new_task:set_include_in_bundle(false) + new_task.ephemeral = true new_task:start() end) end @@ -91,5 +98,3 @@ local comp = { } end, } - -return comp diff --git a/lua/overseer/component/timeout.lua b/lua/overseer/component/timeout.lua index aa1bfaad..959287fc 100644 --- a/lua/overseer/component/timeout.lua +++ b/lua/overseer/component/timeout.lua @@ -1,5 +1,5 @@ ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "Cancel task if it exceeds a timeout", params = { timeout = { @@ -13,14 +13,12 @@ local comp = { }, constructor = function(opts) opts = opts or {} - vim.validate({ - timeout = { opts.timeout, "n" }, - }) + vim.validate("timeout", opts.timeout, "number") return { timer = nil, canceled = false, on_start = function(self, task) - self.timer = vim.loop.new_timer() + self.timer = vim.uv.new_timer() self.timer:start( 1000 * opts.timeout, 0, @@ -42,14 +40,6 @@ local comp = { self.timer = nil end end, - render = function(self, task, lines, highlights, detail) - if self.canceled then - table.insert(lines, "Task timed out") - table.insert(highlights, { "DiagnosticWarn", #lines, 0, -1 }) - end - end, } end, } - -return comp diff --git a/lua/overseer/component/unique.lua b/lua/overseer/component/unique.lua index b8169947..6290b863 100644 --- a/lua/overseer/component/unique.lua +++ b/lua/overseer/component/unique.lua @@ -1,7 +1,7 @@ local task_list = require("overseer.task_list") local util = require("overseer.util") ---@type overseer.ComponentFileDefinition -local comp = { +return { desc = "Ensure that this task does not have any duplicates", -- Doesn't make sense for user to add this using a form. editable = false, @@ -17,6 +17,11 @@ local comp = { type = "boolean", default = true, }, + soft = { + desc = "Only dispose duplicate tasks if they are completed. Implies replace = true.", + type = "boolean", + default = false, + }, }, constructor = function(params) return { @@ -25,11 +30,13 @@ local comp = { for _, t in ipairs(tasks) do if t.name == task.name and t ~= task then if params.replace then - task:subscribe("on_start", function() - util.replace_buffer_in_wins(t:get_bufnr(), task:get_bufnr()) - return false - end) - t:dispose(true) + if t:is_complete() or not params.soft then + task:subscribe("on_start", function() + util.replace_buffer_in_wins(t:get_bufnr(), task:get_bufnr()) + return false + end) + t:dispose(true) + end else task:dispose(true) t:restart(params.restart_interrupts) @@ -41,5 +48,3 @@ local comp = { } end, } - -return comp diff --git a/lua/overseer/config.lua b/lua/overseer/config.lua index fc9057ca..fc2e3733 100644 --- a/lua/overseer/config.lua +++ b/lua/overseer/config.lua @@ -1,154 +1,91 @@ local default_config = { - -- Default task strategy - strategy = "terminal", - -- Template modules to load - templates = { "builtin" }, - -- Directories where overseer will look for template definitions (relative to rtp) - template_dirs = { "overseer.template" }, - -- When true, tries to detect a green color from your colorscheme to use for success highlight - auto_detect_success_color = true, -- Patch nvim-dap to support preLaunchTask and postDebugTask dap = true, + -- Configure the task output buffer and window + output = { + -- Use a terminal buffer to display output. If false, a normal buffer is used + use_terminal = true, + -- If true, don't clear the buffer when a task restarts + preserve_output = false, + }, -- Configure the task list task_list = { - -- Default detail level for tasks. Can be 1-3. - default_detail = 1, + -- Default direction. Can be "left", "right", or "bottom" + direction = "bottom", -- Width dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%) -- min_width and max_width can be a single value or a list of mixed integer/float types. -- max_width = {100, 0.2} means "the lesser of 100 columns or 20% of total" max_width = { 100, 0.2 }, -- min_width = {40, 0.1} means "the greater of 40 columns or 10% of total" min_width = { 40, 0.1 }, - -- optionally define an integer/float for the exact width of the task list - width = nil, - max_height = { 20, 0.1 }, + max_height = { 20, 0.2 }, min_height = 8, - height = nil, -- String that separates tasks - separator = "────────────────────────────────────────", - -- Default direction. Can be "left", "right", or "bottom" - direction = "bottom", + separator = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + -- Indentation for child tasks + child_indent = { "┃ ", "┣━", "┗━" }, + -- Function that renders tasks. See lua/overseer/render.lua for built-in options + -- and for useful functions if you want to build your own. + render = function(task) + return require("overseer.render").format_standard(task) + end, + -- The sort function for tasks + sort = function(a, b) + return require("overseer.task_list").default_sort(a, b) + end, -- Set keymap to false to remove default behavior -- You can add custom keymaps here as well (anything vim.keymap.set accepts) - bindings = { - ["?"] = "ShowHelp", - ["g?"] = "ShowHelp", - [""] = "RunAction", - [""] = "Edit", - ["o"] = "Open", - [""] = "OpenVsplit", - [""] = "OpenSplit", - [""] = "OpenFloat", - [""] = "OpenQuickFix", - ["p"] = "TogglePreview", - [""] = "IncreaseDetail", - [""] = "DecreaseDetail", - ["L"] = "IncreaseAllDetail", - ["H"] = "DecreaseAllDetail", - ["["] = "DecreaseWidth", - ["]"] = "IncreaseWidth", - ["{"] = "PrevTask", - ["}"] = "NextTask", - [""] = "ScrollOutputUp", - [""] = "ScrollOutputDown", - ["q"] = "Close", + keymaps = { + ["?"] = "keymap.show_help", + ["g?"] = "keymap.show_help", + [""] = "keymap.run_action", + ["dd"] = { "keymap.run_action", opts = { action = "dispose" }, desc = "Dispose task" }, + [""] = { "keymap.run_action", opts = { action = "edit" }, desc = "Edit task" }, + ["o"] = "keymap.open", + [""] = { "keymap.open", opts = { dir = "vsplit" }, desc = "Open task output in vsplit" }, + [""] = { "keymap.open", opts = { dir = "split" }, desc = "Open task output in split" }, + [""] = { "keymap.open", opts = { dir = "tab" }, desc = "Open task output in tab" }, + [""] = { "keymap.open", opts = { dir = "float" }, desc = "Open task output in float" }, + [""] = { + "keymap.run_action", + opts = { action = "open output in quickfix" }, + desc = "Open task output in the quickfix", + }, + ["p"] = "keymap.toggle_preview", + ["{"] = "keymap.prev_task", + ["}"] = "keymap.next_task", + [""] = "keymap.scroll_output_up", + [""] = "keymap.scroll_output_down", + ["g."] = "keymap.toggle_show_wrapped", + ["q"] = { "close", desc = "Close task list" }, }, }, - -- See :help overseer-actions + -- Custom actions for tasks. See :help overseer-actions actions = {}, -- Configure the floating window used for task templates that require input -- and the floating window used for editing tasks form = { - border = "rounded", zindex = 40, -- Dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%) -- min_X and max_X can be a single value or a list of mixed integer/float types. min_width = 80, max_width = 0.9, - width = nil, min_height = 10, max_height = 0.9, - height = nil, - -- Set any window options here (e.g. winhighlight) - win_opts = { - winblend = 0, - }, - }, - task_launcher = { - -- Set keymap to false to remove default behavior - -- You can add custom keymaps here as well (anything vim.keymap.set accepts) - bindings = { - i = { - [""] = "Submit", - [""] = "Cancel", - }, - n = { - [""] = "Submit", - [""] = "Submit", - ["q"] = "Cancel", - ["?"] = "ShowHelp", - }, - }, - }, - task_editor = { - -- Set keymap to false to remove default behavior - -- You can add custom keymaps here as well (anything vim.keymap.set accepts) - bindings = { - i = { - [""] = "NextOrSubmit", - [""] = "Submit", - [""] = "Next", - [""] = "Prev", - [""] = "Cancel", - }, - n = { - [""] = "NextOrSubmit", - [""] = "Submit", - [""] = "Next", - [""] = "Prev", - ["q"] = "Cancel", - ["?"] = "ShowHelp", - }, - }, - }, - -- Configure the floating window used for confirmation prompts - confirm = { - border = "rounded", - zindex = 40, - -- Dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%) - -- min_X and max_X can be a single value or a list of mixed integer/float types. - min_width = 20, - max_width = 0.5, - width = nil, - min_height = 6, - max_height = 0.9, - height = nil, -- Set any window options here (e.g. winhighlight) - win_opts = { - winblend = 0, - }, + win_opts = {}, }, - -- Configuration for task floating windows + -- Configuration for task floating output windows task_win = { -- How much space to leave around the floating window padding = 2, - border = "rounded", -- Set any window options here (e.g. winhighlight) - win_opts = { - winblend = 0, - }, - }, - -- Configuration for mapping help floating windows - help_win = { - border = "rounded", win_opts = {}, }, -- Aliases for bundles of components. Redefine the builtins, or create your own. component_aliases = { -- Most tasks are initialized with the default components default = { - { "display_duration", detail_level = 2 }, - "on_output_summarize", "on_exit_set_status", "on_complete_notify", { "on_complete_dispose", require_view = { "SUCCESS", "FAILURE" } }, @@ -158,106 +95,168 @@ local default_config = { "default", "on_result_diagnostics", }, - }, - bundles = { - -- When saving a bundle with OverseerSaveBundle or save_task_bundle(), filter the tasks with - -- these options (passed to list_tasks()) - save_task_opts = { - bundleable = true, + -- Tasks created from experimental_wrap_builtins + default_builtin = { + "on_exit_set_status", + "on_complete_dispose", + { "unique", soft = true }, }, - -- Autostart tasks when they are loaded from a bundle - autostart_on_load = true, }, - -- A list of components to preload on setup. - -- Only matters if you want them to show up in the task editor. - preload_components = {}, - -- Controls when the parameter prompt is shown when running a template - -- always Show when template has any params - -- missing Show when template has any params not explicitly passed in - -- allow Only show when a required param is missing - -- avoid Only show when a required param with no default value is missing - -- never Never show prompt (error if required param missing) - default_template_prompt = "allow", - -- For template providers, how long to wait (in ms) before timing out. - -- Set to 0 to disable timeouts. - template_timeout = 3000, + -- List of other directories to search for task templates. + -- This will search under the runtimepath, so for example + -- "foo/bar" will search "/lua/foo/bar/*" + template_dirs = {}, + -- For template providers, how long to wait before timing out. + -- Set to 0 to wait forever. + template_timeout_ms = 3000, -- Cache template provider results if the provider takes longer than this to run. - -- Time is in ms. Set to 0 to disable caching. - template_cache_threshold = 100, - -- Configure where the logs go and what level to use - -- Types are "echo", "notify", and "file" - log = { - { - type = "echo", - level = vim.log.levels.WARN, - }, - { - type = "file", - filename = "overseer.log", - level = vim.log.levels.WARN, - }, + -- Set to 0 to disable caching. + template_cache_threshold_ms = 200, + log_level = vim.log.levels.WARN, + -- Overseer can wrap any call to vim.system and vim.fn.jobstart as a task. + experimental_wrap_builtins = { + enabled = false, + condition = function(cmd, caller, opts) + return true + end, }, } -local M = vim.deepcopy(default_config) - -local function merge_actions(default_actions, user_actions) - local actions = {} - for k, v in pairs(default_actions) do - actions[k] = v - end - for k, v in pairs(user_actions or {}) do - if not v then - actions[k] = nil - else - actions[k] = v - end - end - return actions -end - ----If user creates a mapping for an action, remove the default mapping to that action ----(unless they explicitly specify that key as well) ----@param user_conf overseer.Config -local function remove_binding_conflicts(user_conf) - for key, configval in pairs(user_conf) do - if type(configval) == "table" and configval.bindings then - local orig_bindings = default_config[key].bindings - local rev = {} - -- Make a reverse lookup of shortcut-to-key - -- e.g. ["Open"] = "o" - for k, v in pairs(orig_bindings) do - rev[v] = k - end - for k, v in pairs(configval.bindings) do - -- If the user is choosing to map a command to a different key, remove the original default - -- map (e.g. if {"u" = "Open"}, then set {"o" = false}) - if rev[v] and rev[v] ~= k and not configval.bindings[rev[v]] then - configval.bindings[rev[v]] = false - end - end - end - end -end +local M = {} ----@param opts? overseer.Config +local has_setup = false +---@param opts? overseer.SetupOpts M.setup = function(opts) - local component = require("overseer.component") - local log = require("overseer.log") + has_setup = true opts = opts or {} - remove_binding_conflicts(opts) + local newconf = vim.tbl_deep_extend("force", default_config, opts) for k, v in pairs(newconf) do M[k] = v end - log.set_root(log.new({ handlers = M.log })) - - M.actions = merge_actions(require("overseer.task_list.actions"), newconf.actions) + if opts.task_list and opts.task_list.keymaps then + -- Handle keymap overrides in a case-insensitive way + local case_map = {} + for k in pairs(default_config.task_list.keymaps) do + case_map[k:lower()] = k + end + newconf.task_list.keymaps = vim.deepcopy(default_config.task_list.keymaps) + -- We don't want to deep merge the keymaps, we want any keymap defined by the user to override + -- everything about the default. + for k, v in pairs(opts.task_list.keymaps) do + k = case_map[k:lower()] or k + if v then + newconf.task_list.keymaps[k] = v + else + newconf.task_list.keymaps[k] = nil + end + end + end - for k, v in pairs(M.component_aliases) do - component.alias(k, v) + for i, dir in ipairs(M.template_dirs) do + -- for backwards compatibility, we used to allow module paths + M.template_dirs[i] = dir:gsub("%.", "/") end end +---@class (exact) overseer.Config +---@field setup fun(opts: overseer.SetupOpts) +---@field dap boolean +---@field log_level integer +---@field experimental_wrap_builtins overseer.ConfigWrapBuiltins +---@field output overseer.ConfigOutput +---@field task_list overseer.ConfigTaskList +---@field actions table See :help overseer-actions +---@field form overseer.ConfigFloatWin +---@field task_win overseer.ConfigTaskWin +---@field component_aliases table Aliases for bundles of components. Redefine the builtins, or create your own. +---@field template_dirs string[] List of other directories to search for task templates. +---@field template_timeout_ms? integer For template providers, how long to wait (in ms) before timing out. Set to 0 to disable timeouts. +---@field template_cache_threshold_ms? integer Cache template provider results if the provider takes longer than this to run. Time is in ms. Set to 0 to disable caching. + +---@class (exact) overseer.SetupOpts +---@field dap? boolean Patch nvim-dap to support preLaunchTask and postDebugTask +---@field log_level? integer Log level +---@field experimental_wrap_builtins? overseer.SetupConfigWrapBuiltins +---@field output? overseer.SetupConfigOutput +---@field task_list? overseer.SetupConfigTaskList +---@field actions? table See :help overseer-actions +---@field form? overseer.SetupConfigFloatWin +---@field task_win? overseer.SetupConfigTaskWin +---@field component_aliases? table Aliases for bundles of components. Redefine the builtins, or create your own. +---@field template_dirs? string[] List of other directories to search for task templates. +---@field template_timeout_ms? integer For template providers, how long to wait (in ms) before timing out. Set to 0 to disable timeouts. +---@field template_cache_threshold_ms? integer Cache template provider results if the provider takes longer than this to run. Time is in ms. Set to 0 to disable caching. + +---@class (exact) overseer.ConfigWrapBuiltins +---@field enabled boolean overseer will hook vim.system and vim.fn.jobstart and display those as tasks +---@field condition fun(cmd: string|string[], caller: overseer.Caller, opts?: table): boolean callback to determine if overseer should create a task for this jobstart/system + +---@class (exact) overseer.SetupConfigWrapBuiltins +---@field enabled? boolean overseer will hook vim.system and vim.fn.jobstart and display those as tasks +---@field condition? fun(cmd: string|string[], caller: overseer.Caller, opts?: table): boolean callback to determine if overseer should create a task for this jobstart/system + +---@class (exact) overseer.ConfigOutput +---@field preserve_output? boolean +---@field use_terminal? boolean + +---@class (exact) overseer.SetupConfigOutput +---@field preserve_output? boolean Use a terminal buffer to display output. If false, a normal buffer is used. +---@field use_terminal? boolean If true, don't clear the buffer when a task restarts + +---@class (exact) overseer.ConfigTaskList : overseer.LayoutOpts +---@field direction "left"|"right"|"bottom" +---@field separator string String that separates tasks +---@field child_indent {[1]: string, [2]: string, [3]: string} +---@field render fun(task: overseer.Task): overseer.TextChunk[][] Function that renders tasks +---@field sort fun(a: overseer.Task, b: overseer.Task): boolean Function that sorts tasks +---@field keymaps table Set keymap to false to remove default behavior + +---@class (exact) overseer.SetupConfigTaskList +---@field direction? "left"|"right"|"bottom" Direction to open task list (default "bottom") +---@field max_width? number|number[] Width dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%). min_width and max_width can be a single value or a list of mixed integer/float types. max_width = {100, 0.2} means "the lesser of 100 columns or 20% of total" +---@field min_width? number|number[] min_width = {40, 0.1} means "the greater of 40 columns or 10% of total" +---@field max_height? number|number[] +---@field min_height? number|number[] +---@field separator? string String that separates tasks +---@field child_indent? {[1]: string, [2]: string, [3]: string} +---@field render? fun(task: overseer.Task): overseer.TextChunk[][] Function that renders tasks +---@field sort? fun(a: overseer.Task, b: overseer.Task): boolean Function that sorts tasks +---@field keymaps? table Set keymap to false to remove default behavior + +---@class (exact) overseer.ConfigFloatWin : overseer.LayoutOpts +---@field zindex integer +---@field win_opts table + +---@class (exact) overseer.SetupConfigFloatWin +---@field zindex? integer +---@field min_width? number|number[] +---@field max_width? number|number[] +---@field min_height? number|number[] +---@field max_height? number|number[] +---@field win_opts? table + +---@class (exact) overseer.ConfigTaskWin +---@field padding integer +---@field zindex? integer +---@field win_opts table + +---@class (exact) overseer.SetupConfigTaskWin +---@field padding? integer How much space to leave around the floating window +---@field zindex? integer +---@field win_opts? table Set any window options here (e.g. winhighlight) + +setmetatable(M, { + -- If the user hasn't called setup() yet, make sure we correctly set up the config object so there + -- aren't random crashes. + __index = function(self, key) + if not has_setup then + M.setup() + end + return rawget(self, key) + end, +}) + +---@cast M overseer.Config return M diff --git a/lua/overseer/confirm.lua b/lua/overseer/confirm.lua deleted file mode 100644 index d0c620ec..00000000 --- a/lua/overseer/confirm.lua +++ /dev/null @@ -1,147 +0,0 @@ -local config = require("overseer.config") -local layout = require("overseer.layout") - -return function(opts, callback) - vim.validate({ - message = { opts.message, "s" }, - choices = { opts.choices, "t", true }, - default = { opts.default, "n", true }, - type = { opts.type, "s", true }, - callback = { callback, "f" }, - }) - if not opts.choices then - opts.choices = { "&OK" } - end - if not opts.default then - opts.default = 1 - end - -- TODO this doesn't do anything yet - if not opts.type then - opts.type = "G" - else - opts.type = string.sub(opts.type, 1, 1) - end - - local bufnr = vim.api.nvim_create_buf(false, true) - vim.bo[bufnr].buftype = "nofile" - vim.bo[bufnr].bufhidden = "wipe" - vim.bo[bufnr].buflisted = false - vim.bo[bufnr].swapfile = false - local winid - - local function choose(idx) - local cb = callback - callback = function(_) end - if winid then - vim.api.nvim_win_close(winid, true) - end - cb(idx) - end - local function cancel() - choose(0) - end - - local clean_choices = {} - local choice_shortcut_idx = {} - for i, choice in ipairs(opts.choices) do - local idx = choice:find("&") - local key - if idx and idx < string.len(choice) then - table.insert(clean_choices, choice:sub(1, idx - 1) .. choice:sub(idx + 1)) - key = choice:sub(idx + 1, idx + 1) - table.insert(choice_shortcut_idx, idx) - else - key = choice:sub(1, 1) - table.insert(clean_choices, choice) - table.insert(choice_shortcut_idx, 1) - end - vim.keymap.set("n", key:lower(), function() - choose(i) - end, { buffer = bufnr }) - vim.keymap.set("n", key:upper(), function() - choose(i) - end, { buffer = bufnr }) - end - vim.keymap.set("n", "", cancel, { buffer = bufnr }) - vim.keymap.set("n", "", cancel, { buffer = bufnr }) - - local lines = vim.split(opts.message, "\n") - local highlights = {} - table.insert(lines, "") - - -- Calculate the width of the choices if they are on a single line - local choices_width = 0 - for _, choice in ipairs(clean_choices) do - choices_width = choices_width + vim.api.nvim_strwidth(choice) - end - -- Make sure to account for spacing - choices_width = choices_width + #clean_choices - 1 - - local desired_width = choices_width - for _, line in ipairs(lines) do - local len = string.len(line) - if len > desired_width then - desired_width = len - end - end - - local width = layout.calculate_width(desired_width, config.confirm) - - if width < choices_width then - -- Render one choice per line - for i, choice in ipairs(clean_choices) do - table.insert(lines, choice) - table.insert(highlights, { "Keyword", #lines, choice_shortcut_idx[i] - 1 }) - end - else - -- Render all choices on a single line - local extra_spacing = width - choices_width - local line = "" - local num_dividers = #clean_choices - 1 - for i, choice in ipairs(clean_choices) do - if i > 1 then - line = line .. " " .. string.rep(" ", math.floor(extra_spacing / num_dividers)) - if extra_spacing % num_dividers >= i then - line = line .. " " - end - end - local col_start = line:len() - 1 - line = line .. choice - table.insert(highlights, { "Keyword", #lines + 1, col_start + choice_shortcut_idx[i] }) - end - table.insert(lines, line) - end - - vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines) - local ns = vim.api.nvim_create_namespace("confirm") - for _, hl in ipairs(highlights) do - local group, lnum, col_start, col_end = unpack(hl) - if not col_end then - col_end = col_start + 1 - end - vim.api.nvim_buf_add_highlight(bufnr, ns, group, lnum - 1, col_start, col_end) - end - - local height = layout.calculate_height(#lines, config.confirm) - winid = vim.api.nvim_open_win(bufnr, true, { - relative = "editor", - border = config.confirm.border, - zindex = config.confirm.zindex, - style = "minimal", - width = width, - height = height, - col = math.floor((layout.get_editor_width() - width) / 2), - row = math.floor((layout.get_editor_height() - height) / 2), - }) - for k, v in pairs(config.confirm.win_opts) do - vim.api.nvim_set_option_value(k, v, { scope = "local", win = winid }) - end - - vim.api.nvim_create_autocmd("BufLeave", { - buffer = bufnr, - callback = cancel, - once = true, - nested = true, - }) - vim.bo[bufnr].modifiable = false -end diff --git a/lua/overseer/dap.lua b/lua/overseer/dap.lua index 9bf3ff69..359b8ddc 100644 --- a/lua/overseer/dap.lua +++ b/lua/overseer/dap.lua @@ -1,7 +1,7 @@ local constants = require("overseer.constants") local dap = require("dap") local log = require("overseer.log") -local vscode = require("overseer.template.vscode") +local vscode = require("overseer.vscode") local STATUS = constants.STATUS local TAG = constants.TAG local M = {} @@ -17,7 +17,7 @@ local function get_task(name, config, cb) else args.name = name end - require("overseer").run_template(args, cb) + require("overseer").run_task(args, cb) end M.listener = function(config) @@ -28,10 +28,10 @@ M.listener = function(config) if config.postDebugTask then dap.listeners.after.event_terminated.overseer = function() - log:debug("Running DAP postDebugTask %s", config.postDebugTask) + log.debug("Running DAP postDebugTask %s", config.postDebugTask) get_task(config.postDebugTask, config, function(task, err) if err then - log:error("Could not run postDebugTask %s", config.postDebugTask) + log.error("Could not run postDebugTask %s", config.postDebugTask) elseif task then task:start() end @@ -40,11 +40,11 @@ M.listener = function(config) end if config.preLaunchTask then - log:debug("Running DAP preLaunchTask %s", config.preLaunchTask) + log.debug("Running DAP preLaunchTask %s", config.preLaunchTask) local co = coroutine.running() get_task(config.preLaunchTask, config, function(task, err) if not task then - log:error("Could not run preLaunchTask %s: %s", config.preLaunchTask, err) + log.error("Could not run preLaunchTask %s: %s", config.preLaunchTask, err) return end diff --git a/lua/overseer/files.lua b/lua/overseer/files.lua index 223d9f1a..300228eb 100644 --- a/lua/overseer/files.lua +++ b/lua/overseer/files.lua @@ -1,10 +1,10 @@ local M = {} ---@type boolean -M.is_windows = vim.loop.os_uname().version:match("Windows") +M.is_windows = vim.uv.os_uname().version:match("Windows") ---@type boolean -M.is_mac = vim.loop.os_uname().sysname == "Darwin" +M.is_mac = vim.uv.os_uname().sysname == "Darwin" ---@type string M.sep = M.is_windows and "\\" or "/" @@ -23,21 +23,10 @@ end ---@param filepath string ---@return boolean M.exists = function(filepath) - local stat = vim.loop.fs_stat(filepath) + local stat = vim.uv.fs_stat(filepath) return stat ~= nil and stat.type ~= nil end ----@return string -M.join = function(...) - local joined = table.concat({ ... }, M.sep) - if M.is_windows then - joined = joined:gsub("\\\\+", "\\") - else - joined = joined:gsub("//+", "/") - end - return joined -end - M.is_absolute = function(path) if M.is_windows then return path:lower():match("^%a:") @@ -105,10 +94,10 @@ M.read_file = function(filepath) if not M.exists(filepath) then return nil end - local fd = assert(vim.loop.fs_open(filepath, "r", 420)) -- 0644 - local stat = assert(vim.loop.fs_fstat(fd)) - local content = vim.loop.fs_read(fd, stat.size) - vim.loop.fs_close(fd) + local fd = assert(vim.uv.fs_open(filepath, "r", 420)) -- 0644 + local stat = assert(vim.uv.fs_fstat(fd)) + local content = vim.uv.fs_read(fd, stat.size) + vim.uv.fs_close(fd) return content end @@ -137,9 +126,9 @@ end ---@return string[] M.list_files = function(dir) ---@diagnostic disable-next-line: param-type-mismatch - local fd = vim.loop.fs_opendir(dir, nil, 32) + local fd = vim.uv.fs_opendir(dir, nil, 32) ---@diagnostic disable-next-line: param-type-mismatch - local entries = vim.loop.fs_readdir(fd) + local entries = vim.uv.fs_readdir(fd) local ret = {} while entries do for _, entry in ipairs(entries) do @@ -148,10 +137,10 @@ M.list_files = function(dir) end end ---@diagnostic disable-next-line: param-type-mismatch - entries = vim.loop.fs_readdir(fd) + entries = vim.uv.fs_readdir(fd) end ---@diagnostic disable-next-line: param-type-mismatch - vim.loop.fs_closedir(fd) + vim.uv.fs_closedir(fd) return ret end @@ -166,7 +155,7 @@ M.mkdir = function(dirname, perms) if not M.exists(parent) then M.mkdir(parent) end - vim.loop.fs_mkdir(dirname, perms) + vim.uv.fs_mkdir(dirname, perms) end end @@ -174,15 +163,15 @@ end ---@param contents string M.write_file = function(filename, contents) M.mkdir(vim.fn.fnamemodify(filename, ":p:h")) - local fd = assert(vim.loop.fs_open(filename, "w", 420)) -- 0644 - vim.loop.fs_write(fd, contents) - vim.loop.fs_close(fd) + local fd = assert(vim.uv.fs_open(filename, "w", 420)) -- 0644 + vim.uv.fs_write(fd, contents) + vim.uv.fs_close(fd) end ---@param filename string M.delete_file = function(filename) if M.exists(filename) then - vim.loop.fs_unlink(filename) + vim.uv.fs_unlink(filename) return true end end diff --git a/lua/overseer/form/init.lua b/lua/overseer/form/init.lua index 28822e7e..cd559b51 100644 --- a/lua/overseer/form/init.lua +++ b/lua/overseer/form/init.lua @@ -1,66 +1,15 @@ -local binding_util = require("overseer.binding_util") -local config = require("overseer.config") local form_utils = require("overseer.form.utils") local util = require("overseer.util") local M = {} -local bindings = { - { - desc = "Show default key bindings", - plug = "OverseerTaskEditor:ShowHelp", - rhs = function(builder) - builder.disable_close_on_leave = true - binding_util.show_bindings("OverseerTaskEditor:") - end, - }, - { - desc = "Move to next form field, or submit the task if on the last field", - plug = "OverseerTaskEditor:NextOrSubmit", - rhs = function(builder) - builder:confirm() - end, - }, - { - desc = "Submit the task", - plug = "OverseerTaskEditor:Submit", - rhs = function(builder) - builder:submit() - end, - }, - { - desc = "Cancel the task", - plug = "OverseerTaskEditor:Cancel", - rhs = function(builder) - builder:cancel() - end, - }, - { - desc = "Move to the next field", - plug = "OverseerTaskEditor:Next", - rhs = function(builder) - builder:next_field() - end, - }, - { - desc = "Move to the previous field", - plug = "OverseerTaskEditor:Prev", - rhs = function(builder) - builder:prev_field() - end, - }, -} - -local Builder = {} +local Form = {} local function line_len(bufnr, lnum) return string.len(vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, true)[1]) end -local function parse_line(line) - return line:match("^%*?([^%s]+): ?(.*)$") -end -function Builder.new(title, schema, params, callback) +function Form.new(title, schema, params, callback) -- Filter out the opaque types local keys = vim.tbl_filter(function(key) return schema[key].type ~= "opaque" @@ -100,19 +49,20 @@ function Builder.new(title, schema, params, callback) vim.bo[bufnr].swapfile = false vim.bo[bufnr].bufhidden = "wipe" vim.bo[bufnr].buftype = "acwrite" - vim.api.nvim_buf_set_name(bufnr, "Overseer task builder") + vim.api.nvim_buf_set_name(bufnr, "Overseer form") - local autocmds = {} - local builder + local form local cleanup, layout = form_utils.open_form_win(bufnr, { - autocmds = autocmds, on_resize = function() - builder:render() + form:render() end, get_preferred_dim = function() local max_len = 1 for k, v in pairs(schema) do - local len = string.len(form_utils.render_field(v, " ", k, params[k])) + local len = string.len(k .. tostring(form_utils.render_value(v, params[k]))) + 2 + if v.required then + len = len + 1 + end if v.desc then len = len + 1 + string.len(v.desc) end @@ -123,148 +73,146 @@ function Builder.new(title, schema, params, callback) return max_len, #keys + 1 end, }) - table.insert( - autocmds, - vim.api.nvim_create_autocmd("BufEnter", { - desc = "Reset disable_close_on_leave", - buffer = bufnr, - nested = true, - callback = function() - builder.disable_close_on_leave = false - end, - }) - ) + vim.api.nvim_create_autocmd("BufEnter", { + desc = "Reset disable_close_on_leave", + buffer = bufnr, + nested = true, + callback = function() + form.disable_close_on_leave = false + end, + }) vim.bo[bufnr].filetype = "OverseerForm" + local called_callback = false + local function cb(...) + if not called_callback then + callback(...) + called_callback = true + end + end - builder = setmetatable({ + form = setmetatable({ disable_close_on_leave = false, cur_line = nil, title = title, schema_keys = keys, schema = schema, params = params, - callback = callback, + callback = cb, cleanup = cleanup, layout = layout, - autocmds = autocmds, bufnr = bufnr, fields_focused = {}, fields_ever_focused = {}, + ext_id_to_schema_field_name = {}, ever_submitted = false, - }, { __index = Builder }) - builder:init_autocmds() - builder:init_keymaps() + }, { __index = Form }) + form:init_autocmds() + + vim.keymap.set({ "i", "n" }, "", function() + form:cancel() + end, { buffer = bufnr }) + vim.keymap.set("n", "q", function() + form:cancel() + end, { buffer = bufnr }) + vim.keymap.set("i", "", function() + form:confirm() + end, { buffer = bufnr }) vim.api.nvim_create_autocmd("BufWriteCmd", { desc = "Submit on buffer write", buffer = bufnr, callback = function() - builder:submit() + form:submit() end, }) - return builder + return form end -function Builder:init_autocmds() - table.insert( - self.autocmds, - vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { - desc = "Update form on change", - buffer = self.bufnr, - nested = true, - callback = function() - local lnum = vim.api.nvim_win_get_cursor(0)[1] - local line = vim.api.nvim_buf_get_lines(0, lnum - 1, lnum, true)[1] - self.cur_line = { lnum, line } - self:parse() - end, - }) - ) - table.insert( - self.autocmds, - vim.api.nvim_create_autocmd("InsertLeave", { - desc = "Rerender form", - buffer = self.bufnr, - callback = function() - self:render() - end, - }) - ) - table.insert( - self.autocmds, - vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { - desc = "Update form on move cursor", - buffer = self.bufnr, - nested = true, - callback = function() - self:on_cursor_move() - end, - }) - ) - table.insert( - self.autocmds, - vim.api.nvim_create_autocmd("BufLeave", { - desc = "Close float on BufLeave", - buffer = self.bufnr, - nested = true, - callback = function() - if not self.disable_close_on_leave then - self:cancel() - end - end, - }) - ) -end - -function Builder:init_keymaps() - binding_util.create_plug_bindings(self.bufnr, bindings, self) - for mode, user_bindings in pairs(config.task_editor.bindings) do - binding_util.create_bindings_to_plug(self.bufnr, mode, user_bindings, "OverseerTaskEditor:") - end - - -- Some shenanigans to make behave the way we expect - vim.keymap.set("i", "", function() - local cur = vim.api.nvim_win_get_cursor(0) - local line = vim.api.nvim_buf_get_lines(self.bufnr, cur[1] - 1, cur[1], true)[1] - local name = line:match("^[^%s]+: ") - if name then - local rem = string.sub(line, cur[2] + 1) - vim.api.nvim_buf_set_lines( - self.bufnr, - cur[1] - 1, - cur[1], - true, - { string.format("%s%s", name, rem) } - ) +function Form:init_autocmds() + vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { + desc = "Update form on change", + buffer = self.bufnr, + nested = true, + callback = function() + local lnum = vim.api.nvim_win_get_cursor(0)[1] + local line = vim.api.nvim_buf_get_lines(0, lnum - 1, lnum, true)[1] + self.cur_line = { lnum, line } self:parse() - vim.api.nvim_win_set_cursor(0, { cur[1], 0 }) - end - end, { buffer = self.bufnr }) + vim.bo[self.bufnr].modified = false + end, + }) + vim.api.nvim_create_autocmd("InsertLeave", { + desc = "Rerender form", + buffer = self.bufnr, + callback = function() + self:render() + end, + }) + vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { + desc = "Update form on move cursor", + buffer = self.bufnr, + nested = true, + callback = function() + self:on_cursor_move() + end, + }) + vim.api.nvim_create_autocmd("BufLeave", { + desc = "Close float on BufLeave", + buffer = self.bufnr, + nested = true, + callback = function() + if not self.disable_close_on_leave then + self:cancel() + end + end, + }) end -function Builder:render() +function Form:render() local title_ns = vim.api.nvim_create_namespace("overseer_title") vim.api.nvim_buf_clear_namespace(self.bufnr, title_ns, 0, -1) local lines = { util.align(self.title, vim.api.nvim_win_get_width(0), "center") } - local highlights = { { "OverseerTask", 1, 0, -1 } } local extmarks = {} + table.insert(extmarks, { + 0, + 0, + { hl_group = "OverseerTask", end_col = #lines[1] }, + }) + local extmark_idx_to_name = {} for _, name in ipairs(self.schema_keys) do local prefix = self.schema[name].optional and "" or "*" local schema = self.schema[name] - table.insert(lines, form_utils.render_field(schema, prefix, name, self.params[name])) + local field_hl = "OverseerField" + if + (self.fields_ever_focused[name] or self.ever_submitted) + and not form_utils.validate_field(self.schema[name], self.params[name]) + then + field_hl = "DiagnosticError" + end + table.insert(extmarks, { + #lines, + 0, + { + virt_text = { { prefix, "NormalFloat" }, { name, field_hl }, { ": ", "NormalFloat" } }, + virt_text_pos = "inline", + undo_restore = false, + invalidate = true, + }, + }) + extmark_idx_to_name[#extmarks] = name + table.insert(lines, tostring(form_utils.render_value(schema, self.params[name]))) if schema.conceal then - local start_col = (prefix .. name):len() + 1 - local length = #lines[#lines] - start_col + local length = #lines[#lines] -- Because conceallevel replaces every concealed _block_ with a single character, we have to -- create 1-width conceal blocks, one for each character - for i = 1, length do + for i = 0, length do table.insert(extmarks, { #lines - 1, - start_col + i, + i, { - right_gravity = false, + undo_restore = false, strict = false, conceal = "*", - end_col = start_col + i + 2, + end_col = i + 1, }, }) end @@ -276,46 +224,43 @@ function Builder:render() lines[lnum] = line end vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, true, lines) - util.add_highlights(self.bufnr, title_ns, highlights) - for _, mark in ipairs(extmarks) do + self.ext_id_to_schema_field_name = {} + for i, mark in ipairs(extmarks) do local line, col, opts = unpack(mark) - vim.api.nvim_buf_set_extmark(self.bufnr, title_ns, line, col, opts) + local ext_id = vim.api.nvim_buf_set_extmark(self.bufnr, title_ns, line, col, opts) + self.ext_id_to_schema_field_name[ext_id] = extmark_idx_to_name[i] end self:on_cursor_move() end -function Builder:on_cursor_move() +function Form:on_cursor_move() local cur = vim.api.nvim_win_get_cursor(0) if self.cur_line and self.cur_line[1] ~= cur[1] then self.cur_line = nil self:render() return end - local original_cur = vim.deepcopy(cur) local ns = vim.api.nvim_create_namespace("overseer") vim.api.nvim_buf_clear_namespace(self.bufnr, ns, 0, -1) local num_lines = vim.api.nvim_buf_line_count(self.bufnr) -- Top line is title if cur[1] == 1 and num_lines > 1 then - cur[1] = 2 + vim.schedule_wrap(vim.api.nvim_win_set_cursor)(0, { 2, 0 }) + return end local buflines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, true) + local lnum_to_field_name = self:get_lnum_to_field_name() for i, line in ipairs(buflines) do - local name, text = parse_line(line) + local name = lnum_to_field_name[i] if name and self.schema[name] then local focused = i == cur[1] - local name_end = string.len(line) - string.len(text) - -- Move cursor to input section of field if focused then - if cur[2] < name_end then - cur[2] = name_end - end local schema = self.schema[name] local vtext = {} if schema.type == "namedEnum" then - local value = schema.choices[text] + local value = schema.choices[line] if value then table.insert(vtext, { string.format("[%s] ", value), "Comment" }) end @@ -341,29 +286,34 @@ function Builder:on_cursor_move() elseif self.fields_focused[name] then self.fields_ever_focused[name] = true end - - local group = "OverseerField" - if - (self.fields_ever_focused[name] or self.ever_submitted) - and not form_utils.validate_field(self.schema[name], self.params[name]) - then - group = "DiagnosticError" - end - vim.api.nvim_buf_add_highlight(self.bufnr, ns, group, i - 1, 0, name_end) end end +end - if cur and (cur[1] ~= original_cur[1] or cur[2] ~= original_cur[2]) then - vim.api.nvim_win_set_cursor(0, cur) +---@private +---@return table +function Form:get_lnum_to_field_name() + local title_ns = vim.api.nvim_create_namespace("overseer_title") + local extmarks = + vim.api.nvim_buf_get_extmarks(self.bufnr, title_ns, 0, -1, { type = "virt_text" }) + local lnum_to_field_name = {} + for _, extmark in ipairs(extmarks) do + local ext_id, row = extmark[1], extmark[2] + local field_name = self.ext_id_to_schema_field_name[ext_id] + if field_name then + lnum_to_field_name[row + 1] = field_name + end end + return lnum_to_field_name end -function Builder:parse() +function Form:parse() local buflines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, true) - for _, line in ipairs(buflines) do - local name, text = parse_line(line) + local lnum_to_field_name = self:get_lnum_to_field_name() + for i, line in ipairs(buflines) do + local name = lnum_to_field_name[i] if name and self.schema[name] then - local parsed, value = form_utils.parse_value(self.schema[name], text) + local parsed, value = form_utils.parse_value(self.schema[name], line) if parsed then self.params[name] = value end @@ -373,15 +323,19 @@ function Builder:parse() self:render() end -function Builder:cancel() - self.cleanup() +function Form:cancel() self.callback(nil) + self.cleanup() end -function Builder:submit() +function Form:submit() + local first_submit = not self.ever_submitted self.ever_submitted = true for i, name in pairs(self.schema_keys) do if not form_utils.validate_field(self.schema[name], self.params[name]) then + if first_submit then + self:render() + end local lnum = i + 1 if vim.api.nvim_win_get_cursor(0)[1] ~= lnum then vim.api.nvim_win_set_cursor(0, { lnum, line_len(self.bufnr, lnum) }) @@ -391,11 +345,11 @@ function Builder:submit() return end end - self.cleanup() self.callback(self.params) + self.cleanup() end -function Builder:next_field() +function Form:next_field() local lnum = vim.api.nvim_win_get_cursor(0)[1] if lnum == vim.api.nvim_buf_line_count(0) then return false @@ -405,7 +359,7 @@ function Builder:next_field() end end -function Builder:prev_field() +function Form:prev_field() local lnum = vim.api.nvim_win_get_cursor(0)[1] if lnum == 1 then return false @@ -414,7 +368,7 @@ function Builder:prev_field() end end -function Builder:confirm() +function Form:confirm() if not self:next_field() then self:submit() end @@ -431,8 +385,8 @@ M.open = function(title, schema, params, callback) callback(params) return end - local builder = Builder.new(title, schema, params, callback) - builder:render() + local form = Form.new(title, schema, params, callback) + form:render() vim.defer_fn(function() vim.cmd([[startinsert!]]) diff --git a/lua/overseer/form/utils.lua b/lua/overseer/form/utils.lua index ec84068b..61ba966d 100644 --- a/lua/overseer/form/utils.lua +++ b/lua/overseer/form/utils.lua @@ -2,14 +2,13 @@ local config = require("overseer.config") local layout = require("overseer.layout") local log = require("overseer.log") local util = require("overseer.util") ----@diagnostic disable-next-line: deprecated -local islist = vim.islist or vim.tbl_islist local M = {} ---@alias overseer.Param overseer.StringParam|overseer.BoolParam|overseer.NumberParam|overseer.IntParam|overseer.ListParam|overseer.EnumParam|overseer.NamedEnumParam|overseer.OpaqueParam ---@class overseer.BaseParam ---@field name? string +---@field deprecated? boolean ---@field desc? string ---@field long_desc? string ---@field order? integer @@ -70,12 +69,10 @@ M.validate_params = function(params) if name:match("%s") then error(string.format("Param '%s' cannot contain whitespace", name)) end - vim.validate({ - name = { param.name, "s", true }, - desc = { param.desc, "s", true }, - optional = { param.optional, "b", true }, - -- default = any type - }) + vim.validate("name", param.name, "string", true) + vim.validate("desc", param.desc, "string", true) + vim.validate("optional", param.optional, "boolean", true) + -- default = any type local default = default_schema[param.type] if default then for k, v in pairs(default) do @@ -115,16 +112,6 @@ M.render_value = function(schema, value) return value end ----@param schema overseer.Param ----@param prefix string ----@param name string ----@param value any ----@return string -M.render_field = function(schema, prefix, name, value) - local str_value = M.render_value(schema, value) - return string.format("%s%s: %s", prefix, name, str_value) -end - ---@param schema overseer.Param ---@param value any ---@return boolean @@ -139,7 +126,7 @@ local function validate_type(schema, value) elseif ptype == "namedEnum" then return vim.tbl_contains(vim.tbl_values(schema.choices), value) elseif ptype == "list" then - return type(value) == "table" and islist(value) + return type(value) == "table" and vim.islist(value) elseif ptype == "number" then return type(value) == "number" elseif ptype == "integer" then @@ -149,7 +136,7 @@ local function validate_type(schema, value) elseif ptype == "string" then return true else - log:warn("Unknown param type '%s'", ptype) + log.warn("Unknown param type '%s'", ptype) return false end end @@ -264,14 +251,14 @@ end local registered_cmp = false +---@param bufnr integer +---@param opts? { on_resize?: fun(), get_preferred_dim?: fun(): integer, integer } +---@return fun() cleanup +---@return fun() set_layout M.open_form_win = function(bufnr, opts) opts = opts or {} - vim.validate({ - autocmds = { opts.autocmds, "t", true }, - on_resize = { opts.on_resize, "f", true }, - get_preferred_dim = { opts.get_preferred_dim, "f", true }, - }) - opts.autocmds = opts.autocmds or {} + vim.validate("on_resize", opts.on_resize, "function", true) + vim.validate("get_preferred_dim", opts.get_preferred_dim, "function", true) local function calc_layout() local desired_width local desired_height @@ -282,7 +269,6 @@ M.open_form_win = function(bufnr, opts) local height = layout.calculate_height(desired_height, config.form) local win_opts = { relative = "editor", - border = config.form.border, zindex = config.form.zindex, width = width, height = height, @@ -303,11 +289,18 @@ M.open_form_win = function(bufnr, opts) end local function set_layout() - vim.api.nvim_win_set_config(winid, calc_layout()) + if vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_set_config(winid, calc_layout()) + else + return true + end end local winwidth = vim.api.nvim_win_get_width(winid) local function on_win_scrolled() + if not vim.api.nvim_win_is_valid(winid) then + return true + end local new_width = vim.api.nvim_win_get_width(winid) if winwidth ~= new_width then winwidth = new_width @@ -316,45 +309,18 @@ M.open_form_win = function(bufnr, opts) end if opts.on_resize then - table.insert( - opts.autocmds, - vim.api.nvim_create_autocmd("WinScrolled", { - desc = "Rerender on window resize", - pattern = tostring(winid), - nested = true, - callback = on_win_scrolled, - }) - ) - end - table.insert( - opts.autocmds, - vim.api.nvim_create_autocmd("VimResized", { - desc = "Rerender on vim resize", + vim.api.nvim_create_autocmd("WinScrolled", { + desc = "Rerender on window resize", + pattern = tostring(winid), nested = true, - callback = set_layout, + callback = on_win_scrolled, }) - ) - -- This is a little bit of a hack. We force the cursor to be *after the ': ' - -- of the fields, but if the user enters insert mode with "i", the cursor will - -- now be before the space. If they type, the parsing will misbehave. So we - -- detect that and just...nudge them forwards a bit. - table.insert( - opts.autocmds, - vim.api.nvim_create_autocmd("InsertCharPre", { - desc = "Move cursor to end of line when inserting", - buffer = bufnr, - nested = true, - callback = function() - local cur = vim.api.nvim_win_get_cursor(0) - local lnum = cur[1] - local line = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, true)[1] - local name = line:match("^[^%s]+: ") - if name and cur[2] < string.len(name) then - vim.api.nvim_win_set_cursor(0, { lnum, string.len(name) }) - end - end, - }) - ) + end + vim.api.nvim_create_autocmd("VimResized", { + desc = "Rerender on vim resize", + nested = true, + callback = set_layout, + }) vim.bo[bufnr].omnifunc = "v:lua.overseer_form_omnifunc" -- Configure nvim-cmp if installed @@ -374,9 +340,6 @@ M.open_form_win = function(bufnr, opts) end local function cleanup() - for _, id in ipairs(opts.autocmds) do - vim.api.nvim_del_autocmd(id) - end util.leave_insert() vim.api.nvim_win_close(winid, true) end diff --git a/lua/overseer/health.lua b/lua/overseer/health.lua new file mode 100644 index 00000000..c999376f --- /dev/null +++ b/lua/overseer/health.lua @@ -0,0 +1,70 @@ +local commands = require("overseer.commands") +local config = require("overseer.config") +local log = require("overseer.log") + +local M = {} + +local level_map = {} +for k, v in pairs(vim.log.levels) do + level_map[v] = k +end + +M.check = function() + vim.health.start("overseer.nvim report") + + if vim.fn.has("nvim-0.11") == 0 then + vim.health.error("Neovim 0.11 or later is required") + end + vim.health.info(string.format("Log file: %s", log.get_logfile())) + vim.health.info(string.format("Log level: %s", level_map[config.log_level])) + + ---@type overseer.Report + local info + commands.info(function(info_cb) + info = info_cb + end) + + vim.wait(10000, function() + return info ~= nil + end) + + if not info then + vim.health.warn("timeout waiting for tasks to generate") + return + end + + local provider_names = vim.tbl_keys(info.providers) + table.sort(provider_names, function(a_name, b_name) + local a, b = info.providers[a_name], info.providers[b_name] + local a_err, b_err = a.message ~= nil, b.message ~= nil + if a_err ~= b_err then + return not a_err + end + return a_name < b_name + end) + for _, name in ipairs(provider_names) do + local provider_report = info.providers[name] + if name:match("^[%w_%.%-]+$") then + name = "{" .. name .. "}" + end + if provider_report.message then + vim.health.warn(string.format("%s: %s", name, provider_report.message)) + else + if provider_report.from_cache then + name = name .. " (cached)" + elseif provider_report.elapsed_ms > 0 then + name = string.format("%s (%sms)", name, provider_report.elapsed_ms) + end + vim.health.ok( + string.format( + "%s: `%d/%d tasks available`", + name, + provider_report.available_tasks, + provider_report.total_tasks + ) + ) + end + end +end + +return M diff --git a/lua/overseer/init.lua b/lua/overseer/init.lua index 29fc0f6d..8827da38 100644 --- a/lua/overseer/init.lua +++ b/lua/overseer/init.lua @@ -1,131 +1,6 @@ ----@mod overseer - ----@diagnostic disable: undefined-doc-param - local M = {} -local setup_callbacks = {} - -local BREAKING_CHANGES_NOTICE = - [[ATTN: overseer.nvim will experience breaking changes soon. Pin to version v1.6.0 or earlier to avoid disruption. -See: https://github.com/stevearc/overseer.nvim/pull/448]] -local initialized = false -local pending_opts -local function do_setup() - if not pending_opts then - if initialized then - return - else - -- If user hasn't called setup(), assume an empty options table - pending_opts = {} - end - end - vim.notify_once(BREAKING_CHANGES_NOTICE, vim.log.levels.WARN) - local config = require("overseer.config") - local log = require("overseer.log") - config.setup(pending_opts) - pending_opts = nil - local util = require("overseer.util") - local success_color = util.find_success_color() - for _, hl in ipairs(M.get_all_highlights()) do - vim.cmd(string.format("hi default link %s %s", hl.name, hl.default)) - end - local aug = vim.api.nvim_create_augroup("Overseer", {}) - if config.auto_detect_success_color then - vim.api.nvim_create_autocmd("ColorScheme", { - pattern = "*", - group = aug, - desc = "Set Overseer default success color", - callback = function() - success_color = util.find_success_color() - vim.cmd(string.format("hi link OverseerSUCCESS %s", success_color)) - end, - }) - end - vim.api.nvim_create_autocmd("User", { - pattern = "SessionSavePre", - desc = "Save task state when vim-session saves", - group = aug, - callback = function() - local task_list = require("overseer.task_list") - local cmds = vim.g.session_save_commands - local tasks = vim.tbl_map(function(task) - return task:serialize() - end, task_list.list_tasks({ bundleable = true })) - -- Abort if no tasks or if not using vim-session (no vim.g.session_save_commands) - if not cmds or vim.tbl_isempty(tasks) then - return - end - table.insert(cmds, '" overseer.nvim') - ---@type string - local data = vim.json.encode(tasks) ---@diagnostic disable-line: assign-type-mismatch - -- For some reason, vim.json.encode encodes / as \/. - data = string.gsub(data, "\\/", "/") - data = string.gsub(data, "'", "\\'") - table.insert(cmds, string.format("lua require('overseer')._start_tasks('%s')", data)) - vim.g.session_save_commands = cmds - end, - }) - local Notifier = require("overseer.notifier") - vim.api.nvim_create_autocmd("FocusGained", { - desc = "Track editor focus for overseer", - group = aug, - callback = function() - Notifier.focused = true - end, - }) - vim.api.nvim_create_autocmd("FocusLost", { - desc = "Track editor focus for overseer", - group = aug, - callback = function() - Notifier.focused = false - end, - }) - initialized = true - for _, cb in ipairs(setup_callbacks) do - local cb_ok, err = pcall(cb) - if not cb_ok then - log:error("Error running overseer setup callback: %s", err) - end - end -end - ----When this function is called, complete the overseer setup ----@param mod string Name of overseer module ----@param fn string Name of function to wrap -local function lazy(mod, fn) - return function(...) - do_setup() - return require(string.format("overseer.%s", mod))[fn](...) - end -end - ----When this function is called, if overseer has not loaded yet defer the call until after overseer ----has loaded. ----@param mod string Name of overseer module ----@param fn string Name of function to wrap -local function lazy_pend(mod, fn) - return function(...) - if initialized then - require(string.format("overseer.%s", mod))[fn](...) - else - local args = { ... } - local traceback = debug.traceback() - M.on_setup(function() - local log = require("overseer.log") - local ok, module = pcall(require, string.format("overseer.%s", mod)) - if not ok then - log:error("Error requiring overseer lazy module: %s", module) - return - end - local call_ok, err = pcall(module[fn], unpack(args)) - if not call_ok then - log:error("Error in overseer lazy call to %s.%s: %s\n%s", mod, fn, err, traceback) - end - end) - end - end -end +---@alias overseer.Serialized string|{[1]: string, [string]: any} local commands = { { @@ -165,43 +40,6 @@ local commands = { end, }, }, - { - cmd = "OverseerSaveBundle", - args = "`[name]`", - func = "_save_bundle", - def = { - desc = "Serialize and save the current tasks to disk", - nargs = "?", - }, - }, - { - cmd = "OverseerLoadBundle", - args = "`[name]`", - func = "_load_bundle", - def = { - desc = "Load tasks that were saved to disk. With `!` tasks will not be started", - nargs = "?", - bang = true, - }, - }, - { - cmd = "OverseerDeleteBundle", - args = "`[name]`", - func = "_delete_bundle", - def = { - desc = "Delete a saved task bundle", - nargs = "?", - }, - }, - { - cmd = "OverseerRunCmd", - args = "`[command]`", - func = "_run_command", - def = { - desc = "Run a raw shell command", - nargs = "?", - }, - }, { cmd = "OverseerRun", args = "`[name/tags]`", @@ -212,26 +50,14 @@ local commands = { }, }, { - cmd = "OverseerInfo", - func = "_info", - def = { - desc = "Display diagnostic information about overseer", - }, - }, - { - cmd = "OverseerBuild", - func = "_build_task", - def = { - desc = "Open the task builder", - }, - }, - { - cmd = "OverseerQuickAction", - args = "`[action]`", - func = "_quick_action", + cmd = "OverseerShell", + args = "`[command]`", + func = "_run_shell", def = { - nargs = "?", - desc = "Run an action on the most recent task, or the task under the cursor", + desc = "Run a shell command as an overseer task. With `!` the task is created but not started", + complete = "shellcmdline", + bang = true, + nargs = "*", }, }, { @@ -241,28 +67,20 @@ local commands = { desc = "Select a task to run an action on", }, }, - { - cmd = "OverseerClearCache", - func = "_clear_cache", - def = { - desc = "Clear the task cache", - }, - }, } local function create_commands() for _, v in pairs(commands) do - vim.api.nvim_create_user_command(v.cmd, lazy("commands", v.func), v.def) + vim.api.nvim_create_user_command(v.cmd, function(args) + require("overseer.commands")[v.func](args) + end, v.def) end end ----Add support for preLaunchTask/postDebugTask to nvim-dap ----@private ----@deprecated ----@param enabled boolean -M.patch_dap = function(enabled) - M.enable_dap(enabled) -end +M.builtin = { + jobstart = vim.fn.jobstart, + system = vim.system, +} ---Add support for preLaunchTask/postDebugTask to nvim-dap ---This is enabled by default when you call overseer.setup() unless you set `dap = false` @@ -280,7 +98,7 @@ M.enable_dap = function(enabled) end if not dap.listeners.on_config then local log = require("overseer.log") - log:warn("overseer requires a newer version of nvim-dap to enable DAP integration") + log.warn("overseer requires a newer version of nvim-dap to enable DAP integration") return end if enabled then @@ -298,32 +116,52 @@ M.enable_dap = function(enabled) end ---Initialize overseer ----@param opts overseer.Config|nil Configuration options +---@param opts overseer.SetupOpts|nil Configuration options M.setup = function(opts) - if vim.fn.has("nvim-0.8") == 0 then - vim.notify_once( - "overseer has dropped support for Neovim <0.8. Please use the nvim-0.7 branch or upgrade Neovim", - vim.log.levels.ERROR - ) + opts = opts or {} + if not M.private_setup() then return end - opts = opts or {} - create_commands() - M.enable_dap(opts.dap) - pending_opts = opts - if initialized then - do_setup() + local config = require("overseer.config") + config.setup(opts) + M.enable_dap(config.dap) + M.wrap_builtins(config.experimental_wrap_builtins.enabled) +end + +local function create_highlights() + for _, hl in ipairs(M.get_all_highlights()) do + vim.api.nvim_set_hl(0, hl.name, { link = hl.default, default = true }) end end ----Add a callback to run after overseer lazy setup ----@param callback fun() -M.on_setup = function(callback) - if initialized then - callback() - else - table.insert(setup_callbacks, callback) +local did_setup = false +---@private +---@return boolean +M.private_setup = function() + if vim.fn.has("nvim-0.11") == 0 then + vim.notify_once( + "overseer has dropped support for Neovim <0.11. Please use a different branch or upgrade Neovim", + vim.log.levels.ERROR + ) + return false end + + if did_setup then + return true + end + did_setup = true + + create_commands() + M.wrap_builtins() + create_highlights() + local aug = vim.api.nvim_create_augroup("Overseer", {}) + vim.api.nvim_create_autocmd("ColorScheme", { + pattern = "*", + group = aug, + desc = "Update Overseer highlights", + callback = create_highlights, + }) + return true end ---Create a new Task @@ -331,89 +169,111 @@ end ---@return overseer.Task ---@example --- local task = overseer.new_task({ ---- cmd = { "./build.sh" }, ---- args = { "all" }, +--- cmd = { "./build.sh", "all" }, --- components = { { "on_output_quickfix", open = true }, "default" } --- }) --- task:start() -M.new_task = lazy("task", "new") +M.new_task = function(opts) + ---@diagnostic disable-next-line: invisible + local data = opts.from_template + if data then + local template = require("overseer.template") + local tmpl + local done = false + template.get_by_name(data.name, data.search, function(t) + tmpl = t + done = true + end) + vim.wait(2000, function() + return done + end) + if not tmpl then + error(string.format("Could not find template '%s'", data.name)) + end + local task + done = false + local build_opts = { + params = data.params, + env = data.env, + cwd = opts.cwd, + search = data.search, + disallow_prompt = true, + } + template.build_task(tmpl, build_opts, function(_, t) + done = true + task = t + end) + vim.wait(500, function() + return done + end) + if not task then + error(string.format("Error building task from template '%s'", data.name)) + end + return task + else + return require("overseer.task").new(opts) + end +end ---Open or close the task list ---@param opts nil|overseer.WindowOpts -M.toggle = lazy("window", "toggle") +M.toggle = function(opts) + return require("overseer.window").toggle(opts) +end ---Open the task list ---@param opts nil|overseer.WindowOpts ---- enter boolean|nil If false, stay in current window. Default true ---- direction nil|"left"|"right" Which direction to open the task list -M.open = lazy("window", "open") +M.open = function(opts) + return require("overseer.window").open(opts) +end ---Close the task list -M.close = lazy("window", "close") - ----Get the list of saved task bundles ----@return string[] Names of task bundles -M.list_task_bundles = lazy("task_bundle", "list_task_bundles") ----Load tasks from a saved bundle ----@param name nil|string ----@param opts nil|table ---- ignore_missing nil|boolean When true, don't notify if bundle doesn't exist ---- autostart nil|boolean When true, start the tasks after loading (default true) -M.load_task_bundle = lazy("task_bundle", "load_task_bundle") ----Save tasks to a bundle on disk ----@param name string|nil Name of bundle. If nil, will prompt user. ----@param tasks nil|overseer.Task[] Specific tasks to save. If nil, uses config.bundles.save_task_opts ----@param opts table|nil ---- on_conflict nil|"overwrite"|"append"|"cancel" -M.save_task_bundle = lazy("task_bundle", "save_task_bundle") ----Delete a saved task bundle ----@param name string|nil -M.delete_task_bundle = lazy("task_bundle", "delete_task_bundle") +M.close = function() + return require("overseer.window").close() +end ---List all tasks ---@param opts nil|overseer.ListTaskOpts ---@return overseer.Task[] -M.list_tasks = lazy("task_list", "list_tasks") +M.list_tasks = function(opts) + return require("overseer.task_list").list_tasks(opts) +end ---Run a task from a template ---@param opts overseer.TemplateRunOpts ---@param callback nil|fun(task: overseer.Task|nil, err: string|nil) ----@note ---- The prompt option will control when the user is presented a popup dialog to input template ---- parameters. The possible values are: ---- always Show when template has any params ---- missing Show when template has any params not explicitly passed in ---- allow Only show when a required param is missing ---- avoid Only show when a required param with no default value is missing ---- never Never show prompt (error if required param missing) ---- The default is controlled by the default_template_prompt config option. ---@example --- -- Run the task named "make all" --- -- equivalent to :OverseerRun make\ all ---- overseer.run_template({name = "make all"}) +--- overseer.run_task({name = "make all"}) --- -- Run the default "build" task --- -- equivalent to :OverseerRun BUILD ---- overseer.run_template({tags = {overseer.TAG.BUILD}}) +--- overseer.run_task({tags = {overseer.TAG.BUILD}}) --- -- Run the task named "serve" with some default parameters ---- overseer.run_template({name = "serve", params = {port = 8080}}) +--- overseer.run_task({name = "serve", params = {port = 8080}}) --- -- Create a task but do not start it ---- overseer.run_template({name = "make", autostart = false}, function(task) +--- overseer.run_task({name = "make", autostart = false}, function(task) --- -- do something with the task --- end) --- -- Run a task and immediately open the floating window ---- overseer.run_template({name = "make"}, function(task) +--- overseer.run_task({name = "make"}, function(task) --- if task then --- overseer.run_action(task, 'open float') --- end --- end) ---- -- Run a task and always show the parameter prompt ---- overseer.run_template({name = "npm watch", prompt = "always"}) -M.run_template = lazy("commands", "run_template") +M.run_task = function(opts, callback) + return require("overseer.commands").run_template(opts, callback) +end + +---Use overseer.run_task +---@deprecated +M.run_template = function(opts, callback) + vim.deprecate("overseer.run_template", "overseer.run_task", "2026-01-01", "overseer.nvim") + return M.run_task(opts, callback) +end ----Preload templates for run_template ----@param opts nil|table ---- dir string ---- ft nil|string ----@param cb nil|fun() Called when preloading is complete +---Preload templates for run_task +---@param opts? overseer.SearchParams +---@param cb? fun() Called when preloading is complete ---@note --- Typically this would be done to prevent a long wait time for :OverseerRun when using a slow --- template provider. @@ -423,78 +283,24 @@ M.run_template = lazy("commands", "run_template") --- local cwd = vim.v.cwd or vim.fn.getcwd() --- require("overseer").preload_task_cache({ dir = cwd }) --- }) -M.preload_task_cache = lazy("commands", "preload_cache") ----Clear cached templates for run_template ----@param opts nil|table ---- dir string ---- ft nil|string -M.clear_task_cache = lazy("commands", "clear_cache") +M.preload_task_cache = function(opts, cb) + return require("overseer.commands").preload_cache(opts, cb) +end +---Clear cached templates for run_task +---@param opts? overseer.SearchParams +M.clear_task_cache = function(opts) + return require("overseer.commands").clear_cache(opts) +end ---Run an action on a task ---@param task overseer.Task ----@param name string|nil Name of action. When omitted, prompt user to pick. -M.run_action = lazy("action_util", "run_task_action") - ----Create a new template by overriding fields on another ----@param base overseer.TemplateFileDefinition The base template definition to wrap ----@param override nil|table Override any fields on the base ----@param default_params nil|table Provide default values for any parameters on the base ----@return overseer.TemplateFileDefinition ----@note ---- This is typically used for a TemplateProvider, to define the task a single time and generate ---- multiple templates based on the available args. ----@example ---- local tmpl = { ---- params = { ---- args = { type = 'list', delimiter = ' ' } ---- }, ---- builder = function(params) ---- return { ---- cmd = { 'make' }, ---- args = params.args, ---- } ---- } ---- local template_provider = { ---- name = "Some provider", ---- generator = function(opts, cb) ---- cb({ ---- overseer.wrap_template(tmpl, nil, { args = { 'all' } }), ---- overseer.wrap_template(tmpl, {name = 'make clean'}, { args = { 'clean' } }), ---- }) ---- end ---- } -M.wrap_template = function(base, override, default_params) - override = override or {} - if default_params then - local base_params = base.params - if type(base_params) == "function" then - override.params = function() - local params = base_params() - for k, v in pairs(default_params) do - params[k].default = v - params[k].optional = true - end - return params - end - else - override.params = vim.deepcopy(base_params or {}) - for k, v in pairs(default_params) do - override.params[k].default = v - override.params[k].optional = true - end - end - end - setmetatable(override, { __index = base }) - ---@cast override overseer.TemplateFileDefinition - return override +---@param name? string Name of action. When omitted, prompt user to pick. +M.run_action = function(task, name) + return require("overseer.action_util").run_task_action(task, name) end ---Add a hook that runs on a TaskDefinition before the task is created ---@param opts nil|overseer.HookOptions When nil, run the hook on all templates ---- name nil|string Only run if the template name matches this pattern (using string.match) ---- module nil|string Only run if the template module matches this pattern (using string.match) ---- filetype nil|string|string[] Only run if the current file is one of these filetypes ---- dir nil|string|string[] Only run if inside one of these directories ---@param hook fun(task_defn: overseer.TaskDefinition, util: overseer.TaskUtil) ---@example --- -- Add on_output_quickfix component to all "cargo" templates @@ -511,7 +317,9 @@ end --- GO111MODULE = "on" --- }) --- end) -M.add_template_hook = lazy_pend("template", "add_hook_template") +M.add_template_hook = function(opts, hook) + require("overseer.template").add_hook_template(opts, hook) +end ---Remove a hook that was added with add_template_hook ---@param opts nil|overseer.HookOptions Same as for add_template_hook ---@param hook fun(task_defn: overseer.TaskDefinition, util: overseer.TaskUtil) @@ -523,7 +331,9 @@ M.add_template_hook = lazy_pend("template", "add_hook_template") --- overseer.add_template_hook(opts, hook) --- -- Remove should pass in the same opts as add --- overseer.remove_template_hook(opts, hook) -M.remove_template_hook = lazy_pend("template", "remove_hook_template") +M.remove_template_hook = function(opts, hook) + require("overseer.template").remove_hook_template(opts, hook) +end ---Directly register an overseer template ---@param defn overseer.TemplateDefinition|overseer.TemplateProvider @@ -536,67 +346,122 @@ M.remove_template_hook = lazy_pend("template", "remove_hook_template") --- } --- end, --- }) -M.register_template = lazy_pend("template", "register") ----Load a template definition from its module location ----@param name string ----@example ---- -- This will load the template in lua/overseer/template/mytask.lua ---- overseer.load_template('mytask') -M.load_template = lazy_pend("template", "load_template") - ----Open a tab with windows laid out for debugging a parser -M.debug_parser = lazy("parser.debug", "start_debug_session") +M.register_template = function(defn) + require("overseer.template").register(defn) +end ---Register a new component alias. ---@param name string ---@param components overseer.Serialized[] +---@param override? boolean When true, override any existing alias with the same name ---@note --- This is intended to be used by plugin authors that wish to build on top of overseer. They do not --- have control over the call to overseer.setup(), so this provides an alternative method of --- setting a component alias that they can then use when creating tasks. ---@example --- require("overseer").register_alias("my_plugin", { "default", "on_output_quickfix" }) -M.register_alias = lazy("component", "alias") +M.register_alias = function(name, components, override) + return require("overseer.component").alias(name, components, override) +end + +---Set a window to display the output of a dynamically-chosen task +---@param winid? integer The window to use for displaying the task output +---@param opts? overseer.TaskViewOpts +---@example +--- -- Always show the output from the most recent Neotest task in this window. +--- -- Close it automatically when all test tasks are disposed. +--- overseer.create_task_output_view(0, { +--- select = function(self, tasks, task_under_cursor) +--- for _, task in ipairs(tasks) do +--- if task.metadata.neotest_group_id then +--- return task +--- end +--- end +--- self:dispose() +--- end, +--- }) +M.create_task_output_view = function(winid, opts) + require("overseer.task_view").new(winid, opts) +end --- Used for vim-session integration. -local timer_active = false +---@param cmd string|string[] +---@param opts? table +---@return any +local wrapped_jobstart = function(cmd, opts) + local config = require("overseer.config") + local util = require("overseer.util") + local caller = util.get_caller() + -- TODO wrapping jobstart in a fast event is difficult because we call a lot of unsafe APIs + if vim.in_fast_event() or not config.experimental_wrap_builtins.condition(cmd, caller, opts) then + return M.builtin.jobstart(cmd, opts) + end + opts = opts or {} + local task = M.new_task({ + cmd = cmd, + cwd = opts.cwd, + env = opts.env, + source = caller, + ephemeral = true, + strategy = { "jobstart", wrap_opts = opts }, + components = { "default_builtin" }, + }) + task:start() + ---@diagnostic disable-next-line: invisible + local strat = task.strategy + ---@cast strat overseer.JobstartStrategy + return strat.job_id +end +---@param cmd string[] +---@param opts? vim.SystemOpts +---@param on_exit? fun(out: vim.SystemCompleted) +---@return vim.SystemObj +local wrapped_system = function(cmd, opts, on_exit) + local config = require("overseer.config") + local util = require("overseer.util") + local caller = util.get_caller() + -- TODO wrapping vim.system in a fast event is difficult because we call a lot of unsafe APIs + if vim.in_fast_event() or not config.experimental_wrap_builtins.condition(cmd, caller, opts) then + return M.builtin.system(cmd, opts, on_exit) + end + opts = opts or {} + local task = M.new_task({ + cmd = cmd, + cwd = opts.cwd, + ---@diagnostic disable-next-line: assign-type-mismatch + env = opts.env, + source = caller, + ephemeral = true, + strategy = { "system", wrap_opts = opts, wrap_exit = on_exit }, + components = { "default_builtin" }, + }) + task:start() + ---@diagnostic disable-next-line: invisible + local strat = task.strategy + ---@cast strat overseer.SystemStrategy + return strat.handle +end + +local patched = false +---Hook vim.system and vim.fn.jobstart to display tasks in overseer ---@private -M._start_tasks = function(str) - -- HACK for some reason vim-session fires SessionSavePre multiple times, which - -- can lead to multiple 'load' lines in the same session file. We need to make - -- sure we only take the first one. - if timer_active then +---@param enabled? boolean +M.wrap_builtins = function(enabled) + if enabled == nil then + enabled = true + end + if patched == enabled then return end - timer_active = true - vim.defer_fn(function() - ---@type any - local data = vim.json.decode(str) - for _, params in ipairs(data) do - local task = M.new_task(params) - task:start() - end - timer_active = false - end, 100) -end + patched = enabled -setmetatable(M, { - __index = function(t, key) - local ok, val = pcall(require, string.format("overseer.%s", key)) - if ok then - rawset(t, key, val) - return val - else - -- allow top-level direct access to constants (e.g. overseer.STATUS) - local constants = require("overseer.constants") - if constants[key] then - rawset(t, key, constants[key]) - return constants[key] - end - error(string.format("Error requiring overseer.%s: %s", key, val)) - end - end, -}) + if patched then + vim.fn.jobstart = wrapped_jobstart + vim.system = wrapped_system + else + vim.fn.jobstart = M.builtin.jobstart + vim.system = M.builtin.system + end +end ---Used for documentation generation ---@private @@ -616,12 +481,10 @@ end ---Used for documentation generation ---@private M.get_all_highlights = function() - local util = require("overseer.util") - local success_color = util.find_success_color() return { { name = "OverseerPENDING", default = "Normal", desc = "Pending tasks" }, { name = "OverseerRUNNING", default = "Constant", desc = "Running tasks" }, - { name = "OverseerSUCCESS", default = success_color, desc = "Succeeded tasks" }, + { name = "OverseerSUCCESS", default = "DiagnosticOk", desc = "Succeeded tasks" }, { name = "OverseerCANCELED", default = "DiagnosticWarn", desc = "Canceled tasks" }, { name = "OverseerFAILURE", default = "DiagnosticError", desc = "Failed tasks" }, { name = "OverseerDISPOSED", default = "Comment" }, @@ -649,4 +512,16 @@ M.get_all_highlights = function() } end +setmetatable(M, { + __index = function(t, key) + -- allow top-level direct access to constants (e.g. overseer.STATUS) + local constants = require("overseer.constants") + if constants[key] then + rawset(t, key, constants[key]) + return constants[key] + end + return rawget(t, key) + end, +}) + return M diff --git a/lua/overseer/keymap_util.lua b/lua/overseer/keymap_util.lua new file mode 100644 index 00000000..76b27a52 --- /dev/null +++ b/lua/overseer/keymap_util.lua @@ -0,0 +1,166 @@ +local keymaps = require("overseer.task_list.keymaps") +local layout = require("overseer.layout") +local util = require("overseer.util") + +local M = {} + +---@param rhs string|table|fun() +---@return string|fun() rhs +---@return table opts +---@return string|nil mode +local function resolve(rhs) + if type(rhs) == "string" and vim.startswith(rhs, "keymap.") then + local action_name = vim.split(rhs, ".", { plain = true })[2] + local action = keymaps[action_name] + if not action then + vim.notify("[overseer.nvim] Unknown action name: " .. action_name, vim.log.levels.ERROR) + end + return resolve(action) + elseif type(rhs) == "table" then + local opts = vim.deepcopy(rhs) + -- We support passing in a `callback` key, or using the 1 index as the rhs of the keymap + local callback, parent_opts = resolve(opts.callback or opts[1]) + + -- Fall back to the parent desc, adding the opts as a string if it exists + if parent_opts.desc and not opts.desc then + if opts.opts then + opts.desc = + string.format("%s %s", parent_opts.desc, vim.inspect(opts.opts):gsub("%s+", " ")) + else + opts.desc = parent_opts.desc + end + end + + local mode = opts.mode + if type(rhs.callback) == "string" then + local action_opts, action_mode + callback, action_opts, action_mode = resolve(rhs.callback) + opts = vim.tbl_extend("keep", opts, action_opts) + mode = mode or action_mode + end + + -- remove all the keys that we can't pass as options to `vim.keymap.set` + opts.callback = nil + opts.mode = nil + opts[1] = nil + opts.deprecated = nil + opts.parameters = nil + + if opts.opts and type(callback) == "function" then + local callback_args = opts.opts + opts.opts = nil + local orig_callback = callback + callback = function() + ---@diagnostic disable-next-line: redundant-parameter + orig_callback(callback_args) + end + end + + return callback, opts, mode + else + return rhs, {} + end +end + +---@param new_keymaps table +---@param bufnr integer +M.set_keymaps = function(new_keymaps, bufnr) + for k, v in pairs(new_keymaps) do + local rhs, opts, mode = resolve(v) + if rhs then + vim.keymap.set(mode or "", k, rhs, vim.tbl_extend("keep", { buffer = bufnr }, opts)) + end + end +end + +---@param maps table +M.show_help = function(maps) + local rhs_to_lhs = {} + local lhs_to_all_lhs = {} + for k, rhs in pairs(maps) do + if rhs then + if rhs_to_lhs[rhs] then + local first_lhs = rhs_to_lhs[rhs] + table.insert(lhs_to_all_lhs[first_lhs], k) + else + rhs_to_lhs[rhs] = k + lhs_to_all_lhs[k] = { k } + end + end + end + + local max_lhs = 1 + local keymap_entries = {} + for k, rhs in pairs(maps) do + local all_lhs = lhs_to_all_lhs[k] + if all_lhs then + local _, opts = resolve(rhs) + local keystr = table.concat(all_lhs, "/") + max_lhs = math.max(max_lhs, vim.api.nvim_strwidth(keystr)) + table.insert(keymap_entries, { str = keystr, all_lhs = all_lhs, desc = opts.desc or "" }) + end + end + table.sort(keymap_entries, function(a, b) + return a.desc < b.desc + end) + + local lines = {} + local highlights = {} + local max_line = 1 + for _, entry in ipairs(keymap_entries) do + local line = string.format(" %s %s", util.align(entry.str, max_lhs, "left"), entry.desc) + max_line = math.max(max_line, vim.api.nvim_strwidth(line)) + table.insert(lines, line) + local start = 1 + for _, key in ipairs(entry.all_lhs) do + local keywidth = vim.api.nvim_strwidth(key) + table.insert(highlights, { "Special", #lines, start, start + keywidth }) + start = start + keywidth + 1 + end + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines) + local ns = vim.api.nvim_create_namespace("Overseer") + for _, hl in ipairs(highlights) do + local hl_group, lnum, start_col, end_col = unpack(hl) + vim.api.nvim_buf_set_extmark(bufnr, ns, lnum - 1, start_col, { + end_col = end_col, + hl_group = hl_group, + }) + end + vim.keymap.set("n", "q", "close", { buffer = bufnr }) + vim.keymap.set("n", "", "close", { buffer = bufnr }) + vim.bo[bufnr].modifiable = false + vim.bo[bufnr].bufhidden = "wipe" + + local editor_width = vim.o.columns + local editor_height = layout.get_editor_height() + local winid = vim.api.nvim_open_win(bufnr, true, { + relative = "editor", + row = math.max(0, (editor_height - #lines) / 2), + col = math.max(0, (editor_width - max_line - 1) / 2), + width = math.min(editor_width, max_line + 1), + height = math.min(editor_height, #lines), + zindex = 150, + style = "minimal", + }) + local function close() + if vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_close(winid, true) + end + end + vim.api.nvim_create_autocmd("BufLeave", { + callback = close, + once = true, + nested = true, + buffer = bufnr, + }) + vim.api.nvim_create_autocmd("WinLeave", { + callback = close, + once = true, + nested = true, + }) +end + +return M diff --git a/lua/overseer/layout.lua b/lua/overseer/layout.lua index 8ac50a45..8d859e6d 100644 --- a/lua/overseer/layout.lua +++ b/lua/overseer/layout.lua @@ -1,11 +1,17 @@ local config = require("overseer.config") local M = {} +---@param value number +---@return boolean local function is_float(value) local _, p = math.modf(value) return p ~= 0 end +---@generic T +---@param value T +---@param max_value integer +---@return T local function calc_float(value, max_value) if value and is_float(value) then return math.min(max_value, value * max_value) @@ -14,10 +20,12 @@ local function calc_float(value, max_value) end end +---@return integer M.get_editor_width = function() return vim.o.columns end +---@return integer M.get_editor_height = function() local editor_height = vim.o.lines - vim.o.cmdheight -- Subtract 1 if tabline is visible @@ -33,6 +41,11 @@ M.get_editor_height = function() return editor_height end +---@param values? number|number[] +---@param max_value? integer +---@param aggregator fun(a: number, b: number): number +---@param limit number +---@return nil|integer local function calc_list(values, max_value, aggregator, limit) local ret = limit if not max_value or not values then @@ -48,6 +61,12 @@ local function calc_list(values, max_value, aggregator, limit) return ret end +---@param desired_size? integer +---@param exact_size? integer +---@param min_size? number|number[] +---@param max_size? number|number[] +---@param total_size integer +---@return integer local function calculate_dim(desired_size, exact_size, min_size, max_size, total_size) local ret = calc_float(exact_size, total_size) local min_val = calc_list(min_size, total_size, math.max, 1) @@ -72,6 +91,17 @@ local function calculate_dim(desired_size, exact_size, min_size, max_size, total return math.floor(ret) end +---@class (exact) overseer.LayoutOpts +---@field width? number +---@field height? number +---@field min_width? number|number[] +---@field max_width? number|number[] +---@field min_height? number|number[] +---@field max_height? number|number[] + +---@param desired_width? integer +---@param opts overseer.LayoutOpts +---@return integer M.calculate_width = function(desired_width, opts) return calculate_dim( desired_width, @@ -82,6 +112,9 @@ M.calculate_width = function(desired_width, opts) ) end +---@param desired_height? integer +---@param opts overseer.LayoutOpts +---@return integer M.calculate_height = function(desired_height, opts) return calculate_dim( desired_height, @@ -106,7 +139,6 @@ M.open_fullscreen_float = function(bufnr) col = col, width = width, height = height, - border = conf.border, zindex = conf.zindex, style = "minimal", }) diff --git a/lua/overseer/log.lua b/lua/overseer/log.lua index 0a81901d..b70d5a34 100644 --- a/lua/overseer/log.lua +++ b/lua/overseer/log.lua @@ -1,34 +1,30 @@ -local files = require("overseer.files") -local LogHandler = {} +local config = require("overseer.config") local levels_reverse = {} for k, v in pairs(vim.log.levels) do levels_reverse[v] = k end -function LogHandler.new(opts) - vim.validate({ - type = { opts.type, "s" }, - handle = { opts.handle, "f" }, - formatter = { opts.formatter, "f" }, - level = { opts.level, "n", true }, - }) - return setmetatable({ - type = opts.type, - handle = opts.handle, - formatter = opts.formatter, - level = opts.level or vim.log.levels.INFO, - }, { __index = LogHandler }) -end +local Log = {} + +---@type integer +Log.level = vim.log.levels.WARN -function LogHandler:log(level, msg, ...) - if self.level <= level then - local text = self.formatter(level, msg, ...) - self.handle(level, text) +---@return string +Log.get_logfile = function() + local ok, stdpath = pcall(vim.fn.stdpath, "log") + if not ok then + stdpath = vim.fn.stdpath("cache") end + assert(type(stdpath) == "string") + return vim.fs.joinpath(stdpath, "overseer.log") end -local function default_formatter(level, msg, ...) +---@param level integer +---@param msg string +---@param ... any[] +---@return string +local function format(level, msg, ...) local args = vim.F.pack_len(...) for i = 1, args.n do local v = args[i] @@ -39,165 +35,97 @@ local function default_formatter(level, msg, ...) end end local ok, text = pcall(string.format, msg, vim.F.unpack_len(args)) + local timestr = vim.fn.strftime("%Y-%m-%d %H:%M:%S") if ok then local str_level = levels_reverse[level] - return string.format("[%s] %s", str_level, text) + return string.format("%s[%s] %s", timestr, str_level, text) else - return string.format("[ERROR] error formatting log line: '%s' args %s", msg, vim.inspect(args)) + return string.format( + "%s[ERROR] error formatting log line: '%s' args %s", + timestr, + vim.inspect(msg), + vim.inspect(args) + ) end end -local function create_file_handler(opts) - vim.validate({ - filename = { opts.filename, "s" }, - }) - local ok, stdpath = pcall(vim.fn.stdpath, "log") - if not ok then - stdpath = vim.fn.stdpath("cache") +---@param line string +local function write(line) + -- This will be replaced during initialization +end + +local initialized = false +local function initialize() + if initialized then + return + end + initialized = true + local filepath = Log.get_logfile() + + local stat = vim.uv.fs_stat(filepath) + if stat and stat.size > 10 * 1024 * 1024 then + local backup = filepath .. ".1" + vim.uv.fs_unlink(backup) + vim.uv.fs_rename(filepath, backup) end - local filepath = files.join(stdpath, opts.filename) + + local parent = vim.fs.dirname(filepath) + vim.fn.mkdir(parent, "p") + local logfile, openerr = io.open(filepath, "a+") if not logfile then - local err_msg = string.format("Failed to open Overseer log file: %s", openerr) + local err_msg = string.format("Failed to open overseer.nvim log file: %s", openerr) vim.notify(err_msg, vim.log.levels.ERROR) - opts.handle = function() end else - opts.handle = function(level, text) - logfile:write(text) + write = function(line) + logfile:write(line) logfile:write("\n") logfile:flush() end end - return LogHandler.new(opts) -end - -local function create_notify_handler(opts) - opts.handle = function(level, text) - vim.notify(text, level) - end - return LogHandler.new(opts) end -local function create_echo_handler(opts) - opts.handle = function(level, text) - local hl = "Normal" - if level == vim.log.levels.ERROR then - hl = "DiagnosticError" - elseif level == vim.log.levels.WARN then - hl = "DiagnosticWarn" +---Override the file handler e.g. for tests +---@param handler fun(line: string) +function Log.set_handler(handler) + write = handler + initialized = true +end + +function Log.log(level, msg, ...) + if config.log_level <= level then + initialize() + if vim.in_fast_event() then + local splat = vim.F.pack_len(...) + vim.schedule(function() + local text = format(level, msg, vim.F.unpack_len(splat)) + write(text) + end) + else + local text = format(level, msg, ...) + write(text) end - vim.api.nvim_echo({ { text, hl } }, true, {}) - end - return LogHandler.new(opts) -end - -local function create_null_handler() - return LogHandler.new({ - formatter = function() end, - handle = function() end, - }) -end - -local function create_handler(opts) - vim.validate({ - type = { opts.type, "s" }, - }) - if not opts.formatter then - opts.formatter = default_formatter - end - if opts.type == "file" then - return create_file_handler(opts) - elseif opts.type == "notify" then - return create_notify_handler(opts) - elseif opts.type == "echo" then - return create_echo_handler(opts) - else - vim.notify(string.format("Unknown log handler %s", opts.type), vim.log.levels.ERROR) - return create_null_handler() - end -end - -local Log = {} - -function Log.new(opts) - vim.validate({ - handlers = { opts.handlers, "t" }, - level = { opts.level, "n", true }, - }) - local handlers = {} - for _, defn in ipairs(opts.handlers) do - table.insert(handlers, create_handler(defn)) - end - local log = setmetatable({ - handlers = handlers, - }, { __index = Log }) - if opts.level then - log:set_level(opts.level) - end - return log -end - -function Log:set_level(level) - for _, handler in ipairs(self.handlers) do - handler.level = level end end -function Log:get_handlers() - return self.handlers +function Log.trace(...) + Log.log(vim.log.levels.TRACE, ...) end -function Log:log(level, msg, ...) - for _, handler in ipairs(self.handlers) do - handler:log(level, msg, ...) - end +function Log.debug(...) + Log.log(vim.log.levels.DEBUG, ...) end -function Log:trace(...) - self:log(vim.log.levels.TRACE, ...) +function Log.info(...) + Log.log(vim.log.levels.INFO, ...) end -function Log:debug(...) - self:log(vim.log.levels.DEBUG, ...) +function Log.warn(...) + Log.log(vim.log.levels.WARN, ...) end -function Log:info(...) - self:log(vim.log.levels.INFO, ...) +function Log.error(...) + Log.log(vim.log.levels.ERROR, ...) end -function Log:warn(...) - self:log(vim.log.levels.WARN, ...) -end - -function Log:error(...) - self:log(vim.log.levels.ERROR, ...) -end - -local root = Log.new({ - handlers = { - { - type = "echo", - level = vim.log.levels.WARN, - }, - }, -}) - -local M = {} - -M.new = Log.new - -M.set_root = function(logger) - root = logger -end - -M.get_root = function() - return root -end - -setmetatable(M, { - __index = function(_, key) - return root[key] - end, -}) - -return M +return Log diff --git a/lua/overseer/notifier.lua b/lua/overseer/notifier.lua index 13e920e1..5ce98c13 100644 --- a/lua/overseer/notifier.lua +++ b/lua/overseer/notifier.lua @@ -1,12 +1,38 @@ local files = require("overseer.files") local log = require("overseer.log") +local overseer = require("overseer") + local Notifier = { focused = true } ---@class overseer.NotifierParams ---@field system "always"|"never"|"unfocused" +local initialized = false +local function create_autocmds() + if initialized then + return + end + initialized = true + local aug = vim.api.nvim_create_augroup("Overseer", { clear = false }) + vim.api.nvim_create_autocmd("FocusGained", { + desc = "Track editor focus for overseer", + group = aug, + callback = function() + Notifier.focused = true + end, + }) + vim.api.nvim_create_autocmd("FocusLost", { + desc = "Track editor focus for overseer", + group = aug, + callback = function() + Notifier.focused = false + end, + }) +end + ---@param opts? overseer.NotifierParams function Notifier.new(opts) + create_autocmds() opts = vim.tbl_deep_extend("keep", opts or {}, { system = "never", }) @@ -15,37 +41,29 @@ function Notifier.new(opts) end local function system_notify(message, level) - local job_id if files.is_windows then -- TODO - log:warn("System notifications are not supported on Windows yet") + log.warn("System notifications are not supported on Windows yet") return elseif files.is_mac then local cmd = { "osascript", "-e", - string.format('display notification "%s" with title "%s"', "Overseer task complete", message), + string.format('display notification "Overseer task complete" with title "%s"', message), } if vim.fn.executable("reattach-to-user-namespace") == 1 then table.insert(cmd, 1, "reattach-to-user-namespace") end - job_id = vim.fn.jobstart(cmd, { - stdin = "null", - }) + overseer.builtin.system(cmd, {}) else local urgency = level == vim.log.levels.INFO and "normal" or "critical" - job_id = vim.fn.jobstart({ + overseer.builtin.system({ "notify-send", "-u", urgency, "Overseer task complete", message, - }, { - stdin = "null", - }) - end - if job_id <= 0 then - log:warn("Error performing system notification") + }, {}) end end diff --git a/lua/overseer/parselib.lua b/lua/overseer/parselib.lua new file mode 100644 index 00000000..d9253fbd --- /dev/null +++ b/lua/overseer/parselib.lua @@ -0,0 +1,311 @@ +local M = {} + +---@class (exact) overseer.OutputParser +---@field parse fun(self: overseer.OutputParser, line: string) Called repeatedly with each line of output +---@field get_result fun(self: overseer.OutputParser): table Mapping of result keys to parsed values. Usually contains a "diagnostics" key with a list of quickfix entries. +---@field reset fun(self: overseer.OutputParser) Reset the parser to its initial state +---@field result_version? number For background parsers only, this number should be bumped to indicate that the task should set the result while the process is still running + +---@alias overseer.TestFn fun(line: string): boolean +---@alias overseer.ParseFn fun(line: string): nil|vim.quickfix.entry +---@alias overseer.MatchFn fun(line: string): nil|string[] + +---@alias overseer.FieldProcessor fun(match: string, item: vim.quickfix.entry, field: string): any +---@alias overseer.ParseFieldWithConversion {[1]: string, [2]: overseer.FieldProcessor} + +---@alias overseer.ParseField string|overseer.ParseFieldWithConversion + +---@type overseer.FieldProcessor +local function default_postprocess_field(value, _, field) + if value:match("^%d+$") then + return tonumber(value) + elseif field == "type" then + return value:upper():match("^%w") + else + return value + end +end + +---Create a match function from a lua pattern +---@param pattern string lua pattern +---@return overseer.MatchFn +---@example +--- local match_fn = parselib.make_lua_match_fn("^(%S+):(%d+):(%d+): (.+)$") +--- local parse_fn = parselib.make_parse_fn(match_fn, {"filename", "lnum", "col", "text"}) +--- local parser = parselib.make_parser(parse_fn) +M.make_lua_match_fn = function(pattern) + return function(line) + local ret = { line:match(pattern) } + if vim.tbl_isempty(ret) then + return nil + end + return ret + end +end + +---Create a test function (returns true/false) from a lua pattern +---@param pattern string lua pattern +---@return overseer.TestFn +---@example +--- local test_fn = parselib.make_lua_test_fn("^File change detected") +M.make_lua_test_fn = function(pattern) + return function(line) + local matched = line:match(pattern) + return matched ~= nil + end +end + +---Create a match function from a vim regex +---@param pattern string vim regex, passed to vim.fn.matchlist +---@return overseer.MatchFn +---@example +--- local match_fn = parselib.make_regex_match_fn("\\v^(\\S+):(\\d+):(\\d+): (.+)$") +--- local parse_fn = parselib.make_parse_fn(match_fn, {"filename", "lnum", "col", "text"}) +--- local parser = parselib.make_parser(parse_fn) +M.make_regex_match_fn = function(pattern) + return function(line) + local result = vim.fn.matchlist(line, pattern) + if vim.tbl_isempty(result) then + return nil + end + table.remove(result, 1) + -- matchlist() will use "" if an optional submatch does not match, and it also throws a + -- bunch of "" on the end of the list just for funzies. + for i, v in ipairs(result) do + if v == "" then + result[i] = nil + end + end + return result + end +end + +---Create a test function (returns true/false) from a match function +---@param match overseer.MatchFn function that parses a line into a list of values +---@return overseer.TestFn +---@example +--- local match_fn = parselib.make_lua_match_fn("^(%S+):(%d+):(%d+): (.+)$") +--- local test_fn = parselib.match_to_test_fn(match_fn) +M.match_to_test_fn = function(match) + return function(line) + local ret = match(line) + return ret ~= nil + end +end + +---Create a function that parses a line into a quickfix entry +---@param match overseer.MatchFn function that parses a line into a list of values +---@param fields overseer.ParseField[] list of field names, or {field_name, postprocess_fn} tuples +---@return overseer.ParseFn +---@example +--- local match_fn = parselib.make_lua_match_fn("^(%S+):(%d+):(%d+): (.+)$") +--- local parse_fn = parselib.make_parse_fn(match_fn, {"filename", "lnum", "col", "text"}) +--- local parser = parselib.make_parser(parse_fn) +M.make_parse_fn = function(match, fields) + return function(line) + local result = match(line) + if not result then + return nil + end + local item + for i, field in ipairs(fields) do + if result[i] then + if not item then + item = {} + end + local key, postprocess + if type(field) == "table" then + key, postprocess = field[1], field[2] + else + key = field + end + if not postprocess then + postprocess = default_postprocess_field + end + if key ~= "_" then + item[key] = postprocess(result[i], item, key) + end + end + end + return item + end +end + +---Create a parser from a vim errorformat +---@param errorformat string vim errorformat string +---@return overseer.OutputParser +---@example +--- local parser = parselib.parser_from_errorformat("%f:%l: %m") +M.parser_from_errorformat = function(errorformat) + local result = {} + local pending_lines = {} + local last_item_pending = false + ---@type overseer.OutputParser + return { + parse = function(_, line) + table.insert(pending_lines, line) + local items = vim.fn.getqflist({ + lines = pending_lines, + efm = errorformat, + }).items + local valid_items = vim.tbl_filter(function(item) + return item.valid == 1 + end, items) + + if #valid_items > 1 then + table.insert(result, valid_items[2]) + last_item_pending = true + pending_lines = { line } + elseif #valid_items == 1 then + if last_item_pending then + result[#result] = valid_items[1] + else + table.insert(result, valid_items[1]) + end + last_item_pending = true + else + last_item_pending = false + pending_lines = {} + end + end, + get_result = function() + return { diagnostics = result } + end, + reset = function() + result = {} + end, + } +end + +---Create a parser from a parse function +---@param parse_fn overseer.ParseFn function that parses a line into a quickfix entry +---@param results_key? string The key to put matches in the results table. defaults to "diagnostics" +---@return overseer.OutputParser +---@example +--- local match_fn = parselib.make_lua_match_fn("^(%S+):(%d+):(%d+): (.+)$") +--- local parse_fn = parselib.make_parse_fn(match_fn, {"filename", "lnum", "col", "text"}) +--- local parser = parselib.make_parser(parse_fn) +M.make_parser = function(parse_fn, results_key) + if not results_key then + results_key = "diagnostics" + end + local result = {} + ---@type overseer.OutputParser + return { + parse = function(_, line) + local item = parse_fn(line) + if item then + table.insert(result, item) + end + end, + get_result = function() + return { [results_key] = result } + end, + reset = function() + result = {} + end, + } +end + +---Combine multiple parsers into a single one (will merge the results) +---@param parsers overseer.OutputParser[] +---@return overseer.OutputParser +---@example +--- local match_fn = parselib.make_lua_match_fn("^(%S+):(%d+):(%d+): (.+)$") +--- local parse_fn = parselib.make_parse_fn(match_fn, {"filename", "lnum", "col", "text"}) +--- local parser1 = parselib.make_parser(parse_fn) +--- local parser2 = parselib.parser_from_errorformat("%f:%l: %m") +--- local combined_parser = parselib.combine_parsers({parser1, parser2}) +M.combine_parsers = function(parsers) + ---@type overseer.OutputParser + return { + result_version = 0, + parse = function(self, line) + local version = 0 + for _, parser in ipairs(parsers) do + parser:parse(line) + if parser.result_version then + version = version + parser.result_version + end + end + self.result_version = version + end, + get_result = function() + local ret = {} + for _, parser in ipairs(parsers) do + local res = parser:get_result() + for k, v in pairs(res) do + if not ret[k] then + ret[k] = v + elseif vim.islist(v) and vim.islist(ret[k]) then + local new_list = vim.list_extend({}, ret[k]) + vim.list_extend(new_list, v) + ret[k] = new_list + elseif type(v) == "table" and type(ret[k]) == "table" then + ret[k] = vim.tbl_deep_extend("force", ret[k], v) + else + ret[k] = v + end + end + end + return ret + end, + reset = function() + for _, parser in ipairs(parsers) do + parser:reset() + end + end, + } +end + +---@class overseer.BackgroundParserOpts +---@field active_on_start? boolean Whether the parser should be active immediately or wait for the start_fn to begin parsing +---@field start_fn? overseer.TestFn Function that tests whether to start parsing +---@field end_fn? overseer.TestFn Function that tests whether to stop parsing + +---Wrap a parser and only activate it in between a matching start and end lines +---@param parser overseer.OutputParser +---@param opts overseer.BackgroundParserOpts +---@return overseer.OutputParser +---@example +--- local base_parser = parselib.parser_from_errorformat("%f:%l: %m") +--- local parser = parselib.wrap_background_parser(base_parser, { +--- start_fn = parselib.make_lua_test_fn("^Starting analysis...$"), +--- end_fn = parselib.make_lua_test_fn("^Analysis complete.$"), +--- }) +M.wrap_background_parser = function(parser, opts) + local is_active = opts.active_on_start + ---@type overseer.OutputParser + return { + result_version = 0, + parse = function(self, line) + if is_active then + if opts.end_fn and opts.end_fn(line) then + self.result_version = self.result_version + 1 + is_active = false + else + parser:parse(line) + end + elseif opts.start_fn and opts.start_fn(line) then + is_active = true + parser:reset() + -- Only bump the version if we have ever had results. + -- If we have previously set results on the task, hitting the start pattern again should + -- clear them immediately. + if self.result_version ~= 0 then + self.result_version = self.result_version + 1 + end + end + end, + get_result = function() + return parser:get_result() + end, + reset = function(self) + self.result_version = 0 + is_active = opts.active_on_start + parser:reset() + end, + } +end + +return M diff --git a/lua/overseer/parser/always.lua b/lua/overseer/parser/always.lua deleted file mode 100644 index 54d2eb79..00000000 --- a/lua/overseer/parser/always.lua +++ /dev/null @@ -1,54 +0,0 @@ -local parser = require("overseer.parser") -local util = require("overseer.parser.util") -local Always = { - desc = "A decorator that always returns SUCCESS", - doc_args = { - { - name = "succeed", - type = "boolean", - desc = "Set to false to always return FAILURE", - default = true, - position_optional = true, - }, - { - name = "child", - type = "parser", - desc = "The child parser node", - }, - }, - examples = { - { - desc = [[An extract node that returns SUCCESS even when it fails]], - code = [[ - {"always", - {"extract", "^([^%s].+):(%d+): (.+)$", "filename", "lnum", "text" } - } -]], - }, - }, -} - -function Always.new(succeed, child) - if child == nil then - child = succeed - succeed = true - end - return setmetatable({ - child = util.hydrate(child), - succeed = succeed, - }, { __index = Always }) -end - -function Always:reset() - self.child:reset() -end - -function Always:ingest(...) - local st = self.child:ingest(...) - if st == parser.STATUS.RUNNING then - return st - end - return self.succeed and parser.STATUS.SUCCESS or parser.STATUS.FAILURE -end - -return Always diff --git a/lua/overseer/parser/append.lua b/lua/overseer/parser/append.lua deleted file mode 100644 index 20a61ae9..00000000 --- a/lua/overseer/parser/append.lua +++ /dev/null @@ -1,39 +0,0 @@ -local parser = require("overseer.parser") -local Append = { - desc = "Append the current item to the results list", - long_desc = "Normally the 'extract' node appends for you, but in cases where you use extract with `append = false`, you can explicitly append without extracting using this node.", - doc_args = { - { - name = "opts", - type = "object", - desc = "Configuration options", - position_optional = true, - fields = { - { - name = "postprocess", - type = "function", - desc = "Call this function to do post-extraction processing on the values", - }, - }, - }, - }, -} - -function Append.new(opts) - opts = opts or {} - return setmetatable({ - postprocess = opts.postprocess, - }, { __index = Append }) -end - -function Append:reset() end - -function Append:ingest(line, ctx) - if self.postprocess then - self.postprocess(ctx.item, ctx) - end - parser.util.append_item(true, line, ctx) - return parser.STATUS.SUCCESS -end - -return Append diff --git a/lua/overseer/parser/debug.lua b/lua/overseer/parser/debug.lua deleted file mode 100644 index caa767a6..00000000 --- a/lua/overseer/parser/debug.lua +++ /dev/null @@ -1,196 +0,0 @@ ----@mod overseer.parser.debug ----Provides an environment for writing and debugging parsers -local files = require("overseer.files") -local parser = require("overseer.parser") -local util = require("overseer.util") -local M = {} - -local source_buf -local output_buf -local input_buf - -local function get_filepath(filename) - return files.join(vim.fn.stdpath("cache"), "overseer", filename) -end - -local function load_parser() - local bufnr = source_buf - if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then - return - end - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true) - local text = table.concat(lines, "\n") - parser.trace(true) - local builder = assert(loadstring(text)) - local ok, ret = pcall(builder) - if ok then - if ret.ingest then - return ret - else - return nil, - string.format("Expected parser to have method 'ingest'. Found %s", vim.inspect(ret)) - end - else - return nil, ret - end -end - -local parser_status_to_hl = setmetatable({ - RESET = "OverseerCanceled", -}, { - __index = function(_, key) - return string.format("Overseer%s", key) - end, -}) - -local function render_node(lines, highlights, node, depth, trace) - local name = string.format("%s%s", string.rep(" ", depth), node.name) - if trace[node.id] then - local col = string.len(name) + 1 - for _, status in ipairs(trace[node.id]) do - table.insert( - highlights, - { parser_status_to_hl[status], #lines + 1, col, col + string.len(status) } - ) - col = col + string.len(status) + 1 - end - name = name .. " " .. table.concat(trace[node.id], " ") - end - table.insert(lines, name) - if node.child then - render_node(lines, highlights, node.child, depth + 1, trace) - elseif node.children then - for _, child in ipairs(node.children) do - render_node(lines, highlights, child, depth + 1, trace) - end - end -end - -local function render_parser(input_lnum) - local bufnr = output_buf - if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then - return - end - local ns = vim.api.nvim_create_namespace("OverseerParser") - vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) - local p, err = load_parser() - if not p then - vim.bo[bufnr].modifiable = true - vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, vim.split(vim.inspect(err), "\n")) - vim.bo[bufnr].modifiable = false - return - end - p:ingest(vim.api.nvim_buf_get_lines(input_buf, 0, input_lnum or -1, true)) - local trace = parser.get_trace() - local lines = {} - local highlights = {} - if p.tree then - render_node(lines, highlights, p.tree, 0, trace) - elseif p.children then - for k, v in pairs(p.children) do - table.insert(lines, string.format("%s:", k)) - render_node(lines, highlights, v, 1, trace) - end - end - - local rem = p:get_remainder() - if rem then - table.insert(lines, "ITEM:") - table.insert(highlights, { "Title", #lines, 0, -1 }) - vim.list_extend(lines, vim.split(vim.inspect(rem), "\n")) - end - table.insert(lines, "RESULT:") - table.insert(highlights, { "Title", #lines, 0, -1 }) - local results = p:get_result() - vim.list_extend(lines, vim.split(vim.inspect(results), "\n")) - - vim.bo[bufnr].modifiable = true - vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines) - vim.bo[bufnr].modifiable = false - util.add_highlights(bufnr, ns, highlights) -end - -local function create_source_bufnr() - local file = get_filepath("debug_parser_source.lua") - vim.cmd(string.format("edit %s", file)) - local bufnr = vim.api.nvim_get_current_buf() - if vim.api.nvim_buf_line_count(bufnr) == 1 then - local lines = { - 'local parser = require("overseer.parser")', - "return parser.new({", - ' {"extract", "(.*)", "text"}', - "})", - } - vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines) - vim.cmd([[noautocmd write]]) - end - source_buf = bufnr - vim.api.nvim_create_autocmd("BufWritePost", { - desc = "update parser debug view on write", - callback = function() - local lnum - for _, winid in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - if vim.api.nvim_win_is_valid(winid) and vim.api.nvim_win_get_buf(winid) == input_buf then - lnum = vim.api.nvim_win_get_cursor(winid)[1] - break - end - end - render_parser(lnum) - end, - buffer = bufnr, - }) -end - -local function create_output_buf(bufnr) - if not bufnr then - bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_win_set_buf(0, bufnr) - end - output_buf = bufnr - vim.bo[bufnr].bufhidden = "wipe" - vim.bo[bufnr].modifiable = false -end - -local function create_input_buf() - local file = get_filepath("debug_parser_input.txt") - vim.cmd(string.format("edit %s", file)) - local bufnr = vim.api.nvim_get_current_buf() - input_buf = bufnr - if vim.api.nvim_buf_line_count(bufnr) == 1 then - local lines = { "foo.lua:234: Sample input line" } - vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines) - vim.cmd([[noautocmd write]]) - end - vim.api.nvim_create_autocmd("CursorMoved", { - desc = "Rerun parser when cursor moves", - buffer = bufnr, - callback = function() - local lnum = vim.api.nvim_win_get_cursor(0)[1] - render_parser(lnum) - end, - }) -end - -M.start_debug_session = function() - for _, buf in ipairs({ source_buf, output_buf, input_buf }) do - if buf and vim.api.nvim_buf_is_valid(buf) then - vim.api.nvim_buf_delete(buf, { force = true }) - end - end - local overseer_dir = files.join(vim.fn.stdpath("cache"), "overseer") - if vim.fn.isdirectory(overseer_dir) == 0 then - vim.fn.mkdir(overseer_dir) - end - vim.cmd([[tabnew]]) - create_source_bufnr() - local source_win = vim.api.nvim_get_current_win() - vim.cmd([[vsplit]]) - create_output_buf() - vim.api.nvim_set_current_win(source_win) - vim.cmd([[split]]) - create_input_buf() - vim.api.nvim_set_current_win(source_win) - render_parser() -end - -return M diff --git a/lua/overseer/parser/dispatch.lua b/lua/overseer/parser/dispatch.lua deleted file mode 100644 index 9b00b677..00000000 --- a/lua/overseer/parser/dispatch.lua +++ /dev/null @@ -1,52 +0,0 @@ -local parser = require("overseer.parser") -local Dispatch = { - desc = "Dispatch an event", - long_desc = "Events can be subscribed to using the parser:subscribe() method.", - doc_args = { - { - name = "name", - type = "string", - desc = "Event name", - }, - { - name = "arg", - type = "any|fun()", - desc = "A value to send with the event, or a function that creates a value", - vararg = true, - }, - }, - examples = { - { - desc = [[clear_results will clear all current results from the parser. Pass `true` to only clear the results under the current key]], - code = [[{"dispatch", "clear_results"}]], - }, - { - desc = [[set_results is used by the on_output_parse component to immediately set the current results on the task]], - code = [[{"dispatch", "set_results"}]], - }, - }, -} - -function Dispatch.new(name, ...) - return setmetatable({ - event_name = name, - args = { ... }, - }, { __index = Dispatch }) -end - -function Dispatch:reset() end - -function Dispatch:ingest(line, ctx) - local params = {} - for _, v in ipairs(self.args) do - if type(v) == "function" then - table.insert(params, v(line, ctx)) - else - table.insert(params, v) - end - end - ctx.dispatch(self.event_name, unpack(params)) - return parser.STATUS.SUCCESS -end - -return Dispatch diff --git a/lua/overseer/parser/ensure.lua b/lua/overseer/parser/ensure.lua deleted file mode 100644 index 6b61e3fc..00000000 --- a/lua/overseer/parser/ensure.lua +++ /dev/null @@ -1,62 +0,0 @@ -local parser = require("overseer.parser") -local util = require("overseer.parser.util") -local Ensure = { - desc = "Decorator that runs a child until it succeeds", - doc_args = { - { - name = "succeed", - type = "boolean", - desc = "Set to false to run child until failure", - default = true, - position_optional = true, - }, - { - name = "child", - type = "parser", - desc = "The child parser node", - }, - }, - examples = { - { - desc = [[An extract node that runs until it successfully parses]], - code = [[ - {"ensure", - {"extract", "^([^%s].+):(%d+): (.+)$", "filename", "lnum", "text" } - } -]], - }, - }, -} - -local MAX_LOOP = 2 - -function Ensure.new(succeed, child) - if type(succeed) ~= "boolean" then - child = succeed - succeed = true - end - return setmetatable({ - child = util.hydrate(child), - succeed = succeed, - }, { __index = Ensure }) -end - -function Ensure:reset() - self.child:reset() -end - -function Ensure:ingest(...) - for _ = 1, MAX_LOOP do - local st = self.child:ingest(...) - if st == parser.STATUS.FAILURE and self.succeed then - self.child:reset() - elseif st == parser.STATUS.SUCCESS and not self.succeed then - self.child:reset() - else - return st - end - end - return parser.STATUS.RUNNING -end - -return Ensure diff --git a/lua/overseer/parser/extract.lua b/lua/overseer/parser/extract.lua deleted file mode 100644 index a08cca7b..00000000 --- a/lua/overseer/parser/extract.lua +++ /dev/null @@ -1,115 +0,0 @@ -local parser = require("overseer.parser") - -local Extract = { - desc = "Parse a line into an object and append it to the results", - doc_args = { - { - name = "opts", - type = "object", - desc = "Configuration options", - position_optional = true, - fields = { - { - name = "consume", - type = "boolean", - desc = "Consumes the line of input, blocking execution until the next line is fed in", - default = true, - }, - { - name = "append", - type = "boolean", - desc = "After parsing, append the item to the results list. When false, the pending item will stick around.", - default = true, - }, - { - name = "regex", - type = "boolean", - desc = "Use vim regex instead of lua pattern (see :help pattern)", - default = false, - }, - { - name = "postprocess", - type = "function", - desc = "Call this function to do post-extraction processing on the values", - }, - }, - }, - { - name = "pattern", - type = "string|function|string[]", - desc = "The lua pattern to use for matching. Must have the same number of capture groups as there are field arguments.", - long_desc = "Can also be a list of strings/functions and it will try matching against all of them", - }, - { - name = "field", - type = "string", - vararg = true, - desc = 'The name of the extracted capture group. Use `"_"` to discard.', - }, - }, - examples = { - { - desc = [[Convert a line in the format of `/path/to/file.txt:123: This is a message` into an item `{filename = "/path/to/file.txt", lnum = 123, text = "This is a message"}`]], - code = [[{"extract", "^([^%s].+):(%d+): (.+)$", "filename", "lnum", "text" }]], - }, - { - desc = [[The same logic, but using a vim regex]], - code = [[{"extract", {regex = true}, "\\v^([^:space:].+):(\\d+): (.+)$", "filename", "lnum", "text" }]], - }, - }, -} - -function Extract.new(opts, pattern, ...) - local fields - if type(opts) ~= "table" then - fields = { pattern, ... } - pattern = opts - opts = {} - else - fields = { ... } - end - opts = vim.tbl_deep_extend("keep", opts, { - consume = true, - append = true, - regex = false, - }) - return setmetatable({ - consume = opts.consume, - append = opts.append, - postprocess = opts.postprocess, - done = nil, - pattern = pattern, - extract = parser.util.patterns_to_extract(pattern, opts.regex, fields), - }, { __index = Extract }) -end - -function Extract:reset() - self.done = nil -end - -function Extract:ingest(line, ctx) - if self.done then - return self.done - end - - local item = self.extract(line) - if item then - for k, v in pairs(item) do - ctx.item[k] = v - end - vim.tbl_extend("force", ctx.item, item) - end - - if not item then - self.done = parser.STATUS.FAILURE - return parser.STATUS.FAILURE - end - if self.postprocess then - self.postprocess(ctx.item, ctx) - end - parser.util.append_item(self.append, line, ctx) - self.done = parser.STATUS.SUCCESS - return self.consume and parser.STATUS.RUNNING or parser.STATUS.SUCCESS -end - -return Extract diff --git a/lua/overseer/parser/extract_efm.lua b/lua/overseer/parser/extract_efm.lua deleted file mode 100644 index 35c33658..00000000 --- a/lua/overseer/parser/extract_efm.lua +++ /dev/null @@ -1,124 +0,0 @@ -local parser = require("overseer.parser") -local ExtractEfm = { - desc = "Parse a line using vim's errorformat and append it to the results", - doc_args = { - { - name = "opts", - type = "object", - desc = "Configuration options", - position_optional = true, - fields = { - { - name = "efm", - type = "string", - desc = "The errorformat string to use. Defaults to current option value.", - }, - { - name = "consume", - type = "boolean", - desc = "Consumes the line of input, blocking execution until the next line is fed in", - default = true, - }, - { - name = "append", - type = "boolean", - desc = "After parsing, append the item to the results list. When false, the pending item will stick around.", - default = true, - }, - { - name = "test", - type = "function", - desc = "A function that operates on the parsed value and returns true/false for SUCCESS/FAILURE", - }, - { - name = "postprocess", - type = "function", - desc = "Call this function to do post-extraction processing on the values", - }, - }, - }, - }, -} - -function ExtractEfm.new(opts) - opts = opts or {} - opts = vim.tbl_deep_extend("keep", opts, { - append = true, - consume = true, - }) - return setmetatable({ - efm = opts.efm, - append = opts.append, - consume = opts.consume, - test = opts.test, - postprocess = opts.postprocess, - done = nil, - }, { __index = ExtractEfm }) -end - -function ExtractEfm:reset() - self.done = nil -end - -function ExtractEfm:ingest(line, ctx) - if self.done then - return self.done - end - local item = ctx.item - - local parsed_item = vim.fn.getqflist({ - lines = { line }, - efm = self.efm, - }).items[1] - if not parsed_item or parsed_item.valid ~= 1 or (self.test and not self.test(parsed_item)) then - self.done = parser.STATUS.FAILURE - return parser.STATUS.FAILURE - end - - -- Convert the quickfix item format to something a little easier to process - if not parsed_item.filename and parsed_item.bufnr ~= 0 then - parsed_item.filename = vim.api.nvim_buf_get_name(parsed_item.bufnr) - end - parsed_item.bufnr = nil - parsed_item.valid = nil - if parsed_item.module == "" then - parsed_item.module = nil - end - if parsed_item.nr == -1 then - parsed_item.nr = nil - end - if parsed_item.type == "" then - parsed_item.type = nil - end - if parsed_item.pattern == "" then - parsed_item.pattern = nil - end - if parsed_item.col == 0 then - parsed_item.col = nil - end - if parsed_item.vcol == 0 then - parsed_item.vcol = nil - end - if parsed_item.end_col == 0 then - parsed_item.end_col = nil - end - if parsed_item.lnum == 0 then - parsed_item.lnum = nil - end - if parsed_item.end_lnum == 0 then - parsed_item.end_lnum = nil - end - - for k, v in pairs(parsed_item) do - item[k] = v - end - - if self.postprocess then - self.postprocess(item, { line = line }) - end - parser.util.append_item(self.append, line, ctx) - self.done = parser.STATUS.SUCCESS - return self.consume and parser.STATUS.RUNNING or parser.STATUS.SUCCESS -end - -return ExtractEfm diff --git a/lua/overseer/parser/extract_json.lua b/lua/overseer/parser/extract_json.lua deleted file mode 100644 index 50d937f7..00000000 --- a/lua/overseer/parser/extract_json.lua +++ /dev/null @@ -1,82 +0,0 @@ -local parser = require("overseer.parser") -local ExtractJson = { - desc = "Parse a line as json and append it to the results", - doc_args = { - { - name = "opts", - type = "object", - desc = "Configuration options", - position_optional = true, - fields = { - { - name = "consume", - type = "boolean", - desc = "Consumes the line of input, blocking execution until the next line is fed in", - default = true, - }, - { - name = "append", - type = "boolean", - desc = "After parsing, append the item to the results list. When false, the pending item will stick around.", - default = true, - }, - { - name = "test", - type = "function", - desc = "A function that operates on the parsed value and returns true/false for SUCCESS/FAILURE", - }, - { - name = "postprocess", - type = "function", - desc = "Call this function to do post-extraction processing on the values", - }, - }, - }, - }, -} - -function ExtractJson.new(opts) - opts = opts or {} - opts = vim.tbl_deep_extend("keep", opts, { - append = true, - consume = true, - }) - return setmetatable({ - append = opts.append, - consume = opts.consume, - test = opts.test, - postprocess = opts.postprocess, - done = nil, - }, { __index = ExtractJson }) -end - -function ExtractJson:reset() - self.done = nil -end - -function ExtractJson:ingest(line, ctx) - if self.done then - return self.done - end - local item = ctx.item - - local ok, result = pcall(vim.json.decode, line, { luanil = { object = true } }) - if not ok or (self.test and not self.test(result)) then - self.done = parser.STATUS.FAILURE - return parser.STATUS.FAILURE - end - assert(result) - - for k, v in pairs(result) do - item[k] = v - end - - if self.postprocess then - self.postprocess(item, { line = line }) - end - parser.util.append_item(self.append, line, ctx) - self.done = parser.STATUS.SUCCESS - return self.consume and parser.STATUS.RUNNING or parser.STATUS.SUCCESS -end - -return ExtractJson diff --git a/lua/overseer/parser/extract_multiline.lua b/lua/overseer/parser/extract_multiline.lua deleted file mode 100644 index 5517f96f..00000000 --- a/lua/overseer/parser/extract_multiline.lua +++ /dev/null @@ -1,101 +0,0 @@ -local parser = require("overseer.parser") -local ExtractMultiline = { - desc = "Extract a multiline string as a single field on an item", - doc_args = { - { - name = "opts", - type = "object", - desc = "Configuration options", - position_optional = true, - fields = { - { - name = "append", - type = "boolean", - desc = "After parsing, append the item to the results list. When false, the pending item will stick around.", - default = true, - }, - }, - }, - { - name = "pattern", - type = "string|function", - desc = "The lua pattern to use for matching. As long as the pattern matches, lines will continue to be appended to the field.", - }, - { - name = "field", - type = "string", - desc = "The name of the field to add to the item", - }, - }, - examples = { - { - desc = [[Extract all indented lines as a message]], - code = [[{"extract_multiline", "^( .+)", "message"}]], - }, - }, -} - -function ExtractMultiline.new(opts, pattern, field) - if field == nil then - field = pattern - pattern = opts - opts = {} - end - opts = vim.tbl_deep_extend("keep", opts, { - append = true, - }) - return setmetatable({ - append = opts.append, - done = nil, - any_match = false, - pattern = pattern, - field = field, - }, { __index = ExtractMultiline }) -end - -function ExtractMultiline:reset() - self.done = nil - self.any_match = false -end - -local function append_line(item, key, value) - if not item[key] then - item[key] = value - else - item[key] = item[key] .. "\n" .. value - end -end - -function ExtractMultiline:ingest(line, ctx) - if self.done then - return self.done - end - local item = ctx.item - - local result - if type(self.pattern) == "string" then - result = line:match(self.pattern) - else - result = self.pattern(line) - end - if result then - self.any_match = true - if type(self.field) == "table" then - local key, postprocess = unpack(self.field) - append_line(item, key, postprocess(result, self)) - else - append_line(item, self.field, result) - end - return parser.STATUS.RUNNING - else - if self.any_match or not vim.tbl_isempty(item) then - self.done = parser.STATUS.SUCCESS - parser.util.append_item(self.append, line, ctx) - else - self.done = parser.STATUS.FAILURE - end - return self.done - end -end - -return ExtractMultiline diff --git a/lua/overseer/parser/extract_nested.lua b/lua/overseer/parser/extract_nested.lua deleted file mode 100644 index d303363f..00000000 --- a/lua/overseer/parser/extract_nested.lua +++ /dev/null @@ -1,128 +0,0 @@ -local parser = require("overseer.parser") -local util = require("overseer.parser.util") -local ExtractNested = { - desc = "Run a subparser and put the extracted results on the field of an item", - doc_args = { - { - name = "opts", - type = "object", - desc = "Configuration options", - position_optional = true, - fields = { - { - name = "append", - type = "boolean", - desc = "After parsing, append the item to the results list. When false, the pending item will stick around.", - default = true, - }, - { - name = "fail_on_empty", - type = "boolean", - desc = "Return FAILURE if there are no results from the child", - default = true, - }, - }, - }, - { - name = "field", - type = "string", - desc = "The name of the field to add to the item", - }, - { - name = "child", - type = "parser", - desc = "The child parser node", - }, - }, - examples = { - { - desc = [[Extract a golang test failure, then add the stacktrace to it (if present)]], - code = [[ - {"extract", - { - regex = true, - append = false, - }, - "\\v^--- (FAIL|PASS|SKIP): ([^[:space:] ]+) \\(([0-9\\.]+)s\\)", - "status", - "name", - "duration", - }, - {"always", - {"sequence", - {"test", "^panic:"}, - {"skip_until", "^goroutine%s"}, - {"extract_nested", - { append = false }, - "stacktrace", - {"loop", - {"sequence", - {"extract",{ append = false }, { "^(.+)%(.*%)$", "^created by (.+)$" }, "text"}, - {"extract","^%s+([^:]+.go):([0-9]+)", "filename", "lnum"} - } - } - } - } - } - ]], - }, - }, -} - -function ExtractNested.new(opts, field, child) - if child == nil then - child = field - field = opts - opts = {} - end - opts = vim.tbl_deep_extend("keep", opts, { - append = true, - fail_on_empty = true, - }) - return setmetatable({ - child = util.hydrate(child), - field = field, - append = opts.append, - fail_on_empty = opts.fail_on_empty, - results = {}, - item = {}, - }, { __index = ExtractNested }) -end - -function ExtractNested:reset() - self.done = nil - self.results = {} - self.item = {} - self.child:reset() -end - -function ExtractNested:ingest(line, ctx) - if self.done then - return self.done - end - local nested_ctx = { - results = self.results, - item = self.item, - } - local st = self.child:ingest(line, nested_ctx) - if st == parser.STATUS.FAILURE then - if not self.fail_on_empty or not vim.tbl_isempty(self.results) then - st = parser.STATUS.SUCCESS - end - elseif st == parser.STATUS.RUNNING then - if not vim.tbl_isempty(self.results) then - -- As soon as we extract any values, make sure the field exists on the item - ctx.item[self.field] = self.results - end - return st - end - - if st == parser.STATUS.SUCCESS then - ctx.item[self.field] = self.results - parser.util.append_item(self.append, line, ctx) - end - - self.done = st - return self.done -end -return ExtractNested diff --git a/lua/overseer/parser/init.lua b/lua/overseer/parser/init.lua deleted file mode 100644 index 0103077a..00000000 --- a/lua/overseer/parser/init.lua +++ /dev/null @@ -1,324 +0,0 @@ --- Utilities for parsing lines of output -local Enum = require("overseer.enum") -local util = require("overseer.util") ----@diagnostic disable-next-line: deprecated -local islist = vim.islist or vim.tbl_islist -local M = {} - -local debug = false -local next_id = 1 ----@type table -local trace = {} - ----@param id integer ----@param action overseer.ParserStatus -local function add_trace(id, action) - if not trace[id] then - trace[id] = { action } - else - table.insert(trace[id], action) - end -end - ----@class overseer.Parser ----@field reset fun(self: overseer.Parser) ----@field ingest fun(self: overseer.Parser, lines: string[]): overseer.ParserStatus ----@field subscribe fun(self: overseer.Parser, event: string, callback: fun(key: string, value: any)) ----@field unsubscribe fun(self: overseer.Parser, event: string, callback: fun(key: string, value: any)) ----@field get_result fun(self: overseer.Parser): table ----@field get_remainder fun(self: overseer.Parser): table ----@note ---- Built-in events that can be subscribed to: ---- new_item Dispatched when an item is appended to the result ---- clear_results Clear results items from the parser ---- set_results Canonically used to force the on_output_parse component to set task results - ----@class overseer.ParserNode ----@field ingest fun(self: overseer.ParserNode, line: string, ctx: table): overseer.ParserStatus ----@field reset fun() - -setmetatable(M, { - __index = function(_, key) - local mod = require(string.format("overseer.parser.%s", key)) - if key == "util" or key == "debug" then - return mod - end - if debug then - return function(...) - local node = mod.new(...) - local ingest = node.ingest - local reset = node.reset - node.reset = function(self) - add_trace(self.id, "RESET") - reset(self) - end - node.ingest = function(self, line, ctx) - local depth = ctx.debug_depth or 0 - ctx.debug_depth = depth + 1 - local st = ingest(self, line, ctx) - add_trace(self.id, st) - ctx.debug_depth = depth - return st - end - node.id = next_id - node.name = key - next_id = next_id + 1 - return node - end - else - return mod.new - end - end, -}) - ----@alias overseer.ParserStatus "RESET"|"RUNNING"|"SUCCESS"|"FAILURE" - -M.STATUS = Enum.new({ - "RUNNING", - "SUCCESS", - "FAILURE", -}) - ----@param subs table ----@param event string ----@param callback fun() -local function subscribe(subs, event, callback) - if not subs[event] then - subs[event] = {} - end - table.insert(subs[event], callback) -end - ----@param subs table ----@param event string ----@param callback fun() -local function unsubscribe(subs, event, callback) - if subs[event] then - util.tbl_remove(subs[event], callback) - end -end - -local function dispatch(subs, event, ...) - if subs[event] then - for _, cb in ipairs(subs[event]) do - cb(...) - end - end -end - ----@class overseer.ListParser : overseer.Parser ----@field tree overseer.ParserNode ----@field subs table -local ListParser = {} - ----@return overseer.ListParser -function ListParser.new(children) - local parser = { - tree = M.loop({ ignore_failure = true }, M.sequence(children)), - results = {}, - item = {}, - subs = {}, - } - setmetatable(parser, { __index = ListParser }) - parser:subscribe("clear_results", function() - parser.results = {} - if parser.ctx then - parser.ctx.results = parser.results - parser.ctx.__num_results = 0 - end - end) - return parser -end - -function ListParser:reset() - self.tree:reset() - self.results = {} - self.item = {} -end - -function ListParser:ingest(lines) - self.ctx = { - __num_results = #self.results, - item = self.item, - results = self.results, - default_values = {}, - dispatch = function(...) - dispatch(self.subs, ...) - end, - } - for _, line in ipairs(lines) do - self.ctx.line = line - if debug then - trace = {} - end - self.tree:ingest(line, self.ctx) - end - for i = self.ctx.__num_results + 1, #self.results do - local result = self.results[i] - dispatch(self.subs, "new_item", "", result) - end - self.ctx = nil -end - -function ListParser:subscribe(event, callback) - subscribe(self.subs, event, callback) -end - -function ListParser:unsubscribe(event, callback) - unsubscribe(self.subs, event, callback) -end - -function ListParser:get_result() - return self.results -end - -function ListParser:get_remainder() - if not vim.tbl_isempty(self.item) then - return self.item - end -end - ----@class overseer.MapParser : overseer.Parser ----@field children table ----@field results table ----@field items table ----@field subs table -local MapParser = {} - ----@return overseer.MapParser -function MapParser.new(children) - local results = {} - local items = {} - local wrapped_children = {} - for k, v in pairs(children) do - results[k] = {} - items[k] = {} - wrapped_children[k] = M.loop({ ignore_failure = true }, M.sequence(v)) - end - local parser = { - children = wrapped_children, - results = results, - items = items, - subs = {}, - } - setmetatable(parser, { __index = MapParser }) - parser:subscribe("clear_results", function(current_key_only) - if not current_key_only then - for k in pairs(parser.children) do - parser.results[k] = {} - end - if parser.ctx then - parser.ctx.results = parser.results[parser.ctx.__key] - parser.ctx.__num_results = 0 - end - elseif parser.ctx then - -- We want to clear just the items for the current key in results, so we need to modify the - -- ctx results in-place - while not vim.tbl_isempty(parser.ctx.results) do - table.remove(parser.ctx.results) - end - parser.ctx.__num_results = 0 - end - end) - return parser -end - -function MapParser:reset() - for k, v in pairs(self.children) do - self.results[k] = {} - self.items[k] = {} - v:reset() - end -end - -function MapParser:ingest(lines) - for _, line in ipairs(lines) do - if debug then - trace = {} - end - for k, v in pairs(self.children) do - self.ctx = { - __key = k, - __num_results = #self.results[k], - item = self.items[k], - results = self.results[k], - default_values = {}, - line = line, - dispatch = function(...) - dispatch(self.subs, ...) - end, - } - v:ingest(line, self.ctx) - for i = self.ctx.__num_results + 1, #self.ctx.results do - local result = self.ctx.results[i] - dispatch(self.subs, "new_item", k, result) - end - self.ctx = nil - end - end -end - -function MapParser:subscribe(event, callback) - subscribe(self.subs, event, callback) -end - -function MapParser:unsubscribe(event, callback) - unsubscribe(self.subs, event, callback) -end - -function MapParser:get_result() - return self.results -end - -function MapParser:get_remainder() - for _, v in pairs(self.items) do - if not vim.tbl_isempty(v) then - return self.items - end - end -end - ----@param config table ----@return overseer.Parser -M.new = function(config) - vim.validate({ - config = { config, "t" }, - }) - if islist(config) or M.util.is_parser(config) then - return ListParser.new(config) - else - return MapParser.new(config) - end -end - ----@param enabled boolean -M.trace = function(enabled) - debug = enabled -end - ----@return table -M.get_trace = function() - return trace -end - ----Used for documentation generation ----@private -M.get_parser_docs = function(...) - local ret = {} - for _, name in ipairs({ ... }) do - local mod = require(string.format("overseer.parser.%s", name)) - if mod.doc_args then - table.insert(ret, { - name = name, - desc = mod.desc, - doc_args = mod.doc_args, - examples = mod.examples, - }) - else - table.insert(ret, {}) - end - end - return ret -end - -return M diff --git a/lua/overseer/parser/inline.lua b/lua/overseer/parser/inline.lua deleted file mode 100644 index a51fd844..00000000 --- a/lua/overseer/parser/inline.lua +++ /dev/null @@ -1,20 +0,0 @@ -local Inline = {} - -function Inline.new(callback, reset) - return setmetatable({ - callback = callback, - reset_fn = reset, - }, { __index = Inline }) -end - -function Inline:reset() - if self.reset_fn then - self.reset_fn() - end -end - -function Inline:ingest(...) - return self.callback(...) -end - -return Inline diff --git a/lua/overseer/parser/invert.lua b/lua/overseer/parser/invert.lua deleted file mode 100644 index 621516a8..00000000 --- a/lua/overseer/parser/invert.lua +++ /dev/null @@ -1,45 +0,0 @@ -local parser = require("overseer.parser") -local util = require("overseer.parser.util") -local Invert = { - desc = "A decorator that inverts the child's return value", - doc_args = { - { - name = "child", - type = "parser", - desc = "The child parser node", - }, - }, - examples = { - { - desc = [[An extract node that returns SUCCESS when it fails, and vice-versa]], - code = [[ - {"invert", - {"extract", "^([^%s].+):(%d+): (.+)$", "filename", "lnum", "text" } - } -]], - }, - }, -} - -function Invert.new(child) - return setmetatable({ - child = util.hydrate(child), - }, { __index = Invert }) -end - -function Invert:reset() - self.child:reset() -end - -function Invert:ingest(...) - local st = self.child:ingest(...) - if st == parser.STATUS.FAILURE then - return parser.STATUS.SUCCESS - elseif st == parser.STATUS.SUCCESS then - return parser.STATUS.FAILURE - else - return st - end -end - -return Invert diff --git a/lua/overseer/parser/lib.lua b/lua/overseer/parser/lib.lua deleted file mode 100644 index 932fa403..00000000 --- a/lua/overseer/parser/lib.lua +++ /dev/null @@ -1,71 +0,0 @@ -local M = {} - ----Create a list of nodes that will parse repeating "watch" command output (e.g. tsc --watch) ----@param start_pat string|table Pattern or {opts, pattern} table that matches when we should start extracting ----@param end_pat string|table Pattern or {opts, pattern} table that matches when we should finish extracting ----@param opts table ---- wrap boolean If true, wrap the resulting parser in a loop->sequence ---- active_on_start boolean When false, require start_pat to match before parsing errors ---- only_clear_results_key boolean When true, only clear the current results key ----@return table -M.watcher_output = function(start_pat, end_pat, extraction, opts) - opts = vim.tbl_extend("keep", opts or {}, { - wrap = false, - active_on_start = true, - only_clear_results_key = false, - }) - local end_test - if type(end_pat) == "table" then - end_test = { "test", unpack(end_pat) } - else - end_test = { "test", end_pat } - end - local skip_until_start - if type(start_pat) == "table" then - local start_opts, pat = unpack(start_pat) - start_opts.skip_matching_line = true - skip_until_start = { "skip_until", start_opts, pat } - else - skip_until_start = { "skip_until", { skip_matching_line = true }, start_pat } - end - local seq = { - { - "always", -- When the loop exits, proceed to the next node - { - "loop", -- Extract errors until exit - { - "parallel", - { - "invert", -- Exit the loop when we detect the end of the output - end_test, - }, - { - "always", -- Don't exit the loop if extraction fails - extraction, - }, - -- Prevent spin-looping when extraction fails - { "skip_lines", 1 }, - }, - }, - }, - { "dispatch", "set_results" }, - } - local reset_seq = { - skip_until_start, - { "dispatch", "clear_results", opts.only_clear_results_key }, - } - if opts.active_on_start then - vim.list_extend(seq, reset_seq) - else - vim.list_extend(reset_seq, seq) - seq = reset_seq - end - - if opts.wrap then - table.insert(seq, 1, "sequence") - seq = { "loop", seq } - end - return seq -end - -return M diff --git a/lua/overseer/parser/loop.lua b/lua/overseer/parser/loop.lua deleted file mode 100644 index 0f2a24cf..00000000 --- a/lua/overseer/parser/loop.lua +++ /dev/null @@ -1,86 +0,0 @@ -local parser = require("overseer.parser") -local util = require("overseer.parser.util") -local Loop = { - desc = "A decorator that repeats the child", - doc_args = { - { - name = "opts", - type = "object", - desc = "Configuration options", - position_optional = true, - fields = { - { - name = "ignore_failure", - type = "boolean", - desc = "Keep looping even when the child fails", - default = false, - }, - { - name = "repetitions", - type = "integer", - desc = "When set, loop a set number of times then return SUCCESS", - }, - }, - }, - { - name = "child", - type = "parser", - desc = "The child parser node", - }, - }, -} - -local MAX_LOOP = 2 - -function Loop.new(opts, child) - if child == nil then - child = opts - opts = {} - end - vim.validate({ - ignore_failure = { opts.ignore_failure, "b", true }, - repetitions = { opts.repetitions, "n", true }, - }) - return setmetatable({ - ignore_failure = opts.ignore_failure, - repetitions = opts.repetitions, - count = 0, - done = nil, - child = util.hydrate(child), - }, { __index = Loop }) -end - -function Loop:reset() - self.count = 0 - self.done = nil - self.child:reset() -end - -function Loop:ingest(...) - if self.done then - return self.done - end - local loop_count = 0 - local st - repeat - if self.repetitions and self.count >= self.repetitions then - return parser.STATUS.SUCCESS - end - st = self.child:ingest(...) - if st == parser.STATUS.SUCCESS then - self.child:reset() - self.count = self.count + 1 - elseif st == parser.STATUS.FAILURE then - self.child:reset() - self.count = self.count + 1 - if not self.ignore_failure then - self.done = st - return st - end - end - loop_count = loop_count + 1 - until st == parser.STATUS.RUNNING or loop_count >= MAX_LOOP - return parser.STATUS.RUNNING -end - -return Loop diff --git a/lua/overseer/parser/parallel.lua b/lua/overseer/parser/parallel.lua deleted file mode 100644 index b1158caa..00000000 --- a/lua/overseer/parser/parallel.lua +++ /dev/null @@ -1,105 +0,0 @@ -local parser = require("overseer.parser") -local parser_util = require("overseer.parser.util") -local Parallel = { - desc = "Run the child nodes in parallel", - long_desc = "The children are still run in-order, it just means that the input lines are fed to all the children on every iteration", - doc_args = { - { - name = "opts", - type = "object", - desc = "Configuration options", - position_optional = true, - fields = { - { - name = "break_on_first_failure", - type = "boolean", - desc = "Stop executing as soon as a child returns FAILURE", - default = true, - }, - { - name = "break_on_first_success", - type = "boolean", - desc = "Stop executing as soon as a child returns SUCCESS", - default = false, - }, - { - name = "reset_children", - type = "boolean", - desc = "Reset all children at the beginning of each iteration", - default = false, - }, - }, - }, - { - name = "child", - type = "parser", - vararg = true, - desc = "The child parser nodes. Can be passed in as varargs or as a list.", - }, - }, -} - -function Parallel.new(opts, ...) - local children - if parser_util.is_parser(opts) then - children = vim.F.pack_len(opts, ...) - opts = {} - else - children = vim.F.pack_len(...) - end - vim.validate({ - break_on_first_failure = { opts.break_on_first_failure, "b", true }, - break_on_first_success = { opts.break_on_first_success, "b", true }, - reset_children = { opts.reset_children, "b", true }, - }) - opts = vim.tbl_deep_extend("keep", opts, { - break_on_first_failure = true, - break_on_first_success = false, - reset_children = false, - }) - return setmetatable({ - break_on_first_success = opts.break_on_first_success, - break_on_first_failure = opts.break_on_first_failure, - reset_children = opts.reset_children, - children = parser_util.hydrate_list(children), - }, { __index = Parallel }) -end - -function Parallel:reset() - for _, child in ipairs(self.children) do - child:reset() - end -end - -function Parallel:ingest(...) - local any_failed = false - local any_running = false - for _, child in ipairs(self.children) do - if self.reset_children then - child:reset() - end - local st = child:ingest(...) - if st == parser.STATUS.SUCCESS then - if self.break_on_first_success then - return st - end - elseif st == parser.STATUS.FAILURE then - if self.break_on_first_failure then - return st - end - any_failed = true - else - any_running = true - end - end - - if any_running then - return parser.STATUS.RUNNING - elseif any_failed then - return parser.STATUS.FAILURE - else - return parser.STATUS.SUCCESS - end -end - -return Parallel diff --git a/lua/overseer/parser/sequence.lua b/lua/overseer/parser/sequence.lua deleted file mode 100644 index 64c0cae2..00000000 --- a/lua/overseer/parser/sequence.lua +++ /dev/null @@ -1,120 +0,0 @@ -local parser = require("overseer.parser") -local parser_util = require("overseer.parser.util") -local Sequence = { - desc = "Run the child nodes sequentially", - doc_args = { - { - name = "opts", - type = "object", - desc = "Configuration options", - position_optional = true, - fields = { - { - name = "break_on_first_failure", - type = "boolean", - desc = "Stop executing as soon as a child returns FAILURE", - default = true, - }, - { - name = "break_on_first_success", - type = "boolean", - desc = "Stop executing as soon as a child returns SUCCESS", - default = false, - }, - }, - }, - { - name = "child", - type = "parser", - vararg = true, - desc = "The child parser nodes. Can be passed in as varargs or as a list.", - }, - }, - examples = { - { - desc = [[Extract the message text from one line, then the filename and lnum from the next line]], - code = [[ - {"sequence", - {"extract", { append = false }, { "^(.+)%(.*%)$", "^created by (.+)$" }, "text"}, - {"extract", "^%s+([^:]+.go):([0-9]+)", "filename", "lnum"} - } - ]], - }, - }, -} - -function Sequence.new(opts, ...) - local children - if parser_util.is_parser(opts) then - -- No opts, children passed in as args - children = vim.F.pack_len(opts, ...) - opts = {} - elseif parser_util.tbl_is_parser_list(opts) then - -- No opts, children are passed in as a list - children = opts - opts = {} - else - if select("#", ...) == 1 then - local arg1 = select(1, ...) - -- we got opts, and children are passed in as a list - if parser_util.tbl_is_parser_list(arg1) then - children = arg1 - end - end - if not children then - -- children are passed in as args - children = vim.F.pack_len(...) - end - end - vim.validate({ - break_on_first_failure = { opts.break_on_first_failure, "b", true }, - break_on_first_success = { opts.break_on_first_success, "b", true }, - }) - opts = vim.tbl_deep_extend("keep", opts, { - break_on_first_failure = true, - break_on_first_success = false, - }) - return setmetatable({ - idx = 1, - any_failures = false, - break_on_first_success = opts.break_on_first_success, - break_on_first_failure = opts.break_on_first_failure, - children = parser_util.hydrate_list(children), - }, { __index = Sequence }) -end - -function Sequence:reset() - self.idx = 1 - self.any_failures = false - for _, child in ipairs(self.children) do - child:reset() - end -end - -function Sequence:ingest(...) - while self.idx <= #self.children do - local child = self.children[self.idx] - local st = child:ingest(...) - if st == parser.STATUS.SUCCESS then - if self.break_on_first_success then - return st - end - elseif st == parser.STATUS.FAILURE then - self.any_failures = true - if self.break_on_first_failure then - return st - end - elseif st == parser.STATUS.RUNNING then - return st - end - self.idx = self.idx + 1 - end - - if self.any_failures then - return parser.STATUS.FAILURE - else - return parser.STATUS.SUCCESS - end -end - -return Sequence diff --git a/lua/overseer/parser/set_defaults.lua b/lua/overseer/parser/set_defaults.lua deleted file mode 100644 index 0723fd84..00000000 --- a/lua/overseer/parser/set_defaults.lua +++ /dev/null @@ -1,87 +0,0 @@ -local util = require("overseer.parser.util") -local SetDefaults = { - desc = "A decorator that adds values to any items extracted by the child", - doc_args = { - { - name = "opts", - type = "object", - desc = "Configuration options", - position_optional = true, - fields = { - { - name = "values", - type = "object", - desc = "Hardcoded key-value pairs to set as default values", - }, - { - name = "hoist_item", - type = "boolean", - desc = "Take the current pending item, and use its fields as the default key-value pairs", - default = true, - }, - }, - }, - { - name = "child", - type = "parser", - desc = "The child parser node", - }, - }, - examples = { - { - desc = [[Extract the filename from a header line, then for each line of output beneath it parse the test name + status, and also add the filename to each item]], - code = [[ - {"sequence", - {"extract", {append = false}, "^Test result (.+)$", "filename"} - {"set_defaults", - {"loop", - {"extract", "^Test (.+): (.+)$", "test_name", "status"} - } - } - } - ]], - }, - }, -} - -function SetDefaults.new(opts, child) - if child == nil then - child = opts - opts = {} - end - opts = vim.tbl_deep_extend("keep", opts, { - values = {}, - hoist_item = true, - }) - vim.validate({ - values = { opts.values, "t" }, - hoist_item = { opts.hoist_item, "b" }, - }) - return setmetatable({ - default_values = opts.values, - hoist_item = opts.hoist_item, - current_defaults = nil, - child = util.hydrate(child), - }, { __index = SetDefaults }) -end - -function SetDefaults:reset() - self.current_defaults = nil - self.child:reset() -end - -function SetDefaults:ingest(line, ctx) - if not self.current_defaults then - self.current_defaults = vim.deepcopy(self.default_values) - if self.hoist_item then - self.current_defaults = vim.tbl_extend("force", self.current_defaults, ctx.item) - end - end - local prev_default_values = ctx.default_values - ctx.default_values = vim.tbl_deep_extend("force", ctx.default_values or {}, self.current_defaults) - local status = self.child:ingest(line, ctx) - ctx.default_values = prev_default_values - return status -end - -return SetDefaults diff --git a/lua/overseer/parser/skip_lines.lua b/lua/overseer/parser/skip_lines.lua deleted file mode 100644 index c9ba9f41..00000000 --- a/lua/overseer/parser/skip_lines.lua +++ /dev/null @@ -1,30 +0,0 @@ -local parser = require("overseer.parser") -local SkipLines = { - desc = "Skip over a set number of lines", - doc_args = { - { - name = "count", - type = "integer", - desc = "How many lines to skip", - }, - }, -} - -function SkipLines.new(count) - return setmetatable({ count = count, idx = 0 }, { __index = SkipLines }) -end - -function SkipLines:reset() - self.idx = 0 -end - -function SkipLines:ingest() - self.idx = self.idx + 1 - if self.idx <= self.count then - return parser.STATUS.RUNNING - else - return parser.STATUS.SUCCESS - end -end - -return SkipLines diff --git a/lua/overseer/parser/skip_until.lua b/lua/overseer/parser/skip_until.lua deleted file mode 100644 index 313de9a8..00000000 --- a/lua/overseer/parser/skip_until.lua +++ /dev/null @@ -1,80 +0,0 @@ -local parser = require("overseer.parser") -local SkipUntil = { - desc = "Skip over lines until one matches", - doc_args = { - { - name = "opts", - type = "object", - desc = "Configuration options", - position_optional = true, - fields = { - { - name = "skip_matching_line", - type = "boolean", - desc = "Consumes the line that matches. Later nodes will only see the next line.", - default = true, - }, - { - name = "regex", - type = "boolean", - desc = "Use vim regex instead of lua pattern (see :help pattern)", - default = true, - }, - }, - }, - { - name = "pattern", - vararg = true, - type = "string|string[]|fun(line: string): string", - desc = "The lua pattern to use for matching. The node succeeds if any of these patterns match.", - }, - }, - examples = { - { - desc = [[Skip input until we see "Error" or "Warning"]], - code = [[{"skip_until", "^Error:", "^Warning:"}]], - }, - }, -} - -function SkipUntil.new(opts, ...) - local patterns - if type(opts) ~= "table" then - patterns = { opts, ... } - opts = {} - else - patterns = { ... } - end - vim.validate({ - skip_matching_line = { opts.skip_matching_line, "b", true }, - }) - if opts.skip_matching_line == nil then - opts.skip_matching_line = true - end - return setmetatable({ - skip_matching_line = opts.skip_matching_line, - test = parser.util.patterns_to_test(patterns, opts.regex), - done = false, - }, { __index = SkipUntil }) -end - -function SkipUntil:reset() - self.done = false -end - -function SkipUntil:ingest(line) - if self.done then - return parser.STATUS.SUCCESS - end - if self.test(line) then - self.done = true - if self.skip_matching_line then - return parser.STATUS.RUNNING - else - return parser.STATUS.SUCCESS - end - end - return parser.STATUS.RUNNING -end - -return SkipUntil diff --git a/lua/overseer/parser/test.lua b/lua/overseer/parser/test.lua deleted file mode 100644 index 70ca155a..00000000 --- a/lua/overseer/parser/test.lua +++ /dev/null @@ -1,53 +0,0 @@ -local parser = require("overseer.parser") -local Test = { - desc = "Returns SUCCESS when the line matches the pattern", - doc_args = { - { - name = "opts", - type = "object", - desc = "Configuration options", - position_optional = true, - fields = { - { - name = "regex", - type = "boolean", - desc = "Use vim regex instead of lua pattern (see :help pattern)", - default = true, - }, - }, - }, - { - name = "pattern", - type = "string|fun(line: string): string", - desc = "The lua pattern to use for matching, or test function", - }, - }, - examples = { - { - desc = [[Fail until a line starts with "panic:"]], - code = [[{"test", "^panic:"}]], - }, - }, -} - -function Test.new(opts, pattern) - if not pattern then - pattern = opts - opts = {} - end - return setmetatable({ - test = parser.util.patterns_to_test(pattern, opts.regex), - }, { __index = Test }) -end - -function Test:reset() end - -function Test:ingest(line) - if self.test(line) then - return parser.STATUS.SUCCESS - else - return parser.STATUS.FAILURE - end -end - -return Test diff --git a/lua/overseer/parser/util.lua b/lua/overseer/parser/util.lua deleted file mode 100644 index 8824bafd..00000000 --- a/lua/overseer/parser/util.lua +++ /dev/null @@ -1,194 +0,0 @@ -local util = require("overseer.util") ----@diagnostic disable-next-line: deprecated -local islist = vim.islist or vim.tbl_islist -local M = {} - -M.append_item = function(append, line, ctx) - if not append then - return - end - local item = vim.tbl_deep_extend("keep", ctx.item, ctx.default_values or {}) - if type(append) == "function" then - append(ctx.results, vim.deepcopy(item), { line = line }) - else - table.insert(ctx.results, vim.deepcopy(item)) - end - - for k in pairs(ctx.item) do - ctx.item[k] = nil - end -end - ----@param data table|nil A parser or parser definition -M.hydrate = function(data) - vim.validate({ - data = { data, "t", true }, - }) - if not data then - return nil - end - if data.ingest then - return data - else - local constructor = require("overseer.parser")[data[1]] - local args = util.tbl_slice(data, 2) - return constructor(unpack(args)) - end -end - ----@param list table[] -M.hydrate_list = function(list) - vim.validate({ - list = { list, "t" }, - }) - local ret = {} - for _, v in ipairs(list) do - table.insert(ret, M.hydrate(v)) - end - return ret -end - ----@param data table ----@return boolean -M.is_parser = function(data) - if type(data) ~= "table" then - return false - end - return data.ingest or (islist(data) and type(data[1]) == "string") -end - ----@param list any ----@return boolean -M.tbl_is_parser_list = function(list) - if not islist(list) then - return false - end - return util.list_all(list, M.is_parser) -end - ----@param pattern string|fun() ----@param regex boolean ----@return fun(line: string): boolean -M.pattern_to_test = function(pattern, regex) - if type(pattern) == "string" then - if regex then - return function(line) - return vim.fn.match(line, pattern) >= 0 - end - else - return function(line) - return line:match(pattern) - end - end - else - return pattern - end -end - ----@param patterns string[]|fun()[] ----@param regex boolean ----@return fun(line: string): boolean -M.patterns_to_test = function(patterns, regex) - if type(patterns) ~= "table" then - return M.pattern_to_test(patterns, regex) - end - local tests = {} - for _, pat in ipairs(patterns) do - table.insert(tests, M.pattern_to_test(pat, regex)) - end - - return function(line) - for _, test in ipairs(tests) do - if test(line) then - return true - end - end - return false - end -end - -local function default_postprocess_field(value, opts) - if value:match("^%d+$") then - return tonumber(value) - elseif opts.field == "type" then - return value:upper():match("^%w") - else - return value - end -end - ----@param pattern string|fun(line: string) ----@param regex boolean ----@param fields string[] ----@return fun(line: string): nil|table -M.pattern_to_extract = function(pattern, regex, fields) - local match - if type(pattern) == "string" then - if regex then - match = function(line) - local result = vim.fn.matchlist(line, pattern) - table.remove(result, 1) - return result - end - else - match = function(line) - return { line:match(pattern) } - end - end - else - match = function(line) - return { pattern(line) } - end - end - return function(line) - local result = match(line) - if not result then - return nil - end - local item - for i, field in ipairs(fields) do - if result[i] then - if not item then - item = {} - end - local key, postprocess - if type(field) == "table" then - key, postprocess = unpack(field) - else - key = field - postprocess = default_postprocess_field - end - if key ~= "_" then - item[key] = postprocess(result[i], { item = item, field = key }) - end - end - end - return item - end -end - ----@param patterns string[]|fun()[] ----@param regex boolean ----@param fields string[] ----@return fun(line: string): nil|table -M.patterns_to_extract = function(patterns, regex, fields) - if type(patterns) ~= "table" then - return M.pattern_to_extract(patterns, regex, fields) - end - - local extractors = {} - for _, pat in ipairs(patterns) do - table.insert(extractors, M.pattern_to_extract(pat, regex, fields)) - end - - return function(line) - for _, ext in ipairs(extractors) do - local item = ext(line) - if item then - return item - end - end - end -end - -return M diff --git a/lua/overseer/render.lua b/lua/overseer/render.lua new file mode 100644 index 00000000..a49b19a3 --- /dev/null +++ b/lua/overseer/render.lua @@ -0,0 +1,379 @@ +local util = require("overseer.util") + +local M = {} + +---@alias overseer.TextChunk {[1]: string, [2]: nil|string} + +---The default format for tasks in the task list +---@param task overseer.Task +---@return overseer.TextChunk[][] +---@example +--- require("overseer").setup({ +--- task_list = { +--- render = function(task) +--- return require("overseer.render").format_standard(task) +--- end, +--- }, +--- }) +M.format_standard = function(task) + local ret = { + M.status_and_name(task), + } + vim.list_extend(ret, M.source_lines(task)) + table.insert( + ret, + M.join(M.duration(task), M.time_since_completed(task, { hl_group = "Comment" })) + ) + vim.list_extend(ret, M.result_lines(task, { oneline = true })) + vim.list_extend(ret, M.output_lines(task, { num_lines = 1 })) + return M.remove_empty_lines(ret) +end + +---A more compact format for tasks +---@param task overseer.Task +---@return overseer.TextChunk[][] +---@example +--- require("overseer").setup({ +--- task_list = { +--- render = function(task) +--- return require("overseer.render").format_compact(task) +--- end, +--- }, +--- }) +M.format_compact = function(task) + return { + M.status_and_name(task), + M.join(M.duration(task), M.time_since_completed(task, { hl_group = "Comment" })), + } +end + +---A more verbose format for tasks +---@param task overseer.Task +---@return overseer.TextChunk[][] +---@example +--- require("overseer").setup({ +--- task_list = { +--- render = function(task) +--- return require("overseer.render").format_verbose(task) +--- end, +--- }, +--- }) +M.format_verbose = function(task) + local ret = { + M.status_and_name(task), + M.join(M.duration(task), M.time_since_completed(task, { hl_group = "Comment" })), + } + vim.list_extend(ret, M.result_lines(task, { oneline = true })) + vim.list_extend(ret, M.output_lines(task, { num_lines = 4 })) + return M.remove_empty_lines(ret) +end + +---Text chunks that display the status of a task +---@param task overseer.Task +---@return overseer.TextChunk[] +---@example +--- require("overseer").setup({ +--- task_list = { +--- render = function(task) +--- local render = require("overseer.render") +--- return { +--- render.status(task), +--- { { task.name, "OverseerTask" } }, +--- } +--- end, +--- }, +--- }) +M.status = function(task) + return { { task.status, "Overseer" .. task.status } } +end + +---Text chunks that display the name of a task +---@param task overseer.Task +---@return overseer.TextChunk[] +---@example +--- require("overseer").setup({ +--- task_list = { +--- render = function(task) +--- local render = require("overseer.render") +--- return { +--- render.name(task), +--- } +--- end, +--- }, +--- }) +M.name = function(task) + return { { task.name, "OverseerTask" } } +end + +---Text chunks that display the status and name of a task +---@param task overseer.Task +---@return overseer.TextChunk[] +---@example +--- require("overseer").setup({ +--- task_list = { +--- render = function(task) +--- local render = require("overseer.render") +--- return { +--- render.status_and_name(task), +--- } +--- end, +--- }, +--- }) +M.status_and_name = function(task) + return M.join(M.status(task), M.name(task), ": ") +end + +---Text chunks that display the command that was run +---@param task overseer.Task +---@return overseer.TextChunk[] +---@example +--- require("overseer").setup({ +--- task_list = { +--- render = function(task) +--- local render = require("overseer.render") +--- return { +--- { { task.name, "OverseerTask" } }, +--- render.cmd(task), +--- } +--- end, +--- }, +--- }) +M.cmd = function(task) + local cmd = task.cmd + if not cmd then + return {} + elseif type(cmd) == "string" then + return { { cmd } } + else + return { { table.concat(cmd, " ") } } + end +end + +local function stringify_result(res) + if type(res) == "table" then + if vim.tbl_isempty(res) then + return "{}" + else + return string.format("{<%d items>}", vim.tbl_count(res)) + end + else + return string.format("%s", res) + end +end + +---Lines that display the result of a task +---@param task overseer.Task +---@param opts? {oneline?: boolean} +---@return overseer.TextChunk[][] +---@example +--- require("overseer").setup({ +--- task_list = { +--- render = function(task) +--- local render = require("overseer.render") +--- return vim.list_extend({ +--- { { task.name, "OverseerTask" } }, +--- }, render.result_lines(task, { oneline = false })) +--- end, +--- }, +--- }) +M.result_lines = function(task, opts) + ---@type {oneline: boolean} + opts = vim.tbl_extend("keep", opts or {}, { oneline = false }) + if not task.result or vim.tbl_isempty(task.result) then + return {} + end + local ret = {} + if opts.oneline then + local pieces = {} + for k, v in pairs(task.result) do + table.insert(pieces, string.format("%s=%s", k, stringify_result(v))) + end + table.insert(ret, { { "Result: " }, { table.concat(pieces, ", ") } }) + else + table.insert(ret, { { "Result: " } }) + for k, v in pairs(task.result) do + table.insert(ret, { { string.format(" %s = %s", k, stringify_result(v)) } }) + end + end + return ret +end + +---Text chunks that display how long a task has been running / ran for +---@param task overseer.Task +---@param opts? {hl_group?: string} +---@return overseer.TextChunk[] +---@example +--- require("overseer").setup({ +--- task_list = { +--- render = function(task) +--- local render = require("overseer.render") +--- return { +--- { { task.name, "OverseerTask" } }, +--- render.duration(task), +--- } +--- end, +--- }, +--- }) +M.duration = function(task, opts) + opts = opts or {} + if not task.time_start then + return {} + end + local duration + if task.time_end then + duration = task.time_end - task.time_start + else + duration = os.time() - task.time_start + end + return { { util.format_duration(duration), opts.hl_group } } +end + +---Text chunks that display the time since a task was completed +---@param task overseer.Task +---@param opts? {hl_group?: string} +---@return overseer.TextChunk[] +---@example +--- require("overseer").setup({ +--- task_list = { +--- render = function(task) +--- local render = require("overseer.render") +--- return { +--- { { task.name, "OverseerTask" } }, +--- render.time_since_completed(task), +--- } +--- end, +--- }, +--- }) +M.time_since_completed = function(task, opts) + opts = opts or {} + if not task.time_end then + return {} + end + return { { util.format_relative_timestamp(task.time_end), opts.hl_group } } +end + +---Lines that display the last few lines of output from a task +---@param task overseer.Task +---@param opts? {num_lines?: integer, prefix?: string, prefix_hl_group?: string} +---@return overseer.TextChunk[][] +---@example +--- require("overseer").setup({ +--- task_list = { +--- render = function(task) +--- local render = require("overseer.render") +--- return vim.list_extend({ +--- { { task.name, "OverseerTask" } }, +--- }, render.output_lines(task, { num_lines = 3, prefix = "$ " })) +--- end, +--- }, +--- }) +M.output_lines = function(task, opts) + ---@type {num_lines: integer, prefix: string, prefix_hl_group: string} + opts = vim.tbl_extend( + "keep", + opts or {}, + { num_lines = 1, prefix = "> ", prefix_hl_group = "Comment" } + ) + local bufnr = task:get_bufnr() + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return {} + end + local lines = util.get_last_output_lines(bufnr, opts.num_lines) + local ret = {} + for _, line in ipairs(lines) do + table.insert(ret, { { opts.prefix, opts.prefix_hl_group }, { line, "OverseerOutput" } }) + end + return ret +end + +---Lines that display the source of a wrapped vim.system or vim.fn.jobstart task +---@param task overseer.Task +---@param opts? {hl_group?: string} +---@return overseer.TextChunk[][] +---@example +--- require("overseer").setup({ +--- task_list = { +--- render = function(task) +--- local render = require("overseer.render") +--- return vim.list_extend({ +--- { { task.name, "OverseerTask" } }, +--- }, render.source_lines(task, { num_lines = 3, prefix = "$ " })) +--- end, +--- }, +--- }) +M.source_lines = function(task, opts) + ---@type {hl_group: string} + opts = vim.tbl_extend("keep", opts or {}, { hl_group = "Comment" }) + local ret = {} + if task.source and task.source.module then + table.insert(ret, { { task.source.module, opts.hl_group } }) + end + return ret +end + +---Join two lists of text chunks together with a separator +---@param a overseer.TextChunk[] +---@param b overseer.TextChunk[] +---@param sep? string|overseer.TextChunk +---@return overseer.TextChunk[] +---@example +--- require("overseer").setup({ +--- task_list = { +--- render = function(task) +--- local render = require("overseer.render") +--- return { +--- render.join(render.status(task), render.name(task), ": "), +--- } +--- end, +--- }, +--- }) +M.join = function(a, b, sep) + if not sep then + sep = " " + end + local ret = {} + for _, v in ipairs(a) do + table.insert(ret, v) + end + -- Only add the separator if there was anything in "a" + if #ret > 0 then + if type(sep) == "string" then + table.insert(ret, { sep }) + else + table.insert(ret, sep) + end + end + for _, v in ipairs(b) do + table.insert(ret, v) + end + return ret +end + +---Removes empty lines from a list of lines (each line is a list of text chunks) +---@param lines overseer.TextChunk[][] +---@return overseer.TextChunk[][] +---@example +--- require("overseer").setup({ +--- task_list = { +--- render = function(task) +--- local render = require("overseer.render") +--- local ret = vim.list_extend({ +--- { { task.name, "OverseerTask" } }, +--- }, render.output_lines(task, { num_lines = 3, prefix = "$ " })) +--- return render.remove_empty_lines(ret) +--- end, +--- }, +--- }) +M.remove_empty_lines = function(lines) + local i = 1 + while i <= #lines do + if vim.tbl_isempty(lines[i]) then + table.remove(lines, i) + else + i = i + 1 + end + end + return lines +end + +return M diff --git a/lua/overseer/strategy/_jobs.lua b/lua/overseer/strategy/_jobs.lua deleted file mode 100644 index a42cdc2d..00000000 --- a/lua/overseer/strategy/_jobs.lua +++ /dev/null @@ -1,43 +0,0 @@ -local log = require("overseer.log") - -local M = {} - -local cleanup_autocmd -local all_channels = {} - -M.register = function(job_id) - if not cleanup_autocmd then - -- Neovim will send a SIGHUP to PTY processes on exit. Unfortunately, some programs handle - -- SIGHUP (for a legitimate purpose) and do not terminate, which leaves orphaned processes after - -- Neovim exits. To avoid this, we need to explicitly call jobstop(), which will send a SIGHUP, - -- wait (controlled by KILL_TIMEOUT_MS in process.c, 2000ms at the time of writing), then send a - -- SIGTERM (possibly also a SIGKILL if that is insufficient). - cleanup_autocmd = vim.api.nvim_create_autocmd("VimLeavePre", { - desc = "Clean up running overseer tasks on exit", - callback = function() - local job_ids = vim.tbl_keys(all_channels) - log:debug("VimLeavePre clean up terminal tasks %s", job_ids) - for _, chan_id in ipairs(job_ids) do - vim.fn.jobstop(chan_id) - end - local start_wait = vim.loop.hrtime() - -- This makes sure Neovim doesn't exit until it has successfully killed all child processes. - vim.fn.jobwait(job_ids) - local elapsed = (vim.loop.hrtime() - start_wait) / 1e6 - if elapsed > 1000 then - log:warn( - "Killing running tasks took %dms. One or more processes likely did not terminate on SIGHUP. See https://github.com/stevearc/overseer.nvim/issues/46", - elapsed - ) - end - end, - }) - end - all_channels[job_id] = true -end - -M.unregister = function(job_id) - all_channels[job_id] = nil -end - -return M diff --git a/lua/overseer/strategy/init.lua b/lua/overseer/strategy/init.lua index 7381a932..69841781 100644 --- a/lua/overseer/strategy/init.lua +++ b/lua/overseer/strategy/init.lua @@ -1,58 +1,24 @@ -local config = require("overseer.config") -local log = require("overseer.log") local util = require("overseer.util") local M = {} ---@class overseer.Strategy ---@field name string ----@field new function ---@field reset fun(self: overseer.Strategy) ---@field get_bufnr fun(): number|nil ---@field start fun(self: overseer.Strategy, task: overseer.Task) ---@field stop fun(self: overseer.Strategy) ---@field dispose fun(self: overseer.Strategy) ----@field render nil|fun(self: overseer.Strategy, lines: string[], highlights: table, detail: number) -local NilStrategy = {} - ----@return overseer.Strategy -function NilStrategy.new() - local strategy = {} - setmetatable(strategy, { __index = NilStrategy }) - ---@cast strategy overseer.Strategy - return strategy -end - -function NilStrategy:reset() end - -function NilStrategy:get_bufnr() end - -function NilStrategy:start() - error(string.format("Strategy '%s' not found", self.name)) -end - -function NilStrategy:stop() end - -function NilStrategy:dispose() end - ----@param name_or_config? string|table +---@param name_or_config overseer.Serialized ---@return overseer.Strategy M.load = function(name_or_config) - if not name_or_config then - name_or_config = config.strategy - end local conf local name name, conf = util.split_config(name_or_config) - local ok, strategy = pcall(require, string.format("overseer.strategy.%s", name)) - if ok then - local instance = strategy.new(conf) - instance.name = name - return instance - else - log:error("No task strategy '%s'", name) - return NilStrategy.new() - end + local strategy = require(string.format("overseer.strategy.%s", name)) + local instance = strategy.new(conf) + instance.name = name + return instance end return M diff --git a/lua/overseer/strategy/jobstart.lua b/lua/overseer/strategy/jobstart.lua index 29903b2d..3007710d 100644 --- a/lua/overseer/strategy/jobstart.lua +++ b/lua/overseer/strategy/jobstart.lua @@ -1,18 +1,58 @@ -local jobs = require("overseer.strategy._jobs") local log = require("overseer.log") +local overseer = require("overseer") local util = require("overseer.util") +local cleanup_autocmd +local all_channels = {} +local function register(job_id) + if not cleanup_autocmd then + -- Neovim will send a SIGHUP to PTY processes on exit. Unfortunately, some programs handle + -- SIGHUP (for a legitimate purpose) and do not terminate, which leaves orphaned processes after + -- Neovim exits. To avoid this, we need to explicitly call jobstop(), which will send a SIGHUP, + -- wait (controlled by KILL_TIMEOUT_MS in process.c, 2000ms at the time of writing), then send a + -- SIGTERM (possibly also a SIGKILL if that is insufficient). + cleanup_autocmd = vim.api.nvim_create_autocmd("VimLeavePre", { + desc = "Clean up running overseer tasks on exit", + callback = function() + local job_ids = vim.tbl_keys(all_channels) + log.debug("VimLeavePre clean up terminal tasks %s", job_ids) + for _, chan_id in ipairs(job_ids) do + vim.fn.jobstop(chan_id) + end + local start_wait = vim.uv.hrtime() + -- This makes sure Neovim doesn't exit until it has successfully killed all child processes. + vim.fn.jobwait(job_ids) + local elapsed = (vim.uv.hrtime() - start_wait) / 1e6 + if elapsed > 1000 then + log.warn( + "Killing running tasks took %dms. One or more processes likely did not terminate on SIGHUP. See https://github.com/stevearc/overseer.nvim/issues/46", + elapsed + ) + end + end, + }) + end + all_channels[job_id] = true +end + +local function unregister(job_id) + all_channels[job_id] = nil +end + ---@class overseer.JobstartStrategy : overseer.Strategy ---@field bufnr nil|integer ---@field job_id nil|integer ---@field term_id nil|integer ----@field opts table +---@field opts overseer.JobstartStrategyOpts local JobstartStrategy = {} +---@class (exact) overseer.JobstartStrategyOpts +---@field preserve_output? boolean If true, don't clear the buffer when tasks restart +---@field use_terminal? boolean If false, use a normal non-terminal buffer to store the output. This may produce unwanted results if the task outputs terminal escape sequences. +---@field wrap_opts? table Opts that were passed to jobstart(). We should wrap them + ---Run tasks using jobstart() ----@param opts nil|table ---- preserve_output boolean If true, don't clear the buffer when tasks restart ---- use_terminal boolean If false, use a normal non-terminal buffer to store the output. This may produce unwanted results if the task outputs terminal escape sequences. +---@param opts nil|overseer.JobstartStrategyOpts ---@return overseer.Strategy function JobstartStrategy.new(opts) opts = vim.tbl_extend("keep", opts or {}, { @@ -23,6 +63,7 @@ function JobstartStrategy.new(opts) bufnr = nil, job_id = nil, term_id = nil, + pending_output = {}, opts = opts, } setmetatable(strategy, { __index = JobstartStrategy }) @@ -36,7 +77,7 @@ function JobstartStrategy:reset() self.bufnr = nil self.term_id = nil end - if self.job_id then + if self.job_id and self.job_id > 0 then vim.fn.jobstop(self.job_id) self.job_id = nil end @@ -46,56 +87,123 @@ function JobstartStrategy:get_bufnr() return self.bufnr end ----@param task overseer.Task -function JobstartStrategy:start(task) - if not self.bufnr then - self.bufnr = vim.api.nvim_create_buf(false, true) - if self.opts.use_terminal then - local mode = vim.api.nvim_get_mode().mode - local term_id - util.run_in_fullscreen_win(self.bufnr, function() - term_id = vim.api.nvim_open_term(self.bufnr, { - on_input = function(_, _, _, data) - pcall(vim.api.nvim_chan_send, self.job_id, data) - vim.defer_fn(function() - util.terminal_tail_hack(self.bufnr) - end, 10) - end, - }) - end) - self.term_id = term_id - -- Set the scrollback to max - vim.bo[self.bufnr].scrollback = 100000 - util.hack_around_termopen_autocmd(mode) +---@return boolean +local function can_create_terminal() + -- Only allow creating a terminal in normal mode when we are not in a floating win. + -- Creating the terminal will exit visual/insert mode and can cause some dialogs to close. + return vim.api.nvim_get_mode().mode == "n" and not util.is_floating_win(0) +end + +local pending_terminal_jobs = {} +local function render_pending_terminals() + if not can_create_terminal() then + return + end + for _, strat in ipairs(pending_terminal_jobs) do + strat:_create_terminal() + end + pending_terminal_jobs = {} +end + +local created_autocmds = false +---@param strat overseer.JobstartStrategy +local function queue_terminal_creation(strat) + table.insert(pending_terminal_jobs, strat) + if created_autocmds then + return + end + created_autocmds = true + vim.api.nvim_create_autocmd("ModeChanged", { + pattern = "*:n", + callback = render_pending_terminals, + }) + vim.api.nvim_create_autocmd("WinEnter", { + callback = render_pending_terminals, + }) +end + +function JobstartStrategy:_create_terminal() + if not self.bufnr or not vim.api.nvim_buf_is_valid(self.bufnr) or self.term_id then + return + end + local term_id + util.run_in_fullscreen_win(self.bufnr, function() + term_id = vim.api.nvim_open_term(self.bufnr, { + on_input = function(_, _, _, data) + pcall(vim.api.nvim_chan_send, self.job_id, data) + vim.defer_fn(function() + util.terminal_tail_hack(self.bufnr) + end, 10) + end, + }) + -- Set the scrollback to max + vim.bo[self.bufnr].scrollback = 100000 + for _, data in ipairs(self.pending_output) do + pcall(vim.api.nvim_chan_send, term_id, table.concat(data, "\r\n")) + end + self.pending_output = {} + end) + self.term_id = term_id +end + +function JobstartStrategy:_init_buffer() + local bufnr = vim.api.nvim_create_buf(false, true) + self.bufnr = bufnr + self.pending_output = {} + if self.opts.use_terminal then + if can_create_terminal() then + self:_create_terminal() else - vim.bo[self.bufnr].modifiable = false - local function open_input() - local prompt = vim.api.nvim_buf_get_lines(self.bufnr, -2, -1, true)[1] - if prompt:match("^%s*$") then - prompt = "Input: " - end - vim.ui.input({ prompt = prompt }, function(text) - if text then - pcall(vim.api.nvim_chan_send, self.job_id, text .. "\r") - end - end) - end - for _, lhs in ipairs({ "i", "I", "a", "A", "o", "O" }) do - vim.keymap.set("n", lhs, open_input, { buffer = self.bufnr }) + queue_terminal_creation(self) + end + else + vim.bo[self.bufnr].modifiable = false + local function open_input() + local prompt = vim.api.nvim_buf_get_lines(self.bufnr, -2, -1, true)[1] + if prompt:match("^%s*$") then + prompt = "Input: " end + vim.ui.input({ prompt = prompt }, function(text) + if text then + pcall(vim.api.nvim_chan_send, self.job_id, text .. "\r") + end + end) end + for _, lhs in ipairs({ "i", "I", "a", "A", "o", "O" }) do + vim.keymap.set("n", lhs, open_input, { buffer = self.bufnr }) + end + end +end + +---@param task overseer.Task +function JobstartStrategy:start(task) + local wrap_term = self.opts.wrap_opts and self.opts.wrap_opts.term + local wrap = self.opts.wrap_opts or {} + + if wrap_term then + -- If we are wrapping jobstart() and the user passed `term = true`, then they are intending to + -- use the current buffer as the output display. + self.bufnr = vim.api.nvim_get_current_buf() + end + if not self.bufnr then + self:_init_buffer() end - local job_id local stdout_iter = util.get_stdout_line_iter() local function on_stdout(data) -- Update the buffer - if self.opts.use_terminal then - pcall(vim.api.nvim_chan_send, self.term_id, table.concat(data, "\r\n")) - vim.defer_fn(function() - util.terminal_tail_hack(self.bufnr) - end, 10) + if wrap_term then + -- don't do anything + elseif self.opts.use_terminal then + if self.term_id then + pcall(vim.api.nvim_chan_send, self.term_id, table.concat(data, "\r\n")) + vim.defer_fn(function() + util.terminal_tail_hack(self.bufnr) + end, 10) + else + table.insert(self.pending_output, data) + end else -- Track which wins we will need to scroll local trail_wins = {} @@ -130,39 +238,65 @@ function JobstartStrategy:start(task) task:dispatch("on_output_lines", lines) end end - job_id = vim.fn.jobstart(task.cmd, { + + local function coalesce(a, b) + if a == nil then + return b + else + return a + end + end + + local opts = vim.tbl_extend("force", wrap, { cwd = task.cwd, env = task.env, - pty = self.opts.use_terminal, + pty = coalesce(wrap.pty, self.opts.use_terminal), -- Take 4 off the total width so it looks nice in the floating window - width = vim.o.columns - 4, - on_stdout = function(j, d) + width = coalesce(wrap.width, vim.o.columns - 4), + on_stdout = function(j, d, m) + if wrap.on_stdout then + wrap.on_stdout(j, d, m) + end if self.job_id ~= j then return end on_stdout(d) end, - on_stderr = function(j, d) + on_stderr = function(j, d, m) + if wrap.on_stderr then + wrap.on_stderr(j, d, m) + end if self.job_id ~= j then return end on_stdout(d) end, - on_exit = function(j, c) - jobs.unregister(j) + on_exit = function(j, c, m) + if wrap.on_exit then + wrap.on_exit(j, c, m) + end + unregister(j) if self.job_id ~= j then return end - log:debug("Task %s exited with code %s", task.name, c) + log.debug("Task %s exited with code %s", task.name, c) -- Feed one last line end to flush the output on_stdout({ "" }) if self.opts.use_terminal then - pcall(vim.api.nvim_chan_send, self.term_id, string.format("\r\n[Process exited %d]\r\n", c)) - -- HACK force terminal buffer to update - -- see https://github.com/neovim/neovim/issues/23360 - vim.bo[self.bufnr].scrollback = vim.bo[self.bufnr].scrollback - 1 - vim.bo[self.bufnr].scrollback = vim.bo[self.bufnr].scrollback + 1 - util.terminal_tail_hack(self.bufnr) + if self.term_id then + pcall( + vim.api.nvim_chan_send, + self.term_id, + string.format("\r\n[Process exited %d]\r\n", c) + ) + -- HACK force terminal buffer to update + -- see https://github.com/neovim/neovim/issues/23360 + vim.bo[self.bufnr].scrollback = vim.bo[self.bufnr].scrollback - 1 + vim.bo[self.bufnr].scrollback = vim.bo[self.bufnr].scrollback + 1 + util.terminal_tail_hack(self.bufnr) + else + table.insert(self.pending_output, { "", string.format("[Process exited %d]", c), "" }) + end else vim.bo[self.bufnr].modifiable = true vim.api.nvim_buf_set_lines( @@ -173,29 +307,32 @@ function JobstartStrategy:start(task) { string.format("[Process exited %d]", c), "" } ) vim.bo[self.bufnr].modifiable = false + vim.bo[self.bufnr].modified = false end self.job_id = nil -- If we're exiting vim, don't call the on_exit handler -- We manually kill processes during VimLeavePre cleanup, and we don't want to trigger user -- code because of that if vim.v.exiting == vim.NIL then + ---@diagnostic disable-next-line: invisible task:on_exit(c) end end, }) - if job_id == 0 then - error(string.format("Invalid arguments for task '%s'", task.name)) - elseif job_id == -1 then - error(string.format("Command '%s' not executable", vim.inspect(task.cmd))) + self.job_id = overseer.builtin.jobstart(task.cmd, opts) + + if self.job_id == 0 then + log.error("Invalid arguments for task '%s'", task.name) + elseif self.job_id == -1 then + log.error("Command '%s' not executable", task.cmd) else - jobs.register(job_id) - self.job_id = job_id + register(self.job_id) end end function JobstartStrategy:stop() - if self.job_id then + if self.job_id and self.job_id > 0 then vim.fn.jobstop(self.job_id) self.job_id = nil end diff --git a/lua/overseer/strategy/orchestrator.lua b/lua/overseer/strategy/orchestrator.lua index cb52a564..c936e0c6 100644 --- a/lua/overseer/strategy/orchestrator.lua +++ b/lua/overseer/strategy/orchestrator.lua @@ -7,8 +7,6 @@ local task_list = require("overseer.task_list") local template = require("overseer.template") local util = require("overseer.util") local STATUS = constants.STATUS ----@diagnostic disable-next-line: deprecated -local islist = vim.isarray or vim.tbl_islist ---Check if this is a reference to a defined task template ---@param task any @@ -20,7 +18,7 @@ local function is_named_task(task) end assert(type(task) == "table", "Task must be a string or table") - if islist(task) then + if vim.islist(task) then -- If this is a list-like table, then this is not a named task. -- It will be a list of named tasks or task definitions. return false @@ -72,19 +70,15 @@ local OrchestratorStrategy = {} --- }, --- }) function OrchestratorStrategy.new(opts) - vim.validate({ - opts = { opts, "t" }, - }) - vim.validate({ - tasks = { opts.tasks, "t" }, - }) + vim.validate("opts", opts, "table") + vim.validate("opts.tasks", opts.tasks, "table") -- Each entry in tasks can be either a task definition, OR a list of task definitions. -- Convert it to each entry being a list of task definitions. local task_defns = {} for i, v in ipairs(opts.tasks) do if is_named_task(v) then task_defns[i] = { v } - elseif islist(v) then + elseif vim.islist(v) then task_defns[i] = v else task_defns[i] = { v } @@ -108,9 +102,6 @@ function OrchestratorStrategy:render_buf() local ns = vim.api.nvim_create_namespace("overseer") vim.api.nvim_buf_clear_namespace(self.bufnr, ns, 0, -1) - local lines = {} - local highlights = {} - local columns = {} local col_widths = {} local max_row = 0 @@ -128,34 +119,25 @@ function OrchestratorStrategy:render_buf() max_row = math.max(max_row, #columns[i]) end + local lines = {} for i = 1, max_row do local line = {} - local col_start = 0 for j, column in ipairs(columns) do local task = column[i] + if j > 1 then + table.insert(line, { " -> " }) + end if task then - table.insert( - line, - util.ljust(string.format("%s %s", task.status, task.name), col_widths[j]) - ) - local col_end = col_start + task.status:len() - table.insert( - highlights, - { string.format("Overseer%s", task.status), #lines + 1, col_start, col_end } - ) + table.insert(line, { task.status, string.format("Overseer%s", task.status) }) + table.insert(line, { util.ljust(" " .. task.name, col_widths[j] - #task.status) }) else - table.insert(line, string.rep(" ", col_widths[j])) + table.insert(line, { string.rep(" ", col_widths[j]) }) end - col_start = col_start + line[#line]:len() + 4 end - table.insert(lines, table.concat(line, " -> ")) + table.insert(lines, line) end - vim.bo[self.bufnr].modifiable = true - vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, true, lines) - vim.bo[self.bufnr].modifiable = false - vim.bo[self.bufnr].modified = false - util.add_highlights(self.bufnr, ns, highlights) + util.render_buf_chunks(self.bufnr, ns, lines) end function OrchestratorStrategy:reset() @@ -199,6 +181,7 @@ function OrchestratorStrategy:start_next() break elseif status == STATUS.FAILURE or status == STATUS.CANCELED then if self.task and self.task:is_running() then + ---@diagnostic disable-next-line: invisible self.task:finalize(status) end break @@ -206,6 +189,7 @@ function OrchestratorStrategy:start_next() all_success = i == #self.tasks end if all_success then + ---@diagnostic disable-next-line: invisible self.task:finalize(STATUS.SUCCESS) end end @@ -225,9 +209,6 @@ function OrchestratorStrategy:build_task(defn, i, j) ---@param task overseer.Task local function finalize_subtask(task) task:add_component("orchestrator.on_status_broadcast") - -- Don't include child tasks when saving to bundle. We will re-create them when the - -- orchestration task is loaded. - task:set_include_in_bundle(false) self.tasks[i][j] = task.id if self:section_complete(1) then self:start_next() @@ -235,7 +216,8 @@ function OrchestratorStrategy:build_task(defn, i, j) end if type(defn) == "table" and defn[1] == nil then - defn = vim.tbl_extend("error", { parent_id = self.task.id }, defn) + defn = vim.tbl_extend("error", { parent_id = self.task.id, ephemeral = true }, defn) + ---@cast defn overseer.TaskDefinition local task = require("overseer").new_task(defn) finalize_subtask(task) return @@ -245,7 +227,8 @@ function OrchestratorStrategy:build_task(defn, i, j) params = params or {} template.get_by_name(name, search, function(tmpl) if not tmpl then - log:error("Orchestrator could not find task '%s'", name) + log.error("Orchestrator could not find task '%s'", name) + ---@diagnostic disable-next-line: invisible self.task:finalize(STATUS.FAILURE) return end @@ -258,7 +241,8 @@ function OrchestratorStrategy:build_task(defn, i, j) build_opts, vim.schedule_wrap(function(task_defn) if not task_defn then - log:warn("Canceled building task '%s'", name) + log.warn("Canceled building task '%s'", name) + ---@diagnostic disable-next-line: invisible self.task:finalize(STATUS.FAILURE) return end diff --git a/lua/overseer/strategy/system.lua b/lua/overseer/strategy/system.lua new file mode 100644 index 00000000..b3b5ba9d --- /dev/null +++ b/lua/overseer/strategy/system.lua @@ -0,0 +1,274 @@ +local log = require("overseer.log") +local overseer = require("overseer") +local util = require("overseer.util") + +---@param proc vim.SystemObj +local function graceful_kill(proc) + proc:kill("SIGTERM") + vim.defer_fn(function() + if not proc:is_closing() then + proc:kill("SIGKILL") + end + end, 5000) +end + +local cleanup_autocmd +local all_procs = {} +---@param proc vim.SystemObj +local function register(proc) + if not cleanup_autocmd then + cleanup_autocmd = vim.api.nvim_create_autocmd("VimLeavePre", { + desc = "Clean up running overseer tasks on exit", + callback = function() + if #all_procs == 0 then + return + end + log.debug("VimLeavePre clean up %d vim.system processes", #all_procs) + for _, p in ipairs(all_procs) do + graceful_kill(p) + end + local start_wait = vim.uv.now() + if vim.wait(5001, function() + return #all_procs == 0 + end) then + return + end + + local elapsed = (vim.uv.now() - start_wait) + if elapsed > 1000 then + log.warn( + "Killing running vim.system tasks took %dms. One or more processes likely did not terminate on SIGHUP. See https://github.com/stevearc/overseer.nvim/issues/46", + elapsed + ) + end + end, + }) + end + table.insert(all_procs, proc) +end + +---@param proc vim.SystemObj +local function unregister(proc) + for i, p in ipairs(all_procs) do + if p == proc then + table.remove(all_procs, i) + return + end + end +end + +---@class overseer.SystemStrategy : overseer.Strategy +---@field bufnr nil|integer +---@field handle nil|vim.SystemObj +---@field opts overseer.SystemStrategyOpts +local SystemStrategy = {} + +---@class (exact) overseer.SystemStrategyOpts +---@field wrap_opts? vim.SystemOpts Opts that were passed to vim.system(). We should wrap them +---@field wrap_exit? fun(out: vim.SystemCompleted) + +---@param opts nil|overseer.SystemStrategyOpts +---@return overseer.Strategy +function SystemStrategy.new(opts) + local strategy = { + bufnr = nil, + job_id = nil, + term_id = nil, + opts = opts or {}, + } + setmetatable(strategy, { __index = SystemStrategy }) + ---@type overseer.SystemStrategy + return strategy +end + +function SystemStrategy:reset() + if self.bufnr then + util.soft_delete_buf(self.bufnr) + self.bufnr = nil + end + if self.handle then + graceful_kill(self.handle) + self.handle = nil + end +end + +function SystemStrategy:get_bufnr() + return self.bufnr +end + +---@param task overseer.Task +function SystemStrategy:start(task) + local wrap = self.opts.wrap_opts or {} + + if not self.bufnr then + self.bufnr = vim.api.nvim_create_buf(false, true) + vim.bo[self.bufnr].modifiable = false + end + + local stdout_iter = util.get_stdout_line_iter() + + ---@param data string + local on_output = vim.schedule_wrap(function(data) + -- Update the buffer + -- Track which wins we will need to scroll + local trail_wins = {} + local line_count = vim.api.nvim_buf_line_count(self.bufnr) + for _, winid in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(winid) and vim.api.nvim_win_get_buf(winid) == self.bufnr then + if vim.api.nvim_win_get_cursor(winid)[1] == line_count then + table.insert(trail_wins, winid) + end + end + end + local end_line = vim.api.nvim_buf_get_lines(self.bufnr, -2, -1, true)[1] or "" + if not wrap.text then + data = data:gsub("\r", "") + end + local raw_data = vim.split(data, "\n") + local lines = raw_data + lines[1] = end_line .. lines[1] + vim.bo[self.bufnr].modifiable = true + vim.api.nvim_buf_set_lines(self.bufnr, -2, -1, true, lines) + vim.bo[self.bufnr].modifiable = false + vim.bo[self.bufnr].modified = false + + -- Scroll to end of updated windows so we can tail output + local lnum = line_count + #lines - 1 + local col = vim.api.nvim_strwidth(lines[#lines]) + for _, winid in ipairs(trail_wins) do + vim.api.nvim_win_set_cursor(winid, { lnum, col }) + end + + -- Send output to task + task:dispatch("on_output", raw_data) + local iter_lines = stdout_iter(raw_data) + if not vim.tbl_isempty(iter_lines) then + task:dispatch("on_output_lines", iter_lines) + end + end) + + local handle + local outputs = {} + if wrap.stdout == nil or wrap.stdout == true then + outputs.stdout = {} + end + if wrap.stderr == nil or wrap.stderr == true then + outputs.stderr = {} + end + + local function output_fn(channel) + return function(err, data) + if type(wrap[channel]) == "function" then + local ok, cb_err = pcall(wrap[channel], err, data) + if not ok then + vim.schedule(function() + log.error("Error in %s %s callback: %s", task.name, channel, cb_err) + end) + end + end + if outputs[channel] and data then + table.insert(outputs[channel], data) + end + if err then + vim.schedule(function() + log.error("Error in %s %s callback: %s", task.name, channel, err) + end) + end + if self.handle == handle and data then + on_output(data) + end + end + end + + local opts = vim.tbl_extend("force", wrap, { + cwd = task.cwd, + env = task.env, + stdout = output_fn("stdout"), + stderr = output_fn("stderr"), + }) + ---@param out vim.SystemCompleted + local function on_exit(out) + -- If we patched the opts to include a stdout/stderr callback function where there was none + -- before, we need to manually set those values on the SystemCompleted object + if not out.stdout and outputs.stdout then + out.stdout = table.concat(outputs.stdout) + end + if wrap.stderr and outputs.stderr then + out.stderr = table.concat(outputs.stderr) + end + + if self.opts.wrap_exit then + self.opts.wrap_exit(out) + end + unregister(handle) + if self.handle ~= handle then + return + end + + -- The rest of this needs to not happen in a fast event + vim.schedule(function() + log.debug("Task %s exited with code %s", task.name, out.code) + -- Feed one last line end to flush the output + on_output("\n") + vim.bo[self.bufnr].modifiable = true + vim.api.nvim_buf_set_lines( + self.bufnr, + -1, + -1, + true, + { string.format("[Process exited %d]", out.code), "" } + ) + vim.bo[self.bufnr].modifiable = false + vim.bo[self.bufnr].modified = false + self.handle = nil + -- If we're exiting vim, don't call the on_exit handler + -- We manually kill processes during VimLeavePre cleanup, and we don't want to trigger user + -- code because of that + if vim.v.exiting == vim.NIL then + ---@diagnostic disable-next-line: invisible + task:on_exit(out.code) + end + end) + end + + local cmd = task.cmd + ---@cast cmd string[] + handle = overseer.builtin.system(cmd, opts, on_exit) + + local raw_wait = handle.wait + -- NOTE: In practice the .stdout/.stderr patching done in on_exit is all we need, because the same + -- object is returned from wait(). However, we're patching the wait function just in case the + -- internal implementation changes at some point. + handle.wait = function(p, timeout) + local out = raw_wait(p, timeout) + -- If we patched the opts to include a stdout/stderr callback function where there was none + -- before, we need to manually set those values on the SystemCompleted object + if not out.stdout and outputs.stdout then + out.stdout = table.concat(outputs.stdout) + end + if wrap.stderr and outputs.stderr then + out.stderr = table.concat(outputs.stderr) + end + return out + end + + self.handle = handle + + if not wrap.detach then + register(self.handle) + end +end + +function SystemStrategy:stop() + if self.handle then + graceful_kill(self.handle) + self.handle = nil + end +end + +function SystemStrategy:dispose() + self:stop() + util.soft_delete_buf(self.bufnr) +end + +return SystemStrategy diff --git a/lua/overseer/strategy/terminal.lua b/lua/overseer/strategy/terminal.lua deleted file mode 100644 index 8a23494e..00000000 --- a/lua/overseer/strategy/terminal.lua +++ /dev/null @@ -1,111 +0,0 @@ -local jobs = require("overseer.strategy._jobs") -local log = require("overseer.log") -local util = require("overseer.util") - ----@class overseer.TerminalStrategy : overseer.Strategy ----@field bufnr nil|integer ----@field chan_id nil|integer -local TerminalStrategy = {} - ----Run tasks using termopen() ----@return overseer.Strategy -function TerminalStrategy.new() - local strategy = { - bufnr = nil, - chan_id = nil, - } - setmetatable(strategy, { __index = TerminalStrategy }) - ---@type overseer.TerminalStrategy - return strategy -end - -function TerminalStrategy:reset() - util.soft_delete_buf(self.bufnr) - self.bufnr = nil - if self.chan_id then - vim.fn.jobstop(self.chan_id) - self.chan_id = nil - end -end - -function TerminalStrategy:get_bufnr() - return self.bufnr -end - ----@param task overseer.Task -function TerminalStrategy:start(task) - self.bufnr = vim.api.nvim_create_buf(false, true) - local chan_id - local mode = vim.api.nvim_get_mode().mode - local stdout_iter = util.get_stdout_line_iter() - - local function on_stdout(data) - task:dispatch("on_output", data) - local lines = stdout_iter(data) - if not vim.tbl_isempty(lines) then - task:dispatch("on_output_lines", lines) - end - vim.defer_fn(function() - util.terminal_tail_hack(self.bufnr) - end, 10) - end - util.run_in_fullscreen_win(self.bufnr, function() - chan_id = vim.fn.termopen(task.cmd, { - cwd = task.cwd, - env = task.env, - on_stdout = function(j, d) - if self.chan_id ~= j then - return - end - on_stdout(d) - end, - on_exit = function(j, c) - jobs.unregister(j) - if self.chan_id ~= j then - return - end - log:debug("Task %s exited with code %s", task.name, c) - -- Feed one last line end to flush the output - on_stdout({ "" }) - -- HACK force terminal buffer to update - -- see https://github.com/neovim/neovim/issues/23360 - vim.bo[self.bufnr].scrollback = vim.bo[self.bufnr].scrollback - 1 - vim.bo[self.bufnr].scrollback = vim.bo[self.bufnr].scrollback + 1 - self.chan_id = nil - -- If we're exiting vim, don't call the on_exit handler - -- We manually kill processes during VimLeavePre cleanup, and we don't want to trigger user - -- code because of that - if vim.v.exiting == vim.NIL then - task:on_exit(c) - end - end, - }) - end) - - -- Set the scrollback to max - vim.bo[self.bufnr].scrollback = 100000 - util.hack_around_termopen_autocmd(mode) - - if chan_id == 0 then - error(string.format("Invalid arguments for task '%s'", task.name)) - elseif chan_id == -1 then - error(string.format("Command '%s' not executable", vim.inspect(task.cmd))) - else - jobs.register(chan_id) - self.chan_id = chan_id - end -end - -function TerminalStrategy:stop() - if self.chan_id then - vim.fn.jobstop(self.chan_id) - self.chan_id = nil - end -end - -function TerminalStrategy:dispose() - self:stop() - util.soft_delete_buf(self.bufnr) -end - -return TerminalStrategy diff --git a/lua/overseer/strategy/test.lua b/lua/overseer/strategy/test.lua index 808deaec..ac153be2 100644 --- a/lua/overseer/strategy/test.lua +++ b/lua/overseer/strategy/test.lua @@ -43,6 +43,7 @@ function TestStrategy:send_exit(code) -- Feed one last line end to flush the output self.task:dispatch("on_output", "\n") self.task:dispatch("on_output_lines", { "" }) + ---@diagnostic disable-next-line: invisible self.task:on_exit(code or 0) end diff --git a/lua/overseer/strategy/toggleterm.lua b/lua/overseer/strategy/toggleterm.lua deleted file mode 100644 index 081af043..00000000 --- a/lua/overseer/strategy/toggleterm.lua +++ /dev/null @@ -1,159 +0,0 @@ -local jobs = require("overseer.strategy._jobs") -local shell = require("overseer.shell") -local util = require("overseer.util") - -local terminal = require("toggleterm.terminal") - ----@class overseer.ToggleTermStrategy : overseer.Strategy ----@field private opts overseeer.ToggleTermStrategyOpts ----@field private term? Terminal -local ToggleTermStrategy = {} - ----@class overseeer.ToggleTermStrategyOpts ----@field use_shell? boolean load user shell before running task ----@field size? number the size of the split if direction is vertical or horizontal ----@field direction? "vertical"|"horizontal"|"tab"|"float" ----@field highlights? table map to a highlight group name and a table of it's values ----@field auto_scroll? boolean automatically scroll to the bottom on task output ----@field close_on_exit? boolean close the terminal and delete terminal buffer (if open) after task exits ----@field quit_on_exit? "never"|"always"|"success" close the terminal window (if open) after task exits ----@field open_on_start? boolean toggle open the terminal automatically when task starts ----@field hidden? boolean cannot be toggled with normal ToggleTerm commands ----@field on_create? fun(term: table) function to execute on terminal creation - ----Run tasks using the toggleterm plugin ----@param opts? overseeer.ToggleTermStrategyOpts ----@return overseer.Strategy -function ToggleTermStrategy.new(opts) - opts = vim.tbl_extend("keep", opts or {}, { - use_shell = false, - size = nil, - direction = nil, - highlights = nil, - auto_scroll = nil, - close_on_exit = false, - quit_on_exit = "never", - open_on_start = true, - hidden = false, - on_create = nil, - }) - local strategy = { - opts = opts, - term = nil, - } - setmetatable(strategy, { __index = ToggleTermStrategy }) - ---@type overseer.ToggleTermStrategy - return strategy -end - -function ToggleTermStrategy:reset() - if self.term then - self.term:shutdown() - self.term = nil - end -end - -function ToggleTermStrategy:get_bufnr() - return self.term and self.term.bufnr -end - ----@param task overseer.Task -function ToggleTermStrategy:start(task) - local mode = vim.api.nvim_get_mode().mode - local stdout_iter = util.get_stdout_line_iter() - - local function on_stdout(data) - task:dispatch("on_output", data) - local lines = stdout_iter(data) - if not vim.tbl_isempty(lines) then - task:dispatch("on_output_lines", lines) - end - end - - local cmd = task.cmd - if type(cmd) == "table" then - cmd = shell.escape_cmd(cmd, "strong") - end - - local passed_cmd - if not self.opts.use_shell then - passed_cmd = cmd - end - - self.term = terminal.Terminal:new({ - cmd = passed_cmd, - env = task.env, - highlights = self.opts.highlights, - dir = task.cwd, - direction = self.opts.direction, - auto_scroll = self.opts.auto_scroll, - close_on_exit = self.opts.close_on_exit, - hidden = self.opts.hidden, - on_create = function(t) - local job_id = t.job_id - jobs.register(job_id) - - if self.opts.on_create then - self.opts.on_create(t) - end - - if self.opts.use_shell then - t:send(cmd) - t:send("exit " .. (vim.o.shell:find("fish") and "$status" or "$?")) - end - end, - on_stdout = function(t, job_id, d) - if t ~= self.term then - return - end - on_stdout(d) - end, - on_exit = function(t, j, c) - jobs.unregister(j) - if t ~= self.term then - return - end - -- Feed one last line end to flush the output - on_stdout({ "" }) - if vim.v.exiting == vim.NIL then - task:on_exit(c) - end - - local close = self.opts.quit_on_exit == "always" - or (self.opts.quit_on_exit == "success" and c == 0) - if close then - t:close() - end - end, - }) - - if self.opts.open_on_start then - self.term:toggle(self.opts.size) - else - self.term:spawn() - end - - util.hack_around_termopen_autocmd(mode) -end - -function ToggleTermStrategy:stop() - if self.term and self.term.job_id then - vim.fn.jobstop(self.term.job_id) - end -end - -function ToggleTermStrategy:dispose() - if self.term then - self.term:shutdown() - self.term = nil - end -end - ----@param direction "float"|"tab"|"vertical"|"horizontal" -function ToggleTermStrategy:open_terminal(direction) - if self.term then - self.term:open(self.opts.size, direction) - end -end - -return ToggleTermStrategy diff --git a/lua/overseer/task.lua b/lua/overseer/task.lua index 02691018..5aede0ea 100644 --- a/lua/overseer/task.lua +++ b/lua/overseer/task.lua @@ -1,7 +1,6 @@ local component = require("overseer.component") local config = require("overseer.config") local constants = require("overseer.constants") -local form_utils = require("overseer.form.utils") local layout = require("overseer.layout") local log = require("overseer.log") local shell = require("overseer.shell") @@ -12,36 +11,33 @@ local util = require("overseer.util") local STATUS = constants.STATUS ---@class overseer.Task ----@field id number ----@field result? table ----@field metadata table ----@field default_component_params table ----@field status overseer.Status ----@field cmd string|string[] ----@field cwd string ----@field env? table ----@field strategy_defn string|table ----@field strategy overseer.Strategy ----@field name string ----@field exit_code? number ----@field components overseer.Component[] +---@opaque +---@field id integer Unique ID for this task +---@field result? table For successful tasks, arbitrary key-value mapping of data produced by components +---@field metadata table Arbitrary key-value mapping passed by the user during construction +---@field private default_component_params table +---@field status overseer.Status Current task status +---@field cmd string|string[] Command to run. If it's a string it is run in the shell +---@field cwd string Working directory the task is run in +---@field env? table Additional environment variables for the task +---@field private strategy_defn string|table +---@field private strategy overseer.Strategy +---@field name string Name of the task +---@field ephemeral boolean Indicates that this task was generated indirectly (e.g. with run_after) +---@field source? overseer.Caller If this task was created by wrapping jobstart/vim.system, this contains information about the callsite +---@field exit_code? integer Exit code of the task process +---@field private components overseer.Component[] ---@field parent_id? integer ID of parent task. Used only to visually group tasks in the task list +---@field time_start? integer Timestamp when the task was started (os.time()) +---@field time_end? integer Timestamp when the task ended (os.time()) +---@field private from_template? overseer.TemplateSource ---@field private prev_bufnr? integer ----@field private _subscribers table +---@field private _subscribers table local Task = {} -local next_id = 1 +---@alias overseer.TaskEventHandler fun(task: overseer.Task, ...: any): nil|boolean -Task.ordered_params = { "cmd", "cwd" } ----@type overseer.Params -Task.params = { - -- It's kind of a hack to specify a delimiter without type = 'list'. This is - -- so the task editor displays nicely if the value is a list OR a string - cmd = { delimiter = " " }, - cwd = { - optional = true, - }, -} +local next_id = 1 ---@class (exact) overseer.TaskDefinition ---@field cmd string|string[] Command to run. If it's a string it is run in the shell; a table is run directly @@ -53,25 +49,32 @@ Task.params = { ---@field metadata? table Arbitrary metadata for your own use ---@field default_component_params? table Default values for component params ---@field components? overseer.Serialized[] List of components to attach. Defaults to `{"default"}` +---@field ephemeral? boolean Indicates that this task was generated by another task (e.g. with run_after) +---@field private from_template? overseer.TemplateSource + +---@class (exact) overseer.TemplateSource +---@field name string Name of the template +---@field env? table +---@field params table +---@field search overseer.SearchParams ----Create an uninitialized Task with no ID that will not be run ----This is used by the Task previewer (loading task bundles) so that we can use ----the Task rendering logic, but don't end up actually creating & registering a ----Task. +---Create a new Task ---@param opts overseer.TaskDefinition ---@return overseer.Task -function Task.new_uninitialized(opts) +function Task.new(opts) opts = opts or {} - vim.validate({ - -- cmd can be table or string - args = { opts.args, "t", true }, - cwd = { opts.cwd, "s", true }, - env = { opts.env, "t", true }, - name = { opts.name, "s", true }, - components = { opts.components, "t", true }, - metadata = { opts.metadata, "t", true }, - default_component_params = { opts.default_component_params, "t", true }, - }) + log.trace("New task: %s", opts) + -- cmd can be table or string + vim.validate("cmd", opts.name, function(v) + return type(v) == "string" or type(v) == "table" + end, true) + vim.validate("args", opts.args, "table", true) + vim.validate("cwd", opts.cwd, "string", true) + vim.validate("env", opts.env, "table", true) + vim.validate("name", opts.name, "string", true) + vim.validate("components", opts.components, "table", true) + vim.validate("metadata", opts.metadata, "table", true) + vim.validate("default_component_params", opts.default_component_params, "table", true) if opts.env and vim.tbl_isempty(opts.env) then -- For some reason termopen() doesn't like an empty env table opts.env = nil @@ -102,16 +105,21 @@ function Task.new_uninitialized(opts) name = name:gsub("\n", " ") if not opts.strategy then - opts.strategy = config.strategy + opts.strategy = { + "jobstart", + use_terminal = config.output.use_terminal, + preserve_output = config.output.preserve_output, + } end -- Build the instance data for the task local task = { + id = next_id, result = nil, metadata = opts.metadata or {}, default_component_params = opts.default_component_params or {}, _references = 0, - _include_in_bundle = true, + ephemeral = opts.ephemeral == true, _subscribers = {}, status = STATUS.PENDING, cmd = opts.cmd, @@ -124,125 +132,27 @@ function Task.new_uninitialized(opts) prev_bufnr = nil, components = {}, -- for internal use + ---@diagnostic disable-next-line: invisible + from_template = opts.from_template, ---@diagnostic disable-next-line: undefined-field parent_id = opts.parent_id, + ---@diagnostic disable-next-line: undefined-field + source = opts.source, } + next_id = next_id + 1 setmetatable(task, { __index = Task }) task:add_components(opts.components) ---@cast task overseer.Task - return task -end - ----@param opts overseer.TaskDefinition ----@return overseer.Task -function Task.new(opts) - log:trace("New task: %s", opts) - local task = Task.new_uninitialized(opts) - task.id = next_id - next_id = next_id + 1 task:dispatch("on_init") local bufnr = task:get_bufnr() if bufnr then vim.b[bufnr].overseer_task = task.id end + task:subscribe("on_status", task_list.on_task_updated) return task end -local function stringify_result(res) - if type(res) == "table" then - if vim.tbl_isempty(res) then - return "{}" - else - return string.format("{<%d items>}", vim.tbl_count(res)) - end - else - return string.format("%s", res) - end -end - -function Task:render(lines, highlights, detail) - vim.validate({ - lines = { lines, "t" }, - detail = { detail, "n" }, - }) - table.insert(lines, string.format("%s: %s", self.status, self.name)) - table.insert(highlights, { "Overseer" .. self.status, #lines, 0, string.len(self.status) }) - table.insert(highlights, { "OverseerTask", #lines, string.len(self.status) + 2, -1 }) - - if self.strategy.render then - self.strategy:render(lines, highlights, detail) - end - - if detail > 1 and self.cmd then - local cmd = self.cmd - local cmd_str - if type(cmd) == "string" then - cmd_str = cmd - else - cmd_str = table.concat(cmd, " ") - end - table.insert(lines, cmd_str) - end - - -- Render components - if detail >= 3 then - for _, comp in ipairs(self.components) do - if comp.desc then - table.insert(lines, string.format("%s (%s)", comp.name, comp.desc)) - table.insert(highlights, { "OverseerComponent", #lines, 0, string.len(comp.name) }) - table.insert(highlights, { "Comment", #lines, string.len(comp.name) + 1, -1 }) - else - table.insert(lines, comp.name) - end - - local comp_def = assert(component.get(comp.name)) - for k, v in pairs(comp.params) do - if k ~= 1 then - table.insert(lines, form_utils.render_field(comp_def.params[k], " ", k, v)) - end - end - - if comp.render then - comp:render(self, lines, highlights, detail) - end - end - else - for _, comp in ipairs(self.components) do - if comp.render then - comp:render(self, lines, highlights, detail) - end - end - end - - -- Render the result - if self.result and not vim.tbl_isempty(self.result) then - if detail == 1 then - local pieces = {} - for k, v in pairs(self.result) do - table.insert(pieces, string.format("%s=%s", k, stringify_result(v))) - end - table.insert(lines, "Result: " .. table.concat(pieces, ", ")) - else - table.insert(lines, "Result:") - for k, v in pairs(self.result) do - table.insert(lines, string.format(" %s = %s", k, stringify_result(v))) - end - end - end -end - ----Check if task should be included when saving "all" tasks to a bundle file ----@return boolean -function Task:should_include_in_bundle() - return self._include_in_bundle -end - ----@param include boolean -function Task:set_include_in_bundle(include) - self._include_in_bundle = include -end - --- Returns the arguments require to create a clone of this task +---Returns the arguments require to create a clone of this task when passed to overseer.new_task ---@return overseer.TaskDefinition function Task:serialize() local components = {} @@ -259,22 +169,26 @@ function Task:serialize() env = self.env, strategy = self.strategy_defn, components = components, + from_template = self.from_template, } end +---Create a deep copy of this task ---@return overseer.Task function Task:clone() return Task.new(self:serialize()) end +---Add a component, no-op if it already exists +---@param comp overseer.Serialized function Task:add_component(comp) self:add_components({ comp }) end +---Add components, skipping any that already exist +---@param components overseer.Serialized[] function Task:add_components(components) - vim.validate({ - components = { components, "t" }, - }) + vim.validate("components", components, "table") local new_comps = component.resolve(components, self.components) for _, v in ipairs(component.load(new_comps, self.default_component_params)) do table.insert(self.components, v) @@ -285,15 +199,16 @@ function Task:add_components(components) end end +---Add component, overwriting any existing +---@param comp overseer.Serialized function Task:set_component(comp) self:set_components({ comp }) end --- Add components, overwriting any existing +---Add components, overwriting any existing +---@param components overseer.Serialized[] function Task:set_components(components) - vim.validate({ - components = { components, "t" }, - }) + vim.validate("components", components, "table") for _, new_comp in ipairs(component.load(components, self.default_component_params)) do local found = false local replaced = false @@ -321,11 +236,9 @@ function Task:set_components(components) end ---@param name string ----@return overseer.Component? +---@return nil|overseer.Component function Task:get_component(name) - vim.validate({ - name = { name, "s" }, - }) + vim.validate("name", name, "string") for _, v in ipairs(self.components) do if v.name == name then return v @@ -334,18 +247,16 @@ function Task:get_component(name) end ---@param name string +---@return nil|overseer.Component function Task:remove_component(name) - vim.validate({ - name = { name, "s" }, - }) - return self:remove_components({ name }) + vim.validate("name", name, "string") + return self:remove_components({ name })[1] end ---@param names string[] +---@return overseer.Component[] function Task:remove_components(names) - vim.validate({ - names = { names, "t" }, - }) + vim.validate("names", names, "table") local lookup = {} for _, name in ipairs(names) do lookup[name] = true @@ -372,16 +283,17 @@ end ---@param name string ---@return boolean function Task:has_component(name) - vim.validate({ name = { name, "s" } }) + vim.validate("name", name, "string") local new_comps = component.resolve({ name }, self.components) return vim.tbl_isempty(new_comps) end ---Subscribe to events on this task ----Listeners cannot be serialized, so will not be saved when saving task to disk and will not be ----copied when cloning the task. ---@param event string ----@param callback fun(task: overseer.Task, ...: any): nil|boolean Callback can return false to unsubscribe itself +---@param callback fun(task: overseer.Task, ...: any): nil|boolean Callback can return a truthy value to unsubscribe itself +---@note +--- Listeners cannot be serialized, so will not be saved when saving task +--- to disk and will not be copied when cloning the task. function Task:subscribe(event, callback) if not self._subscribers[event] then self._subscribers[event] = {} @@ -389,6 +301,7 @@ function Task:subscribe(event, callback) table.insert(self._subscribers[event], callback) end +---Unsubscribe from an event that was previously subscribed to ---@param event string ---@param callback fun(task: overseer.Task, ...: any) function Task:unsubscribe(event, callback) @@ -402,26 +315,31 @@ function Task:unsubscribe(event, callback) end end +---Returns true if the task is PENDING ---@return boolean function Task:is_pending() return self.status == STATUS.PENDING end +---Returns true if the task is RUNNING ---@return boolean function Task:is_running() return self.status == STATUS.RUNNING end +---Returns true if the task is complete (not PENDING or RUNNING) ---@return boolean function Task:is_complete() return self.status ~= STATUS.PENDING and self.status ~= STATUS.RUNNING end +---Returns true if the task is DISPOSED ---@return boolean function Task:is_disposed() return self.status == STATUS.DISPOSED end +---Get the buffer containing the task output. Will be nil if task is PENDING. ---@return number|nil function Task:get_bufnr() local bufnr = self.strategy:get_bufnr() @@ -430,22 +348,16 @@ function Task:get_bufnr() end end +---Open the task output in a window ---@param direction? "float"|"tab"|"vertical"|"horizontal" +---@note +--- You can also use get_bufnr() to get the buffer and open it however you like. function Task:open_output(direction) local bufnr = self:get_bufnr() if not bufnr then return end - -- Toggleterm itself needs to handle these operations. - -- TODO: maybe we should build a formal abstraction that handles this, instead of relying on a - -- gross if statement here. - if self.strategy.name == "toggleterm" and direction then - ---@diagnostic disable-next-line: undefined-field - self.strategy:open_terminal(direction) - return - end - if direction == "float" then local winid = layout.open_fullscreen_float(bufnr) util.scroll_to_end(winid) @@ -455,13 +367,24 @@ function Task:open_output(direction) util.set_term_window_opts() util.scroll_to_end(0) elseif direction == "vertical" then + -- If we're currently in the task list or any other fixed-height panel, + -- open a split in the nearest other window + if vim.wo.winfixheight then + for _, winid in ipairs(util.get_fixed_wins()) do + if not vim.wo[winid].winfixheight then + util.go_win_no_au(winid) + break + end + end + end vim.cmd.vsplit() vim.api.nvim_win_set_buf(0, bufnr) util.set_term_window_opts() util.scroll_to_end(0) elseif direction == "horizontal" then - -- If we're currently in the task list, open a split in the nearest other window - if vim.bo.filetype == "OverseerList" then + -- If we're currently in the task list or any other fixed-width side panel, + -- open a split in the nearest other window + if vim.wo.winfixwidth then for _, winid in ipairs(util.get_fixed_wins()) do if not vim.wo[winid].winfixwidth then util.go_win_no_au(winid) @@ -474,33 +397,43 @@ function Task:open_output(direction) util.set_term_window_opts() util.scroll_to_end(0) else + -- If we're currently in the task list, open in a different window + if vim.bo.filetype == "OverseerList" then + for _, winid in ipairs(util.get_fixed_wins()) do + if not vim.wo[winid].winfixwidth then + util.go_win_no_au(winid) + break + end + end + end vim.cmd.normal({ args = { "m'" }, bang = true }) vim.api.nvim_win_set_buf(0, bufnr) util.scroll_to_end(0) end end +---Put the task back in PENDING state. +---@note +--- Cannot be called on running or disposed tasks. function Task:reset() - if self:is_disposed() then - error(string.format("Cannot reset %s task", self.status)) - return - elseif self:is_running() then + if self:is_disposed() or self:is_running() then error(string.format("Cannot reset %s task", self.status)) return end + self.time_start = nil + self.time_end = nil self.result = nil self.exit_code = nil self.status = STATUS.PENDING self:dispatch("on_status", self.status) self.strategy:reset() - task_list.touch_task(self) self:dispatch("on_reset") end ---Dispatch an event to all other tasks ---@param name string function Task:broadcast(name, ...) - for _, task in ipairs(task_list.list_tasks()) do + for _, task in ipairs(task_list.list_tasks({ include_ephemeral = true })) do if task.id ~= self.id then task:dispatch(name, ...) end @@ -516,7 +449,7 @@ function Task:dispatch(name, ...) if type(comp[name]) == "function" then local ok, err = pcall(comp[name], comp, self, ...) if not ok then - log:error("Task %s dispatch %s.%s: %s", self.name, comp.name, name, err) + log.error("Task %s dispatch %s.%s: %s", self.name, comp.name, name, err) elseif err ~= nil then table.insert(ret, err) end @@ -527,8 +460,9 @@ function Task:dispatch(name, ...) for _, cb in ipairs(self._subscribers[name]) do local ok, err = pcall(cb, self, ...) if not ok then - log:error("Task %s dispatch callback %s: %s", self.name, name, err) - elseif err == false then + log.error("Task %s dispatch callback %s: %s", self.name, name, err) + elseif err then + -- A truthy return value means unsubscribe table.insert(to_unsub, cb) end end @@ -537,23 +471,23 @@ function Task:dispatch(name, ...) end end if self.id and not self:is_disposed() then - task_list.update(self) + task_list.touch(self) end return ret end +---@private ---@param status overseer.Status function Task:finalize(status) - vim.validate({ - status = { status, "s" }, - }) + vim.validate("status", status, "string") if not self:is_running() then - log:warn("Task %s cannot change status from %s to %s", self.name, self.status, status) + log.warn("Task %s cannot change status from %s to %s", self.name, self.status, status) return elseif status ~= STATUS.SUCCESS and status ~= STATUS.FAILURE and status ~= STATUS.CANCELED then - log:error("Task %s finalize passed invalid status %s", self.name, status) + log.error("Task %s finalize passed invalid status %s", self.name, status) return end + self.time_end = os.time() self.status = status local results = self:dispatch("on_pre_result") if not vim.tbl_isempty(results) then @@ -567,11 +501,10 @@ function Task:finalize(status) end end +---@private ---@param data? table function Task:set_result(data) - vim.validate({ - data = { data, "t" }, - }) + vim.validate("data", data, "table", true) if not self:is_running() then return end @@ -579,8 +512,7 @@ function Task:set_result(data) self:dispatch("on_result", self.result) end ----Increment the refcount for this Task. ----Prevents it from being disposed +---Increment the refcount for this Task, preventing it from being disposed (unless force=true) function Task:inc_reference() self._references = self._references + 1 end @@ -592,16 +524,14 @@ end ---Cleans up resources, removes from task list, and deletes buffer. ---@param force? boolean When true, will dispose even with a nonzero refcount or when buffer is visible ----@return boolean disposed +---@return boolean disposed True if task was disposed function Task:dispose(force) - vim.validate({ - force = { force, "b", true }, - }) + vim.validate("force", force, "boolean", true) if self:is_disposed() then - return false + return true end if self._references > 0 and not force then - log:debug("Not disposing task %s: has %d references", self.name, self._references) + log.debug("Not disposing task %s: has %d references", self.name, self._references) return false end local bufnr = self:get_bufnr() @@ -609,7 +539,7 @@ function Task:dispose(force) if not force then -- Can't dispose if the strategy bufnr is open if bufnr_visible then - log:debug("Not disposing task %s: buffer is visible", self.name) + log.debug("Not disposing task %s: buffer is visible", self.name) return false end end @@ -625,7 +555,7 @@ function Task:dispose(force) end self.status = STATUS.DISPOSED self:dispatch("on_status", self.status) - log:debug("Disposing task %s", self.name) + log.debug("Disposing task %s", self.name) self.strategy:dispose() self:dispatch("on_dispose") task_list.remove(self) @@ -639,11 +569,12 @@ function Task:dispose(force) return true end +---Reset and re-run the task ---@param force_stop? boolean If true, restart the Task even if it is currently running ---@return boolean function Task:restart(force_stop) - vim.validate({ force_stop = { force_stop, "b", true } }) - log:debug("Restart task %s", self.name) + vim.validate("force_stop", force_stop, "boolean", true) + log.debug("Restart task %s", self.name) if self:is_running() then if force_stop then self:stop() @@ -658,6 +589,7 @@ function Task:restart(force_stop) end ---Called when the task strategy exits +---@private ---@param code number function Task:on_exit(code) self.exit_code = code @@ -668,7 +600,7 @@ function Task:on_exit(code) self:dispatch("on_exit", code) -- We shouldn't hit this unless there is no result component or it errored if self:is_running() then - log:error( + log.error( "Task %s did not finalize during exit. Is it missing the on_exit_set_status component?", self.name ) @@ -677,28 +609,30 @@ function Task:on_exit(code) end end +---Start a pending task function Task:start() if self:is_complete() then - log:error("Cannot start task '%s' that has completed", self.name) + log.error("Cannot start task '%s' that has completed", self.name) return false end if self:is_disposed() then - log:error("Cannot start task '%s' that has been disposed", self.name) + log.error("Cannot start task '%s' that has been disposed", self.name) return false end if self:is_running() then return false end if vim.tbl_contains(self:dispatch("on_pre_start"), false) then - log:debug("Component prevented task %s from starting", self.name) + log.debug("Component prevented task %s from starting", self.name) return false end - log:debug("Starting task %s", self.name) + log.debug("Starting task %s", self.name) local ok, err = pcall(self.strategy.start, self.strategy, self) if not ok then - log:error("Strategy '%s' failed to start for task '%s': %s", self.strategy.name, self.name, err) + log.error("Strategy '%s' failed to start for task '%s': %s", self.strategy.name, self.name, err) return false end + self.time_start = os.time() self.status = STATUS.RUNNING self:dispatch("on_status", self.status) self:dispatch("on_start") @@ -713,11 +647,13 @@ function Task:start() return true end +---Stop a running task +---@return boolean stopped True if the task was stopped function Task:stop() if not self:is_running() then return false end - log:debug("Stopping task %s", self.name) + log.debug("Stopping task %s", self.name) self:finalize(STATUS.CANCELED) self.strategy:stop() return true diff --git a/lua/overseer/task_bundle.lua b/lua/overseer/task_bundle.lua deleted file mode 100644 index 0b6f1268..00000000 --- a/lua/overseer/task_bundle.lua +++ /dev/null @@ -1,245 +0,0 @@ -local Task = require("overseer.task") -local config = require("overseer.config") -local confirm = require("overseer.confirm") -local files = require("overseer.files") -local log = require("overseer.log") -local task_list = require("overseer.task_list") -local M = {} - -local function get_bundle_dir() - return files.get_stdpath_filename("state", "overseer") -end - -local function get_bundle_previewer() - local ok, Previewer = pcall(require, "telescope.previewers.previewer") - if not ok then - return nil - end - - return Previewer:new({ - title = "Task bundle", - setup = function(self) - return { - bufnr = vim.api.nvim_create_buf(false, true), - } - end, - teardown = function(self) - local bufnr = self.state and self.state.bufnr - if bufnr then - pcall(vim.api.nvim_buf_delete, bufnr, { force = true }) - end - end, - preview_fn = function(self, entry, status) - local ns = vim.api.nvim_create_namespace("overseer") - vim.api.nvim_buf_clear_namespace(self.state.bufnr, ns, 0, -1) - vim.api.nvim_win_set_buf(status.preview_win, self.state.bufnr) - local lines = {} - local highlights = {} - local data = files.load_json_file( - files.join(get_bundle_dir(), string.format("%s.bundle.json", entry.value)) - ) - for _, params in ipairs(data) do - local task_ok, task = pcall(Task.new_uninitialized, params) - if task_ok then - task:render(lines, highlights, 3) - end - end - vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, true, lines) - for _, hl in ipairs(highlights) do - local group, row, col_start, col_end = unpack(hl) - vim.api.nvim_buf_add_highlight(self.state.bufnr, ns, group, row - 1, col_start, col_end) - end - end, - }) -end - ----@return string[] -M.list_task_bundles = function() - local bundle_dir = get_bundle_dir() - if not files.exists(bundle_dir) then - return {} - end - local filenames = files.list_files(bundle_dir) - local ret = {} - for _, filename in ipairs(filenames) do - local name = filename:match("^(.+)%.bundle%.json$") - if name then - table.insert(ret, name) - end - end - return ret -end - ----@param name nil|string ----@param opts nil|table ---- ignore_missing nil|boolean When true, don't notify if bundle doesn't exist ---- autostart nil|boolean When true, start the tasks after loading (default to config.bundles.autostart_on_load) -M.load_task_bundle = function(name, opts) - vim.validate({ - name = { name, "s", true }, - opts = { opts, "t", true }, - }) - opts = vim.tbl_deep_extend("keep", opts or {}, { - autostart = config.bundles.autostart_on_load, - }) - if name then - local filepath = files.join(get_bundle_dir(), string.format("%s.bundle.json", name)) - local data = files.load_json_file(filepath) - if not data then - if not opts.ignore_missing then - vim.notify(string.format("Could not find task bundle %s", name), vim.log.levels.ERROR) - end - return - end - local count = 0 - for _, params in ipairs(data) do - local ok, task = pcall(Task.new, params) - if ok then - count = count + 1 - if opts.autostart then - task:start() - end - else - log:error("Could not load task in bundle %s: %s", filepath, task) - end - end - vim.notify(string.format("Started %d tasks", count)) - else - local tasks = M.list_task_bundles() - if #tasks == 0 then - if not opts.ignore_missing then - vim.notify("No saved task bundles", vim.log.levels.WARN) - end - return - end - vim.ui.select(tasks, { - prompt = "Load task bundle:", - kind = "overseer_task_bundle", - telescope = { - previewer = get_bundle_previewer(), - }, - }, function(selected) - if selected then - M.load_task_bundle(selected, opts) - end - end) - end -end - ----@param name? string ----@param tasks? overseer.Task[] ----@param opts? {on_conflict?: "overwrite"|"append"|"cancel"} -M.save_task_bundle = function(name, tasks, opts) - vim.validate({ - name = { name, "s", true }, - tasks = { tasks, "t", true }, - opts = { opts, "t", true }, - }) - opts = opts or {} - if name then - local filename = string.format("%s.bundle.json", name) - local serialized - if tasks then - serialized = {} - for _, task in ipairs(tasks) do - table.insert(serialized, task:serialize()) - end - else - serialized = vim.tbl_map(function(task) - return task:serialize() - end, task_list.list_tasks(config.bundles.save_task_opts)) - end - if vim.tbl_isempty(serialized) then - if opts.on_conflict == "overwrite" then - M.delete_task_bundle(name, { ignore_missing = true }) - end - return - end - local filepath = files.join(get_bundle_dir(), filename) - - local function append_to_file() - local data = files.load_json_file(files.join(get_bundle_dir(), filename)) - for _, new_task in ipairs(serialized) do - table.insert(data, new_task) - end - files.write_json_file(filepath, data) - end - - if files.exists(filepath) then - if opts.on_conflict == "overwrite" then - files.write_json_file(filepath, serialized) - elseif opts.on_conflict == "append" then - append_to_file() - elseif opts.on_conflict == "cancel" then - -- Do nothing - else - confirm({ - message = string.format( - "%s exists.\nWould you like to overwrite it or append to it?", - filename - ), - choices = { - "&Overwrite", - "&Append", - "Cancel", - }, - default = 3, - }, function(idx) - if idx == 1 then - files.write_json_file(filepath, serialized) - elseif idx == 2 then - append_to_file() - end - end) - end - else - files.write_json_file(filepath, serialized) - end - else - vim.ui.input({ - prompt = "Task bundle name:", - completion = "customlist,overseer#task_bundle_completelist", - }, function(selected) - if selected and selected ~= "" then - M.save_task_bundle(selected, tasks) - end - end) - end -end - ----@param name? string ----@param opts? {ignore_missing?: boolean} -M.delete_task_bundle = function(name, opts) - vim.validate({ - name = { name, "s", true }, - opts = { opts, "t", true }, - }) - opts = opts or {} - if name then - local filename = string.format("%s.bundle.json", name) - if not files.delete_file(files.join(get_bundle_dir(), filename)) then - if not opts.ignore_missing then - vim.notify(string.format("No task bundle at %s", filename)) - end - end - else - local tasks = M.list_task_bundles() - if #tasks == 0 then - vim.notify("No saved task bundles", vim.log.levels.WARN) - return - end - vim.ui.select(tasks, { - prompt = "Delete task bundle:", - kind = "overseer_task_bundle", - telescope = { - previewer = get_bundle_previewer(), - }, - }, function(selected) - if selected then - M.delete_task_bundle(selected) - end - end) - end -end - -return M diff --git a/lua/overseer/task_editor.lua b/lua/overseer/task_editor.lua index ff8aa650..49246219 100644 --- a/lua/overseer/task_editor.lua +++ b/lua/overseer/task_editor.lua @@ -1,110 +1,33 @@ -local Task = require("overseer.task") -local binding_util = require("overseer.binding_util") local component = require("overseer.component") -local config = require("overseer.config") local form_utils = require("overseer.form.utils") local util = require("overseer.util") local M = {} -local bindings = { - { - desc = "Show default key bindings", - plug = "OverseerLauncher:ShowHelp", - rhs = function(editor) - editor.disable_close_on_leave = true - binding_util.show_bindings("OverseerLauncher:") - end, - }, - { - desc = "Submit the task", - plug = "OverseerLauncher:Submit", - rhs = function(editor) - editor:submit() - end, - }, - { - desc = "Cancel editing the task", - plug = "OverseerLauncher:Cancel", - rhs = function(editor) - editor:cancel() - end, +local task_editable_params = { "cmd", "cwd" } +---@type overseer.Params +local task_builtin_params = { + -- It's kind of a hack to specify a delimiter without type = 'list'. This is + -- so the task editor displays nicely if the value is a list OR a string + cmd = { delimiter = " " }, + cwd = { + optional = true, }, } --- Telescope-specific settings for picking a new component -local function get_telescope_new_component(options) - local has_telescope = pcall(require, "telescope") - if not has_telescope then - return - end - - local themes = require("telescope.themes") - local finders = require("telescope.finders") - local entry_display = require("telescope.pickers.entry_display") - local picker_opts = themes.get_dropdown() - - local width = vim.api.nvim_win_get_width(0) - 2 - local height = vim.api.nvim_win_get_height(0) - 2 - picker_opts.layout_config.width = function(_, max_columns, _) - return math.min(max_columns, width) - end - picker_opts.layout_config.height = function(_, _, max_lines) - return math.min(max_lines, height) - end - - local max_name = 1 - for _, name in ipairs(options) do - local len = string.len(name) - if len > max_name then - max_name = len - end - end - - local displayer = entry_display.create({ - separator = " ", - items = { - { width = max_name }, - { remaining = true }, - }, - }) - - local function make_display(entry) - local columns = { - entry.value, - } - if entry.desc then - table.insert(columns, { entry.desc, "Comment" }) - end - return displayer(columns) - end - picker_opts.finder = finders.new_table({ - results = options, - entry_maker = function(item) - local comp = component.get(item) - local ordinal = item - local description - if comp then - description = comp.desc - else - description = component.stringify_alias(item) - end - if description then - ordinal = ordinal .. " " .. description - end - return { - display = make_display, - ordinal = ordinal, - desc = description, - value = item, - } - end, - }) - return picker_opts -end - +---@class overseer.TaskEditor +---@field cur_line? {[1]: number, [2]: string} +---@field bufnr integer +---@field private components overseer.ComponentDefinition[] +---@field private ext_id_to_comp_idx_and_schema_field_name table +---@field private task overseer.Task +---@field private task_data table +---@field private callback fun(task?: overseer.Task) +---@field layout fun() +---@field cleanup fun() local Editor = {} function Editor.new(task, task_cb) + -- Make sure the task doesn't get disposed while we're editing it task:inc_reference() local function callback(...) task:dec_reference() @@ -112,6 +35,7 @@ function Editor.new(task, task_cb) task_cb(...) end end + local bufnr = vim.api.nvim_create_buf(false, true) vim.bo[bufnr].swapfile = false vim.bo[bufnr].bufhidden = "wipe" @@ -123,19 +47,13 @@ function Editor.new(task, task_cb) table.insert(components, vim.deepcopy(comp.params)) end local task_data = {} - for k in pairs(Task.params) do + for k in pairs(task_builtin_params) do task_data[k] = vim.deepcopy(task[k]) end - local autocmds = {} - local cleanup, layout = form_utils.open_form_win(bufnr, { - autocmds = autocmds, - get_preferred_dim = function() - -- TODO this is causing a lot of jumping - end, - }) + local cleanup, layout = form_utils.open_form_win(bufnr, {}) vim.bo[bufnr].filetype = "OverseerForm" - local editor = setmetatable({ + local self = setmetatable({ cur_line = nil, task = task, callback = callback, @@ -143,127 +61,115 @@ function Editor.new(task, task_cb) components = components, task_name = task.name, task_data = task_data, - line_to_comp = {}, disable_close_on_leave = false, + ext_id_to_comp_idx_and_schema_field_name = {}, layout = layout, cleanup = cleanup, - autocmds = autocmds, }, { __index = Editor }) - binding_util.create_plug_bindings(bufnr, bindings, editor) - for mode, user_bindings in pairs(config.task_launcher.bindings) do - binding_util.create_bindings_to_plug(bufnr, mode, user_bindings, "OverseerLauncher:") - end + vim.keymap.set({ "i", "n" }, "", function() + self:cancel() + end, { buffer = bufnr }) + vim.keymap.set("n", "q", function() + self:cancel() + end, { buffer = bufnr }) vim.api.nvim_create_autocmd("BufWriteCmd", { desc = "Submit on buffer write", buffer = bufnr, callback = function() - editor:submit() + self:submit() end, }) - table.insert( - editor.autocmds, - vim.api.nvim_create_autocmd("BufLeave", { - desc = "Close float on BufLeave", - buffer = bufnr, - nested = true, - callback = function() - if not editor.disable_close_on_leave then - editor:cancel() - end - end, - }) - ) - table.insert( - editor.autocmds, - vim.api.nvim_create_autocmd("BufEnter", { - desc = "Reset disable_close_on_leave", - buffer = bufnr, - nested = true, - callback = function() - editor.disable_close_on_leave = false - end, - }) - ) - table.insert( - editor.autocmds, - vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { - desc = "Update form on change", - buffer = bufnr, - nested = true, - callback = function() - local lnum = vim.api.nvim_win_get_cursor(0)[1] - local line = vim.api.nvim_buf_get_lines(0, lnum - 1, lnum, true)[1] - editor.cur_line = { lnum, line } - editor:parse() - end, - }) - ) - table.insert( - editor.autocmds, - vim.api.nvim_create_autocmd("InsertLeave", { - desc = "Rerender form", - buffer = bufnr, - callback = function() - editor:render() - end, - }) - ) - table.insert( - editor.autocmds, - vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { - desc = "Update form on move cursor", - buffer = bufnr, - nested = true, - callback = function() - editor:on_cursor_move() - end, - }) - ) - return editor + vim.api.nvim_create_autocmd("BufLeave", { + desc = "Close float on BufLeave", + buffer = bufnr, + nested = true, + callback = function() + if not self.disable_close_on_leave then + self:cancel() + end + end, + }) + vim.api.nvim_create_autocmd("BufEnter", { + desc = "Reset disable_close_on_leave", + buffer = bufnr, + nested = true, + callback = function() + self.disable_close_on_leave = false + end, + }) + vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { + desc = "Update form on change", + buffer = bufnr, + nested = true, + callback = function() + local lnum = vim.api.nvim_win_get_cursor(0)[1] + local line = vim.api.nvim_buf_get_lines(0, lnum - 1, lnum, true)[1] + self.cur_line = { lnum, line } + self:parse() + vim.bo[self.bufnr].modified = false + end, + }) + vim.api.nvim_create_autocmd("InsertLeave", { + desc = "Rerender form", + buffer = bufnr, + callback = function() + self:render() + end, + }) + vim.api.nvim_create_autocmd({ "CursorMoved" }, { + desc = "Update form on move cursor", + buffer = bufnr, + nested = true, + callback = function() + self:on_cursor_move() + end, + }) + return self +end + +---@private +---@return table +function Editor:get_lnum_to_comp_idx_and_field() + local ns = vim.api.nvim_create_namespace("overseer") + local extmarks = vim.api.nvim_buf_get_extmarks(self.bufnr, ns, 0, -1, { type = "virt_text" }) + local lnum_to_field_name = {} + for _, extmark in ipairs(extmarks) do + local ext_id, row = extmark[1], extmark[2] + local comp_and_field = self.ext_id_to_comp_idx_and_schema_field_name[ext_id] + if comp_and_field then + lnum_to_field_name[row + 1] = comp_and_field + end + end + return lnum_to_field_name end function Editor:on_cursor_move() if vim.api.nvim_get_mode().mode == "i" then return end - local cur = vim.api.nvim_win_get_cursor(0) - if self.cur_line and self.cur_line[1] ~= cur[1] then + local lnum = vim.api.nvim_win_get_cursor(0)[1] + if self.cur_line and self.cur_line[1] ~= lnum then self.cur_line = nil self:render() return end - local original_cur = vim.deepcopy(cur) local vtext_ns = vim.api.nvim_create_namespace("overseer_vtext") vim.api.nvim_buf_clear_namespace(self.bufnr, vtext_ns, 0, -1) + local lnum_to_comp_and_field = self:get_lnum_to_comp_idx_and_field() - -- First line is task name, successive lines are task params - -- If cursor is on the task params, make sure it's past the label - if cur[1] > 1 and cur[1] <= #Task.ordered_params + 1 then - local param_name = Task.ordered_params[cur[1] - 1] - local schema = Task.params[param_name] - local label = form_utils.render_field(schema, "", param_name, "") - if cur[2] < string.len(label) then - cur[2] = string.len(label) - vim.api.nvim_win_set_cursor(0, cur) - end + local comp_and_field = lnum_to_comp_and_field[lnum] + if not comp_and_field then return end + local comp_idx, field_name = comp_and_field[1], comp_and_field[2] - if not self.line_to_comp[cur[1]] then - return - end - local comp, param_name = unpack(self.line_to_comp[cur[1]]) - - if param_name then - local schema = comp.params[param_name] - local label = form_utils.render_field(schema, " ", param_name, "") - if cur[2] < string.len(label) then - cur[2] = string.len(label) - end + if comp_idx and field_name then + local comp = assert(component.get(self.components[comp_idx][1])) + local schema = comp.params[field_name] if schema.desc then - vim.api.nvim_buf_set_extmark(self.bufnr, vtext_ns, cur[1] - 1, 0, { + vim.api.nvim_buf_set_extmark(self.bufnr, vtext_ns, lnum - 1, 0, { virt_text = { { schema.desc, "Comment" } }, }) end @@ -272,70 +178,102 @@ function Editor:on_cursor_move() or completion_schema.choices vim.api.nvim_buf_set_var(0, "overseer_choices", choices) end - if cur[1] ~= original_cur[1] or cur[2] ~= original_cur[2] then - vim.api.nvim_win_set_cursor(0, cur) - end end function Editor:render() local ns = vim.api.nvim_create_namespace("overseer") vim.api.nvim_buf_clear_namespace(self.bufnr, ns, 0, -1) - self.line_to_comp = {} local lines = { self.task_name } - local highlights = { { "OverseerTask", 1, 0, -1 } } + local ext_idx_to_comp_and_schema_field_name = {} + local extmarks = {} + table.insert(extmarks, { + 1, + 0, + { hl_group = "OverseerTask", end_col = #lines[1] }, + }) - for _, k in ipairs(Task.ordered_params) do - local schema = Task.params[k] + for _, k in ipairs(task_editable_params) do + local schema = task_builtin_params[k] local value = self.task_data[k] - table.insert(lines, form_utils.render_field(schema, "", k, value)) - if form_utils.validate_field(schema, value) then - table.insert(highlights, { "OverseerField", #lines, 0, string.len(k) }) - else - table.insert(highlights, { "DiagnosticError", #lines, 0, string.len(k) }) - end + table.insert(lines, tostring(form_utils.render_value(schema, value))) + local hl = form_utils.validate_field(schema, value) and "OverseerField" or "DiagnosticError" + table.insert(extmarks, { + #lines, + 0, + { + virt_text = { { k, hl }, { ": ", "NormalFloat" } }, + virt_text_pos = "inline", + undo_restore = false, + invalidate = true, + }, + }) + ext_idx_to_comp_and_schema_field_name[#extmarks] = { nil, k } end - for _, params in ipairs(self.components) do + for i, params in ipairs(self.components) do local comp = assert(component.get(params[1])) - local line = comp.name - table.insert(highlights, { "OverseerComponent", #lines + 1, 0, string.len(comp.name) }) + table.insert(lines, "") + local desc if comp.desc then - local prev_len = string.len(line) - line = string.format("%s (%s)", line, comp.desc) - table.insert(highlights, { "Comment", #lines + 1, prev_len + 1, -1 }) + desc = { string.format(" (%s)", comp.desc), "Comment" } end - table.insert(lines, line) - self.line_to_comp[#lines] = { comp, nil } + table.insert(extmarks, { + #lines, + 0, + { + virt_text = { { comp.name, "OverseerComponent" }, desc }, + virt_text_pos = "overlay", + invalidate = true, + undo_restore = false, + }, + }) + ext_idx_to_comp_and_schema_field_name[#extmarks] = { i, nil } local schema = comp.params if schema then for k, param_schema in pairs(schema) do local value = params[k] - table.insert(lines, form_utils.render_field(param_schema, " ", k, value)) - if form_utils.validate_field(param_schema, value) then - table.insert(highlights, { "OverseerField", #lines, 0, 2 + string.len(k) }) - else - table.insert(highlights, { "DiagnosticError", #lines, 0, 2 + string.len(k) }) + table.insert(lines, tostring(form_utils.render_value(param_schema, value))) + local field_hl = "OverseerField" + if not form_utils.validate_field(param_schema, value) then + field_hl = "DiagnosticError" end - self.line_to_comp[#lines] = { comp, k } + table.insert(extmarks, { + #lines, + 0, + { + virt_text = { { " " .. k, field_hl }, { ": ", "NormalFloat" } }, + virt_text_pos = "inline", + undo_restore = false, + invalidate = true, + }, + }) + ext_idx_to_comp_and_schema_field_name[#extmarks] = { i, k } end end end + + -- When in insert mode, don't overwrite whatever in-progress value the user is typing if self.cur_line and vim.api.nvim_get_mode().mode == "i" then local lnum, line = unpack(self.cur_line) lines[lnum] = line end vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, true, lines) - util.add_highlights(self.bufnr, ns, highlights) if self.task_name:match("^%s*$") then vim.api.nvim_buf_set_extmark(self.bufnr, ns, 0, 0, { virt_text = { { "Task name is required", "DiagnosticError" } }, }) end + for i, mark in ipairs(extmarks) do + local lnum, col, opts = unpack(mark) + local ext_id = vim.api.nvim_buf_set_extmark(self.bufnr, ns, lnum - 1, col, opts) + self.ext_id_to_comp_idx_and_schema_field_name[ext_id] = ext_idx_to_comp_and_schema_field_name[i] + end self:on_cursor_move() end +---@param insert_position integer function Editor:add_new_component(insert_position) self.disable_close_on_leave = true self.cur_line = nil @@ -345,6 +283,7 @@ function Editor:add_new_component(insert_position) end local options = {} + local longest_option = 1 local existing = {} for _, comp in ipairs(self.components) do existing[comp[1]] = true @@ -352,11 +291,13 @@ function Editor:add_new_component(insert_position) for _, v in ipairs(component.list_editable()) do if not existing[v] then table.insert(options, v) + longest_option = math.max(longest_option, vim.api.nvim_strwidth(v)) end end for _, v in ipairs(component.list_aliases()) do if not v:match("^default") then table.insert(options, v) + longest_option = math.max(longest_option, vim.api.nvim_strwidth(v)) end end table.sort(options) @@ -365,18 +306,18 @@ function Editor:add_new_component(insert_position) prompt = "New component", kind = "overseer_new_component", format_item = function(item) + local name = util.align(item, longest_option, "left") local comp = component.get(item) if comp then if comp.desc then - return string.format("%s - %s", item, comp.desc) + return string.format("%s %s", name, comp.desc) else return item end else - return string.format("%s [%s]", item, component.stringify_alias(item)) + return string.format("%s [%s]", name, component.stringify_alias(item)) end end, - telescope = get_telescope_new_component(options), }, function(result) self.disable_close_on_leave = false if result then @@ -389,7 +330,7 @@ function Editor:add_new_component(insert_position) else compdef = vim.tbl_deep_extend("force", component.create_default_params(v[1]), v) end - table.insert(self.components, insert_position - 1 + i, compdef) + table.insert(self.components, insert_position + i, compdef) end else local params = component.create_default_params(result) @@ -401,68 +342,47 @@ function Editor:add_new_component(insert_position) end function Editor:parse() - self.task_name = vim.api.nvim_buf_get_lines(self.bufnr, 0, 1, true)[1] - local offset = 1 - local buflines = vim.api.nvim_buf_get_lines(self.bufnr, offset, -1, true) - local comp_map = {} - local comp_idx = {} - for i, v in ipairs(self.components) do - comp_map[v[1]] = v - comp_idx[v[1]] = i + local lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, true) + self.task_name = lines[1] + local lnum_to_comp_and_field = self:get_lnum_to_comp_idx_and_field() + if vim.tbl_isempty(lnum_to_comp_and_field) then + -- user may have done an "undo" and removed all the extmarks + self:render() + return end - local insert_position = #self.components + 1 - for i, line in ipairs(buflines) do - if line:match("^%s*$") then - local comp = self.line_to_comp[i + offset] - if comp then - insert_position = comp_idx[comp[1].name] - elseif i < #buflines / 2 then - insert_position = 1 - end - self:add_new_component(insert_position) + local new_comp_insert_pos = 1 + local seen_components = {} + -- Skip the first line, which is the task name + for i = 2, #lines do + local line = lines[i] + local comp_and_field = lnum_to_comp_and_field[i] + -- If this line doesn't map to a task param, component name, or component field, then it is a + -- new blank line and we should insert a new component + if not comp_and_field then + self:add_new_component(new_comp_insert_pos) return end - end - - local seen_comps = {} - local comp - local last_idx = 0 - for _, line in ipairs(buflines) do - local prefix, name, text = line:match("^(%s*)([^%s]+): ?(.*)$") - if name and comp and prefix == " " then - local param_schema = comp.params[name] - if param_schema then - local parsed, value = form_utils.parse_value(param_schema, text) - if parsed then - comp_map[comp.name][name] = value - end + local comp_idx, field_name = comp_and_field[1], comp_and_field[2] + + if not comp_idx and field_name then + -- This is a task param + local param_schema = task_builtin_params[field_name] + local parsed, value = form_utils.parse_value(param_schema, line) + if parsed then + self.task_data[field_name] = value end - elseif name and prefix == "" then - local param_schema = Task.params[name] - if param_schema then - local parsed, value = form_utils.parse_value(param_schema, text) - if parsed then - self.task_data[name] = value - end - end - else - local comp_name = line:match("^([^%s]+) ") - if comp_name then - comp = component.get(comp_name) - if comp then - if not comp_map[comp_name] then - -- This is a new component we need to insert - last_idx = last_idx + 1 - local params = component.create_default_params(comp_name) - comp_map[comp_name] = params - comp_idx[comp_name] = last_idx - table.insert(self.components, last_idx, params) - else - last_idx = comp_idx[comp_name] - end - seen_comps[comp_name] = true - end + elseif comp_idx and not field_name then + local comp_name = self.components[comp_idx][1] + new_comp_insert_pos = new_comp_insert_pos + 1 + seen_components[comp_name] = true + elseif comp_idx and field_name then + local comp_data = self.components[comp_idx] + local comp = assert(component.get(comp_data[1])) + local param_schema = comp.params[field_name] + local parsed, value = form_utils.parse_value(param_schema, line) + if parsed then + comp_data[field_name] = value end end end @@ -470,7 +390,7 @@ function Editor:parse() -- Remove all the components that we didn't see local to_remove = {} for i, v in ipairs(self.components) do - if not seen_comps[v[1]] then + if not seen_components[v[1]] then table.insert(to_remove, 1, i) end end @@ -503,6 +423,7 @@ function Editor:submit() return c[1] end, self.components)) local to_remove = {} + ---@diagnostic disable-next-line: invisible for _, v in ipairs(self.task.components) do if not seen[v.name] then table.insert(to_remove, v.name) diff --git a/lua/overseer/task_list/actions.lua b/lua/overseer/task_list/actions.lua index 0cf938d0..6702f1d9 100644 --- a/lua/overseer/task_list/actions.lua +++ b/lua/overseer/task_list/actions.lua @@ -1,7 +1,6 @@ local component = require("overseer.component") local constants = require("overseer.constants") local form = require("overseer.form") -local task_bundle = require("overseer.task_bundle") local task_editor = require("overseer.task_editor") local task_list = require("overseer.task_list") local util = require("overseer.util") @@ -9,6 +8,12 @@ local STATUS = constants.STATUS local M +---@class (exact) overseer.Action +---@field desc? string Detailed description of what the action does +---@field condition? fun(task: overseer.Task): boolean Function to check if the action is applicable +---@field run fun(task: overseer.Task) + +---@type table M = { start = { condition = function(task) @@ -26,12 +31,6 @@ M = { task:stop() end, }, - save = { - desc = "save the task to a bundle file", - run = function(task) - task_bundle.save_task_bundle(nil, { task }) - end, - }, restart = { condition = function(task) return task.status ~= STATUS.PENDING @@ -46,10 +45,11 @@ M = { end, }, edit = { + desc = "Edit the task components directly", run = function(task) task_editor.open(task, function(t) if t then - task_list.update(t) + task_list.touch(t) end end) end, @@ -91,7 +91,7 @@ M = { end params[1] = "restart_on_save" task:set_component(params) - task_list.update(task) + task_list.touch(task) end) end, }, @@ -107,7 +107,7 @@ M = { ["open float"] = { desc = "open terminal in a floating window", condition = function(task) - return task:get_bufnr() + return task:get_bufnr() ~= nil end, run = function(task) task:open_output("float") @@ -116,7 +116,7 @@ M = { open = { desc = "open terminal in the current window", condition = function(task) - return task:get_bufnr() + return task:get_bufnr() ~= nil end, run = function(task) task:open_output() @@ -125,7 +125,7 @@ M = { ["open hsplit"] = { desc = "open terminal in a horizontal split", condition = function(task) - return task:get_bufnr() + return task:get_bufnr() ~= nil end, run = function(task) task:open_output("horizontal") @@ -134,7 +134,7 @@ M = { ["open vsplit"] = { desc = "open terminal in a vertical split", condition = function(task) - return task:get_bufnr() + return task:get_bufnr() ~= nil end, run = function(task) task:open_output("vertical") @@ -143,7 +143,7 @@ M = { ["open tab"] = { desc = "open terminal in a new tab", condition = function(task) - return task:get_bufnr() + return task:get_bufnr() ~= nil end, run = function(task) task:open_output("tab") @@ -152,7 +152,7 @@ M = { ["set quickfix diagnostics"] = { desc = "put the diagnostics results into quickfix", condition = function(task) - return task.result + return task.result ~= nil and task.result.diagnostics and not vim.tbl_isempty(task.result.diagnostics) end, @@ -163,7 +163,7 @@ M = { ["set loclist diagnostics"] = { desc = "put the diagnostics results into loclist", condition = function(task) - return task.result + return task.result ~= nil and task.result.diagnostics and not vim.tbl_isempty(task.result.diagnostics) end, @@ -177,17 +177,17 @@ M = { condition = function(task) local bufnr = task:get_bufnr() return task:is_complete() - and bufnr + and bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr) and vim.api.nvim_buf_is_loaded(bufnr) end, run = function(task) - local lines = vim.api.nvim_buf_get_lines(task:get_bufnr(), 0, -1, true) + local lines = vim.api.nvim_buf_get_lines(assert(task:get_bufnr()), 0, -1, true) vim.fn.setqflist({}, " ", { title = task.name, - context = task.name, lines = lines, -- Peep into the default component params to fetch the errorformat + ---@diagnostic disable-next-line: invisible efm = task.default_component_params.errorformat, }) vim.cmd("botright copen") diff --git a/lua/overseer/task_list/bindings.lua b/lua/overseer/task_list/bindings.lua deleted file mode 100644 index 355d6c08..00000000 --- a/lua/overseer/task_list/bindings.lua +++ /dev/null @@ -1,161 +0,0 @@ -local binding_util = require("overseer.binding_util") -local M -M = { - { - desc = "Show default key bindings", - plug = "OverseerTask:ShowHelp", - rhs = function() - binding_util.show_bindings("OverseerTask:") - end, - }, - { - desc = "Open task action menu", - plug = "OverseerTask:RunAction", - rhs = function(sidebar) - sidebar:run_action() - end, - }, - { - desc = "Edit task", - plug = "OverseerTask:Edit", - rhs = function(sidebar) - sidebar:run_action("edit") - end, - }, - { - desc = "Open task terminal in current window", - plug = "OverseerTask:Open", - rhs = function(sidebar) - sidebar:run_action("open") - end, - }, - { - desc = "Open task terminal in a split", - plug = "OverseerTask:OpenSplit", - rhs = function(sidebar) - sidebar:run_action("open hsplit") - end, - }, - { - desc = "Open task terminal in a vsplit", - plug = "OverseerTask:OpenVsplit", - rhs = function(sidebar) - sidebar:run_action("open vsplit") - end, - }, - { - desc = "Open task terminal in a floating window", - plug = "OverseerTask:OpenFloat", - rhs = function(sidebar) - sidebar:run_action("open float") - end, - }, - { - desc = "Open task output in a quickfix window", - plug = "OverseerTask:OpenQuickFix", - rhs = function(sidebar) - sidebar:run_action("open output in quickfix") - end, - }, - { - desc = "Toggle task terminal in a preview window", - plug = "OverseerTask:TogglePreview", - rhs = function(sidebar) - sidebar:toggle_preview() - end, - }, - { - desc = "Increase task detail level", - plug = "OverseerTask:IncreaseDetail", - rhs = function(sidebar) - sidebar:change_task_detail(1) - end, - }, - { - desc = "Decrease task detail level", - plug = "OverseerTask:DecreaseDetail", - rhs = function(sidebar) - sidebar:change_task_detail(-1) - end, - }, - { - desc = "Increase all task detail levels", - plug = "OverseerTask:IncreaseAllDetail", - rhs = function(sidebar) - sidebar:change_default_detail(1) - end, - }, - { - desc = "Decrease all task detail levels", - plug = "OverseerTask:DecreaseAllDetail", - rhs = function(sidebar) - sidebar:change_default_detail(-1) - end, - }, - { - desc = "Decrease window width", - plug = "OverseerTask:DecreaseWidth", - rhs = function() - local width = vim.api.nvim_win_get_width(0) - vim.api.nvim_win_set_width(0, math.max(10, width - 10)) - end, - }, - { - desc = "Increase window width", - plug = "OverseerTask:IncreaseWidth", - rhs = function() - local width = vim.api.nvim_win_get_width(0) - vim.api.nvim_win_set_width(0, math.max(10, width + 10)) - end, - }, - { - desc = "Jump to previous task", - plug = "OverseerTask:PrevTask", - rhs = function(sidebar) - sidebar:jump(-1) - end, - }, - { - desc = "Jump to next task", - plug = "OverseerTask:NextTask", - rhs = function(sidebar) - sidebar:jump(1) - end, - }, - { - desc = "Scroll up in the task output window", - plug = "OverseerTask:ScrollOutputUp", - rhs = function(sidebar) - sidebar:scroll_output(-1) - end, - }, - { - desc = "Scroll down in the task output window", - plug = "OverseerTask:ScrollOutputDown", - rhs = function(sidebar) - sidebar:scroll_output(1) - end, - }, - { - desc = "Close window", - plug = "OverseerTask:Close", - rhs = function() - vim.cmd("close") - end, - }, - { - desc = "Dispose task", - plug = "OverseerTask:Dispose", - rhs = function(sidebar) - sidebar:run_action("dispose") - end, - }, - { - desc = "Stop task", - plug = "OverseerTask:Stop", - rhs = function(sidebar) - sidebar:run_action("stop") - end, - }, -} -return M diff --git a/lua/overseer/task_list/init.lua b/lua/overseer/task_list/init.lua index d55edcab..74a9bca8 100644 --- a/lua/overseer/task_list/init.lua +++ b/lua/overseer/task_list/init.lua @@ -1,8 +1,12 @@ local util = require("overseer.util") local M = {} +---@type overseer.Task[] local tasks = {} +---@type table local lookup = {} +---@type table +local sorted_cache = {} ---@return integer M.get_or_create_bufnr = function() @@ -10,54 +14,70 @@ M.get_or_create_bufnr = function() return sidebar.bufnr end -M.rerender = function() +local function dispatch() + sorted_cache = {} vim.api.nvim_exec_autocmds("User", { pattern = "OverseerListUpdate", modeline = false }) end -local function group_parents_and_children() - local order = {} - for i, task in ipairs(tasks) do - order[task.id] = i +---@param sort? fun(a: overseer.Task, b: overseer.Task): boolean Function that sorts tasks +local function get_sorted_tasks(sort) + if not sort then + sort = M.sort_newest_first end + if sorted_cache[sort] then + return sorted_cache[sort] + end + + local child_groups = {} + local top_level = {} for _, task in ipairs(tasks) do if task.parent_id then - order[task.id] = order[task.parent_id] - 0.5 + local group = child_groups[task.parent_id] + if not group then + group = {} + child_groups[task.parent_id] = group + end + table.insert(group, task) + else + table.insert(top_level, task) + end + end + + table.sort(top_level, sort) + for _, children in pairs(child_groups) do + table.sort(children, sort) + end + + local ret = {} + for _, task in ipairs(top_level) do + table.insert(ret, task) + local children = child_groups[task.id] + if children then + for _, child in ipairs(children) do + table.insert(ret, child) + end end end - table.sort(tasks, function(a, b) - return order[a.id] < order[b.id] - end) + + sorted_cache[sort] = ret + return ret end +---Trigger a re-render without re-sorting the tasks ---@param task? overseer.Task -M.update = function(task) - if not task then - M.rerender() - return - end - if task:is_disposed() then +M.touch = function(task) + if not task or task:is_disposed() then return end if not lookup[task.id] then lookup[task.id] = task table.insert(tasks, task) - group_parents_and_children() end - M.rerender() + dispatch() end ----@param task overseer.Task -M.touch_task = function(task) - if not lookup[task.id] then - return - end - local idx = util.tbl_index(tasks, task.id, function(t) - return t.id - end) - table.remove(tasks, idx) - table.insert(tasks, task) - group_parents_and_children() - M.rerender() +M.on_task_updated = function() + dispatch() end ---@param task overseer.Task @@ -69,7 +89,7 @@ M.remove = function(task) break end end - M.rerender() + dispatch() end ---@param id number @@ -88,51 +108,36 @@ M.get_by_name = function(name) end end --- 1-indexed, most recent first ----@param index number ----@return overseer.Task|nil -M.get_by_index = function(index) - return tasks[#tasks + 1 - index] -end - ----@class overseer.ListTaskOpts +---@class (exact) overseer.ListTaskOpts ---@field unique? boolean Deduplicates non-running tasks by name ----@field name? string|string[] Only list tasks with this name or names ----@field name_not? boolean Invert the name search (tasks *without* that name) ---@field status? overseer.Status|overseer.Status[] Only list tasks with this status or statuses ----@field status_not? boolean Invert the status search ----@field recent_first? boolean The most recent tasks are first in the list ----@field bundleable? boolean Only list tasks that should be included in a bundle ----@field filter? fun(task: overseer.Task): boolean +---@field include_ephemeral? boolean Include ephemeral tasks +---@field wrapped? boolean Include tasks that were created by the jobstart/vim.system wrappers +---@field filter? fun(task: overseer.Task): boolean Only include tasks where this function returns true +---@field sort? fun(a: overseer.Task, b: overseer.Task): boolean Function that sorts tasks ---@param opts? overseer.ListTaskOpts ---@return overseer.Task[] M.list_tasks = function(opts) opts = opts or {} - vim.validate({ - unique = { opts.unique, "b", true }, - -- name is string or list - name_not = { opts.name_not, "b", true }, - -- status is string or list - status_not = { opts.status_not, "b", true }, - recent_first = { opts.recent_first, "b", true }, - bundleable = { opts.bundleable, "b", true }, - filter = { opts.filter, "f", true }, - }) - local name = util.list_to_map(opts.name or {}) + vim.validate("unique", opts.unique, "boolean", true) + vim.validate("status", opts.status, function(n) + return type(n) == "string" or type(n) == "table" + end, true) + vim.validate("wrapped", opts.wrapped, "boolean", true) + vim.validate("include_ephemeral", opts.include_ephemeral, "boolean", true) + vim.validate("filter", opts.filter, "function", true) + vim.validate("sort", opts.sort, "function", true) + local status = util.list_to_map(opts.status or {}) local seen = {} local ret = {} - for _, task in ipairs(tasks) do + for _, task in ipairs(get_sorted_tasks(opts.sort)) do if - ( - not opts.name - or (name[task.name] and not opts.name_not) - or (not name[task.name] and opts.name_not) - ) - and (not opts.status or (status[task.status] and not opts.status_not) or (not status[task.status] and opts.status_not)) - and (not opts.bundleable or task:should_include_in_bundle()) + (not opts.status or status[task.status]) + and (opts.include_ephemeral or not task.ephemeral) and (not opts.filter or opts.filter(task)) + and (opts.wrapped or not task.source) then local idx = seen[task.name] if idx and opts.unique then @@ -150,10 +155,69 @@ M.list_tasks = function(opts) end end end - if opts.recent_first then - util.tbl_reverse(ret) - end return ret end +---General purpose sort by status and start/end time +---@param a overseer.Task +---@param b overseer.Task +---@return boolean +M.default_sort = function(a, b) + if (a.time_end == nil) ~= (b.time_end == nil) then + -- If only one of the tasks has already ended, put the other one first + return a.time_end == nil + elseif (a.time_start ~= nil) ~= (b.time_start ~= nil) then + -- If only one of the tasks has started, put the other one first + return a.time_start == nil + elseif a.time_end ~= nil and a.time_end ~= b.time_end then + -- If both tasks have ended, sort by end time (most recent first) + return a.time_end > b.time_end + elseif a.time_start ~= nil and a.time_start ~= b.time_start then + -- If both tasks have started, sort by start time (most recent first) + return a.time_start > b.time_start + end + + -- fall back to sort by name + return a.name < b.name +end + +---A sort function that puts the newest tasks first +---@param a overseer.Task +---@param b overseer.Task +---@return boolean +M.sort_newest_first = function(a, b) + if a.time_start == nil then + if b.time_start == nil then + -- fall back to sort by name + return a.name < b.name + else + return true + end + elseif b.time_start == nil then + return false + end + + -- Sort newest first + return a.time_start > b.time_start +end + +---A sort function that puts tasks that finished most recently first +---@param a overseer.Task +---@param b overseer.Task +---@return boolean +M.sort_finished_recently = function(a, b) + if (a.time_end == nil) ~= (b.time_end == nil) then + return a.time_end ~= nil + elseif a.time_end ~= nil and a.time_end ~= b.time_end then + return a.time_end > b.time_end + elseif (a.time_start == nil) ~= (b.time_start == nil) then + return a.time_start ~= nil + elseif a.time_start ~= nil and a.time_start ~= b.time_start then + return a.time_start > b.time_start + else + -- fall back to sort by name + return a.name < b.name + end +end + return M diff --git a/lua/overseer/task_list/keymaps.lua b/lua/overseer/task_list/keymaps.lua new file mode 100644 index 00000000..16efab0d --- /dev/null +++ b/lua/overseer/task_list/keymaps.lua @@ -0,0 +1,121 @@ +local M = {} + +---@return overseer.Sidebar +local function get_sidebar() + return assert(require("overseer.task_list.sidebar").get()) +end + +M.show_help = { + desc = "Show default keymaps", + callback = function() + local config = require("overseer.config") + require("overseer.keymap_util").show_help(config.task_list.keymaps) + end, +} + +M.run_action = { + desc = "Run an action on the current task", + callback = function(opts) + opts = opts or {} + local sb = get_sidebar() + sb:run_action(opts.action) + end, + parameters = { + action = { + type = "string", + desc = "Run an action on the current task", + }, + }, +} + +M.open = { + desc = "Open task output", + callback = function(opts) + opts = opts or {} + local sb = get_sidebar() + if opts.dir == "split" then + sb:run_action("open hsplit") + elseif opts.dir == "vsplit" then + sb:run_action("open vsplit") + elseif opts.dir == "tab" then + sb:run_action("open tab") + elseif opts.dir == "float" then + sb:run_action("open float") + else + sb:run_action("open") + end + end, + parameters = { + dir = { + type = '"split"|"vsplit"|"tab"|"float"', + desc = "type of window to open the task output in", + }, + }, +} + +M.prev_task = { + desc = "Jump to previous task", + callback = function() + local sb = get_sidebar() + sb:jump(-1) + end, +} + +M.next_task = { + desc = "Jump to next task", + callback = function() + local sb = get_sidebar() + sb:jump(1) + end, +} + +M.scroll_output_up = { + desc = "Scroll up in the task output window", + callback = function() + local sb = get_sidebar() + sb:scroll_output(-1) + end, +} + +M.scroll_output_down = { + desc = "Scroll down in the task output window", + callback = function() + local sb = get_sidebar() + sb:scroll_output(1) + end, +} + +M.toggle_preview = { + desc = "Toggle task output in a preview floating window", + callback = function() + local sb = get_sidebar() + sb:toggle_preview() + end, +} + +M.toggle_show_wrapped = { + desc = "Toggle showing wrapped builtin jobstart/vim.system tasks", + callback = function() + local sb = get_sidebar() + sb:toggle_show_wrapped() + end, +} + +---List keymaps for documentation generation +---@private +M._get_keymaps = function() + local ret = {} + for name, keymap in pairs(M) do + if type(keymap) == "table" and keymap.desc then + table.insert(ret, { + name = name, + desc = keymap.desc, + deprecated = keymap.deprecated, + parameters = keymap.parameters, + }) + end + end + return ret +end + +return M diff --git a/lua/overseer/task_list/sidebar.lua b/lua/overseer/task_list/sidebar.lua index 96f7ef06..26e5dcb5 100644 --- a/lua/overseer/task_list/sidebar.lua +++ b/lua/overseer/task_list/sidebar.lua @@ -1,8 +1,7 @@ local TaskView = require("overseer.task_view") local action_util = require("overseer.action_util") -local binding_util = require("overseer.binding_util") -local bindings = require("overseer.task_list.bindings") local config = require("overseer.config") +local keymap_util = require("overseer.keymap_util") local layout = require("overseer.layout") local task_list = require("overseer.task_list") local util = require("overseer.util") @@ -11,35 +10,35 @@ local M = {} ---@class overseer.Sidebar ---@field bufnr integer ----@field default_detail integer ----@field private task_lines {[1]: integer, [2]: overseer.Task}[] ----@field private task_detail table +---@field private task_lines {[1]: integer, [2]: integer, [3]: overseer.Task}[] ---@field private preview? overseer.TaskView ---@field private focused_task_id? integer +---@field private list_task_opts overseer.ListTaskOpts local Sidebar = {} local ref +---@return overseer.Sidebar +---@return boolean M.get_or_create = function() local sb = M.get() local created = not sb if not sb then - ref = Sidebar.new() - sb = ref - sb:render(task_list.list_tasks()) + sb = Sidebar.new() + ref = sb + sb:render() end return sb, created end +---@return nil|overseer.Sidebar M.get = function() if ref and vim.api.nvim_buf_is_loaded(ref.bufnr) and vim.api.nvim_buf_is_valid(ref.bufnr) then return ref end end -local MIN_DETAIL = 1 -local MAX_DETAIL = 3 - +---@return overseer.Sidebar function Sidebar.new() local bufnr = vim.api.nvim_create_buf(false, true) @@ -52,12 +51,12 @@ function Sidebar.new() local self = setmetatable({ bufnr = bufnr, - default_detail = config.task_list.default_detail, - task_detail = {}, task_lines = {}, preview = nil, + list_task_opts = { include_ephemeral = true, sort = config.task_list.sort }, }, { __index = Sidebar }) self:init() + ---@cast self overseer.Sidebar return self end @@ -89,12 +88,24 @@ function Sidebar:init() pattern = "OverseerListUpdate", desc = "[Overseer] Update task list when tasks change", callback = function() - self:render(task_list.list_tasks()) + self:render() end, }) - binding_util.create_plug_bindings(self.bufnr, bindings, self) - binding_util.create_bindings_to_plug(self.bufnr, "n", config.task_list.bindings, "OverseerTask:") + local periodic_update + periodic_update = function() + if not vim.api.nvim_buf_is_valid(self.bufnr) then + return + end + -- only rerender if the buffer is visible + if self:get_winid() then + self:render() + end + vim.defer_fn(periodic_update, 1000) -- update every second + end + periodic_update() + + keymap_util.set_keymaps(config.task_list.keymaps, self.bufnr) end ---@private @@ -134,43 +145,44 @@ end ---@private ---@return nil|overseer.Task +---@return nil|integer offset function Sidebar:get_task_from_line(lnum) if not lnum then local winid = self:get_winid() if not winid then - return nil + return nil, nil end lnum = vim.api.nvim_win_get_cursor(winid)[1] end for _, v in ipairs(self.task_lines) do - local end_lnum, task = v[1], v[2] - if end_lnum >= lnum then - return task + local start_lnum, last_lnum, task = v[1], v[2], v[3] + if lnum <= last_lnum then + return task, lnum - start_lnum end end end ---@param task_id integer -function Sidebar:focus_task_id(task_id) +---@param offset? integer +function Sidebar:focus_task_id(task_id, offset) local winid = self:get_winid() if not winid then return end - local start_lnum = 1 + offset = offset or 0 for _, v in ipairs(self.task_lines) do - local end_lnum, task = v[1], v[2] + local start_line, task = v[1], v[3] if task.id == task_id then - vim.api.nvim_win_set_cursor(winid, { start_lnum, 0 }) + pcall(vim.api.nvim_win_set_cursor, winid, { start_line + offset, 0 }) self:set_task_focused(task_id) return end - start_lnum = end_lnum + 2 end end ---@param bufnr integer ----@param winlayout nil|any +---@param winlayout? vim.fn.winlayout.branch|vim.fn.winlayout.leaf|vim.fn.winlayout.empty ---@return nil|"left"|"right"|"bottom" local function detect_direction(bufnr, winlayout) if not winlayout then @@ -178,12 +190,14 @@ local function detect_direction(bufnr, winlayout) end local type = winlayout[1] if type == "leaf" then + ---@cast winlayout vim.fn.winlayout.leaf if vim.api.nvim_win_get_buf(winlayout[2]) == bufnr then return "left" else return nil end else + ---@cast winlayout vim.fn.winlayout.branch for i, nested in ipairs(winlayout[2]) do local dir = detect_direction(bufnr, nested) if dir then @@ -198,7 +212,7 @@ local function detect_direction(bufnr, winlayout) end function Sidebar:toggle_preview() - if self.preview and not self.preview:is_disposed() then + if self.preview and not self.preview:is_win_closed() then self.preview:dispose() return end @@ -217,7 +231,6 @@ function Sidebar:toggle_preview() local col = (direction == "left" and (win_width + padding) or padding) local winid = vim.api.nvim_open_win(0, false, { relative = "editor", - border = config.task_win.border, row = 1, col = col, width = width, @@ -230,6 +243,7 @@ function Sidebar:toggle_preview() end self.preview = TaskView.new(winid, { close_on_list_close = true, + list_task_opts = self.list_task_opts, select = function(_, tasks, task_under_cursor) return task_under_cursor or tasks[1] end, @@ -244,26 +258,6 @@ function Sidebar:toggle_preview() util.scroll_to_end(winid) end -function Sidebar:change_task_detail(delta) - local task = self:get_task_from_line() - if not task then - return - end - local detail = self.task_detail[task.id] or self.default_detail - self.task_detail[task.id] = math.max(MIN_DETAIL, math.min(MAX_DETAIL, detail + delta)) - task_list.update(task) -end - -function Sidebar:change_default_detail(delta) - self.default_detail = math.max(MIN_DETAIL, math.min(MAX_DETAIL, self.default_detail + delta)) - for i, v in pairs(self.task_detail) do - if (delta < 0 and v > self.default_detail) or (delta > 0 and v < self.default_detail) then - self.task_detail[i] = nil - end - end - task_list.update() -end - ---@private ---@return integer[] function Sidebar:get_output_wins() @@ -285,42 +279,31 @@ function Sidebar:highlight_focused() return end - local start_lnum = 1 for _, v in ipairs(self.task_lines) do - local end_lnum, task = v[1], v[2] + local start_lnum, end_lnum, task = v[1], v[2], v[3] if task.id == self.focused_task_id then - if vim.fn.has("nvim-0.10") == 1 then - vim.api.nvim_buf_set_extmark(self.bufnr, ns, start_lnum - 1, 0, { - line_hl_group = "CursorLine", - end_row = end_lnum - 1, - }) - else - for i = start_lnum, end_lnum do - vim.api.nvim_buf_add_highlight(self.bufnr, ns, "CursorLine", i - 1, 0, -1) - end - end + vim.api.nvim_buf_set_extmark(self.bufnr, ns, start_lnum - 1, 0, { + line_hl_group = "CursorLine", + end_row = end_lnum - 1, + }) end - start_lnum = end_lnum + 2 end end function Sidebar:jump(direction) local lnum = vim.api.nvim_win_get_cursor(0)[1] - local prev = 1 - local cur = 1 - for _, v in ipairs(self.task_lines) do - local end_lnum = v[1] - local next = end_lnum + 2 - if end_lnum >= lnum then - if direction < 0 then - vim.api.nvim_win_set_cursor(0, { prev, 0 }) - else - pcall(vim.api.nvim_win_set_cursor, 0, { next, 0 }) + for i, v in ipairs(self.task_lines) do + local first_line = v[1] + if first_line <= lnum then + if direction < 0 and i > 1 then + local new_lnum = self.task_lines[i - 1][1] + vim.api.nvim_win_set_cursor(0, { new_lnum, 0 }) + elseif direction > 0 and i < #self.task_lines then + local new_lnum = self.task_lines[i + 1][1] + vim.api.nvim_win_set_cursor(0, { new_lnum, 0 }) end return end - prev = cur - cur = next end end @@ -344,7 +327,6 @@ function Sidebar:scroll_output(direction) end function Sidebar:run_action(name) - vim.validate({ name = { name, "s", true } }) local task = self:get_task_from_line() if not task then return @@ -353,97 +335,70 @@ function Sidebar:run_action(name) action_util.run_task_action(task, name) end -function Sidebar:render(tasks) +function Sidebar:toggle_show_wrapped() + self.list_task_opts.wrapped = not self.list_task_opts.wrapped + self:render() +end + +function Sidebar:render() if not vim.api.nvim_buf_is_valid(self.bufnr) then return false end - local prev_num_lines = vim.api.nvim_buf_line_count(self.bufnr) - local prev_first_task = self:get_task_from_line(1) - local prev_first_task_id = prev_first_task and prev_first_task.id + local tasks = task_list.list_tasks(self.list_task_opts) local ns = vim.api.nvim_create_namespace("overseer") vim.api.nvim_buf_clear_namespace(self.bufnr, ns, 0, -1) local lines = {} - local highlights = {} + local extmarks = {} self.task_lines = {} - local subtask_prefix = "▐ " + -- virtual text lines for separating processes + local border = "OverseerTaskBorder" + local tl = config.task_list + local sep_lines = { { { tl.separator, border } } } + local child_indent = { tl.child_indent[1], border } + local child_sep_1 = { { { tl.child_indent[2], border }, { tl.separator, border } } } + local child_sep_2 = { { { tl.child_indent[3], border }, { tl.separator, border } } } + -- Iterate backwards so we show most recent tasks first - for i = #tasks, 1, -1 do - local task = tasks[i] - local detail = self.task_detail[task.id] or self.default_detail - local start_idx = #lines + 1 - local hl_start_idx = #highlights + 1 - task:render(lines, highlights, detail) + for i, task in ipairs(tasks) do + local line_start = #lines + 1 + local task_lines = config.task_list.render(task) -- Indent subtasks if task.parent_id then - for j = start_idx, #lines do - lines[j] = subtask_prefix .. lines[j] - end - for j = hl_start_idx, #highlights do - local hl = highlights[j] - hl[3] = hl[3] + subtask_prefix:len() - if hl[4] ~= -1 then - hl[4] = hl[4] + subtask_prefix:len() - end - highlights[j] = hl - end - for j = start_idx, #lines do - table.insert(highlights, { "OverseerTaskBorder", j, 0, subtask_prefix:len() }) + for j = 1, #task_lines do + table.insert(task_lines[j], 1, child_indent) end end - table.insert(self.task_lines, { #lines, task }) - if i > 1 then - if tasks[i - 1].parent_id then - table.insert(lines, subtask_prefix .. vim.fn.strcharpart(config.task_list.separator, 2)) + + vim.list_extend(lines, task_lines) + table.insert(self.task_lines, { line_start, #lines, task }) + + -- task separator + if i < #tasks then + local prev_is_child = i > 1 and tasks[i - 1].parent_id ~= nil + local next_is_child = tasks[i + 1].parent_id ~= nil + if next_is_child then + table.insert(extmarks, { #lines - 1, 0, { virt_lines = child_sep_1 } }) + elseif prev_is_child then + table.insert(extmarks, { #lines - 1, 0, { virt_lines = child_sep_2 } }) else - table.insert(lines, config.task_list.separator) + table.insert(extmarks, { #lines - 1, 0, { virt_lines = sep_lines } }) end - table.insert(highlights, { "OverseerTaskBorder", #lines, 0, -1 }) end end - -- Attempting to render a newline within a line will cause a crash - for i, line in ipairs(lines) do - lines[i] = line:gsub("\n", " ") + util.render_buf_chunks(self.bufnr, ns, lines) + for _, extmark in ipairs(extmarks) do + vim.api.nvim_buf_set_extmark(self.bufnr, ns, extmark[1], extmark[2], extmark[3]) end local sidebar_winid = self:get_winid() - local view - if sidebar_winid then - vim.api.nvim_win_call(sidebar_winid, function() - view = vim.fn.winsaveview() - end) - end - vim.bo[self.bufnr].modifiable = true - vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, true, lines) - vim.bo[self.bufnr].modifiable = false - vim.bo[self.bufnr].modified = false - util.add_highlights(self.bufnr, ns, highlights) - if sidebar_winid then - if view then - vim.api.nvim_win_call(sidebar_winid, function() - vim.fn.winrestview(view) - end) - end - - local new_first_task = tasks[#tasks] - local new_first_task_id = new_first_task and new_first_task.id - local new_line_count = vim.api.nvim_buf_line_count(self.bufnr) - - local in_sidebar = vim.api.nvim_get_current_win() == sidebar_winid - local in_output_win = vim.b.overseer_task ~= nil - if - not in_sidebar - and not in_output_win - and prev_first_task_id ~= new_first_task_id - and new_first_task_id - then - self:focus_task_id(new_first_task_id) - elseif prev_num_lines ~= new_line_count then - -- Make sure our cursor stays on the previously focused task, even if it's moved - self:focus_task_id(self.focused_task_id) + local cursor = vim.api.nvim_win_get_cursor(sidebar_winid) + local task, offset = self:get_task_from_line(cursor[1]) + if task then + self:focus_task_id(task.id, offset) end end diff --git a/lua/overseer/task_view.lua b/lua/overseer/task_view.lua index 53ad499d..0ffe8048 100644 --- a/lua/overseer/task_view.lua +++ b/lua/overseer/task_view.lua @@ -1,3 +1,4 @@ +local config = require("overseer.config") local task_list = require("overseer.task_list") local min_win_opts = { @@ -18,21 +19,23 @@ local function set_minimal_win_opts(winid) end ---@class (exact) overseer.TaskViewOpts ----@field select? fun(self: overseer.TaskView, tasks: overseer.Task[], task_under_cursor: overseer.Task?): overseer.Task? +---@field select? fun(self: overseer.TaskView, tasks: overseer.Task[], task_under_cursor?: overseer.Task): nil|overseer.Task Select which task in the task list to display the output of ---@field close_on_list_close? boolean Close the window when the task list is closed +---@field list_task_opts? overseer.ListTaskOpts Passed to list_tasks() to get the list of tasks to pass to the select() function ---@class overseer.TaskView ---@field winid integer ---@field private select fun(self: overseer.TaskView, tasks: overseer.Task[], task_under_cursor: overseer.Task?): overseer.Task? ---@field private autocmd_ids integer[] +---@field private list_task_opts? overseer.ListTaskOpts local TaskView = {} ----@param winid integer +---@param winid? integer ---@param opts? overseer.TaskViewOpts ---@return overseer.TaskView function TaskView.new(winid, opts) opts = opts or {} - if winid == 0 then + if not winid or winid == 0 then winid = vim.api.nvim_get_current_win() end set_minimal_win_opts(winid) @@ -41,6 +44,8 @@ function TaskView.new(winid, opts) select = opts.select or function(self, tasks) return tasks[1] end, + list_task_opts = opts.list_task_opts + or { include_ephemeral = true, sort = config.task_list.sort }, autocmd_ids = {}, } setmetatable(self, { __index = TaskView }) @@ -117,7 +122,7 @@ function TaskView.new(winid, opts) end ---@return boolean -function TaskView:is_disposed() +function TaskView:is_win_closed() return not vim.api.nvim_win_is_valid(self.winid) end @@ -134,7 +139,8 @@ local function get_empty_bufnr() end function TaskView:update() - if self:is_disposed() then + if self:is_win_closed() then + self:dispose() return end @@ -142,10 +148,10 @@ function TaskView:update() TaskView.task_under_cursor = nil end - local tasks = task_list.list_tasks({ recent_first = true }) + local tasks = task_list.list_tasks(self.list_task_opts) local task = self.select(self, tasks, TaskView.task_under_cursor) -- select() function can call dispose() - if self:is_disposed() then + if self:is_win_closed() then return end diff --git a/lua/overseer/template/init.lua b/lua/overseer/template.lua similarity index 56% rename from lua/overseer/template/init.lua rename to lua/overseer/template.lua index 2810f076..986f4287 100644 --- a/lua/overseer/template/init.lua +++ b/lua/overseer/template.lua @@ -1,3 +1,4 @@ +local Task = require("overseer.task") local component = require("overseer.component") local config = require("overseer.config") local files = require("overseer.files") @@ -5,47 +6,40 @@ local form = require("overseer.form") local form_utils = require("overseer.form.utils") local log = require("overseer.log") local util = require("overseer.util") ----@diagnostic disable-next-line: deprecated -local islist = vim.islist or vim.tbl_islist local M = {} ----@class overseer.TemplateFileProvider ----@field module? string The name of the module this was loaded from ----@field condition? overseer.SearchCondition +---@class (exact) overseer.TemplateFileProvider +---@field condition? overseer.SearchCondition simple condition checks for when this provider is available ---@field cache_key? fun(opts: overseer.SearchParams): nil|string ----@field generator fun(opts: overseer.SearchParams, cb: fun(tmpls: overseer.TemplateDefinition[])) +---@field generator fun(opts: overseer.SearchParams, cb: fun(tmpls_or_err: string|overseer.TemplateDefinition[])) : nil|string|overseer.TemplateDefinition[] ----@class overseer.TemplateProvider : overseer.TemplateFileProvider +---@class (exact) overseer.TemplateProvider : overseer.TemplateFileProvider ---@field name string - ----@class overseer.TemplateFileDefinition ---@field module? string The name of the module this was loaded from ----@field aliases? string[] ----@field desc? string ----@field tags? string[] ----@field params? overseer.Params|fun(): overseer.Params ----@field priority? number ----@field condition? overseer.SearchCondition ----@field builder fun(params: table): overseer.TaskDefinition + +---@class (exact) overseer.TemplateFileDefinition +---@field aliases? string[] alternate names for the task. Used when running a task by name in overseer.run_task +---@field desc? string extended description of the task +---@field tags? string[] collection of tags used for optional task filtering +---@field params? overseer.Params|fun(): overseer.Params parameters to customize the task. If any are missing when the task is built, overseer will prompt the user for input +---@field condition? overseer.SearchCondition simple condition checks for when this task is available +---@field builder fun(params: table): overseer.TaskDefinition function that builds the task definition from the parameters ---@field hide? boolean Hide from the template list ----@class overseer.TemplateDefinition : overseer.TemplateFileDefinition +---@class (exact) overseer.TemplateDefinition : overseer.TemplateFileDefinition ---@field name string +---@field module? string The name of the module this was loaded from ----@class overseer.SearchCondition +---@class (exact) overseer.SearchCondition ---@field filetype? string|string[] ---@field dir? string|string[] ----@field callback? fun(search: overseer.SearchParams): boolean, nil|string ---@alias overseer.Params table -local DEFAULT_PRIORITY = 50 - ----@type table -local registry = {} - ---@type overseer.TemplateProvider[] -local providers = {} +local registered_providers = {} +---@type overseer.TemplateProvider[] +local file_providers = {} ---@type table local cached_provider_results = {} @@ -55,6 +49,77 @@ local clear_cache_autocmd local hooks = {} +---@param defn overseer.TemplateProvider +local function validate_template_provider(defn) + vim.validate("name", defn.name, "string") + vim.validate("generator", defn.generator, "function") + vim.validate("cache_key", defn.cache_key, "function", true) +end + +---@param name string +---@return nil|overseer.TemplateDefinition|overseer.TemplateProvider +local function load_template(name) + local ok, defn = pcall(require, name) + if not ok then + log.error("Error loading template '%s': %s", name, defn) + return + end + name = name:gsub("^overseer%.template%.", "") + -- If this module was just a list of names, then it's an alias for a + -- collection of templates + if vim.islist(defn) then + log.warn("Deprecated: Template '%s' is a list of templates. We don't need this anymore.", name) + else + if not defn.name then + defn.name = name + end + defn.module = name + return defn + end +end + +local _combined_providers +local last_rtp +---@return overseer.TemplateProvider[] +local function get_providers() + if last_rtp == vim.o.runtimepath then + return _combined_providers + end + file_providers = {} + last_rtp = vim.o.runtimepath + + local all_dirs = vim.list_extend({ "overseer/template" }, config.template_dirs) + for _, dir in ipairs(all_dirs) do + local path = vim.fs.joinpath("lua", dir, "**", "*.lua") + local task_files = vim.api.nvim_get_runtime_file(path, true) + for _, abspath in ipairs(task_files) do + local module_name = abspath:match("^.*(overseer/template/.*)%.lua$"):gsub("/", ".") + local tmpl = load_template(module_name) + if tmpl then + if tmpl.generator then + table.insert(file_providers, tmpl) + else + local provider = { + name = tmpl.module, + module = tmpl.module, + condition = tmpl.condition, + generator = function() + return { tmpl } + end, + } + ---@cast provider overseer.TemplateProvider + validate_template_provider(provider) + table.insert(file_providers, provider) + end + end + end + end + + _combined_providers = vim.list_extend({}, registered_providers) + vim.list_extend(_combined_providers, file_providers) + return _combined_providers +end + ---@param condition? overseer.SearchCondition Template conditions ---@param tags? string[] Template tags ---@param search overseer.SearchParams Search parameters @@ -64,6 +129,9 @@ local hooks = {} local function condition_matches(condition, tags, search, match_tags) condition = condition or {} if condition.filetype then + if not search.filetype then + return false, string.format("Does not match filetype %s", vim.inspect(condition.filetype)) + end local search_fts = vim.split(search.filetype, ".", { plain = true }) local any_ft_match = false for _, ft in util.iter_as_list(condition.filetype) do @@ -104,12 +172,6 @@ local function condition_matches(condition, tags, search, match_tags) end end - if condition.callback then - local passed, message = condition.callback(search) - if not passed then - return false, message - end - end return true end @@ -138,73 +200,13 @@ local function hook_matches(opts, search, name, module) return true end ----@param name string -M.load_template = function(name) - local ok, defn - for _, template_dir in ipairs(config.template_dirs) do - ok, defn = pcall(require, string.format("%s.%s", template_dir, name)) - if ok then - break - end - end - if not ok then - log:error("Error loading template '%s': %s", name, defn) - return - end - -- If this module was just a list of names, then it's an alias for a - -- collection of templates - if islist(defn) then - for _, v in ipairs(defn) do - M.load_template(v) - end - else - if not defn.name then - defn.name = name - end - defn.module = name - local register_ok, err = pcall(M.register, defn) - if not register_ok then - log:error("Error loading template '%s': %s", name, err) - end - end -end - -local initialized = false -local function initialize() - if initialized then - return - end - for _, name in ipairs(config.templates) do - M.load_template(name) - end - initialized = true -end - ----@param defn overseer.TemplateProvider -local function validate_template_provider(defn) - vim.validate({ - name = { defn.name, "s" }, - generator = { defn.generator, "f" }, - cache_key = { defn.cache_key, "f", true }, - }) - if not defn.cache_key then - defn.cache_key = function(opts) - return nil - end - end -end - ---@param defn overseer.TemplateDefinition local function validate_template_definition(defn) - defn.priority = defn.priority or DEFAULT_PRIORITY defn.params = defn.params or {} - vim.validate({ - name = { defn.name, "s" }, - desc = { defn.desc, "s", true }, - tags = { defn.tags, "t", true }, - priority = { defn.priority, "n" }, - builder = { defn.builder, "f" }, - }) + vim.validate("name", defn.name, "string") + vim.validate("desc", defn.desc, "string", true) + vim.validate("tags", defn.tags, "table", true) + vim.validate("builder", defn.builder, "function") local params = defn.params if type(params) == "table" then form_utils.validate_params(params) @@ -246,8 +248,9 @@ end ---@param tmpl overseer.TemplateDefinition ---@param search overseer.SearchParams ---@param params table +---@param on_build? fun(task_defn: overseer.TaskDefinition, util: overseer.TaskUtil) ---@return overseer.TaskDefinition -local function build_task_args(tmpl, search, params) +local function build_task_args(tmpl, search, params, on_build) local task_defn = tmpl.builder(params) task_defn.components = component.resolve(task_defn.components or { "default" }) @@ -257,12 +260,15 @@ local function build_task_args(tmpl, search, params) end end + if on_build then + on_build(task_defn, task_util) + end return task_defn end ---@class overseer.HookOptions : overseer.SearchCondition ----@field module? string ----@field name? string +---@field module? string Only run if the template module matches this pattern (using string.match) +---@field name? string Only run if the template name matches this pattern (using string.match) ---@param opts nil|overseer.HookOptions ---@param hook fun(task_defn: overseer.TaskDefinition, util: overseer.TaskUtil) @@ -300,32 +306,39 @@ M.register = function(defn) if defn.generator then ---@cast defn overseer.TemplateProvider validate_template_provider(defn) - table.insert(providers, defn) + table.insert(registered_providers, defn) else ---@cast defn overseer.TemplateDefinition validate_template_definition(defn) - registry[defn.name] = defn + local provider = { + name = defn.name, + module = defn.module, + condition = defn.condition, + generator = function() + return { defn } + end, + } + ---@cast provider overseer.TemplateProvider + validate_template_provider(provider) + table.insert(registered_providers, provider) end end ---Check if template should prompt user for input. Exposed for testing ---@private ----@param prompt "always"|"never"|"allow"|"missing"|"avoid" +---@param disallow_prompt? boolean ---@param param_schema table ---@param params table ---@return nil|boolean ---@return nil|string Error message if error is present -M._should_prompt = function(prompt, param_schema, params) +M._should_prompt = function(disallow_prompt, param_schema, params) if vim.tbl_isempty(param_schema) then return false end - local show_prompt = prompt == "always" + local show_prompt = false for k, schema in pairs(param_schema) do -- This parameter has no value passed in via the API if params[k] == nil then - if prompt == "missing" then - show_prompt = true - end local has_default = schema.default ~= nil if has_default then -- Set the default value into the params, if any @@ -333,16 +346,10 @@ M._should_prompt = function(prompt, param_schema, params) end -- If the param is not optional, process possible prompt values to show the prompt or error - if not schema.optional then - if prompt == "allow" then - show_prompt = true - end - if not has_default then - if prompt == "avoid" then - show_prompt = true - elseif prompt == "never" then - return nil, string.format("Missing param %s", k) - end + if not schema.optional and not has_default then + show_prompt = true + if disallow_prompt then + return nil, string.format("Missing param %s", k) end end end @@ -351,30 +358,27 @@ M._should_prompt = function(prompt, param_schema, params) end ---@class overseer.TemplateBuildOpts ----@field prompt? "always"|"never"|"allow"|"missing"|"avoid" ---@field params table ---@field search overseer.SearchParams +---@field disallow_prompt? boolean +---@field on_build? fun(task_defn: overseer.TaskDefinition, util: overseer.TaskUtil) ---@param tmpl overseer.TemplateDefinition ---@param opts overseer.TemplateBuildOpts ----@param callback fun(task: overseer.TaskDefinition|nil, err: string|nil) +---@param callback fun(err: string|nil, task: overseer.TaskDefinition|nil, params: table|nil) M.build_task_args = function(tmpl, opts, callback) - vim.validate({ - prompt = { opts.prompt, "s", true }, - params = { opts.params, "t" }, - }) - opts.prompt = opts.prompt or config.default_template_prompt + vim.validate("params", opts.params, "table") local param_schema = tmpl.params or {} if type(param_schema) == "function" then param_schema = param_schema() form_utils.validate_params(param_schema) end - local show_prompt, err = M._should_prompt(opts.prompt, param_schema, opts.params) + local show_prompt, err = M._should_prompt(opts.disallow_prompt, param_schema, opts.params) if err then - return callback(nil, err) + return callback(err) end if not show_prompt then - callback(build_task_args(tmpl, opts.search, opts.params)) + callback(nil, build_task_args(tmpl, opts.search, opts.params, opts.on_build), opts.params) return end @@ -384,13 +388,51 @@ M.build_task_args = function(tmpl, opts, callback) end form.open(tmpl.name, schema, opts.params, function(final_params) if final_params then - callback(build_task_args(tmpl, opts.search, final_params)) + callback(nil, build_task_args(tmpl, opts.search, final_params, opts.on_build), final_params) else callback() end end) end +---@class overseer.TaskBuildOpts : overseer.TemplateBuildOpts +---@field cwd? string +---@field env? table + +---@param tmpl overseer.TemplateDefinition +---@param opts overseer.TaskBuildOpts +---@param callback fun(err: string|nil, task: overseer.Task|nil) +M.build_task = function(tmpl, opts, callback) + M.build_task_args(tmpl, opts, function(err, task_defn, params) + if err then + return callback(err) + end + if not task_defn then + return callback() + end + assert(task_defn) + assert(params) + local task + if opts.cwd then + task_defn.cwd = opts.cwd + end + if task_defn.env or opts.env then + task_defn.env = vim.tbl_deep_extend("force", task_defn.env or {}, opts.env or {}) + end + + ---@diagnostic disable-next-line: invisible + task_defn.from_template = { + name = tmpl.name, + env = opts.env, + params = params or {}, + search = opts.search, + } + + task = Task.new(task_defn) + callback(nil, task) + end) +end + ---@class overseer.SearchParams ---@field filetype? string ---@field tags? string[] @@ -398,23 +440,32 @@ end ---@param opts overseer.SearchParams M.clear_cache = function(opts) - for _, provider in ipairs(providers) do - local cache_key = provider.cache_key(opts) - if cache_key then - cached_provider_results[cache_key] = nil + for _, provider in ipairs(get_providers()) do + if provider.cache_key then + local cache_key = provider.cache_key(opts) + if cache_key then + cached_provider_results[cache_key] = nil + end end end end +---@class (exact) overseer.Report +---@field providers table + +---@class (exact) overseer.ProviderReport +---@field message? string +---@field from_cache? boolean +---@field total_tasks integer +---@field available_tasks integer +---@field elapsed_ms integer + ---@param opts overseer.SearchParams ----@param cb fun(templates: overseer.TemplateDefinition[], report: table) +---@param cb fun(templates: overseer.TemplateDefinition[], report: overseer.Report) M.list = function(opts, cb) - initialize() - vim.validate({ - tags = { opts.tags, "t", true }, - dir = { opts.dir, "s" }, - filetype = { opts.filetype, "s", true }, - }) + vim.validate("tags", opts.tags, "table", true) + vim.validate("dir", opts.dir, "string") + vim.validate("filetype", opts.filetype, "string", true) -- Make sure the search dir is an absolute path opts.dir = vim.fn.fnamemodify(opts.dir, ":p") @@ -433,21 +484,10 @@ M.list = function(opts, cb) end local ret = {} + ---@type overseer.Report local report = { - templates = {}, providers = {}, } - -- First add all of the simple templates that match the condition - for _, tmpl in pairs(registry) do - local is_match, message = condition_matches(tmpl.condition, tmpl.tags, opts, true) - if is_match then - table.insert(ret, tmpl) - end - report.templates[tmpl.name] = { - is_present = is_match, - message = message, - } - end local finished_iterating = false local pending = {} @@ -456,14 +496,6 @@ M.list = function(opts, cb) if not finished_iterating or not vim.tbl_isempty(pending) then return end - -- Make sure results are sorted by priority, and then name - table.sort(ret, function(a, b) - if a.priority == b.priority then - return a.name < b.name - else - return a.priority < b.priority - end - end) cb(ret, report) end @@ -471,76 +503,101 @@ M.list = function(opts, cb) local start_times = {} local timed_out = false ---This is the async callback that is passed to generators - ---@param tmpls overseer.TemplateDefinition[] + ---@param tmpls_or_err string|overseer.TemplateDefinition[] ---@param provider_name string ---@param module nil|string ---@param cache_key nil|string ---@param from_cache nil|boolean - local function handle_tmpls(tmpls, provider_name, module, cache_key, from_cache) - local elapsed_ms = (vim.loop.hrtime() - start_times[provider_name]) / 1e6 + local function handle_tmpls(tmpls_or_err, provider_name, module, cache_key, from_cache) + local elapsed_ms = (vim.uv.now() - start_times[provider_name]) if cache_key - and config.template_cache_threshold > 0 - and elapsed_ms >= config.template_cache_threshold + and config.template_cache_threshold_ms > 0 + and elapsed_ms >= config.template_cache_threshold_ms + and type(tmpls_or_err) == "table" then - log:debug("Caching %s: [%s] = %d", provider_name, cache_key, #tmpls) - cached_provider_results[cache_key] = tmpls + log.debug("Caching %s: [%s] = %d", provider_name, cache_key, #tmpls_or_err) + cached_provider_results[cache_key] = tmpls_or_err end if not pending[provider_name] then if not timed_out then - log:warn("Template %s double-called callback", provider_name) + log.warn("Template %s double-called callback", provider_name) end return end pending[provider_name] = nil local num_available = 0 - for _, tmpl in ipairs(tmpls) do - -- Set the module on the template so it can be used to match hooks - tmpl.module = module - local ok, err = pcall(validate_template_definition, tmpl) - if ok then - if condition_matches(tmpl.condition, tmpl.tags, opts, true) then - num_available = num_available + 1 - table.insert(ret, tmpl) + + if type(tmpls_or_err) == "string" then + report.providers[provider_name] = { + message = tmpls_or_err, + from_cache = false, + total_tasks = 0, + available_tasks = 0, + elapsed_ms = elapsed_ms, + } + else + for _, tmpl in ipairs(tmpls_or_err) do + -- Set the module on the template so it can be used to match hooks + tmpl.module = module + local ok, err = pcall(validate_template_definition, tmpl) + if ok then + if condition_matches(tmpl.condition, tmpl.tags, opts, true) then + num_available = num_available + 1 + table.insert(ret, tmpl) + end + else + log.error("Template %s from %s: %s", tmpl.name, provider_name, err) end - else - log:error("Template %s from %s: %s", tmpl.name, provider_name, err) end + report.providers[provider_name] = { + message = nil, + from_cache = from_cache, + total_tasks = #tmpls_or_err, + available_tasks = num_available, + elapsed_ms = elapsed_ms, + } end - report.providers[provider_name] = { - is_present = true, - message = nil, - from_cache = from_cache, - total_tasks = #tmpls, - available_tasks = num_available, - } final_callback() end -- Timeout - if config.template_timeout > 0 then + if config.template_timeout_ms > 0 then vim.defer_fn(function() if not vim.tbl_isempty(pending) then timed_out = true - log:error("Listing templates timed out. Pending providers: %s", vim.tbl_keys(pending)) + log.error("Listing templates timed out. Pending providers: %s", vim.tbl_keys(pending)) pending = {} final_callback() -- Make sure that the callback doesn't get called again cb = function() end end - end, config.template_timeout) + end, config.template_timeout_ms) end - for _, provider in ipairs(providers) do + for _, provider in ipairs(get_providers()) do local provider_name = provider.name local is_match, message = condition_matches(provider.condition, nil, opts, false) if is_match then - local cache_key = provider.cache_key(opts) - local provider_cb = function(tmpls) - handle_tmpls(tmpls, provider_name, provider.module, cache_key) + local cache_key + if provider.cache_key then + cache_key = provider.cache_key(opts) end - start_times[provider.name] = vim.loop.hrtime() + local provider_done = false + ---@param tmpls_or_err string|overseer.TemplateDefinition[] + local provider_cb = function(tmpls_or_err) + if provider_done then + log.error( + "Template provider %s: generator callback called twice. This can also happen if you return results from the function and call the callback.", + provider.name + ) + else + provider_done = true + handle_tmpls(tmpls_or_err, provider_name, provider.module, cache_key) + end + end + start_times[provider.name] = vim.uv.now() pending[provider.name] = true if cache_key and cached_provider_results[cache_key] then handle_tmpls( @@ -555,19 +612,21 @@ M.list = function(opts, cb) if ok then if tmpls then -- if there was a return value, the generator completed synchronously - -- TODO deprecate this flow provider_cb(tmpls) end else - log:error("Template provider %s: %s", provider.name, tmpls) + assert(type(tmpls) == "string") + log.error("Template provider %s: %s", provider.name, tmpls) + local errmsg = vim.split(tmpls, "\n", { plain = true })[1] + provider_cb(errmsg) end end else report.providers[provider_name] = { - is_present = is_match, message = message, total_tasks = 0, available_tasks = 0, + elapsed_ms = 0, } end end @@ -579,12 +638,6 @@ end ---@param opts overseer.SearchParams ---@param cb fun(template: nil|overseer.TemplateDefinition) M.get_by_name = function(name, opts, cb) - initialize() - local ret = registry[name] - if ret and condition_matches(ret.condition, ret.tags, opts, false) then - cb(ret) - return - end M.list(opts, function(templates) for _, tmpl in ipairs(templates) do if tmpl.name == name or (tmpl.aliases and vim.tbl_contains(tmpl.aliases, name)) then diff --git a/lua/overseer/template/builtin.lua b/lua/overseer/template/builtin.lua deleted file mode 100644 index cf2cbc47..00000000 --- a/lua/overseer/template/builtin.lua +++ /dev/null @@ -1,16 +0,0 @@ -return { - "cargo", - "just", - "make", - "npm", - "shell", - "tox", - "vscode", - "mage", - "mix", - "deno", - "rake", - "task", - "composer", - "cargo-make", -} diff --git a/lua/overseer/template/cargo-make.lua b/lua/overseer/template/cargo-make.lua index 59b2da70..98c27228 100644 --- a/lua/overseer/template/cargo-make.lua +++ b/lua/overseer/template/cargo-make.lua @@ -1,77 +1,39 @@ -local files = require("overseer.files") -local log = require("overseer.log") -local overseer = require("overseer") - ----@type overseer.TemplateFileDefinition -local tmpl = { - priority = 60, - params = { - args = { type = "list", delimiter = " " }, - cwd = { optional = true }, - }, - builder = function(params) - local cmd = { "cargo-make", "make" } - return { - args = params.args, - cmd = cmd, - cwd = params.cwd, - } - end, -} - ----@param opts overseer.SearchParams ----@return nil|string -local function get_cargo_make_file(opts) - return vim.fs.find("Makefile.toml", { upward = true, type = "file", path = opts.dir })[1] -end - +---@type overseer.TemplateFileProvider return { - cache_key = function(opts) - return get_cargo_make_file(opts) - end, - condition = { - callback = function(opts) - if vim.fn.executable("cargo-make") == 0 then - return false, 'Command "cargo-make" not found' - end - if not get_cargo_make_file(opts) then - return false, 'No "Makefile.toml" file found' - end - return true - end, - }, - generator = function(opts, cb) - local ret = {} - - local cargo_make_file = get_cargo_make_file(opts) + generator = function(opts) + if vim.fn.executable("cargo-make") == 0 then + return 'Command "cargo-make" not found' + end + local cargo_make_file = + vim.fs.find("Makefile.toml", { upward = true, type = "file", path = opts.dir })[1] if not cargo_make_file then - log:error("No Makefile.toml file found") - cb(ret) - return + return 'No "Makefile.toml" file found' end + local ret = {} + local cargo_make_file_dir = vim.fs.dirname(cargo_make_file) - local data = files.read_file(cargo_make_file) - if not data then - log:error("Failed to read Makefile.toml file") - cb(ret) - return + local file = io.open(cargo_make_file, "r") + if not file then + return "Failed to read Makefile.toml file" end - for s in vim.gsplit(data, "\n", { plain = true }) do + for s in file:lines() do local _, _, task_name = string.find(s, "^%[tasks%.(.+)%]$") if task_name ~= nil then - table.insert( - ret, - overseer.wrap_template( - tmpl, - { name = string.format("cargo-make %s", task_name) }, - { args = { task_name }, cwd = cargo_make_file_dir } - ) - ) + table.insert(ret, { + name = string.format("cargo-make %s", task_name), + builder = function() + return { + cmd = { "cargo-make", "make", task_name }, + cwd = cargo_make_file_dir, + } + end, + }) end end + file:close() - cb(ret) + return ret end, } diff --git a/lua/overseer/template/cargo.lua b/lua/overseer/template/cargo.lua index 8e350235..c70bf61c 100644 --- a/lua/overseer/template/cargo.lua +++ b/lua/overseer/template/cargo.lua @@ -1,39 +1,8 @@ local constants = require("overseer.constants") local json = require("overseer.json") -local log = require("overseer.log") local overseer = require("overseer") local TAG = constants.TAG ----@type overseer.TemplateFileDefinition -local tmpl = { - priority = 60, - params = { - args = { type = "list", delimiter = " " }, - cwd = { optional = true }, - relative_file_root = { - desc = "Relative filepaths will be joined to this root (instead of task cwd)", - optional = true, - }, - }, - builder = function(params) - return { - cmd = { "cargo" }, - args = params.args, - cwd = params.cwd, - default_component_params = { - errorformat = [[%Eerror: %\%%(aborting %\|could not compile%\)%\@!%m,]] - .. [[%Eerror[E%n]: %m,]] - .. [[%Inote: %m,]] - .. [[%Wwarning: %\%%(%.%# warning%\)%\@!%m,]] - .. [[%C %#--> %f:%l:%c,]] - .. [[%E left:%m,%C right:%m %f:%l:%c,%Z,]] - .. [[%.%#panicked at \'%m\'\, %f:%l:%c]], - relative_file_root = params.relative_file_root, - }, - } - end, -} - ---@param opts overseer.SearchParams ---@return nil|string local function get_cargo_file(opts) @@ -43,102 +12,89 @@ end ---@param cwd string ---@param cb fun(error: nil|string, workspace_root: nil|string) local function get_workspace_root(cwd, cb) - local jid = vim.fn.jobstart({ "cargo", "metadata", "--no-deps", "--format-version", "1" }, { + overseer.builtin.system({ "cargo", "metadata", "--no-deps", "--format-version", "1" }, { cwd = cwd, - stdout_buffered = true, - on_stdout = function(j, output) - local ok, data = pcall(json.decode, table.concat(output, "")) - if ok then - if data.workspace_root then - cb(nil, data.workspace_root) - else - cb("No workspace_root found in output") - end + text = true, + }, function(out) + local ok, data = pcall(json.decode, out.stdout) + if ok then + if data.workspace_root then + cb(nil, data.workspace_root) else - cb(data) + cb("No workspace_root found in output") end - end, - }) - if jid == 0 then - cb("Passed invalid arguments to 'cargo metadata'") - elseif jid == -1 then - cb("'cargo' is not executable") - end + else + cb(data) + end + end) end +local commands = { + { args = { "build" }, tags = { TAG.BUILD } }, + { args = { "run" }, tags = { TAG.RUN } }, + { args = { "test" }, tags = { TAG.TEST } }, + { args = { "clean" }, tags = { TAG.CLEAN } }, + { args = { "check" } }, + { args = { "doc" } }, + { args = { "doc", "--open" } }, + { args = { "bench" } }, + { args = { "update" } }, + { args = { "publish" } }, + { args = { "clippy" } }, + { args = { "fmt" } }, +} + return { cache_key = function(opts) return get_cargo_file(opts) end, - condition = { - callback = function(opts) - if vim.fn.executable("cargo") == 0 then - return false, 'Command "cargo" not found' - end - if not get_cargo_file(opts) then - return false, "No Cargo.toml file found" - end - return true - end, - }, generator = function(opts, cb) - local cargo_dir = vim.fs.dirname(assert(get_cargo_file(opts))) + if vim.fn.executable("cargo") == 0 then + return 'Command "cargo" not found' + end + local cargo_file = get_cargo_file(opts) + if not cargo_file then + return "No Cargo.toml file found" + end + local cargo_dir = vim.fs.dirname(cargo_file) local ret = {} get_workspace_root(cargo_dir, function(err, workspace_root) if err then - log:error("Error fetching cargo workspace_root: %s", err) - cb(ret) - return + return cb(err) end - local commands = { - { args = { "build" }, tags = { TAG.BUILD } }, - { args = { "run" }, tags = { TAG.RUN } }, - { args = { "test" }, tags = { TAG.TEST } }, - { args = { "clean" }, tags = { TAG.CLEAN } }, - { args = { "check" } }, - { args = { "doc" } }, - { args = { "doc", "--open" } }, - { args = { "bench" } }, - { args = { "update" } }, - { args = { "publish" } }, - { args = { "clippy" } }, - { args = { "fmt" } }, - } - local roots = - { { - postfix = "", - cwd = cargo_dir, - priority = 55, - } } + local roots = { { + postfix = "", + cwd = cargo_dir, + } } if workspace_root ~= cargo_dir then roots[1].relative_file_root = workspace_root table.insert(roots, { postfix = " (workspace)", cwd = workspace_root }) end for _, root in ipairs(roots) do for _, command in ipairs(commands) do - table.insert( - ret, - overseer.wrap_template( - tmpl, - { - name = string.format("cargo %s%s", table.concat(command.args, " "), root.postfix), - tags = command.tags, - priority = root.priority, - }, - { args = command.args, cwd = root.cwd, relative_file_root = root.relative_file_root } - ) - ) + table.insert(ret, { + name = string.format("cargo %s%s", table.concat(command.args, " "), root.postfix), + tags = command.tags, + builder = function() + return { + cmd = vim.list_extend({ "cargo" }, command.args), + cwd = root.cwd, + default_component_params = { + errorformat = [[%Eerror: %\%%(aborting %\|could not compile%\)%\@!%m,]] + .. [[%Eerror[E%n]: %m,]] + .. [[%Inote: %m,]] + .. [[%Wwarning: %\%%(%.%# warning%\)%\@!%m,]] + .. [[%C %#--> %f:%l:%c,]] + .. [[%E left:%m,%C right:%m %f:%l:%c,%Z,]] + .. [[%.%#panicked at \'%m\'\, %f:%l:%c]], + relative_file_root = root.relative_file_root, + }, + } + end, + }) end - table.insert( - ret, - overseer.wrap_template( - tmpl, - { name = "cargo" .. root.postfix }, - { cwd = root.cwd, relative_file_root = root.relative_file_root } - ) - ) end cb(ret) end) diff --git a/lua/overseer/template/composer.lua b/lua/overseer/template/composer.lua index 4b238498..8f6caad4 100644 --- a/lua/overseer/template/composer.lua +++ b/lua/overseer/template/composer.lua @@ -1,60 +1,35 @@ local files = require("overseer.files") -local overseer = require("overseer") - ----@type overseer.TemplateFileDefinition -local tmpl = { - priority = 60, - params = { - args = { type = "list", delimiter = " " }, - cwd = { optional = true }, - }, - builder = function(params) - local cmd = { "composer" } - return { - args = params.args, - cmd = cmd, - cwd = params.cwd, - } - end, -} - -local function get_composer_file(opts) - return vim.fs.find("composer.json", { upward = true, type = "file", path = opts.dir })[1] -end +---@type overseer.TemplateFileProvider return { - cache_key = function(opts) - return get_composer_file(opts) - end, - condition = { - callback = function(opts) - if vim.fn.executable("composer") == 0 then - return false, "executable composer not found" - end - if not get_composer_file(opts) then - return false, "No composer.json file found" - end - return true - end, - }, - generator = function(opts, cb) - local package = get_composer_file(opts) + generator = function(opts) + if vim.fn.executable("composer") == 0 then + return "executable composer not found" + end + local package = + vim.fs.find("composer.json", { upward = true, type = "file", path = opts.dir })[1] + if not package then + return "No composer.json file found" + end local data = files.load_json_file(package) local ret = {} local scripts = data.scripts - if scripts then - for k in pairs(scripts) do - table.insert( - ret, - overseer.wrap_template( - tmpl, - { name = string.format("composer %s", k) }, - { args = { "run-script", k } } - ) - ) - end + if not scripts or vim.tbl_isempty(scripts) then + return "No scripts in composer.json" + end + local cwd = vim.fs.dirname(package) + + for k in pairs(scripts) do + table.insert(ret, { + name = string.format("composer %s", k), + builder = function() + return { + cmd = { "composer", "run-script", k }, + cwd = cwd, + } + end, + }) end - table.insert(ret, overseer.wrap_template(tmpl, { name = "composer" })) - cb(ret) + return ret end, } diff --git a/lua/overseer/template/deno.lua b/lua/overseer/template/deno.lua index 5da87b72..bb4cfbe7 100644 --- a/lua/overseer/template/deno.lua +++ b/lua/overseer/template/deno.lua @@ -1,62 +1,34 @@ local files = require("overseer.files") -local overseer = require("overseer") - ----@type overseer.TemplateFileDefinition -local tmpl = { - priority = 60, - params = { - args = { type = "list", delimiter = " " }, - cwd = { optional = true }, - }, - builder = function(params) - local cmd = { "deno" } - return { - args = params.args, - cmd = cmd, - cwd = params.cwd, - } - end, -} - -local function get_deno_file(opts) - local deno_json = { "deno.json", "deno.jsonc" } - return vim.fs.find(deno_json, { upward = true, type = "file", path = opts.dir })[1] -end +---@type overseer.TemplateFileProvider return { - cache_key = function(opts) - return get_deno_file(opts) - end, - condition = { - callback = function(opts) - if vim.fn.executable("deno") == 0 then - return false, "executable deno not found" - end - if not get_deno_file(opts) then - return false, "No deno.{json,jsonc} file found" - end - return true - end, - }, - generator = function(opts, cb) - local package = get_deno_file(opts) + generator = function(opts) + if vim.fn.executable("deno") == 0 then + return "executable deno not found" + end + local deno_json = { "deno.json", "deno.jsonc" } + local package = vim.fs.find(deno_json, { upward = true, type = "file", path = opts.dir })[1] + if not package then + return "No deno.{json,jsonc} file found" + end local package_dir = vim.fs.dirname(package) local data = files.load_json_file(package) local ret = {} local tasks = data.tasks - if tasks then - for k in pairs(tasks) do - table.insert( - ret, - overseer.wrap_template( - tmpl, - { name = string.format("deno %s", k) }, - { args = { "task", k }, cwd = package_dir } - ) - ) - end + if not tasks or vim.tbl_isempty(tasks) then + return "no tasks found in deno json file" + end + for k in pairs(tasks) do + table.insert(ret, { + name = string.format("deno %s", k), + builder = function() + return { + cmd = { "deno", "task", k }, + cwd = package_dir, + } + end, + }) end - table.insert(ret, overseer.wrap_template(tmpl, { name = "deno" })) - cb(ret) + return ret end, } diff --git a/lua/overseer/template/just.lua b/lua/overseer/template/just.lua index b4502fb0..d44219b5 100644 --- a/lua/overseer/template/just.lua +++ b/lua/overseer/template/just.lua @@ -1,12 +1,16 @@ local log = require("overseer.log") +local overseer = require("overseer") + +---@param name string +---@return boolean +local function is_justfile(name) + name = name:lower() + return name == "justfile" or name == ".justfile" +end ---@param opts overseer.SearchParams ---@return nil|string local function get_justfile(opts) - local is_justfile = function(name) - name = name:lower() - return name == "justfile" or name == ".justfile" - end return vim.fs.find(is_justfile, { upward = true, path = opts.dir })[1] end @@ -15,87 +19,71 @@ local tmpl = { cache_key = function(opts) return get_justfile(opts) end, - condition = { - callback = function(opts) - if vim.fn.executable("just") == 0 then - return false, 'Command "just" not found' - end - if not get_justfile(opts) then - return false, "No justfile found" - end - return true - end, - }, generator = function(opts, cb) + if vim.fn.executable("just") == 0 then + return 'Command "just" not found' + end + local justfile = get_justfile(opts) + if not justfile then + return "No justfile found" + end + local cwd = vim.fs.dirname(justfile) local ret = {} - local jid = vim.fn.jobstart({ "just", "--unstable", "--dump", "--dump-format", "json" }, { - cwd = opts.dir, - stdout_buffered = true, - on_stdout = vim.schedule_wrap(function(j, output) - local ok, data = - pcall(vim.json.decode, table.concat(output, ""), { luanil = { object = true } }) + overseer.builtin.system( + { "just", "--unstable", "--dump", "--dump-format", "json" }, + { + cwd = cwd, + text = true, + }, + vim.schedule_wrap(function(out) + if out.code ~= 0 then + cb(out.stderr or out.stdout or "Error running 'just'") + return + end + local ok, data = pcall(vim.json.decode, out.stdout, { luanil = { object = true } }) if not ok then - log:error("just produced invalid json: %s\n%s", data, output) - cb(ret) + log.error("just produced invalid json: %s", out.stdout) + cb(string.format("just produced invalid json: %s\n%s", data)) return end assert(data) - for k, recipe in pairs(data.recipes) do - if recipe.private then - goto continue - end - local params_defn = {} - for _, param in ipairs(recipe.parameters) do - local param_defn = { - default = param.default, - type = param.kind == "singular" and "string" or "list", - delimiter = " ", - } - -- We don't want "star" arguments to be optional = true because then we won't show the - -- input form. Instead, let's set a default value and filter it out in the builder. - if param.kind == "star" and param.default == nil then - if param_defn.type == "string" then - param_defn.default = "" - else - param_defn.default = {} - end + for _, recipe in pairs(data.recipes) do + if not recipe.private then + local params_defn = {} + for _, param in ipairs(recipe.parameters) do + params_defn[param.name] = { + default = param.default, + type = param.kind == "singular" and "string" or "list", + delimiter = " ", + } end - params_defn[param.name] = param_defn - end - table.insert(ret, { - name = string.format("just %s", recipe.name), - desc = recipe.doc, - priority = k == data.first and 55 or 60, - params = params_defn, - builder = function(params) - local cmd = { "just", recipe.name } - for _, param in ipairs(recipe.parameters) do - local v = params[param.name] - if v then - if type(v) == "table" then - vim.list_extend(cmd, v) - elseif v ~= "" then - table.insert(cmd, v) + + table.insert(ret, { + name = string.format("just %s", recipe.name), + desc = recipe.doc, + params = params_defn, + builder = function(params) + local cmd = { "just", recipe.name } + for _, param in ipairs(recipe.parameters) do + local v = params[param.name] + if v and v ~= "" then + if type(v) == "table" then + vim.list_extend(cmd, v) + else + table.insert(cmd, v) + end end end - end - return { - cmd = cmd, - } - end, - }) - ::continue:: + return { + cmd = cmd, + } + end, + }) + end end cb(ret) - end), - }) - if jid == 0 then - log:error("Passed invalid arguments to 'just'") - cb(ret) - elseif jid == -1 then - log:error("'just' is not executable") - cb(ret) - end + end) + ) end, } diff --git a/lua/overseer/template/mage.lua b/lua/overseer/template/mage.lua index 4ef25037..a71d2eeb 100644 --- a/lua/overseer/template/mage.lua +++ b/lua/overseer/template/mage.lua @@ -1,9 +1,7 @@ +local overseer = require("overseer") -- A make/rake-like build tool using Go -- https://magefile.org/ -local log = require("overseer.log") -local overseer = require("overseer") - ---@param opts overseer.SearchParams ---@return nil|string local function get_magefile(opts) @@ -20,88 +18,53 @@ local function get_magedir(opts) return vim.fs.find("magefiles", { upward = true, type = "directory", path = opts.dir })[1] end ----@type overseer.TemplateDefinition -local template = { - name = "mage", - priority = 60, - params = { - ---@type overseer.StringParam - target = { optional = false, type = "string", desc = "target" }, - ---@type overseer.ListParam - args = { optional = true, type = "list", delimiter = " " }, - }, - builder = function(params) - local cmd = { "mage" } - if params.target then - table.insert(cmd, params.target) - end - - ---@type overseer.TaskDefinition - local task = { cmd = cmd } - - if params.args then - task.args = params.args - end - return task - end, -} - ---@type overseer.TemplateFileProvider -local provider = { +return { cache_key = function(opts) local magefile = get_magefile(opts) return magefile ~= nil and magefile or get_magedir(opts) end, - condition = { - callback = function(opts) - if vim.fn.executable("mage") == 0 then - return false, 'Command "mage" not found' - end - if not (get_magedir(opts) or get_magefile(opts)) then - return false, "No magefile.go file or magefiles directory found" - end - return true - end, - }, generator = function(opts, cb) - local ret = {} + if vim.fn.executable("mage") == 0 then + return 'Command "mage" not found' + end local magefile, magedir = get_magefile(opts), get_magedir(opts) - local jid = vim.fn.jobstart({ - "mage", - "-l", - }, { - env = { MAGEFILE_ENABLE_COLOR = "false" }, - cwd = magefile ~= nil and vim.fs.dirname(magefile) - or (magedir ~= nil and vim.fs.dirname(magedir) or opts.dir), - stdout_buffered = true, - on_stdout = vim.schedule_wrap(function(_, output) - for _, line in ipairs(output) do - if #line > 0 then - local task_name, asterick, description = line:match("^ ([%w:]+)(%*?)%s+(.*)") + if not (magedir or magefile) then + return "No magefile.go file or magefiles directory found" + end + local cwd = magefile ~= nil and vim.fs.dirname(magefile) + or (magedir ~= nil and vim.fs.dirname(magedir) or opts.dir) + local ret = {} + overseer.builtin.system( + { "mage", "-l" }, + { + env = { MAGEFILE_ENABLE_COLOR = "false" }, + cwd = cwd, + text = true, + }, + vim.schedule_wrap(function(out) + if out.code ~= 0 then + return cb(out.stderr or out.stdout or "Error running 'mage -l'") + end + for line in vim.gsplit(out.stdout, "\n") do + if line ~= "" then + local task_name, _, description = line:match("^ ([%w:]+)(%*?)%s+(.*)") if task_name ~= nil then - local override = { + table.insert(ret, { name = string.format("mage %s", task_name), desc = #description > 0 and description or nil, - } - -- default task - if asterick == "*" then - override.priority = 55 - end - table.insert(ret, overseer.wrap_template(template, override, { target = task_name })) + builder = function() + return { + cmd = { "mage", task_name }, + cwd = cwd, + } + end, + }) end end end cb(ret) - end), - }) - if jid == 0 then - log:error("Passed invalid arguments to 'mage'") - cb(ret) - elseif jid == -1 then - log:error("'mage' is not executable") - cb(ret) - end + end) + ) end, } - -return provider diff --git a/lua/overseer/template/make.lua b/lua/overseer/template/make.lua index b67bf3db..145e2211 100644 --- a/lua/overseer/template/make.lua +++ b/lua/overseer/template/make.lua @@ -1,69 +1,4 @@ -local constants = require("overseer.constants") -local log = require("overseer.log") local overseer = require("overseer") -local TAG = constants.TAG - ----@type overseer.TemplateFileDefinition -local tmpl = { - name = "make", - priority = 60, - tags = { TAG.BUILD }, - params = { - args = { optional = true, type = "list", delimiter = " " }, - cwd = { optional = true }, - }, - builder = function(params) - return { - cmd = { "make" }, - args = params.args, - cwd = params.cwd, - } - end, -} - -local function parse_make_output(cwd, ret, cb) - local jid = vim.fn.jobstart({ "make", "-rRpq" }, { - cwd = cwd, - stdout_buffered = true, - env = { - ["LANG"] = "C.UTF-8", - }, - on_stdout = vim.schedule_wrap(function(j, output) - local parsing = false - local prev_line = "" - for _, line in ipairs(output) do - if line:find("# Files") == 1 then - parsing = true - elseif line:find("# Finished Make") == 1 then - break - elseif parsing then - if line:match("^[^%.#%s]") and prev_line:find("# Not a target") ~= 1 then - local idx = line:find(":") - if idx then - local target = line:sub(1, idx - 1) - local override = { name = string.format("make %s", target) } - table.insert( - ret, - overseer.wrap_template(tmpl, override, { args = { target }, cwd = cwd }) - ) - end - end - end - prev_line = line - end - - cb(ret) - end), - }) - if jid == 0 then - log:error("Passed invalid arguments to 'make'") - cb(ret) - elseif jid == -1 then - log:error("'make' is not executable") - cb(ret) - end -end - ---@param opts overseer.SearchParams ---@return nil|string local function get_makefile(opts) @@ -71,27 +6,64 @@ local function get_makefile(opts) end ---@type overseer.TemplateFileProvider -local provider = { +return { cache_key = function(opts) return get_makefile(opts) end, - condition = { - callback = function(opts) - if vim.fn.executable("make") == 0 then - return false, 'Command "make" not found' - end - if not get_makefile(opts) then - return false, "No Makefile found" - end - return true - end, - }, generator = function(opts, cb) - local makefile = assert(get_makefile(opts)) + if vim.fn.executable("make") == 0 then + return 'Command "make" not found' + end + local makefile = get_makefile(opts) + if not makefile then + return "No Makefile found" + end local cwd = vim.fs.dirname(makefile) - local ret = { overseer.wrap_template(tmpl, nil, { cwd = cwd }) } - parse_make_output(cwd, ret, cb) + local ret = {} + overseer.builtin.system( + { "make", "-rRpq" }, + { + cwd = cwd, + text = true, + env = { + ["LANG"] = "C.UTF-8", + }, + }, + vim.schedule_wrap(function(out) + if out.code ~= 0 and out.code ~= 1 then + return cb(out.stderr or out.stdout or "Error running 'make'") + end + + local parsing = false + local prev_line = "" + for line in vim.gsplit(out.stdout, "\n") do + if line:find("# Files") == 1 then + parsing = true + elseif line:find("# Finished Make") == 1 then + break + elseif parsing then + if line:match("^[^%.#%s]") and prev_line:find("# Not a target") ~= 1 then + local idx = line:find(":") + if idx then + local target = line:sub(1, idx - 1) + table.insert(ret, { + name = string.format("make %s", target), + builder = function() + return { + cmd = { "make", target }, + cwd = cwd, + } + end, + }) + end + end + end + prev_line = line + end + + cb(ret) + end) + ) end, } -return provider diff --git a/lua/overseer/template/mix.lua b/lua/overseer/template/mix.lua index 9284473f..b095b9f7 100644 --- a/lua/overseer/template/mix.lua +++ b/lua/overseer/template/mix.lua @@ -1,78 +1,45 @@ -local log = require("overseer.log") local overseer = require("overseer") - ----@type overseer.TemplateFileDefinition -local tmpl = { - priority = 60, - params = { - subcmd = { optional = true }, - args = { type = "list", delimiter = " ", default = {} }, - }, - builder = function(params) - local cmd = { "mix" } - local args = params.args or {} - if params.subcmd then - table.insert(args, 1, params.subcmd) - end - return { - cmd = cmd, - args = args, - } - end, -} - ---@param opts overseer.SearchParams ---@return nil|string local function get_mix_file(opts) return vim.fs.find("mix.exs", { upward = true, type = "file", path = opts.dir })[1] end +---@type overseer.TemplateFileProvider return { cache_key = function(opts) return get_mix_file(opts) end, - condition = { - callback = function(opts) - if not get_mix_file(opts) then - return false, "No mix.exs file found" - end - return true - end, - }, generator = function(opts, cb) -- mix will not return all the tasks unless you invoke it in the mix.exs folder - local mix_folder = vim.fs.dirname(assert(get_mix_file(opts))) + local mix_file = get_mix_file(opts) + if not mix_file then + return "No mix.exs file found" + end + local mix_folder = vim.fs.dirname(mix_file) local ret = {} - local jid = vim.fn.jobstart({ - "mix", - "help", - }, { - cwd = mix_folder, - stdout_buffered = true, - on_stdout = vim.schedule_wrap(function(j, output) - for _, line in ipairs(output) do + overseer.builtin.system( + { "mix", "help" }, + { + cwd = mix_folder, + text = true, + }, + vim.schedule_wrap(function(out) + if out.code ~= 0 then + return cb(out.stderr or out.stdout or "Error running 'mix help'") + end + for line in vim.gsplit(out.stdout, "\n") do local task_name = line:match("mix (%S+)%s") - table.insert( - ret, - overseer.wrap_template( - tmpl, - { name = string.format("mix %s", task_name) }, - { subcmd = task_name } - ) - ) + table.insert(ret, { + name = string.format("mix %s", task_name), + builder = function() + return { + cmd = { "mix", task_name }, + } + end, + }) end - table.insert(ret, overseer.wrap_template(tmpl, { name = "mix", priority = 65 })) - end), - on_exit = vim.schedule_wrap(function(j, output) - cb(ret) - end), - }) - if jid == 0 then - log:error("Passed invalid arguments to 'mix'") - cb(ret) - elseif jid == -1 then - log:error("'mix' is not executable") - cb(ret) - end + end) + ) end, } diff --git a/lua/overseer/template/npm.lua b/lua/overseer/template/npm.lua index 4deec256..50fab99a 100644 --- a/lua/overseer/template/npm.lua +++ b/lua/overseer/template/npm.lua @@ -1,6 +1,4 @@ local files = require("overseer.files") -local overseer = require("overseer") -local util = require("overseer.util") ---@type table local mgr_lockfiles = { @@ -10,23 +8,6 @@ local mgr_lockfiles = { bun = { "bun.lockb", "bun.lock" }, } ----@type overseer.TemplateFileDefinition -local tmpl = { - priority = 60, - params = { - args = { optional = true, type = "list", delimiter = " " }, - cwd = { optional = true }, - bin = { optional = true, type = "string" }, - }, - builder = function(params) - return { - cmd = { params.bin }, - args = params.args, - cwd = params.cwd, - } - end, -} - ---@param opts overseer.SearchParams local function get_candidate_package_files(opts) -- Some projects have package.json files in subfolders, which are not the main project package.json file, @@ -71,77 +52,65 @@ end local function pick_package_manager(package_file) local package_dir = vim.fs.dirname(package_file) for mgr, lockfiles in pairs(mgr_lockfiles) do - if - util.list_any(lockfiles, function(lockfile) - return files.exists(files.join(package_dir, lockfile)) - end) - then - return mgr + for _, lockfile in ipairs(lockfiles) do + if vim.uv.fs_stat(vim.fs.joinpath(package_dir, lockfile)) then + return mgr + end end end return "npm" end +---@type overseer.TemplateFileProvider return { - cache_key = function(opts) - return get_package_file(opts) - end, - condition = { - callback = function(opts) - local package_file = get_package_file(opts) - if not package_file then - return false, "No package.json file found" - end - local package_manager = pick_package_manager(package_file) - if vim.fn.executable(package_manager) == 0 then - return false, string.format("Could not find command '%s'", package_manager) - end - return true - end, - }, - generator = function(opts, cb) + generator = function(opts) local package = get_package_file(opts) if not package then - cb({}) - return + return "No package.json file found" end local bin = pick_package_manager(package) + if vim.fn.executable(bin) == 0 then + return string.format("Could not find command '%s'", bin) + end + local data = files.load_json_file(package) local ret = {} + local cwd = vim.fs.dirname(package) if data.scripts then for k in pairs(data.scripts) do - table.insert( - ret, - overseer.wrap_template( - tmpl, - { name = string.format("%s %s", bin, k) }, - { args = { "run", k }, bin = bin, cwd = vim.fs.dirname(package) } - ) - ) + table.insert(ret, { + name = string.format("%s %s", bin, k), + builder = function() + return { + cmd = { bin, "run", k }, + cwd = cwd, + } + end, + }) end end -- Load tasks from workspaces if data.workspaces then for _, workspace in ipairs(data.workspaces) do - local workspace_path = files.join(vim.fs.dirname(package), workspace) - local workspace_package_file = files.join(workspace_path, "package.json") + local workspace_path = vim.fs.joinpath(cwd, workspace) + local workspace_package_file = vim.fs.joinpath(workspace_path, "package.json") local workspace_data = files.load_json_file(workspace_package_file) if workspace_data and workspace_data.scripts then for k in pairs(workspace_data.scripts) do - table.insert( - ret, - overseer.wrap_template( - tmpl, - { name = string.format("%s[%s] %s", bin, workspace, k) }, - { args = { "run", k }, bin = bin, cwd = workspace_path } - ) - ) + table.insert(ret, { + name = string.format("%s[%s] %s", bin, workspace, k), + builder = function() + return { + cmd = { bin, "run", k }, + cwd = workspace_path, + } + end, + }) end end end end - table.insert(ret, overseer.wrap_template(tmpl, { name = bin }, { bin = bin })) - cb(ret) + return ret end, } diff --git a/lua/overseer/template/rake.lua b/lua/overseer/template/rake.lua index cf594dd2..089a00ee 100644 --- a/lua/overseer/template/rake.lua +++ b/lua/overseer/template/rake.lua @@ -1,4 +1,4 @@ -local log = require("overseer.log") +local overseer = require("overseer") ---@param opts overseer.SearchParams ---@return nil|string @@ -7,33 +7,33 @@ local function get_rakefile(opts) end ---@type overseer.TemplateFileProvider -local provider = { +return { cache_key = function(opts) return get_rakefile(opts) end, - condition = { - callback = function(opts) - if vim.fn.executable("rake") == 0 then - return false, 'Command "rake" not found' - end - if not get_rakefile(opts) then - return false, "No Rakefile found" - end - return true - end, - }, generator = function(opts, cb) + if vim.fn.executable("rake") == 0 then + return 'Command "rake" not found' + end + local rakefile = get_rakefile(opts) + if not rakefile then + return "No Rakefile found" + end + local cwd = vim.fs.dirname(rakefile) local ret = {} - local jid = vim.fn.jobstart({ - "rake", - "-T", - }, { - cwd = opts.dir, - stdout_buffered = true, - on_stdout = vim.schedule_wrap(function(j, output) + overseer.builtin.system( + { "rake", "-T" }, + { + cwd = cwd, + text = true, + }, + vim.schedule_wrap(function(out) + if out.code ~= 0 then + return cb(out.stderr or out.stdout or "Error running 'rake -T'") + end local tasks = {} - for _, line in ipairs(output) do - if #line > 0 then + for line in vim.gsplit(out.stdout, "\n") do + if line ~= "" then local task_name, params = line:match("^rake (%S+)(%[%S+%])") if task_name == nil then -- no parameters @@ -57,7 +57,6 @@ local provider = { for _, task in ipairs(tasks) do table.insert(ret, { name = string.format("rake %s", task.task_name), - priority = 60, params = task.args, builder = function(parms) local param_vals = {} @@ -68,26 +67,15 @@ local provider = { if #param_vals > 0 then p = "[" .. table.concat(param_vals, ",") .. "]" end - local cmd = { "rake", task.task_name .. p } return { - cmd = cmd, + cmd = { "rake", task.task_name .. p }, } end, }) end - end), - on_exit = vim.schedule_wrap(function(j, output) + cb(ret) - end), - }) - if jid == 0 then - log:error("Passed invalid arguments to 'rake'") - cb(ret) - elseif jid == -1 then - log:error("'rake' is not executable") - cb(ret) - end + end) + ) end, } - -return provider diff --git a/lua/overseer/template/shell.lua b/lua/overseer/template/shell.lua deleted file mode 100644 index 63727ec4..00000000 --- a/lua/overseer/template/shell.lua +++ /dev/null @@ -1,34 +0,0 @@ ----@type overseer.TemplateFileDefinition -local tmpl = { - name = "shell", - params = { - cmd = { type = "string", order = 1 }, - name = { type = "string", optional = true, order = 2 }, - cwd = { type = "string", optional = true, order = 4 }, - env = { type = "opaque", optional = true }, - metadata = { type = "opaque", optional = true }, - components = { type = "opaque", optional = true }, - strategy = { type = "opaque", optional = true }, - expand_cmd = { - desc = "Run expandcmd() on command before execution", - type = "boolean", - default = true, - optional = true, - order = 3, - }, - }, - builder = function(params) - local cmd = params.expand_cmd and vim.fn.expandcmd(params.cmd) or params.cmd - return { - cmd = cmd, - env = params.env, - cwd = params.cwd, - name = params.name, - metadata = params.metadata, - components = params.components, - strategy = params.strategy, - } - end, -} - -return tmpl diff --git a/lua/overseer/template/task.lua b/lua/overseer/template/task.lua index 7eddb6b5..4f0a8405 100644 --- a/lua/overseer/template/task.lua +++ b/lua/overseer/template/task.lua @@ -1,6 +1,5 @@ -- A task runner / simpler Make alternative written in Go -- https://taskfile.dev/ - local log = require("overseer.log") local overseer = require("overseer") @@ -17,94 +16,51 @@ local function find_taskfile(opts) return vim.fs.find(taskfiles, { upward = true, type = "file", path = opts.dir })[1] end ----@type overseer.TemplateDefinition -local template = { - name = "task", - desc = "default target", - priority = 60, - params = { - ---@type overseer.StringParam - target = { optional = false, type = "string", desc = "target" }, - ---@type overseer.ListParam - args = { optional = true, type = "list", delimiter = " " }, - ---@type overseer.StringParam - cwd = { optional = true }, - }, - builder = function(params) - local cmd = { "task" } - if params.target then - table.insert(cmd, params.target) - end - - ---@type overseer.TaskDefinition - local task = { - cmd = cmd, - cwd = params.cwd, - } - - if params.args then - task.args = vim.list_extend({ "--" }, params.args) - end - return task - end, -} - ----@type overseer.TemplateProvider -local provider = { - name = "task", +---@type overseer.TemplateFileProvider +return { cache_key = function(opts) return find_taskfile(opts) end, - condition = { - callback = function(opts) - if vim.fn.executable("task") == 0 then - return false, 'Command "task" not found' - end - if not find_taskfile(opts) then - return false, "No Taskfile found" - end - return true - end, - }, generator = function(opts, cb) + if vim.fn.executable("task") == 0 then + return 'Command "task" not found' + end + local taskfile = find_taskfile(opts) + if not taskfile then + return "No Taskfile found" + end + local cwd = vim.fs.dirname(taskfile) local ret = {} - local cmd = { "task", "--list-all", "--json" } - local jid = vim.fn.jobstart(cmd, { - cwd = opts.dir, - stdout_buffered = true, - on_stdout = vim.schedule_wrap(function(_, output) - local ok, data = - pcall(vim.json.decode, table.concat(output, "\n"), { luanil = { object = true } }) + overseer.builtin.system( + { "task", "--list-all", "--json" }, + { + cwd = cwd, + text = true, + }, + vim.schedule_wrap(function(out) + if out.code ~= 0 then + return cb(out.stderr or out.stdout or "Error running 'task'") + end + local ok, data = pcall(vim.json.decode, out.stdout, { luanil = { object = true } }) if not ok then - log:error("Task produced invalid json: %s\n%s", data, output) - cb(ret) - return + log.error("Task produced invalid json: %s", out.stdout) + return cb(data) end assert(data) for _, target in ipairs(data.tasks) do - local override = { + table.insert(ret, { name = string.format("task %s", target.name), desc = target.desc, - } - if target.name == "default" then - override.priority = 55 - end - table.insert( - ret, - overseer.wrap_template(template, override, { target = target.name, cwd = opts.dir }) - ) + builder = function() + return { + cmd = { "task", target }, + cwd = cwd, + } + end, + }) end cb(ret) - end), - }) - if jid == 0 then - log:error("Passed invalid arguments to 'task'") - cb(ret) - elseif jid == -1 then - log:error("'task' is not executable") - cb(ret) - end + end) + ) end, } - -return provider diff --git a/lua/overseer/template/tox.lua b/lua/overseer/template/tox.lua index 4d6a551f..1280d375 100644 --- a/lua/overseer/template/tox.lua +++ b/lua/overseer/template/tox.lua @@ -1,47 +1,16 @@ -local files = require("overseer.files") -local overseer = require("overseer") - ----@type overseer.TemplateDefinition -local tmpl = { - name = "tox", - priority = 60, - params = { - args = { optional = true, type = "list", delimiter = " " }, - }, - builder = function(params) - local cmd = { "tox" } - if params.args then - cmd = vim.list_extend(cmd, params.args) - end - return { - cmd = cmd, - } - end, -} - ----@param opts overseer.SearchParams ----@return nil|string -local function get_toxfile(opts) - return vim.fs.find("tox.ini", { upward = true, type = "file", path = opts.dir })[1] -end - +---@type overseer.TemplateFileProvider return { - cache_key = function(opts) - return get_toxfile(opts) - end, - condition = { - callback = function(opts) - if not get_toxfile(opts) then - return false, "No tox.ini file found" - end - return true - end, - }, - generator = function(opts, cb) - local tox_file = assert(get_toxfile(opts)) - local content = assert(files.read_file(tox_file)) + generator = function(opts) + local tox_file = vim.fs.find("tox.ini", { upward = true, type = "file", path = opts.dir })[1] + if not tox_file then + return "No tox.ini file found" + end + local file = io.open(tox_file, "r") + if not file then + return "Failed to read tox.ini file" + end local targets = {} - for line in vim.gsplit(content, "\n") do + for line in file:lines() do local envlist = line:match("^envlist%s*=%s*(.+)$") if envlist then for t in vim.gsplit(envlist, "%s*,%s*") do @@ -57,17 +26,19 @@ return { end end - local ret = { tmpl } + local ret = {} + local cwd = vim.fs.dirname(tox_file) for k in pairs(targets) do - table.insert( - ret, - overseer.wrap_template( - tmpl, - { name = string.format("tox -e %s", k) }, - { args = { "-e", k } } - ) - ) + table.insert(ret, { + name = string.format("tox %s", k), + builder = function() + return { + cmd = { "tox", "-e", k }, + cwd = cwd, + } + end, + }) end - cb(ret) + return ret end, } diff --git a/lua/overseer/template/vscode.lua b/lua/overseer/template/vscode.lua new file mode 100644 index 00000000..8b87087f --- /dev/null +++ b/lua/overseer/template/vscode.lua @@ -0,0 +1,48 @@ +local files = require("overseer.files") +local variables = require("overseer.vscode.variables") +local vs_util = require("overseer.vscode.vs_util") +local vscode = require("overseer.vscode") + +---@type overseer.TemplateFileProvider +return { + generator = function(opts) + local tasks_file = vs_util.get_tasks_file(vim.fn.getcwd(), opts.dir) + if not tasks_file then + return "No .vscode/tasks.json file found" + end + local content = vs_util.load_tasks_file(tasks_file) + local global_defaults = {} + for k, v in pairs(content) do + if k ~= "version" and k ~= "tasks" then + global_defaults[k] = v + end + end + local os_key + if files.is_windows then + os_key = "windows" + elseif files.is_mac then + os_key = "osx" + else + os_key = "linux" + end + if content[os_key] then + global_defaults = vim.tbl_deep_extend("force", global_defaults, content[os_key]) + end + local ret = {} + local precalculated_vars = variables.precalculate_vars() + + if content.tasks == nil then + return "No 'tasks' key found in '.vscode/tasks.json'" + end + + for _, task in ipairs(content.tasks) do + local defn = vim.tbl_deep_extend("force", global_defaults, task) + defn = vim.tbl_deep_extend("force", defn, task[os_key] or {}) + local tmpl = vscode.convert_vscode_task(defn, precalculated_vars) + if tmpl then + table.insert(ret, tmpl) + end + end + return ret + end, +} diff --git a/lua/overseer/types.lua b/lua/overseer/types.lua deleted file mode 100644 index 5fb5bf5d..00000000 --- a/lua/overseer/types.lua +++ /dev/null @@ -1,52 +0,0 @@ ----@class (exact) overseer.Config ----@field strategy? overseer.Serialized Default task strategy ----@field templates? string[] Template modules to load ----@field auto_detect_success_color? boolean ----@field dap? boolean Patch nvim-dap to support preLaunchTask and postDebugTask ----@field task_list? overseer.ConfigTaskList Configure the task list ----@field actions? any See :help overseer-actions ----@field form? overseer.ConfigFloatWin Configure the floating window used for task templates that require input and the floating window used for editing tasks ----@field task_launcher? table ----@field task_editor? table ----@field confirm? overseer.ConfigFloatWin ----@field task_win? overseer.ConfigTaskWin ----@field help_win? overseer.ConfigFloatWin ----@field component_aliases? table Aliases for bundles of components. Redefine the builtins, or create your own. ----@field bundles? overseer.ConfigBundles ----@field preload_components? string[] A list of components to preload on setup. Only matters if you want them to show up in the task editor. ----@field default_template_prompt? "always"|"missing"|"allow"|"avoid"|"never" Controls when the parameter prompt is shown when running a template ----@field template_timeout? integer For template providers, how long to wait (in ms) before timing out. Set to 0 to disable timeouts. ----@field template_cache_threshold? integer Cache template provider results if the provider takes longer than this to run. Time is in ms. Set to 0 to disable caching. ----@field log? table[] - ----@class (exact) overseer.ConfigTaskList ----@field default_detail? 1|2|3 Default detail level for tasks. Can be 1-3. ----@field max_width? number|number[] Width dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%). min_width and max_width can be a single value or a list of mixed integer/float types. max_width = {100, 0.2} means "the lesser of 100 columns or 20% of total" ----@field min_width? number|number[] min_width = {40, 0.1} means "the greater of 40 columns or 10% of total" ----@field width? number optionally define an integer/float for the exact width of the task list ----@field max_height? number|number[] ----@field min_height? number|number[] ----@field height? number ----@field separator? string String that separates tasks ----@field direction? string Default direction. Can be "left", "right", or "bottom" ----@field bindings? table Set keymap to false to remove default behavior - ----@class (exact) overseer.ConfigFloatWin ----@field border? string|table ----@field zindex? integer ----@field min_width? number|number[] ----@field max_width? number|number[] ----@field min_height? number|number[] ----@field max_height? number|number[] ----@field win_opts? table - ----@class (exact) overseer.ConfigTaskWin ----@field border? string|table ----@field padding? integer ----@field win_opts? table - ----@class (exact) overseer.ConfigBundles ----@field save_task_opts? table When saving a bundle with OverseerSaveBundle or save_task_bundle(), filter the tasks with these options (passed to list_tasks()) ----@field autostart_on_load? boolean Autostart tasks when they are loaded from a bundle - ----@alias overseer.Serialized string|{[1]: string, [string]: any} diff --git a/lua/overseer/util.lua b/lua/overseer/util.lua index 15d3b983..c40670f3 100644 --- a/lua/overseer/util.lua +++ b/lua/overseer/util.lua @@ -1,3 +1,4 @@ +local log = require("overseer.log") local M = {} ---@param winid? number @@ -147,13 +148,38 @@ M.scroll_to_end = function(winid) vim.api.nvim_set_option_value("scrolloff", scrolloff, { scope = "local", win = winid }) end ----@param bufnr number ----@param ns number ----@param highlights table -M.add_highlights = function(bufnr, ns, highlights) - for _, hl in ipairs(highlights) do - local group, lnum, col_start, col_end = unpack(hl) - vim.api.nvim_buf_add_highlight(bufnr, ns, group, lnum - 1, col_start, col_end) +---@param bufnr integer +---@param ns integer +---@param lines overseer.TextChunk[][] +M.render_buf_chunks = function(bufnr, ns, lines) + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return + end + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + local new_lines = {} + local extmarks = {} + for _, chunks in ipairs(lines) do + local line = {} + local i = 0 + for _, chunk in ipairs(chunks) do + ---@cast chunk overseer.TextChunk + local text, hl = chunk[1], chunk[2] + assert(type(text) == "string", "Text chunk must have a string as the first element") + table.insert(line, text) + if hl then + table.insert(extmarks, { #new_lines, i, { hl_group = hl, end_col = i + #text } }) + end + i = i + #text + end + local line_text = table.concat(line, ""):gsub("\n", " ") + table.insert(new_lines, line_text) + end + vim.bo[bufnr].modifiable = true + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, new_lines) + vim.bo[bufnr].modifiable = false + vim.bo[bufnr].modified = false + for _, extmark in ipairs(extmarks) do + vim.api.nvim_buf_set_extmark(bufnr, ns, extmark[1], extmark[2], extmark[3]) end end @@ -171,15 +197,18 @@ end ---@param text string ---@param width number ---@param alignment "left"|"right"|"center" +---@return string M.align = function(text, width, alignment) + local textwidth = vim.api.nvim_strwidth(text) if alignment == "center" then - local padding = math.floor((width - string.len(text)) / 2) - return string.rep(" ", padding) .. text + local padding = math.floor((width - textwidth) / 2) + return string.rep(" ", padding) .. text .. string.rep(" ", width - textwidth - padding) elseif alignment == "right" then - local padding = width - string.len(text) + local padding = width - textwidth return string.rep(" ", padding) .. text else - return text + local padding = width - textwidth + return text .. string.rep(" ", padding) end end @@ -486,75 +515,11 @@ M.set_bufenter_callback = function(bufnr, key, callback) }) end ----@param group string ----@return nil|integer -M.get_hl_foreground = function(group) - if vim.fn.has("nvim-0.9") == 1 then - return vim.api.nvim_get_hl(0, { name = group }).fg - else - ---@diagnostic disable-next-line: undefined-field, deprecated - local ok, data = pcall(vim.api.nvim_get_hl_by_name, group, true) - if ok then - return data.foreground - end - end -end - ----@param color integer ----@return number[] -M.color_to_rgb = function(color) - local r = bit.band(bit.rshift(color, 16), 0xff) - local g = bit.band(bit.rshift(color, 8), 0xff) - local b = bit.band(color, 0xff) - return { r / 255.0, g / 255.0, b / 255.0 } -end - --- Attempts to find a green color from the current colorscheme -M.find_success_color = function() - if vim.fn.has("nvim-0.9") == 1 then - return "DiagnosticOk" - end - local candidates = { - "Constant", - "Keyword", - "Special", - "Type", - "PreProc", - "Operator", - "String", - "Statement", - "Identifier", - "Function", - "Character", - "Title", - } - local best_grp - local best - for _, grp in ipairs(candidates) do - local fg = M.get_hl_foreground(grp) - if fg then - local rgb = M.color_to_rgb(fg) - -- Super simple "green" detection heuristic: g - r - b - local score = rgb[2] - rgb[1] - rgb[3] - if not best or score > best then - best_grp = grp - best = score - end - end - end - if best_grp and best > -0.5 then - return best_grp - end - return "DiagnosticInfo" -end - ---@param func fun(...: any) ---@param opts? {reset_timer_on_call: nil|boolean, delay: nil|integer|fun(...: any): integer} M.debounce = function(func, opts) - vim.validate({ - func = { func, "f" }, - opts = { opts, "t", true }, - }) + vim.validate("func", func, "function") + vim.validate("opts", opts, "table", true) opts = opts or {} local delay = opts.delay or 300 local timer = nil @@ -571,7 +536,7 @@ M.debounce = function(func, opts) if type(delay) == "function" then delay = delay(unpack(args)) end - timer = assert(vim.loop.new_timer()) + timer = assert(vim.uv.new_timer()) timer:start(delay, 0, function() timer:close() timer = nil @@ -603,16 +568,46 @@ M.format_duration = function(duration) return time end +---@param time integer +---@return string +M.format_relative_timestamp = function(time) + local from_now = time - os.time() + local suffix = "" + if from_now <= 0 and from_now > -5 then + return "just now" + elseif from_now > 0 and from_now <= 5 then + return "a few seconds" + end + if from_now < 0 then + from_now = -from_now + suffix = " ago" + end + local secs = from_now % 60 + local days = math.floor(from_now / day_s) + local hours = math.floor((from_now % day_s) / hour_s) + local mins = math.floor((from_now % hour_s) / minute_s) + if days > 0 then + return string.format("%d day%s%s", days, days > 1 and "s" or "", suffix) + elseif hours > 0 then + return string.format("%d hour%s%s", hours, hours > 1 and "s" or "", suffix) + elseif mins > 0 then + return string.format("%d minute%s%s", mins, mins > 1 and "s" or "", suffix) + else + return string.format("%d second%s%s", secs, secs > 1 and "s" or "", suffix) + end +end + ---@param name_or_config string|table ---@param cb fun(task: nil|overseer.Task) M.run_template_or_task = function(name_or_config, cb) if type(name_or_config) == "table" and name_or_config[1] == nil then -- This is a raw task params table + ---@cast name_or_config overseer.TaskDefinition cb(require("overseer").new_task(name_or_config)) else local name, dep_params = M.split_config(name_or_config) -- If no task ID found, start the dependency - require("overseer.commands").run_template({ + require("overseer").run_task({ name = name, params = dep_params, autostart = false, @@ -625,37 +620,53 @@ end ---@param callback fun() M.run_in_fullscreen_win = function(bufnr, callback) if not bufnr or bufnr == 0 then - bufnr = vim.api.nvim_get_current_buf() + bufnr = vim.api.nvim_create_buf(false, true) + vim.bo[bufnr].bufhidden = "wipe" end local start_winid = vim.api.nvim_get_current_win() - local winid = vim.api.nvim_open_win(bufnr, false, { + local eventignore = vim.o.eventignore + vim.o.eventignore = "all" + local winid = vim.api.nvim_open_win(bufnr, true, { relative = "editor", width = vim.o.columns, height = vim.o.lines, row = 0, col = 0, - noautocmd = true, }) - local winnr = vim.api.nvim_win_get_number(winid) - vim.cmd.wincmd({ count = winnr, args = { "w" }, mods = { noautocmd = true } }) local ok, err = xpcall(callback, debug.traceback) if not ok then - vim.api.nvim_err_writeln(err) + vim.api.nvim_echo({ { err } }, true, { err = true }) + end + pcall(vim.api.nvim_win_close, winid, true) + vim.api.nvim_set_current_win(start_winid) + vim.o.eventignore = eventignore +end + +---@param callback fun() +M.eventignore_call = function(callback) + local eventignore = vim.o.eventignore + vim.o.eventignore = "all" + local ok, err = xpcall(callback, debug.traceback) + vim.o.eventignore = eventignore + if not ok then + error(err) end - winnr = vim.api.nvim_win_get_number(winid) - vim.cmd.close({ count = winnr, mods = { noautocmd = true, emsg_silent = true } }) - winnr = vim.api.nvim_win_get_number(start_winid) - vim.cmd.wincmd({ count = winnr, args = { "w" }, mods = { noautocmd = true } }) end ---Run a function in the context of a current directory ----@param cwd string +---@param cwd? string ---@param callback fun() M.run_in_cwd = function(cwd, callback) - M.run_in_fullscreen_win(nil, function() - vim.cmd.lcd({ args = { cwd }, mods = { silent = true, noautocmd = true } }) - callback() - end) + if not cwd then + return callback() + end + local prev_cwd = vim.fn.getcwd() + vim.cmd.lcd({ args = { cwd }, mods = { emsg_silent = true, noautocmd = true } }) + local ok, err = xpcall(callback, debug.traceback) + if not ok then + vim.api.nvim_echo({ { err } }, true, { err = true }) + end + vim.cmd.lcd({ args = { prev_cwd }, mods = { emsg_silent = true, noautocmd = true } }) end ---@param status overseer.Status @@ -684,23 +695,6 @@ M.soft_delete_buf = function(bufnr) end end ----This is a hack so we don't end up in insert mode after starting a task ----@param prev_mode string The vim mode we were in before opening a terminal -M.hack_around_termopen_autocmd = function(prev_mode) - -- It's common to have autocmds that enter insert mode when opening a terminal - vim.defer_fn(function() - local new_mode = vim.api.nvim_get_mode().mode - if new_mode ~= prev_mode then - if string.find(new_mode, "i") == 1 then - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, true, true), "n", false) - if string.find(prev_mode, "v") == 1 or string.find(prev_mode, "V") == 1 then - vim.cmd.normal({ bang = true, args = { "gv" } }) - end - end - end - end, 10) -end - ---@param old_bufnr nil|integer ---@param new_bufnr nil|integer M.replace_buffer_in_wins = function(old_bufnr, new_bufnr) @@ -745,4 +739,71 @@ M.get_last_output_lines = function(bufnr, num_lines) return lines end +---@class overseer.Caller +---@field file? string +---@field lnum? integer +---@field module? string +---@field top_module? string + +---@param file string +---@param caller overseer.Caller +local function assign_module(file, caller) + local relpath + for path in vim.gsplit(vim.o.runtimepath, ",", { plain = true }) do + path = path .. "/lua" + if file:find(path, 1, true) == 1 then + relpath = file:sub(#path + 2) + break + end + end + if relpath then + local mod = vim.fn.fnamemodify(relpath, ":r"):gsub("[/\\]", ".") + local top_mod + local dot_idx = mod:find(".", 1, true) + if dot_idx then + top_mod = mod:sub(1, dot_idx - 1) + else + top_mod = mod + end + caller.module = mod + caller.top_module = top_mod + end +end + +---@return overseer.Caller +M.get_caller = function() + -- 1: this function + -- 2: the wrapper function in init.lua + -- 3: the actual caller of the jobstart/system function + local level = 3 + local info + while true do + info = debug.getinfo(level, "Sl") + if not info then + log.trace("No source info found: %s", debug.traceback()) + return {} + end + if info.what ~= "C" then + break + end + level = level + 1 + end + local file, lnum + if not info.source:match("^@") then + log.trace("Source is not file: %s\n%s", info.source, debug.traceback()) + return {} + end + + file = info.source:sub(2) + lnum = info.currentline + local ret = { file = file, lnum = lnum } + if vim.in_fast_event() then + -- This reads runtimepath and uses fnamemodify, which are not safe in fast events + vim.schedule_wrap(assign_module)(file, ret) + else + assign_module(file, ret) + end + return ret +end + return M diff --git a/lua/overseer/template/vscode/init.lua b/lua/overseer/vscode/init.lua similarity index 76% rename from lua/overseer/template/vscode/init.lua rename to lua/overseer/vscode/init.lua index 4846603d..e7ef7261 100644 --- a/lua/overseer/template/vscode/init.lua +++ b/lua/overseer/vscode/init.lua @@ -1,11 +1,17 @@ local constants = require("overseer.constants") -local files = require("overseer.files") local log = require("overseer.log") -local problem_matcher = require("overseer.template.vscode.problem_matcher") -local variables = require("overseer.template.vscode.variables") -local vs_util = require("overseer.template.vscode.vs_util") +local problem_matcher = require("overseer.vscode.problem_matcher") +local variables = require("overseer.vscode.variables") -local LAUNCH_CONFIG_KEY = "__launch_config__ " +local M = {} + +M.LAUNCH_CONFIG_KEY = "__launch_config__ " + +---@class (exact) overseer.VSCodeTaskProvider +---@field problem_patterns? table +---@field problem_matchers? table +---@field on_load? fun() +---@field get_task_opts fun(defn: table, launch_config?: table): table ---@param params table ---@param str string @@ -97,6 +103,7 @@ local group_to_tag = { clean = constants.TAG.CLEAN, } +---@param task_provider overseer.VSCodeTaskProvider local function register_provider(task_provider) if task_provider.problem_patterns then for k, v in pairs(task_provider.problem_patterns) do @@ -114,9 +121,10 @@ local function register_provider(task_provider) end local registered_providers = {} -local function get_provider(type) - local ok, task_provider = - pcall(require, string.format("overseer.template.vscode.provider.%s", type)) +---@param type string +---@return nil|overseer.VSCodeTaskProvider +M.get_provider = function(type) + local ok, task_provider = pcall(require, string.format("overseer.vscode.provider.%s", type)) if ok then if not registered_providers[type] then register_provider(task_provider) @@ -179,9 +187,9 @@ local function get_presentation_components(defn) end ---@param defn table ----@param precalculated_vars table +---@param precalculated_vars? table local function get_task_builder(defn, precalculated_vars) - local task_provider = get_provider(defn.type) + local task_provider = M.get_provider(defn.type) if not task_provider then return nil end @@ -195,12 +203,12 @@ local function get_task_builder(defn, precalculated_vars) end -- Pass the provider the raw task definition data and the launch.json configuration data -- (if present) - local task_opts = task_provider.get_task_opts(defn, params[LAUNCH_CONFIG_KEY]) + local task_opts = task_provider.get_task_opts(defn, params[M.LAUNCH_CONFIG_KEY]) local opts = vim.tbl_deep_extend("force", defn.options or {}, task_opts) local components = { "default_vscode" } local pmatcher = defn.problemMatcher - if not pmatcher and task_provider.problem_matcher then - pmatcher = task_provider.problem_matcher + if not pmatcher and task_opts.problem_matcher then + pmatcher = task_opts.problem_matcher end if pmatcher then table.insert(components, 1, { @@ -225,8 +233,8 @@ local function get_task_builder(defn, precalculated_vars) end ---@param defn table ----@param precalculated_vars table -local function convert_vscode_task(defn, precalculated_vars) +---@param precalculated_vars? table +M.convert_vscode_task = function(defn, precalculated_vars) local alias = string.format("%s: %s", defn.type, defn.command) local tmpl = { name = defn.label or alias, @@ -239,7 +247,7 @@ local function convert_vscode_task(defn, precalculated_vars) local task_builder = get_task_builder(defn, precalculated_vars) -- If we don't have a task builder, but the type exists, then we don't support this task type if not task_builder and defn.type then - log:warn("Unsupported VSCode task type '%s' for task %s", defn.type, tmpl.name) + log.warn("Unsupported VSCode task type '%s' for task %s", defn.type, tmpl.name) return nil end @@ -248,9 +256,6 @@ local function convert_vscode_task(defn, precalculated_vars) tmpl.tags = { group_to_tag[defn.group] } else tmpl.tags = { group_to_tag[defn.group.kind] } - if defn.isDefault then - tmpl.priority = 40 - end end end if defn.hide then @@ -266,7 +271,7 @@ local function convert_vscode_task(defn, precalculated_vars) local task_defn = task_builder(params) table.insert(task_defn.components, { "dependencies", - task_names = defn.dependsOn, + tasks = defn.dependsOn, sequential = defn.dependsOrder == "sequence", }) return task_defn @@ -294,7 +299,7 @@ local function convert_vscode_task(defn, precalculated_vars) elseif task_builder then tmpl.builder = task_builder else - log:warn( + log.warn( 'VSCode task \'%s\' is missing type. Try setting "type": "shell"', defn.label or defn.name or defn.command ) @@ -307,59 +312,4 @@ local function convert_vscode_task(defn, precalculated_vars) return tmpl end -return { - cache_key = function(opts) - return vs_util.get_tasks_file(vim.fn.getcwd(), opts.dir) - end, - condition = { - callback = function(opts) - if not vs_util.get_tasks_file(vim.fn.getcwd(), opts.dir) then - return false, "No .vscode/tasks.json file found" - end - return true - end, - }, - generator = function(opts, cb) - local tasks_file = vs_util.get_tasks_file(vim.fn.getcwd(), opts.dir) - local content = vs_util.load_tasks_file(assert(tasks_file)) - local global_defaults = {} - for k, v in pairs(content) do - if k ~= "version" and k ~= "tasks" then - global_defaults[k] = v - end - end - local os_key - if files.is_windows then - os_key = "windows" - elseif files.is_mac then - os_key = "osx" - else - os_key = "linux" - end - if content[os_key] then - global_defaults = vim.tbl_deep_extend("force", global_defaults, content[os_key]) - end - local ret = {} - local precalculated_vars = variables.precalculate_vars() - - if content.tasks == nil then - vim.notify("No 'tasks' key found in '.vscode/tasks.json'", vim.log.levels.WARN) - cb({}) - return - end - - for _, task in ipairs(content.tasks) do - local defn = vim.tbl_deep_extend("force", global_defaults, task) - defn = vim.tbl_deep_extend("force", defn, task[os_key] or {}) - local tmpl = convert_vscode_task(defn, precalculated_vars) - if tmpl then - table.insert(ret, tmpl) - end - end - cb(ret) - end, - -- expose these for unit tests - get_provider = get_provider, - convert_vscode_task = convert_vscode_task, - LAUNCH_CONFIG_KEY = LAUNCH_CONFIG_KEY, -} +return M diff --git a/lua/overseer/template/vscode/problem_matcher.lua b/lua/overseer/vscode/problem_matcher.lua similarity index 74% rename from lua/overseer/template/vscode/problem_matcher.lua rename to lua/overseer/vscode/problem_matcher.lua index d812e715..caf2f885 100644 --- a/lua/overseer/template/vscode/problem_matcher.lua +++ b/lua/overseer/vscode/problem_matcher.lua @@ -1,8 +1,6 @@ local log = require("overseer.log") -local parser_lib = require("overseer.parser.lib") -local variables = require("overseer.template.vscode.variables") ----@diagnostic disable-next-line: deprecated -local islist = vim.islist or vim.tbl_islist +local parselib = require("overseer.parselib") +local variables = require("overseer.vscode.variables") local M = {} -- Taken from https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/tasks/common/problemMatcher.ts#L1207 @@ -281,7 +279,7 @@ local default_matchers = { ---@param defn table M.register_pattern = function(name, defn) if name:find("$", nil, true) ~= 1 then - log:warn("Pattern '%s' should start with '$'", name) + log.warn("Pattern '%s' should start with '$'", name) name = "$" .. name end default_patterns[name] = defn @@ -291,7 +289,7 @@ end ---@param defn table M.register_problem_matcher = function(name, defn) if name:find("$", nil, true) ~= 1 then - log:warn("Problem matcher '%s' should start with '$'", name) + log.warn("Problem matcher '%s' should start with '$'", name) name = "$" .. name end default_matchers[name] = defn @@ -314,38 +312,40 @@ local match_names = { "code", "message", } -local function num_field(name) - return { - name, - function(value, ctx) - return tonumber(value) - end, - } + +---@param n string +---@return nil|number +local function convert_number(n) + return tonumber(n) end + +---Convert VS Code match names to keys in a vim.quickfix.entry +---@param name string +---@return overseer.ParseField local function convert_match_name(name) if name == "file" then return "filename" elseif name == "location" then return { "lnum", - function(value, ctx) + function(value, item) local lnum, col, end_lnum, end_col = unpack(vim.split(value, ",")) - ctx.item.col = tonumber(col) - ctx.item.end_lnum = tonumber(end_lnum) - ctx.item.end_col = tonumber(end_col) + item.col = tonumber(col) + item.end_lnum = tonumber(end_lnum) + item.end_col = tonumber(end_col) return tonumber(lnum) end, } elseif name == "line" then - return num_field("lnum") + return { "lnum", convert_number } elseif name == "column" then - return num_field("col") + return { "col", convert_number } elseif name == "character" then - return num_field("col") + return { "col", convert_number } elseif name == "endLine" then - return num_field("end_lnum") + return { "end_lnum", convert_number } elseif name == "endColumn" then - return num_field("end_col") + return { "end_col", convert_number } elseif name == "severity" then return "type" elseif name == "code" then @@ -358,8 +358,35 @@ local function convert_match_name(name) end end -local function convert_pattern(pattern, opts) - opts = opts or {} +---@param pattern string|table +---@return overseer.MatchFn +local function pattern_to_match_fn(pattern) + if type(pattern) == "string" then + return parselib.make_regex_match_fn("\\v" .. pattern) + elseif pattern.lua_pat then + return parselib.make_lua_match_fn(pattern.lua_pat) + elseif pattern.vim_regexp then + return parselib.make_regex_match_fn(pattern.vim_regexp) + else + -- Fall back to trying to auto-convert the JS regex to a vim regex + return parselib.make_regex_match_fn("\\v" .. pattern.regexp) + end +end + +---@param pattern? table|string +---@return nil|overseer.TestFn +local function pattern_to_test_fn(pattern) + if not pattern then + return nil + end + return parselib.match_to_test_fn(pattern_to_match_fn(pattern)) +end + +---@param pattern table +---@param opts {append?: boolean, qf_type?: string, file_convert?: fun(file: string): string} +---@return overseer.ParseFn +local function pattern_to_parse_fn(pattern, opts) + ---@type overseer.ParseField[] local args = {} local full_line_key local max_arg = 0 @@ -389,36 +416,76 @@ local function convert_pattern(pattern, opts) args[i] = "_" end end - local extract_opts = { - append = opts.append, - postprocess = function(item, ctx) + + local match = pattern_to_match_fn(pattern) + local extract = parselib.make_parse_fn(match, args) + + return function(line) + local item = extract(line) + if item then if not item.type then item.type = opts.qf_type end - if full_line_key and not ctx.default_values[full_line_key] then - item[full_line_key] = ctx.line + if full_line_key then + item[full_line_key] = line end if opts.file_convert and item.filename then item.filename = opts.file_convert(item.filename) end - end, - } - local extract_pat - if pattern.lua_pat then - extract_pat = pattern.lua_pat - elseif pattern.vim_regexp then - extract_pat = pattern.vim_regexp - extract_opts.regex = true - else - -- Fall back to trying to auto-convert the JS regex to a vim regex - extract_pat = "\\v" .. pattern.regexp - extract_opts.regex = true + end + return item end - local extract = { "extract", extract_opts, extract_pat, unpack(args) } - if pattern.loop then - return { "set_defaults", { "loop", extract } } +end + +---@param patterns table[] +---@param opts {append?: boolean, qf_type?: string, file_convert?: fun(file: string): string} +---@return overseer.OutputParser +local function patterns_to_parser(patterns, opts) + local parse_fns = {} + for _, pattern in ipairs(patterns) do + local parse_fn = pattern_to_parse_fn(pattern, opts) + table.insert(parse_fns, parse_fn) end - return extract + local loop_last = patterns[#patterns].loop + local idx = 1 + local pending_item = {} + local result = {} + + ---@type overseer.OutputParser + return { + parse = function(self, line) + local item = parse_fns[idx](line) + local is_last_fn = idx == #parse_fns + if item then + if is_last_fn then + item = vim.tbl_extend("keep", item, pending_item) + table.insert(result, item) + if not loop_last then + idx = 1 + end + else + pending_item = vim.tbl_extend("force", pending_item, item) + idx = idx + 1 + end + else + -- If we are in the middle of the parse funcs and the match fails, reset and try matching + -- again starting from the first function + if idx > 1 then + idx = 1 + pending_item = {} + self:parse(line) + end + end + end, + get_result = function() + return { diagnostics = result } + end, + reset = function() + idx = 1 + pending_item = {} + result = {} + end, + } end M.resolve_problem_matcher = function(problem_matcher) @@ -428,10 +495,10 @@ M.resolve_problem_matcher = function(problem_matcher) if type(problem_matcher) == "string" then local pm = default_matchers[problem_matcher] if not pm then - log:error("Could not find problem matcher '%s'", problem_matcher) + log.error("Could not find problem matcher '%s'", problem_matcher) end return M.resolve_problem_matcher(pm) - elseif islist(problem_matcher) then + elseif vim.islist(problem_matcher) then local children = {} for _, v in ipairs(problem_matcher) do local pm = M.resolve_problem_matcher(v) @@ -448,43 +515,13 @@ M.resolve_problem_matcher = function(problem_matcher) if default_matchers[problem_matcher.base] then return vim.tbl_deep_extend("keep", problem_matcher, default_matchers[problem_matcher.base]) else - log:error("Could not find problem matcher '%s'", problem_matcher.base) + log.error("Could not find problem matcher '%s'", problem_matcher.base) return nil end end return problem_matcher end -local function pattern_to_test(pattern) - if not pattern then - return nil - elseif type(pattern) == "string" then - return { { regex = true }, "\\v" .. pattern } - else - if pattern.lua_pat then - return pattern.lua_pat - elseif pattern.vim_regexp then - return { { regex = true }, pattern.vim_regexp } - else - return pattern_to_test(pattern.regexp) - end - end -end - -local function add_background(background, child) - if not background then - return child - end - return parser_lib.watcher_output( - assert(pattern_to_test(background.beginsPattern)), - assert(pattern_to_test(background.endsPattern)), - child, - { - active_on_start = background.activeOnStart, - } - ) -end - -- Process file name based on "fileLocation" -- Valid: "absolute", "relative", "autoDetect", ["relative", "path value"], ["autoDetect", "path value"] local function file_converter(file_loc, precalculated_vars) @@ -498,6 +535,7 @@ local function file_converter(file_loc, precalculated_vars) and variables.replace_vars(file_loc[2], {}, precalculated_vars) or vim.fn.getcwd() + ---@param file string return function(file) if typ == "absolute" then return file @@ -511,68 +549,68 @@ local function file_converter(file_loc, precalculated_vars) end end ----@param problem_matcher table +---@param parser overseer.OutputParser +---@param background? table +local function wrap_background_parser(parser, background) + if not background then + return parser + end + return parselib.wrap_background_parser(parser, { + active_on_start = background.activeOnStart, + start_fn = pattern_to_test_fn(background.beginsPattern), + end_fn = pattern_to_test_fn(background.endsPattern), + }) +end + +---@param problem_matcher? table ---@param precalculated_vars? table +---@return nil|overseer.OutputParser M.get_parser_from_problem_matcher = function(problem_matcher, precalculated_vars) + problem_matcher = M.resolve_problem_matcher(problem_matcher) if not problem_matcher then return nil end - if islist(problem_matcher) then + + if vim.islist(problem_matcher) then + -- this is a list of problem matchers local background - local children = {} + local all_parsers = {} for _, v in ipairs(problem_matcher) do local parser = M.get_parser_from_problem_matcher(v, precalculated_vars) - assert(parser, "Failed to create overseer parser from VS Code problem matcher") - local is_parser = type(parser[1]) == "string" - if is_parser then - table.insert(children, parser) - else - vim.list_extend(children, parser) - end - if v.background then - background = v.background + if parser then + assert(parser, "Failed to create overseer parser from VS Code problem matcher") + table.insert(all_parsers, parser) + if v.background then + background = v.background + end end end - local ret = { "parallel", { break_on_first_failure = false }, unpack(children) } - return add_background(background, ret) + return wrap_background_parser(parselib.combine_parsers(all_parsers), background) end - -- NOTE: we ignore matcher.owner local qf_type = severity_to_type[problem_matcher.severity] local pattern = problem_matcher.pattern local background = problem_matcher.background local convert = problem_matcher.fileLocation and file_converter(problem_matcher.fileLocation, precalculated_vars) - local ret if type(pattern) == "string" then if default_patterns[pattern] then pattern = vim.deepcopy(default_patterns[pattern]) else - log:error("Could not find problem matcher pattern '%s'", pattern) + log.error("Could not find problem matcher pattern '%s'", pattern) return nil end end - if islist(pattern) then - ret = { "sequence" } - for i, v in ipairs(pattern) do - local append = i == #pattern - local parse_node = - convert_pattern(v, { append = append, qf_type = qf_type, file_convert = convert }) - if not parse_node then - return nil - end - table.insert(ret, parse_node) - end + local ret + if vim.islist(pattern) then + ret = patterns_to_parser(pattern, { qf_type = qf_type, file_convert = convert }) else - local parse_node = convert_pattern(pattern, { qf_type = qf_type, file_convert = convert }) - if parse_node then - ret = parse_node - else - return nil - end + local parse_fn = pattern_to_parse_fn(pattern, { qf_type = qf_type, file_convert = convert }) + ret = parselib.make_parser(parse_fn) end - return add_background(background, ret) + + return wrap_background_parser(ret, background) end ---This is used for generating documentation diff --git a/lua/overseer/template/vscode/provider/func/debug_java.lua b/lua/overseer/vscode/provider/func/debug_java.lua similarity index 100% rename from lua/overseer/template/vscode/provider/func/debug_java.lua rename to lua/overseer/vscode/provider/func/debug_java.lua diff --git a/lua/overseer/template/vscode/provider/func/debug_node.lua b/lua/overseer/vscode/provider/func/debug_node.lua similarity index 100% rename from lua/overseer/template/vscode/provider/func/debug_node.lua rename to lua/overseer/vscode/provider/func/debug_node.lua diff --git a/lua/overseer/template/vscode/provider/func/debug_powershell.lua b/lua/overseer/vscode/provider/func/debug_powershell.lua similarity index 100% rename from lua/overseer/template/vscode/provider/func/debug_powershell.lua rename to lua/overseer/vscode/provider/func/debug_powershell.lua diff --git a/lua/overseer/template/vscode/provider/func/init.lua b/lua/overseer/vscode/provider/func/init.lua similarity index 79% rename from lua/overseer/template/vscode/provider/func/init.lua rename to lua/overseer/vscode/provider/func/init.lua index 539c9a56..9d15181e 100644 --- a/lua/overseer/template/vscode/provider/func/init.lua +++ b/lua/overseer/vscode/provider/func/init.lua @@ -2,7 +2,6 @@ -- VS Code task definition provided by https://github.com/microsoft/vscode-azurefunctions -- Reference implementation: https://github.com/microsoft/vscode-azurefunctions/blob/411ece5f9453af075c1ff48c70aec349f5942a47/src/debug/FuncTaskProvider.ts#L101 local log = require("overseer.log") -local vs_util = require("overseer.template.vscode.vs_util") local M = {} M.problem_patterns = { @@ -72,11 +71,33 @@ local function get_runtime_from_language(language) end end +---@type {[1]: string, [2]: string[]}[] +local language_indicators = { + { "python", { "setup.py", "setup.cfg", "pyproject.toml", "mypy.ini" } }, + { "typescript", { "tsconfig.json" } }, + { "javascript", { "package.json" } }, + -- TODO java + -- TODO powershell +} + +---Get the primary language for the workspace +---TODO this is VERY incomplete at the moment +---@return string|nil +local function get_workspace_language() + for _, lang_config in ipairs(language_indicators) do + local lang, files = lang_config[1], lang_config[2] + for _, file in ipairs(files) do + if vim.uv.fs_stat(file) then + return lang + end + end + end +end + ---@param runtime? string ---@return table|nil local function get_debug_provider(runtime) - local ok, debug = - pcall(require, string.format("overseer.template.vscode.provider.func.debug_%s", runtime)) + local ok, debug = pcall(require, string.format("overseer.vscode.provider.func.debug_%s", runtime)) if ok then return debug else @@ -100,7 +121,7 @@ local function get_host_start_options(runtime, launch_config) } end else - log:warn("Azure func task provider could not find debug provider for runtime %s", runtime) + log.warn("Azure func task provider could not find debug provider for runtime %s", runtime) return nil end end @@ -112,13 +133,13 @@ M.get_task_opts = function(defn, launch_config) } if defn.command:match("^%s*host start") or defn.command:match("^%s*start") then - local language = vs_util.get_workspace_language() + local language = get_workspace_language() local runtime = get_runtime_from_language(language) if not defn.problemMatcher then if runtime then ret.problem_matcher = string.format("$func-%s-watch", runtime) else - log:warn("Azure func task provider could not find runtime for language %s", language) + log.warn("Azure func task provider could not find runtime for language %s", language) ret.problem_matcher = "$func-watch" end end @@ -126,7 +147,7 @@ M.get_task_opts = function(defn, launch_config) local start_opts = get_host_start_options(runtime, launch_config) ret = vim.tbl_deep_extend("force", ret, start_opts or {}) else - log:warn("Azure func task provider could not find debug provider for language %s", language) + log.warn("Azure func task provider could not find debug provider for language %s", language) end end diff --git a/lua/overseer/template/vscode/provider/npm.lua b/lua/overseer/vscode/provider/npm.lua similarity index 100% rename from lua/overseer/template/vscode/provider/npm.lua rename to lua/overseer/vscode/provider/npm.lua diff --git a/lua/overseer/template/vscode/provider/process.lua b/lua/overseer/vscode/provider/process.lua similarity index 100% rename from lua/overseer/template/vscode/provider/process.lua rename to lua/overseer/vscode/provider/process.lua diff --git a/lua/overseer/template/vscode/provider/shell.lua b/lua/overseer/vscode/provider/shell.lua similarity index 100% rename from lua/overseer/template/vscode/provider/shell.lua rename to lua/overseer/vscode/provider/shell.lua diff --git a/lua/overseer/template/vscode/provider/typescript.lua b/lua/overseer/vscode/provider/typescript.lua similarity index 87% rename from lua/overseer/template/vscode/provider/typescript.lua rename to lua/overseer/vscode/provider/typescript.lua index fe2a8f8e..acab7642 100644 --- a/lua/overseer/template/vscode/provider/typescript.lua +++ b/lua/overseer/vscode/provider/typescript.lua @@ -2,7 +2,7 @@ local files = require("overseer.files") local M = {} local function get_npm_bin(name) - local package_bin = files.join("node_modules", ".bin", name) + local package_bin = vim.fs.joinpath("node_modules", ".bin", name) if files.exists(package_bin) then return package_bin end diff --git a/lua/overseer/template/vscode/variables.lua b/lua/overseer/vscode/variables.lua similarity index 97% rename from lua/overseer/template/vscode/variables.lua rename to lua/overseer/vscode/variables.lua index a296a671..4d567545 100644 --- a/lua/overseer/template/vscode/variables.lua +++ b/lua/overseer/vscode/variables.lua @@ -77,7 +77,7 @@ M.replace_vars = function(str, params, precalculated_vars) return precalculated_vars[name] end if name == "userHome" then - return assert(vim.loop.os_homedir()) + return assert(vim.uv.os_homedir()) elseif name == "workspaceFolder" then return get_workspace_folder() elseif name == "workspaceRoot" then @@ -102,7 +102,7 @@ M.replace_vars = function(str, params, precalculated_vars) elseif name == "fileExtname" then return vim.fn.expand("%:e") elseif name == "cwd" then - return vim.loop.cwd() + return vim.uv.cwd() elseif name == "lineNumber" then return vim.api.nvim_win_get_cursor(0)[1] elseif name == "selectedText" then @@ -129,7 +129,7 @@ M.replace_vars = function(str, params, precalculated_vars) -- TODO does not support ${config:VALUE} -- TODO does not support ${command:VALUE} if name == "workspacefolder" or name == "config" or name == "command" then - log:warn("Unsupported VS Code variable: %s", fullname) + log.warn("Unsupported VS Code variable: %s", fullname) end return fullname end diff --git a/lua/overseer/template/vscode/vs_util.lua b/lua/overseer/vscode/vs_util.lua similarity index 57% rename from lua/overseer/template/vscode/vs_util.lua rename to lua/overseer/vscode/vs_util.lua index 94b926ce..1e16319a 100644 --- a/lua/overseer/template/vscode/vs_util.lua +++ b/lua/overseer/vscode/vs_util.lua @@ -1,29 +1,14 @@ local files = require("overseer.files") local M = {} ----Get the primary language for the workspace ----TODO this is VERY incomplete at the moment ----@return string|nil -M.get_workspace_language = function() - if files.any_exists("setup.py", "setup.cfg", "pyproject.toml", "mypy.ini") then - return "python" - elseif files.any_exists("tsconfig.json") then - return "typescript" - elseif files.any_exists("package.json") then - return "javascript" - end - -- TODO java - -- TODO powershell -end - ---@param dir string ---@return nil|string local function find_tasks_file(dir) local vscode_dirs = vim.fs.find(".vscode", { upward = true, type = "directory", path = dir, limit = math.huge }) for _, vscode_dir in ipairs(vscode_dirs) do - local tasks_file = files.join(vscode_dir, "tasks.json") - if files.exists(tasks_file) then + local tasks_file = vim.fs.joinpath(vscode_dir, "tasks.json") + if vim.uv.fs_stat(tasks_file) then return tasks_file end end @@ -37,6 +22,7 @@ M.get_tasks_file = function(cwd, dir) return find_tasks_file(cwd) or find_tasks_file(dir) end +---We use this so we can inject a different value in tests ---@param tasks_file string ---@return table M.load_tasks_file = function(tasks_file) diff --git a/lua/overseer/window.lua b/lua/overseer/window.lua index 6b032c63..ee6f9a45 100644 --- a/lua/overseer/window.lua +++ b/lua/overseer/window.lua @@ -80,7 +80,7 @@ M.is_open = function() end ---@class overseer.WindowOpts ----@field enter? boolean +---@field enter? boolean Focus the task list window after opening (default true) ---@field direction? "left"|"right"|"bottom" ---@field winid? integer Use this existing window instead of opening a new window ---@field focus_task_id? integer After opening, focus this task diff --git a/lua/resession/extensions/overseer.lua b/lua/resession/extensions/overseer.lua index 0ed5c522..7f8ffbc0 100644 --- a/lua/resession/extensions/overseer.lua +++ b/lua/resession/extensions/overseer.lua @@ -1,18 +1,23 @@ local M = {} +---@class (exact) overseer.ResessionConfig +---@field autostart_on_load boolean Whether to start tasks when loading (default true) +---@field filter overseer.ListTaskOpts Options to use when listing tasks to save local conf = {} +---@param data? overseer.ResessionConfig M.config = function(data) - conf = data + conf = vim.tbl_extend("keep", data or {}, { + autostart_on_load = true, + filter = {}, + }) end M.on_save = function() - local config = require("overseer.config") local task_list = require("overseer.task_list") - local opts = vim.tbl_deep_extend("keep", conf or {}, config.bundles.save_task_opts) local serialized = vim.tbl_map(function(task) return task:serialize() - end, task_list.list_tasks(opts)) + end, task_list.list_tasks(conf.filter)) if #serialized > 0 then return serialized end @@ -20,10 +25,9 @@ end M.on_load = function(data) local overseer = require("overseer") - local config = require("overseer.config") for _, params in ipairs(data) do local task = overseer.new_task(params) - if config.bundles.autostart_on_load then + if conf.autostart_on_load then task:start() end end @@ -34,21 +38,14 @@ M.is_win_supported = function(winid, bufnr) end M.save_win = function(winid) - local sidebar = require("overseer.task_list.sidebar") - local sb = sidebar.get() - return { - default_detail = sb.default_detail, - } + return {} end M.load_win = function(winid, data) local sidebar = require("overseer.task_list.sidebar") local window = require("overseer.window") window.open({ winid = winid }) - local sb = sidebar.get_or_create() - if data.default_detail then - sb:change_default_detail(data.default_detail - sb.default_detail) - end + sidebar.get_or_create() end return M diff --git a/plugin/overseer.lua b/plugin/overseer.lua new file mode 100644 index 00000000..34bf5e30 --- /dev/null +++ b/plugin/overseer.lua @@ -0,0 +1 @@ +require("overseer").private_setup() diff --git a/scripts/generate.py b/scripts/generate.py index 52e4d32f..aa379fab 100755 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -3,7 +3,6 @@ import os import re import subprocess -from collections import OrderedDict from functools import lru_cache from typing import Any, Dict, Iterable, List, Optional, Tuple @@ -22,7 +21,9 @@ parse_directory, read_section, render_md_api2, + render_md_classes, render_vimdoc_api2, + render_vimdoc_classes, replace_section, wrap, ) @@ -134,23 +135,6 @@ def update_components_md(): ofile.writelines(lines) -def get_parser_nodes() -> Dict[str, Any]: - names = [] - for filename in sorted(os.listdir(os.path.join(ROOT, "lua", "overseer", "parser"))): - basename = os.path.splitext(filename)[0] - if basename != "init": - names.append(basename) - escaped_names = ", ".join([f'"{name}"' for name in names]) - parsers_data = read_nvim_json( - f'require("overseer.parser").get_parser_docs({escaped_names})' - ) - ret = OrderedDict() - for name, data in zip(names, parsers_data): - if data: - ret[name] = data - return ret - - def get_desc(arg: Dict) -> str: desc = arg["desc"] if "default" in arg: @@ -158,56 +142,6 @@ def get_desc(arg: Dict) -> str: return desc -def format_parser_arg_table(args: List[Dict]) -> Iterable[str]: - rows = [] - any_subparams = False - for arg in args: - ftype = arg["type"].replace("|", r"\|") - rows.append( - { - "Param": arg["name"], - "Type": f"`{ftype}`", - "Desc": get_desc(arg), - } - ) - for subp in arg.get("fields", []): - any_subparams = True - ftype = subp["type"].replace("|", r"\|") - rows.append( - { - "Type": subp["name"], - "Desc": f"`{ftype}`", - "": get_desc(subp), - } - ) - - cols = ["Param", "Type", "Desc"] - if any_subparams: - cols.append("") - yield from format_md_table(rows, cols) - - -def format_parser_args(name: str, args: List[Dict]) -> Iterable[str]: - yield "```lua\n" - - def arg_name(arg: Dict) -> str: - if arg.get("vararg"): - return arg["name"] + "..." - else: - return arg["name"] - - required_args = ['"%s"' % name] + [ - arg_name(arg) for arg in args if not arg.get("position_optional") - ] - yield "{" + ", ".join(required_args) + "}\n" - all_args = ['"%s"' % name] + [arg_name(arg) for arg in args] - if len(all_args) != len(required_args): - yield "{" + ", ".join(all_args) + "}\n" - yield "```\n" - yield "\n" - yield from format_parser_arg_table(args) - - def format_example_code(code: str, indentation: int = 0) -> Iterable[str]: lines = code.split("\n") while re.match(r"^\s*$", lines[0]): @@ -220,7 +154,7 @@ def format_example_code(code: str, indentation: int = 0) -> Iterable[str]: def updated_problem_matcher_list(doc: str): patterns = read_nvim_json( - 'require("overseer.template.vscode.problem_matcher").list_patterns()' + 'require("overseer.vscode.problem_matcher").list_patterns()' ) lines = [f"- `{pat}`\n" for pat in patterns] replace_section( @@ -230,7 +164,7 @@ def updated_problem_matcher_list(doc: str): ["\n"] + lines, ) matchers = read_nvim_json( - 'require("overseer.template.vscode.problem_matcher").list_problem_matchers()' + 'require("overseer.vscode.problem_matcher").list_problem_matchers()' ) lines = [f"- `{matcher}`\n" for matcher in matchers] replace_section( @@ -242,47 +176,32 @@ def updated_problem_matcher_list(doc: str): def update_parsers_md(): - doc = os.path.join(ROOT, "doc", "parsers.md") + doc = os.path.join(DOC, "parsers.md") updated_problem_matcher_list(doc) - prefix = [ - "\n", - "This is a list of the parser nodes that are built-in to overseer. They can be found in [lua/overseer/parser](../lua/overseer/parser)\n", - "\n", - ] - lines = [] - for name, parser in get_parser_nodes().items(): - lines.append(f"### {name}\n\n") - lines.append(f"[{name}.lua](../lua/overseer/parser/{name}.lua)\n\n") - lines.append(parser["desc"]) - if parser.get("long_desc"): - lines[-1] += " \\\n" - lines.extend(wrap(parser["long_desc"], width=100)) - else: - lines[-1] += "\n" - lines.append("\n") - lines.extend(format_parser_args(parser["name"], parser["doc_args"])) - if parser.get("examples"): - lines.extend(["\n", "#### Examples\n"]) - for example in parser["examples"]: - lines.extend( - [ - "\n", - example["desc"] + "\n", - "\n", - "```lua\n", - ] - ) - lines.extend(format_example_code(example["code"])) - lines.extend(["```\n"]) - lines.append("\n") - while lines[-1] == "\n": - lines.pop() + types = parse_lua() + funcs = types.files["overseer/parselib.lua"].functions + lines = ["\n"] + render_md_api2(funcs, types, level=2) + ["\n"] replace_section( doc, - r"^## Parser nodes", - None, - prefix + lines, + r"^$", + r"^$", + lines, ) + update_md_toc(doc, 2) + + +def update_rendering_md(): + doc = os.path.join(DOC, "rendering.md") + types = parse_lua() + funcs = types.files["overseer/render.lua"].functions + lines = ["\n"] + render_md_api2(funcs, types, level=2) + ["\n"] + replace_section( + doc, + r"^$", + r"^$", + lines, + ) + update_md_toc(doc, 2) def update_commands_md(): @@ -413,6 +332,95 @@ def get_highlights_vimdoc() -> "VimdocSection": return section +def get_api_vimdoc() -> "VimdocSection": + types = parse_lua() + funcs = types.files["overseer/init.lua"].functions + section = VimdocSection( + "API", "overseer-api", render_vimdoc_api2("overseer", funcs, types) + ) + + task = types.classes["overseer.Task"] + section.body.extend(render_vimdoc_classes([task], types)) + section.body.append("\n") + funcs = types.files["overseer/task.lua"].functions + # Strip out Task.new because it's duplicative of overseer.new_task + funcs.pop(0) + section.body.append("\n") + section.body.extend(render_vimdoc_api2("overseer", funcs, types)) + section.body.append("\n") + return section + + +def load_params(params: Dict[str, Any]) -> List[LuaParam]: + ret = [] + for name, data in sorted(params.items()): + ret.append(LuaParam(name, data["type"], data["desc"])) + return ret + + +def get_keymaps_vimdoc() -> "VimdocSection": + section = VimdocSection("Keymaps", "overseer-keymaps", ["\n"]) + section.body.append( + """The `task_list.keymaps` option in `overseer.setup` allow you to create mappings +using all the same parameters as |vim.keymap.set|. +>lua + keymaps = { + -- Mappings can be a string + [""] = "lua require('overseer').run_action()", + -- Mappings can be a function + gd = function() + for _, task in ipairs(require("overseer").list_tasks()) do + task:dispose() + end + end, + -- You can pass additional opts to vim.keymap.set by using + -- a table with the mapping as the first element. + gd = { + function() + for _, task in ipairs(require("overseer").list_tasks()) do + task:dispose() + end + end, + mode = "n", + nowait = true, + desc = "Dispose all tasks" + }, + -- Mappings that are a string starting with "keymap." will be + -- one of the built-in keymaps, documented below. + p = "keymap.toggle_preview", + -- Some keymaps have parameters. These are passed in via the `opts` key. + dd = { "keymap.run_action", opts = { action = "dispose" }, desc = "Dispose task" }, + } +""" + ) + section.body.append("\n") + section.body.extend( + wrap( + """Below are the mappings that can be used in the `keymaps` section of config options. You can refer to them as strings (e.g. "keymaps.")""" + ) + ) + section.body.append("\n") + keymaps = read_nvim_json('require("overseer.task_list.keymaps")._get_keymaps()') + keymaps.sort(key=lambda a: a["name"]) + for keymap in keymaps: + if keymap.get("deprecated"): + continue + name = keymap["name"] + desc = keymap["desc"] + section.body.append(leftright(name, f"*keymaps.{name}*")) + section.body.extend(wrap(desc, 4)) + params = keymap.get("parameters") + if params: + section.body.append("\n") + section.body.append(" Parameters:\n") + section.body.extend( + format_vimdoc_params(load_params(params), LuaTypes(), 6) + ) + + section.body.append("\n") + return section + + def get_components_vimdoc() -> "VimdocSection": section = VimdocSection("Components", "overseer-components", ["\n"]) components = read_nvim_json('require("overseer.component").get_all_descriptions()') @@ -454,44 +462,6 @@ def get_components_vimdoc() -> "VimdocSection": return section -def get_strategies_vimdoc() -> "VimdocSection": - section = VimdocSection("Strategies", "overseer-strategies", ["\n"]) - new_funcs = get_strategy_funcs() - section.body += render_vimdoc_api2("strategy", new_funcs, parse_lua()) - return section - - -def get_parsers_vimdoc() -> "VimdocSection": - section = VimdocSection("Parsers", "overseer-parsers", ["\n"]) - for name, parser in get_parser_nodes().items(): - section.body.append(leftright(name, f"*parser.{name}*")) - section.body.append(4 * " " + parser["desc"] + "\n") - if "long_desc" in parser: - section.body.extend(wrap(parser["long_desc"], 4)) - params = [] - for arg in parser["doc_args"]: - subparams = [] - for subp in arg.get("fields", []): - subparams.append(LuaParam(subp["name"], subp["type"], get_desc(subp))) - params.append(LuaParam(arg["name"], arg["type"], get_desc(arg), subparams)) - if params: - section.body.extend(["\n", " Parameters:\n"]) - section.body.extend(format_vimdoc_params(params, parse_lua(), 6)) - if "examples" in parser: - for example in parser["examples"]: - section.body.extend(["\n", " Examples:\n"]) - section.body.extend(wrap(example["desc"], 4)) - section.body.extend( - [ - ">\n", - ] - ) - section.body.extend(format_example_code(example["code"], 4)) - section.body.extend(["<\n"]) - section.body.append("\n") - return section - - def convert_md_link(match): text = match[1] dest = match[2] @@ -516,19 +486,14 @@ def convert_md_section( def generate_vimdoc(): doc = Vimdoc("overseer.txt", "overseer") - types = parse_lua() - funcs = types.files["overseer/init.lua"].functions doc.sections.extend( [ get_commands_vimdoc(), get_options_vimdoc(), get_highlights_vimdoc(), - VimdocSection( - "API", "overseer-api", render_vimdoc_api2("overseer", funcs, types) - ), + get_api_vimdoc(), + get_keymaps_vimdoc(), get_components_vimdoc(), - get_strategies_vimdoc(), - get_parsers_vimdoc(), convert_md_section( os.path.join(DOC, "reference.md"), "^## Parameters", @@ -562,6 +527,22 @@ def update_md_api(): lines, ) + task = types.classes["overseer.Task"] + lines = render_md_classes([task], types, level=3) + lines.append("\n") + + funcs = types.files["overseer/task.lua"].functions + # Strip out Task.new because it's duplicative of overseer.new_task + funcs.pop(0) + lines.extend(render_md_api2(funcs, types, level=4)) + lines.append("\n") + replace_section( + os.path.join(DOC, "reference.md"), + r"^$", + r"^$", + lines, + ) + def update_md_toc(filename: str, max_level: int = 99): toc = ["\n"] + generate_md_toc(filename, max_level) + ["\n"] @@ -663,21 +644,15 @@ def update_reference_md(): components_toc = add_md_link_path( "components.md", generate_md_toc(os.path.join(DOC, "components.md")) ) - parser_section = read_section( - os.path.join(DOC, "parsers.md"), "^## Parser nodes", None - ) strategies_toc = add_md_link_path( "strategies.md", generate_md_toc(os.path.join(DOC, "strategies.md")) ) - parsers_toc = add_md_link_path("parsers.md", generate_md_toc(parser_section, 2)) reference_doc = os.path.join(DOC, "reference.md") toc = ["\n"] + generate_md_toc(reference_doc) + ["\n"] idx = toc.index("- [Components](#components)\n") toc[idx + 1 : idx + 1] = [" " + line for line in components_toc] idx = toc.index("- [Strategies](#strategies)\n") toc[idx + 1 : idx + 1] = [" " + line for line in strategies_toc] - idx = toc.index("- [Parsers](#parsers)\n") - toc[idx + 1 : idx + 1] = [" " + line for line in parsers_toc] replace_section( reference_doc, r"^$", @@ -696,12 +671,6 @@ def update_reference_md(): r"^$", ["\n"] + strategies_toc + ["\n"], ) - replace_section( - reference_doc, - r"^$", - r"^$", - ["\n"] + parsers_toc + ["\n"], - ) def main() -> None: @@ -710,7 +679,7 @@ def main() -> None: update_strategies_md() update_md_toc(os.path.join(DOC, "strategies.md"), 2) update_parsers_md() - update_md_toc(os.path.join(DOC, "parsers.md"), 2) + update_rendering_md() update_components_md() update_md_toc(os.path.join(DOC, "components.md")) update_reference_md() diff --git a/tests/manual/test_dependencies.lua b/tests/manual/test_dependencies.lua index 35f705f6..1735ee80 100644 --- a/tests/manual/test_dependencies.lua +++ b/tests/manual/test_dependencies.lua @@ -1,14 +1,14 @@ local overseer = require("overseer") -overseer.run_template( - { name = "shell", autostart = false, params = { cmd = "ls -l" } }, - function(task) - if task then - task:add_component({ - "dependencies", - task_names = { { "shell", cmd = "sleep 5" } }, - }) - task:start() - end - end -) +local task = overseer.new_task({ + cmd = "ls -l", + components = { + "default", + { + "dependencies", + tasks = { { cmd = "sleep 5" } }, + }, + }, +}) + +task:start() diff --git a/tests/manual/test_form.lua b/tests/manual/test_form.lua index 29370368..cdc9a761 100644 --- a/tests/manual/test_form.lua +++ b/tests/manual/test_form.lua @@ -1,4 +1,4 @@ -local overseer = require("overseer") +local form = require("overseer.form") local schema = { required_str = { desc = "This is a required param" }, @@ -16,15 +16,15 @@ local schema = { type = "number", optional = true, }, - required_list = { desc = "This is a required number param", type = "list" }, + required_list = { desc = "This is a required list param", type = "list" }, optional_list = { - desc = "This is an optional number param", + desc = "This is an optional list param", type = "list", optional = true, }, - required_bool = { desc = "This is a required number param", type = "boolean" }, + required_bool = { desc = "This is a required boolean param", type = "boolean" }, optional_bool = { - desc = "This is an optional number param", + desc = "This is an optional boolean param", type = "boolean", optional = true, }, @@ -36,7 +36,6 @@ local schema = { }, } -overseer.close() -- Need this to trigger the setup() -overseer.form.open("Test template builder", schema, {}, function(params) +form.open("Test form", schema, {}, function(params) vim.notify(vim.inspect(params)) end) diff --git a/tests/manual/test_orchestrator.lua b/tests/manual/test_orchestrator.lua index 3f1ec624..f9891759 100644 --- a/tests/manual/test_orchestrator.lua +++ b/tests/manual/test_orchestrator.lua @@ -6,19 +6,17 @@ local task = overseer.new_task({ "orchestrator", tasks = { { - "shell", name = "Assemble rocket", cmd = "echo mining ore && sleep 2 && echo bribing gremlins && sleep 2 && echo assembled!", }, { { name = "Fuel rocket", cmd = "echo fueling && sleep 2 && echo tanked up!" }, { - "shell", name = "Preflight checklist", cmd = "echo checking nose cone && sleep 1 && echo checking fins && sleep 1 && echo checking heat shield && sleep 1 && echo checking payload && sleep 1 && echo checklist passed!", }, }, - { "shell", name = "Launch", cmd = [[echo FWOOOOSH && sleep 2 && echo "we're in space"]] }, + { name = "Launch", cmd = [[echo FWOOOOSH && sleep 2 && echo "we're in space"]] }, }, }, }) diff --git a/tests/manual/test_task_editor.lua b/tests/manual/test_task_editor.lua index f484e8e2..90e91c07 100644 --- a/tests/manual/test_task_editor.lua +++ b/tests/manual/test_task_editor.lua @@ -4,4 +4,4 @@ local task = overseer.new_task({ cmd = { "echo", "hello", "world" }, }) -overseer.task_editor.open(task) +require("overseer.task_editor").open(task) diff --git a/tests/manual/test_wrapped_jobstart.lua b/tests/manual/test_wrapped_jobstart.lua new file mode 100644 index 00000000..b78977ef --- /dev/null +++ b/tests/manual/test_wrapped_jobstart.lua @@ -0,0 +1,13 @@ +local jid = vim.fn.jobstart("sleep 10", { + on_exit = function(_, code) + vim.notify("Job exited with code: " .. code) + end, +}) +print("sleep jid", jid) + +jid = vim.fn.jobstart("echo hihihiihihihi", { + on_stdout = function(_, data) + vim.notify("Job stdout: " .. vim.inspect(data)) + end, +}) +print("echo jid", jid) diff --git a/tests/manual/test_wrapped_system.lua b/tests/manual/test_wrapped_system.lua new file mode 100644 index 00000000..748bbda7 --- /dev/null +++ b/tests/manual/test_wrapped_system.lua @@ -0,0 +1,17 @@ +vim.system({ "sleep", "10" }, {}, function(out) + vim.notify("Job exited with code: " .. out.code) +end) + +vim.system({ "echo", "hihihiihihihi" }, { + stdout = function(err, data) + vim.notify(string.format("Job stdout: %s", data)) + end, +}) + +vim.system({ "echo", "hello world" }, {}, function(out) + vim.notify("Job stdout: " .. out.stdout) +end) + +local proc = vim.system({ "echo", "hello world" }, {}) +local out = proc:wait() +vim.notify("Job stdout: " .. out.stdout) diff --git a/tests/parser_spec.lua b/tests/parser_spec.lua index d1f7d57e..dd6a0156 100644 --- a/tests/parser_spec.lua +++ b/tests/parser_spec.lua @@ -1,780 +1,353 @@ -local parser = require("overseer.parser") -local STATUS = parser.STATUS - -describe("skip_until", function() - it("skips lines that do not match", function() - local node = parser.skip_until({ skip_matching_line = false }, "apple") - assert.equals(STATUS.RUNNING, node:ingest("foo")) - assert.equals(STATUS.RUNNING, node:ingest("bar")) - assert.equals(STATUS.SUCCESS, node:ingest("pineapple")) - end) - - it("can match multiple patterns", function() - local node = parser.skip_until({ skip_matching_line = false }, "foo", "bar") - assert.equals(STATUS.RUNNING, node:ingest("baz")) - assert.equals(STATUS.SUCCESS, node:ingest("foo")) - assert.equals(STATUS.SUCCESS, node:ingest("bar")) - end) - - it("skips the matching line by default", function() - local node = parser.skip_until("apple") - assert.equals(STATUS.RUNNING, node:ingest("foo")) - assert.equals(STATUS.RUNNING, node:ingest("bar")) - assert.equals(STATUS.RUNNING, node:ingest("pineapple")) - assert.equals(STATUS.SUCCESS, node:ingest("")) - end) -end) - -describe("skip_lines", function() - it("skips lines until count is met", function() - local node = parser.skip_lines(2) - assert.equals(STATUS.RUNNING, node:ingest("foo")) - assert.equals(STATUS.RUNNING, node:ingest("bar")) - assert.equals(STATUS.SUCCESS, node:ingest("pineapple")) - assert.equals(STATUS.SUCCESS, node:ingest("")) - end) - - it("resets", function() - local node = parser.skip_lines(2) - assert.equals(STATUS.RUNNING, node:ingest("foo")) - assert.equals(STATUS.RUNNING, node:ingest("bar")) - assert.equals(STATUS.SUCCESS, node:ingest("pineapple")) - node:reset() - assert.equals(STATUS.RUNNING, node:ingest("pineapple")) - end) -end) - -describe("extract", function() - it("extracts nothing when no match", function() - local node = parser.extract("hello (.+)", "name") - local ctx = { item = {} } - assert.equals(STATUS.FAILURE, node:ingest("foo", ctx)) - assert.is_true(vim.tbl_isempty(ctx.item)) - end) - - it("extracts fields when they match", function() - local node = parser.extract("(.+) (.+)", "action", "name") - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("hello world", ctx)) - assert.is_true(vim.tbl_isempty(ctx.item)) - assert.are.same({ { action = "hello", name = "world" } }, ctx.results) - assert.equals(STATUS.SUCCESS, node:ingest("next", ctx)) - end) - - it("can extract via vim regex", function() - local node = parser.extract({ regex = true }, "\\v(\\d+):(a|b)$", "lnum", "char") - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("123:b", ctx)) - assert.is_true(vim.tbl_isempty(ctx.item)) - assert.are.same({ { lnum = 123, char = "b" } }, ctx.results) - assert.equals(STATUS.SUCCESS, node:ingest("next", ctx)) - end) - - it("converts extracted integers by default", function() - local node = parser.extract("(.+):(%d+)", "file", "lnum") - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("/tmp:123", ctx)) - assert.is_true(vim.tbl_isempty(ctx.item)) - assert.are.same({ { file = "/tmp", lnum = 123 } }, ctx.results) - end) - - it("returns success if consume = false", function() - local node = parser.extract({ consume = false }, "(.+) (.+)", "action", "name") - local ctx = { item = {}, results = {} } - assert.equals(STATUS.SUCCESS, node:ingest("hello world", ctx)) - end) - - it("modifies item in-place if append = false", function() - local node = parser.extract({ append = false }, "(.+) (.+)", "action", "name") - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("hello world", ctx)) - assert.are.same({ action = "hello", name = "world" }, ctx.item) - end) - - it("can use a list of strings match", function() - local node = parser.extract({ consume = false, append = false }, { - "^(a.+)$", - "^(z.+)$", - }, "word") - local ctx = { item = {}, results = {} } - assert.equals(STATUS.FAILURE, node:ingest("something", ctx)) - node:reset() - assert.equals(STATUS.SUCCESS, node:ingest("apple", ctx)) - assert.equals("apple", ctx.item.word) - node:reset() - assert.equals(STATUS.SUCCESS, node:ingest("zero", ctx)) - assert.equals("zero", ctx.item.word) - end) - - it("can use a function match", function() - local node = parser.extract({ append = false }, function() - return "greetings", "Paul" - end, "action", "name") - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("hello world", ctx)) - assert.are.same({ action = "greetings", name = "Paul" }, ctx.item) - end) - - it("can use a list of functions match", function() - local node = parser.extract({ consume = false, append = false }, { - function(line) - if line:match("^a") then - return "alpha" - end - end, - function(line) - if line:match("^z") then - return "zeta" - end - end, - }, "letter") - local ctx = { item = {}, results = {} } - assert.equals(STATUS.FAILURE, node:ingest("something", ctx)) - node:reset() - assert.equals(STATUS.SUCCESS, node:ingest("apple", ctx)) - assert.equals("alpha", ctx.item.letter) - node:reset() - assert.equals(STATUS.SUCCESS, node:ingest("zero", ctx)) - assert.equals("zeta", ctx.item.letter) - end) - - it("can postprocess item", function() - local node = parser.extract({ - postprocess = function(item) - item.extra = true - end, - }, "(.+) (.+)", "action", "name") - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("hello world", ctx)) - assert.are.same({ { action = "hello", name = "world", extra = true } }, ctx.results) - end) - - it("can use a function to append", function() - local node = parser.extract({ - append = function(results, item) - results.single = item - end, - }, "(.+) (.+)", "action", "name") - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("hello world", ctx)) - assert.is_true(vim.tbl_isempty(ctx.item)) - assert.are.same({ action = "hello", name = "world" }, ctx.results.single) - end) -end) - -describe("extract_multiline", function() - it("extracts lines into a single field until no match", function() - local node = parser.extract_multiline("^.+$", "text") - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("hello", ctx)) - assert.equals(STATUS.RUNNING, node:ingest("world", ctx)) - assert.equals(STATUS.SUCCESS, node:ingest("", ctx)) - assert.are.same({ { text = "hello\nworld" } }, ctx.results) - end) - - it("returns FAILURE when no lines match", function() - local node = parser.extract_multiline("^.+$", "text") - local ctx = { item = {}, results = {} } - assert.equals(STATUS.FAILURE, node:ingest("", ctx)) - end) - - it("modifies item in-place if append = false", function() - local node = parser.extract_multiline({ append = false }, "^.+$", "text") - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("hello", ctx)) - assert.equals(STATUS.RUNNING, node:ingest("world", ctx)) - assert.equals(STATUS.SUCCESS, node:ingest("", ctx)) - assert.are.same({ text = "hello\nworld" }, ctx.item) - assert.are.same({}, ctx.results) - end) -end) - -describe("extract_nested", function() - it("extracts children into a nested key", function() - local node = parser.extract_nested( - "child", - parser.sequence(parser.extract("(%a+)", "word"), parser.extract("(%d+)", "num")) - ) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("hello 123", ctx)) - assert.equals(STATUS.RUNNING, node:ingest("world 456", ctx)) - assert.equals(STATUS.SUCCESS, node:ingest("", ctx)) - assert.are.same({ { child = { { word = "hello" }, { num = 456 } } } }, ctx.results) - end) - - it("returns FAILURE when no children match", function() - local node = parser.extract_nested("child", parser.extract("(%d+)", "num")) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.FAILURE, node:ingest("hello", ctx)) - end) - - it("can return SUCCESS even when no children match", function() - local node = - parser.extract_nested({ fail_on_empty = false }, "child", parser.extract("(%d+)", "num")) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.SUCCESS, node:ingest("hello", ctx)) - assert.are.same({ { child = {} } }, ctx.results) - end) - - it("modifies item in-place if append = false", function() - local node = parser.extract_nested({ append = false }, "child", parser.extract("(%d+)", "num")) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("123", ctx)) - assert.equals(STATUS.SUCCESS, node:ingest("", ctx)) - assert.are.same({}, ctx.results) - assert.are.same({ child = { { num = 123 } } }, ctx.item) - end) -end) - -describe("extract_json", function() - it("extracts json values", function() - local node = parser.extract_json() - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest('{"msg": "hello"}', ctx)) - assert.are.same({ { msg = "hello" } }, ctx.results) - assert.equals(STATUS.SUCCESS, node:ingest("next", ctx)) - end) - - it("modifies item in-place if append = false", function() - local node = parser.extract_json({ append = false }) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest('{"msg": "hello"}', ctx)) - assert.are.same({ msg = "hello" }, ctx.item) - end) - - it("can use a function to append", function() - local node = parser.extract_json({ - append = function(results, item) - results.single = item - end, - }) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest('{"msg": "hello"}', ctx)) - assert.are.same({ single = { msg = "hello" } }, ctx.results) - end) - - it("can test the values before appending", function() - local node = parser.extract_json({ - test = function(values) - return values.action == "pass" - end, - }) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest('{"action": "pass", "msg": "hello"}', ctx)) - node:reset() - assert.equals(STATUS.FAILURE, node:ingest('{"action": "fail", "msg": "bye"}', ctx)) - assert.are.same({ { action = "pass", msg = "hello" } }, ctx.results) - end) - - it("can postprocess item", function() - local node = parser.extract_json({ - postprocess = function(item) - item.extra = true - end, - }) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest('{"msg": "hello"}', ctx)) - assert.are.same({ { msg = "hello", extra = true } }, ctx.results) - end) -end) - -describe("extract_efm", function() - it("extracts values using built-in errorformat", function() - local node = parser.extract_efm() - local ctx = { item = {}, results = {} } - vim.o.errorformat = "%f:%l: %m" - assert.equals(STATUS.RUNNING, node:ingest("foo.txt:15: Text", ctx)) - assert.are.same( - { { filename = vim.fn.fnamemodify("foo.txt", ":p"), lnum = 15, text = "Text" } }, - ctx.results - ) - assert.equals(STATUS.SUCCESS, node:ingest("next", ctx)) - end) - - it("extracts values using passed-in errorformat", function() - local node = parser.extract_efm({ efm = "%f:%m" }) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("foo.txt:15: Text", ctx)) - assert.are.same( - { { filename = vim.fn.fnamemodify("foo.txt", ":p"), text = "15: Text" } }, - ctx.results - ) - assert.equals(STATUS.SUCCESS, node:ingest("next", ctx)) - end) - - it("modifies item in-place if append = false", function() - local node = parser.extract_efm({ efm = "%m", append = false }) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("hello", ctx)) - assert.are.same({ text = "hello" }, ctx.item) - end) - - it("can use a function to append", function() - local node = parser.extract_efm({ - efm = "%m", - append = function(results, item) - results.single = item - end, - }) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("hello", ctx)) - assert.are.same({ single = { text = "hello" } }, ctx.results) - end) - - it("can test the values before appending", function() - local node = parser.extract_efm({ - efm = "%m", - test = function(values) - return values.text == "pass" - end, - }) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("pass", ctx)) - node:reset() - assert.equals(STATUS.FAILURE, node:ingest("fail", ctx)) - assert.are.same({ { text = "pass" } }, ctx.results) - end) - - it("can postprocess item", function() - local node = parser.extract_efm({ - efm = "%m", - postprocess = function(item) - item.extra = true - end, - }) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("hello", ctx)) - assert.are.same({ { text = "hello", extra = true } }, ctx.results) - end) -end) - -describe("test", function() - it("returns FAILURE when no match", function() - local node = parser.test("hello (.+)") - assert.equals(STATUS.FAILURE, node:ingest("foo")) - end) - - it("returns SUCCESS when it matches", function() - local node = parser.test("(.+) (.+)") - assert.equals(STATUS.SUCCESS, node:ingest("hello world")) - end) - - it("can use a list of strings match", function() - local node = parser.test({ - "^(a.+)$", - "^(z.+)$", - }) - assert.equals(STATUS.FAILURE, node:ingest("something")) - assert.equals(STATUS.SUCCESS, node:ingest("apple")) - assert.equals(STATUS.SUCCESS, node:ingest("zero")) - end) - - it("can use a function match", function() - local node = parser.test(function() - return true +local parselib = require("overseer.parselib") + +---@param obj vim.quickfix.entry +---@return vim.quickfix.entry +local function make_qf_item(obj) + return vim.tbl_extend("keep", obj, { + bufnr = -1, + col = 0, + end_col = 0, + end_lnum = 0, + lnum = 0, + module = "", + nr = -1, + pattern = "", + text = "", + type = "", + valid = 1, + vcol = 0, + }) +end + +describe("parselib", function() + describe("make_match_fn", function() + it("matches lua patterns", function() + local match = parselib.make_lua_match_fn("(%w+) (.*)$") + local ret = match("hello world") + assert.are.same({ "hello", "world" }, ret) end) - assert.equals(STATUS.SUCCESS, node:ingest("hello world")) - end) - - it("can use a list of functions match", function() - local node = parser.test({ - function(line) - return line:match("^a") - end, - function(line) - return line:match("^z") - end, - }) - assert.equals(STATUS.FAILURE, node:ingest("something")) - assert.equals(STATUS.SUCCESS, node:ingest("apple")) - assert.equals(STATUS.SUCCESS, node:ingest("zero")) - end) - - it("can use a regex match", function() - local node = parser.test({ regex = true }, "\\v^hello") - assert.equals(STATUS.SUCCESS, node:ingest("hello world")) - end) - - it("can use a list of regexes", function() - local node = parser.test({ regex = true }, { "\\v^a", "\\v^z" }) - assert.equals(STATUS.FAILURE, node:ingest("something")) - assert.equals(STATUS.SUCCESS, node:ingest("apple")) - assert.equals(STATUS.SUCCESS, node:ingest("zero")) - end) -end) - -describe("append", function() - it("appends item to the results", function() - local node = parser.append() - local ctx = { item = { foo = "bar" }, results = {} } - assert.equals(STATUS.SUCCESS, node:ingest("foo", ctx)) - assert.are.same({ { foo = "bar" } }, ctx.results) - assert.is_true(vim.tbl_isempty(ctx.item)) - end) -end) - -describe("set_defaults", function() - it("sets default values for parsed items", function() - local node = parser.set_defaults({ values = { foo = "bar" } }, parser.extract("(.+)", "word")) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("foo", ctx)) - assert.are.same({ { foo = "bar", word = "foo" } }, ctx.results) - end) - - it("sets hoists current item into default values", function() - local node = parser.set_defaults(parser.loop(parser.extract("(.+)", "word"))) - local ctx = { item = { foo = "bar" }, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("foo", ctx)) - assert.equals(STATUS.RUNNING, node:ingest("bar", ctx)) - assert.are.same({ { foo = "bar", word = "foo" }, { foo = "bar", word = "bar" } }, ctx.results) - end) -end) - -describe("loop", function() - it("can propagate failures", function() - local node = parser.loop(parser.extract("^a.*", "word")) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("apple", ctx)) - assert.equals(STATUS.FAILURE, node:ingest("foo", ctx)) - end) - - it("can ignore failures", function() - local node = parser.loop({ ignore_failure = true }, parser.extract("^a.*", "word")) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("foo", ctx)) - assert.equals(STATUS.RUNNING, node:ingest("apple", ctx)) - assert.equals(STATUS.RUNNING, node:ingest("foo", ctx)) - assert.equals(STATUS.RUNNING, node:ingest("antlers", ctx)) - assert.are.same({ { word = "apple" }, { word = "antlers" } }, ctx.results) - end) - - it("can loop a specific number of times", function() - local node = parser.loop({ repetitions = 2 }, parser.skip_lines(1)) - assert.equals(STATUS.RUNNING, node:ingest("apple")) - assert.equals(STATUS.RUNNING, node:ingest("foo")) - assert.equals(STATUS.SUCCESS, node:ingest("bar")) - end) - - it("can short-circuit if stuck in infinite loop", function() - local node = parser.loop(parser.test(".*")) - assert.equals(STATUS.RUNNING, node:ingest("apple")) - end) -end) - -describe("sequence", function() - it("runs child nodes in succession", function() - local node = parser.sequence(parser.extract("^(.+) ", "word"), parser.extract(" (.+)$", "word")) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("hello there", ctx)) - assert.equals(STATUS.RUNNING, node:ingest("seal party", ctx)) - assert.equals(STATUS.SUCCESS, node:ingest("", ctx)) - assert.are.same({ { word = "hello" }, { word = "party" } }, ctx.results) - end) - - it("stops running on first failure", function() - local node = parser.sequence(parser.extract("^(.+) ", "word"), parser.extract(" (.+)$", "word")) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("hello there", ctx)) - assert.equals(STATUS.FAILURE, node:ingest("Kansas", ctx)) - assert.are.same({ { word = "hello" } }, ctx.results) - end) - - it("has option to ignore failure", function() - local node = parser.sequence( - { break_on_first_failure = false }, - parser.extract("^(.+) ", "word"), - parser.extract(" (.+)$", "word"), - parser.extract("(.+)$", "word") - ) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("hello there", ctx)) - assert.equals(STATUS.RUNNING, node:ingest("Kansas", ctx)) - assert.equals(STATUS.FAILURE, node:ingest("", ctx)) - assert.are.same({ { word = "hello" }, { word = "Kansas" } }, ctx.results) - end) - - it("has option to finish on first success", function() - local node = parser.sequence( - { break_on_first_failure = false, break_on_first_success = true }, - parser.extract("^(.+) ", "word"), - parser.extract("^%d+$", "word"), - parser.extract("(.+)$", "word") - ) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("123", ctx)) - assert.equals(STATUS.SUCCESS, node:ingest("Kansas", ctx)) - assert.are.same({ { word = 123 } }, ctx.results) - end) -end) -describe("parallel", function() - it("runs children in parallel", function() - local node = parser.parallel(parser.extract("%a+", "word"), parser.extract("%d+", "num")) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("hello123", ctx)) - assert.equals(STATUS.SUCCESS, node:ingest("", ctx)) - assert.are.same({ { word = "hello" }, { num = 123 } }, ctx.results) - end) - - it("stops running on first failure", function() - local node = parser.parallel(parser.extract("%d+", "num"), parser.extract("%a+", "word")) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.FAILURE, node:ingest("hello", ctx)) - assert.is_true(vim.tbl_isempty(ctx.results)) - end) - - it("has option to ignore failure", function() - local node = parser.parallel( - { break_on_first_failure = false }, - parser.extract("%d+", "num"), - parser.extract("%a+", "word") - ) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("hello", ctx)) - assert.equals(STATUS.FAILURE, node:ingest("", ctx)) - assert.are.same({ { word = "hello" } }, ctx.results) - end) + it("lua pattern returns nil if no match", function() + local match = parselib.make_lua_match_fn("(%w+) (.*)$") + local ret = match("helloworld") + assert.is_nil(ret) + end) - it("has option to finish on first success", function() - local node = parser.parallel( - { break_on_first_failure = false, break_on_first_success = true }, - parser.extract("%a+", "word"), - parser.extract("%d+", "num") - ) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("hello", ctx)) - assert.equals(STATUS.SUCCESS, node:ingest("", ctx)) - assert.are.same({ { word = "hello" } }, ctx.results) - end) + it("matches vim regex", function() + local match = parselib.make_regex_match_fn("\\v^(\\S+) (.*)$") + local ret = match("hello world") + assert.are.same({ "hello", "world" }, ret) + end) - it("has option to restart children on each run", function() - local node = parser.parallel( - { reset_children = true }, - parser.sequence(parser.extract("%a+", "word"), parser.extract("%d+", "word")) - ) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("hello123", ctx)) - assert.equals(STATUS.RUNNING, node:ingest("hello123", ctx)) - assert.equals(STATUS.RUNNING, node:ingest("hello123", ctx)) - assert.are.same({ { word = "hello" }, { word = "hello" }, { word = "hello" } }, ctx.results) + it("vim regex returns nil if no match", function() + local match = parselib.make_regex_match_fn("\\v^(\\S+) (.*)$") + local ret = match("helloworld") + assert.is_nil(ret) + end) end) -end) -describe("always", function() - it("turns FAILURE into SUCCESS", function() - local node = parser.always(parser.test("^a")) - assert.equals(STATUS.SUCCESS, node:ingest("apple")) - assert.equals(STATUS.SUCCESS, node:ingest("foobar")) - end) + describe("make_parse_fn", function() + it("returns nil when no match", function() + local match = parselib.make_lua_match_fn("foo") + local parse = parselib.make_parse_fn(match, { "text" }) + local ret = parse("hello") + assert.is_nil(ret) + end) - it("propagates RUNNING", function() - local node = parser.always(parser.extract("^(a.*)", "word")) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("apple", ctx)) - end) -end) + it("returns item when match", function() + local match = parselib.make_lua_match_fn(".*") + local parse = parselib.make_parse_fn(match, { "text" }) + local ret = parse("hello") + assert.are.same({ text = "hello" }, ret) + end) -describe("inline", function() - it("Returns callback results", function() - local node = parser.inline(function() - return STATUS.SUCCESS + it("automatically converts numbers", function() + local match = parselib.make_lua_match_fn(".*") + local parse = parselib.make_parse_fn(match, { "text" }) + local ret = parse("44") + assert.are.same({ text = 44 }, ret) end) - assert.equals(STATUS.SUCCESS, node:ingest("hello")) - end) - it("Calls reset callback", function() - local count = 0 - local node = parser.inline(function() - count = count + 1 - return count >= 2 and STATUS.SUCCESS or STATUS.RUNNING - end, function() - count = 0 + it("automatically converts quickfix type", function() + local match = parselib.make_lua_match_fn(".*") + local parse = parselib.make_parse_fn(match, { "type" }) + local ret = parse("error") + assert.are.same({ type = "E" }, ret) end) - assert.equals(STATUS.RUNNING, node:ingest("hello")) - assert.equals(STATUS.SUCCESS, node:ingest("hello")) - node:reset() - assert.equals(STATUS.RUNNING, node:ingest("hello")) - assert.equals(STATUS.SUCCESS, node:ingest("hello")) - assert.equals(STATUS.SUCCESS, node:ingest("hello")) - end) -end) -describe("invert", function() - it("Turns a failure into a success", function() - local node = parser.invert(parser.extract({ consume = false }, "apple", "fruit")) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.SUCCESS, node:ingest("bees", ctx)) - end) + it("skips fields labeled '_'", function() + local match = parselib.make_lua_match_fn("(%w+) (%w+) (%w+)") + local parse = parselib.make_parse_fn(match, { "first", "_", "last" }) + local ret = parse("hello there world") + assert.are.same({ first = "hello", last = "world" }, ret) + end) - it("Turns a success into a failure", function() - local node = parser.invert(parser.extract({ consume = false }, "apple", "fruit")) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.FAILURE, node:ingest("apple", ctx)) + it("fields can pass a postprocess function", function() + local match = parselib.make_lua_match_fn(".*") + local parse = parselib.make_parse_fn(match, { + { + "text", + function(v) + return v:upper() + end, + }, + }) + local ret = parse("hello") + assert.are.same({ text = "HELLO" }, ret) + end) end) - it("Passes RUNNING through unchanged", function() - local node = parser.invert(parser.extract("apple", "fruit")) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("apple", ctx)) - end) -end) + describe("parser_from_errorformat", function() + it("returns empty results when no match", function() + local parser = parselib.parser_from_errorformat("%f:%l") + parser:parse("_____") + assert.are.same({ diagnostics = {} }, parser:get_result()) + end) -describe("until", function() - it("returns RUNNING until child succeeds", function() - local node = parser.ensure(parser.extract({ consume = false }, "apple", "fruit")) - local ctx = { item = {}, results = {} } - assert.equals(STATUS.RUNNING, node:ingest("bees", ctx)) - assert.equals(STATUS.RUNNING, node:ingest("Stanley", ctx)) - assert.equals(STATUS.SUCCESS, node:ingest("apple", ctx)) - end) -end) + it("matches lines", function() + local parser = parselib.parser_from_errorformat("%f:%l") + parser:parse("foo:22") + assert.are.same({ + diagnostics = { + make_qf_item({ + bufnr = vim.fn.bufadd("foo"), + lnum = 22, + }), + }, + }, parser:get_result()) + end) -describe("parser", function() - it("parses simple lines into a list", function() - local p = parser.new({ - parser.extract("^(.+):(%d+)", "filename", "lnum"), - }) - p:ingest({ - "foo", - "/file.lua:23", - "/other.cpp:128", - "bar", - }) - local result = p:get_result() - assert.are.same({ - { filename = "/file.lua", lnum = 23 }, - { filename = "/other.cpp", lnum = 128 }, - }, result) + it("handles multiline matches", function() + local parser = parselib.parser_from_errorformat("%A%f:%l,%Z%m") + parser:parse("foo:10") + assert.are.same({ + diagnostics = { + make_qf_item({ + bufnr = vim.fn.bufadd("foo"), + lnum = 10, + }), + }, + }, parser:get_result()) + parser:parse("errmsg") + assert.are.same({ + diagnostics = { + make_qf_item({ + bufnr = vim.fn.bufadd("foo"), + text = "\nerrmsg", + lnum = 10, + }), + }, + }, parser:get_result()) + + parser:parse("bar:11") + assert.are.same({ + diagnostics = { + make_qf_item({ + bufnr = vim.fn.bufadd("foo"), + text = "\nerrmsg", + lnum = 10, + }), + make_qf_item({ + bufnr = vim.fn.bufadd("bar"), + lnum = 11, + }), + }, + }, parser:get_result()) + end) end) - it("creates namespaced results for map-like args", function() - local p = parser.new({ - lnums = { parser.extract("(%d+)", "lnum") }, - filenames = { parser.extract("([/%a%.]+)", "filename") }, - }) - p:ingest({ - "/file.lua:23", - "/other.cpp:128", - }) - local result = p:get_result() - assert.are.same({ - filenames = { { filename = "/file.lua" }, { filename = "/other.cpp" } }, - lnums = { { lnum = 23 }, { lnum = 128 } }, - }, result) + describe("make_parser", function() + it("matches and resets", function() + local match = parselib.make_lua_match_fn("^(%w+)$") + local parse = parselib.make_parse_fn(match, { "word" }) + local parser = parselib.make_parser(parse) + -- non-matching lines + parser:parse("hello world") + parser:parse("multiple words") + assert.are.same({ diagnostics = {} }, parser:get_result()) + + -- some lines match + parser:parse("hello") + parser:parse("multiple words") + parser:parse("world") + assert.are.same( + { diagnostics = { + { word = "hello" }, + { word = "world" }, + } }, + parser:get_result() + ) + + -- reset clears results + parser:reset() + assert.are.same({ diagnostics = {} }, parser:get_result()) + end) end) - describe("events", function() - it("list parser dispatches new_item events", function() - local p = parser.new({ - parser.extract("^(.+):(%d+)", "filename", "lnum"), - }) - local results = {} - p:subscribe("new_item", function(_, item) - table.insert(results, item) - end) - p:ingest({ - "foo", - "/file.lua:23", - "/other.cpp:128", - "bar", - }) + describe("combine_parsers", function() + it("combines results", function() + local match1 = parselib.make_lua_match_fn("^(%w+)") + local parse1 = parselib.make_parse_fn(match1, { "first" }) + local parser1 = parselib.make_parser(parse1) + local match2 = parselib.make_lua_match_fn("(%w+)$") + local parse2 = parselib.make_parse_fn(match2, { "last" }) + local parser2 = parselib.make_parser(parse2) + local parser = parselib.combine_parsers({ parser1, parser2 }) + + -- non-matching lines + parser:parse("^^^&$&$&&$*") + assert.are.same({ diagnostics = {} }, parser:get_result()) + + -- some lines match + parser:parse("hello there world") + parser:parse("*nonmatch present") + parser:parse("present nonmatch*") assert.are.same({ - { filename = "/file.lua", lnum = 23 }, - { filename = "/other.cpp", lnum = 128 }, - }, results) + diagnostics = { + { first = "hello" }, + { first = "present" }, + { last = "world" }, + { last = "present" }, + }, + }, parser:get_result()) + + -- reset clears results + parser:reset() + assert.are.same({ diagnostics = {} }, parser:get_result()) end) + end) - it("map parser dispatches new_item events", function() - local p = parser.new({ - filenames = { parser.extract("([/%a%.]+)", "filename") }, - }) - local results = {} - p:subscribe("new_item", function(k, item) - table.insert(results, { k, item }) - end) - p:ingest({ - "/file.lua:23", - "/other.cpp:128", - }) - assert.are.same({ - { "filenames", { filename = "/file.lua" } }, - { "filenames", { filename = "/other.cpp" } }, - }, results) + describe("wrap_background_parser", function() + it("parses lines between start/end patterns", function() + local test_start = parselib.match_to_test_fn(parselib.make_lua_match_fn("^start$")) + local test_end = parselib.match_to_test_fn(parselib.make_lua_match_fn("^end$")) + local match = parselib.make_lua_match_fn("^(%w+)$") + local parse = parselib.make_parse_fn(match, { "word" }) + local parser = parselib.make_parser(parse) + + local bg = + parselib.wrap_background_parser(parser, { start_fn = test_start, end_fn = test_end }) + + -- not parsing yet + bg:parse("foo") + assert.are.same({ diagnostics = {} }, bg:get_result()) + assert.equal(0, bg.result_version) + + -- parse some values + bg:parse("start") + bg:parse("foo") + bg:parse("bar") + assert.are.same({ diagnostics = { { word = "foo" }, { word = "bar" } } }, bg:get_result()) + assert.equal(0, bg.result_version) + + -- end will stop parsing + bg:parse("end") + bg:parse("foo") + assert.are.same({ diagnostics = { { word = "foo" }, { word = "bar" } } }, bg:get_result()) + assert.equal(1, bg.result_version) + + -- start pattern will reset + bg:parse("start") + assert.are.same({ diagnostics = {} }, bg:get_result()) + assert.equal(2, bg.result_version) + + -- will resume parsing again + bg:parse("foo") + assert.are.same({ diagnostics = { { word = "foo" } } }, bg:get_result()) + assert.equal(2, bg.result_version) + + -- resets + bg:reset() + assert.are.same({ diagnostics = {} }, bg:get_result()) + assert.equal(0, bg.result_version) + + -- not parsing yet after reset + bg:parse("foo") + assert.are.same({ diagnostics = {} }, bg:get_result()) + assert.equal(0, bg.result_version) end) - it("dispatch node can dispatch events", function() - local p = parser.new({ - parser.extract("^(.+):(%d+)", "filename", "lnum"), - parser.dispatch("foo", "hi"), - }) - local calls = 0 - p:subscribe("foo", function(arg) - assert.equals("hi", arg) - calls = calls + 1 - end) - p:ingest({ - "foo", - "/file.lua:23", - "", -- To flush the RUNNING state - }) - assert.equals(1, calls) + it("can set active_on_start", function() + local test_start = parselib.match_to_test_fn(parselib.make_lua_match_fn("^start$")) + local test_end = parselib.match_to_test_fn(parselib.make_lua_match_fn("^end$")) + local match = parselib.make_lua_match_fn("^(%w+)$") + local parse = parselib.make_parse_fn(match, { "word" }) + local parser = parselib.make_parser(parse) + + local bg = parselib.wrap_background_parser( + parser, + { start_fn = test_start, end_fn = test_end, active_on_start = true } + ) + + -- starts parsing immediately + bg:parse("foo") + bg:parse("bar") + assert.are.same({ diagnostics = { { word = "foo" }, { word = "bar" } } }, bg:get_result()) + assert.equal(0, bg.result_version) + + -- end will stop parsing + bg:parse("end") + bg:parse("foo") + assert.are.same({ diagnostics = { { word = "foo" }, { word = "bar" } } }, bg:get_result()) + assert.equal(1, bg.result_version) end) - it("can unsubscribe from events", function() - local p = parser.new({ - parser.extract("^(.+):(%d+)", "filename", "lnum"), - }) - local results = {} - local cb = function(_, item) - table.insert(results, item) - end - p:subscribe("new_item", cb) - p:ingest({ - "/file.lua:23", - }) - p:unsubscribe("new_item", cb) - p:ingest({ - "/other.cpp:128", - }) - assert.are.same({ - { filename = "/file.lua", lnum = 23 }, - }, results) + it("functions with no end_fn", function() + local test_start = parselib.match_to_test_fn(parselib.make_lua_match_fn("^start$")) + local match = parselib.make_lua_match_fn("^(%w+)$") + local parse = parselib.make_parse_fn(match, { "word" }) + local parser = parselib.make_parser(parse) + + local bg = parselib.wrap_background_parser(parser, { start_fn = test_start }) + + -- not parsing yet + bg:parse("foo") + assert.are.same({ diagnostics = {} }, bg:get_result()) + assert.equal(0, bg.result_version) + + -- parse some values + bg:parse("start") + bg:parse("foo") + bg:parse("bar") + assert.are.same({ diagnostics = { { word = "foo" }, { word = "bar" } } }, bg:get_result()) + assert.equal(0, bg.result_version) + + -- resets + bg:reset() + assert.are.same({ diagnostics = {} }, bg:get_result()) + assert.equal(0, bg.result_version) end) - it("dispatch node can generate dynamic arguments for event", function() - local p = parser.new({ - parser.extract("^(.+):(%d+)", "filename", "lnum"), - parser.dispatch("foo", function(line, ctx) - return line - end), - }) - local called = {} - p:subscribe("foo", function(line) - table.insert(called, line) - end) - p:ingest({ - "foo", - "/file.lua:23", - "flush", -- To flush the RUNNING state - }) - assert.are.same({ "flush" }, called) + it("functions with no start_fn", function() + local test_end = parselib.match_to_test_fn(parselib.make_lua_match_fn("^end$")) + local match = parselib.make_lua_match_fn("^(%w+)$") + local parse = parselib.make_parse_fn(match, { "word" }) + local parser = parselib.make_parser(parse) + + local bg = + parselib.wrap_background_parser(parser, { end_fn = test_end, active_on_start = true }) + + -- parse some values + bg:parse("foo") + bg:parse("bar") + assert.are.same({ diagnostics = { { word = "foo" }, { word = "bar" } } }, bg:get_result()) + assert.equal(0, bg.result_version) + + -- end will stop parsing + bg:parse("end") + bg:parse("foo") + assert.are.same({ diagnostics = { { word = "foo" }, { word = "bar" } } }, bg:get_result()) + assert.equal(1, bg.result_version) + + -- resets + bg:reset() + assert.are.same({ diagnostics = {} }, bg:get_result()) + assert.equal(0, bg.result_version) + + -- parses again after reset + bg:parse("foo") + assert.are.same({ diagnostics = { { word = "foo" } } }, bg:get_result()) + assert.equal(0, bg.result_version) end) end) end) - -describe("deserialize", function() - it("creates parser from raw data passed in", function() - local p = parser.new({ - { "always", { "invert", { "extract", { consume = true }, "apple", "fruit" } } }, - }) - p:ingest({ - "foo", - "bar", - "apple", - }) - local result = p:get_result() - assert.are.same({ - { fruit = "apple" }, - }, result) - end) -end) diff --git a/tests/template/prompt_spec.lua b/tests/template/prompt_spec.lua index 5e015dc8..f43330ec 100644 --- a/tests/template/prompt_spec.lua +++ b/tests/template/prompt_spec.lua @@ -1,36 +1,8 @@ local template = require("overseer.template") describe("template should prompt", function() - it("always returns false if schema is empty", function() - local show_prompt, err = template._should_prompt("always", {}, {}) - assert.is_false(show_prompt) - assert.is_nil(err) - end) - - it("always returns true if prompt = 'always'", function() - local show_prompt, err = template._should_prompt("always", { - foo = { - optional = true, - default = "hi", - }, - }, { foo = "hihi" }) - assert.is_true(show_prompt) - assert.is_nil(err) - end) - - it("always returns true if prompt = 'always'", function() - local show_prompt, err = template._should_prompt("always", { - foo = { - optional = true, - default = "hi", - }, - }, { foo = "hihi" }) - assert.is_true(show_prompt) - assert.is_nil(err) - end) - - it("returns false if prompt = 'never' and all params have default", function() - local show_prompt, err = template._should_prompt("never", { + it("returns false if disallow_prompt=true and all params have default", function() + local show_prompt, err = template._should_prompt(true, { foo = { default = "hi", }, @@ -39,8 +11,8 @@ describe("template should prompt", function() assert.is_nil(err) end) - it("returns false if prompt = 'never' and all params were passed a value", function() - local show_prompt, err = template._should_prompt("never", { + it("returns false if disallow_prompt=true and all params were passed a value", function() + local show_prompt, err = template._should_prompt(true, { foo = {}, }, { foo = "hi" }) assert.is_false(show_prompt) @@ -48,9 +20,9 @@ describe("template should prompt", function() end) it( - "returns error if prompt = 'never' and any non-optional, non-default param is missing a value", + "returns error if disallow_prompt=true and any non-optional, non-default param is missing a value", function() - local show_prompt, err = template._should_prompt("never", { + local show_prompt, err = template._should_prompt(true, { foo = {}, }, {}) assert.is_nil(show_prompt) @@ -58,107 +30,26 @@ describe("template should prompt", function() end ) - it("returns true if prompt = 'allow' and any non-optional param is missing a value", function() - local show_prompt, err = template._should_prompt("allow", { - foo = {}, - }, {}) - assert.is_true(show_prompt) - assert.is_nil(err) - end) - - it( - "returns true if prompt = 'allow' and any param is non-optional with missing value, even if default", - function() - local show_prompt, err = template._should_prompt("allow", { - foo = { - default = "hi", - }, - }, {}) - assert.is_true(show_prompt) - assert.is_nil(err) - end - ) - - it("returns false if prompt = 'allow' and all params are optional", function() - local show_prompt, err = template._should_prompt("allow", { - foo = { - optional = true, - }, - }, {}) - assert.is_false(show_prompt) - assert.is_nil(err) - end) - - it("returns false if prompt = 'allow' and all params have a supplied value", function() - local show_prompt, err = template._should_prompt("allow", { - foo = {}, - }, { foo = "hi" }) - assert.is_false(show_prompt) - assert.is_nil(err) - end) - - it("returns true if prompt = 'missing' and any non-optional param is missing a value", function() - local show_prompt, err = template._should_prompt("missing", { + it("returns true if any non-optional param is missing a value", function() + local show_prompt, err = template._should_prompt(nil, { foo = {}, }, {}) assert.is_true(show_prompt) assert.is_nil(err) end) - it( - "returns true if prompt = 'missing' and any param is non-optional with missing value, even if default", - function() - local show_prompt, err = template._should_prompt("missing", { - foo = { - default = "hi", - }, - }, {}) - assert.is_true(show_prompt) - assert.is_nil(err) - end - ) - - it("returns true if prompt = 'missing' and all params are optional", function() - local show_prompt, err = template._should_prompt("missing", { + it("returns false if any param is non-optional with missing value, but has a default", function() + local show_prompt, err = template._should_prompt(nil, { foo = { - optional = true, + default = "hi", }, }, {}) - assert.is_true(show_prompt) - assert.is_nil(err) - end) - - it("returns false if prompt = 'missing' and all params have a supplied value", function() - local show_prompt, err = template._should_prompt("missing", { - foo = {}, - }, { foo = "hi" }) assert.is_false(show_prompt) assert.is_nil(err) end) - it("returns true if prompt = 'avoid' and any non-optional param is missing a value", function() - local show_prompt, err = template._should_prompt("avoid", { - foo = {}, - }, {}) - assert.is_true(show_prompt) - assert.is_nil(err) - end) - - it( - "returns false if prompt = 'avoid' and any param is non-optional with missing value, but has a default", - function() - local show_prompt, err = template._should_prompt("avoid", { - foo = { - default = "hi", - }, - }, {}) - assert.is_false(show_prompt) - assert.is_nil(err) - end - ) - - it("returns false if prompt = 'avoid' and all params are optional", function() - local show_prompt, err = template._should_prompt("avoid", { + it("returns false if all params are optional", function() + local show_prompt, err = template._should_prompt(nil, { foo = { optional = true, }, @@ -167,8 +58,8 @@ describe("template should prompt", function() assert.is_nil(err) end) - it("returns false if prompt = 'avoid' and all params have a supplied value", function() - local show_prompt, err = template._should_prompt("avoid", { + it("returns false if all params have a supplied value", function() + local show_prompt, err = template._should_prompt(nil, { foo = {}, }, { foo = "hi" }) assert.is_false(show_prompt) diff --git a/tests/template/vscode_spec.lua b/tests/template/vscode_spec.lua index 08833622..8424fc33 100644 --- a/tests/template/vscode_spec.lua +++ b/tests/template/vscode_spec.lua @@ -1,21 +1,19 @@ require("plenary.async").tests.add_to_env() local constants = require("overseer.constants") -local files = require("overseer.files") local overseer = require("overseer") -local parser = require("overseer.parser") -local problem_matcher = require("overseer.template.vscode.problem_matcher") -local vscode = require("overseer.template.vscode") +local problem_matcher = require("overseer.vscode.problem_matcher") +local vscode = require("overseer.vscode") describe("vscode", function() it("parses process command and args", function() - local provider = vscode.get_provider("process") + local provider = assert(vscode.get_provider("process")) local opts = provider.get_task_opts({ type = "process", command = "ls", args = { "foo", "bar" } }) assert.are.same({ "ls", "foo", "bar" }, opts.cmd) end) it("parses shell command and args", function() - local provider = vscode.get_provider("shell") + local provider = assert(vscode.get_provider("shell")) local opts = provider.get_task_opts({ type = "shell", command = "ls", @@ -25,7 +23,7 @@ describe("vscode", function() end) it("strong quotes the args", function() - local provider = vscode.get_provider("shell") + local provider = assert(vscode.get_provider("shell")) local opts = provider.get_task_opts({ type = "shell", command = "ls", @@ -35,7 +33,7 @@ describe("vscode", function() end) it("interpolates variables in command, args, and opts", function() - local tmpl = vscode.convert_vscode_task({ + local tmpl = assert(vscode.convert_vscode_task({ label = "task", type = "shell", command = "${workspaceFolder}/script", @@ -46,7 +44,7 @@ describe("vscode", function() FOO = "${execPath}", }, }, - }) + })) local task = tmpl.builder({}) local dir = vim.fn.getcwd(0) assert.equals(string.format("%s/script code", dir), task.cmd) @@ -55,7 +53,7 @@ describe("vscode", function() end) it("interpolates input variables in command", function() - local tmpl = vscode.convert_vscode_task({ + local tmpl = assert(vscode.convert_vscode_task({ label = "task", type = "shell", command = "echo", @@ -68,43 +66,43 @@ describe("vscode", function() options = { "first", "second" }, }, }, - }) + })) local task = tmpl.builder({ a_word = 'hello"world' }) assert.equals('echo hello\\"world', task.cmd) end) it("uses the task label", function() - local tmpl = vscode.convert_vscode_task({ + local tmpl = assert(vscode.convert_vscode_task({ type = "shell", command = "ls", label = "my task", - }) + })) assert.equals("my task", tmpl.name) end) it("sets the tag from the group", function() - local tmpl = vscode.convert_vscode_task({ + local tmpl = assert(vscode.convert_vscode_task({ label = "task", type = "shell", command = "ls", group = "test", - }) + })) assert.are.same({ constants.TAG.TEST }, tmpl.tags) end) it("sets the tag from group object", function() - local tmpl = vscode.convert_vscode_task({ + local tmpl = assert(vscode.convert_vscode_task({ label = "task", type = "shell", command = "ls", group = { kind = "build", isDefault = true }, - }) + })) assert.are.same({ constants.TAG.BUILD }, tmpl.tags) end) describe("problem matcher", function() it("can parse simple line output", function() - local parse = parser.new(problem_matcher.get_parser_from_problem_matcher({ + local parse = assert(problem_matcher.get_parser_from_problem_matcher({ pattern = { regexp = "^(.*):(\\d+):(\\d+):\\s+(warning|error):\\s+(.*)$", file = 1, @@ -114,21 +112,24 @@ describe("vscode", function() message = 5, }, })) - parse:ingest({ "helloWorld.c:5:3: warning: implicit declaration of function 'prinft'" }) + + parse:parse("helloWorld.c:5:3: warning: implicit declaration of function 'prinft'") local results = parse:get_result() assert.are.same({ - { - lnum = 5, - col = 3, - filename = "helloWorld.c", - text = "implicit declaration of function 'prinft'", - type = "W", + diagnostics = { + { + lnum = 5, + col = 3, + filename = "helloWorld.c", + text = "implicit declaration of function 'prinft'", + type = "W", + }, }, }, results) end) it("can set the default severity level", function() - local parse = parser.new(problem_matcher.get_parser_from_problem_matcher({ + local parse = assert(problem_matcher.get_parser_from_problem_matcher({ severity = "warning", pattern = { regexp = "^(.*):(\\d+):(\\d+):\\s+(.*)$", @@ -138,21 +139,23 @@ describe("vscode", function() message = 4, }, })) - parse:ingest({ "helloWorld.c:5:3: implicit declaration of function 'prinft'" }) + parse:parse("helloWorld.c:5:3: implicit declaration of function 'prinft'") local results = parse:get_result() assert.are.same({ - { - lnum = 5, - col = 3, - filename = "helloWorld.c", - text = "implicit declaration of function 'prinft'", - type = "W", + diagnostics = { + { + lnum = 5, + col = 3, + filename = "helloWorld.c", + text = "implicit declaration of function 'prinft'", + type = "W", + }, }, }, results) end) it("can parse a file location", function() - local parse = parser.new(problem_matcher.get_parser_from_problem_matcher({ + local parse = assert(problem_matcher.get_parser_from_problem_matcher({ pattern = { regexp = "^(.*):([0-9,]+):\\s+(.*)$", file = 1, @@ -160,41 +163,45 @@ describe("vscode", function() message = 3, }, })) - parse:ingest({ "helloWorld.c:5,3,5,8: implicit declaration of function 'prinft'" }) + parse:parse("helloWorld.c:5,3,5,8: implicit declaration of function 'prinft'") local results = parse:get_result() assert.are.same({ - { - lnum = 5, - col = 3, - end_lnum = 5, - end_col = 8, - filename = "helloWorld.c", - text = "implicit declaration of function 'prinft'", + diagnostics = { + { + lnum = 5, + col = 3, + end_lnum = 5, + end_col = 8, + filename = "helloWorld.c", + text = "implicit declaration of function 'prinft'", + }, }, }, results) end) it("uses full line as message by default", function() - local parse = parser.new(problem_matcher.get_parser_from_problem_matcher({ + local parse = assert(problem_matcher.get_parser_from_problem_matcher({ pattern = { regexp = "^(.*):(\\d+):.*$", file = 1, location = 2, }, })) - parse:ingest({ "helloWorld.c:5: implicit declaration of function 'prinft'" }) + parse:parse("helloWorld.c:5: implicit declaration of function 'prinft'") local results = parse:get_result() assert.are.same({ - { - lnum = 5, - filename = "helloWorld.c", - text = "helloWorld.c:5: implicit declaration of function 'prinft'", + diagnostics = { + { + lnum = 5, + filename = "helloWorld.c", + text = "helloWorld.c:5: implicit declaration of function 'prinft'", + }, }, }, results) end) it("can match multiline patterns", function() - local parse = parser.new(problem_matcher.get_parser_from_problem_matcher({ + local parse = assert(problem_matcher.get_parser_from_problem_matcher({ pattern = { { regexp = "^([^\\s].*)$", @@ -209,23 +216,23 @@ describe("vscode", function() }, }, })) - parse:ingest({ - { "test.js" }, - { ' 1:0 error Missing "use strict" statement' }, - }) + parse:parse("test.js") + parse:parse(' 1:0 error Missing "use strict" statement') assert.are.same({ - { - filename = "test.js", - lnum = 1, - col = 0, - type = "E", - text = 'Missing "use strict" statement', + diagnostics = { + { + filename = "test.js", + lnum = 1, + col = 0, + type = "E", + text = 'Missing "use strict" statement', + }, }, }, parse:get_result()) end) it("can match repeating multiline patterns", function() - local parse = parser.new(problem_matcher.get_parser_from_problem_matcher({ + local parse = assert(problem_matcher.get_parser_from_problem_matcher({ pattern = { { regexp = "^([^\\s].*)$", @@ -241,40 +248,42 @@ describe("vscode", function() }, }, })) - parse:ingest({ - { "test.js" }, - { ' 1:0 error Missing "use strict" statement' }, - { " 1:9 warning foo is defined but never used" }, - }) + parse:parse("test.js") + parse:parse(' 1:0 error Missing "use strict" statement') + parse:parse(" 1:9 warning foo is defined but never used") assert.are.same({ - { - filename = "test.js", - lnum = 1, - col = 0, - type = "E", - text = 'Missing "use strict" statement', - }, - { - filename = "test.js", - lnum = 1, - col = 9, - type = "W", - text = "foo is defined but never used", + diagnostics = { + { + filename = "test.js", + lnum = 1, + col = 0, + type = "E", + text = 'Missing "use strict" statement', + }, + { + filename = "test.js", + lnum = 1, + col = 9, + type = "W", + text = "foo is defined but never used", + }, }, }, parse:get_result()) end) it("can use built in parsers", function() - local parse = parser.new(problem_matcher.get_parser_from_problem_matcher({ + local parse = assert(problem_matcher.get_parser_from_problem_matcher({ pattern = "$go", })) - parse:ingest({ "my_test.go:307: Expected 'Something' received 'Nothing'" }) + parse:parse("my_test.go:307: Expected 'Something' received 'Nothing'") local results = parse:get_result() assert.are.same({ - { - lnum = 307, - filename = "my_test.go", - text = "Expected 'Something' received 'Nothing'", + diagnostics = { + { + lnum = 307, + filename = "my_test.go", + text = "Expected 'Something' received 'Nothing'", + }, }, }, results) end) @@ -282,13 +291,14 @@ describe("vscode", function() end) describe("vscode integration tests", function() - local vs_util = require("overseer.template.vscode.vs_util") + local vs_util = require("overseer.vscode.vs_util") local _orig_load_tasks_file = vs_util.load_tasks_file local task_file local test_hook = function(task_defn, util) task_defn.strategy = "test" end before_each(function() + ---@diagnostic disable-next-line: duplicate-set-field vs_util.load_tasks_file = function() return task_file end @@ -312,7 +322,7 @@ describe("vscode integration tests", function() }, } - local task, err = a.wrap(overseer.run_template, 2)({ name = "tsc watch" }) + local task, err = a.wrap(overseer.run_task, 2)({ name = "tsc watch" }) assert.is_nil(err) task.strategy:send_output([[ yarn run v1.22.10 @@ -333,7 +343,7 @@ src/index.ts:3:1 - error TS1435: Unknown keyword or identifier. Did you mean 'im assert.are.same({ diagnostics = { { - filename = files.join(task.cwd, "src/index.ts"), + filename = vim.fs.joinpath(task.cwd, "src/index.ts"), lnum = 3, col = 1, type = "E", @@ -348,9 +358,7 @@ src/index.ts:3:1 - error TS1435: Unknown keyword or identifier. Did you mean 'im "[7:48:57 AM] File change detected. Starting incremental compilation...", "", }) - assert.are.same({ - diagnostics = {}, - }, task.result) + assert.are.same({ diagnostics = {} }, task.result) -- We should be able to parse new results after the reset task.strategy:send_output([[ @@ -364,7 +372,7 @@ src/index.ts:3:1 - error TS1435: Unknown keyword or identifier. Did you mean 'im assert.are.same({ diagnostics = { { - filename = files.join(task.cwd, "src/index.ts"), + filename = vim.fs.joinpath(task.cwd, "src/index.ts"), lnum = 3, col = 1, type = "E",