Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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,
Expand All @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion lua/tsc/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ local DEFAULT_CONFIG = {
max_tsconfig_files = 20,
flags = {
noEmit = true,
project = nil,
watch = false,
},
hide_progress_notifications_from_history = true,
Expand Down Expand Up @@ -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
Expand Down
163 changes: 163 additions & 0 deletions lua/tsc/utils-test.lua
Original file line number Diff line number Diff line change
@@ -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)
22 changes: 21 additions & 1 deletion lua/tsc/utils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,39 @@ 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
end

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, "%-%-", "")

Expand Down
Loading