From 0dc8a0ec0f2e5408d9cb9cdbc55c3cb49faab929 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Feb 2026 01:04:23 -0500 Subject: [PATCH] fix(parser): detect filetype from file content (shebang/modeline) When a file has no extension but contains a shebang (e.g., `#!/bin/bash`), filetype detection now reads the first 10 lines from disk and uses `vim.filetype.match({ filename, contents })` for content-based detection. This enables syntax highlighting for files like `build` scripts that rely on shebang detection, even when the file isn't open in a buffer. Detection order: 1. Existing buffer's filetype (already implemented in #69) 2. File content (shebang/modeline) - NEW 3. Filename extension only Also adds `filetype on` to test helpers to ensure `vim.g.ft_ignore_pat` is set, which is required for shell detection. --- lua/diffs/parser.lua | 30 +++++++++++++++++++++ spec/helpers.lua | 2 ++ spec/parser_spec.lua | 62 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index 528327b..d20b95e 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -13,12 +13,33 @@ local M = {} local dbg = require('diffs.log').dbg +---@param filepath string +---@param n integer +---@return string[]? +local function read_first_lines(filepath, n) + local f = io.open(filepath, 'r') + if not f then + return nil + end + local lines = {} + for _ = 1, n do + local line = f:read('*l') + if not line then + break + end + table.insert(lines, line) + end + f:close() + return #lines > 0 and lines or nil +end + ---@param filename string ---@param repo_root string? ---@return string? local function get_ft_from_filename(filename, repo_root) if repo_root then local full_path = vim.fs.joinpath(repo_root, filename) + local buf = vim.fn.bufnr(full_path) if buf ~= -1 then local ft = vim.api.nvim_get_option_value('filetype', { buf = buf }) @@ -27,6 +48,15 @@ local function get_ft_from_filename(filename, repo_root) return ft end end + + local contents = read_first_lines(full_path, 10) + if contents then + local ft = vim.filetype.match({ filename = filename, contents = contents }) + if ft then + dbg('filetype from file content: %s', ft) + return ft + end + end end local ft = vim.filetype.match({ filename = filename }) diff --git a/spec/helpers.lua b/spec/helpers.lua index db9016a..34128ac 100644 --- a/spec/helpers.lua +++ b/spec/helpers.lua @@ -2,6 +2,8 @@ local plugin_dir = vim.fn.getcwd() vim.opt.runtimepath:prepend(plugin_dir) vim.opt.packpath = {} +vim.cmd('filetype on') + local function ensure_parser(lang) local ok = pcall(vim.treesitter.language.inspect, lang) if not ok then diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index e5e1dba..89d0ac8 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -359,5 +359,67 @@ describe('parser', function() delete_buffer(file_buf) delete_buffer(diff_buf) end) + + it('detects filetype from file content shebang without open buffer', function() + local repo_root = '/tmp/diffs-test-shebang' + vim.fn.mkdir(repo_root, 'p') + + local file_path = repo_root .. '/build' + local f = io.open(file_path, 'w') + f:write('#!/bin/bash\n') + f:write('set -e\n') + f:write('echo "hello"\n') + f:close() + + local diff_buf = create_buffer({ + 'M build', + '@@ -1,2 +1,3 @@', + ' #!/bin/bash', + '+set -e', + ' echo "hello"', + }) + vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) + + local hunks = parser.parse_buffer(diff_buf) + + assert.are.equal(1, #hunks) + assert.are.equal('build', hunks[1].filename) + assert.are.equal('sh', hunks[1].ft) + + delete_buffer(diff_buf) + os.remove(file_path) + vim.fn.delete(repo_root, 'rf') + end) + + it('detects python from shebang without open buffer', function() + local repo_root = '/tmp/diffs-test-shebang-py' + vim.fn.mkdir(repo_root, 'p') + + local file_path = repo_root .. '/deploy' + local f = io.open(file_path, 'w') + f:write('#!/usr/bin/env python3\n') + f:write('import sys\n') + f:write('print("hi")\n') + f:close() + + local diff_buf = create_buffer({ + 'M deploy', + '@@ -1,2 +1,3 @@', + ' #!/usr/bin/env python3', + '+import sys', + ' print("hi")', + }) + vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) + + local hunks = parser.parse_buffer(diff_buf) + + assert.are.equal(1, #hunks) + assert.are.equal('deploy', hunks[1].filename) + assert.are.equal('python', hunks[1].ft) + + delete_buffer(diff_buf) + os.remove(file_path) + vim.fn.delete(repo_root, 'rf') + end) end) end)