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 23648e0..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. 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. 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,14 +84,11 @@ 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 = { noEmit = true, - project = function() - return utils.find_nearest_tsconfig() - end, watch = false, }, hide_progress_notifications_from_history = true, @@ -100,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`), 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. + +**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", diff --git a/lua/tsc/init.lua b/lua/tsc/init.lua index 9e79307..67328ea 100644 --- a/lua/tsc/init.lua +++ b/lua/tsc/init.lua @@ -40,7 +40,6 @@ local DEFAULT_CONFIG = { max_tsconfig_files = 20, flags = { noEmit = true, - project = nil, watch = false, }, hide_progress_notifications_from_history = true, @@ -294,6 +293,12 @@ M.run = function() end end + -- Set working directory to project root if available + local project_root = utils.get_project_root(project) + if project_root then + project_opts.cwd = project_root + end + local flags = "" if type(config.flags) == "string" then flags = config.flags diff --git a/lua/tsc/utils-test.lua b/lua/tsc/utils-test.lua new file mode 100644 index 0000000..404e550 --- /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 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) + +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[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) + 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) diff --git a/lua/tsc/utils.lua b/lua/tsc/utils.lua index 6433bcb..60fc40a 100644 --- a/lua/tsc/utils.lua +++ b/lua/tsc/utils.lua @@ -51,12 +51,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 {} 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 @@ -64,6 +71,19 @@ M.parse_flags = function(flags) local parsed_flags = "" + -- Auto-detect project if not explicitly configured + if not flags.project then + local nearest_tsconfigs = M.find_nearest_tsconfig() + if #nearest_tsconfigs > 0 then + 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 + end + for key, value in pairs(flags) do key = string.gsub(key, "%-%-", "")