mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user