From fcc3cfcba3ab2f2b66209bf38c8fd93ecfee89ac Mon Sep 17 00:00:00 2001 From: David Mejorado Date: Wed, 14 Jan 2026 21:55:20 -0800 Subject: [PATCH] feat: TSCMono Exposes a new user command to run tsc in the monorepo mode. We're making the following assumptions around the nature of a monorepo: - When finding all the available tsconfig files, we're searching from the monorepo root. - We find the monorepo root using the option `monorepo_root_patterns`, which is passed as the marker param to `vim.fs.root`, and defaults to `{ ".git" }`. We're making it configurable in case someone uses some other vcs. - Using each tsconfig directory as the project root When working on a monorepo, it's common to jump across projects, which can also mean changing the working directory in order to accommodate LSP clients, direnv callbacks, etc. See vim-rooter. That means that by the time we get the results from tsc, we might already be in a different working directory. Since the results from tsc use relative paths, the quickfix entries will be broken. To fix that issue, we're making all the project paths absolute, so we can: - Run each tsc command from its corresponding directory - Build an absolute path for the resulting errors That guarantees a valid file path in the quickfix, regardless of what the current working directory is by then. --- README.md | 1 + lua/tsc/init.lua | 30 +++++++++++++++++++++++++++--- lua/tsc/utils.lua | 34 +++++++++++++++++++++++----------- 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5ccba76..99de52c 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ By default, the plugin uses the default `tsc` command with the `--noEmit` flag t use_trouble_qflist = false, use_diagnostics = false, run_as_monorepo = false, + monorepo_root_patterns = { ".git" }, max_tsconfig_files = 20, bin_path = nil, bin_name = "tsc", diff --git a/lua/tsc/init.lua b/lua/tsc/init.lua index 0853b8a..8cdf647 100644 --- a/lua/tsc/init.lua +++ b/lua/tsc/init.lua @@ -17,6 +17,7 @@ end --- @field use_trouble_qflist? boolean - (false) When true the quick fix list will be opened in Trouble if it is installed --- @field use_diagnostics? boolean - (false) When true the errors will be set as diagnostics --- @field run_as_monorepo? boolean - (false) When true the `tsc` process will be started mode for each tsconfig in the current working directory +--- @field monorepo_root_patterns? string[] - ({".git"}) The file pattern to be used to find the root of the monorepo --- @field max_tsconfig_files? number - (20) Will not run `tsc` if number of found tsconfig files is greater. --- @field bin_path? string - Path to the tsc binary if it is not in the projects node_modules or globally --- @field bin_name? string - Name of the binary to use (default: "tsc") @@ -39,6 +40,7 @@ local DEFAULT_CONFIG = { enable_progress_notifications = true, enable_error_notifications = true, run_as_monorepo = false, + monorepo_root_patterns = { ".git" }, max_tsconfig_files = 20, flags = { noEmit = true, @@ -83,7 +85,11 @@ local function format_notification_msg(msg, spinner_idx) return string.format(" %s %s ", config.spinner[spinner_idx], msg) end -M.run = function() +--- @class RunParams +--- @field force_monorepo_mode boolean + +--- @param params RunParams? +M.run = function(params) -- Closed over state local tsc = config.bin_path or utils.find_tsc_bin(config.bin_name) local errors = {} @@ -107,7 +113,14 @@ M.run = function() return end - local configs_to_run = utils.find_tsconfigs(config.run_as_monorepo) + --- @type boolean + local run_as_monorepo = (params and params.force_monorepo_mode) or config.run_as_monorepo or false + + -- When running a monorepo check, we need to make sure we run the commands + -- from the root of the monorepo, as the cwd may be a subdirectory + local monorepo_root_path = vim.fs.root(0, config.monorepo_root_patterns) or "." + + local configs_to_run = utils.find_tsconfigs(run_as_monorepo, monorepo_root_path) if #configs_to_run > 0 and not config.run_as_monorepo then M.stop() @@ -240,8 +253,10 @@ M.run = function() ) end + ---@param output string[] + ---@param project string local function on_stdout(output, project) - local result = utils.parse_tsc_output(output, config) + local result = utils.parse_tsc_output(output, config, project) running_processes[project].errors = result.errors @@ -277,6 +292,7 @@ M.run = function() end end + --- @param project string local opts = function(project) return { on_stdout = function(_, output) @@ -286,6 +302,7 @@ M.run = function() on_exit() end, stdout_buffered = true, + cwd = vim.fs.dirname(project), } end @@ -338,6 +355,13 @@ function M.setup(opts) M.run() end, { desc = "Run `tsc` asynchronously and load the results into a qflist", force = true }) + vim.api.nvim_create_user_command("TSCMono", function() + M.run({ force_monorepo_mode = true }) + end, { + desc = "Run `tsc` for all the typescript projects in the monorepo", + force = true, + }) + vim.api.nvim_create_user_command("TSCStop", function() M.stop() vim.notify_once(format_notification_msg("TSC stopped"), nil, get_notify_options()) diff --git a/lua/tsc/utils.lua b/lua/tsc/utils.lua index ded3919..8ae9ddf 100644 --- a/lua/tsc/utils.lua +++ b/lua/tsc/utils.lua @@ -16,15 +16,16 @@ M.find_tsc_bin = function(bin_name) local node_modules_binary = vim.fn.findfile("node_modules/.bin/" .. bin_name, ".;") if node_modules_binary ~= "" then - return node_modules_binary + return vim.fs.abspath(node_modules_binary) end return bin_name end --- @param run_mono_repo boolean ---- @return table -M.find_tsconfigs = function(run_mono_repo) +--- @param monorepo_root_path string +--- @return string[] +M.find_tsconfigs = function(run_mono_repo, monorepo_root_path) if not run_mono_repo then return M.find_nearest_tsconfig() end @@ -33,9 +34,15 @@ M.find_tsconfigs = function(run_mono_repo) local found_configs = nil if M.is_executable("rg") then - found_configs = vim.fn.system("rg -g '!node_modules' --files | rg 'tsconfig.*.json'") + found_configs = vim.trim( + vim.system({ "rg", "--files", "-g", "!node_modules", "-g", "tsconfig.json", monorepo_root_path }):wait().stdout + ) else - found_configs = vim.fn.system('find . -not -path "*/node_modules/*" -name "tsconfig.*.json" -type f') + found_configs = vim.trim( + vim + .system({ "find", monorepo_root_path, "-not", "-path", "*/node_modules/*", "-name", "tsconfig.*.json", "-type", "f" }) + :wait().stdout + ) end if found_configs == nil then @@ -43,7 +50,7 @@ M.find_tsconfigs = function(run_mono_repo) end for s in found_configs:gmatch("[^\r\n]+") do - table.insert(tsconfigs, s) + table.insert(tsconfigs, vim.fs.abspath(s)) end assert(tsconfigs) @@ -51,10 +58,10 @@ M.find_tsconfigs = function(run_mono_repo) end M.find_nearest_tsconfig = function() - local tsconfig = vim.fn.findfile("tsconfig.json", ".;") + local tsconfig_root = vim.fs.root(0, "tsconfig.json") - if tsconfig ~= "" then - return { tsconfig } + if tsconfig_root then + return { vim.fs.joinpath(vim.fs.abspath(tsconfig_root), "tsconfig.json") } end return {} @@ -96,7 +103,10 @@ M.parse_flags = function(flags) return parsed_flags end -M.parse_tsc_output = function(output, config) +---@param output string[] +---@param config Opts +---@param project string +M.parse_tsc_output = function(output, config, project) local errors = {} local files = {} @@ -104,6 +114,8 @@ M.parse_tsc_output = function(output, config) return { errors = errors, files = files } end + local project_root = vim.fs.dirname(project) + for _, line in ipairs(output) do local filename, lineno, colno, message = line:match("^(.+)%((%d+),(%d+)%)%s*:%s*(.+)$") if filename ~= nil then @@ -112,7 +124,7 @@ M.parse_tsc_output = function(output, config) text = better_messages.translate(message) end table.insert(errors, { - filename = filename, + filename = vim.fs.joinpath(project_root, filename), lnum = tonumber(lineno), col = tonumber(colno), text = text,