From 49bc1217e3237eb4a8eff2ad564e82c1d2e6a483 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sun, 28 Dec 2025 23:33:05 +0800 Subject: [PATCH 1/2] fix(heartbeat): add language fallback detection from file extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When vim.bo[bufnr].filetype is empty, detect language from file extension. This ensures the language field is populated even when Neovim's filetype detection doesn't recognize the file. - Add lua/shelltime/utils/language.lua with extension mapping - Update heartbeat.lua to use lang.get_language() - Add comprehensive tests for language detection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- lua/shelltime/heartbeat.lua | 3 +- lua/shelltime/utils/init.lua | 1 + lua/shelltime/utils/language.lua | 78 +++++++++++++++ tests/helpers/reset.lua | 1 + tests/language_spec.lua | 163 +++++++++++++++++++++++++++++++ 5 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 lua/shelltime/utils/language.lua create mode 100644 tests/language_spec.lua diff --git a/lua/shelltime/heartbeat.lua b/lua/shelltime/heartbeat.lua index 3b63e5e..c54d60e 100644 --- a/lua/shelltime/heartbeat.lua +++ b/lua/shelltime/heartbeat.lua @@ -3,6 +3,7 @@ local config = require('shelltime.config') local system = require('shelltime.utils.system') local git = require('shelltime.utils.git') +local lang = require('shelltime.utils.language') local M = {} @@ -103,7 +104,7 @@ local function create_heartbeat(bufnr, is_write) project = system.get_project_name(project_root), projectRootPath = project_root, branch = git.get_branch(file_path), - language = vim.bo[bufnr].filetype or '', + language = lang.get_language(vim.bo[bufnr].filetype, file_path), lines = vim.api.nvim_buf_line_count(bufnr), lineNumber = cursor[1], -- Already 1-indexed cursorPosition = cursor[2], -- 0-indexed column diff --git a/lua/shelltime/utils/init.lua b/lua/shelltime/utils/init.lua index 7ea34db..eaca1e4 100644 --- a/lua/shelltime/utils/init.lua +++ b/lua/shelltime/utils/init.lua @@ -5,5 +5,6 @@ local M = {} M.system = require('shelltime.utils.system') M.git = require('shelltime.utils.git') M.yaml = require('shelltime.utils.yaml') +M.language = require('shelltime.utils.language') return M diff --git a/lua/shelltime/utils/language.lua b/lua/shelltime/utils/language.lua new file mode 100644 index 0000000..4925cc1 --- /dev/null +++ b/lua/shelltime/utils/language.lua @@ -0,0 +1,78 @@ +-- Language detection utilities for shelltime +-- Provides fallback language detection from file extension when filetype is empty + +local M = {} + +-- File extension to language mapping (used only when filetype is empty) +local extension_map = { + lua = 'lua', + py = 'python', + js = 'javascript', + ts = 'typescript', + tsx = 'typescriptreact', + jsx = 'javascriptreact', + rs = 'rust', + go = 'go', + rb = 'ruby', + php = 'php', + java = 'java', + kt = 'kotlin', + swift = 'swift', + c = 'c', + cpp = 'cpp', + cc = 'cpp', + cxx = 'cpp', + h = 'c', + hpp = 'cpp', + cs = 'csharp', + sh = 'sh', + bash = 'bash', + zsh = 'zsh', + fish = 'fish', + html = 'html', + css = 'css', + scss = 'scss', + less = 'less', + json = 'json', + yaml = 'yaml', + yml = 'yaml', + xml = 'xml', + md = 'markdown', + sql = 'sql', + vim = 'vim', + dockerfile = 'dockerfile', + toml = 'toml', + ini = 'ini', + conf = 'conf', +} + +--- Get language for a file +---@param filetype string|nil Buffer filetype +---@param file_path string File path +---@return string Language identifier +function M.get_language(filetype, file_path) + -- Use filetype if available + if filetype and filetype ~= '' then + return filetype + end + + -- Fallback: detect from file extension + local ext = file_path:match('%.([^%.]+)$') + if ext then + ext = ext:lower() + return extension_map[ext] or ext + end + + -- Special case: Dockerfile, Makefile, etc. + local filename = file_path:match('[/\\]?([^/\\]+)$') or '' + local basename = filename:lower() + if basename == 'dockerfile' then + return 'dockerfile' + elseif basename == 'makefile' then + return 'makefile' + end + + return '' +end + +return M diff --git a/tests/helpers/reset.lua b/tests/helpers/reset.lua index 1e62a5f..caa4faf 100644 --- a/tests/helpers/reset.lua +++ b/tests/helpers/reset.lua @@ -20,6 +20,7 @@ function M.reset_all() 'shelltime.utils.system', 'shelltime.utils.git', 'shelltime.utils.yaml', + 'shelltime.utils.language', } for _, name in ipairs(modules) do package.loaded[name] = nil diff --git a/tests/language_spec.lua b/tests/language_spec.lua new file mode 100644 index 0000000..168b0f5 --- /dev/null +++ b/tests/language_spec.lua @@ -0,0 +1,163 @@ +-- Tests for shelltime/utils/language.lua +describe('shelltime.utils.language', function() + local language + + before_each(function() + package.loaded['shelltime.utils.language'] = nil + language = require('shelltime.utils.language') + end) + + describe('get_language', function() + describe('when filetype is available', function() + it('should return filetype as-is for lua', function() + assert.equals('lua', language.get_language('lua', '/path/to/file.lua')) + end) + + it('should return filetype as-is for python', function() + assert.equals('python', language.get_language('python', '/path/to/file.py')) + end) + + it('should return filetype as-is for typescript', function() + assert.equals('typescript', language.get_language('typescript', '/path/to/file.ts')) + end) + + it('should return filetype as-is for javascript', function() + assert.equals('javascript', language.get_language('javascript', '/path/to/file.js')) + end) + + it('should return filetype as-is for sh', function() + assert.equals('sh', language.get_language('sh', '/path/to/file.sh')) + end) + + it('should return filetype as-is for vim', function() + assert.equals('vim', language.get_language('vim', '/path/to/file.vim')) + end) + + it('should return filetype even if extension differs', function() + assert.equals('python', language.get_language('python', '/path/to/file.txt')) + end) + end) + + describe('when filetype is empty', function() + it('should detect lua from extension', function() + assert.equals('lua', language.get_language('', '/path/to/file.lua')) + end) + + it('should detect python from .py extension', function() + assert.equals('python', language.get_language('', '/path/to/file.py')) + end) + + it('should detect javascript from .js extension', function() + assert.equals('javascript', language.get_language('', '/path/to/file.js')) + end) + + it('should detect typescript from .ts extension', function() + assert.equals('typescript', language.get_language('', '/path/to/file.ts')) + end) + + it('should detect typescriptreact from .tsx extension', function() + assert.equals('typescriptreact', language.get_language('', '/path/to/file.tsx')) + end) + + it('should detect javascriptreact from .jsx extension', function() + assert.equals('javascriptreact', language.get_language('', '/path/to/file.jsx')) + end) + + it('should detect rust from .rs extension', function() + assert.equals('rust', language.get_language('', '/path/to/file.rs')) + end) + + it('should detect go from .go extension', function() + assert.equals('go', language.get_language('', '/path/to/file.go')) + end) + + it('should detect csharp from .cs extension', function() + assert.equals('csharp', language.get_language('', '/path/to/file.cs')) + end) + + it('should detect cpp from .cpp extension', function() + assert.equals('cpp', language.get_language('', '/path/to/file.cpp')) + end) + + it('should detect cpp from .cc extension', function() + assert.equals('cpp', language.get_language('', '/path/to/file.cc')) + end) + + it('should detect yaml from .yaml extension', function() + assert.equals('yaml', language.get_language('', '/path/to/file.yaml')) + end) + + it('should detect yaml from .yml extension', function() + assert.equals('yaml', language.get_language('', '/path/to/file.yml')) + end) + + it('should detect markdown from .md extension', function() + assert.equals('markdown', language.get_language('', '/path/to/file.md')) + end) + + it('should detect json from .json extension', function() + assert.equals('json', language.get_language('', '/path/to/file.json')) + end) + + it('should handle uppercase extensions', function() + assert.equals('lua', language.get_language('', '/path/to/file.LUA')) + end) + + it('should handle mixed case extensions', function() + assert.equals('python', language.get_language('', '/path/to/file.Py')) + end) + + it('should return unknown extension as-is', function() + assert.equals('xyz', language.get_language('', '/path/to/file.xyz')) + end) + end) + + describe('special filenames', function() + it('should detect dockerfile', function() + assert.equals('dockerfile', language.get_language('', '/path/to/Dockerfile')) + end) + + it('should detect dockerfile case insensitive', function() + assert.equals('dockerfile', language.get_language('', '/path/to/dockerfile')) + end) + + it('should detect makefile', function() + assert.equals('makefile', language.get_language('', '/path/to/Makefile')) + end) + + it('should detect makefile case insensitive', function() + assert.equals('makefile', language.get_language('', '/path/to/makefile')) + end) + end) + + describe('edge cases', function() + it('should return empty string for nil filetype and no extension', function() + assert.equals('', language.get_language(nil, '/path/to/file')) + end) + + it('should return empty string for empty filetype and no extension', function() + assert.equals('', language.get_language('', '/path/to/file')) + end) + + it('should handle file with multiple dots', function() + assert.equals('lua', language.get_language('', '/path/to/file.test.lua')) + end) + + it('should handle hidden files with extension', function() + assert.equals('yaml', language.get_language('', '/path/to/.hidden.yaml')) + end) + + it('should return empty for hidden files without extension', function() + assert.equals('', language.get_language('', '/path/to/.hidden')) + end) + + it('should handle nil filetype', function() + assert.equals('lua', language.get_language(nil, '/path/to/file.lua')) + end) + + it('should prefer filetype over extension', function() + assert.equals('typescriptreact', language.get_language('typescriptreact', '/path/to/file.tsx')) + end) + end) + end) +end) From 38c3e34b40319e4a3a2e7dff108c564d54d23b9e Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sun, 28 Dec 2025 23:38:20 +0800 Subject: [PATCH 2/2] fix(language): handle hidden files without extension correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files like .hidden, .gitignore should return empty string, not the filename after the dot. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- lua/shelltime/utils/language.lua | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lua/shelltime/utils/language.lua b/lua/shelltime/utils/language.lua index 4925cc1..3e5dea0 100644 --- a/lua/shelltime/utils/language.lua +++ b/lua/shelltime/utils/language.lua @@ -57,6 +57,14 @@ function M.get_language(filetype, file_path) end -- Fallback: detect from file extension + local filename = file_path:match('[/\\]?([^/\\]+)$') or '' + + -- Check for hidden files without extension (e.g., .hidden, .gitignore) + -- These start with a dot and have no other dots + if filename:match('^%.[^%.]+$') then + return '' + end + local ext = file_path:match('%.([^%.]+)$') if ext then ext = ext:lower() @@ -64,7 +72,6 @@ function M.get_language(filetype, file_path) end -- Special case: Dockerfile, Makefile, etc. - local filename = file_path:match('[/\\]?([^/\\]+)$') or '' local basename = filename:lower() if basename == 'dockerfile' then return 'dockerfile'