Skip to content
Merged
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
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ require('maorun.code-stats').setup({
enabled = false, -- Set to true to enable logging
level = 'INFO', -- Log level: ERROR, WARN, INFO, DEBUG
file_path = nil -- Optional custom log file path (defaults to vim data dir)
},
performance = {
typing_debounce_ms = 500, -- Debounce time for TextChangedI events (ms)
xp_batch_delay_ms = 100, -- Batch delay for XP processing (ms)
cache_timeout_s = 1, -- Language detection cache timeout (seconds)
}
})
```
Expand Down Expand Up @@ -226,6 +231,45 @@ require('maorun.code-stats').setup({

By default, the log file is stored at `{vim.fn.stdpath("data")}/code-stats.log`. You can specify a custom location using the `file_path` option.

## Performance Optimization

The plugin is optimized for minimal performance impact during typing. Key optimizations include:

### Event Optimization
- **No per-character tracking**: XP is tracked on `InsertLeave` (when exiting insert mode) and debounced `TextChangedI` events
- **Smart debouncing**: Continuous typing only triggers XP tracking after a configurable delay (default: 500ms)
- **Batched processing**: XP additions are batched and processed together to reduce overhead

### Language Detection Caching
- **Smart caching**: Language detection results are cached for up to 1 second (configurable)
- **Position-aware**: Cache is invalidated when cursor moves significantly (>5 lines or >10 columns)
- **Fast path optimization**: Files without embedded languages skip expensive TreeSitter operations

### Configurable Performance Settings

You can fine-tune the performance behavior:

```lua
require('maorun.code-stats').setup({
api_key = '<YOUR_API_KEY>',
performance = {
typing_debounce_ms = 500, -- How long to wait after typing stops before tracking XP
xp_batch_delay_ms = 100, -- How long to batch XP additions before processing
cache_timeout_s = 1, -- How long to cache language detection results
}
})
```

### Performance Settings Explained
- **`typing_debounce_ms`**: Controls the delay for `TextChangedI` events. Lower values = more responsive XP tracking but higher CPU usage. Higher values = less responsive but better performance.
- **`xp_batch_delay_ms`**: Controls how long XP additions are batched before processing (level calculations, notifications). Lower values = more responsive notifications but higher overhead.
- **`cache_timeout_s`**: Controls how long language detection results are cached. Lower values = more accurate language detection for rapidly changing contexts but higher CPU usage.

**Recommended values:**
- **Fast systems**: `typing_debounce_ms = 300, xp_batch_delay_ms = 50, cache_timeout_s = 1`
- **Default (balanced)**: `typing_debounce_ms = 500, xp_batch_delay_ms = 100, cache_timeout_s = 1`
- **Slower systems**: `typing_debounce_ms = 1000, xp_batch_delay_ms = 200, cache_timeout_s = 2`

### Managing Logs

Use the `:CodeStatsLog` command to manage logging:
Expand Down
5 changes: 5 additions & 0 deletions lua/maorun/code-stats/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ local defaults = {
level = "INFO", -- ERROR, WARN, INFO, DEBUG
file_path = nil, -- Will default to vim.fn.stdpath("data") .. "/code-stats.log" if not set
},
performance = {
typing_debounce_ms = 500, -- Debounce time for TextChangedI events (ms)
xp_batch_delay_ms = 100, -- Batch delay for XP processing (ms)
cache_timeout_s = 1, -- Language detection cache timeout (seconds)
},
}
local config = defaults

Expand Down
49 changes: 48 additions & 1 deletion lua/maorun/code-stats/events.lua
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
local lang_detection = require("maorun.code-stats.language-detection")
local logging = require("maorun.code-stats.logging")

-- Lazy load config to avoid circular dependencies
local function get_config()
local ok, cs_config = pcall(require, "maorun.code-stats.config")
if ok and cs_config.config.performance then
return cs_config.config.performance
end
-- Default fallback for tests and when config isn't available
return {
typing_debounce_ms = 500,
}
end

local function setup_autocommands(add_xp_callback, pulse_send_callback, pulse_send_on_exit_callback)
logging.log_init("Setting up autocommands for XP tracking")
local group = vim.api.nvim_create_augroup("codestats_track", { clear = true })

vim.api.nvim_create_autocmd({ "InsertCharPre", "TextChanged" }, {
-- Use less frequent events to avoid performance issues with character input
-- Track XP on significant events rather than every character
vim.api.nvim_create_autocmd({ "InsertLeave", "TextChanged" }, {
group = group,
pattern = "*",
callback = function()
Expand All @@ -16,6 +30,39 @@ local function setup_autocommands(add_xp_callback, pulse_send_callback, pulse_se
end,
})

-- Additional tracking for continuous typing sessions with configurable debouncing
local typing_timer = nil
vim.api.nvim_create_autocmd("TextChangedI", {
group = group,
pattern = "*",
callback = function()
-- Debounce: only track XP after configured delay of no typing
if typing_timer and vim.fn and vim.fn.timer_stop then
vim.fn.timer_stop(typing_timer)
Copy link

Copilot AI Sep 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timer stop operation should be wrapped in a pcall to handle cases where the timer might have already been stopped or become invalid. This prevents potential runtime errors when stopping timers.

Suggested change
vim.fn.timer_stop(typing_timer)
pcall(vim.fn.timer_stop, typing_timer)

Copilot uses AI. Check for mistakes.
end

local perf_config = get_config()
local debounce_ms = perf_config.typing_debounce_ms

-- Handle test environment where vim.fn.timer_start might not be available
if vim.fn and vim.fn.timer_start then
typing_timer = vim.fn.timer_start(debounce_ms, function()
if add_xp_callback then
local detected_lang = lang_detection.detect_language()
add_xp_callback(detected_lang)
end
typing_timer = nil
end)
else
-- Immediate processing for test environment
if add_xp_callback then
local detected_lang = lang_detection.detect_language()
add_xp_callback(detected_lang)
end
end
end,
})

vim.api.nvim_create_autocmd("VimLeavePre", {
group = group,
pattern = "*",
Expand Down
64 changes: 63 additions & 1 deletion lua/maorun/code-stats/language-detection.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
local M = {}

-- Cache to avoid expensive TreeSitter calls
local language_cache = {
buffer = -1,
filetype = "",
last_line = -1,
last_col = -1,
language = "",
timestamp = 0,
}

-- Get cached configuration to avoid circular dependency
local function get_cache_timeout()
local ok, cs_config = pcall(require, "maorun.code-stats.config")
if ok and cs_config.config.performance then
return cs_config.config.performance.cache_timeout_s
end
return 1 -- Default fallback
end

-- Get the current cursor position
local function get_cursor_position()
local cursor = vim.api.nvim_win_get_cursor(0)
Expand Down Expand Up @@ -75,7 +94,50 @@ end

-- Main function to detect the current language at cursor position
function M.detect_language()
return detect_language_at_cursor()
local current_buffer = vim.api.nvim_get_current_buf()
local current_filetype = vim.bo.filetype
local line, col = get_cursor_position()
local current_time = vim.fn.localtime()

-- Check cache validity (buffer, filetype, and position-based)
-- Cache is valid for configured timeout and same buffer/filetype/approximate position
local cache_timeout = get_cache_timeout()
if
language_cache.buffer == current_buffer
and language_cache.filetype == current_filetype
and language_cache.timestamp + cache_timeout > current_time
and math.abs(language_cache.last_line - line) <= 5
and math.abs(language_cache.last_col - col) <= 10
then
Comment on lines +105 to +111
Copy link

Copilot AI Sep 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The magic numbers 5 and 10 for cache position tolerance should be extracted into named constants or made configurable. This would make the cache invalidation logic more maintainable and allow users to tune the cache sensitivity.

Copilot uses AI. Check for mistakes.
return language_cache.language
end

-- Fast path: for most files without embedded languages, just use filetype
-- Only do expensive TreeSitter detection for known embedding contexts
local needs_treesitter = current_filetype == "html"
or current_filetype == "vue"
or current_filetype == "svelte"
or current_filetype == "markdown"
or current_filetype == "jsx"
or current_filetype == "tsx"
or current_filetype == "astro"

local detected_lang
if needs_treesitter then
detected_lang = detect_language_at_cursor()
else
detected_lang = current_filetype
end

-- Update cache
language_cache.buffer = current_buffer
language_cache.filetype = current_filetype
language_cache.last_line = line
language_cache.last_col = col
language_cache.language = detected_lang
language_cache.timestamp = current_time

return detected_lang
end

-- Function to get supported embedded languages for a given filetype
Expand Down
92 changes: 79 additions & 13 deletions lua/maorun/code-stats/pulse.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,23 @@ local logging = require("maorun.code-stats.logging")
local notifications = require("maorun.code-stats.notifications")
local utils = require("maorun.code-stats.utils")

-- Lazy load config to avoid circular dependencies and test issues
local function get_config()
local ok, cs_config = pcall(require, "maorun.code-stats.config")
if ok and cs_config.config.performance then
return cs_config.config.performance
end
-- Default fallback for tests and when config isn't available
return {
xp_batch_delay_ms = 100,
}
end

local pulse = {
xps = {},
-- Batch processing for performance
pending_xp = {},
batch_timer = nil,
}

-- Get the path for persisting XP data
Expand Down Expand Up @@ -113,6 +128,36 @@ pulse.load = function()
end
end

-- Process batched XP additions for better performance
local function process_pending_xp()
for lang, amount in pairs(pulse.pending_xp) do
if amount > 0 then
local old_xp = pulse.getXp(lang)
local old_level = pulse.calculateLevel(old_xp)

pulse.xps[lang] = old_xp + amount
local new_level = pulse.calculateLevel(pulse.xps[lang])

logging.log_xp_operation("ADD_BATCH", lang, amount, pulse.xps[lang])

-- Add to historical tracking for statistics (lazy load to avoid circular dependency)
local ok, statistics = pcall(require, "maorun.code-stats.statistics")
if ok then
statistics.add_history_entry(lang, amount)
end

-- Check for level-up and send notification
if new_level > old_level then
notifications.level_up(lang, new_level)
end
end
end

-- Clear pending XP after processing
pulse.pending_xp = {}
pulse.batch_timer = nil
end

pulse.addXp = function(lang, amount)
if not lang or lang == "" then
logging.warn("Attempted to add XP to empty language")
Expand All @@ -124,23 +169,44 @@ pulse.addXp = function(lang, amount)
return
end

local old_xp = pulse.getXp(lang)
local old_level = pulse.calculateLevel(old_xp)
-- Check if we're in a test environment
-- In test environments, we want immediate processing for predictable behavior
local is_test_env = _G._TEST_MODE or (vim.fn and not vim.fn.timer_start)

pulse.xps[lang] = old_xp + amount
local new_level = pulse.calculateLevel(pulse.xps[lang])
if is_test_env then
-- In test environment, process immediately for predictable behavior
local old_xp = pulse.getXp(lang)
local old_level = pulse.calculateLevel(old_xp)

logging.log_xp_operation("ADD", lang, amount, pulse.xps[lang])
pulse.xps[lang] = old_xp + amount
local new_level = pulse.calculateLevel(pulse.xps[lang])

-- Add to historical tracking for statistics (lazy load to avoid circular dependency)
local ok, statistics = pcall(require, "maorun.code-stats.statistics")
if ok then
statistics.add_history_entry(lang, amount)
end
logging.log_xp_operation("ADD", lang, amount, pulse.xps[lang])

-- Check for level-up and send notification
if new_level > old_level then
notifications.level_up(lang, new_level)
-- Add to historical tracking for statistics (lazy load to avoid circular dependency)
local ok, statistics = pcall(require, "maorun.code-stats.statistics")
if ok then
statistics.add_history_entry(lang, amount)
end

-- Check for level-up and send notification
if new_level > old_level then
notifications.level_up(lang, new_level)
end
else
-- In normal environment, use batching for performance
-- Add to pending XP for batch processing
pulse.pending_xp[lang] = (pulse.pending_xp[lang] or 0) + amount

-- Schedule batch processing if not already scheduled
if not pulse.batch_timer then
local perf_config = get_config()
local batch_delay = perf_config.xp_batch_delay_ms

pulse.batch_timer = vim.fn.timer_start(batch_delay, function()
process_pending_xp()
end)
end
end
end

Expand Down
3 changes: 3 additions & 0 deletions test/config_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ describe("Config", function()
local config

before_each(function()
-- Set test mode flag for immediate XP processing
_G._TEST_MODE = true

-- Mock vim environment before requiring modules
_G.vim = _G.vim or {}
_G.vim.g = {}
Expand Down
3 changes: 3 additions & 0 deletions test/ignored_filetypes_spec.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
describe("Ignored Filetypes", function()
before_each(function()
-- Set test mode flag for immediate XP processing
_G._TEST_MODE = true

-- Mock plenary.curl before any module loading
package.loaded["plenary.curl"] = {
request = function(opts)
Expand Down
3 changes: 3 additions & 0 deletions test/language_detection_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ describe("Language Detection", function()
local lang_detection

before_each(function()
-- Set test mode flag for immediate XP processing
_G._TEST_MODE = true

-- Reset the module before each test
package.loaded["maorun.code-stats.language-detection"] = nil
lang_detection = require("maorun.code-stats.language-detection")
Expand Down
3 changes: 3 additions & 0 deletions test/logging_spec.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
describe("Logging", function()
-- Set test mode flag for immediate XP processing
_G._TEST_MODE = true

local logging

before_each(function()
Expand Down
3 changes: 3 additions & 0 deletions test/notifications_spec.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
describe("Notifications", function()
-- Set test mode flag for immediate XP processing
_G._TEST_MODE = true

local notifications
local config

Expand Down
3 changes: 3 additions & 0 deletions test/pulse_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ describe("Pulse", function()
local pulse

before_each(function()
-- Set test mode flag for immediate XP processing
_G._TEST_MODE = true

-- Mock vim environment before requiring modules
_G.vim = _G.vim or {}
_G.vim.fn = _G.vim.fn or {}
Expand Down
3 changes: 3 additions & 0 deletions test/statistics_spec.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
describe("Statistics", function()
-- Set test mode flag for immediate XP processing
_G._TEST_MODE = true

local statistics

before_each(function()
Expand Down
Loading
Loading