feat(editor): detect the language of extensionless scripts from their shebang (#25055)

Extensionless files such as bin/deploy now pick their syntax highlighting
from a leading shebang (python, shell, node, ruby, perl, php, lua,
powershell, ts-node/tsx) via the shared setup-script shebang parser.
Named files and unmapped extensions keep their name-based language, and
diff tabs stay name-based so highlighting does not flicker on save.

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
TheCaptain
2026-10-27 23:02:56 -07:00
committed by GitHub
co-authored by Neil
parent 4dca163bb7
commit fed9a762be
4 changed files with 97 additions and 4 deletions
@@ -129,6 +129,33 @@ describe('getEditorPanelRenderModel HTML preview affordance', () => {
})
})
describe('getEditorPanelRenderModel shebang language fallback', () => {
it('uses the shebang when an extensionless file has no name-based language', () => {
const scriptFile: OpenFile = {
id: '/repo/bin/deploy',
filePath: '/repo/bin/deploy',
relativePath: 'bin/deploy',
worktreeId: 'wt-1',
language: 'plaintext',
mode: 'edit',
isDirty: false
}
const model = renderModel({
activeFile: scriptFile,
fileContents: {
[scriptFile.id]: textContent({ content: '#!/usr/bin/env python3\nprint(1)' })
}
})
expect(model.resolvedLanguage).toBe('python')
const diffModel = renderModel({
activeFile: { ...scriptFile, mode: 'diff', diffSource: 'unstaged' },
editorDrafts: { [scriptFile.id]: '#!/usr/bin/env python3\nprint(2)' }
})
expect(diffModel.resolvedLanguage).toBe('plaintext')
})
})
describe('getEditorPanelRenderModel read-only raw rendering (AI Vault View Log)', () => {
it('renders a read-only markdown log as raw source with no markdown viewer or chrome', () => {
const model = renderModel({ activeFile: markdownFile({ readOnly: true }) })
@@ -54,10 +54,14 @@ export function getEditorPanelRenderModel({
activeFile.diffSource === 'combined-uncommitted' ||
activeFile.diffSource === 'combined-branch' ||
activeFile.diffSource === 'combined-commit')
// Why: diff drafts vanish on save, so shebang detection there would flicker; diffs stay name-based.
const resolvedLanguage =
activeFile.mode === 'diff'
? detectLanguage(activeFile.relativePath)
: detectLanguage(activeFile.filePath)
: detectLanguage(
activeFile.filePath,
editorDrafts[activeFile.id] ?? fileContents[activeFile.id]?.content
)
// Read-only artifact tabs require raw source viewers; retain real-language tokenization while suppressing rich/preview modes and their editing affordances.
const rawReadOnly = activeFile.mode === 'edit' && activeFile.readOnly === true
const viewerLanguage = rawReadOnly ? 'plaintext' : resolvedLanguage
@@ -254,4 +254,25 @@ describe('detectLanguage', () => {
])('maps exact filenames case-insensitively: %s', (filePath, expected) => {
expect(detectLanguage(filePath)).toBe(expected)
})
it.each([
['#!/usr/bin/python3\n', 'python'],
['#!/usr/bin/env bash\necho hi', 'shell'],
['#! /bin/sh -e\n', 'shell'],
['#!/usr/bin/env -S node --flag', 'javascript'],
['#!C:\\Python\\python.exe\n', 'python'],
['#!/usr/bin/env python3.12', 'python'],
['#!/usr/bin/ruby\r\nputs 1', 'ruby'],
['#!/usr/bin/env awk', 'plaintext'],
['#!/usr/bin/env toString', 'plaintext'],
['#!/usr/bin/__proto__', 'plaintext'],
['echo hi\n#!/bin/sh', 'plaintext']
])('falls back to the shebang for extensionless files: %j', (content, expected) => {
expect(detectLanguage('bin/tool', content)).toBe(expected)
})
it('only applies the shebang to extensionless names', () => {
expect(detectLanguage('script.rb', '#!/usr/bin/env python3')).toBe('ruby')
expect(detectLanguage('Makefile', '#!/usr/bin/env python3')).toBe('makefile')
expect(detectLanguage('notes.txt', '#!/usr/bin/env python3')).toBe('plaintext')
})
})
@@ -1,3 +1,4 @@
import { parseSetupScriptShebang } from '../setup-script-shebang'
import { detectMonacoFilenameLanguage } from './monaco-filename-language'
function extname(filePath: string): string {
@@ -145,7 +146,43 @@ const FILENAME_LOWER_TO_LANGUAGE: Record<string, string> = Object.fromEntries(
Object.entries(FILENAME_TO_LANGUAGE).map(([name, language]) => [name.toLowerCase(), language])
)
export function detectLanguage(filePath: string): string {
const SHEBANG_INTERPRETER_TO_LANGUAGE: Record<string, string> = {
sh: 'shell',
bash: 'shell',
zsh: 'shell',
dash: 'shell',
ksh: 'shell',
ash: 'shell',
fish: 'shell',
python: 'python',
pypy: 'python',
node: 'javascript',
nodejs: 'javascript',
deno: 'javascript',
bun: 'javascript',
'ts-node': 'typescript',
tsx: 'typescript',
ruby: 'ruby',
perl: 'perl',
php: 'php',
lua: 'lua',
luajit: 'lua',
pwsh: 'powershell',
powershell: 'powershell'
}
function detectShebangLanguage(content: string): string | null {
// Why: scan a bounded prefix so per-keystroke detection stays cheap on large files.
const interpreter = parseSetupScriptShebang(content.slice(0, 256))?.interpreter.replace(
/[\d.]+$/,
''
)
return interpreter !== undefined && Object.hasOwn(SHEBANG_INTERPRETER_TO_LANGUAGE, interpreter)
? SHEBANG_INTERPRETER_TO_LANGUAGE[interpreter]
: null
}
export function detectLanguage(filePath: string, content?: string): string {
const parts = filePath.split(/[\\/]/)
const filename = parts.at(-1)!
if (Object.hasOwn(FILENAME_TO_LANGUAGE, filename)) {
@@ -159,9 +196,13 @@ export function detectLanguage(filePath: string): string {
const ext = extname(filename).toLowerCase()
const lowerName = filename.toLowerCase()
// Scoped dotenv names fall back to INI only when no specific extension matches.
return (
const language =
EXT_TO_LANGUAGE[ext] ??
detectMonacoFilenameLanguage(filename) ??
(lowerName === '.env' || lowerName.startsWith('.env.') ? 'ini' : 'plaintext')
)
// Why: only extensionless names fall back to the shebang; unmapped extensions like .txt stay plaintext.
if (language === 'plaintext' && ext === '' && content !== undefined) {
return detectShebangLanguage(content) ?? 'plaintext'
}
return language
}