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
3 changes: 2 additions & 1 deletion lua/shelltime/heartbeat.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lua/shelltime/utils/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
85 changes: 85 additions & 0 deletions lua/shelltime/utils/language.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
-- 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 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()
return extension_map[ext] or ext
end

-- Special case: Dockerfile, Makefile, etc.
local basename = filename:lower()
if basename == 'dockerfile' then
return 'dockerfile'
elseif basename == 'makefile' then
return 'makefile'
end

return ''
Comment on lines +74 to +82
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The special filename handling can be made more maintainable and scalable by using a lookup table instead of an if/elseif chain. This makes it easier to add more special filenames in the future.

For better performance, you could also consider moving the special_filenames table outside the get_language function to avoid re-creating it on every call.

  -- Special case: Dockerfile, Makefile, etc.
  local special_filenames = {
    dockerfile = 'dockerfile',
    makefile = 'makefile',
  }
  local filename = file_path:match('([^/\\]+)$')
  if filename then
    local lang = special_filenames[filename:lower()]
    if lang then
      return lang
    end
  end

  return ''

end

return M
1 change: 1 addition & 0 deletions tests/helpers/reset.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
163 changes: 163 additions & 0 deletions tests/language_spec.lua
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +11 to +39
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This block of tests is quite repetitive. You can use a data-driven approach to make the tests more concise and easier to maintain. This involves defining a table of test cases and iterating over it to create the tests.

This pattern can also be applied to other repetitive test blocks in this file, such as 'when filetype is empty' and 'special filenames'.

    describe('when filetype is available', function()
      local test_cases = {
        { name = 'lua', filetype = 'lua', path = '/path/to/file.lua' },
        { name = 'python', filetype = 'python', path = '/path/to/file.py' },
        { name = 'typescript', filetype = 'typescript', path = '/path/to/file.ts' },
        { name = 'javascript', filetype = 'javascript', path = '/path/to/file.js' },
        { name = 'sh', filetype = 'sh', path = '/path/to/file.sh' },
        { name = 'vim', filetype = 'vim', path = '/path/to/file.vim' },
        { name = 'python with different extension', filetype = 'python', path = '/path/to/file.txt' },
      }

      for _, tc in ipairs(test_cases) do
        it('should return filetype as-is for ' .. tc.name, function()
          assert.equals(tc.filetype, language.get_language(tc.filetype, tc.path))
        end)
      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)