From 25d3f791fc98cc328fe143c7972cef0ca2214d76 Mon Sep 17 00:00:00 2001 From: Dillon Mulroy Date: Tue, 8 Jul 2025 12:12:05 -0400 Subject: [PATCH 1/3] fix: resolve TypeScript error detection issues This commit fixes three critical issues that prevented tsc.nvim from detecting TypeScript errors that were visible when running tsc directly: 1. Auto-detection override: Removed hardcoded project function from default config to allow user configurations to take precedence while maintaining backward compatibility through conditional auto-detection in parse_flags() 2. ANSI color parsing: Added automatic --color false flag (unless user explicitly sets color) to ensure TypeScript outputs plain text that the regex parser can handle properly 3. Working directory mismatch: Set proper cwd option in jobstart() to run tsc from project root instead of current buffer directory, ensuring accurate type-checking context Additional improvements: - Enhanced error handling with validation for tsconfig.json existence and readability - Added comprehensive test suite in utils-test.lua covering all utility functions and edge cases - Updated documentation to reflect new behavior and configuration options - Maintained full backward compatibility with existing user configurations The plugin now properly detects TypeScript errors that match the output of running pnpm run typecheck or tsc directly. --- CLAUDE.md | 81 ++++++++++++++++++++ README.md | 7 +- lua/tsc/init.lua | 34 ++++++++- lua/tsc/utils-test.lua | 163 +++++++++++++++++++++++++++++++++++++++++ lua/tsc/utils.lua | 22 +++++- 5 files changed, 297 insertions(+), 10 deletions(-) create mode 100644 CLAUDE.md create mode 100644 lua/tsc/utils-test.lua diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..acc45e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,81 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Code Formatting +- **Format Lua code**: `stylua lua/` (follows configuration in `.stylua.toml`) +- **CI formatting check**: `stylua --color always --check lua` (used in GitHub Actions) + +### Testing +- Tests are located in `lua/tsc/better-messages-test.lua` and `lua/tsc/utils-test.lua` +- Uses a simple test framework with `describe` and `it` blocks +- Tests focus on the better-messages translation functionality and core utils functions + +## Recent Bug Fixes + +### Error Detection Issues (Fixed) +The plugin previously failed to detect TypeScript errors that were visible when running `pnpm run typecheck` or `tsc` directly. Three root causes were identified and fixed: + +1. **Auto-detection override**: The default config always called `find_nearest_tsconfig()` for the project flag, overriding user configuration. Now auto-detection only occurs when no explicit project is configured. + +2. **ANSI color parsing**: TypeScript outputs colored text by default, but the regex parser expected plain text. Fixed by adding `--color false` flag to disable colored output. + +3. **Working directory mismatch**: The plugin ran from the current buffer directory instead of project root. Fixed by setting `cwd` option in `jobstart()` to the project root directory. + +### Enhanced Error Handling +- Added validation to ensure tsconfig.json exists and is readable +- Improved error messages when tsconfig is not found or invalid +- Better feedback for configuration issues + +## Architecture Overview + +This is a Neovim plugin that provides asynchronous TypeScript type-checking using `tsc`. The plugin consists of three main modules: + +### Core Structure +- `lua/tsc/init.lua` - Main plugin entry point with setup and run functions +- `lua/tsc/utils.lua` - Utility functions for TSC binary discovery, output parsing, and quickfix list management +- `lua/tsc/better-messages.lua` - Enhanced error message translation system + +### Key Components + +#### Main Plugin (`init.lua`) +- Exposes `:TSC` command for manual type-checking +- Manages asynchronous job execution with progress notifications +- Handles configuration and watch mode functionality +- Integrates with nvim-notify for enhanced UI notifications + +#### Utilities (`utils.lua`) +- `find_tsc_bin()` - Discovers local node_modules or global TSC binary +- `find_nearest_tsconfig()` - Locates nearest tsconfig.json file +- `parse_flags()` - Converts configuration flags to CLI arguments +- `parse_tsc_output()` - Parses TSC output into quickfix list format +- `set_qflist()` - Manages quickfix list display and behavior + +#### Better Messages System (`better-messages.lua`) +- Translates cryptic TypeScript error codes (e.g., TS7006) into human-readable messages +- Uses markdown files in `better-messages/` directory (60+ error translations) +- Supports parameter substitution using `{0}`, `{1}` placeholders +- Strips markdown links and formatting for cleaner error display + +### Configuration System +The plugin uses a flexible configuration system supporting: +- String-based flags (backward compatibility) +- Object-based flags with boolean, string, and function values +- Watch mode with auto-start capabilities +- Quickfix list behavior customization +- Progress notification preferences + +### Watch Mode +When enabled, the plugin: +- Monitors TypeScript files for changes +- Automatically runs type-checking on save +- Parses incremental output differently than one-time runs +- Can auto-start when opening TypeScript files + +## File Structure Notes +- Better message templates are stored as individual markdown files named by error code (e.g., `2604.md`) +- Each template has an "original" pattern and "better" replacement message +- The plugin searches upward from current directory for tsconfig.json (mimics TSC behavior) +- Binary discovery checks local node_modules first, then falls back to global TSC \ No newline at end of file diff --git a/README.md b/README.md index 3561b5d..a3c70f3 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ If `watch` mode is enabled, tsc.nvim will automatically run in the background ev ## Configuration -By default, the plugin uses the default `tsc` command with the `--noEmit` flag to avoid generating output files during type-checking. It also emulates the default tsc behavior of performing a backward search from the current directory for a `tsconfig` file. The flags option can accept both a string and a table. Here's the default configuration: +By default, the plugin uses the default `tsc` command with the `--noEmit` flag to avoid generating output files during type-checking and `--color false` to ensure proper error parsing. It automatically performs a backward search from the current directory for a `tsconfig` file when no explicit project is configured. The flags option can accept both a string and a table. Here's the default configuration: ```lua { @@ -82,9 +82,6 @@ By default, the plugin uses the default `tsc` command with the `--noEmit` flag t enable_progress_notifications = true, flags = { noEmit = true, - project = function() - return utils.find_nearest_tsconfig() - end, watch = false, }, hide_progress_notifications_from_history = true, @@ -93,7 +90,7 @@ By default, the plugin uses the default `tsc` command with the `--noEmit` flag t } ``` -With this configuration, you can use keys for flag names and their corresponding values to enable/disable the flag (in the case of `noEmit = true`), provide a function (as in the case of the `project`) or enable watch mode. This makes the configuration more explicit and easier to read. Additionally, the flags option is backwards compatible and can accept a string value if you prefer a simpler configuration: +With this configuration, you can use keys for flag names and their corresponding values to enable/disable the flag (in the case of `noEmit = true`), specify a custom project path (e.g., `project = "/path/to/tsconfig.json"`), or enable watch mode. This makes the configuration more explicit and easier to read. Additionally, the flags option is backwards compatible and can accept a string value if you prefer a simpler configuration: ```lua flags = "--noEmit", diff --git a/lua/tsc/init.lua b/lua/tsc/init.lua index 8ed57d1..a563588 100644 --- a/lua/tsc/init.lua +++ b/lua/tsc/init.lua @@ -18,9 +18,6 @@ local DEFAULT_CONFIG = { enable_progress_notifications = true, flags = { noEmit = true, - project = function() - return utils.find_nearest_tsconfig() - end, watch = false, }, hide_progress_notifications_from_history = true, @@ -190,7 +187,36 @@ M.run = function() opts.on_stdout = watch_on_stdout end - vim.fn.jobstart(tsc .. " " .. utils.parse_flags(config.flags), opts) + -- Set working directory to project root if available + local parsed_flags = utils.parse_flags(config.flags) + local tsconfig_path = utils.find_nearest_tsconfig() + if tsconfig_path then + -- Validate that tsconfig file exists + if vim.fn.filereadable(tsconfig_path) == 0 then + vim.notify( + format_notification_msg( + string.format("tsconfig.json file not found or not readable: %s", tsconfig_path) + ), + vim.log.levels.ERROR, + get_notify_options() + ) + is_running = false + return + end + opts.cwd = utils.get_project_root(tsconfig_path) + else + vim.notify( + format_notification_msg( + "No tsconfig.json found. Please create a tsconfig.json file in your project root." + ), + vim.log.levels.ERROR, + get_notify_options() + ) + is_running = false + return + end + + vim.fn.jobstart(tsc .. " " .. parsed_flags, opts) end function M.is_running() diff --git a/lua/tsc/utils-test.lua b/lua/tsc/utils-test.lua new file mode 100644 index 0000000..f3c728a --- /dev/null +++ b/lua/tsc/utils-test.lua @@ -0,0 +1,163 @@ +local utils = require("tsc.utils") + +describe("Auto-detection override fix", function() + it("Auto-detects project when not explicitly configured", function() + local flags = { + noEmit = true, + watch = false, + } + + -- Mock find_nearest_tsconfig to return a test path + local original_find_nearest_tsconfig = utils.find_nearest_tsconfig + utils.find_nearest_tsconfig = function() + return "/test/path/tsconfig.json" + end + + local result = utils.parse_flags(flags) + + -- Restore original function + utils.find_nearest_tsconfig = original_find_nearest_tsconfig + + assert.is_true(string.find(result, "--project /test/path/tsconfig.json") ~= nil) + end) + + it("Does not override explicit project configuration", function() + local flags = { + noEmit = true, + watch = false, + project = "/custom/path/tsconfig.json", + } + + local result = utils.parse_flags(flags) + + assert.is_true(string.find(result, "--project /custom/path/tsconfig.json") ~= nil) + end) + + it("Handles missing tsconfig gracefully", function() + local flags = { + noEmit = true, + watch = false, + } + + -- Mock find_nearest_tsconfig to return nil + local original_find_nearest_tsconfig = utils.find_nearest_tsconfig + utils.find_nearest_tsconfig = function() + return nil + end + + local result = utils.parse_flags(flags) + + -- Restore original function + utils.find_nearest_tsconfig = original_find_nearest_tsconfig + + assert.is_true(string.find(result, "--project") == nil) + end) +end) + +describe("ANSI color parsing fix", function() + it("Includes --color false flag by default when not explicitly set", function() + local flags = { + noEmit = true, + watch = false, + } + + local result = utils.parse_flags(flags) + + assert.is_true(string.find(result, "--color false") ~= nil) + end) + + it("Respects explicit color configuration", function() + local flags = { + noEmit = true, + watch = false, + color = true, + } + + local result = utils.parse_flags(flags) + + assert.is_true(string.find(result, "--color") ~= nil) + assert.is_true(string.find(result, "--color false") == nil) + end) +end) + +describe("Working directory mismatch fix", function() + it("find_nearest_tsconfig returns absolute path", function() + -- Mock vim.fn.findfile to return a relative path + local original_findfile = vim.fn.findfile + local original_fnamemodify = vim.fn.fnamemodify + + vim.fn.findfile = function(name, path) + return "./tsconfig.json" + end + + vim.fn.fnamemodify = function(path, modifier) + if modifier == ":p" then + return "/absolute/path/tsconfig.json" + end + return path + end + + local result = utils.find_nearest_tsconfig() + + -- Restore original functions + vim.fn.findfile = original_findfile + vim.fn.fnamemodify = original_fnamemodify + + assert.equals("/absolute/path/tsconfig.json", result) + end) + + it("get_project_root returns correct directory", function() + -- Mock vim.fn.fnamemodify + local original_fnamemodify = vim.fn.fnamemodify + + vim.fn.fnamemodify = function(path, modifier) + if modifier == ":h" then + return "/absolute/path" + end + return path + end + + local result = utils.get_project_root("/absolute/path/tsconfig.json") + + -- Restore original function + vim.fn.fnamemodify = original_fnamemodify + + assert.equals("/absolute/path", result) + end) + + it("get_project_root handles nil input", function() + local result = utils.get_project_root(nil) + assert.equals(nil, result) + end) +end) + +describe("TSC output parsing", function() + it("Parses TSC output correctly", function() + local output = { + "src/test.ts(10,5): error TS2304: Cannot find name 'foo'.", + "src/other.ts(20,10): error TS2322: Type 'string' is not assignable to type 'number'.", + } + + local config = { pretty_errors = false } + local result = utils.parse_tsc_output(output, config) + + assert.equals(2, #result.errors) + assert.equals("src/test.ts", result.errors[1].filename) + assert.equals(10, result.errors[1].lnum) + assert.equals(5, result.errors[1].col) + assert.equals("error TS2304: Cannot find name 'foo'.", result.errors[1].text) + + assert.equals("src/other.ts", result.errors[2].filename) + assert.equals(20, result.errors[2].lnum) + assert.equals(10, result.errors[2].col) + assert.equals("error TS2322: Type 'string' is not assignable to type 'number'.", result.errors[2].text) + end) + + it("Handles empty output gracefully", function() + local config = { pretty_errors = false } + local result = utils.parse_tsc_output(nil, config) + + assert.equals(0, #result.errors) + assert.equals(0, #result.files) + end) +end) \ No newline at end of file diff --git a/lua/tsc/utils.lua b/lua/tsc/utils.lua index aaf20fd..6516904 100644 --- a/lua/tsc/utils.lua +++ b/lua/tsc/utils.lua @@ -20,12 +20,19 @@ M.find_nearest_tsconfig = function() local tsconfig = vim.fn.findfile("tsconfig.json", ".;") if tsconfig ~= "" then - return tsconfig + return vim.fn.fnamemodify(tsconfig, ":p") end return nil end +M.get_project_root = function(tsconfig_path) + if tsconfig_path then + return vim.fn.fnamemodify(tsconfig_path, ":h") + end + return nil +end + M.parse_flags = function(flags) if type(flags) == "string" then return flags @@ -33,6 +40,19 @@ M.parse_flags = function(flags) local parsed_flags = "" + -- Auto-detect project if not explicitly configured + if not flags.project then + local nearest_tsconfig = M.find_nearest_tsconfig() + if nearest_tsconfig then + flags.project = nearest_tsconfig + end + end + + -- Add --color false to ensure plain text output for parsing, unless user explicitly set color + if flags.color == nil then + flags.color = false + end + for key, value in pairs(flags) do key = string.gsub(key, "%-%-", "") From 7a7d803454cd547c41ab1bf4e31abdc4078344a8 Mon Sep 17 00:00:00 2001 From: Dillon Mulroy Date: Tue, 8 Jul 2025 12:25:55 -0400 Subject: [PATCH 2/3] docs: fix README accuracy and clarify color flag behavior - Fix bin_path default value (nil, not utils.find_tsc_bin()) - Clarify that --color false is added automatically unless explicitly overridden - Add note about color flag behavior in configuration section - Improve accuracy of default configuration documentation --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cdaf6b8..0de3f73 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ To stop any running `:TSC` command, use the `:TSCStop` command in Neovim. ## Configuration -By default, the plugin uses the default `tsc` command with the `--noEmit` flag to avoid generating output files during type-checking and `--color false` to ensure proper error parsing. It automatically performs a backward search from the current directory for a `tsconfig` file when no explicit project is configured. The flags option can accept both a string and a table. Here's the default configuration: +By default, the plugin uses the default `tsc` command with the `--noEmit` flag to avoid generating output files during type-checking. It automatically adds `--color false` to ensure proper error parsing (unless you explicitly set `color = true`). It performs a backward search from the current directory for a `tsconfig` file when no explicit project is configured. The flags option can accept both a string and a table. Here's the default configuration: ```lua { @@ -84,7 +84,7 @@ By default, the plugin uses the default `tsc` command with the `--noEmit` flag t use_diagnostics = false, run_as_monorepo = false, max_tsconfig_files = 20, - bin_path = utils.find_tsc_bin(), + bin_path = nil, enable_progress_notifications = true, enable_error_notifications = true, flags = { @@ -97,7 +97,11 @@ By default, the plugin uses the default `tsc` command with the `--noEmit` flag t } ``` -With this configuration, you can use keys for flag names and their corresponding values to enable/disable the flag (in the case of `noEmit = true`), specify a custom project path (e.g., `project = "/path/to/tsconfig.json"`), or enable watch mode. This makes the configuration more explicit and easier to read. Additionally, the flags option is backwards compatible and can accept a string value if you prefer a simpler configuration: +With this configuration, you can use keys for flag names and their corresponding values to enable/disable the flag (in the case of `noEmit = true`), specify a custom project path (e.g., `project = "/path/to/tsconfig.json"`), or enable watch mode. This makes the configuration more explicit and easier to read. + +**Note**: The plugin automatically adds `--color false` to ensure proper error parsing unless you explicitly set `color = true` in your flags. + +Additionally, the flags option is backwards compatible and can accept a string value if you prefer a simpler configuration: ```lua flags = "--noEmit", From 8b240c08ee73f2e2f5c5415fd99e03ac9cf9e01f Mon Sep 17 00:00:00 2001 From: Dillon Mulroy Date: Tue, 8 Jul 2025 12:34:28 -0400 Subject: [PATCH 3/3] style: fix lua formatting with stylua - Fix spacing and formatting issues in utils.lua - Fix spacing and formatting issues in utils-test.lua - Ensure consistency with .stylua.toml configuration - All files now pass stylua --check validation --- lua/tsc/utils-test.lua | 70 +++++++++++++++++++++--------------------- lua/tsc/utils.lua | 2 +- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/lua/tsc/utils-test.lua b/lua/tsc/utils-test.lua index b9beca1..404e550 100644 --- a/lua/tsc/utils-test.lua +++ b/lua/tsc/utils-test.lua @@ -6,50 +6,50 @@ describe("Auto-detection override fix", function() noEmit = true, watch = false, } - + -- Mock find_nearest_tsconfig to return a test path local original_find_nearest_tsconfig = utils.find_nearest_tsconfig utils.find_nearest_tsconfig = function() - return {"/test/path/tsconfig.json"} + return { "/test/path/tsconfig.json" } end - + local result = utils.parse_flags(flags) - + -- Restore original function utils.find_nearest_tsconfig = original_find_nearest_tsconfig - + assert.is_true(string.find(result, "--project /test/path/tsconfig.json") ~= nil) end) - + it("Does not override explicit project configuration", function() local flags = { noEmit = true, watch = false, project = "/custom/path/tsconfig.json", } - + local result = utils.parse_flags(flags) - + assert.is_true(string.find(result, "--project /custom/path/tsconfig.json") ~= nil) end) - + it("Handles missing tsconfig gracefully", function() local flags = { noEmit = true, watch = false, } - + -- Mock find_nearest_tsconfig to return empty array local original_find_nearest_tsconfig = utils.find_nearest_tsconfig utils.find_nearest_tsconfig = function() return {} end - + local result = utils.parse_flags(flags) - + -- Restore original function utils.find_nearest_tsconfig = original_find_nearest_tsconfig - + assert.is_true(string.find(result, "--project") == nil) end) end) @@ -60,21 +60,21 @@ describe("ANSI color parsing fix", function() noEmit = true, watch = false, } - + local result = utils.parse_flags(flags) - + assert.is_true(string.find(result, "--color false") ~= nil) end) - + it("Respects explicit color configuration", function() local flags = { noEmit = true, watch = false, color = true, } - + local result = utils.parse_flags(flags) - + assert.is_true(string.find(result, "--color") ~= nil) assert.is_true(string.find(result, "--color false") == nil) end) @@ -85,46 +85,46 @@ describe("Working directory mismatch fix", function() -- Mock vim.fn.findfile to return a relative path local original_findfile = vim.fn.findfile local original_fnamemodify = vim.fn.fnamemodify - + vim.fn.findfile = function(name, path) return "./tsconfig.json" end - + vim.fn.fnamemodify = function(path, modifier) if modifier == ":p" then return "/absolute/path/tsconfig.json" end return path end - + local result = utils.find_nearest_tsconfig() - + -- Restore original functions vim.fn.findfile = original_findfile vim.fn.fnamemodify = original_fnamemodify - + assert.equals("/absolute/path/tsconfig.json", result[1]) end) - + it("get_project_root returns correct directory", function() -- Mock vim.fn.fnamemodify local original_fnamemodify = vim.fn.fnamemodify - + vim.fn.fnamemodify = function(path, modifier) if modifier == ":h" then return "/absolute/path" end return path end - + local result = utils.get_project_root("/absolute/path/tsconfig.json") - + -- Restore original function vim.fn.fnamemodify = original_fnamemodify - + assert.equals("/absolute/path", result) end) - + it("get_project_root handles nil input", function() local result = utils.get_project_root(nil) assert.equals(nil, result) @@ -137,27 +137,27 @@ describe("TSC output parsing", function() "src/test.ts(10,5): error TS2304: Cannot find name 'foo'.", "src/other.ts(20,10): error TS2322: Type 'string' is not assignable to type 'number'.", } - + local config = { pretty_errors = false } local result = utils.parse_tsc_output(output, config) - + assert.equals(2, #result.errors) assert.equals("src/test.ts", result.errors[1].filename) assert.equals(10, result.errors[1].lnum) assert.equals(5, result.errors[1].col) assert.equals("error TS2304: Cannot find name 'foo'.", result.errors[1].text) - + assert.equals("src/other.ts", result.errors[2].filename) assert.equals(20, result.errors[2].lnum) assert.equals(10, result.errors[2].col) assert.equals("error TS2322: Type 'string' is not assignable to type 'number'.", result.errors[2].text) end) - + it("Handles empty output gracefully", function() local config = { pretty_errors = false } local result = utils.parse_tsc_output(nil, config) - + assert.equals(0, #result.errors) assert.equals(0, #result.files) end) -end) \ No newline at end of file +end) diff --git a/lua/tsc/utils.lua b/lua/tsc/utils.lua index 0813186..60fc40a 100644 --- a/lua/tsc/utils.lua +++ b/lua/tsc/utils.lua @@ -78,7 +78,7 @@ M.parse_flags = function(flags) flags.project = nearest_tsconfigs[1] end end - + -- Add --color false to ensure plain text output for parsing, unless user explicitly set color if flags.color == nil then flags.color = false