Skip to content

Commit 6f1a133

Browse files
committed
feat(syntax): detect languages from shebangs
1 parent bfb9c66 commit 6f1a133

9 files changed

Lines changed: 406 additions & 27 deletions

File tree

default_config.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ waiting = "steady_underscore"
232232
# extensions = ["build"]
233233
# filenames = ["Buildfile"]
234234
# aliases = ["build-script"]
235+
# shebangs = ["build-script"]
235236
# comment = "# %s"
236237
# indent_width = 2
237238
#

docs/LANGUAGES.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Language extensions
22

33
Red uses one language definition for syntax highlighting, exact filename and
4-
extension detection, comment templates, indentation, and language-server
4+
extension and shebang detection, comment templates, indentation, and language-server
55
routing. Definitions can live in your configuration or in an installable
66
external plugin package.
77

@@ -32,7 +32,32 @@ validate = true
3232
```
3333

3434
Extensions are case-insensitive and may start with a dot. Exact filenames are
35-
case-sensitive and take precedence over extensions. Aliases are accepted by
35+
case-sensitive and take precedence over extensions. When no filename or extension matches, Red reads up to 512 characters from the
36+
first line and checks its shebang. Bundled interpreters include `sh`, `bash`,
37+
`dash`, `ash`, `zsh` (using Bash syntax), `fish`, `pwsh`, `powershell`, `node`,
38+
`nodejs`, `lua`, `luajit`, and `husk`. No executable permission is required.
39+
40+
Add interpreter basenames to any language definition or language-pack manifest:
41+
42+
```toml
43+
[languages.python]
44+
shebangs = ["python", "python3", "pypy", "pypy3"]
45+
```
46+
47+
This registers detection; Python highlighting still requires its language pack.
48+
Direct paths and common `env` forms are supported, including `env -S bash -eu`,
49+
`-i`, `-u NAME`, `-C DIR`, `--`, and environment assignments. Numeric interpreter
50+
versions such as `python3.12` fall back to the registered base name. Quoted
51+
commands, shell expansion, and arbitrary wrapper commands are not interpreted.
52+
An unknown or overlong shebang leaves the language undetected.
53+
54+
Detection uses the current buffer, so editing the first line updates syntax even
55+
when it is offscreen. `:syntax <language>` overrides detection and `:syntax off`
56+
disables highlighting and structural operations. Shebang detection also applies
57+
to full-source previews. LSP routing still requires a filename or extension
58+
selector; this feature does not attach language servers to extensionless files.
59+
60+
Aliases are accepted by
3661
`:syntax build-script`, syntax completion, and Markdown code fences.
3762

3863
Every field is optional. A grammar-free language can still provide syntax

src/buffer.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ pub struct SearchMatch {
119119
/// Buffer-local syntax-highlighting selection.
120120
#[derive(Debug, Clone, Default, PartialEq, Eq)]
121121
pub enum SyntaxSelection {
122-
/// Detect syntax from the buffer's file name.
122+
/// Detect syntax from the file name, then the first-line shebang.
123123
#[default]
124124
Auto,
125125
/// Disable syntax highlighting.

src/config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,6 +1065,9 @@ pub struct LanguageConfig {
10651065
/// Case-sensitive exact file names, such as `Dockerfile` or `Makefile`.
10661066
#[serde(default)]
10671067
pub filenames: Vec<String>,
1068+
/// Interpreter basenames recognized in a first-line shebang.
1069+
#[serde(default)]
1070+
pub shebangs: Vec<String>,
10681071
/// Additional names accepted by syntax selection and injected fenced blocks.
10691072
#[serde(default)]
10701073
pub aliases: Vec<String>,
@@ -2285,6 +2288,7 @@ fn known_schema_path(path: &[String]) -> bool {
22852288
"extensions"
22862289
| "filenames"
22872290
| "aliases"
2291+
| "shebangs"
22882292
| "comment"
22892293
| "text_width"
22902294
| "indent_width"

src/editor.rs

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7278,7 +7278,10 @@ impl Editor {
72787278
match buffer.syntax_selection() {
72797279
SyntaxSelection::Auto => self
72807280
.highlighter
7281-
.language_id_for_file(buffer.file.as_deref())
7281+
.language_id_for_source(
7282+
buffer.file.as_deref(),
7283+
&buffer.line_prefix_contents(0, crate::highlighter::MAX_SHEBANG_CHARS + 1),
7284+
)
72827285
.map(ToString::to_string),
72837286
SyntaxSelection::Off => None,
72847287
SyntaxSelection::Language(language) => self
@@ -19424,7 +19427,12 @@ impl Editor {
1942419427
}
1942519428

1942619429
self.highlighter
19427-
.language_id_for_file(self.current_buffer().file.as_deref())
19430+
.language_id_for_source(
19431+
self.current_buffer().file.as_deref(),
19432+
&self
19433+
.current_buffer()
19434+
.line_prefix_contents(0, crate::highlighter::MAX_SHEBANG_CHARS + 1),
19435+
)
1942819436
.map(str::to_string)
1942919437
.or_else(|| self.current_buffer().file_type())
1943019438
}
@@ -34479,6 +34487,33 @@ builtin = "rust"
3447934487
assert_eq!(editor.config.commenting.languages["buildspec"], "# %s");
3448034488
}
3448134489

34490+
#[tokio::test]
34491+
async fn shebang_reload_updates_open_unsaved_buffer() {
34492+
let directory = tempfile::tempdir().unwrap();
34493+
let path = directory.path().join("config.toml");
34494+
std::fs::write(
34495+
&path,
34496+
"[languages.custom]\nshebangs = ['custom']\ncomment = '# %s'\nindent_width = 2\n",
34497+
)
34498+
.unwrap();
34499+
let mut editor = test_editor(80, 12);
34500+
editor.buffer_manager[0] = Buffer::new(None, "#!/bin/custom\nhello\n".into());
34501+
editor.current_buffer_mut().insert_str(0, 1, "changed ");
34502+
let contents = editor.current_buffer().contents();
34503+
let revision = editor.current_buffer().revision();
34504+
editor.set_language_reload_source(path.clone(), Vec::new());
34505+
editor.reload_languages().await.unwrap();
34506+
assert_eq!(editor.current_language_id().as_deref(), Some("custom"));
34507+
assert_eq!(editor.indentation().shift_width, 2);
34508+
assert!(editor.configured_comment_syntax().is_some());
34509+
assert_eq!(editor.current_buffer().contents(), contents);
34510+
assert_eq!(editor.current_buffer().revision(), revision);
34511+
assert!(editor.current_buffer().is_dirty());
34512+
std::fs::write(&path, "[languages.custom]\nshebangs = ['other']\n").unwrap();
34513+
editor.reload_languages().await.unwrap();
34514+
assert_eq!(editor.current_language_id(), None);
34515+
}
34516+
3448234517
#[tokio::test]
3448334518
async fn rejected_language_reload_keeps_previous_registry_and_configuration() {
3448434519
let directory = tempfile::tempdir().unwrap();
@@ -38119,6 +38154,46 @@ builtin = "rust"
3811938154
assert_eq!(span_shape(&reopened), span_shape(&expected));
3812038155
}
3812138156

38157+
#[test]
38158+
fn shebang_follows_edits_offscreen_and_manual_selection() {
38159+
let mut editor = rust_test_editor(100, 120, 22);
38160+
let text = format!(
38161+
"#!/bin/bash\n{}",
38162+
"if true; then echo hello; fi\n".repeat(100)
38163+
);
38164+
editor.buffer_manager[0] = Buffer::new(Some("script".into()), text);
38165+
assert!(!editor
38166+
.viewport_highlight_spans(0, 60, 20)
38167+
.unwrap()
38168+
.is_empty());
38169+
assert_eq!(
38170+
editor.highlight_cache[&0].language_id.as_deref(),
38171+
Some("bash")
38172+
);
38173+
assert_eq!(editor.current_language_id().as_deref(), Some("bash"));
38174+
assert!(editor.configured_comment_syntax().is_some());
38175+
editor.current_buffer_mut().insert_str(0, 0, "#");
38176+
assert!(editor
38177+
.viewport_highlight_spans(0, 60, 20)
38178+
.unwrap()
38179+
.is_empty());
38180+
editor
38181+
.current_buffer_mut()
38182+
.set_syntax_selection(SyntaxSelection::Language("fish".into()));
38183+
editor.viewport_highlight_spans(0, 60, 20).unwrap();
38184+
assert_eq!(
38185+
editor.highlight_cache[&0].language_id.as_deref(),
38186+
Some("fish")
38187+
);
38188+
editor
38189+
.current_buffer_mut()
38190+
.set_syntax_selection(SyntaxSelection::Off);
38191+
assert!(editor
38192+
.viewport_highlight_spans(0, 60, 20)
38193+
.unwrap()
38194+
.is_empty());
38195+
}
38196+
3812238197
#[test]
3812338198
fn viewport_highlight_cache_follows_buffer_syntax_selection() {
3812438199
let mut editor = rust_test_editor(100, 120, 22);

0 commit comments

Comments
 (0)