├── lua └── dotnvim │ ├── generators │ ├── init.lua │ └── class_generator.lua │ ├── utils │ ├── lsp_utils.lua │ ├── path_utils.lua │ ├── templates.lua │ ├── init.lua │ ├── telescope_utils.lua │ ├── logger.lua │ ├── namespace_resolver.lua │ ├── validation.lua │ ├── project_scanner.lua │ ├── ui_helpers.lua │ ├── variables.lua │ └── buffer_helpers.lua │ ├── config.lua │ ├── nuget.lua │ ├── osharp.lua │ ├── generator.lua │ ├── health.lua │ ├── tasks │ ├── init.lua │ ├── validation.lua │ ├── parsers.lua │ ├── runner.lua │ └── discovery.lua │ ├── bootstrappers.lua │ ├── builder.lua │ ├── init.lua │ ├── config_manager.lua │ └── ui.lua ├── assets └── DotNvim.png ├── autoload └── health │ └── dotnvim.vim ├── .neoconf.json ├── .luacheckrc ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug-report.md └── workflows │ └── docs.yml ├── LICENSE ├── docs ├── norg │ ├── README.md │ ├── index.norg │ ├── installation.norg │ └── configuration.norg └── configuration-ui.md ├── Makefile ├── docgen └── minimal_init.lua ├── .ai └── issue-08-09-2025.md ├── CONTRIBUTING.md ├── plugin └── dotnvim.lua └── CLAUDE.md /lua/dotnvim/generators/init.lua: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lua/dotnvim/utils/lsp_utils.lua: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/DotNvim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamkali/dotnvim/HEAD/assets/DotNvim.png -------------------------------------------------------------------------------- /autoload/health/dotnvim.vim: -------------------------------------------------------------------------------- 1 | function! health#dotnvim#check() 2 | lua require 'dotnvim.health'.check() 3 | endfunction 4 | -------------------------------------------------------------------------------- /.neoconf.json: -------------------------------------------------------------------------------- 1 | { 2 | "neodev": { 3 | "library": { 4 | "enabled": true, 5 | "plugins": ["neoconf.nvim", "nvim-lspconfig"] 6 | } 7 | }, 8 | "neoconf": { 9 | "plugins": { 10 | "lua_ls": { 11 | "enabled": true 12 | } 13 | } 14 | }, 15 | "lspconfig": { 16 | "sumneko_lua": {} 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | --std = luajit 2 | codes = true 3 | 4 | self = false 5 | 6 | ignore = { 7 | "212", -- Unused argument, In the case of callback function, _arg_name is easier to understand than _, so this option is set to off. 8 | "122", -- Indirectly setting a readonly global 9 | } 10 | 11 | -- Global objects defined by the C code 12 | globals = { 13 | "vim", 14 | } 15 | 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | - [ ] i have verified this behavior with [dotnvim-config](https://github.com/adamkali/dotnvim-config). This is a bug with dotnvim. 27 | 28 | 29 | -------------------------------------------------------------------------------- /lua/dotnvim/generators/class_generator.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local model = function (name, namespace, public) 4 | return [[ 5 | using System; 6 | 7 | namespace ]] .. namespace .. [[ 8 | { 9 | ]] .. public ..[[class ]] .. name .. [[ 10 | { 11 | } 12 | } 13 | ]] 14 | end 15 | 16 | 17 | --- @param name string Class name of the generatedFile 18 | --- @param namespace string Namespace of the generated file 19 | --- @param opts table a table to be used when generating the file specifed 20 | --- @return string 21 | M.generate = function (name, namespace, opts) 22 | if opts.model then 23 | return model(name, namespace, opts.public) 24 | elseif opts.controller then 25 | return "" 26 | else 27 | return "" 28 | end 29 | end 30 | 31 | 32 | -------------------------------------------------------------------------------- /lua/dotnvim/config.lua: -------------------------------------------------------------------------------- 1 | local dotnvim_util = require('dotnvim.utils') 2 | local config_manager = require('dotnvim.config_manager') 3 | local configurator = {} 4 | local load_module = dotnvim_util.load_module 5 | 6 | configurator.configurate_adapter = function() 7 | local dap = load_module("dap", "dotnvim.dap") 8 | local dap_config = config_manager.get_dap_config() 9 | dap.adapters.coreclr = dap_config.adapter 10 | dap.adapters.netcoredbg = dap_config.adapter 11 | end 12 | 13 | -- aka the boulivard of broken dreams 14 | configurator.configurate_dap = function(config_dap) 15 | local configurations = {} 16 | 17 | if config_dap ~= nil then 18 | table.insert(configurations, config_dap.configurations) 19 | end 20 | 21 | local dap = load_module("dap", "dotnvim.dap") 22 | configurator.configurate_adapter() 23 | dap.configurations.cs = {} 24 | 25 | for _, config in ipairs(configurations) do 26 | if config.type == "netcoredbg" then 27 | print(config.name) 28 | table.insert(dap.configurations.cs, config) 29 | end 30 | end 31 | end 32 | 33 | return configurator 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Adam Kalinowski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lua/dotnvim/nuget.lua: -------------------------------------------------------------------------------- 1 | local config_manager = require('dotnvim.config_manager') 2 | 3 | local NugetClient = {} 4 | 5 | NugetClient.authenticate = function() 6 | local Job = require('plenary.job') 7 | local nuget_config = config_manager.get_nuget_config() 8 | vim.print(nuget_config.authenticators) 9 | for _, authenticator in ipairs(nuget_config.authenticators) do 10 | vim.print(authenticator) 11 | Job:new({ 12 | command = authenticator.cmd, 13 | args = authenticator.args, 14 | on_exit = function(j, return_val) 15 | local cmd = authenticator.cmd .. " " .. table.concat(authenticator.args, " ") 16 | local logger = config_manager.get_logger() 17 | if return_val == 0 then 18 | logger.info("Command executed successfully: " .. cmd) 19 | print("Command executed successfully: " .. cmd) 20 | else 21 | logger.error("Command execution failed: " .. cmd) 22 | logger.error("Error: " .. table.concat(j:stderr_result(), "\n")) 23 | print("Command execution failed: " .. cmd) 24 | end 25 | end, 26 | on_stderr = function(_, data) 27 | config_manager.get_logger().error("stderr: " .. data) 28 | end, 29 | }):start() 30 | end 31 | end 32 | 33 | return NugetClient 34 | -------------------------------------------------------------------------------- /lua/dotnvim/osharp.lua: -------------------------------------------------------------------------------- 1 | local OSharpRequest = { 2 | client = nil, 3 | title = "NOT INITIATED", 4 | builtin = "null", 5 | osharpn = "null", 6 | osharpr = function(err, result, context, config) end, 7 | callback = function(locations, lsp_client) end, 8 | telscope = function(locations, lsp_client, config) end 9 | } 10 | 11 | function get_lsp_client() 12 | local clients = nil; 13 | if vim.lsp.get_clients ~= nil then 14 | clients = vim.lsp.get_clients({ buffer = 0 }) 15 | else 16 | clients = vim.lsp.buf_get_clients(0) 17 | end 18 | 19 | for _, client in pairs(clients) do 20 | if client.name == "omnisharp" or client.name == "omnisharp_mono" then 21 | return client 22 | end 23 | end 24 | end 25 | 26 | function OSharpRequest:new(o) 27 | o = o or {} 28 | setmetatable(o, self) 29 | self.__index = self 30 | return o 31 | end 32 | 33 | function OSharpRequest:osharp_handler(err, result, ctx, config) 34 | o = o or {} 35 | setmetatable(o, self) 36 | self.__index = self 37 | return o 38 | end 39 | 40 | function OSharpRequest:cmd() 41 | local client = get_lsp_client 42 | if client then 43 | client.request( 44 | self.osharpn, 45 | function () 46 | nil 47 | end, 48 | function (err, result, ctx, config) 49 | self:osharp_handler(err, result, ctx, config) 50 | end 51 | ) 52 | end 53 | end 54 | 55 | 56 | return OSharpRequest 57 | -------------------------------------------------------------------------------- /docs/norg/README.md: -------------------------------------------------------------------------------- 1 | # Documentation System 2 | 3 | This directory contains the documentation source files for dotnvim, written in Neorg (.norg) format. 4 | 5 | ## Structure 6 | 7 | - `index.norg` - Main documentation homepage 8 | - `installation.norg` - Installation and setup guide 9 | - `configuration.norg` - Configuration options and examples 10 | - Additional .norg files for specific topics 11 | 12 | ## Local Generation 13 | 14 | To generate documentation locally: 15 | 16 | ```bash 17 | make docs-local 18 | ``` 19 | 20 | This will: 21 | 1. Clone the neorg plugin if needed 22 | 2. Convert all .norg files to markdown 23 | 3. Output to `docs/generated/` 24 | 25 | ## Automation 26 | 27 | Documentation is automatically generated and deployed when: 28 | - Changes are pushed to the `main` branch 29 | - Changes are made to files in `docs/norg/` 30 | - The workflow is manually triggered 31 | 32 | The generated documentation is available at: `https://adamkali.github.io/dotnvim/` 33 | 34 | ## Writing Documentation 35 | 36 | When adding new .norg files: 37 | 38 | 1. Include proper document metadata: 39 | ```norg 40 | @document.meta 41 | title: Your Title 42 | description: Brief description 43 | authors: adamkali 44 | categories: relevant categories 45 | created: YYYY-MM-DD 46 | updated: YYYY-MM-DD 47 | version: 1.0.0 48 | @end 49 | ``` 50 | 51 | 2. Use proper Neorg syntax for headers, links, and code blocks 52 | 3. Reference other documentation pages using `{/ filename}` syntax 53 | 4. Test locally with `make docs-local` before committing -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # dotnvim Makefile 2 | # Provides targets for documentation generation and development tasks 3 | 4 | .PHONY: help docs docs-local docs-clean install test 5 | 6 | # Default target 7 | help: 8 | @echo "Available targets:" 9 | @echo " docs - Generate documentation (requires neorg)" 10 | @echo " docs-local - Generate documentation for local development" 11 | @echo " docs-clean - Clean generated documentation" 12 | @echo " install - Install development dependencies" 13 | @echo " test - Run plugin tests" 14 | 15 | # Generate documentation using neorg export 16 | docs: 17 | @echo "Generating documentation from .norg files..." 18 | @mkdir -p docs/generated 19 | @BATCH_EXPORT=1 nvim --headless -u docgen/minimal_init.lua -c 'qa!' 20 | @echo "Documentation generated in docs/generated/" 21 | 22 | # Generate documentation for local development 23 | docs-local: 24 | @echo "Generating documentation locally..." 25 | @mkdir -p docs/generated 26 | @if ! command -v nvim >/dev/null 2>&1; then \ 27 | echo "Error: neovim is not installed"; \ 28 | exit 1; \ 29 | fi 30 | @# Clone neorg locally if it doesn't exist 31 | @if [ ! -d "neorg" ]; then \ 32 | echo "Cloning neorg..."; \ 33 | git clone --depth=1 https://github.com/nvim-neorg/neorg.git neorg; \ 34 | fi 35 | @NEORG_PATH=./neorg BATCH_EXPORT=1 nvim --headless -u docgen/minimal_init.lua -c 'qa!' 36 | @echo "Documentation generated in docs/generated/" 37 | 38 | # Clean generated documentation 39 | docs-clean: 40 | @echo "Cleaning generated documentation..." 41 | @rm -rf docs/generated/ 42 | @rm -rf neorg/ 43 | @echo "Documentation cleaned." 44 | 45 | # Install development dependencies 46 | install: 47 | @echo "Installing development dependencies..." 48 | @if command -v luarocks >/dev/null 2>&1; then \ 49 | luarocks install --local plenary.nvim; \ 50 | else \ 51 | echo "Warning: luarocks not found, skipping lua dependencies"; \ 52 | fi 53 | @echo "Dependencies installed." 54 | 55 | # Run plugin tests (if test framework is set up) 56 | test: 57 | @echo "Running plugin tests..." 58 | @if [ -d "tests" ]; then \ 59 | echo "Running tests with plenary..."; \ 60 | nvim --headless -c "PlenaryBustedDirectory tests/ {minimal_init = 'tests/minimal_init.lua'}"; \ 61 | else \ 62 | echo "No tests directory found."; \ 63 | fi -------------------------------------------------------------------------------- /lua/dotnvim/generator.lua: -------------------------------------------------------------------------------- 1 | local Utils = require('dotnvim.utils') 2 | local generator = {} 3 | 4 | --- @class generator.class_config 5 | --- @field use_filename_as_name boolean 6 | --- @field use_cwd_as_ns boolean 7 | --- @field use_public boolean 8 | --- @field use_private boolean 9 | --- @field use_protected boolean 10 | --- @field use_model_default boolean 11 | --- @field use_controller_default boolean 12 | 13 | --- @class generator.Config 14 | --- @field class_config generator.class_config 15 | 16 | 17 | --- @type generator.Config 18 | generator.Config = { 19 | class_config = { 20 | use_filename_as_name = true, 21 | use_cwd_as_ns = true, 22 | use_public = true, 23 | use_private = false, 24 | use_protected = false, 25 | use_model_default = false, 26 | use_controller_default = false 27 | } 28 | } 29 | 30 | 31 | ---@param opts generator.class_config 32 | generator.generate_class = function(opts) 33 | local current_buf = vim.api.nvim_get_current_buf() 34 | local file_obj = Utils.get_curr_file_and_namespace() 35 | local namespace = "DefaultNamespace" 36 | local classname = "DefaultController" 37 | local qf_list = {} 38 | if opts.use_cwd_as_ns then 39 | namespace = file_obj.namespace 40 | else 41 | qf_list. 42 | 43 | end 44 | if opts.use_filename_as_name then 45 | classname = file_obj.file_name 46 | end 47 | local transparency = function() 48 | if opts.use_public then 49 | return "public " 50 | elseif opts.use_protected then 51 | return "protected " 52 | elseif opts.use_private then 53 | return "private " 54 | end 55 | end 56 | local generated = require('dotnvim.generators.class_generator').generator(classname, namespace, { 57 | public = transparency(), 58 | model = opts.use_model_default, 59 | controller = opts.use_controller_default 60 | }) 61 | local lines = vim.split(generated, "\n", nil) 62 | vim.api.nvim_buf_set_lines(current_buf, 1, #lines, false, lines) 63 | end 64 | 65 | ---@param ots: table 66 | generator.setup = function(opts) 67 | if opts.generate_class_config then 68 | for use_opt, value in pairs(opts.generate_class_config) do 69 | generator.Config.class_config[use_opt] = value 70 | end 71 | end 72 | vim.api.nvim_create_user_command("DotnvimGenerate", function(args, bang, nargs) 73 | end, {}) 74 | end 75 | -------------------------------------------------------------------------------- /docgen/minimal_init.lua: -------------------------------------------------------------------------------- 1 | -- Minimal Neovim configuration for documentation generation 2 | -- This file is used by the documentation generation system 3 | 4 | -- Determine neorg path (CI vs local) 5 | local neorg_path = vim.env.NEORG_PATH or '/tmp/neorg' 6 | if vim.fn.isdirectory(neorg_path) == 0 then 7 | -- Try alternative paths for local development 8 | local alt_paths = { 9 | vim.fn.expand('~/.local/share/nvim/lazy/neorg'), 10 | vim.fn.expand('~/.local/share/nvim/site/pack/packer/start/neorg'), 11 | './neorg', -- If cloned locally 12 | } 13 | 14 | for _, path in ipairs(alt_paths) do 15 | if vim.fn.isdirectory(path) == 1 then 16 | neorg_path = path 17 | break 18 | end 19 | end 20 | end 21 | 22 | -- Add neorg to runtime path 23 | vim.opt.rtp:prepend(neorg_path) 24 | 25 | -- Disable swap files and backups for headless operation 26 | vim.opt.swapfile = false 27 | vim.opt.backup = false 28 | vim.opt.writebackup = false 29 | 30 | -- Setup neorg with minimal configuration for export 31 | require('neorg').setup { 32 | load = { 33 | ["core.defaults"] = {}, 34 | ["core.export"] = {}, 35 | ["core.export.markdown"] = { 36 | config = { 37 | extensions = "all", 38 | metadata = true, 39 | } 40 | }, 41 | ["core.integrations.treesitter"] = { 42 | config = { 43 | configure_parsers = true, 44 | install_parsers = false, -- Don't auto-install in CI 45 | } 46 | } 47 | } 48 | } 49 | 50 | -- Helper function to export a single file 51 | local function export_norg_file(input_file, output_file) 52 | vim.cmd('edit ' .. input_file) 53 | 54 | -- Wait for file to load 55 | vim.wait(1000, function() 56 | return vim.api.nvim_buf_get_name(0):match('%.norg$') ~= nil 57 | end) 58 | 59 | -- Export to markdown 60 | vim.cmd('Neorg export to-file ' .. output_file) 61 | 62 | -- Wait for export to complete 63 | vim.wait(2000, function() 64 | return vim.fn.filereadable(output_file) == 1 65 | end) 66 | end 67 | 68 | -- Export all norg files if run in batch mode 69 | if vim.env.BATCH_EXPORT then 70 | local docs_dir = 'docs/norg' 71 | local output_dir = 'docs/generated' 72 | 73 | -- Ensure output directory exists 74 | vim.fn.mkdir(output_dir, 'p') 75 | 76 | -- Find all .norg files 77 | local norg_files = vim.fn.glob(docs_dir .. '/*.norg', false, true) 78 | 79 | for _, norg_file in ipairs(norg_files) do 80 | local filename = vim.fn.fnamemodify(norg_file, ':t:r') -- Remove path and extension 81 | local output_file = output_dir .. '/' .. filename .. '.md' 82 | 83 | print('Converting ' .. norg_file .. ' to ' .. output_file) 84 | export_norg_file(norg_file, output_file) 85 | end 86 | 87 | print('Documentation generation complete') 88 | vim.cmd('qa!') 89 | end -------------------------------------------------------------------------------- /.ai/issue-08-09-2025.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Issue 09/08/2025 0 - Create a "task" workflow 4 | 5 | ## Summary 6 | In this task what i want you to create is to make a workflow for the plugin that a user can specify a task 7 | in the `dotnetproject/.nvim` or `dotnetproject/.vscode` directory of the dotnet project and it will be loaded before debugging the project. 8 | 9 | the idea of this will to be able to have a json file: 10 | ```json 11 | /// .nvim/tasks.json // where the sln is 12 | { 13 | "version": "0.1.0", 14 | "tasks": [ 15 | { 16 | "previous": "test", 17 | "name": "build", 18 | "command": "dotnet build", 19 | "cwd": ".", 20 | "env": { 21 | "DOTNET_NOLOGO": "true", 22 | "ASPNETCORE_ENVIRONMENT": "Development", 23 | "DOTNET_RUNNING_IN_CONTAINER": "true" 24 | } 25 | }, 26 | { 27 | "name": "test", 28 | "command": "dotnet test", 29 | "cwd": ".", 30 | "env": { 31 | "DOTNET_NOLOGO": "true", 32 | "ASPNETCORE_ENVIRONMENT": "Development", 33 | "DOTNET_RUNNING_IN_CONTAINER": "true", 34 | "BYPASS_SECURITY": "c2f2b720-d55c-4f7e-a0fb-d95b2be80900" 35 | } 36 | } 37 | 38 | ] 39 | } 40 | ``` 41 | 42 | and be able to have these commands be run before debugging the project. Ideally, we could extend the functionallity 43 | from dap to be able to run a task before debugging, for example the way to call dapui is: 44 | ```lua 45 | local dap = require "dap" 46 | local dapui = require "dapui" 47 | 48 | dap.listeners.before.launch.dapui_config = function() 49 | dapui.open() 50 | end 51 | ``` 52 | 53 | ## Goals 54 | - Create a workflow for the plugin that a user can specify a task in the `.nvim` or `.vscode` directory of the dotnet project and it will be loaded before debugging the project. 55 | - make sure that we can hook into the dap.listenrs api to be able to run a task before debugging the project and ensure that the last project 56 | 57 | ## Before You Start 58 | - Make sure you are up to date with the latest changes in the repository 59 | - Make sure to create a new branch for your work based on the dev branch 60 | - the branech should share the same name as the issue (i.e. whatever is after the # Issue DATE/MONTH/YEAR DAY_ISSUE_NUMBER -) 61 | 62 | ## When You Finish 63 | - Verify that the build target works by using make build 64 | - Update the Claude.md file to explain to other ai agents the current state of the project and what you have done 65 | - Commit your changes to the repository and explain the changes you made. 66 | - Make a pull request to the dev branch 67 | - The pull request name should be the same as the issue title (i.e. whatever is after the # Issue DATE/MONTH/YEAR DAY_ISSUE_NUMBER - ) replace the spaces with hyphens and lowercase the title 68 | -------------------------------------------------------------------------------- /lua/dotnvim/utils/path_utils.lua: -------------------------------------------------------------------------------- 1 | local log = require("plenary.log"):new() 2 | 3 | local M = {} 4 | 5 | local scan = require "plenary.scandir" 6 | 7 | function M.tbl_contains_pattern(t, pattern) 8 | vim.validate { t = { t, "t" } } 9 | 10 | for _, v in ipairs(t) do 11 | if string.match(v, pattern) then 12 | return true 13 | end 14 | end 15 | return false 16 | end 17 | 18 | function M.cwd_contains_pattern(pattern) 19 | local dir = scan.scan_dir(".", { hidden = true, depth = 1 }) 20 | 21 | local result = M.tbl_contains_pattern(dir, pattern) 22 | 23 | return result 24 | end 25 | 26 | function M.is_directory(path) 27 | if vim.fn.isdirectory(path) == 1 then 28 | return true 29 | else 30 | return false 31 | end 32 | end 33 | 34 | function M.get_last_path_part(path) 35 | local part = nil 36 | for current_match in string.gmatch(path, "[^/\\]+") do 37 | part = current_match 38 | end 39 | 40 | return part 41 | end 42 | 43 | function M.get_parent_directory(path) 44 | local directory 45 | 46 | local split = path:match "/[^/]*$" 47 | 48 | if split ~= nil then 49 | directory = path:gsub(split, "") 50 | end 51 | 52 | return directory 53 | end 54 | 55 | function M.get_file_path_namespace(file_path) 56 | local path = file_path 57 | local path_tmp 58 | local cwd = vim.fn.getcwd() 59 | local project 60 | local project_parent 61 | local dir 62 | 63 | path = M.get_parent_directory(path) 64 | path_tmp = path 65 | 66 | -- Get project path 67 | if file_path ~= nil then 68 | while true do 69 | dir = scan.scan_dir(path, { hidden = true, depth = 1 }) 70 | 71 | if M.tbl_contains_pattern(dir, ".*.csproj") then 72 | project = path 73 | log.warn("Breaking ...") 74 | break 75 | end 76 | 77 | if path == cwd then 78 | return cwd 79 | end 80 | 81 | path = M.get_parent_directory(path) 82 | end 83 | 84 | if project and path_tmp then 85 | project_parent = M.get_parent_directory(project) 86 | 87 | return path_tmp:gsub(project_parent, ""):sub(2):gsub("/", ".") 88 | else 89 | log.error("Failed to find parent project") 90 | return cwd 91 | end 92 | else 93 | log.error("Failed to find parent project") 94 | return cwd 95 | end 96 | end 97 | 98 | function M.get_projects() 99 | local csproj_paths = scan.scan_dir(vim.fn.getcwd(), { search_pattern = ".*.csproj$" }) 100 | 101 | return csproj_paths 102 | end 103 | 104 | M.get_project_name_and_directory = function(name_with_path) 105 | name_with_path = string.gsub(name_with_path, "\\", "/") 106 | local directory = string.match(name_with_path, "(.+/)[^/\\]+") 107 | 108 | if directory == nil or directory == '' or directory == './' then 109 | directory = '' 110 | end 111 | 112 | local name = string.gsub(name_with_path, directory, '') 113 | 114 | return { 115 | project_name = name, 116 | project_directory = directory, 117 | } 118 | end 119 | 120 | return M 121 | 122 | -------------------------------------------------------------------------------- /docs/norg/index.norg: -------------------------------------------------------------------------------- 1 | @document.meta 2 | title: dotnvim Documentation 3 | description: Complete guide to dotnvim - .NET tooling for Neovim 4 | authors: adamkali 5 | categories: neovim plugin dotnet 6 | created: 2025-08-28 7 | updated: 2025-08-28 8 | version: 1.0.0 9 | @end 10 | 11 | * dotnvim - .NET Tooling for Neovim 12 | 13 | Welcome to the complete documentation for {* dotnvim *}, a comprehensive Neovim plugin that provides .NET development tooling and project management capabilities. 14 | 15 | ** What is dotnvim? 16 | 17 | dotnvim is a Neovim plugin designed to streamline .NET development workflows by providing: 18 | - Project building and watching capabilities 19 | - Interactive code generation for classes and controllers 20 | - NuGet package management and authentication 21 | - Task execution system with dependency resolution 22 | - Debug Adapter Protocol (DAP) integration 23 | - Health checking for development environment 24 | 25 | ** Quick Start 26 | 27 | To get started with dotnvim: 28 | 29 | @code lua 30 | -- In your Neovim configuration 31 | require('dotnvim').setup({ 32 | builders = { 33 | https_launch_setting_always = true, 34 | }, 35 | ui = { 36 | no_pretty_uis = false, 37 | }, 38 | tasks = { 39 | enabled = true, 40 | execution_mode = "dependency_aware", 41 | } 42 | }) 43 | @end 44 | 45 | ** Main Features 46 | 47 | *** Building and Watching 48 | - `require('dotnvim').build()` - Build your .NET project 49 | - `require('dotnvim').watch()` - Start dotnet watch for hot reloading 50 | - `require('dotnvim').restart_watch()` - Restart watch process 51 | - `require('dotnvim').shutdown_watch()` - Stop watch process 52 | 53 | *** Code Generation 54 | - `require('dotnvim').bootstrap()` - Interactive code generation 55 | - Support for C# classes and ASP.NET controllers 56 | - Template-based generation with customizable templates 57 | 58 | *** NuGet Management 59 | - `require('dotnvim').nuget_auth()` - Authenticate NuGet sources 60 | - Support for private NuGet feeds 61 | - Configurable authentication methods 62 | 63 | *** Task System 64 | - Full task workflow system with pre-debug execution 65 | - Multi-format configuration support (JSON, YAML, TOML) 66 | - Dependency resolution and parallel execution 67 | - DAP integration for debugging workflows 68 | 69 | ** Documentation Sections 70 | 71 | - {/ installation} - Installation and setup guide 72 | - {/ configuration} - Configuration options and examples 73 | - {/ commands} - Available commands and usage 74 | - {/ tasks} - Task system documentation 75 | - {/ debugging} - Debugging setup and usage 76 | - {/ api} - API reference and examples 77 | - {/ troubleshooting} - Common issues and solutions 78 | 79 | ** Getting Help 80 | 81 | - Run `:checkhealth dotnvim` to verify your setup 82 | - Check the {https://github.com/adamkali/dotnvim}[GitHub repository] for issues and updates 83 | - Review the configuration examples in this documentation 84 | 85 | ** Contributing 86 | 87 | Contributions are welcome! Please see the CONTRIBUTING.md file in the repository for guidelines. -------------------------------------------------------------------------------- /lua/dotnvim/utils/templates.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | M.dotnvim_api_controller_template = function (name, namespace) 4 | return [[ 5 | using Microsoft.AspNetCore.Authorization; 6 | using Microsoft.AspNetCore.Mvc; 7 | using System.Threading.Tasks; 8 | 9 | namespace ]] .. namespace .. [[ 10 | { 11 | [Route("api/[controller]")] 12 | [ApiController] 13 | [Authorize] 14 | [ApiExplorerSettings(IgnoreApi = false)] 15 | public class ]] .. name .. [[: ControllerBase 16 | { 17 | public ]] .. name .. [[() 18 | { 19 | } 20 | 21 | [HttpGet()] 22 | [Route('/')] 23 | [ 24 | ProducesResponseType(StatusCodes.Status200OK, 25 | Type = typeof(String)) 26 | ] 27 | public async Task 28 | Get() 29 | { 30 | return Ok("Hello World") 31 | } 32 | } 33 | } 34 | ]] 35 | end 36 | 37 | M.dotnvim_api_model_template = function (name, namespace) 38 | return [[ 39 | using System; 40 | 41 | namespace ]] .. namespace .. [[ 42 | { 43 | public class ]] .. name .. [[ 44 | { 45 | public ID Guid { get; set; } 46 | // Insert The rest of your columns 47 | // ... 48 | } 49 | } 50 | ]] 51 | end 52 | 53 | M.dotnvim_api_mvc_controller_template = function (name, namespace) 54 | return [[ 55 | using Microsoft.AspNetCore.Authorization; 56 | using Microsoft.AspNetCore.Http; 57 | using Microsoft.AspNetCore.Mvc; 58 | using System; 59 | using System.Collections.Generic; 60 | using System.Linq; 61 | using System.Threading.Tasks; 62 | 63 | namespace]] .. namespace .. [[ 64 | { 65 | public class ]] .. name .. [[Controller : ApiController 66 | { 67 | // GET api/ 68 | [HttpGet()] 69 | [Route('/')] 70 | public IEnumerable Get() 71 | { 72 | return new string[] { "value1", "value2" }; 73 | } 74 | 75 | // GET api//5 76 | [HttpGet()] 77 | [Route('/{id}')] 78 | public string Get([FromRoute] int id) 79 | { 80 | return "value"; 81 | } 82 | 83 | // POST api/ 84 | [HttpPost()] 85 | [Route('/{id}')] 86 | public void Post([FromBody] string value) 87 | { 88 | } 89 | 90 | // PUT api//5 91 | [HttpPut()] 92 | [Route('/{id}')] 93 | public void Put([FromRoute] int id, [FromBody] string value) 94 | { 95 | } 96 | 97 | // DELETE api//5 98 | [HttpPut()] 99 | [Route('/{id}')] 100 | public void Delete([FromRoute] int id) 101 | { 102 | } 103 | } 104 | } 105 | ]] 106 | end 107 | 108 | M.dotnvim_razor_component_template = function(name, namespace) 109 | return [[ 110 | @page "/]] .. name:lower() .. [[" 111 | @namespace ]] .. namespace .. [[ 112 | 113 |

]] .. name .. [[

114 | 115 |

This is the ]] .. name .. [[ component.

116 | 117 | @code { 118 | // Your C# code for the component goes here 119 | } 120 | ]] 121 | end 122 | 123 | 124 | return M 125 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing to `dotnvim` 3 | 4 | We welcome contributions to `dotnvim`! Whether you're fixing bugs, adding new features, or improving documentation, your help is appreciated. 5 | 6 | ## How to Contribute 7 | 8 | ### 1. Fork the Repository 9 | 10 | Fork the [repository](https://github.com/adamkali/dotnvim.git) 11 | 12 | ### 2. Clone the Fork 13 | 14 | Clone your forked repository to your local machine: 15 | 16 | ```bash 17 | git clone https://github.com/adamkali/dotnvim.git 18 | ``` 19 | 20 | ### 3. Create a Branch 21 | 22 | Create a new branch for your work. Use a descriptive name for the branch to make it clear what your changes are about. Please use semantic branch names to categorize your work, such as: 23 | - `feat/feature-name` for new features 24 | - `bug/bug-fix-description` for bug fixes 25 | - `infra/infrastructure-change` for infrastructure or configuration changes 26 | - `docs/documentation-update` for documentation changes 27 | 28 | **e.g. //** 29 | 30 | Example: 31 | 32 | ```bash 33 | git checkout -b feat/new-authentication-system 34 | ``` 35 | 36 | types: 37 | ``` 38 | [ 39 | 'build', 40 | 'docs', 41 | 'feat', 42 | 'fix', 43 | 'perf', 44 | 'refactor', 45 | 'infra', 46 | 'style' 47 | ]; 48 | ``` 49 | 50 | ### 4. Make Your Changes 51 | 52 | Make the necessary changes to the codebase. Ensure your code follows the project's coding standards and includes appropriate documentation. 53 | 54 | ### 5. Commit Your Changes 55 | 56 | Once you've made your changes, commit them with a clear and concise commit message: 57 | 58 | ```bash 59 | git add . 60 | git commit -m "Brief description of your changes" 61 | ``` 62 | 63 | Realistically, keep your pull requests to keep to a somewhat [unix philosophy](https://en.wikipedia.org/wiki/Unix_philosophy) for pull requests. 64 | - 1. Make each pull request do one thing. (i.e. the semantic branch name) 65 | - 2. Anyone looking at the pull request should expect what the change is doing, and be able to verify that the changes are what is expected. 66 | - 3. Any pull request should be able to be thrown away easily, ie Modular revisions. 67 | - 4. The commits in the pull request should read as a change log. If anyone who has not worked on the project were to look at your "change log" they should be able to get up to speed. Anyone who is doing code review should be able to explain based on your "change log" where and what commits are making the pull request rejected. 68 | 69 | ### 6. Push Your Changes 70 | 71 | Push your changes to your forked repository: 72 | 73 | ```bash 74 | git push origin feat/new-authentication-system 75 | ``` 76 | 77 | ### 7. Create a Pull Request 78 | 79 | Go to the original repository on GitHub and create a pull request from your branch. Provide a clear description of what your changes do and why they are needed. 80 | 81 | ## Style Guide 82 | 83 | use the lua formatter. 84 | 85 | ## Reporting Issues 86 | 87 | If you find any bugs or have suggestions for improvements, please open an issue in the [issue tracker](link-to-issue-tracker). 88 | 89 | ## License 90 | 91 | By contributing to this repository, you agree that your contributions will be licensed under the [MIT License](https://opensource.org/license/MIT). 92 | 93 | -------------------------------------------------------------------------------- /lua/dotnvim/health.lua: -------------------------------------------------------------------------------- 1 | local health = vim.health or require "health" 2 | local start = health.start or health.report_start 3 | local health_ok = health.ok or health.report_ok 4 | local health_warn = health.warn or health.report_warn 5 | local health_error = health.error or health.report_error 6 | local health_info = health.info or health.report_info 7 | 8 | --local is_win = vim.api.nvim_call_function("has", { "win32" }) == 1 9 | 10 | local M = {} 11 | 12 | function M.check() 13 | start("dotnvim") 14 | local function info(msg, ...) 15 | health_info(msg:format(...)) 16 | end 17 | local function ok(msg, ...) 18 | health_ok(msg:format(...)) 19 | end 20 | local function warn(msg, ...) 21 | health_warn(msg:format(...)) 22 | end 23 | local function error(msg, ...) 24 | health_warn(msg:format(...)) 25 | end 26 | 27 | local function exe_check(binary) 28 | local found = vim.fn.executable(binary) == 1 29 | if found then 30 | local handle = io.popen(binary .. " --version") 31 | if handle ~= nil then 32 | local binary_ver = handle:read "*a" 33 | handle:close() 34 | return true, binary_ver 35 | end 36 | end 37 | return false, nil 38 | end 39 | 40 | -- check the executables 41 | info("Checking Required Executables") 42 | local dotnet_exe, dotnet_vers = exe_check("dotnet") 43 | local fd_exe, fd_vers = exe_check("fd") 44 | local netcoredbg_exe, netcoredbg_vers = exe_check("netcoredbg") 45 | if dotnet_exe then 46 | ok(" ==> #dotnet# is installed -> "..dotnet_vers) 47 | else 48 | error("!!! ==> #dotnet# is not installed, make sure dotnet is in $PATH") 49 | end 50 | 51 | if fd_exe then 52 | ok(" ==> #fd# is installed -> "..fd_vers) 53 | else 54 | error("!!! ==> #fd# is not installed, make sure fd is in $PATH") 55 | end 56 | 57 | if netcoredbg_exe then 58 | ok(" ==> #netcoredbg# is installed -> "..netcoredbg_exe) 59 | else 60 | error("!!! ==> #netcoredbg# is not installed, make sure netcoredbg is in $PATH") 61 | end 62 | -- Check the Required Dependencies 63 | info("Checking Required Dependencies") 64 | if pcall(require, 'nvim-treesitter.configs') then 65 | ok(" ==> #nvim-treesitter# is installed.") 66 | else 67 | error("!!! ==> #nvim-treesitter# is not installed. This plugin must be installed [nvim-treesitter](https://github.com/nvim-treesitter/nvim-treesitter)") 68 | end 69 | 70 | -- check the required optionall dependencies 71 | info("Checking Optional Dependencies") 72 | if pcall(require, 'telescope') then 73 | ok(" ==> #telescope# is installed for dotnvim.buildcsproj") 74 | else 75 | warn("!!! ==> #telescope# is not installed. You will not be able to use telescope ui features. [telescope](https://github.com/nvim-telescope/telescope.nvim)") 76 | end 77 | if pcall(require, 'nui.utils') then 78 | ok(" ==> #nui.nvim# is installed for ui capabilities during bootsrap") 79 | else 80 | warn("!!! ==> #nui.nvim# is not installed. You will not be able to use ui features when bootstrapping. [nui](https://github.com/MunifTanjim/nui.nvim)") 81 | end 82 | end 83 | 84 | return M 85 | -------------------------------------------------------------------------------- /docs/configuration-ui.md: -------------------------------------------------------------------------------- 1 | # DotNvim Configuration UI 2 | 3 | The DotNvim plugin now includes a comprehensive configuration UI that displays all your configured settings in an easy-to-read floating window. 4 | 5 | ## Usage 6 | 7 | ### View All Configuration 8 | To view your complete DotNvim configuration: 9 | 10 | ```lua 11 | -- Via Lua API 12 | require('dotnvim').show_config() 13 | 14 | -- Via command 15 | :DotNvimConfig 16 | ``` 17 | 18 | ### View Specific Configuration Section 19 | To view a specific configuration section: 20 | 21 | ```lua 22 | -- Via Lua API 23 | require('dotnvim').show_config_section('builders') 24 | require('dotnvim').show_config_section('ui') 25 | require('dotnvim').show_config_section('dap') 26 | require('dotnvim').show_config_section('nuget') 27 | require('dotnvim').show_config_section('tasks') 28 | require('dotnvim').show_config_section('debug') 29 | 30 | -- Via command 31 | :DotNvimConfigSection builders 32 | :DotNvimConfigSection ui 33 | :DotNvimConfigSection dap 34 | :DotNvimConfigSection nuget 35 | :DotNvimConfigSection tasks 36 | :DotNvimConfigSection debug 37 | ``` 38 | 39 | ## What's Displayed 40 | 41 | The configuration UI shows: 42 | 43 | ### 📊 Status Section 44 | - Configuration initialization status 45 | - Last used project path 46 | - Running watch status 47 | 48 | ### 🔨 Builders Configuration 49 | - Build output callback settings 50 | - HTTPS launch settings 51 | - Other build-related configurations 52 | 53 | ### 🎨 UI Configuration 54 | - Pretty UI preferences 55 | - Display options 56 | 57 | ### 🐛 DAP (Debug Adapter Protocol) Configuration 58 | - Adapter settings (netcoredbg configuration) 59 | - Debug configurations for C# 60 | - Adapter command and arguments 61 | 62 | ### 📦 NuGet Configuration 63 | - Package sources 64 | - Authentication settings 65 | - Custom authenticators 66 | 67 | ### ⚡ Tasks Configuration 68 | - Task execution mode 69 | - DAP integration settings 70 | - Pre-debug task configuration 71 | - Timeout settings 72 | 73 | ### 🔍 Debug Configuration 74 | - Debug logging enablement 75 | - Log file path settings 76 | 77 | ## Navigation 78 | 79 | Within the configuration window: 80 | - Press `q`, ``, or `` to close the window 81 | - Use standard Neovim navigation keys to scroll through the content 82 | - The window is read-only and syntax-highlighted as Lua code 83 | 84 | ## Configuration Tips 85 | 86 | The UI also provides helpful tips at the bottom: 87 | - How to customize settings using `require("dotnvim").setup({...})` 88 | - Reference to documentation 89 | - Log file location for troubleshooting 90 | 91 | ## Example Usage in Your Config 92 | 93 | ```lua 94 | -- In your Neovim configuration 95 | local dotnvim = require('dotnvim') 96 | 97 | -- Setup with custom configuration 98 | dotnvim.setup({ 99 | builders = { 100 | https_launch_setting_always = true, 101 | }, 102 | ui = { 103 | no_pretty_uis = false, 104 | }, 105 | dap = { 106 | adapter = { 107 | type = 'executable', 108 | command = "netcoredbg", 109 | args = { '--interpreter=vscode' }, 110 | }, 111 | }, 112 | -- ... other configurations 113 | }) 114 | 115 | -- View your configuration anytime 116 | vim.keymap.set('n', 'dc', function() 117 | dotnvim.show_config() 118 | end, { desc = 'Show DotNvim config' }) 119 | 120 | -- Quick access to specific sections 121 | vim.keymap.set('n', 'dd', function() 122 | dotnvim.show_config_section('dap') 123 | end, { desc = 'Show DAP config' }) 124 | ``` 125 | 126 | This configuration UI makes it easy to verify your settings, troubleshoot issues, and understand the current state of your DotNvim setup. -------------------------------------------------------------------------------- /lua/dotnvim/utils/init.lua: -------------------------------------------------------------------------------- 1 | -- Legacy compatibility module - delegates to focused utility modules 2 | local validation = require('dotnvim.utils.validation') 3 | local project_scanner = require('dotnvim.utils.project_scanner') 4 | local namespace_resolver = require('dotnvim.utils.namespace_resolver') 5 | local ui_helpers = require('dotnvim.utils.ui_helpers') 6 | local buffer_helpers = require('dotnvim.utils.buffer_helpers') 7 | 8 | local M = {} 9 | 10 | -- @param callback func(csproj): Function to call with selected csproj path 11 | M.select_csproj = function(callback) 12 | return ui_helpers.select_csproj(callback) 13 | end 14 | 15 | -- Load a module with better error handling 16 | -- @param module_name string: Name of the module to load 17 | -- @param source string: Source context for error messages 18 | -- @return table: Loaded module 19 | M.load_module = function(module_name, source) 20 | validation.assert_string(module_name, "module_name", false) 21 | validation.assert_string(source, "source", false) 22 | 23 | local ok, module = pcall(require, module_name) 24 | if not ok then 25 | local error_msg = string.format("%s dependency error: %s not installed", source, module_name) 26 | error(validation.validation_error(error_msg, "load_module")) 27 | end 28 | return module 29 | end 30 | 31 | -- Get file information and namespace for a given path 32 | -- @param path string: File path (optional, defaults to current file) 33 | -- @return table: {namespace: string, path: string, file_name: string} 34 | M.get_file_and_namespace = function(path) 35 | return namespace_resolver.get_file_and_namespace(path) 36 | end 37 | 38 | -- Get file information and namespace for the current buffer 39 | -- @return table: {namespace: string, path: string, file_name: string} 40 | M.get_curr_file_and_namespace = function() 41 | return namespace_resolver.get_current_file_and_namespace() 42 | end 43 | 44 | -- Convert file path to namespace based on project structure 45 | -- @param path string: File path 46 | -- @param directory string: Project directory 47 | -- @return string: Calculated namespace 48 | M.get_namespace_from_path = function(path, directory) 49 | return namespace_resolver.get_namespace_from_path(path, directory) 50 | end 51 | 52 | -- Split text by whitespace with special handling for multiple spaces 53 | -- @param entry string: Text to split 54 | -- @return table: Array of tokens 55 | M.get_tokens_split_by_whitespace = function(entry) 56 | validation.assert_string(entry, "entry", false) 57 | 58 | -- Replace double spaces with placeholder, single spaces with underscore, restore spaces 59 | entry = string.gsub(entry, " ", "~") 60 | entry = string.gsub(entry, " ", "_") 61 | entry = string.gsub(entry, "~", " ") 62 | 63 | local tokens = {} 64 | for v in string.gmatch(entry, "%S+") do 65 | v = string.match(v, "%S+") 66 | if v then 67 | v = string.gsub(v, "_", " ") 68 | v = string.gsub(v, '^%s*(.-)%s*$', '%1') -- Fix: was '%2' which is invalid 69 | v = string.gsub(v, '[ \t]+%f[\r\n%z]', '') 70 | table.insert(tokens, v) 71 | end 72 | end 73 | 74 | return tokens 75 | end 76 | 77 | -- Get all .csproj files in the current working directory 78 | -- @return table: Array of {index: number, value: string} tables 79 | M.get_all_csproj = function() 80 | return project_scanner.get_all_csproj() 81 | end 82 | 83 | -- Get DLL path from .csproj path 84 | -- @param csproj_path string: Path to the .csproj file 85 | -- @return string: Path to the compiled DLL 86 | function M.get_dll_from_csproj(csproj_path) 87 | return project_scanner.get_dll_from_csproj(csproj_path) 88 | end 89 | 90 | -- Append lines to a buffer 91 | -- @param bufnr number: Buffer number 92 | -- @param lines table: Array of lines to append 93 | -- @return boolean: Success status 94 | M.append_to_buffer = function(bufnr, lines) 95 | return buffer_helpers.append_to_buffer(bufnr, lines) 96 | end 97 | 98 | 99 | return M 100 | -------------------------------------------------------------------------------- /plugin/dotnvim.lua: -------------------------------------------------------------------------------- 1 | -- Check if the minimum required Neovim version is met 2 | if vim.fn.has("nvim-0.9.0") ~= 1 then 3 | vim.notify("dotnvim requires at least nvim-0.9.0.", vim.log.levels.ERROR) 4 | return 5 | end 6 | 7 | -- Configuration is now managed by config_manager.lua 8 | 9 | local log_file_path = vim.fn.stdpath('data') .. '/dotnvim.log' 10 | 11 | -- Create a user command to view the log 12 | vim.api.nvim_create_user_command('DotnvimLog', function() 13 | vim.cmd("edit " .. log_file_path) 14 | end, { nargs = 0 }) 15 | 16 | -- NuGet authentication command 17 | vim.api.nvim_create_user_command('DotnvimNugetAuth', function() 18 | require('dotnvim').nuget_auth() 19 | end, { nargs = 0 }) 20 | 21 | -- Task system commands 22 | vim.api.nvim_create_user_command('DotnvimTaskRun', function(opts) 23 | local task_name = opts.args 24 | if task_name == "" then 25 | local available_tasks = require('dotnvim').get_available_tasks() 26 | if #available_tasks > 0 then 27 | vim.ui.select(available_tasks, { 28 | prompt = 'Select task to run:', 29 | }, function(choice) 30 | if choice then 31 | require('dotnvim').run_task(choice) 32 | end 33 | end) 34 | else 35 | vim.notify("No tasks available", vim.log.levels.WARN) 36 | end 37 | else 38 | require('dotnvim').run_task(task_name) 39 | end 40 | end, { 41 | nargs = '?', 42 | desc = 'Run a task', 43 | complete = function() 44 | local tasks = require('dotnvim').get_available_tasks() 45 | return tasks or {} 46 | end 47 | }) 48 | 49 | vim.api.nvim_create_user_command('DotnvimTaskCancel', function() 50 | local cancelled = require('dotnvim').cancel_tasks() 51 | if cancelled then 52 | vim.notify("Task execution cancelled", vim.log.levels.INFO) 53 | else 54 | vim.notify("No tasks running", vim.log.levels.INFO) 55 | end 56 | end, { nargs = 0 }) 57 | 58 | vim.api.nvim_create_user_command('DotnvimTaskStatus', function() 59 | local status = require('dotnvim').task_status() 60 | 61 | local lines = {"=== Task System Status ==="} 62 | table.insert(lines, "Has config: " .. tostring(status.has_config)) 63 | 64 | if status.config_reason then 65 | table.insert(lines, "Config: " .. status.config_reason) 66 | end 67 | 68 | table.insert(lines, "Running: " .. tostring(status.running)) 69 | 70 | if #status.available_tasks > 0 then 71 | table.insert(lines, "Available tasks: " .. table.concat(status.available_tasks, ", ")) 72 | end 73 | 74 | if status.execution_history_count > 0 then 75 | table.insert(lines, "Execution history: " .. status.execution_history_count .. " entries") 76 | end 77 | 78 | if status.current_execution then 79 | table.insert(lines, "Currently running: " .. table.concat(status.current_execution.active_tasks, ", ")) 80 | end 81 | 82 | -- Create a temporary buffer to display status 83 | local buf = vim.api.nvim_create_buf(false, true) 84 | vim.bo[buf].buftype = 'nofile' 85 | vim.bo[buf].filetype = 'text' 86 | vim.api.nvim_buf_set_name(buf, "DotnvimTaskStatus") 87 | vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) 88 | vim.api.nvim_set_current_buf(buf) 89 | end, { nargs = 0 }) 90 | 91 | vim.api.nvim_create_user_command('DotnvimTaskInit', function(opts) 92 | local format = opts.args ~= "" and opts.args or "json" 93 | local success, message = require('dotnvim').create_task_config(format) 94 | 95 | if success then 96 | vim.notify("Task configuration created: " .. message, vim.log.levels.INFO) 97 | else 98 | vim.notify("Failed to create task configuration: " .. message, vim.log.levels.ERROR) 99 | end 100 | end, { 101 | nargs = '?', 102 | desc = 'Initialize task configuration', 103 | complete = function() 104 | return {"json", "yaml", "toml"} 105 | end 106 | }) 107 | 108 | -------------------------------------------------------------------------------- /lua/dotnvim/utils/telescope_utils.lua: -------------------------------------------------------------------------------- 1 | local function extract_directory(file_path) 2 | return file_path:match("(.*/)") 3 | end 4 | 5 | local M = {} 6 | 7 | local default_out_space = { 8 | filename = "", 9 | filepath = "", 10 | buffer = "" 11 | } 12 | 13 | -- Use Telescope as a selector to run the bootstrapper 14 | -- @param bootstrappers bootstrappers table to use 15 | M.telescope_select_bootstrapper = function(bootstrappers) 16 | local pickers = require('telescope.pickers') 17 | local finders = require('telescope.finders') 18 | local conf = require('telescope.config').values 19 | local actions = require('telescope.actions') 20 | local action_state = require('telescope.actions.state') 21 | local previewers = require 'telescope.previewers' 22 | 23 | local opts = {} 24 | pickers.new(opts, { 25 | prompt_title = " Bootstrapper", 26 | finder = finders.new_table { 27 | results = bootstrappers, 28 | entry_maker = function(entry) 29 | return { 30 | value = entry, 31 | display = entry.name, 32 | ordinal = entry.search, 33 | } 34 | end 35 | }, 36 | sorter = conf.generic_sorter(opts), 37 | previewer = previewers.new_buffer_previewer { 38 | define_preview = function(self, entry, status) 39 | local bufnr = self.state.bufnr 40 | vim.bo[bufnr].modifiable = true 41 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {}) 42 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, 43 | vim.split(entry.value.callback("PREVIEW", "NAMESPACE"), "\n")) 44 | vim.bo[bufnr].modifiable = false 45 | vim.bo[bufnr].filetype = 'cs' 46 | end 47 | }, 48 | attach_mappings = function(prompt_bufnr, map) 49 | actions.select_default:replace(function() 50 | actions.close(prompt_bufnr) 51 | local selection = action_state.get_selected_entry().value 52 | print('selection: ' .. selection.name) 53 | vim.ui.input({ prompt = 'Class Name: ' }, function(input) 54 | local out_space = default_out_space 55 | if input then 56 | out_space = selection.func(input, nil) 57 | local extracted = extract_directory(out_space.filepath) 58 | local plenary = require('plenary.path') 59 | local path = plenary:new(extracted .. input .. '.cs') 60 | path:write(out_space.buffer, 'w') 61 | vim.cmd("edit " .. path.filename) 62 | end 63 | end) 64 | end) 65 | return true 66 | end, 67 | }):find() 68 | end 69 | 70 | -- return 71 | -- { 72 | -- index = 0, 73 | -- value = path/to/.csproj 74 | -- } 75 | M.telescope_select_csproj = function(selections, callback) 76 | assert(#selections > 0, "No selections provided") -- Ensure there are selections 77 | 78 | local pickers = require('telescope.pickers') 79 | local finders = require('telescope.finders') 80 | local conf = require('telescope.config').values 81 | local actions = require('telescope.actions') 82 | local action_state = require('telescope.actions.state') 83 | 84 | local opts = {} 85 | pickers.new(opts, { 86 | prompt_title = " Select a Project to Build", 87 | finder = finders.new_table { 88 | results = selections, 89 | entry_maker = function(entry) 90 | return { 91 | value = entry.value, -- <-- -----------------\ 92 | display = entry.value, -- SAME THING | 93 | ordinal = entry.value, -- /--------------------/ 94 | } -- | 95 | end -- | 96 | }, -- | 97 | sorter = conf.generic_sorter(opts), -- | 98 | attach_mappings = function(prompt_bufnr, _map) -- | 99 | actions.select_default:replace(function() -- | 100 | local selection = action_state.get_selected_entry() -- | 101 | actions.close(prompt_bufnr) -- | 102 | callback(selection.value) 103 | -- ^\____________________________________/ 104 | end) 105 | return true 106 | end, 107 | }):find() 108 | end 109 | 110 | return M 111 | -------------------------------------------------------------------------------- /docs/norg/installation.norg: -------------------------------------------------------------------------------- 1 | @document.meta 2 | title: Installation Guide 3 | description: How to install and set up dotnvim 4 | authors: adamkali 5 | categories: installation setup 6 | created: 2025-08-28 7 | updated: 2025-08-28 8 | version: 1.0.0 9 | @end 10 | 11 | * Installation Guide 12 | 13 | ** Requirements 14 | 15 | *** System Dependencies 16 | dotnvim requires the following system executables: 17 | - `dotnet` - .NET CLI (required) 18 | - `fd` - File finder utility (required) 19 | - `netcoredbg` - .NET Core debugger (optional, for debugging support) 20 | 21 | *** Neovim Plugin Dependencies 22 | - `plenary.nvim` - Required for async jobs and utilities 23 | - `nvim-treesitter` - Required for syntax parsing 24 | - `telescope.nvim` - Optional, for project selection UI 25 | - `nui.nvim` - Optional, for bootstrap UI components 26 | - `nvim-dap` - Optional, for debugging support 27 | 28 | ** Installation Methods 29 | 30 | *** Using lazy.nvim 31 | @code lua 32 | { 33 | 'adamkali/dotnvim', 34 | dependencies = { 35 | 'nvim-lua/plenary.nvim', 36 | 'nvim-treesitter/nvim-treesitter', 37 | -- Optional dependencies 38 | 'nvim-telescope/telescope.nvim', 39 | 'MunifTanjim/nui.nvim', 40 | 'mfussenegger/nvim-dap', 41 | }, 42 | config = function() 43 | require('dotnvim').setup({ 44 | -- Your configuration here 45 | }) 46 | end, 47 | } 48 | @end 49 | 50 | *** Using packer.nvim 51 | @code lua 52 | use { 53 | 'adamkali/dotnvim', 54 | requires = { 55 | 'nvim-lua/plenary.nvim', 56 | 'nvim-treesitter/nvim-treesitter', 57 | -- Optional 58 | 'nvim-telescope/telescope.nvim', 59 | 'MunifTanjim/nui.nvim', 60 | 'mfussenegger/nvim-dap', 61 | }, 62 | config = function() 63 | require('dotnvim').setup() 64 | end 65 | } 66 | @end 67 | 68 | *** Using vim-plug 69 | @code vim 70 | Plug 'nvim-lua/plenary.nvim' 71 | Plug 'nvim-treesitter/nvim-treesitter' 72 | " Optional dependencies 73 | Plug 'nvim-telescope/telescope.nvim' 74 | Plug 'MunifTanjim/nui.nvim' 75 | Plug 'mfussenegger/nvim-dap' 76 | 77 | Plug 'adamkali/dotnvim' 78 | @end 79 | 80 | ** System Setup 81 | 82 | *** Installing .NET CLI 83 | **** Windows 84 | Download from {https://dotnet.microsoft.com/download}[Microsoft's official site] 85 | 86 | **** macOS 87 | @code bash 88 | brew install dotnet 89 | @end 90 | 91 | **** Linux (Ubuntu/Debian) 92 | @code bash 93 | wget https://packages.microsoft.com/config/ubuntu/20.04/packages-microsoft-prod.deb -O packages-microsoft-prod.deb 94 | sudo dpkg -i packages-microsoft-prod.deb 95 | rm packages-microsoft-prod.deb 96 | sudo apt update 97 | sudo apt install -y dotnet-sdk-8.0 98 | @end 99 | 100 | *** Installing fd 101 | **** macOS 102 | @code bash 103 | brew install fd 104 | @end 105 | 106 | **** Linux 107 | @code bash 108 | # Ubuntu/Debian 109 | sudo apt install fd-find 110 | 111 | # Arch Linux 112 | sudo pacman -S fd 113 | @end 114 | 115 | **** Windows 116 | @code bash 117 | # Using chocolatey 118 | choco install fd 119 | 120 | # Using scoop 121 | scoop install fd 122 | @end 123 | 124 | *** Installing netcoredbg (Optional) 125 | For debugging support, install netcoredbg: 126 | 127 | @code bash 128 | # Download the latest release from GitHub 129 | # https://github.com/Samsung/netcoredbg/releases 130 | 131 | # Or build from source 132 | git clone https://github.com/Samsung/netcoredbg 133 | cd netcoredbg 134 | mkdir build && cd build 135 | cmake .. -DCMAKE_INSTALL_PREFIX=/usr/local 136 | make && sudo make install 137 | @end 138 | 139 | ** Verification 140 | 141 | After installation, verify everything is working: 142 | 143 | @code vim 144 | :checkhealth dotnvim 145 | @end 146 | 147 | This will check all dependencies and provide guidance for any missing components. 148 | 149 | ** Basic Configuration 150 | 151 | Add this to your Neovim configuration: 152 | 153 | @code lua 154 | require('dotnvim').setup({ 155 | builders = { 156 | build_output_callback = nil, 157 | https_launch_setting_always = true, 158 | }, 159 | ui = { 160 | no_pretty_uis = false, 161 | }, 162 | dap = { 163 | adapter = { 164 | type = 'executable', 165 | command = "netcoredbg", 166 | args = { '--interpreter=vscode' }, 167 | } 168 | }, 169 | tasks = { 170 | enabled = true, 171 | execution_mode = "dependency_aware", 172 | dap_integration = { 173 | enabled = true, 174 | block_on_failure = true, 175 | timeout_seconds = 300, 176 | } 177 | } 178 | }) 179 | @end 180 | 181 | ** Next Steps 182 | 183 | Once installed, check out: 184 | - {/ configuration} - Detailed configuration options 185 | - {/ commands} - Available commands and keybindings 186 | - {/ tasks} - Setting up task workflows -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Generate Documentation 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - 'docs/norg/**' 8 | - '.github/workflows/docs.yml' 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: read 13 | pages: write 14 | id-token: write 15 | 16 | concurrency: 17 | group: "pages" 18 | cancel-in-progress: false 19 | 20 | jobs: 21 | generate-docs: 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v4 27 | 28 | - name: Setup Neovim 29 | uses: rhymond/setup-neovim@v1 30 | with: 31 | neovim-version: v0.10.0 32 | 33 | - name: Install dependencies 34 | run: | 35 | # Install luarocks 36 | sudo apt update 37 | sudo apt install -y luarocks 38 | 39 | # Install plenary 40 | sudo luarocks install plenary.nvim 41 | 42 | - name: Install Neorg 43 | run: | 44 | # Clone neorg to a temporary location 45 | git clone --depth=1 https://github.com/nvim-neorg/neorg.git /tmp/neorg 46 | 47 | - name: Setup documentation generation environment 48 | run: | 49 | mkdir -p docgen 50 | mkdir -p docs/generated 51 | 52 | - name: Create minimal Neovim config 53 | run: | 54 | cat > docgen/minimal_init.lua << 'EOF' 55 | -- Add neorg to runtimepath 56 | vim.opt.rtp:prepend('/tmp/neorg') 57 | 58 | -- Setup neorg with minimal config 59 | require('neorg').setup { 60 | load = { 61 | ["core.defaults"] = {}, 62 | ["core.export"] = {}, 63 | ["core.export.markdown"] = { 64 | config = { 65 | extensions = "all" 66 | } 67 | } 68 | } 69 | } 70 | EOF 71 | 72 | - name: Generate documentation 73 | run: | 74 | # Convert each .norg file to markdown 75 | for norg_file in docs/norg/*.norg; do 76 | if [ -f "$norg_file" ]; then 77 | filename=$(basename "$norg_file" .norg) 78 | echo "Converting $norg_file to docs/generated/${filename}.md" 79 | 80 | nvim --headless \ 81 | -u docgen/minimal_init.lua \ 82 | -c "edit $norg_file" \ 83 | -c "Neorg export to-file docs/generated/${filename}.md" \ 84 | -c "qa!" 85 | fi 86 | done 87 | 88 | - name: Create index.html for GitHub Pages 89 | run: | 90 | cat > docs/generated/index.html << 'EOF' 91 | 92 | 93 | 94 | dotnvim Documentation 95 | 96 | 97 | 111 | 112 | 113 |

dotnvim Documentation

114 |

.NET tooling for Neovim - Complete documentation and guides

115 | 116 |

Documentation Sections

117 |
    118 | EOF 119 | 120 | # Add links to all generated markdown files 121 | for md_file in docs/generated/*.md; do 122 | if [ -f "$md_file" ]; then 123 | filename=$(basename "$md_file" .md) 124 | if [ "$filename" != "index" ]; then 125 | echo "
  • ${filename^}
  • " >> docs/generated/index.html 126 | fi 127 | fi 128 | done 129 | 130 | # Add index link 131 | if [ -f "docs/generated/index.md" ]; then 132 | echo "
  • Overview
  • " >> docs/generated/index.html 133 | fi 134 | 135 | cat >> docs/generated/index.html << 'EOF' 136 |
137 | 138 |

Links

139 | 143 | 144 | 145 | EOF 146 | 147 | - name: Setup Pages 148 | uses: actions/configure-pages@v4 149 | 150 | - name: Upload artifact 151 | uses: actions/upload-pages-artifact@v3 152 | with: 153 | path: 'docs/generated' 154 | 155 | deploy: 156 | environment: 157 | name: github-pages 158 | url: ${{ steps.deployment.outputs.page_url }} 159 | runs-on: ubuntu-latest 160 | needs: generate-docs 161 | steps: 162 | - name: Deploy to GitHub Pages 163 | id: deployment 164 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /lua/dotnvim/tasks/init.lua: -------------------------------------------------------------------------------- 1 | -- Task system entry point and public API 2 | local manager = require('dotnvim.tasks.manager') 3 | local discovery = require('dotnvim.tasks.discovery') 4 | local runner = require('dotnvim.tasks.runner') 5 | local validation = require('dotnvim.utils.validation') 6 | 7 | local M = {} 8 | 9 | -- Re-export main functionality for convenient access 10 | M.execute_tasks = manager.execute_tasks 11 | M.execute_task_chain = manager.execute_task_chain 12 | M.cancel_execution = manager.cancel_execution 13 | M.is_running = manager.is_running 14 | M.get_execution_status = manager.get_execution_status 15 | M.get_execution_history = manager.get_execution_history 16 | M.get_available_tasks = manager.get_available_tasks 17 | M.has_task_support = manager.has_task_support 18 | M.create_example_config = manager.create_example_config 19 | M.reload_config = manager.reload_config 20 | M.get_config_summary = manager.get_config_summary 21 | M.set_execution_mode = manager.set_execution_mode 22 | M.get_execution_mode = manager.get_execution_mode 23 | 24 | -- Direct access to specific modules for advanced usage 25 | M.manager = manager 26 | M.discovery = discovery 27 | M.runner = runner 28 | 29 | -- Constants 30 | M.EXECUTION_MODES = manager.EXECUTION_MODES 31 | M.TASK_STATES = runner.TASK_STATES 32 | 33 | -- Convenience functions 34 | 35 | -- Execute a single task by name 36 | -- @param task_name string: Name of the task to execute 37 | -- @param options table: Execution options (optional) 38 | -- @param callback function: Completion callback (optional) 39 | -- @return boolean: True if execution started successfully 40 | function M.run_task(task_name, options, callback) 41 | validation.assert_string(task_name, "task_name", false) 42 | return M.execute_tasks({task_name}, options, callback) 43 | end 44 | 45 | -- Execute pre-debug tasks if configured 46 | -- @param options table: Execution options (optional) 47 | -- @param callback function: Completion callback (optional) 48 | -- @return boolean: True if execution started successfully or no pre-debug tasks 49 | function M.run_pre_debug_tasks(options, callback) 50 | options = options or {} 51 | 52 | -- Look for common pre-debug task names 53 | local pre_debug_candidates = {"pre-debug", "debug-prep", "prepare-debug", "before-debug"} 54 | 55 | local available_tasks, err = M.get_available_tasks(options.project_root) 56 | if not available_tasks then 57 | if callback then callback(true, "No task configuration available") end 58 | return true -- Not an error, just no tasks available 59 | end 60 | 61 | -- Find the first available pre-debug task 62 | local pre_debug_task = nil 63 | for _, candidate in ipairs(pre_debug_candidates) do 64 | if vim.tbl_contains(available_tasks, candidate) then 65 | pre_debug_task = candidate 66 | break 67 | end 68 | end 69 | 70 | if not pre_debug_task then 71 | -- Check for user-configured pre-debug tasks in options 72 | if options.pre_debug_tasks then 73 | validation.assert_table(options.pre_debug_tasks, "options.pre_debug_tasks", false) 74 | return M.execute_tasks(options.pre_debug_tasks, options, callback) 75 | end 76 | 77 | if callback then callback(true, "No pre-debug tasks configured") end 78 | return true -- Not an error, just no pre-debug tasks 79 | end 80 | 81 | return M.execute_task_chain(pre_debug_task, options, callback) 82 | end 83 | 84 | -- Quick status check 85 | -- @return table: Quick status summary 86 | function M.status() 87 | local status = { 88 | running = M.is_running(), 89 | has_config = false, 90 | available_tasks = {}, 91 | execution_history_count = 0 92 | } 93 | 94 | -- Check if config exists 95 | local has_support, reason = M.has_task_support() 96 | status.has_config = has_support 97 | status.config_reason = reason 98 | 99 | if has_support then 100 | local tasks, err = M.get_available_tasks() 101 | if tasks then 102 | status.available_tasks = tasks 103 | end 104 | end 105 | 106 | -- Get execution history count 107 | local history = M.get_execution_history(1) 108 | status.execution_history_count = #M.get_execution_history(100) -- Get full count 109 | 110 | if M.is_running() then 111 | status.current_execution = M.get_execution_status() 112 | end 113 | 114 | return status 115 | end 116 | 117 | -- Initialize task system for a project 118 | -- @param project_root string: Project root directory (optional) 119 | -- @param format string: Format for example config (optional) 120 | -- @return boolean, string: Success status, message 121 | function M.init(project_root, format) 122 | local has_support, reason = M.has_task_support(project_root) 123 | 124 | if has_support then 125 | return true, "Task system already initialized: " .. reason 126 | end 127 | 128 | -- Create example configuration 129 | format = format or "json" 130 | local success, file_path_or_error = M.create_example_config(format, project_root) 131 | 132 | if success then 133 | return true, "Task configuration created: " .. file_path_or_error 134 | else 135 | return false, "Failed to create task configuration: " .. file_path_or_error 136 | end 137 | end 138 | 139 | -- Setup function for integration with main plugin 140 | -- @param config table: Task system configuration (optional) 141 | function M.setup(config) 142 | config = config or {} 143 | 144 | -- Set execution mode if specified 145 | if config.execution_mode then 146 | M.set_execution_mode(config.execution_mode) 147 | end 148 | 149 | -- Any other task-specific setup can go here 150 | end 151 | 152 | return M -------------------------------------------------------------------------------- /lua/dotnvim/bootstrappers.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | -- [[ 4 | -- Credit to [MoaidHathot](https://github.com/MoaidHathot/dotnet.nvim) on github for the utils that i am stealing here 5 | -- ]] 6 | local namespace_resolver = require('dotnvim.utils.namespace_resolver') 7 | local dotnvim_templates = require('dotnvim.utils.templates') 8 | local validation = require('dotnvim.utils.validation') 9 | local ui_helpers = require('dotnvim.utils.ui_helpers') 10 | 11 | function M.bootstrap_model(name, namespace) 12 | validation.assert_string(name, "name", false) 13 | 14 | local path = '' 15 | if namespace == nil then 16 | local n = namespace_resolver.get_current_file_and_namespace() 17 | if not n then 18 | ui_helpers.show_error("Could not determine namespace from current file", "bootstrap_model") 19 | return nil 20 | end 21 | path = n.path 22 | namespace = n.namespace 23 | else 24 | validation.assert_string(namespace, "namespace", false) 25 | path = string.gsub(namespace, '.', '/') 26 | end 27 | 28 | local working_filename = path .. name .. ".cs" 29 | ui_helpers.show_info("Creating model: " .. working_filename, "bootstrap_model") 30 | 31 | return { 32 | filename = working_filename, 33 | filepath = path, 34 | buffer = dotnvim_templates.dotnvim_api_model_template(name, namespace) 35 | } 36 | end 37 | 38 | function M.bootstrap_api_controller(name, namespace) 39 | validation.assert_string(name, "name", false) 40 | 41 | local path = '' 42 | if namespace == nil then 43 | local n = namespace_resolver.get_current_file_and_namespace() 44 | if not n then 45 | ui_helpers.show_error("Could not determine namespace from current file", "bootstrap_api_controller") 46 | return nil 47 | end 48 | path = n.path 49 | namespace = n.namespace 50 | else 51 | validation.assert_string(namespace, "namespace", false) 52 | path = string.gsub(namespace, '.', '/') 53 | end 54 | 55 | return { 56 | filename = path .. name .. ".cs", 57 | filepath = path, 58 | buffer = dotnvim_templates.dotnvim_api_controller_template(name, namespace) 59 | } 60 | end 61 | 62 | function M.bootstrap_api_controller_rw(name, namespace) 63 | validation.assert_string(name, "name", false) 64 | 65 | local path = '' 66 | if namespace == nil then 67 | local n = namespace_resolver.get_current_file_and_namespace() 68 | if not n then 69 | ui_helpers.show_error("Could not determine namespace from current file", "bootstrap_api_controller_rw") 70 | return nil 71 | end 72 | path = n.path 73 | namespace = n.namespace 74 | else 75 | validation.assert_string(namespace, "namespace", false) 76 | path = string.gsub(namespace, '.', '/') 77 | end 78 | 79 | return { 80 | filename = path .. name .. ".cs", 81 | filepath = path, 82 | buffer = dotnvim_templates.dotnvim_api_mvc_controller_template(name, namespace) 83 | } 84 | end 85 | 86 | function M.bootstrap_razor_component(name, namespace) 87 | validation.assert_string(name, "name", false) 88 | 89 | local path = '' 90 | if namespace == nil then 91 | local n = namespace_resolver.get_current_file_and_namespace() 92 | if not n then 93 | ui_helpers.show_error("Could not determine namespace from current file", "bootstrap_razor_component") 94 | return nil 95 | end 96 | path = n.path 97 | namespace = n.namespace 98 | else 99 | validation.assert_string(namespace, "namespace", false) 100 | path = string.gsub(namespace, '.', '/') 101 | end 102 | 103 | return { 104 | filename = path .. name .. ".cs", 105 | filepath = path, 106 | buffer = dotnvim_templates.dotnvim_razor_component_template(name, namespace) 107 | } 108 | end 109 | 110 | 111 | M.bootstrappers = { 112 | { 113 | search = "c_sharp_model", 114 | name = "C# Model", 115 | callback = function (name, namespace) 116 | return dotnvim_templates.dotnvim_api_model_template(name, namespace) 117 | end, 118 | func = function (name, namespace) 119 | return M.bootstrap_api_model(name, namespace) 120 | end, 121 | }, 122 | { 123 | search = "asp_net_api_controller_blank", 124 | name = "ASP.NET API Controller", 125 | callback = function (name, namespace) 126 | return dotnvim_templates.dotnvim_api_controller_template(name, namespace) 127 | end, 128 | func = function (name, namespace) 129 | return M.bootstrap_api_controller(name, namespace) 130 | end, 131 | }, 132 | { 133 | search = "asp_net_api_controller_read_write", 134 | name = "ASP.NET API Controller With Read Write", 135 | callback= function(name, namespace) 136 | return dotnvim_templates.dotnvim_api_controller_template(name, namespace) 137 | end, 138 | func = function (name, namespace) 139 | return M.bootstrap_api_controller(name, namespace) 140 | end, 141 | }, 142 | { 143 | search = "razor_component", 144 | name = ".NET Razor Component", 145 | callback= function(name, namespace) 146 | return dotnvim_templates.dotnvim_razor_component_template(name, namespace) 147 | end, 148 | func = function (name, namespace) 149 | return M.bootstrap_razor_component(name, namespace) 150 | end, 151 | } 152 | } 153 | 154 | return M 155 | -------------------------------------------------------------------------------- /lua/dotnvim/builder.lua: -------------------------------------------------------------------------------- 1 | local dotnvim_utils = require('dotnvim.utils') 2 | local Job = require 'plenary.job' 3 | local config_manager = require('dotnvim.config_manager') 4 | local validation = require('dotnvim.utils.validation') 5 | local buffer_helpers = require('dotnvim.utils.buffer_helpers') 6 | local ui_helpers = require('dotnvim.utils.ui_helpers') 7 | 8 | local M = {} 9 | 10 | function start_output_buffer(log_name) 11 | validation.assert_string(log_name, "log_name", false) 12 | 13 | local bufnr = buffer_helpers.create_log_buffer(log_name) 14 | if not bufnr then 15 | ui_helpers.show_error("Failed to create log buffer", "start_output_buffer") 16 | return nil 17 | end 18 | return bufnr 19 | end 20 | 21 | -- HACK: The most disgusting hack i could think of 22 | -- use pgrep and then concat all pids return at the same 23 | -- time so the dotnet sanity checks dont fuck everything up 24 | -- and restart the process leading to 25 | M.kill_dotnet_process = function() 26 | local last_csproj = config_manager.get_last_used_csproj() 27 | if not last_csproj then 28 | ui_helpers.show_warning("No .csproj file was used recently", "kill_dotnet_process") 29 | return false 30 | end 31 | 32 | -- Extract the project name from the .csproj path 33 | local project_name = last_csproj:match("([^/]+)%.csproj$") 34 | if not project_name then 35 | ui_helpers.show_error("Unable to extract project name from the .csproj path", "kill_dotnet_process") 36 | return false 37 | end 38 | 39 | -- Use pgrep to find the PID of the running process 40 | Job:new({ 41 | command = 'pgrep', 42 | args = { '-f', project_name }, 43 | on_exit = function(j, return_val) 44 | if return_val == 0 then 45 | local pids = j:result() 46 | if #pids > 0 then 47 | local kill_cmd = "kill -9 " .. table.concat(pids, " ") 48 | local status = os.execute(kill_cmd) 49 | if status == 0 then 50 | ui_helpers.show_success("Processes killed successfully", "kill_dotnet_process") 51 | else 52 | ui_helpers.show_error("Failed to kill processes", "kill_dotnet_process") 53 | end 54 | else 55 | ui_helpers.show_warning("No valid PIDs found", "kill_dotnet_process") 56 | end 57 | else 58 | ui_helpers.show_info("No process found for project: " .. project_name, "kill_dotnet_process") 59 | end 60 | end, 61 | }):start() 62 | 63 | return true 64 | end 65 | 66 | 67 | function M.dotnet_build(csproj_path) 68 | local valid, err = validation.validate_csproj_path(csproj_path, "csproj_path") 69 | if not valid then 70 | ui_helpers.show_error(err, "dotnet_build") 71 | return false 72 | end 73 | 74 | -- Start the log buffer 75 | local bufnr = start_output_buffer("build.log") 76 | if not bufnr then 77 | return false 78 | end 79 | 80 | -- Start the job to run dotnet build 81 | local job = Job:new({ 82 | command = 'dotnet', 83 | args = { 'build', csproj_path }, 84 | on_stdout = vim.schedule_wrap(function(_, line) 85 | buffer_helpers.append_to_buffer(bufnr, { line }) 86 | end), 87 | on_stderr = vim.schedule_wrap(function(_, line) 88 | buffer_helpers.append_to_buffer(bufnr, { line }) 89 | end), 90 | on_exit = function(j, return_val) 91 | vim.schedule(function() 92 | config_manager.set_last_used_csproj(csproj_path) 93 | if return_val == 0 then 94 | ui_helpers.show_success("Build completed successfully", "dotnet_build") 95 | else 96 | ui_helpers.show_error("Build failed with exit code " .. return_val, "dotnet_build") 97 | end 98 | end) 99 | end, 100 | }) 101 | 102 | job:start() 103 | ui_helpers.show_info("Starting build for " .. vim.fn.fnamemodify(csproj_path, ":t"), "dotnet_build") 104 | return true 105 | end 106 | 107 | function M.dotnet_watch(csproj_path) 108 | local valid, err = validation.validate_csproj_path(csproj_path, "csproj_path") 109 | if not valid then 110 | ui_helpers.show_error(err, "dotnet_watch") 111 | return false 112 | end 113 | 114 | local dotnet_args = { 'watch', '--project', csproj_path } 115 | if config_manager.get_builders_config().https_launch_setting_always then 116 | vim.tbl_extend(dotnet_args, { "-lp", "https" }) 117 | end 118 | vim.print(dotnet_args) 119 | 120 | local bufnr = start_output_buffer("watch.log") 121 | if not bufnr then 122 | return false 123 | end 124 | 125 | -- Start the job to run dotnet watch 126 | local watch_job = Job:new({ 127 | command = 'dotnet', 128 | args = dotnet_args, 129 | on_stdout = vim.schedule_wrap(function(_, line) 130 | buffer_helpers.append_to_buffer(bufnr, { line }) 131 | end), 132 | on_stderr = vim.schedule_wrap(function(_, line) 133 | buffer_helpers.append_to_buffer(bufnr, { line }) 134 | end), 135 | }) 136 | config_manager.set_last_used_csproj(csproj_path) 137 | config_manager.set_running_watch(watch_job) 138 | watch_job:start() 139 | 140 | ui_helpers.show_info("Starting watch for " .. vim.fn.fnamemodify(csproj_path, ":t"), "dotnet_watch") 141 | return true 142 | end 143 | 144 | function M.restart_dotnet_watch() 145 | local last_csproj = config_manager.get_last_used_csproj() 146 | if not last_csproj then 147 | ui_helpers.show_error("No previous .csproj file to restart", "restart_dotnet_watch") 148 | return false 149 | end 150 | 151 | M.kill_dotnet_process() 152 | return M.dotnet_watch(last_csproj) 153 | end 154 | 155 | return M 156 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | **dotnvim** is a Neovim plugin that provides .NET tooling and development support for .NET projects within Neovim. It offers project bootstrapping, building, watching, debugging, and NuGet authentication capabilities. 8 | 9 | ## Architecture 10 | 11 | The plugin follows a modular Lua architecture: 12 | 13 | - **Entry Point**: `lua/dotnvim/init.lua` - Main module with public API functions 14 | - **Core Modules**: 15 | - `builder.lua` - Handles `dotnet build` and `dotnet watch` operations 16 | - `bootstrappers.lua` - Provides code generation for C# classes and controllers 17 | - `config.lua` - Configuration management and DAP setup 18 | - `nuget.lua` - NuGet source authentication 19 | - `health.lua` - Plugin health checking 20 | - **Utilities**: `lua/dotnvim/utils/` - Path utilities, LSP helpers, Telescope integration, and templates 21 | - **Generators**: `lua/dotnvim/generators/` - Code generation templates for classes and controllers 22 | 23 | ## Required Dependencies 24 | 25 | ### System Executables 26 | - `dotnet` - .NET CLI 27 | - `fd` - File finder utility 28 | - `netcoredbg` - .NET Core debugger (for debugging support) 29 | 30 | ### Neovim Plugins 31 | - `plenary.nvim` - Required for async jobs and utilities 32 | - `nvim-treesitter` - Required for syntax parsing 33 | - `telescope.nvim` - Optional, for project selection UI 34 | - `nui.nvim` - Optional, for bootstrap UI components 35 | - `nvim-dap` - Optional, for debugging support 36 | 37 | ## Key APIs and Usage 38 | 39 | ### Main Functions (lua/dotnvim/init.lua) 40 | - `M.build(last)` - Build project (last=true uses cached project) 41 | - `M.watch(last)` - Start dotnet watch (last=true uses cached project) 42 | - `M.bootstrap()` - Interactive code generation for classes/controllers 43 | - `M.nuget_auth()` - Authenticate configured NuGet sources 44 | - `M.restart_watch()` - Restart the watch process 45 | - `M.shutdown_watch()` - Kill running watch processes 46 | - `M.setup(config)` - Configure plugin with user settings 47 | 48 | ### Configuration System 49 | - Configuration is managed centrally by `config_manager.lua` 50 | - State (last used csproj, running jobs) is managed separately from user configuration 51 | - Access config via `config_manager.get_*_config()` functions 52 | - Access state via `config_manager.get_*()` and `config_manager.set_*()` functions 53 | 54 | ### Task System 55 | - **NEW**: Full task workflow system with pre-debug execution support 56 | - Task configuration files: `.nvim/tasks.{json,yaml,toml}` or `.vscode/tasks.{json,yaml,toml}` 57 | - Multi-format support: JSON, YAML, and TOML configurations 58 | - Dependency resolution with array-based task dependencies 59 | - DAP integration: automatic pre-debug task execution 60 | - Commands: `:DotnvimTaskRun`, `:DotnvimTaskStatus`, `:DotnvimTaskCancel`, `:DotnvimTaskInit` 61 | 62 | ## Configuration Structure 63 | 64 | ```lua 65 | { 66 | builders = { 67 | build_output_callback = nil, 68 | https_launch_setting_always = true, 69 | }, 70 | ui = { 71 | no_pretty_uis = false, 72 | }, 73 | dap = { 74 | adapter = { 75 | type = 'executable', 76 | command = "netcoredbg", 77 | args = { '--interpreter=vscode' }, 78 | } 79 | }, 80 | nuget = { 81 | sources = {}, 82 | authenticators = { 83 | { 84 | cmd = "", 85 | args = {} 86 | } 87 | } 88 | }, 89 | tasks = { 90 | enabled = true, 91 | execution_mode = "dependency_aware", -- "sequential", "dependency_aware" 92 | dap_integration = { 93 | enabled = true, 94 | pre_debug_tasks = nil, -- Auto-discover or specify: {"pre-debug", "build"} 95 | block_on_failure = true, 96 | timeout_seconds = 300, 97 | } 98 | } 99 | } 100 | ``` 101 | 102 | ## Task Configuration Examples 103 | 104 | ### JSON Format (`.nvim/tasks.json`) 105 | ```json 106 | { 107 | "version": "0.1.0", 108 | "tasks": [ 109 | { 110 | "name": "restore", 111 | "command": "dotnet restore", 112 | "cwd": ".", 113 | "env": {"DOTNET_NOLOGO": "true"} 114 | }, 115 | { 116 | "name": "build", 117 | "previous": ["restore"], 118 | "command": "dotnet build --no-restore", 119 | "cwd": ".", 120 | "env": {"DOTNET_NOLOGO": "true"} 121 | }, 122 | { 123 | "name": "test", 124 | "previous": ["build"], 125 | "command": "dotnet test --no-build", 126 | "cwd": ".", 127 | "env": {"ASPNETCORE_ENVIRONMENT": "Test"} 128 | }, 129 | { 130 | "name": "pre-debug", 131 | "previous": ["build"], 132 | "command": "echo 'Ready for debugging'", 133 | "cwd": "." 134 | } 135 | ] 136 | } 137 | ``` 138 | 139 | ### YAML Format (`.nvim/tasks.yaml`) 140 | ```yaml 141 | version: "0.1.0" 142 | tasks: 143 | - name: restore 144 | command: dotnet restore 145 | cwd: . 146 | env: 147 | DOTNET_NOLOGO: "true" 148 | 149 | - name: build 150 | previous: [restore] 151 | command: dotnet build --no-restore 152 | cwd: . 153 | env: 154 | DOTNET_NOLOGO: "true" 155 | 156 | - name: pre-debug 157 | previous: [build] 158 | command: echo 'Ready for debugging' 159 | cwd: . 160 | ``` 161 | 162 | ## Health Check 163 | 164 | Run `:checkhealth dotnvim` to verify all dependencies are correctly installed. The health check validates: 165 | - Required executables (`dotnet`, `fd`, `netcoredbg`) 166 | - Required plugins (`plenary`, `nvim-treesitter`) 167 | - Optional plugins (`telescope`, `nui.nvim`) 168 | 169 | ## Development Notes 170 | 171 | - Uses `plenary.job` for async dotnet command execution 172 | - Output buffers are created with timestamps for build/watch logs 173 | - Process management uses `pgrep` to find and kill dotnet processes 174 | - Supports both Telescope and vim.ui.select for project selection 175 | - DAP configurations can be loaded from `.vscode/launch.json` or plugin config 176 | - Log file location: `vim.fn.stdpath('data') .. '/dotnvim.log'` -------------------------------------------------------------------------------- /lua/dotnvim/init.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local bootstrappers = require('dotnvim.bootstrappers') 4 | local telescope_utils = require('dotnvim.utils.telescope_utils') 5 | local dotnvim_builders = require('dotnvim.builder') 6 | local dotnvim_utils = require('dotnvim.utils') 7 | local configurator = require('dotnvim.config') 8 | local nuget_client = require('dotnvim.nuget') 9 | local config_manager = require('dotnvim.config_manager') 10 | local tasks = require('dotnvim.tasks') 11 | local dap_hooks = require('dotnvim.dap_hooks') 12 | local logger = require('dotnvim.utils.logger') 13 | local ui = require('dotnvim.ui') 14 | 15 | -- Ensure Neovim version is at least 0.9.0 16 | if vim.fn.has("nvim-0.9.0") ~= 1 then 17 | vim.notify("dotnvim requires at least nvim-0.9.0.", vim.log.levels.ERROR) 18 | return 19 | end 20 | 21 | -- Setup logging functionality with plenary 22 | local log_file_path = vim.fn.stdpath('data') .. '/dotnvim.log' 23 | local log = require('plenary.log').new({ 24 | plugin = 'dotnvim', 25 | level = 'debug', -- Set log level (trace, debug, info, warn, error, fatal) 26 | use_console = false, -- Optionally log to the console 27 | highlights = true, -- Enable highlighting in the log 28 | use_file = true, -- Enable file logging 29 | file_path = log_file_path, -- Specify the custom log file path 30 | file_level = 'debug', -- Set the file log level 31 | float_precision = 0.01, -- Floating point precision for numbers 32 | }) 33 | 34 | -- Initialize config manager with defaults 35 | config_manager.setup({}) 36 | config_manager.init_state(log) 37 | 38 | M.default_params = { 39 | bootstrap_verbose = false, 40 | bootstrap_namespace = nil 41 | } 42 | 43 | -- Build the last used project or prompt for selection 44 | function M.build(last) 45 | local last_csproj = config_manager.get_last_used_csproj() 46 | if last_csproj and last then 47 | return dotnvim_builders.dotnet_build(last_csproj) 48 | else 49 | dotnvim_utils.select_csproj(dotnvim_builders.dotnet_build) 50 | return true 51 | end 52 | end 53 | 54 | -- Watch the last used project or prompt for selection 55 | function M.watch(last) 56 | local last_csproj = config_manager.get_last_used_csproj() 57 | if last_csproj and last then 58 | return dotnvim_builders.dotnet_watch(last_csproj) 59 | else 60 | dotnvim_utils.select_csproj(dotnvim_builders.dotnet_watch) 61 | return true 62 | end 63 | end 64 | 65 | -- Check the health of the plugin 66 | function M.check() 67 | require('dotnvim.health').check() 68 | end 69 | 70 | -- Bootstrap configuration 71 | function M.bootstrap() 72 | local selection = {} 73 | if pcall(require, 'telescope') then 74 | selection = telescope_utils.telescope_select_bootstrapper(bootstrappers.bootstrappers) 75 | end 76 | if not selection then 77 | -- TODO: vim.ui.select(bootstrappers.bootstrapers) 78 | return 79 | end 80 | end 81 | 82 | -- Query last used .csproj 83 | function M.query_last_ran_csproj() 84 | print(config_manager.get_last_used_csproj()) 85 | end 86 | 87 | -- Restart the watch process 88 | function M.restart_watch() 89 | dotnvim_builders.restart_dotnet_watch() 90 | end 91 | 92 | -- Shutdown the watch process 93 | function M.shutdown_watch() 94 | dotnvim_builders.kill_dotnet_process() 95 | end 96 | 97 | -- Select a .csproj and run the provided callback 98 | function M.select_csproj(callback) 99 | if type(callback) == "function" then 100 | return dotnvim_utils.select_csproj(callback) 101 | end 102 | error("Callback passed to select_csproj is nil") 103 | end 104 | 105 | -- Setup the plugin with user-provided configuration 106 | function M.setup(config) 107 | config = config or {} 108 | 109 | -- Setup centralized configuration 110 | if not config_manager.setup(config) then 111 | return false 112 | end 113 | 114 | -- Initialize logger with debug configuration 115 | local debug_config = config_manager.get_debug_config() 116 | logger.init({ 117 | debug_mode = debug_config.enabled, 118 | log_file_path = debug_config.log_file_path 119 | }) 120 | 121 | -- Apply DAP configuration 122 | configurator.configurate_dap(config_manager.get_dap_config()) 123 | 124 | -- Setup task system 125 | local tasks_config = config_manager.get_tasks_config() 126 | if tasks_config.enabled then 127 | tasks.setup({ 128 | execution_mode = tasks_config.execution_mode 129 | }) 130 | 131 | -- Setup DAP integration 132 | if tasks_config.dap_integration.enabled then 133 | dap_hooks.setup({ 134 | enabled = tasks_config.dap_integration.enabled, 135 | pre_debug_tasks = tasks_config.dap_integration.pre_debug_tasks, 136 | block_debug_on_task_failure = tasks_config.dap_integration.block_on_failure, 137 | timeout_seconds = tasks_config.dap_integration.timeout_seconds 138 | }) 139 | end 140 | end 141 | 142 | return true 143 | end 144 | 145 | function M.nuget_auth() 146 | nuget_client.authenticate() 147 | end 148 | 149 | -- Task system functions 150 | function M.run_task(task_name, options, callback) 151 | return tasks.run_task(task_name, options, callback) 152 | end 153 | 154 | function M.run_tasks(task_names, options, callback) 155 | return tasks.execute_tasks(task_names, options, callback) 156 | end 157 | 158 | function M.cancel_tasks() 159 | return tasks.cancel_execution() 160 | end 161 | 162 | function M.get_available_tasks() 163 | return tasks.get_available_tasks() 164 | end 165 | 166 | function M.task_status() 167 | return tasks.status() 168 | end 169 | 170 | function M.create_task_config(format) 171 | return tasks.create_example_config(format) 172 | end 173 | 174 | -- UI functions 175 | function M.show_config() 176 | ui.show_config() 177 | end 178 | 179 | function M.show_config_section(section) 180 | ui.show_config_section(section) 181 | end 182 | 183 | return M 184 | 185 | -------------------------------------------------------------------------------- /lua/dotnvim/utils/logger.lua: -------------------------------------------------------------------------------- 1 | -- Comprehensive logging utility for dotnvim debugging 2 | local M = {} 3 | 4 | -- Log levels 5 | local LOG_LEVELS = { 6 | TRACE = 1, 7 | DEBUG = 2, 8 | INFO = 3, 9 | WARN = 4, 10 | ERROR = 5, 11 | FATAL = 6 12 | } 13 | 14 | -- Reverse mapping for level names 15 | local LEVEL_NAMES = { 16 | [1] = "TRACE", 17 | [2] = "DEBUG", 18 | [3] = "INFO", 19 | [4] = "WARN", 20 | [5] = "ERROR", 21 | [6] = "FATAL" 22 | } 23 | 24 | -- Logger state 25 | local logger_state = { 26 | debug_mode = false, 27 | log_file_path = vim.fn.stdpath('data') .. '/dotnvim.log', 28 | min_level = LOG_LEVELS.INFO, 29 | max_file_size = 10 * 1024 * 1024, -- 10MB 30 | initialized = false 31 | } 32 | 33 | -- Initialize logger 34 | function M.init(config) 35 | config = config or {} 36 | 37 | -- Update state from config 38 | logger_state.debug_mode = config.debug_mode or false 39 | logger_state.log_file_path = config.log_file_path or logger_state.log_file_path 40 | logger_state.min_level = config.debug_mode and LOG_LEVELS.DEBUG or LOG_LEVELS.INFO 41 | 42 | -- Ensure log directory exists 43 | local log_dir = vim.fn.fnamemodify(logger_state.log_file_path, ':h') 44 | vim.fn.mkdir(log_dir, 'p') 45 | 46 | -- Rotate log file if it's too large 47 | M.rotate_if_needed() 48 | 49 | logger_state.initialized = true 50 | 51 | -- Log initialization 52 | M.info("logger", "Logger initialized - Debug mode: " .. tostring(logger_state.debug_mode)) 53 | end 54 | 55 | -- Check if logging is enabled for level 56 | local function should_log(level) 57 | return logger_state.initialized and level >= logger_state.min_level 58 | end 59 | 60 | -- Format log entry 61 | local function format_log_entry(level, component, message) 62 | local timestamp = os.date("%Y-%m-%d %H:%M:%S") 63 | local level_name = LEVEL_NAMES[level] or "UNKNOWN" 64 | return string.format("[%s] [%s] [%s] %s\n", timestamp, level_name, component, message) 65 | end 66 | 67 | -- Write to log file 68 | local function write_to_file(entry) 69 | if not logger_state.initialized then 70 | return 71 | end 72 | 73 | local file = io.open(logger_state.log_file_path, 'a') 74 | if file then 75 | file:write(entry) 76 | file:close() 77 | end 78 | end 79 | 80 | -- Rotate log file if needed 81 | function M.rotate_if_needed() 82 | local stat = vim.loop.fs_stat(logger_state.log_file_path) 83 | if stat and stat.size > logger_state.max_file_size then 84 | local backup_path = logger_state.log_file_path .. '.old' 85 | os.rename(logger_state.log_file_path, backup_path) 86 | end 87 | end 88 | 89 | -- Core logging function 90 | local function log(level, component, message) 91 | if not should_log(level) then 92 | return 93 | end 94 | 95 | -- Handle table/complex objects 96 | if type(message) == "table" then 97 | message = vim.inspect(message) 98 | else 99 | message = tostring(message) 100 | end 101 | 102 | local entry = format_log_entry(level, component, message) 103 | write_to_file(entry) 104 | 105 | -- Also show in console for WARN and above (if debug mode) 106 | if logger_state.debug_mode and level >= LOG_LEVELS.WARN then 107 | local level_name = LEVEL_NAMES[level] or "LOG" 108 | vim.notify("[dotnvim:" .. component .. "] " .. message, 109 | level >= LOG_LEVELS.ERROR and vim.log.levels.ERROR or vim.log.levels.WARN) 110 | end 111 | end 112 | 113 | -- Public logging functions 114 | function M.trace(component, message) 115 | log(LOG_LEVELS.TRACE, component, message) 116 | end 117 | 118 | function M.debug(component, message) 119 | log(LOG_LEVELS.DEBUG, component, message) 120 | end 121 | 122 | function M.info(component, message) 123 | log(LOG_LEVELS.INFO, component, message) 124 | end 125 | 126 | function M.warn(component, message) 127 | log(LOG_LEVELS.WARN, component, message) 128 | end 129 | 130 | function M.error(component, message) 131 | log(LOG_LEVELS.ERROR, component, message) 132 | end 133 | 134 | function M.fatal(component, message) 135 | log(LOG_LEVELS.FATAL, component, message) 136 | end 137 | 138 | -- Log function call with arguments and return value 139 | function M.log_function_call(component, func_name, args, result) 140 | if not should_log(LOG_LEVELS.DEBUG) then 141 | return 142 | end 143 | 144 | local args_str = args and vim.inspect(args) or "nil" 145 | local result_str = result and vim.inspect(result) or "nil" 146 | 147 | M.debug(component, string.format("CALL %s(%s) -> %s", func_name, args_str, result_str)) 148 | end 149 | 150 | -- Log state changes 151 | function M.log_state_change(component, state_name, old_value, new_value) 152 | if not should_log(LOG_LEVELS.DEBUG) then 153 | return 154 | end 155 | 156 | M.debug(component, string.format("STATE %s: %s -> %s", 157 | state_name, 158 | vim.inspect(old_value), 159 | vim.inspect(new_value))) 160 | end 161 | 162 | -- Log with stack trace 163 | function M.debug_with_trace(component, message) 164 | if not should_log(LOG_LEVELS.DEBUG) then 165 | return 166 | end 167 | 168 | local trace = debug.traceback("", 2) 169 | M.debug(component, message .. "\n" .. trace) 170 | end 171 | 172 | -- Get current logger config 173 | function M.get_config() 174 | return { 175 | debug_mode = logger_state.debug_mode, 176 | log_file_path = logger_state.log_file_path, 177 | min_level = logger_state.min_level, 178 | initialized = logger_state.initialized 179 | } 180 | end 181 | 182 | -- Enable/disable debug mode 183 | function M.set_debug_mode(enabled) 184 | local old_debug = logger_state.debug_mode 185 | logger_state.debug_mode = enabled 186 | logger_state.min_level = enabled and LOG_LEVELS.DEBUG or LOG_LEVELS.INFO 187 | 188 | M.info("logger", "Debug mode changed: " .. tostring(old_debug) .. " -> " .. tostring(enabled)) 189 | end 190 | 191 | -- Clear log file 192 | function M.clear_log() 193 | local file = io.open(logger_state.log_file_path, 'w') 194 | if file then 195 | file:close() 196 | M.info("logger", "Log file cleared") 197 | end 198 | end 199 | 200 | -- Get log file path 201 | function M.get_log_file_path() 202 | return logger_state.log_file_path 203 | end 204 | 205 | return M -------------------------------------------------------------------------------- /lua/dotnvim/utils/namespace_resolver.lua: -------------------------------------------------------------------------------- 1 | -- Namespace resolution utilities for .NET projects 2 | local project_scanner = require('dotnvim.utils.project_scanner') 3 | local path_utils = require('dotnvim.utils.path_utils') 4 | local validation = require('dotnvim.utils.validation') 5 | 6 | local M = {} 7 | 8 | -- Convert file path to namespace based on project structure 9 | -- @param file_path string: Full path to the file 10 | -- @param project_directory string: Root directory of the project 11 | -- @return string: Calculated namespace 12 | function M.get_namespace_from_path(file_path, project_directory) 13 | validation.assert_string(file_path, "file_path", false) 14 | validation.assert_string(project_directory, "project_directory", false) 15 | 16 | local namespace = string.gsub(file_path, project_directory, "") 17 | 18 | -- Convert path separators to dots 19 | namespace = string.gsub(namespace, "/", ".") 20 | namespace = string.gsub(namespace, "\\", ".") 21 | 22 | -- Clean up the namespace 23 | namespace = string.gsub(namespace, "^%.", "") -- Remove leading dot 24 | namespace = string.gsub(namespace, "%.$", "") -- Remove trailing dot 25 | 26 | return namespace 27 | end 28 | 29 | -- Get file information and calculated namespace for a given path 30 | -- @param file_path string: Path to analyze (optional, defaults to current file) 31 | -- @return table: {namespace: string, path: string, file_name: string} or nil on error 32 | function M.get_file_and_namespace(file_path) 33 | file_path = file_path or vim.fn.expand('%:p') 34 | 35 | local valid, err = validation.validate_non_empty_string(file_path, "file_path", false) 36 | if not valid then 37 | vim.notify(validation.validation_error(err, "get_file_and_namespace"), vim.log.levels.ERROR) 38 | return nil 39 | end 40 | 41 | -- Normalize path separators 42 | file_path = string.gsub(file_path, "\\", "/") 43 | 44 | -- Extract directory and file information 45 | local directory = string.match(file_path, "(.+/)[^/\\]+%..+$") 46 | local file_name = string.match(file_path, "[^/\\]+%..+$") 47 | 48 | if not directory or not file_name then 49 | vim.notify("Invalid file path format: " .. file_path, vim.log.levels.ERROR) 50 | return nil 51 | end 52 | 53 | local file_base_name = path_utils.get_last_path_part(file_name) 54 | if not file_base_name then 55 | vim.notify("Could not extract base name from: " .. file_name, vim.log.levels.ERROR) 56 | return nil 57 | end 58 | 59 | file_base_name = string.match(file_base_name, "[^%.]+") 60 | if not file_base_name then 61 | vim.notify("Could not extract file base name from: " .. file_name, vim.log.levels.ERROR) 62 | return nil 63 | end 64 | 65 | -- Find project files 66 | local project_files = project_scanner.find_project_files(directory) 67 | 68 | -- Calculate namespace based on project structure 69 | local namespace = '' 70 | if project_files.slnx then 71 | namespace = M.get_namespace_from_path(file_path, project_files.slnx.directory) 72 | elseif project_files.sln then 73 | namespace = M.get_namespace_from_path(file_path, project_files.sln.directory) 74 | elseif project_files.csproj then 75 | namespace = M.get_namespace_from_path(file_path, project_files.csproj.directory) 76 | end 77 | 78 | -- Clean up namespace - remove file name and extensions 79 | namespace = string.gsub(namespace, "%." .. file_base_name .. "%..*$", "") 80 | namespace = string.gsub(namespace, "^%.", "") 81 | namespace = string.gsub(namespace, "%.$", "") 82 | 83 | return { 84 | namespace = namespace, 85 | path = file_path, 86 | file_name = file_base_name, 87 | project_files = project_files 88 | } 89 | end 90 | 91 | -- Get file information and namespace for the current buffer 92 | -- @return table: {namespace: string, path: string, file_name: string} or nil on error 93 | function M.get_current_file_and_namespace() 94 | local current_path = vim.fn.expand('%:p') 95 | 96 | if current_path == "" then 97 | vim.notify("No file in current buffer", vim.log.levels.WARN) 98 | return nil 99 | end 100 | 101 | return M.get_file_and_namespace(current_path) 102 | end 103 | 104 | -- Validate namespace string 105 | -- @param namespace string: Namespace to validate 106 | -- @return boolean, string: True if valid, error message if invalid 107 | function M.validate_namespace(namespace) 108 | local valid, err = validation.validate_string(namespace, "namespace", false) 109 | if not valid then 110 | return false, err 111 | end 112 | 113 | if namespace == "" then 114 | return false, "Namespace cannot be empty" 115 | end 116 | 117 | -- Check for valid C# namespace format (simplified) 118 | if not namespace:match("^[%w%.]+$") then 119 | return false, "Namespace contains invalid characters - only letters, numbers, and dots allowed" 120 | end 121 | 122 | if namespace:match("^%.") or namespace:match("%.$") then 123 | return false, "Namespace cannot start or end with a dot" 124 | end 125 | 126 | if namespace:match("%.%.") then 127 | return false, "Namespace cannot contain consecutive dots" 128 | end 129 | 130 | return true, nil 131 | end 132 | 133 | -- Create a namespace from components 134 | -- @param components table: Array of namespace components 135 | -- @return string: Joined namespace 136 | function M.create_namespace(components) 137 | validation.assert_table(components, "components", false) 138 | 139 | local valid_components = {} 140 | for _, component in ipairs(components) do 141 | if validation.is_string(component) and component ~= "" then 142 | table.insert(valid_components, component) 143 | end 144 | end 145 | 146 | return table.concat(valid_components, ".") 147 | end 148 | 149 | -- Split namespace into components 150 | -- @param namespace string: Namespace to split 151 | -- @return table: Array of namespace components 152 | function M.split_namespace(namespace) 153 | validation.assert_string(namespace, "namespace", false) 154 | 155 | if namespace == "" then 156 | return {} 157 | end 158 | 159 | local components = {} 160 | for component in string.gmatch(namespace, "[^%.]+") do 161 | table.insert(components, component) 162 | end 163 | 164 | return components 165 | end 166 | 167 | return M -------------------------------------------------------------------------------- /lua/dotnvim/utils/validation.lua: -------------------------------------------------------------------------------- 1 | -- Validation utilities for type checking and parameter validation 2 | local M = {} 3 | 4 | -- Type checking functions 5 | function M.is_string(value) 6 | return type(value) == "string" 7 | end 8 | 9 | function M.is_number(value) 10 | return type(value) == "number" 11 | end 12 | 13 | function M.is_table(value) 14 | return type(value) == "table" 15 | end 16 | 17 | function M.is_function(value) 18 | return type(value) == "function" 19 | end 20 | 21 | function M.is_boolean(value) 22 | return type(value) == "boolean" 23 | end 24 | 25 | function M.is_nil(value) 26 | return value == nil 27 | end 28 | 29 | -- Enhanced validation with error messages 30 | function M.validate_string(value, name, allow_nil) 31 | if allow_nil and M.is_nil(value) then 32 | return true, nil 33 | end 34 | if not M.is_string(value) then 35 | return false, string.format("%s must be a string, got %s", name or "value", type(value)) 36 | end 37 | return true, nil 38 | end 39 | 40 | function M.validate_non_empty_string(value, name, allow_nil) 41 | if allow_nil and M.is_nil(value) then 42 | return true, nil 43 | end 44 | local valid, err = M.validate_string(value, name, false) 45 | if not valid then 46 | return false, err 47 | end 48 | if value == "" then 49 | return false, string.format("%s cannot be empty", name or "string") 50 | end 51 | return true, nil 52 | end 53 | 54 | function M.validate_number(value, name, allow_nil) 55 | if allow_nil and M.is_nil(value) then 56 | return true, nil 57 | end 58 | if not M.is_number(value) then 59 | return false, string.format("%s must be a number, got %s", name or "value", type(value)) 60 | end 61 | return true, nil 62 | end 63 | 64 | function M.validate_function(value, name, allow_nil) 65 | if allow_nil and M.is_nil(value) then 66 | return true, nil 67 | end 68 | if not M.is_function(value) then 69 | return false, string.format("%s must be a function, got %s", name or "value", type(value)) 70 | end 71 | return true, nil 72 | end 73 | 74 | function M.validate_table(value, name, allow_nil) 75 | if allow_nil and M.is_nil(value) then 76 | return true, nil 77 | end 78 | if not M.is_table(value) then 79 | return false, string.format("%s must be a table, got %s", name or "value", type(value)) 80 | end 81 | return true, nil 82 | end 83 | 84 | function M.validate_boolean(value, name, allow_nil) 85 | if allow_nil and M.is_nil(value) then 86 | return true, nil 87 | end 88 | if not M.is_boolean(value) then 89 | return false, string.format("%s must be a boolean, got %s", name or "value", type(value)) 90 | end 91 | return true, nil 92 | end 93 | 94 | -- File path validation 95 | function M.validate_file_path(path, name, must_exist) 96 | local valid, err = M.validate_non_empty_string(path, name or "file path", false) 97 | if not valid then 98 | return false, err 99 | end 100 | 101 | if must_exist then 102 | local stat = vim.loop.fs_stat(path) 103 | if not stat then 104 | return false, string.format("%s does not exist: %s", name or "file", path) 105 | end 106 | if stat.type ~= "file" then 107 | return false, string.format("%s is not a file: %s", name or "path", path) 108 | end 109 | end 110 | 111 | return true, nil 112 | end 113 | 114 | -- Directory path validation 115 | function M.validate_directory_path(path, name, must_exist) 116 | local valid, err = M.validate_non_empty_string(path, name or "directory path", false) 117 | if not valid then 118 | return false, err 119 | end 120 | 121 | if must_exist then 122 | local stat = vim.loop.fs_stat(path) 123 | if not stat then 124 | return false, string.format("%s does not exist: %s", name or "directory", path) 125 | end 126 | if stat.type ~= "directory" then 127 | return false, string.format("%s is not a directory: %s", name or "path", path) 128 | end 129 | end 130 | 131 | return true, nil 132 | end 133 | 134 | -- .csproj file validation 135 | function M.validate_csproj_path(path, name) 136 | local valid, err = M.validate_file_path(path, name or "csproj file", false) 137 | if not valid then 138 | return false, err 139 | end 140 | 141 | if not path:match("%.csproj$") then 142 | return false, string.format("%s must be a .csproj file: %s", name or "file", path) 143 | end 144 | 145 | return true, nil 146 | end 147 | 148 | -- Buffer validation 149 | function M.validate_buffer(bufnr, name) 150 | local valid, err = M.validate_number(bufnr, name or "buffer number", false) 151 | if not valid then 152 | return false, err 153 | end 154 | 155 | if not vim.api.nvim_buf_is_valid(bufnr) then 156 | return false, string.format("%s is not a valid buffer: %d", name or "buffer", bufnr) 157 | end 158 | 159 | return true, nil 160 | end 161 | 162 | -- Multiple parameter validation helper 163 | function M.validate_params(validations) 164 | for _, validation in ipairs(validations) do 165 | local value, validator_fn, name, allow_nil = validation[1], validation[2], validation[3], validation[4] 166 | local valid, err = validator_fn(value, name, allow_nil) 167 | if not valid then 168 | return false, err 169 | end 170 | end 171 | return true, nil 172 | end 173 | 174 | -- Create a validation error 175 | function M.validation_error(message, context) 176 | local full_message = message 177 | if context then 178 | full_message = string.format("[%s] %s", context, message) 179 | end 180 | return full_message 181 | end 182 | 183 | -- Assert-style validation that throws errors 184 | function M.assert_string(value, name, allow_nil) 185 | local valid, err = M.validate_string(value, name, allow_nil) 186 | if not valid then 187 | error(M.validation_error(err, "parameter validation")) 188 | end 189 | end 190 | 191 | function M.assert_function(value, name, allow_nil) 192 | local valid, err = M.validate_function(value, name, allow_nil) 193 | if not valid then 194 | error(M.validation_error(err, "parameter validation")) 195 | end 196 | end 197 | 198 | function M.assert_table(value, name, allow_nil) 199 | local valid, err = M.validate_table(value, name, allow_nil) 200 | if not valid then 201 | error(M.validation_error(err, "parameter validation")) 202 | end 203 | end 204 | 205 | function M.assert_csproj_path(path, name) 206 | local valid, err = M.validate_csproj_path(path, name) 207 | if not valid then 208 | error(M.validation_error(err, "parameter validation")) 209 | end 210 | end 211 | 212 | return M -------------------------------------------------------------------------------- /lua/dotnvim/utils/project_scanner.lua: -------------------------------------------------------------------------------- 1 | -- Project scanning utilities for .csproj, .sln files 2 | local scandir = require('plenary.scandir') 3 | local validation = require('dotnvim.utils.validation') 4 | 5 | local M = {} 6 | 7 | -- Scan for all .csproj files in the current working directory 8 | -- @return table: Array of {index, value} tables where value is the file path 9 | function M.get_all_csproj() 10 | local result = {} 11 | local path = vim.fn.getcwd() 12 | 13 | -- Validate current directory exists 14 | local valid, err = validation.validate_directory_path(path, "current working directory", true) 15 | if not valid then 16 | vim.notify(err, vim.log.levels.ERROR) 17 | return result 18 | end 19 | 20 | local cwd = string.gsub(path, "\\", "/") 21 | 22 | local ok, csproj_files = pcall(scandir.scan_dir, cwd, { 23 | hidden = false, -- Include hidden files (those starting with .) 24 | only_dirs = false, -- Include both files and directories 25 | depth = 5, -- Set the depth of search 26 | search_pattern = "%.csproj$" -- Lua pattern to match .csproj files 27 | }) 28 | 29 | if not ok then 30 | vim.notify("Failed to scan directory for .csproj files: " .. (csproj_files or "unknown error"), vim.log.levels.ERROR) 31 | return result 32 | end 33 | 34 | for index, value in ipairs(csproj_files) do 35 | table.insert(result, { 36 | index = index, 37 | value = value 38 | }) 39 | end 40 | 41 | return result 42 | end 43 | 44 | -- Find project files (.csproj, .sln, .slnx) starting from a given directory 45 | -- @param start_directory string: Directory to start searching from 46 | -- @return table: {csproj: {file, directory}, sln: {file, directory}, slnx: {file, directory}} 47 | function M.find_project_files(start_directory) 48 | local valid, err = validation.validate_non_empty_string(start_directory, "start_directory", false) 49 | if not valid then 50 | error(validation.validation_error(err, "find_project_files")) 51 | end 52 | 53 | local directory = string.gsub(start_directory, "\\", "/") 54 | if not directory:match("/$") then 55 | directory = directory .. "/" 56 | end 57 | 58 | local result = {} 59 | local search_depth = 2 60 | 61 | -- Build parent directories list for traversal 62 | local parents = {} 63 | for dir in string.gmatch(directory, "[^/\\]+") do 64 | table.insert(parents, dir .. '/') 65 | end 66 | table.insert(parents, "") 67 | 68 | -- Search up the directory tree 69 | local curr_directory = directory 70 | for i = #parents, 2, -1 do 71 | local directory_to_remove = parents[i] 72 | curr_directory = string.gsub(curr_directory, directory_to_remove, "") 73 | 74 | local ok, foundFiles = pcall(scandir.scan_dir, curr_directory, { depth = search_depth }) 75 | if ok then 76 | for _, file in pairs(foundFiles) do 77 | -- Look for .csproj files 78 | if result.csproj == nil and string.match(file, "%.csproj$") then 79 | result.csproj = { file = file, directory = curr_directory } 80 | end 81 | 82 | -- Look for solution files (prioritize .slnx over .sln) 83 | if result.sln == nil and result.slnx == nil then 84 | if string.match(file, "%.sln$") then 85 | result.sln = { file = file, directory = curr_directory } 86 | end 87 | if string.match(file, "%.slnx$") then 88 | result.slnx = { file = file, directory = curr_directory } 89 | end 90 | end 91 | end 92 | end 93 | 94 | -- Stop if we found everything we need 95 | if result.csproj and (result.sln or result.slnx) then 96 | break 97 | end 98 | end 99 | 100 | return result 101 | end 102 | 103 | -- Get DLL path from .csproj path 104 | -- @param csproj_path string: Path to the .csproj file 105 | -- @return string: Path to the compiled DLL 106 | function M.get_dll_from_csproj(csproj_path) 107 | validation.assert_csproj_path(csproj_path, "csproj_path") 108 | 109 | local project_name_with_extension = csproj_path:match("([^/\\]+%.csproj)$") 110 | if not project_name_with_extension then 111 | error(validation.validation_error("Invalid .csproj path format: " .. csproj_path, "get_dll_from_csproj")) 112 | end 113 | 114 | local project_name = project_name_with_extension:gsub("%.csproj$", "") 115 | local dll_name = project_name .. ".dll" 116 | local search_directory = csproj_path:gsub(project_name_with_extension, "") 117 | 118 | -- Validate search directory exists 119 | local valid, err = validation.validate_directory_path(search_directory, "project directory", true) 120 | if not valid then 121 | error(validation.validation_error(err, "get_dll_from_csproj")) 122 | end 123 | 124 | -- Scan for the .dll file in the project directory 125 | local ok, dlls = pcall(scandir.scan_dir, search_directory, { 126 | hidden = false, -- Include hidden files (those starting with .) 127 | only_dirs = false, -- Include both files and directories 128 | depth = 5, -- Set the depth of search 129 | search_pattern = dll_name, -- Lua pattern to match the .dll file 130 | }) 131 | 132 | if not ok then 133 | error(validation.validation_error("Failed to scan for DLL files: " .. (dlls or "unknown error"), "get_dll_from_csproj")) 134 | end 135 | 136 | if #dlls > 0 then 137 | return dlls[1] 138 | else 139 | error(validation.validation_error(csproj_path .. " has not been built yet - no DLL found", "get_dll_from_csproj")) 140 | end 141 | end 142 | 143 | -- Check if a file exists and is readable 144 | -- @param filepath string: Path to check 145 | -- @return boolean: True if file exists and is readable 146 | function M.file_exists(filepath) 147 | if not validation.is_string(filepath) or filepath == "" then 148 | return false 149 | end 150 | 151 | local stat = vim.loop.fs_stat(filepath) 152 | return stat ~= nil and stat.type == "file" 153 | end 154 | 155 | -- Get project root directory (where .sln or .slnx is located) 156 | -- @param start_path string: Path to start searching from (optional, defaults to current file) 157 | -- @return string|nil: Project root directory path, or nil if not found 158 | function M.get_project_root(start_path) 159 | start_path = start_path or vim.fn.expand('%:p:h') 160 | 161 | local valid, err = validation.validate_non_empty_string(start_path, "start_path", false) 162 | if not valid then 163 | vim.notify(validation.validation_error(err, "get_project_root"), vim.log.levels.WARN) 164 | return nil 165 | end 166 | 167 | local project_files = M.find_project_files(start_path) 168 | 169 | if project_files.slnx then 170 | return project_files.slnx.directory 171 | elseif project_files.sln then 172 | return project_files.sln.directory 173 | elseif project_files.csproj then 174 | return project_files.csproj.directory 175 | end 176 | 177 | return nil 178 | end 179 | 180 | return M -------------------------------------------------------------------------------- /lua/dotnvim/config_manager.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | -- Default configuration 4 | local default_config = { 5 | builders = { 6 | build_output_callback = nil, 7 | https_launch_setting_always = true, 8 | }, 9 | ui = { 10 | no_pretty_uis = false, 11 | }, 12 | dap = { 13 | adapter = { 14 | type = 'executable', 15 | command = "netcoredbg", 16 | args = { '--interpreter=vscode' }, 17 | }, 18 | configurations = {}, 19 | }, 20 | nuget = { 21 | sources = {}, 22 | authenticators = {}, 23 | }, 24 | tasks = { 25 | enabled = true, 26 | execution_mode = "dependency_aware", -- "sequential", "dependency_aware" 27 | output_window = { 28 | mode = "new_buffer", -- "horizontal_split", "vertical_split", "new_buffer" 29 | size = nil, -- Size of split (nil = auto, number = lines/columns) 30 | focus = false, -- Whether to focus the task output window 31 | }, 32 | dap_integration = { 33 | enabled = true, 34 | pre_debug_tasks = nil, -- Auto-discover or specify: {"pre-debug", "build"} 35 | block_on_failure = true, 36 | timeout_seconds = 300, 37 | } 38 | }, 39 | debug = { 40 | enabled = false, -- Enable debug logging 41 | log_file_path = nil, -- Custom log file path (nil uses default) 42 | } 43 | } 44 | 45 | -- Runtime state (separate from config) 46 | local state = { 47 | last_used_csproj = nil, 48 | running_job = nil, 49 | running_watch = nil, 50 | log = nil, -- Will be initialized with actual logger 51 | } 52 | 53 | -- Configuration validation schema 54 | local function validate_config(config) 55 | if type(config) ~= "table" then 56 | return false, "Config must be a table" 57 | end 58 | 59 | -- Validate builders section 60 | if config.builders then 61 | if type(config.builders) ~= "table" then 62 | return false, "builders must be a table" 63 | end 64 | if config.builders.https_launch_setting_always ~= nil and type(config.builders.https_launch_setting_always) ~= "boolean" then 65 | return false, "builders.https_launch_setting_always must be a boolean" 66 | end 67 | end 68 | 69 | -- Validate UI section 70 | if config.ui then 71 | if type(config.ui) ~= "table" then 72 | return false, "ui must be a table" 73 | end 74 | if config.ui.no_pretty_uis ~= nil and type(config.ui.no_pretty_uis) ~= "boolean" then 75 | return false, "ui.no_pretty_uis must be a boolean" 76 | end 77 | end 78 | 79 | -- Validate DAP section 80 | if config.dap then 81 | if type(config.dap) ~= "table" then 82 | return false, "dap must be a table" 83 | end 84 | if config.dap.adapter then 85 | if type(config.dap.adapter) ~= "table" then 86 | return false, "dap.adapter must be a table" 87 | end 88 | if config.dap.adapter.type and type(config.dap.adapter.type) ~= "string" then 89 | return false, "dap.adapter.type must be a string" 90 | end 91 | if config.dap.adapter.command and type(config.dap.adapter.command) ~= "string" then 92 | return false, "dap.adapter.command must be a string" 93 | end 94 | if config.dap.adapter.args and type(config.dap.adapter.args) ~= "table" then 95 | return false, "dap.adapter.args must be a table" 96 | end 97 | end 98 | if config.dap.configurations and type(config.dap.configurations) ~= "table" then 99 | return false, "dap.configurations must be a table" 100 | end 101 | end 102 | 103 | -- Validate NuGet section 104 | if config.nuget then 105 | if type(config.nuget) ~= "table" then 106 | return false, "nuget must be a table" 107 | end 108 | if config.nuget.sources and type(config.nuget.sources) ~= "table" then 109 | return false, "nuget.sources must be a table" 110 | end 111 | if config.nuget.authenticators then 112 | if type(config.nuget.authenticators) ~= "table" then 113 | return false, "nuget.authenticators must be a table" 114 | end 115 | for i, auth in ipairs(config.nuget.authenticators) do 116 | if type(auth) ~= "table" then 117 | return false, "nuget.authenticators[" .. i .. "] must be a table" 118 | end 119 | if auth.cmd and type(auth.cmd) ~= "string" then 120 | return false, "nuget.authenticators[" .. i .. "].cmd must be a string" 121 | end 122 | if auth.args and type(auth.args) ~= "table" then 123 | return false, "nuget.authenticators[" .. i .. "].args must be a table" 124 | end 125 | end 126 | end 127 | end 128 | 129 | return true, nil 130 | end 131 | 132 | -- Internal config storage 133 | local current_config = vim.deepcopy(default_config) 134 | 135 | -- Setup function to merge user config with defaults 136 | function M.setup(user_config) 137 | user_config = user_config or {} 138 | 139 | -- Validate user config 140 | local valid, error_msg = validate_config(user_config) 141 | if not valid then 142 | vim.notify("dotnvim config error: " .. error_msg, vim.log.levels.ERROR) 143 | return false 144 | end 145 | 146 | -- Deep merge user config with defaults 147 | current_config = vim.tbl_deep_extend("force", current_config, user_config) 148 | 149 | vim.notify("dotnvim configuration loaded successfully", vim.log.levels.INFO) 150 | return true 151 | end 152 | 153 | -- Getter functions for config sections 154 | function M.get_config() 155 | return vim.deepcopy(current_config) 156 | end 157 | 158 | function M.get_builders_config() 159 | return current_config.builders 160 | end 161 | 162 | function M.get_ui_config() 163 | return current_config.ui 164 | end 165 | 166 | function M.get_dap_config() 167 | return current_config.dap 168 | end 169 | 170 | function M.get_nuget_config() 171 | return current_config.nuget 172 | end 173 | 174 | function M.get_tasks_config() 175 | return current_config.tasks 176 | end 177 | 178 | function M.get_debug_config() 179 | return current_config.debug 180 | end 181 | 182 | -- State management functions 183 | function M.init_state(logger) 184 | state.log = { 185 | debug = function(message) logger.debug(message) end, 186 | info = function(message) logger.info(message) end, 187 | warn = function(message) logger.warn(message) end, 188 | error = function(message) logger.error(message) end, 189 | } 190 | end 191 | 192 | function M.get_state() 193 | return state 194 | end 195 | 196 | function M.set_last_used_csproj(path) 197 | state.last_used_csproj = path 198 | end 199 | 200 | function M.get_last_used_csproj() 201 | return state.last_used_csproj 202 | end 203 | 204 | function M.set_running_watch(job) 205 | state.running_watch = job 206 | end 207 | 208 | function M.get_running_watch() 209 | return state.running_watch 210 | end 211 | 212 | function M.get_logger() 213 | return state.log 214 | end 215 | 216 | -- Helper function to check if config is initialized 217 | function M.is_initialized() 218 | return state.log ~= nil 219 | end 220 | 221 | -- Reset to defaults (useful for testing) 222 | function M.reset_to_defaults() 223 | current_config = vim.deepcopy(default_config) 224 | state = { 225 | last_used_csproj = nil, 226 | running_job = nil, 227 | running_watch = nil, 228 | log = nil, 229 | } 230 | end 231 | 232 | return M 233 | -------------------------------------------------------------------------------- /lua/dotnvim/utils/ui_helpers.lua: -------------------------------------------------------------------------------- 1 | -- UI helper utilities for project selection and user interaction 2 | local telescope_utils = require('dotnvim.utils.telescope_utils') 3 | local config_manager = require('dotnvim.config_manager') 4 | local validation = require('dotnvim.utils.validation') 5 | 6 | local M = {} 7 | 8 | -- Select a .csproj file using available UI (Telescope or vim.ui.select) 9 | -- @param callback function: Function to call with selected file path 10 | -- @param options table: Optional configuration {prompt: string, allow_cancel: boolean} 11 | function M.select_csproj(callback, options) 12 | validation.assert_function(callback, "callback", false) 13 | options = options or {} 14 | 15 | local project_scanner = require('dotnvim.utils.project_scanner') 16 | local selections = project_scanner.get_all_csproj() 17 | 18 | if #selections == 0 then 19 | vim.notify("No .csproj files found in current directory tree", vim.log.levels.WARN) 20 | return 21 | end 22 | 23 | -- Try Telescope first if available and not disabled 24 | local ui_config = config_manager.get_ui_config() 25 | if pcall(require, 'telescope') and not ui_config.no_pretty_uis then 26 | telescope_utils.telescope_select_csproj(selections, callback) 27 | return 28 | end 29 | 30 | -- Fallback to vim.ui.select 31 | local items = {} 32 | for _, file in ipairs(selections) do 33 | table.insert(items, file.value) 34 | end 35 | 36 | local prompt = options.prompt or 'Select a .csproj file:' 37 | vim.ui.select(items, { 38 | prompt = prompt, 39 | format_item = function(item) 40 | -- Show relative path from current directory for better readability 41 | local cwd = vim.fn.getcwd() 42 | local relative = vim.fn.fnamemodify(item, ':.' ) 43 | return relative 44 | end 45 | }, function(choice) 46 | if choice then 47 | callback(choice) 48 | elseif not options.allow_cancel then 49 | vim.notify("No file selected", vim.log.levels.INFO) 50 | end 51 | end) 52 | end 53 | 54 | -- Display a simple text input prompt 55 | -- @param prompt string: Prompt message 56 | -- @param callback function: Function to call with input result 57 | -- @param options table: Optional {default: string, completion: string} 58 | function M.input_prompt(prompt, callback, options) 59 | validation.assert_string(prompt, "prompt", false) 60 | validation.assert_function(callback, "callback", false) 61 | options = options or {} 62 | 63 | vim.ui.input({ 64 | prompt = prompt, 65 | default = options.default, 66 | completion = options.completion 67 | }, function(input) 68 | if input and input ~= "" then 69 | callback(input) 70 | else 71 | vim.notify("Input cancelled or empty", vim.log.levels.INFO) 72 | end 73 | end) 74 | end 75 | 76 | -- Show a confirmation dialog 77 | -- @param message string: Message to display 78 | -- @param callback function: Function to call with boolean result 79 | -- @param options table: Optional {yes_text: string, no_text: string} 80 | function M.confirm(message, callback, options) 81 | validation.assert_string(message, "message", false) 82 | validation.assert_function(callback, "callback", false) 83 | options = options or {} 84 | 85 | local yes_text = options.yes_text or "Yes" 86 | local no_text = options.no_text or "No" 87 | 88 | vim.ui.select({yes_text, no_text}, { 89 | prompt = message, 90 | format_item = function(item) 91 | return item 92 | end 93 | }, function(choice) 94 | callback(choice == yes_text) 95 | end) 96 | end 97 | 98 | -- Display error message with consistent formatting 99 | -- @param message string: Error message 100 | -- @param context string: Optional context for the error 101 | function M.show_error(message, context) 102 | validation.assert_string(message, "message", false) 103 | 104 | local full_message = message 105 | if context then 106 | validation.assert_string(context, "context", false) 107 | full_message = string.format("[%s] %s", context, message) 108 | end 109 | 110 | vim.notify(full_message, vim.log.levels.ERROR) 111 | end 112 | 113 | -- Display warning message 114 | -- @param message string: Warning message 115 | -- @param context string: Optional context for the warning 116 | function M.show_warning(message, context) 117 | validation.assert_string(message, "message", false) 118 | 119 | local full_message = message 120 | if context then 121 | validation.assert_string(context, "context", false) 122 | full_message = string.format("[%s] %s", context, message) 123 | end 124 | 125 | vim.notify(full_message, vim.log.levels.WARN) 126 | end 127 | 128 | -- Display info message 129 | -- @param message string: Info message 130 | -- @param context string: Optional context for the message 131 | function M.show_info(message, context) 132 | validation.assert_string(message, "message", false) 133 | 134 | local full_message = message 135 | if context then 136 | validation.assert_string(context, "context", false) 137 | full_message = string.format("[%s] %s", context, message) 138 | end 139 | 140 | vim.notify(full_message, vim.log.levels.INFO) 141 | end 142 | 143 | -- Display success message 144 | -- @param message string: Success message 145 | -- @param context string: Optional context for the message 146 | function M.show_success(message, context) 147 | validation.assert_string(message, "message", false) 148 | 149 | local full_message = message 150 | if context then 151 | validation.assert_string(context, "context", false) 152 | full_message = string.format("[%s] %s", context, message) 153 | end 154 | 155 | -- Use INFO level for success messages since there's no SUCCESS level 156 | vim.notify("✓ " .. full_message, vim.log.levels.INFO) 157 | end 158 | 159 | -- Create a progress indicator (simple implementation) 160 | -- @param title string: Progress title 161 | -- @return table: Progress object with update and finish methods 162 | function M.create_progress(title) 163 | validation.assert_string(title, "title", false) 164 | 165 | local progress = { 166 | title = title, 167 | current = 0, 168 | total = 100 169 | } 170 | 171 | function progress:update(current, message) 172 | if validation.is_number(current) then 173 | self.current = current 174 | end 175 | 176 | local msg = string.format("[%s] %d%% %s", 177 | self.title, 178 | math.floor((self.current / self.total) * 100), 179 | message or "" 180 | ) 181 | vim.notify(msg, vim.log.levels.INFO) 182 | end 183 | 184 | function progress:finish(success, final_message) 185 | local status = success and "✓" or "✗" 186 | local level = success and vim.log.levels.INFO or vim.log.levels.ERROR 187 | local msg = string.format("%s [%s] %s", status, self.title, final_message or "Complete") 188 | vim.notify(msg, level) 189 | end 190 | 191 | -- Initial message 192 | vim.notify(string.format("[%s] Starting...", title), vim.log.levels.INFO) 193 | 194 | return progress 195 | end 196 | 197 | -- Show a list selection with custom formatting 198 | -- @param items table: Array of items to select from 199 | -- @param options table: {prompt: string, format_item: function} 200 | -- @param callback function: Function to call with selected item 201 | function M.select_from_list(items, options, callback) 202 | validation.assert_table(items, "items", false) 203 | validation.assert_table(options, "options", false) 204 | validation.assert_function(callback, "callback", false) 205 | 206 | if #items == 0 then 207 | M.show_warning("No items available for selection") 208 | return 209 | end 210 | 211 | vim.ui.select(items, { 212 | prompt = options.prompt or "Select an item:", 213 | format_item = options.format_item or function(item) 214 | return tostring(item) 215 | end 216 | }, function(choice) 217 | if choice then 218 | callback(choice) 219 | else 220 | M.show_info("No item selected") 221 | end 222 | end) 223 | end 224 | 225 | return M -------------------------------------------------------------------------------- /lua/dotnvim/utils/variables.lua: -------------------------------------------------------------------------------- 1 | -- Variable substitution utility for task configurations 2 | -- Supports VSCode-style variables like ${workspaceFolder} 3 | local validation = require('dotnvim.utils.validation') 4 | 5 | local M = {} 6 | 7 | -- Standard VSCode variables we support 8 | local SUPPORTED_VARIABLES = { 9 | 'workspaceFolder', 10 | 'workspaceFolderBasename', 11 | 'file', 12 | 'relativeFile', 13 | 'relativeFileDirname', 14 | 'fileBasename', 15 | 'fileBasenameNoExtension', 16 | 'fileDirname', 17 | 'fileExtname', 18 | 'cwd', 19 | 'lineNumber', 20 | 'selectedText', 21 | 'execPath', 22 | 'pathSeparator' 23 | } 24 | 25 | -- Get the current workspace folder (project root) 26 | -- @return string: Workspace folder path 27 | local function get_workspace_folder() 28 | -- Try to find git root first 29 | local git_root = vim.fn.system('git rev-parse --show-toplevel 2>/dev/null'):gsub('\n', '') 30 | if vim.v.shell_error == 0 and git_root ~= '' then 31 | return git_root 32 | end 33 | 34 | -- Fall back to current working directory 35 | return vim.fn.getcwd() 36 | end 37 | 38 | -- Get current file information 39 | -- @return table: File information for variable substitution 40 | local function get_file_info() 41 | local current_file = vim.api.nvim_buf_get_name(0) 42 | local workspace_folder = get_workspace_folder() 43 | 44 | if current_file == '' then 45 | -- No file open, use workspace folder 46 | current_file = workspace_folder 47 | end 48 | 49 | local file_dir = vim.fn.fnamemodify(current_file, ':h') 50 | local file_basename = vim.fn.fnamemodify(current_file, ':t') 51 | local file_basename_no_ext = vim.fn.fnamemodify(current_file, ':t:r') 52 | local file_ext = vim.fn.fnamemodify(current_file, ':e') 53 | 54 | -- Calculate relative paths 55 | local relative_file = vim.fn.fnamemodify(current_file, ':.' ) 56 | local relative_file_dir = vim.fn.fnamemodify(file_dir, ':.') 57 | 58 | return { 59 | file = current_file, 60 | file_dirname = file_dir, 61 | file_basename = file_basename, 62 | file_basename_no_extension = file_basename_no_ext, 63 | file_extension = file_ext, 64 | relative_file = relative_file, 65 | relative_file_dirname = relative_file_dir, 66 | workspace_folder = workspace_folder, 67 | workspace_folder_basename = vim.fn.fnamemodify(workspace_folder, ':t'), 68 | } 69 | end 70 | 71 | -- Build variable substitution map 72 | -- @param project_root string: Project root directory (optional) 73 | -- @return table: Map of variable names to values 74 | function M.build_variable_map(project_root) 75 | local workspace_folder = project_root or get_workspace_folder() 76 | local file_info = get_file_info() 77 | 78 | -- Override workspace folder if provided 79 | if project_root then 80 | file_info.workspace_folder = project_root 81 | file_info.workspace_folder_basename = vim.fn.fnamemodify(project_root, ':t') 82 | end 83 | 84 | local variables = { 85 | -- Workspace variables 86 | workspaceFolder = file_info.workspace_folder, 87 | workspaceFolderBasename = file_info.workspace_folder_basename, 88 | 89 | -- File variables 90 | file = file_info.file, 91 | relativeFile = file_info.relative_file, 92 | relativeFileDirname = file_info.relative_file_dirname, 93 | fileBasename = file_info.file_basename, 94 | fileBasenameNoExtension = file_info.file_basename_no_extension, 95 | fileDirname = file_info.file_dirname, 96 | fileExtname = file_info.file_extension, 97 | 98 | -- System variables 99 | cwd = vim.fn.getcwd(), 100 | pathSeparator = vim.loop.os_uname().sysname == 'Windows_NT' and '\\' or '/', 101 | 102 | -- Editor variables (may not always be available) 103 | lineNumber = tostring(vim.api.nvim_win_get_cursor(0)[1]), 104 | selectedText = '', -- TODO: Could implement if needed 105 | execPath = vim.v.progpath, 106 | } 107 | 108 | return variables 109 | end 110 | 111 | -- Substitute variables in a string 112 | -- @param text string: Text containing variables like ${workspaceFolder} 113 | -- @param variable_map table: Map of variable names to values 114 | -- @return string: Text with variables substituted 115 | function M.substitute_variables(text, variable_map) 116 | validation.assert_string(text, "text", false) 117 | validation.assert_table(variable_map, "variable_map", false) 118 | 119 | if not text:find('%${') then 120 | -- No variables to substitute 121 | return text 122 | end 123 | 124 | -- Replace ${variableName} with actual values 125 | local result = text:gsub('%${([^}]+)}', function(var_name) 126 | local value = variable_map[var_name] 127 | if value ~= nil then 128 | return tostring(value) 129 | else 130 | -- Variable not found, leave as-is and warn 131 | vim.notify("Unknown variable: ${" .. var_name .. "}", vim.log.levels.WARN) 132 | return "${" .. var_name .. "}" 133 | end 134 | end) 135 | 136 | return result 137 | end 138 | 139 | -- Substitute variables in a table recursively 140 | -- @param data table: Table that may contain variable strings 141 | -- @param variable_map table: Map of variable names to values 142 | -- @return table: Table with variables substituted 143 | function M.substitute_variables_in_table(data, variable_map) 144 | validation.assert_table(data, "data", false) 145 | validation.assert_table(variable_map, "variable_map", false) 146 | 147 | local function substitute_recursive(obj) 148 | if type(obj) == "string" then 149 | return M.substitute_variables(obj, variable_map) 150 | elseif type(obj) == "table" then 151 | local result = {} 152 | for k, v in pairs(obj) do 153 | local new_key = type(k) == "string" and M.substitute_variables(k, variable_map) or k 154 | result[new_key] = substitute_recursive(v) 155 | end 156 | return result 157 | else 158 | return obj 159 | end 160 | end 161 | 162 | return substitute_recursive(data) 163 | end 164 | 165 | -- Substitute variables in task configuration 166 | -- @param task_config table: Task configuration 167 | -- @param project_root string: Project root directory (optional) 168 | -- @return table: Task configuration with variables substituted 169 | function M.substitute_task_variables(task_config, project_root) 170 | validation.assert_table(task_config, "task_config", false) 171 | 172 | local variable_map = M.build_variable_map(project_root) 173 | return M.substitute_variables_in_table(task_config, variable_map) 174 | end 175 | 176 | -- Get list of supported variables for documentation/completion 177 | -- @return table: List of supported variable names 178 | function M.get_supported_variables() 179 | return vim.deepcopy(SUPPORTED_VARIABLES) 180 | end 181 | 182 | -- Validate that all variables in text are supported 183 | -- @param text string: Text to validate 184 | -- @return boolean, table: Valid status and list of unsupported variables 185 | function M.validate_variables(text) 186 | validation.assert_string(text, "text", false) 187 | 188 | local unsupported = {} 189 | 190 | text:gsub('%${([^}]+)}', function(var_name) 191 | if not vim.tbl_contains(SUPPORTED_VARIABLES, var_name) then 192 | table.insert(unsupported, var_name) 193 | end 194 | end) 195 | 196 | return #unsupported == 0, unsupported 197 | end 198 | 199 | -- Create example with variables for documentation 200 | -- @return string: Example task configuration showing variable usage 201 | function M.create_variable_example() 202 | return [[{ 203 | "version": "0.1.0", 204 | "tasks": [ 205 | { 206 | "name": "build", 207 | "command": "dotnet build ${workspaceFolder}/MyProject.csproj", 208 | "cwd": "${workspaceFolder}", 209 | "env": { 210 | "PROJECT_ROOT": "${workspaceFolder}", 211 | "OUTPUT_PATH": "${workspaceFolder}/bin/Debug" 212 | } 213 | }, 214 | { 215 | "name": "test", 216 | "command": "dotnet test ${workspaceFolder}/Tests/${fileBasenameNoExtension}.Tests.csproj", 217 | "cwd": "${fileDirname}", 218 | "env": { 219 | "TEST_FILE": "${file}" 220 | } 221 | } 222 | ] 223 | }]] 224 | end 225 | 226 | return M -------------------------------------------------------------------------------- /docs/norg/configuration.norg: -------------------------------------------------------------------------------- 1 | @document.meta 2 | title: Configuration Guide 3 | description: Complete configuration options for dotnvim 4 | authors: adamkali 5 | categories: configuration setup 6 | created: 2025-08-28 7 | updated: 2025-08-28 8 | version: 1.0.0 9 | @end 10 | 11 | * Configuration Guide 12 | 13 | ** Overview 14 | 15 | dotnvim provides extensive configuration options to customize your .NET development workflow. All configuration is done through the `setup()` function. 16 | 17 | ** Default Configuration 18 | 19 | @code lua 20 | require('dotnvim').setup({ 21 | builders = { 22 | build_output_callback = nil, 23 | https_launch_setting_always = true, 24 | }, 25 | ui = { 26 | no_pretty_uis = false, 27 | }, 28 | dap = { 29 | adapter = { 30 | type = 'executable', 31 | command = "netcoredbg", 32 | args = { '--interpreter=vscode' }, 33 | } 34 | }, 35 | nuget = { 36 | sources = {}, 37 | authenticators = { 38 | { 39 | cmd = "", 40 | args = {} 41 | } 42 | } 43 | }, 44 | tasks = { 45 | enabled = true, 46 | execution_mode = "dependency_aware", 47 | dap_integration = { 48 | enabled = true, 49 | pre_debug_tasks = nil, 50 | block_on_failure = true, 51 | timeout_seconds = 300, 52 | } 53 | } 54 | }) 55 | @end 56 | 57 | ** Configuration Sections 58 | 59 | *** Builders Configuration 60 | 61 | Controls build and watch behavior: 62 | 63 | @code lua 64 | builders = { 65 | -- Callback function for build output processing 66 | build_output_callback = function(output) 67 | -- Custom processing of build output 68 | print(output) 69 | end, 70 | 71 | -- Always use HTTPS in launch settings 72 | https_launch_setting_always = true, 73 | } 74 | @end 75 | 76 | **** Options: 77 | - `build_output_callback` (function): Custom callback for build output 78 | - `https_launch_setting_always` (boolean): Force HTTPS in launch settings 79 | 80 | *** UI Configuration 81 | 82 | Controls user interface behavior: 83 | 84 | @code lua 85 | ui = { 86 | -- Disable fancy UIs and use vim.ui.select instead 87 | no_pretty_uis = false, 88 | } 89 | @end 90 | 91 | **** Options: 92 | - `no_pretty_uis` (boolean): Use vim.ui.select instead of telescope/nui 93 | 94 | *** DAP Configuration 95 | 96 | Debug Adapter Protocol settings: 97 | 98 | @code lua 99 | dap = { 100 | adapter = { 101 | type = 'executable', 102 | command = "netcoredbg", 103 | args = { '--interpreter=vscode' }, 104 | } 105 | } 106 | @end 107 | 108 | **** Options: 109 | - `adapter.type` (string): DAP adapter type 110 | - `adapter.command` (string): Path to debugger executable 111 | - `adapter.args` (table): Arguments for debugger 112 | 113 | *** NuGet Configuration 114 | 115 | NuGet source and authentication settings: 116 | 117 | @code lua 118 | nuget = { 119 | sources = { 120 | { 121 | name = "MyPrivateFeed", 122 | url = "https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json" 123 | } 124 | }, 125 | authenticators = { 126 | { 127 | cmd = "az", 128 | args = { "artifacts", "universal", "login", "--organization", "myorg" } 129 | } 130 | } 131 | } 132 | @end 133 | 134 | **** Options: 135 | - `sources` (table): List of NuGet sources 136 | -- `name` (string): Source name 137 | -- `url` (string): Source URL 138 | - `authenticators` (table): Authentication commands 139 | -- `cmd` (string): Command to run 140 | -- `args` (table): Command arguments 141 | 142 | *** Tasks Configuration 143 | 144 | Task system configuration: 145 | 146 | @code lua 147 | tasks = { 148 | enabled = true, 149 | execution_mode = "dependency_aware", -- "sequential" | "dependency_aware" 150 | dap_integration = { 151 | enabled = true, 152 | pre_debug_tasks = {"build", "restore"}, -- Auto-discover if nil 153 | block_on_failure = true, 154 | timeout_seconds = 300, 155 | } 156 | } 157 | @end 158 | 159 | **** Options: 160 | - `enabled` (boolean): Enable task system 161 | - `execution_mode` (string): How to execute tasks 162 | -- `"sequential"`: Run tasks one by one 163 | -- `"dependency_aware"`: Respect task dependencies 164 | - `dap_integration.enabled` (boolean): Enable DAP integration 165 | - `dap_integration.pre_debug_tasks` (table): Tasks to run before debugging 166 | - `dap_integration.block_on_failure` (boolean): Stop on task failure 167 | - `dap_integration.timeout_seconds` (number): Task timeout 168 | 169 | ** Configuration Examples 170 | 171 | *** Minimal Configuration 172 | @code lua 173 | require('dotnvim').setup() 174 | @end 175 | 176 | *** Development Environment 177 | @code lua 178 | require('dotnvim').setup({ 179 | builders = { 180 | build_output_callback = function(output) 181 | -- Log build output to file 182 | local log_file = io.open(vim.fn.stdpath('data') .. '/dotnvim-build.log', 'a') 183 | log_file:write(output .. '\n') 184 | log_file:close() 185 | end, 186 | https_launch_setting_always = true, 187 | }, 188 | tasks = { 189 | enabled = true, 190 | execution_mode = "dependency_aware", 191 | } 192 | }) 193 | @end 194 | 195 | *** Enterprise Setup 196 | @code lua 197 | require('dotnvim').setup({ 198 | builders = { 199 | https_launch_setting_always = true, 200 | }, 201 | nuget = { 202 | sources = { 203 | { 204 | name = "CompanyFeed", 205 | url = "https://nuget.company.com/v3/index.json" 206 | } 207 | }, 208 | authenticators = { 209 | { 210 | cmd = "company-auth-tool", 211 | args = { "--login", "--service", "nuget" } 212 | } 213 | } 214 | }, 215 | tasks = { 216 | enabled = true, 217 | execution_mode = "dependency_aware", 218 | dap_integration = { 219 | enabled = true, 220 | pre_debug_tasks = {"restore", "build", "test"}, 221 | block_on_failure = true, 222 | timeout_seconds = 600, 223 | } 224 | } 225 | }) 226 | @end 227 | 228 | ** State Management 229 | 230 | dotnvim separates configuration from runtime state: 231 | 232 | *** Configuration Access 233 | @code lua 234 | local config_manager = require('dotnvim.config_manager') 235 | local builders_config = config_manager.get_builders_config() 236 | local ui_config = config_manager.get_ui_config() 237 | @end 238 | 239 | *** State Access 240 | @code lua 241 | local last_csproj = config_manager.get_last_csproj() 242 | local running_jobs = config_manager.get_running_jobs() 243 | @end 244 | 245 | ** Advanced Configuration 246 | 247 | *** Custom Build Output Processing 248 | @code lua 249 | builders = { 250 | build_output_callback = function(output) 251 | -- Parse build warnings/errors 252 | if output:match("warning") then 253 | vim.notify("Build warning: " .. output, vim.log.levels.WARN) 254 | elseif output:match("error") then 255 | vim.notify("Build error: " .. output, vim.log.levels.ERROR) 256 | end 257 | end 258 | } 259 | @end 260 | 261 | *** Multiple NuGet Sources 262 | @code lua 263 | nuget = { 264 | sources = { 265 | { 266 | name = "Azure DevOps", 267 | url = "https://pkgs.dev.azure.com/org/_packaging/feed/nuget/v3/index.json" 268 | }, 269 | { 270 | name = "Private Registry", 271 | url = "https://private-registry.company.com/nuget" 272 | } 273 | }, 274 | authenticators = { 275 | { 276 | cmd = "az", 277 | args = { "artifacts", "universal", "login" } 278 | }, 279 | { 280 | cmd = "nuget-auth", 281 | args = { "--source", "private-registry" } 282 | } 283 | } 284 | } 285 | @end 286 | 287 | ** Next Steps 288 | 289 | - {/ tasks} - Configure task workflows 290 | - {/ debugging} - Set up debugging 291 | - {/ commands} - Learn available commands -------------------------------------------------------------------------------- /lua/dotnvim/ui.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local config_manager = require('dotnvim.config_manager') 4 | 5 | -- Create a floating window with configuration details 6 | local function create_config_window(content, title) 7 | -- Calculate window size 8 | local width = math.max(80, vim.o.columns - 20) 9 | local height = math.max(20, vim.o.lines - 10) 10 | 11 | -- Calculate position to center the window 12 | local col = math.floor((vim.o.columns - width) / 2) 13 | local row = math.floor((vim.o.lines - height) / 2) 14 | 15 | -- Create buffer 16 | local buf = vim.api.nvim_create_buf(false, true) 17 | 18 | -- Set buffer content 19 | vim.api.nvim_buf_set_lines(buf, 0, -1, false, content) 20 | 21 | -- Set buffer options 22 | vim.api.nvim_buf_set_option(buf, 'modifiable', false) 23 | vim.api.nvim_buf_set_option(buf, 'filetype', 'lua') 24 | 25 | -- Window options 26 | local win_opts = { 27 | relative = 'editor', 28 | width = width, 29 | height = height, 30 | col = col, 31 | row = row, 32 | style = 'minimal', 33 | border = 'rounded', 34 | title = ' ' .. title .. ' ', 35 | title_pos = 'center', 36 | } 37 | 38 | -- Create window 39 | local win = vim.api.nvim_open_win(buf, true, win_opts) 40 | 41 | -- Set window options 42 | vim.api.nvim_win_set_option(win, 'wrap', false) 43 | vim.api.nvim_win_set_option(win, 'cursorline', true) 44 | 45 | -- Set keybindings 46 | local function close_window() 47 | if vim.api.nvim_win_is_valid(win) then 48 | vim.api.nvim_win_close(win, true) 49 | end 50 | end 51 | 52 | -- Key mappings to close the window 53 | local close_keys = {'q', '', ''} 54 | for _, key in ipairs(close_keys) do 55 | vim.keymap.set('n', key, close_window, { buffer = buf, nowait = true }) 56 | end 57 | 58 | return buf, win 59 | end 60 | 61 | -- Convert a table to a formatted string representation 62 | local function table_to_string(tbl, indent, max_depth, current_depth) 63 | indent = indent or 0 64 | max_depth = max_depth or 10 65 | current_depth = current_depth or 0 66 | 67 | if current_depth >= max_depth then 68 | return "{ ... }" 69 | end 70 | 71 | if type(tbl) ~= 'table' then 72 | if type(tbl) == 'string' then 73 | return '"' .. tbl .. '"' 74 | elseif type(tbl) == 'function' then 75 | return '' 76 | elseif tbl == nil then 77 | return 'nil' 78 | else 79 | return tostring(tbl) 80 | end 81 | end 82 | 83 | local result = {} 84 | local spacing = string.rep(' ', indent) 85 | 86 | -- Check if it's an array 87 | local is_array = true 88 | local array_length = 0 89 | for k, _ in pairs(tbl) do 90 | if type(k) ~= 'number' then 91 | is_array = false 92 | break 93 | end 94 | array_length = math.max(array_length, k) 95 | end 96 | 97 | if is_array and #tbl == array_length then 98 | table.insert(result, '{') 99 | for i, v in ipairs(tbl) do 100 | local value_str = table_to_string(v, indent + 1, max_depth, current_depth + 1) 101 | if i == #tbl then 102 | table.insert(result, spacing .. ' ' .. value_str) 103 | else 104 | table.insert(result, spacing .. ' ' .. value_str .. ',') 105 | end 106 | end 107 | table.insert(result, spacing .. '}') 108 | else 109 | table.insert(result, '{') 110 | local keys = {} 111 | for k, _ in pairs(tbl) do 112 | table.insert(keys, k) 113 | end 114 | table.sort(keys, function(a, b) 115 | if type(a) == type(b) then 116 | return tostring(a) < tostring(b) 117 | else 118 | return type(a) < type(b) 119 | end 120 | end) 121 | 122 | for i, k in ipairs(keys) do 123 | local v = tbl[k] 124 | local key_str = type(k) == 'string' and k or '[' .. tostring(k) .. ']' 125 | local value_str = table_to_string(v, indent + 1, max_depth, current_depth + 1) 126 | if i == #keys then 127 | table.insert(result, spacing .. ' ' .. key_str .. ' = ' .. value_str) 128 | else 129 | table.insert(result, spacing .. ' ' .. key_str .. ' = ' .. value_str .. ',') 130 | end 131 | end 132 | table.insert(result, spacing .. '}') 133 | end 134 | 135 | return table.concat(result, '\n') 136 | end 137 | 138 | -- Generate configuration overview content 139 | local function generate_config_content() 140 | local content = {} 141 | 142 | -- Header 143 | table.insert(content, '-- DotNvim Configuration Overview') 144 | table.insert(content, '-- Press q, , or to close this window') 145 | table.insert(content, '') 146 | 147 | -- Check if config is initialized 148 | if not config_manager.is_initialized() then 149 | table.insert(content, '⚠️ Configuration not initialized!') 150 | table.insert(content, 'Run :lua require("dotnvim").setup() first') 151 | return content 152 | end 153 | 154 | -- Get current configuration 155 | local config = config_manager.get_config() 156 | local state = config_manager.get_state() 157 | 158 | -- Status section 159 | table.insert(content, '📊 Status:') 160 | table.insert(content, ' ✅ Configuration initialized: ' .. (config_manager.is_initialized() and 'Yes' or 'No')) 161 | table.insert(content, ' 📁 Last used project: ' .. (state.last_used_csproj or 'None')) 162 | table.insert(content, ' 🔧 Running watch: ' .. (state.running_watch and 'Yes' or 'No')) 163 | table.insert(content, '') 164 | 165 | -- Builders configuration 166 | table.insert(content, '🔨 Builders Configuration:') 167 | local builders_str = table_to_string(config.builders, 0, 5) 168 | for line in builders_str:gmatch('[^\n]+') do 169 | table.insert(content, ' ' .. line) 170 | end 171 | table.insert(content, '') 172 | 173 | -- UI configuration 174 | table.insert(content, '🎨 UI Configuration:') 175 | local ui_str = table_to_string(config.ui, 0, 5) 176 | for line in ui_str:gmatch('[^\n]+') do 177 | table.insert(content, ' ' .. line) 178 | end 179 | table.insert(content, '') 180 | 181 | -- DAP configuration 182 | table.insert(content, '🐛 Debug Adapter Protocol (DAP) Configuration:') 183 | local dap_str = table_to_string(config.dap, 0, 5) 184 | for line in dap_str:gmatch('[^\n]+') do 185 | table.insert(content, ' ' .. line) 186 | end 187 | table.insert(content, '') 188 | 189 | -- NuGet configuration 190 | table.insert(content, '📦 NuGet Configuration:') 191 | local nuget_str = table_to_string(config.nuget, 0, 5) 192 | for line in nuget_str:gmatch('[^\n]+') do 193 | table.insert(content, ' ' .. line) 194 | end 195 | table.insert(content, '') 196 | 197 | -- Tasks configuration 198 | table.insert(content, '⚡ Tasks Configuration:') 199 | local tasks_str = table_to_string(config.tasks, 0, 5) 200 | for line in tasks_str:gmatch('[^\n]+') do 201 | table.insert(content, ' ' .. line) 202 | end 203 | table.insert(content, '') 204 | 205 | -- Debug configuration 206 | table.insert(content, '🔍 Debug Configuration:') 207 | local debug_str = table_to_string(config.debug, 0, 5) 208 | for line in debug_str:gmatch('[^\n]+') do 209 | table.insert(content, ' ' .. line) 210 | end 211 | table.insert(content, '') 212 | 213 | -- Footer with helpful information 214 | table.insert(content, '💡 Configuration Tips:') 215 | table.insert(content, ' • Use require("dotnvim").setup({...}) to customize settings') 216 | table.insert(content, ' • Check :help dotnvim for detailed documentation') 217 | table.insert(content, ' • Log file location: ' .. (vim.fn.stdpath('data') .. '/dotnvim.log')) 218 | 219 | return content 220 | end 221 | 222 | -- Show configuration in a floating window 223 | function M.show_config() 224 | local content = generate_config_content() 225 | create_config_window(content, 'DotNvim Configuration') 226 | end 227 | 228 | -- Show a specific configuration section 229 | function M.show_config_section(section) 230 | local content = {} 231 | 232 | if not config_manager.is_initialized() then 233 | table.insert(content, '⚠️ Configuration not initialized!') 234 | table.insert(content, 'Run :lua require("dotnvim").setup() first') 235 | create_config_window(content, 'DotNvim - Configuration Error') 236 | return 237 | end 238 | 239 | local config_func = config_manager['get_' .. section .. '_config'] 240 | if not config_func then 241 | table.insert(content, '❌ Unknown configuration section: ' .. section) 242 | table.insert(content, 'Available sections: builders, ui, dap, nuget, tasks, debug') 243 | create_config_window(content, 'DotNvim - Section Error') 244 | return 245 | end 246 | 247 | local section_config = config_func() 248 | 249 | table.insert(content, '-- DotNvim ' .. section:gsub("^%l", string.upper) .. ' Configuration') 250 | table.insert(content, '-- Press q, , or to close this window') 251 | table.insert(content, '') 252 | 253 | local config_str = table_to_string(section_config, 0, 8) 254 | for line in config_str:gmatch('[^\n]+') do 255 | table.insert(content, line) 256 | end 257 | 258 | create_config_window(content, 'DotNvim - ' .. section:gsub("^%l", string.upper) .. ' Config') 259 | end 260 | 261 | -- Create user commands for easy access 262 | local function setup_commands() 263 | vim.api.nvim_create_user_command('DotNvimConfig', function() 264 | M.show_config() 265 | end, { desc = 'Show DotNvim configuration overview' }) 266 | 267 | vim.api.nvim_create_user_command('DotNvimConfigSection', function(opts) 268 | if opts.args == '' then 269 | vim.notify('Please specify a section: builders, ui, dap, nuget, tasks, debug', vim.log.levels.WARN) 270 | return 271 | end 272 | M.show_config_section(opts.args) 273 | end, { 274 | desc = 'Show specific DotNvim configuration section', 275 | nargs = 1, 276 | complete = function() 277 | return { 'builders', 'ui', 'dap', 'nuget', 'tasks', 'debug' } 278 | end 279 | }) 280 | end 281 | 282 | -- Initialize commands when module is loaded 283 | setup_commands() 284 | 285 | return M -------------------------------------------------------------------------------- /lua/dotnvim/tasks/validation.lua: -------------------------------------------------------------------------------- 1 | -- Task configuration validation utilities 2 | local base_validation = require('dotnvim.utils.validation') 3 | 4 | local M = {} 5 | 6 | -- Task configuration schema validation 7 | local SUPPORTED_VERSIONS = {"0.1.0"} 8 | 9 | -- Validate a single task configuration 10 | -- @param task table: Task configuration 11 | -- @param index number: Task index for error reporting 12 | -- @return boolean, table: success status and list of errors 13 | function M.validate_task(task, index) 14 | local errors = {} 15 | local task_prefix = "tasks[" .. (index or "?") .. "]" 16 | 17 | -- Validate required fields 18 | local valid, err = base_validation.validate_non_empty_string(task.name, task_prefix .. ".name", false) 19 | if not valid then 20 | table.insert(errors, err) 21 | end 22 | 23 | valid, err = base_validation.validate_non_empty_string(task.command, task_prefix .. ".command", false) 24 | if not valid then 25 | table.insert(errors, err) 26 | end 27 | 28 | -- Validate optional fields 29 | if task.cwd then 30 | valid, err = base_validation.validate_string(task.cwd, task_prefix .. ".cwd", true) 31 | if not valid then 32 | table.insert(errors, err) 33 | end 34 | end 35 | 36 | -- Validate previous dependencies (array of strings) 37 | if task.previous then 38 | valid, err = base_validation.validate_table(task.previous, task_prefix .. ".previous", true) 39 | if not valid then 40 | table.insert(errors, err) 41 | else 42 | -- Validate each dependency name 43 | for i, dep in ipairs(task.previous) do 44 | valid, err = base_validation.validate_non_empty_string(dep, task_prefix .. ".previous[" .. i .. "]", false) 45 | if not valid then 46 | table.insert(errors, err) 47 | end 48 | end 49 | end 50 | end 51 | 52 | -- Validate environment variables 53 | if task.env then 54 | valid, err = base_validation.validate_table(task.env, task_prefix .. ".env", true) 55 | if not valid then 56 | table.insert(errors, err) 57 | else 58 | -- Validate env vars are key-value pairs 59 | for key, value in pairs(task.env) do 60 | if not base_validation.is_string(key) then 61 | table.insert(errors, task_prefix .. ".env key must be string, got " .. type(key)) 62 | end 63 | if not base_validation.is_string(value) then 64 | table.insert(errors, task_prefix .. ".env['" .. key .. "'] must be string, got " .. type(value)) 65 | end 66 | end 67 | end 68 | end 69 | 70 | -- Validate optional parallel flag 71 | if task.parallel ~= nil then 72 | valid, err = base_validation.validate_boolean(task.parallel, task_prefix .. ".parallel", true) 73 | if not valid then 74 | table.insert(errors, err) 75 | end 76 | end 77 | 78 | return #errors == 0, errors 79 | end 80 | 81 | -- Validate entire task configuration 82 | -- @param config table: Full task configuration 83 | -- @param source_file string: Source file path for error context 84 | -- @return boolean, table: success status and list of errors 85 | function M.validate_config(config, source_file) 86 | local errors = {} 87 | local file_context = source_file and (" in " .. source_file) or "" 88 | 89 | -- Validate root structure 90 | local valid, err = base_validation.validate_table(config, "config" .. file_context, false) 91 | if not valid then 92 | return false, {err} 93 | end 94 | 95 | -- Validate version 96 | valid, err = base_validation.validate_string(config.version, "version" .. file_context, false) 97 | if not valid then 98 | table.insert(errors, err) 99 | elseif not vim.tbl_contains(SUPPORTED_VERSIONS, config.version) then 100 | table.insert(errors, "Unsupported version '" .. config.version .. "'" .. file_context .. 101 | ". Supported versions: " .. table.concat(SUPPORTED_VERSIONS, ", ")) 102 | end 103 | 104 | -- Validate tasks array 105 | valid, err = base_validation.validate_table(config.tasks, "tasks" .. file_context, false) 106 | if not valid then 107 | table.insert(errors, err) 108 | return false, errors -- Can't continue without valid tasks array 109 | end 110 | 111 | if #config.tasks == 0 then 112 | table.insert(errors, "No tasks defined" .. file_context) 113 | return false, errors 114 | end 115 | 116 | -- Validate individual tasks 117 | local task_names = {} 118 | for i, task in ipairs(config.tasks) do 119 | local task_valid, task_errors = M.validate_task(task, i) 120 | if not task_valid then 121 | vim.list_extend(errors, task_errors) 122 | end 123 | 124 | -- Check for duplicate task names 125 | if task.name then 126 | if task_names[task.name] then 127 | table.insert(errors, "Duplicate task name '" .. task.name .. "'" .. file_context) 128 | else 129 | task_names[task.name] = true 130 | end 131 | end 132 | end 133 | 134 | return #errors == 0, errors 135 | end 136 | 137 | -- Validate task dependencies exist and detect cycles 138 | -- @param tasks table: Array of task configurations 139 | -- @return boolean, table: success status and list of errors 140 | function M.validate_dependencies(tasks) 141 | local errors = {} 142 | local task_map = {} 143 | 144 | -- Build task name map 145 | for _, task in ipairs(tasks) do 146 | if task.name then 147 | task_map[task.name] = task 148 | end 149 | end 150 | 151 | -- Validate dependencies exist 152 | for _, task in ipairs(tasks) do 153 | if task.previous then 154 | for _, dep_name in ipairs(task.previous) do 155 | if not task_map[dep_name] then 156 | table.insert(errors, "Task '" .. (task.name or "unknown") .. 157 | "' depends on unknown task '" .. dep_name .. "'") 158 | end 159 | end 160 | end 161 | end 162 | 163 | -- Detect circular dependencies using topological sort 164 | local cycle_errors = M.detect_cycles(tasks) 165 | vim.list_extend(errors, cycle_errors) 166 | 167 | return #errors == 0, errors 168 | end 169 | 170 | -- Detect circular dependencies in task graph 171 | -- @param tasks table: Array of task configurations 172 | -- @return table: List of cycle error messages 173 | function M.detect_cycles(tasks) 174 | local errors = {} 175 | local WHITE, GRAY, BLACK = 0, 1, 2 176 | local color = {} 177 | local task_map = {} 178 | 179 | -- Initialize 180 | for _, task in ipairs(tasks) do 181 | if task.name then 182 | color[task.name] = WHITE 183 | task_map[task.name] = task 184 | end 185 | end 186 | 187 | -- DFS to detect cycles 188 | local function dfs(task_name, path) 189 | if color[task_name] == GRAY then 190 | -- Found a cycle 191 | local cycle_start = nil 192 | for i, name in ipairs(path) do 193 | if name == task_name then 194 | cycle_start = i 195 | break 196 | end 197 | end 198 | 199 | local cycle_path = {} 200 | for i = cycle_start, #path do 201 | table.insert(cycle_path, path[i]) 202 | end 203 | table.insert(cycle_path, task_name) -- Complete the cycle 204 | 205 | table.insert(errors, "Circular dependency detected: " .. table.concat(cycle_path, " → ")) 206 | return 207 | end 208 | 209 | if color[task_name] == BLACK then 210 | return -- Already processed 211 | end 212 | 213 | color[task_name] = GRAY 214 | table.insert(path, task_name) 215 | 216 | local task = task_map[task_name] 217 | if task and task.previous then 218 | for _, dep_name in ipairs(task.previous) do 219 | if task_map[dep_name] then -- Only follow existing dependencies 220 | dfs(dep_name, path) 221 | end 222 | end 223 | end 224 | 225 | table.remove(path) -- Remove from current path 226 | color[task_name] = BLACK 227 | end 228 | 229 | -- Check each task 230 | for _, task in ipairs(tasks) do 231 | if task.name and color[task.name] == WHITE then 232 | dfs(task.name, {}) 233 | end 234 | end 235 | 236 | return errors 237 | end 238 | 239 | -- Normalize task configuration from different formats 240 | -- @param config table: Raw configuration from parser 241 | -- @param source_format string: Source format (json, toml, yaml) 242 | -- @return table: Normalized configuration 243 | function M.normalize_config(config, source_format) 244 | local normalized = vim.deepcopy(config) 245 | 246 | if source_format == 'toml' then 247 | normalized = M.normalize_toml_config(normalized) 248 | elseif source_format == 'yaml' then 249 | normalized = M.normalize_yaml_config(normalized) 250 | end 251 | 252 | -- Ensure all tasks have required defaults 253 | if normalized.tasks then 254 | for _, task in ipairs(normalized.tasks) do 255 | -- Default working directory 256 | task.cwd = task.cwd or "." 257 | 258 | -- Ensure previous is always an array (even if originally a string) 259 | if task.previous and type(task.previous) == "string" then 260 | task.previous = {task.previous} 261 | end 262 | 263 | -- Ensure env is a table 264 | task.env = task.env or {} 265 | end 266 | end 267 | 268 | return normalized 269 | end 270 | 271 | -- Normalize TOML-specific quirks 272 | -- @param config table: TOML configuration 273 | -- @return table: Normalized configuration 274 | function M.normalize_toml_config(config) 275 | -- TOML might represent arrays differently 276 | -- Handle any TOML-specific normalization here 277 | return config 278 | end 279 | 280 | -- Normalize YAML-specific quirks 281 | -- @param config table: YAML configuration 282 | -- @return table: Normalized configuration 283 | function M.normalize_yaml_config(config) 284 | -- YAML might have type conversion issues (strings vs numbers) 285 | -- Handle any YAML-specific normalization here 286 | return config 287 | end 288 | 289 | -- Create a validation summary for UI display 290 | -- @param success boolean: Validation success 291 | -- @param errors table: List of error messages 292 | -- @param source_file string: Source file path 293 | -- @return table: Formatted validation summary 294 | function M.create_validation_summary(success, errors, source_file) 295 | return { 296 | success = success, 297 | error_count = #errors, 298 | errors = errors, 299 | source_file = source_file, 300 | summary = success and "✓ Configuration valid" or ("✗ " .. #errors .. " validation errors") 301 | } 302 | end 303 | 304 | return M -------------------------------------------------------------------------------- /lua/dotnvim/utils/buffer_helpers.lua: -------------------------------------------------------------------------------- 1 | -- Buffer manipulation utilities 2 | local validation = require('dotnvim.utils.validation') 3 | 4 | local M = {} 5 | 6 | -- Append lines to a buffer with validation 7 | -- @param bufnr number: Buffer number 8 | -- @param lines table: Array of lines to append 9 | -- @return boolean: Success status 10 | function M.append_to_buffer(bufnr, lines) 11 | local valid, err = validation.validate_buffer(bufnr, "buffer number") 12 | if not valid then 13 | vim.notify(validation.validation_error(err, "append_to_buffer"), vim.log.levels.ERROR) 14 | return false 15 | end 16 | 17 | local lines_valid, lines_err = validation.validate_table(lines, "lines", false) 18 | if not lines_valid then 19 | vim.notify(validation.validation_error(lines_err, "append_to_buffer"), vim.log.levels.ERROR) 20 | return false 21 | end 22 | 23 | -- Validate that lines is an array of strings 24 | for i, line in ipairs(lines) do 25 | if not validation.is_string(line) then 26 | vim.notify(validation.validation_error("lines[" .. i .. "] must be a string, got " .. type(line), "append_to_buffer"), vim.log.levels.ERROR) 27 | return false 28 | end 29 | end 30 | 31 | local ok, line_count = pcall(vim.api.nvim_buf_line_count, bufnr) 32 | if not ok then 33 | vim.notify(validation.validation_error("Failed to get buffer line count: " .. line_count, "append_to_buffer"), vim.log.levels.ERROR) 34 | return false 35 | end 36 | 37 | -- Insert lines at the end of the buffer 38 | ok, err = pcall(vim.api.nvim_buf_set_lines, bufnr, line_count, line_count, false, lines) 39 | if not ok then 40 | vim.notify(validation.validation_error("Failed to append lines to buffer: " .. err, "append_to_buffer"), vim.log.levels.ERROR) 41 | return false 42 | end 43 | 44 | -- Auto-scroll to end if buffer is visible 45 | M.scroll_to_end(bufnr) 46 | 47 | return true 48 | end 49 | 50 | -- Scroll buffer to end if it's visible in any window 51 | -- @param bufnr number: Buffer number 52 | function M.scroll_to_end(bufnr) 53 | local valid, err = validation.validate_buffer(bufnr, "buffer number") 54 | if not valid then 55 | return 56 | end 57 | 58 | local windows = vim.fn.win_findbuf(bufnr) 59 | if #windows > 0 then 60 | local win = windows[1] 61 | 62 | -- Move cursor to the end of the buffer safely 63 | local ok, last_line = pcall(vim.api.nvim_buf_line_count, bufnr) 64 | if ok and last_line > 0 then 65 | pcall(vim.api.nvim_win_set_cursor, win, { last_line, 0 }) 66 | end 67 | end 68 | end 69 | 70 | -- Create a new scratch buffer with specified options 71 | -- @param name string: Buffer name 72 | -- @param options table: Buffer options {filetype: string, modifiable: boolean, buftype: string} 73 | -- @return number|nil: Buffer number or nil on error 74 | function M.create_scratch_buffer(name, options) 75 | local name_valid, name_err = validation.validate_non_empty_string(name, "buffer name", false) 76 | if not name_valid then 77 | vim.notify(validation.validation_error(name_err, "create_scratch_buffer"), vim.log.levels.ERROR) 78 | return nil 79 | end 80 | 81 | options = options or {} 82 | 83 | local ok, bufnr = pcall(vim.api.nvim_create_buf, false, true) 84 | if not ok then 85 | vim.notify(validation.validation_error("Failed to create buffer: " .. bufnr, "create_scratch_buffer"), vim.log.levels.ERROR) 86 | return nil 87 | end 88 | 89 | -- Set buffer name 90 | local timestamp = os.date("%Y-%m-%d-%H-%M-%S") 91 | local full_name = timestamp .. "-" .. name 92 | 93 | ok, err = pcall(vim.api.nvim_buf_set_name, bufnr, full_name) 94 | if not ok then 95 | vim.notify(validation.validation_error("Failed to set buffer name: " .. err, "create_scratch_buffer"), vim.log.levels.WARN) 96 | end 97 | 98 | -- Set buffer options 99 | local buffer_options = { 100 | buftype = options.buftype or 'nofile', 101 | bufhidden = options.bufhidden or 'hide', 102 | swapfile = options.swapfile or false, 103 | modifiable = options.modifiable or true, 104 | filetype = options.filetype or 'text' 105 | } 106 | 107 | for opt, value in pairs(buffer_options) do 108 | vim.bo[bufnr][opt] = value 109 | end 110 | 111 | return bufnr 112 | end 113 | 114 | -- Create a log buffer in a split window based on configuration 115 | -- @param log_name string: Name of the log (e.g., "build", "watch") 116 | -- @param split_config table: Split configuration {mode, size, focus} 117 | -- @return number|nil: Buffer number or nil on error 118 | function M.create_log_buffer_split(log_name, split_config) 119 | local valid, err = validation.validate_non_empty_string(log_name, "log_name", false) 120 | if not valid then 121 | vim.notify(validation.validation_error(err, "create_log_buffer_split"), vim.log.levels.ERROR) 122 | return nil 123 | end 124 | 125 | split_config = split_config or {} 126 | local mode = split_config.mode or "new_buffer" 127 | local size = split_config.size 128 | local focus = split_config.focus ~= false -- Default to true if not specified 129 | 130 | -- Create the buffer first 131 | local bufnr = M.create_scratch_buffer(log_name .. ".log", { 132 | filetype = 'log', 133 | modifiable = true, 134 | buftype = 'nofile', 135 | bufhidden = 'hide' 136 | }) 137 | 138 | if not bufnr then 139 | return nil 140 | end 141 | 142 | -- Handle different window modes 143 | if mode == "horizontal_split" then 144 | -- Create horizontal split 145 | if size then 146 | vim.cmd(size .. "split") 147 | else 148 | vim.cmd("split") 149 | end 150 | vim.api.nvim_win_set_buf(0, bufnr) 151 | elseif mode == "vertical_split" then 152 | -- Create vertical split 153 | if size then 154 | vim.cmd(size .. "vsplit") 155 | else 156 | vim.cmd("vsplit") 157 | end 158 | vim.api.nvim_win_set_buf(0, bufnr) 159 | elseif mode == "new_buffer" then 160 | -- Switch to buffer in current window (original behavior) 161 | vim.api.nvim_set_current_buf(bufnr) 162 | else 163 | vim.notify("Invalid split mode: " .. mode .. ". Using new_buffer mode.", vim.log.levels.WARN) 164 | vim.api.nvim_set_current_buf(bufnr) 165 | end 166 | 167 | -- Handle focus setting 168 | if not focus then 169 | -- Switch back to the previous window if we don't want focus 170 | vim.cmd("wincmd p") 171 | end 172 | 173 | return bufnr 174 | end 175 | 176 | -- Create a log buffer for build/watch output 177 | -- @param log_name string: Name of the log (e.g., "build", "watch") 178 | -- @return number|nil: Buffer number or nil on error 179 | function M.create_log_buffer(log_name) 180 | local valid, err = validation.validate_non_empty_string(log_name, "log_name", false) 181 | if not valid then 182 | vim.notify(validation.validation_error(err, "create_log_buffer"), vim.log.levels.ERROR) 183 | return nil 184 | end 185 | 186 | local bufnr = M.create_scratch_buffer(log_name .. ".log", { 187 | filetype = 'log', 188 | modifiable = true, 189 | buftype = 'nofile', 190 | bufhidden = 'hide' 191 | }) 192 | 193 | if bufnr then 194 | -- Switch to the log buffer to show it 195 | vim.api.nvim_set_current_buf(bufnr) 196 | end 197 | 198 | return bufnr 199 | end 200 | 201 | -- Clear buffer contents 202 | -- @param bufnr number: Buffer number 203 | -- @return boolean: Success status 204 | function M.clear_buffer(bufnr) 205 | local valid, err = validation.validate_buffer(bufnr, "buffer number") 206 | if not valid then 207 | vim.notify(validation.validation_error(err, "clear_buffer"), vim.log.levels.ERROR) 208 | return false 209 | end 210 | 211 | local ok, err_msg = pcall(vim.api.nvim_buf_set_lines, bufnr, 0, -1, false, {}) 212 | if not ok then 213 | vim.notify(validation.validation_error("Failed to clear buffer: " .. err_msg, "clear_buffer"), vim.log.levels.ERROR) 214 | return false 215 | end 216 | 217 | return true 218 | end 219 | 220 | -- Write content to buffer, replacing existing content 221 | -- @param bufnr number: Buffer number 222 | -- @param lines table: Array of lines to write 223 | -- @return boolean: Success status 224 | function M.write_to_buffer(bufnr, lines) 225 | local valid, err = validation.validate_buffer(bufnr, "buffer number") 226 | if not valid then 227 | vim.notify(validation.validation_error(err, "write_to_buffer"), vim.log.levels.ERROR) 228 | return false 229 | end 230 | 231 | local lines_valid, lines_err = validation.validate_table(lines, "lines", false) 232 | if not lines_valid then 233 | vim.notify(validation.validation_error(lines_err, "write_to_buffer"), vim.log.levels.ERROR) 234 | return false 235 | end 236 | 237 | -- Clear buffer first 238 | if not M.clear_buffer(bufnr) then 239 | return false 240 | end 241 | 242 | -- Write new content 243 | return M.append_to_buffer(bufnr, lines) 244 | end 245 | 246 | -- Get buffer content as array of lines 247 | -- @param bufnr number: Buffer number 248 | -- @return table|nil: Array of lines or nil on error 249 | function M.get_buffer_lines(bufnr) 250 | local valid, err = validation.validate_buffer(bufnr, "buffer number") 251 | if not valid then 252 | vim.notify(validation.validation_error(err, "get_buffer_lines"), vim.log.levels.ERROR) 253 | return nil 254 | end 255 | 256 | local ok, lines = pcall(vim.api.nvim_buf_get_lines, bufnr, 0, -1, false) 257 | if not ok then 258 | vim.notify(validation.validation_error("Failed to get buffer lines: " .. lines, "get_buffer_lines"), vim.log.levels.ERROR) 259 | return nil 260 | end 261 | 262 | return lines 263 | end 264 | 265 | -- Check if buffer is visible in any window 266 | -- @param bufnr number: Buffer number 267 | -- @return boolean: True if buffer is visible 268 | function M.is_buffer_visible(bufnr) 269 | local valid, err = validation.validate_buffer(bufnr, "buffer number") 270 | if not valid then 271 | return false 272 | end 273 | 274 | local windows = vim.fn.win_findbuf(bufnr) 275 | return #windows > 0 276 | end 277 | 278 | -- Focus on buffer if it's visible, otherwise open it in current window 279 | -- @param bufnr number: Buffer number 280 | -- @return boolean: Success status 281 | function M.focus_buffer(bufnr) 282 | local valid, err = validation.validate_buffer(bufnr, "buffer number") 283 | if not valid then 284 | vim.notify(validation.validation_error(err, "focus_buffer"), vim.log.levels.ERROR) 285 | return false 286 | end 287 | 288 | local windows = vim.fn.win_findbuf(bufnr) 289 | if #windows > 0 then 290 | -- Buffer is visible, focus on first window showing it 291 | local ok, err_msg = pcall(vim.api.nvim_set_current_win, windows[1]) 292 | if not ok then 293 | vim.notify(validation.validation_error("Failed to focus window: " .. err_msg, "focus_buffer"), vim.log.levels.ERROR) 294 | return false 295 | end 296 | else 297 | -- Buffer not visible, open in current window 298 | local ok, err_msg = pcall(vim.api.nvim_set_current_buf, bufnr) 299 | if not ok then 300 | vim.notify(validation.validation_error("Failed to switch to buffer: " .. err_msg, "focus_buffer"), vim.log.levels.ERROR) 301 | return false 302 | end 303 | end 304 | 305 | return true 306 | end 307 | 308 | return M -------------------------------------------------------------------------------- /lua/dotnvim/tasks/parsers.lua: -------------------------------------------------------------------------------- 1 | -- Multi-format task configuration parsers 2 | local validation = require('dotnvim.utils.validation') 3 | local task_validation = require('dotnvim.tasks.validation') 4 | local variables = require('dotnvim.utils.variables') 5 | 6 | local M = {} 7 | 8 | -- Parser registry for different formats 9 | local parsers = {} 10 | 11 | -- JSON Parser 12 | parsers.json = { 13 | extensions = {'.json'}, 14 | parse = function(content, file_path) 15 | local ok, result = pcall(vim.json.decode, content) 16 | if not ok then 17 | return nil, "JSON parse error: " .. result 18 | end 19 | return result, nil 20 | end 21 | } 22 | 23 | -- Basic TOML Parser (simplified implementation) 24 | parsers.toml = { 25 | extensions = {'.toml'}, 26 | parse = function(content, file_path) 27 | -- Try external TOML parser first if available 28 | local ok, toml_parser = pcall(require, 'toml') 29 | if ok and toml_parser.parse then 30 | local success, result = pcall(toml_parser.parse, content) 31 | if success then 32 | return result, nil 33 | end 34 | end 35 | 36 | -- Fallback to basic TOML parser for simple structures 37 | return M.parse_basic_toml(content) 38 | end 39 | } 40 | 41 | -- Basic YAML Parser (simplified implementation) 42 | parsers.yaml = { 43 | extensions = {'.yaml', '.yml'}, 44 | parse = function(content, file_path) 45 | -- Try external YAML parser first if available 46 | local ok, yaml_parser = pcall(require, 'yaml') 47 | if ok and yaml_parser.load then 48 | local success, result = pcall(yaml_parser.load, content) 49 | if success then 50 | return result, nil 51 | end 52 | end 53 | 54 | -- Try lyaml parser 55 | ok, yaml_parser = pcall(require, 'lyaml') 56 | if ok and yaml_parser.load then 57 | local success, result = pcall(yaml_parser.load, content) 58 | if success then 59 | return result, nil 60 | end 61 | end 62 | 63 | -- Fallback to basic YAML parser for simple structures 64 | return M.parse_basic_yaml(content) 65 | end 66 | } 67 | 68 | -- Detect format from file extension 69 | -- @param file_path string: Path to the configuration file 70 | -- @return string|nil: Format name or nil if unknown 71 | function M.detect_format(file_path) 72 | validation.assert_string(file_path, "file_path", false) 73 | 74 | local lower_path = file_path:lower() 75 | 76 | for format_name, parser in pairs(parsers) do 77 | for _, ext in ipairs(parser.extensions) do 78 | if lower_path:match(ext .. "$") then 79 | return format_name 80 | end 81 | end 82 | end 83 | 84 | return nil 85 | end 86 | 87 | -- Parse task configuration file 88 | -- @param file_path string: Path to the configuration file 89 | -- @return table|nil, string|nil: Parsed configuration or nil, error message 90 | function M.parse_task_file(file_path) 91 | validation.assert_string(file_path, "file_path", false) 92 | 93 | -- Check if file exists 94 | local stat = vim.loop.fs_stat(file_path) 95 | if not stat or stat.type ~= "file" then 96 | return nil, "File does not exist: " .. file_path 97 | end 98 | 99 | -- Read file content 100 | local fd = vim.loop.fs_open(file_path, "r", 438) 101 | if not fd then 102 | return nil, "Could not open file: " .. file_path 103 | end 104 | 105 | local content = vim.loop.fs_read(fd, stat.size, 0) 106 | vim.loop.fs_close(fd) 107 | 108 | if not content then 109 | return nil, "Could not read file: " .. file_path 110 | end 111 | 112 | -- Detect format 113 | local format = M.detect_format(file_path) 114 | if not format then 115 | return nil, "Unknown file format: " .. file_path 116 | end 117 | 118 | -- Parse content 119 | local parser = parsers[format] 120 | if not parser then 121 | return nil, "No parser available for format: " .. format 122 | end 123 | 124 | local config, parse_err = parser.parse(content, file_path) 125 | if not config then 126 | return nil, parse_err or ("Failed to parse " .. format .. " file") 127 | end 128 | 129 | -- Normalize configuration 130 | local normalized = task_validation.normalize_config(config, format) 131 | 132 | -- Apply variable substitution using project root from file path 133 | local project_root = vim.fn.fnamemodify(file_path, ':h:h') -- Go up from .nvim/tasks.json to project root 134 | -- Handle .vscode case 135 | if vim.fn.fnamemodify(file_path, ':h:t') == '.vscode' then 136 | project_root = vim.fn.fnamemodify(file_path, ':h:h') 137 | elseif vim.fn.fnamemodify(file_path, ':h:t') == '.nvim' then 138 | project_root = vim.fn.fnamemodify(file_path, ':h:h') 139 | else 140 | -- Fallback to file directory if not in expected structure 141 | project_root = vim.fn.fnamemodify(file_path, ':h') 142 | end 143 | 144 | normalized = variables.substitute_task_variables(normalized, project_root) 145 | 146 | -- Validate configuration (after variable substitution) 147 | local valid, errors = task_validation.validate_config(normalized, file_path) 148 | 149 | if not valid then 150 | local error_strings = {} 151 | for i, error in ipairs(errors) do 152 | error_strings[i] = type(error) == "string" and error or vim.inspect(error) 153 | end 154 | return nil, "Validation errors:\\n" .. table.concat(error_strings, "\\n") 155 | end 156 | 157 | -- Validate dependencies 158 | local deps_valid, dep_errors = task_validation.validate_dependencies(normalized.tasks) 159 | if not deps_valid then 160 | local error_strings = {} 161 | for i, error in ipairs(dep_errors) do 162 | error_strings[i] = type(error) == "string" and error or vim.inspect(error) 163 | end 164 | return nil, "Dependency errors:\\n" .. table.concat(error_strings, "\\n") 165 | end 166 | 167 | return normalized, nil 168 | end 169 | 170 | -- Basic TOML parser for simple task configurations 171 | -- @param content string: TOML content 172 | -- @return table|nil, string|nil: Parsed content or nil, error message 173 | function M.parse_basic_toml(content) 174 | local config = {tasks = {}} 175 | local current_task = nil 176 | local in_env_section = false 177 | 178 | for line in content:gmatch("[^\\r\\n]+") do 179 | line = line:match("^%s*(.-)%s*$") -- Trim whitespace 180 | 181 | if line == "" or line:match("^#") then 182 | -- Skip empty lines and comments 183 | elseif line:match("^version%s*=") then 184 | config.version = line:match('version%s*=%s*["\']([^"\']+)["\']') 185 | elseif line:match("^%[%[tasks%]%]") then 186 | -- New task section 187 | current_task = {} 188 | table.insert(config.tasks, current_task) 189 | in_env_section = false 190 | elseif line:match("^%[tasks%.env%]") then 191 | -- Environment section for current task 192 | in_env_section = true 193 | if current_task then 194 | current_task.env = current_task.env or {} 195 | end 196 | elseif current_task and not in_env_section then 197 | -- Task property 198 | local key, value = line:match("^(%w+)%s*=%s*(.+)") 199 | if key and value then 200 | if key == "previous" then 201 | -- Handle array format: previous = ["dep1", "dep2"] 202 | local array_content = value:match("%[(.*)%]") 203 | if array_content then 204 | current_task.previous = {} 205 | for item in array_content:gmatch('["\']([^"\']+)["\']') do 206 | table.insert(current_task.previous, item) 207 | end 208 | end 209 | else 210 | -- Remove quotes from string values 211 | current_task[key] = value:match('["\']([^"\']+)["\']') or value 212 | end 213 | end 214 | elseif current_task and in_env_section then 215 | -- Environment variable 216 | local key, value = line:match("^(%w+)%s*=%s*(.+)") 217 | if key and value then 218 | current_task.env[key] = value:match('["\']([^"\']+)["\']') or value 219 | end 220 | end 221 | end 222 | 223 | if not config.version then 224 | return nil, "Missing version in TOML configuration" 225 | end 226 | 227 | return config, nil 228 | end 229 | 230 | -- Basic YAML parser for simple task configurations 231 | -- @param content string: YAML content 232 | -- @return table|nil, string|nil: Parsed content or nil, error message 233 | function M.parse_basic_yaml(content) 234 | local config = {} 235 | local lines = vim.split(content, "\\n") 236 | local current_task = nil 237 | local indent_stack = {} 238 | 239 | for _, line in ipairs(lines) do 240 | local trimmed = line:match("^%s*(.-)%s*$") 241 | 242 | if trimmed == "" or trimmed:match("^#") then 243 | -- Skip empty lines and comments 244 | else 245 | local indent = #line - #line:match("^%s*(.*)"):match("^(%S.*)") 246 | local key, value = trimmed:match("^([^:]+):%s*(.*)") 247 | 248 | if key then 249 | key = key:match("^%s*(.-)%s*$") -- Trim key 250 | value = value:match("^%s*(.-)%s*$") -- Trim value 251 | 252 | if key == "version" then 253 | config.version = value:match('["\']([^"\']+)["\']') or value 254 | elseif key == "tasks" then 255 | config.tasks = {} 256 | elseif trimmed:match("^-%s*name:") then 257 | -- New task item 258 | current_task = {} 259 | table.insert(config.tasks, current_task) 260 | current_task.name = value:match('["\']([^"\']+)["\']') or value 261 | elseif current_task then 262 | if key == "previous" then 263 | -- Handle array format 264 | if value:match("^%[.*%]$") then 265 | -- Inline array: [item1, item2] 266 | current_task.previous = {} 267 | for item in value:gmatch('["\']?([^",\'%]]+)["\']?') do 268 | item = item:match("^%s*(.-)%s*$") 269 | if item ~= "" then 270 | table.insert(current_task.previous, item) 271 | end 272 | end 273 | end 274 | elseif key == "env" then 275 | current_task.env = {} 276 | else 277 | -- Regular property 278 | current_task[key] = value:match('["\']([^"\']+)["\']') or value 279 | end 280 | end 281 | elseif current_task and current_task.env and trimmed:match("^%w+:") then 282 | -- Environment variable 283 | local env_key, env_value = trimmed:match("^(%w+):%s*(.*)") 284 | if env_key and env_value then 285 | current_task.env[env_key] = env_value:match('["\']([^"\']+)["\']') or env_value 286 | end 287 | end 288 | end 289 | end 290 | 291 | if not config.version then 292 | return nil, "Missing version in YAML configuration" 293 | end 294 | 295 | return config, nil 296 | end 297 | 298 | -- Get all supported file extensions 299 | -- @return table: Array of supported extensions 300 | function M.get_supported_extensions() 301 | local extensions = {} 302 | for _, parser in pairs(parsers) do 303 | vim.list_extend(extensions, parser.extensions) 304 | end 305 | return extensions 306 | end 307 | 308 | -- Get supported formats 309 | -- @return table: Array of format names 310 | function M.get_supported_formats() 311 | return vim.tbl_keys(parsers) 312 | end 313 | 314 | -- Register a custom parser 315 | -- @param format_name string: Name of the format 316 | -- @param parser table: Parser configuration {extensions, parse function} 317 | function M.register_parser(format_name, parser) 318 | validation.assert_string(format_name, "format_name", false) 319 | validation.assert_table(parser, "parser", false) 320 | validation.assert_table(parser.extensions, "parser.extensions", false) 321 | validation.assert_function(parser.parse, "parser.parse", false) 322 | 323 | parsers[format_name] = parser 324 | end 325 | 326 | -- Test if content is valid for a given format 327 | -- @param content string: File content 328 | -- @param format string: Format to test 329 | -- @return boolean: True if content can be parsed 330 | function M.test_parse(content, format) 331 | local parser = parsers[format] 332 | if not parser then 333 | return false 334 | end 335 | 336 | local result, err = parser.parse(content, "test") 337 | return result ~= nil 338 | end 339 | 340 | return M -------------------------------------------------------------------------------- /lua/dotnvim/tasks/runner.lua: -------------------------------------------------------------------------------- 1 | -- Task execution engine 2 | local Job = require('plenary.job') 3 | local validation = require('dotnvim.utils.validation') 4 | local buffer_helpers = require('dotnvim.utils.buffer_helpers') 5 | local ui_helpers = require('dotnvim.utils.ui_helpers') 6 | local config_manager = require('dotnvim.config_manager') 7 | 8 | local M = {} 9 | 10 | -- Task execution states 11 | local TASK_STATES = { 12 | PENDING = "pending", 13 | RUNNING = "running", 14 | SUCCESS = "success", 15 | FAILED = "failed", 16 | CANCELLED = "cancelled" 17 | } 18 | 19 | -- Active task tracking 20 | local active_tasks = {} 21 | local task_buffers = {} 22 | 23 | -- Task execution context 24 | local TaskContext = {} 25 | TaskContext.__index = TaskContext 26 | 27 | function TaskContext:new(task, project_root) 28 | local context = { 29 | task = task, 30 | project_root = project_root, 31 | state = TASK_STATES.PENDING, 32 | start_time = nil, 33 | end_time = nil, 34 | exit_code = nil, 35 | job = nil, 36 | buffer = nil, 37 | output_lines = {}, 38 | error_lines = {} 39 | } 40 | setmetatable(context, TaskContext) 41 | return context 42 | end 43 | 44 | function TaskContext:get_working_directory() 45 | local cwd = self.task.cwd or "." 46 | if cwd == "." then 47 | return self.project_root 48 | elseif cwd:match("^/") then 49 | -- Absolute path 50 | return cwd 51 | else 52 | -- Relative to project root 53 | return self.project_root .. "/" .. cwd 54 | end 55 | end 56 | 57 | function TaskContext:get_environment() 58 | local env = {} 59 | 60 | -- Start with system environment 61 | for key, value in pairs(vim.fn.environ()) do 62 | env[key] = value 63 | end 64 | 65 | -- Add task-specific environment variables 66 | if self.task.env then 67 | for key, value in pairs(self.task.env) do 68 | env[key] = tostring(value) 69 | end 70 | end 71 | 72 | return env 73 | end 74 | 75 | function TaskContext:create_output_buffer() 76 | local buffer_name = "task-" .. (self.task.name or "unknown") 77 | 78 | -- Get task output window configuration 79 | local config = config_manager.get_config() 80 | local split_config = config.tasks.output_window or { 81 | mode = "new_buffer", 82 | size = nil, 83 | focus = false 84 | } 85 | 86 | self.buffer = buffer_helpers.create_log_buffer_split(buffer_name, split_config) 87 | 88 | if self.buffer then 89 | task_buffers[self.task.name] = self.buffer 90 | 91 | -- Add header to buffer 92 | local header = { 93 | "=== Task: " .. (self.task.name or "unknown") .. " ===", 94 | "Command: " .. (self.task.command or "unknown"), 95 | "Working Directory: " .. self:get_working_directory(), 96 | "Started: " .. os.date("%Y-%m-%d %H:%M:%S"), 97 | "", 98 | } 99 | 100 | buffer_helpers.write_to_buffer(self.buffer, header) 101 | end 102 | 103 | return self.buffer 104 | end 105 | 106 | function TaskContext:log_output(line, is_error) 107 | if not line or line == "" then return end 108 | 109 | -- Store in context 110 | if is_error then 111 | table.insert(self.error_lines, line) 112 | else 113 | table.insert(self.output_lines, line) 114 | end 115 | 116 | -- Write to buffer if available 117 | if self.buffer then 118 | local prefix = is_error and "[ERROR] " or "" 119 | buffer_helpers.append_to_buffer(self.buffer, {prefix .. line}) 120 | end 121 | end 122 | 123 | function TaskContext:mark_completed(exit_code) 124 | self.end_time = os.time() 125 | self.exit_code = exit_code 126 | 127 | if exit_code == 0 then 128 | self.state = TASK_STATES.SUCCESS 129 | else 130 | self.state = TASK_STATES.FAILED 131 | end 132 | 133 | -- Add footer to buffer 134 | if self.buffer then 135 | local duration = self.end_time - (self.start_time or self.end_time) 136 | local footer = { 137 | "", 138 | "=== Task Completed ===", 139 | "Exit Code: " .. exit_code, 140 | "Duration: " .. duration .. "s", 141 | "Status: " .. self.state, 142 | "Finished: " .. os.date("%Y-%m-%d %H:%M:%S") 143 | } 144 | 145 | buffer_helpers.append_to_buffer(self.buffer, footer) 146 | end 147 | 148 | -- Remove from active tasks 149 | active_tasks[self.task.name] = nil 150 | end 151 | 152 | -- Execute a single task 153 | -- @param task table: Task configuration 154 | -- @param project_root string: Project root directory 155 | -- @param callback function: Completion callback (context, success) 156 | -- @return TaskContext: Task execution context 157 | function M.execute_task(task, project_root, callback) 158 | validation.assert_table(task, "task", false) 159 | validation.assert_string(project_root, "project_root", false) 160 | validation.assert_function(callback, "callback", true) 161 | 162 | local context = TaskContext:new(task, project_root) 163 | 164 | -- Validate task has required fields 165 | if not task.name or not task.command then 166 | context.state = TASK_STATES.FAILED 167 | if callback then 168 | callback(context, false) 169 | end 170 | return context 171 | end 172 | 173 | -- Check if task is already running 174 | if active_tasks[task.name] then 175 | ui_helpers.show_warning("Task '" .. task.name .. "' is already running", "execute_task") 176 | if callback then 177 | callback(context, false) 178 | end 179 | return context 180 | end 181 | 182 | -- Mark as active 183 | active_tasks[task.name] = context 184 | context.state = TASK_STATES.RUNNING 185 | context.start_time = os.time() 186 | 187 | -- Create output buffer 188 | context:create_output_buffer() 189 | 190 | -- Parse command and arguments 191 | local cmd_parts = vim.split(task.command, " ", {plain = false, trimempty = true}) 192 | local command = cmd_parts[1] 193 | local args = {} 194 | for i = 2, #cmd_parts do 195 | table.insert(args, cmd_parts[i]) 196 | end 197 | 198 | -- Get working directory and environment 199 | local working_dir = context:get_working_directory() 200 | local env = context:get_environment() 201 | 202 | ui_helpers.show_info("Starting task: " .. task.name, "execute_task") 203 | 204 | -- Create and start job 205 | context.job = Job:new({ 206 | command = command, 207 | args = args, 208 | cwd = working_dir, 209 | env = env, 210 | on_stdout = vim.schedule_wrap(function(_, line) 211 | context:log_output(line, false) 212 | end), 213 | on_stderr = vim.schedule_wrap(function(_, line) 214 | context:log_output(line, true) 215 | end), 216 | on_exit = vim.schedule_wrap(function(_, exit_code) 217 | context:mark_completed(exit_code) 218 | 219 | if exit_code == 0 then 220 | ui_helpers.show_success("Task completed: " .. task.name, "execute_task") 221 | else 222 | ui_helpers.show_error("Task failed: " .. task.name .. " (exit code: " .. exit_code .. ")", "execute_task") 223 | end 224 | 225 | if callback then 226 | callback(context, exit_code == 0) 227 | end 228 | end) 229 | }) 230 | 231 | -- Start the job 232 | context.job:start() 233 | 234 | return context 235 | end 236 | 237 | -- Execute multiple tasks in sequence 238 | -- @param tasks table: Array of task configurations 239 | -- @param project_root string: Project root directory 240 | -- @param callback function: Completion callback (all_contexts, all_success) 241 | -- @return table: Array of TaskContext objects 242 | function M.execute_tasks_sequence(tasks, project_root, callback) 243 | validation.assert_table(tasks, "tasks", false) 244 | validation.assert_string(project_root, "project_root", false) 245 | validation.assert_function(callback, "callback", true) 246 | 247 | local contexts = {} 248 | local current_index = 1 249 | local all_success = true 250 | 251 | local function execute_next() 252 | if current_index > #tasks then 253 | -- All tasks completed 254 | if callback then 255 | callback(contexts, all_success) 256 | end 257 | return 258 | end 259 | 260 | local task = tasks[current_index] 261 | current_index = current_index + 1 262 | 263 | local context = M.execute_task(task, project_root, function(ctx, success) 264 | table.insert(contexts, ctx) 265 | 266 | if not success then 267 | all_success = false 268 | -- Stop execution on failure 269 | if callback then 270 | callback(contexts, all_success) 271 | end 272 | return 273 | end 274 | 275 | -- Continue with next task 276 | execute_next() 277 | end) 278 | 279 | -- If task failed to start, mark as failure and continue 280 | if context.state == TASK_STATES.FAILED then 281 | table.insert(contexts, context) 282 | all_success = false 283 | if callback then 284 | callback(contexts, all_success) 285 | end 286 | end 287 | end 288 | 289 | execute_next() 290 | return contexts 291 | end 292 | 293 | -- Execute tasks with dependency resolution 294 | -- @param task_names table: Array of task names to execute 295 | -- @param all_tasks table: All available tasks 296 | -- @param project_root string: Project root directory 297 | -- @param callback function: Completion callback (contexts, success) 298 | -- @return table: Array of TaskContext objects 299 | function M.execute_with_dependencies(task_names, all_tasks, project_root, callback) 300 | validation.assert_table(task_names, "task_names", false) 301 | validation.assert_table(all_tasks, "all_tasks", false) 302 | validation.assert_string(project_root, "project_root", false) 303 | validation.assert_function(callback, "callback", true) 304 | 305 | -- Build task map 306 | local task_map = {} 307 | for _, task in ipairs(all_tasks) do 308 | if task.name then 309 | task_map[task.name] = task 310 | end 311 | end 312 | 313 | -- Resolve execution order 314 | local execution_order = {} 315 | local resolved = {} 316 | local resolving = {} 317 | 318 | local function resolve_deps(task_name) 319 | if resolved[task_name] then 320 | return true 321 | end 322 | 323 | if resolving[task_name] then 324 | ui_helpers.show_error("Circular dependency detected: " .. task_name, "execute_with_dependencies") 325 | return false 326 | end 327 | 328 | local task = task_map[task_name] 329 | if not task then 330 | ui_helpers.show_error("Task not found: " .. task_name, "execute_with_dependencies") 331 | return false 332 | end 333 | 334 | resolving[task_name] = true 335 | 336 | -- Resolve dependencies first 337 | if task.previous then 338 | for _, dep_name in ipairs(task.previous) do 339 | if not resolve_deps(dep_name) then 340 | return false 341 | end 342 | end 343 | end 344 | 345 | resolving[task_name] = nil 346 | resolved[task_name] = true 347 | 348 | if not vim.tbl_contains(execution_order, task_name) then 349 | table.insert(execution_order, task_name) 350 | end 351 | 352 | return true 353 | end 354 | 355 | -- Resolve all requested tasks 356 | for _, task_name in ipairs(task_names) do 357 | if not resolve_deps(task_name) then 358 | if callback then 359 | callback({}, false) 360 | end 361 | return {} 362 | end 363 | end 364 | 365 | -- Build execution list 366 | local tasks_to_execute = {} 367 | for _, task_name in ipairs(execution_order) do 368 | local task = task_map[task_name] 369 | if task then 370 | table.insert(tasks_to_execute, task) 371 | end 372 | end 373 | 374 | ui_helpers.show_info("Executing " .. #tasks_to_execute .. " tasks in order: " .. table.concat(execution_order, " → "), "execute_with_dependencies") 375 | 376 | return M.execute_tasks_sequence(tasks_to_execute, project_root, callback) 377 | end 378 | 379 | -- Cancel a running task 380 | -- @param task_name string: Name of the task to cancel 381 | -- @return boolean: True if task was cancelled 382 | function M.cancel_task(task_name) 383 | validation.assert_string(task_name, "task_name", false) 384 | 385 | local context = active_tasks[task_name] 386 | if not context then 387 | return false 388 | end 389 | 390 | if context.job then 391 | context.job:shutdown() 392 | context.state = TASK_STATES.CANCELLED 393 | context.end_time = os.time() 394 | 395 | ui_helpers.show_warning("Task cancelled: " .. task_name, "cancel_task") 396 | 397 | -- Log cancellation to buffer 398 | if context.buffer then 399 | buffer_helpers.append_to_buffer(context.buffer, {"", "=== Task Cancelled ==="}) 400 | end 401 | 402 | active_tasks[task_name] = nil 403 | return true 404 | end 405 | 406 | return false 407 | end 408 | 409 | -- Cancel all running tasks 410 | -- @return number: Number of tasks cancelled 411 | function M.cancel_all_tasks() 412 | local cancelled = 0 413 | local task_names = vim.tbl_keys(active_tasks) 414 | 415 | for _, task_name in ipairs(task_names) do 416 | if M.cancel_task(task_name) then 417 | cancelled = cancelled + 1 418 | end 419 | end 420 | 421 | if cancelled > 0 then 422 | ui_helpers.show_info("Cancelled " .. cancelled .. " running tasks", "cancel_all_tasks") 423 | end 424 | 425 | return cancelled 426 | end 427 | 428 | -- Get status of running tasks 429 | -- @return table: Map of task_name -> TaskContext for active tasks 430 | function M.get_active_tasks() 431 | return vim.deepcopy(active_tasks) 432 | end 433 | 434 | -- Get task execution history for a task 435 | -- @param task_name string: Name of the task 436 | -- @return table|nil: TaskContext for the task or nil if not found 437 | function M.get_task_history(task_name) 438 | validation.assert_string(task_name, "task_name", false) 439 | 440 | -- For now, we only track active tasks 441 | -- Could be extended to maintain execution history 442 | return active_tasks[task_name] 443 | end 444 | 445 | -- Check if any tasks are currently running 446 | -- @return boolean: True if any tasks are running 447 | function M.has_running_tasks() 448 | return next(active_tasks) ~= nil 449 | end 450 | 451 | -- Get task output buffer 452 | -- @param task_name string: Name of the task 453 | -- @return number|nil: Buffer number or nil if not found 454 | function M.get_task_buffer(task_name) 455 | validation.assert_string(task_name, "task_name", false) 456 | return task_buffers[task_name] 457 | end 458 | 459 | -- Clean up completed task buffers 460 | -- @param keep_recent number: Number of recent buffers to keep (optional, defaults to 5) 461 | function M.cleanup_task_buffers(keep_recent) 462 | keep_recent = keep_recent or 5 463 | 464 | -- For now, just clear all non-active task buffers 465 | for task_name, buffer in pairs(task_buffers) do 466 | if not active_tasks[task_name] then 467 | if vim.api.nvim_buf_is_valid(buffer) then 468 | vim.api.nvim_buf_delete(buffer, {force = true}) 469 | end 470 | task_buffers[task_name] = nil 471 | end 472 | end 473 | end 474 | 475 | -- Export task states for external use 476 | M.TASK_STATES = TASK_STATES 477 | 478 | return M -------------------------------------------------------------------------------- /lua/dotnvim/tasks/discovery.lua: -------------------------------------------------------------------------------- 1 | -- Task configuration discovery service 2 | local project_scanner = require('dotnvim.utils.project_scanner') 3 | local parsers = require('dotnvim.tasks.parsers') 4 | local validation = require('dotnvim.utils.validation') 5 | local ui_helpers = require('dotnvim.utils.ui_helpers') 6 | 7 | local M = {} 8 | 9 | -- Task file discovery configuration with priority order 10 | local TASK_DISCOVERY_PATHS = { 11 | -- .nvim directory (highest priority) 12 | { 13 | dir = '.nvim', 14 | files = {'tasks.json', 'tasks.toml', 'tasks.yaml', 'tasks.yml'} 15 | }, 16 | -- .vscode directory (fallback compatibility) 17 | { 18 | dir = '.vscode', 19 | files = {'tasks.json', 'tasks.toml', 'tasks.yaml', 'tasks.yml'} 20 | } 21 | } 22 | 23 | -- Cache for discovered task configurations 24 | local task_cache = {} 25 | local cache_timestamps = {} 26 | 27 | -- Discover task configuration files in project 28 | -- @param project_root string: Root directory of the project (optional) 29 | -- @return string|nil, string|nil: Task file path and format, or nil if not found 30 | function M.discover_task_file(project_root) 31 | project_root = project_root or M.get_project_root() 32 | 33 | if not project_root then 34 | return nil, nil, "No project root found" 35 | end 36 | 37 | local valid, err = validation.validate_directory_path(project_root, "project_root", true) 38 | if not valid then 39 | return nil, nil, err 40 | end 41 | 42 | -- Search through discovery paths in priority order 43 | for _, location in ipairs(TASK_DISCOVERY_PATHS) do 44 | local search_dir = project_root .. '/' .. location.dir 45 | 46 | -- Check if directory exists 47 | local dir_stat = vim.loop.fs_stat(search_dir) 48 | if dir_stat and dir_stat.type == "directory" then 49 | 50 | -- Search for task files in priority order 51 | for _, filename in ipairs(location.files) do 52 | local full_path = search_dir .. '/' .. filename 53 | local file_stat = vim.loop.fs_stat(full_path) 54 | 55 | if file_stat and file_stat.type == "file" then 56 | local format = parsers.detect_format(full_path) 57 | if format then 58 | return full_path, format, nil 59 | end 60 | end 61 | end 62 | end 63 | end 64 | 65 | return nil, nil, "No task configuration file found" 66 | end 67 | 68 | -- Get project root directory 69 | -- @param start_path string: Starting path for search (optional) 70 | -- @return string|nil: Project root path or nil if not found 71 | function M.get_project_root(start_path) 72 | start_path = start_path or vim.fn.expand('%:p:h') 73 | return project_scanner.get_project_root(start_path) 74 | end 75 | 76 | -- Load task configuration from discovered file 77 | -- @param project_root string: Project root directory (optional) 78 | -- @param force_refresh boolean: Force cache refresh (optional) 79 | -- @return table|nil, string|nil: Task configuration or nil, error message 80 | function M.load_task_config(project_root, force_refresh) 81 | project_root = project_root or M.get_project_root() 82 | 83 | if not project_root then 84 | return nil, "No project root found" 85 | end 86 | 87 | -- Check cache first (unless force refresh) 88 | local cache_key = project_root 89 | if not force_refresh and task_cache[cache_key] then 90 | local cached_config = task_cache[cache_key] 91 | local cache_time = cache_timestamps[cache_key] 92 | 93 | -- Check if cache is still valid (file hasn't changed) 94 | if cached_config.file_path then 95 | local stat = vim.loop.fs_stat(cached_config.file_path) 96 | if stat and cache_time and stat.mtime.sec <= cache_time then 97 | return cached_config, nil 98 | end 99 | end 100 | end 101 | 102 | -- Discover task file 103 | local task_file, format, discovery_err = M.discover_task_file(project_root) 104 | if not task_file then 105 | return nil, discovery_err or "No task configuration found" 106 | end 107 | 108 | -- Parse task configuration 109 | local config, parse_err = parsers.parse_task_file(task_file) 110 | if not config then 111 | return nil, parse_err or "Failed to parse task configuration" 112 | end 113 | 114 | -- Enhance config with metadata 115 | config.file_path = task_file 116 | config.format = format 117 | config.project_root = project_root 118 | config.discovery_time = os.time() 119 | 120 | -- Cache the configuration 121 | task_cache[cache_key] = config 122 | cache_timestamps[cache_key] = os.time() 123 | 124 | return config, nil 125 | end 126 | 127 | -- Get all available task names from configuration 128 | -- @param project_root string: Project root directory (optional) 129 | -- @return table, string|nil: Array of task names, error message if any 130 | function M.get_available_tasks(project_root) 131 | local config, err = M.load_task_config(project_root) 132 | if not config then 133 | return {}, err 134 | end 135 | 136 | local task_names = {} 137 | for _, task in ipairs(config.tasks or {}) do 138 | if task.name then 139 | table.insert(task_names, task.name) 140 | end 141 | end 142 | 143 | return task_names, nil 144 | end 145 | 146 | -- Find task by name in configuration 147 | -- @param task_name string: Name of the task to find 148 | -- @param project_root string: Project root directory (optional) 149 | -- @return table|nil, string|nil: Task configuration or nil, error message 150 | function M.find_task(task_name, project_root) 151 | validation.assert_string(task_name, "task_name", false) 152 | 153 | local config, err = M.load_task_config(project_root) 154 | if not config then 155 | return nil, err 156 | end 157 | 158 | for _, task in ipairs(config.tasks or {}) do 159 | if task.name == task_name then 160 | return task, nil 161 | end 162 | end 163 | 164 | return nil, "Task '" .. task_name .. "' not found" 165 | end 166 | 167 | -- Get task dependencies recursively 168 | -- @param task_name string: Name of the task 169 | -- @param project_root string: Project root directory (optional) 170 | -- @return table, string|nil: Array of dependency names in execution order, error message 171 | function M.get_task_dependencies(task_name, project_root) 172 | validation.assert_string(task_name, "task_name", false) 173 | 174 | local config, err = M.load_task_config(project_root) 175 | if not config then 176 | return {}, err 177 | end 178 | 179 | -- Build task map 180 | local task_map = {} 181 | for _, task in ipairs(config.tasks or {}) do 182 | if task.name then 183 | task_map[task.name] = task 184 | end 185 | end 186 | 187 | -- Recursive dependency resolution 188 | local resolved = {} 189 | local visiting = {} 190 | 191 | local function resolve_deps(name) 192 | if resolved[name] then 193 | return -- Already resolved 194 | end 195 | 196 | if visiting[name] then 197 | return {"Circular dependency detected involving task: " .. name} 198 | end 199 | 200 | local task = task_map[name] 201 | if not task then 202 | return {"Task not found: " .. name} 203 | end 204 | 205 | visiting[name] = true 206 | 207 | -- Resolve dependencies first 208 | if task.previous then 209 | for _, dep_name in ipairs(task.previous) do 210 | local dep_errors = resolve_deps(dep_name) 211 | if dep_errors then 212 | return dep_errors 213 | end 214 | end 215 | end 216 | 217 | visiting[name] = nil 218 | resolved[name] = true 219 | 220 | return nil 221 | end 222 | 223 | -- Resolve dependencies for the requested task 224 | local errors = resolve_deps(task_name) 225 | if errors then 226 | return {}, table.concat(errors, "; ") 227 | end 228 | 229 | -- Build execution order list 230 | local execution_order = {} 231 | local function build_order(name) 232 | local task = task_map[name] 233 | if task and task.previous then 234 | for _, dep_name in ipairs(task.previous) do 235 | if not vim.tbl_contains(execution_order, dep_name) then 236 | build_order(dep_name) 237 | end 238 | end 239 | end 240 | 241 | if not vim.tbl_contains(execution_order, name) then 242 | table.insert(execution_order, name) 243 | end 244 | end 245 | 246 | build_order(task_name) 247 | 248 | return execution_order, nil 249 | end 250 | 251 | -- Clear task configuration cache 252 | -- @param project_root string: Project root to clear (optional, clears all if nil) 253 | function M.clear_cache(project_root) 254 | if project_root then 255 | task_cache[project_root] = nil 256 | cache_timestamps[project_root] = nil 257 | else 258 | task_cache = {} 259 | cache_timestamps = {} 260 | end 261 | end 262 | 263 | -- Get cache statistics 264 | -- @return table: Cache information 265 | function M.get_cache_stats() 266 | local stats = { 267 | cached_projects = 0, 268 | total_tasks = 0, 269 | oldest_cache = nil, 270 | newest_cache = nil 271 | } 272 | 273 | for project_root, config in pairs(task_cache) do 274 | stats.cached_projects = stats.cached_projects + 1 275 | 276 | if config.tasks then 277 | stats.total_tasks = stats.total_tasks + #config.tasks 278 | end 279 | 280 | local cache_time = cache_timestamps[project_root] 281 | if cache_time then 282 | if not stats.oldest_cache or cache_time < stats.oldest_cache then 283 | stats.oldest_cache = cache_time 284 | end 285 | if not stats.newest_cache or cache_time > stats.newest_cache then 286 | stats.newest_cache = cache_time 287 | end 288 | end 289 | end 290 | 291 | return stats 292 | end 293 | 294 | -- Validate project has task support 295 | -- @param project_root string: Project root directory (optional) 296 | -- @return boolean, string: Has task support, reason if not 297 | function M.has_task_support(project_root) 298 | project_root = project_root or M.get_project_root() 299 | 300 | if not project_root then 301 | return false, "No .NET project found" 302 | end 303 | 304 | local task_file, format, err = M.discover_task_file(project_root) 305 | if not task_file then 306 | return false, "No task configuration file found" 307 | end 308 | 309 | return true, "Task configuration available at " .. task_file 310 | end 311 | 312 | -- Create example task configuration file 313 | -- @param project_root string: Project root directory (optional) 314 | -- @param format string: Format to create (json, toml, yaml) (optional, defaults to json) 315 | -- @return boolean, string: Success status, file path or error message 316 | function M.create_example_config(project_root, format) 317 | project_root = project_root or M.get_project_root() 318 | format = format or "json" 319 | 320 | if not project_root then 321 | return false, "No .NET project found" 322 | end 323 | 324 | local nvim_dir = project_root .. '/.nvim' 325 | 326 | -- Create .nvim directory if it doesn't exist 327 | local dir_stat = vim.loop.fs_stat(nvim_dir) 328 | if not dir_stat then 329 | local success = vim.loop.fs_mkdir(nvim_dir, 493) -- 0755 permissions 330 | if not success then 331 | return false, "Could not create .nvim directory" 332 | end 333 | end 334 | 335 | -- Create example configuration 336 | local filename = "tasks." .. format 337 | local file_path = nvim_dir .. '/' .. filename 338 | 339 | local example_content 340 | if format == "json" then 341 | example_content = [[{ 342 | "version": "0.1.0", 343 | "tasks": [ 344 | { 345 | "name": "restore", 346 | "command": "dotnet restore ${workspaceFolder}", 347 | "cwd": "${workspaceFolder}", 348 | "env": { 349 | "DOTNET_NOLOGO": "true" 350 | } 351 | }, 352 | { 353 | "name": "build", 354 | "previous": ["restore"], 355 | "command": "dotnet build ${workspaceFolder} --no-restore", 356 | "cwd": "${workspaceFolder}", 357 | "env": { 358 | "DOTNET_NOLOGO": "true", 359 | "PROJECT_ROOT": "${workspaceFolder}" 360 | } 361 | }, 362 | { 363 | "name": "test", 364 | "previous": ["build"], 365 | "command": "dotnet test ${workspaceFolder} --no-build", 366 | "cwd": "${workspaceFolder}", 367 | "env": { 368 | "DOTNET_NOLOGO": "true", 369 | "ASPNETCORE_ENVIRONMENT": "Test", 370 | "TEST_OUTPUT": "${workspaceFolder}/TestResults" 371 | } 372 | }, 373 | { 374 | "name": "pre-debug", 375 | "previous": ["build"], 376 | "command": "echo 'Ready for debugging ${workspaceFolderBasename}'", 377 | "cwd": "${workspaceFolder}" 378 | } 379 | ] 380 | }]] 381 | elseif format == "yaml" then 382 | example_content = [[version: "0.1.0" 383 | tasks: 384 | - name: restore 385 | command: dotnet restore ${workspaceFolder} 386 | cwd: ${workspaceFolder} 387 | env: 388 | DOTNET_NOLOGO: "true" 389 | 390 | - name: build 391 | previous: [restore] 392 | command: dotnet build ${workspaceFolder} --no-restore 393 | cwd: ${workspaceFolder} 394 | env: 395 | DOTNET_NOLOGO: "true" 396 | PROJECT_ROOT: ${workspaceFolder} 397 | 398 | - name: test 399 | previous: [build] 400 | command: dotnet test ${workspaceFolder} --no-build 401 | cwd: ${workspaceFolder} 402 | env: 403 | DOTNET_NOLOGO: "true" 404 | ASPNETCORE_ENVIRONMENT: Test 405 | TEST_OUTPUT: ${workspaceFolder}/TestResults 406 | 407 | - name: pre-debug 408 | previous: [build] 409 | command: echo 'Ready for debugging ${workspaceFolderBasename}' 410 | cwd: ${workspaceFolder} 411 | ]] 412 | elseif format == "toml" then 413 | example_content = [=[version = "0.1.0" 414 | 415 | [[tasks]] 416 | name = "restore" 417 | command = "dotnet restore ${workspaceFolder}" 418 | cwd = "${workspaceFolder}" 419 | 420 | [tasks.env] 421 | DOTNET_NOLOGO = "true" 422 | 423 | [[tasks]] 424 | name = "build" 425 | previous = ["restore"] 426 | command = "dotnet build ${workspaceFolder} --no-restore" 427 | cwd = "${workspaceFolder}" 428 | 429 | [tasks.env] 430 | DOTNET_NOLOGO = "true" 431 | PROJECT_ROOT = "${workspaceFolder}" 432 | 433 | [[tasks]] 434 | name = "test" 435 | previous = ["build"] 436 | command = "dotnet test ${workspaceFolder} --no-build" 437 | cwd = "${workspaceFolder}" 438 | 439 | [tasks.env] 440 | DOTNET_NOLOGO = "true" 441 | ASPNETCORE_ENVIRONMENT = "Test" 442 | TEST_OUTPUT = "${workspaceFolder}/TestResults" 443 | 444 | [[tasks]] 445 | name = "pre-debug" 446 | previous = ["build"] 447 | command = "echo 'Ready for debugging ${workspaceFolderBasename}'" 448 | cwd = "${workspaceFolder}" 449 | ]=] 450 | else 451 | return false, "Unsupported format: " .. format 452 | end 453 | 454 | -- Write example file 455 | local fd = vim.loop.fs_open(file_path, "w", 438) -- 0666 permissions 456 | if not fd then 457 | return false, "Could not create example file: " .. file_path 458 | end 459 | 460 | local write_success = vim.loop.fs_write(fd, example_content, 0) 461 | vim.loop.fs_close(fd) 462 | 463 | if not write_success then 464 | return false, "Could not write to example file: " .. file_path 465 | end 466 | 467 | return true, file_path 468 | end 469 | 470 | return M --------------------------------------------------------------------------------