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
1 change: 1 addition & 0 deletions default_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ waiting = "steady_underscore"
# extensions = ["build"]
# filenames = ["Buildfile"]
# aliases = ["build-script"]
# shebangs = ["build-script"]
# comment = "# %s"
# indent_width = 2
#
Expand Down
29 changes: 27 additions & 2 deletions docs/LANGUAGES.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Language extensions

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

Expand Down Expand Up @@ -32,7 +32,32 @@ validate = true
```

Extensions are case-insensitive and may start with a dot. Exact filenames are
case-sensitive and take precedence over extensions. Aliases are accepted by
case-sensitive and take precedence over extensions. When no filename or extension matches, Red reads up to 512 characters from the
first line and checks its shebang. Bundled interpreters include `sh`, `bash`,
`dash`, `ash`, `zsh` (using Bash syntax), `fish`, `pwsh`, `powershell`, `node`,
`nodejs`, `lua`, `luajit`, and `husk`. No executable permission is required.

Add interpreter basenames to any language definition or language-pack manifest:

```toml
[languages.python]
shebangs = ["python", "python3", "pypy", "pypy3"]
```

This registers detection; Python highlighting still requires its language pack.
Direct paths and common `env` forms are supported, including `env -S bash -eu`,
`-i`, `-u NAME`, `-C DIR`, `--`, and environment assignments. Numeric interpreter
versions such as `python3.12` fall back to the registered base name. Quoted
commands, shell expansion, and arbitrary wrapper commands are not interpreted.
An unknown or overlong shebang leaves the language undetected.

Detection uses the current buffer, so editing the first line updates syntax even
when it is offscreen. `:syntax <language>` overrides detection and `:syntax off`
disables highlighting and structural operations. Shebang detection also applies
to full-source previews. LSP routing still requires a filename or extension
selector; this feature does not attach language servers to extensionless files.

Aliases are accepted by
`:syntax build-script`, syntax completion, and Markdown code fences.

Every field is optional. A grammar-free language can still provide syntax
Expand Down
2 changes: 1 addition & 1 deletion src/buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ pub struct SearchMatch {
/// Buffer-local syntax-highlighting selection.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum SyntaxSelection {
/// Detect syntax from the buffer's file name.
/// Detect syntax from the file name, then the first-line shebang.
#[default]
Auto,
/// Disable syntax highlighting.
Expand Down
4 changes: 4 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,9 @@ pub struct LanguageConfig {
/// Case-sensitive exact file names, such as `Dockerfile` or `Makefile`.
#[serde(default)]
pub filenames: Vec<String>,
/// Interpreter basenames recognized in a first-line shebang.
#[serde(default)]
pub shebangs: Vec<String>,
/// Additional names accepted by syntax selection and injected fenced blocks.
#[serde(default)]
pub aliases: Vec<String>,
Expand Down Expand Up @@ -2285,6 +2288,7 @@ fn known_schema_path(path: &[String]) -> bool {
"extensions"
| "filenames"
| "aliases"
| "shebangs"
| "comment"
| "text_width"
| "indent_width"
Expand Down
79 changes: 77 additions & 2 deletions src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7278,7 +7278,10 @@ impl Editor {
match buffer.syntax_selection() {
SyntaxSelection::Auto => self
.highlighter
.language_id_for_file(buffer.file.as_deref())
.language_id_for_source(
buffer.file.as_deref(),
&buffer.line_prefix_contents(0, crate::highlighter::MAX_SHEBANG_CHARS + 1),
)
.map(ToString::to_string),
SyntaxSelection::Off => None,
SyntaxSelection::Language(language) => self
Expand Down Expand Up @@ -19424,7 +19427,12 @@ impl Editor {
}

self.highlighter
.language_id_for_file(self.current_buffer().file.as_deref())
.language_id_for_source(
self.current_buffer().file.as_deref(),
&self
.current_buffer()
.line_prefix_contents(0, crate::highlighter::MAX_SHEBANG_CHARS + 1),
)
.map(str::to_string)
.or_else(|| self.current_buffer().file_type())
}
Expand Down Expand Up @@ -34479,6 +34487,33 @@ builtin = "rust"
assert_eq!(editor.config.commenting.languages["buildspec"], "# %s");
}

#[tokio::test]
async fn shebang_reload_updates_open_unsaved_buffer() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml");
std::fs::write(
&path,
"[languages.custom]\nshebangs = ['custom']\ncomment = '# %s'\nindent_width = 2\n",
)
.unwrap();
let mut editor = test_editor(80, 12);
editor.buffer_manager[0] = Buffer::new(None, "#!/bin/custom\nhello\n".into());
editor.current_buffer_mut().insert_str(0, 1, "changed ");
let contents = editor.current_buffer().contents();
let revision = editor.current_buffer().revision();
editor.set_language_reload_source(path.clone(), Vec::new());
editor.reload_languages().await.unwrap();
assert_eq!(editor.current_language_id().as_deref(), Some("custom"));
assert_eq!(editor.indentation().shift_width, 2);
assert!(editor.configured_comment_syntax().is_some());
assert_eq!(editor.current_buffer().contents(), contents);
assert_eq!(editor.current_buffer().revision(), revision);
assert!(editor.current_buffer().is_dirty());
std::fs::write(&path, "[languages.custom]\nshebangs = ['other']\n").unwrap();
editor.reload_languages().await.unwrap();
assert_eq!(editor.current_language_id(), None);
}

#[tokio::test]
async fn rejected_language_reload_keeps_previous_registry_and_configuration() {
let directory = tempfile::tempdir().unwrap();
Expand Down Expand Up @@ -38119,6 +38154,46 @@ builtin = "rust"
assert_eq!(span_shape(&reopened), span_shape(&expected));
}

#[test]
fn shebang_follows_edits_offscreen_and_manual_selection() {
let mut editor = rust_test_editor(100, 120, 22);
let text = format!(
"#!/bin/bash\n{}",
"if true; then echo hello; fi\n".repeat(100)
);
editor.buffer_manager[0] = Buffer::new(Some("script".into()), text);
assert!(!editor
.viewport_highlight_spans(0, 60, 20)
.unwrap()
.is_empty());
assert_eq!(
editor.highlight_cache[&0].language_id.as_deref(),
Some("bash")
);
assert_eq!(editor.current_language_id().as_deref(), Some("bash"));
assert!(editor.configured_comment_syntax().is_some());
editor.current_buffer_mut().insert_str(0, 0, "#");
assert!(editor
.viewport_highlight_spans(0, 60, 20)
.unwrap()
.is_empty());
editor
.current_buffer_mut()
.set_syntax_selection(SyntaxSelection::Language("fish".into()));
editor.viewport_highlight_spans(0, 60, 20).unwrap();
assert_eq!(
editor.highlight_cache[&0].language_id.as_deref(),
Some("fish")
);
editor
.current_buffer_mut()
.set_syntax_selection(SyntaxSelection::Off);
assert!(editor
.viewport_highlight_spans(0, 60, 20)
.unwrap()
.is_empty());
}

#[test]
fn viewport_highlight_cache_follows_buffer_syntax_selection() {
let mut editor = rust_test_editor(100, 120, 22);
Expand Down
Loading
Loading