From 6f1a1337c61af9faf918f9f3aff1335730d3db2e Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 4 Sep 2026 23:38:24 -0300 Subject: [PATCH 1/2] feat(syntax): detect languages from shebangs --- default_config.toml | 1 + docs/LANGUAGES.md | 29 +++++- src/buffer.rs | 2 +- src/config.rs | 4 + src/editor.rs | 79 +++++++++++++++- src/highlighter.rs | 193 +++++++++++++++++++++++++++++++++++++++- src/plugin/markdown.rs | 20 +++-- src/plugin/workspace.rs | 17 +++- src/ui/picker.rs | 88 +++++++++++++++--- 9 files changed, 406 insertions(+), 27 deletions(-) diff --git a/default_config.toml b/default_config.toml index 262197ad..df24d367 100644 --- a/default_config.toml +++ b/default_config.toml @@ -232,6 +232,7 @@ waiting = "steady_underscore" # extensions = ["build"] # filenames = ["Buildfile"] # aliases = ["build-script"] +# shebangs = ["build-script"] # comment = "# %s" # indent_width = 2 # diff --git a/docs/LANGUAGES.md b/docs/LANGUAGES.md index 02d4dbc7..fe454a43 100644 --- a/docs/LANGUAGES.md +++ b/docs/LANGUAGES.md @@ -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. @@ -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 ` 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 diff --git a/src/buffer.rs b/src/buffer.rs index f0dc38ff..db33475b 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -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. diff --git a/src/config.rs b/src/config.rs index ed54d1c2..6f6170b6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1065,6 +1065,9 @@ pub struct LanguageConfig { /// Case-sensitive exact file names, such as `Dockerfile` or `Makefile`. #[serde(default)] pub filenames: Vec, + /// Interpreter basenames recognized in a first-line shebang. + #[serde(default)] + pub shebangs: Vec, /// Additional names accepted by syntax selection and injected fenced blocks. #[serde(default)] pub aliases: Vec, @@ -2285,6 +2288,7 @@ fn known_schema_path(path: &[String]) -> bool { "extensions" | "filenames" | "aliases" + | "shebangs" | "comment" | "text_width" | "indent_width" diff --git a/src/editor.rs b/src/editor.rs index f9abb0f3..62fca892 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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 @@ -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()) } @@ -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(); @@ -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); diff --git a/src/highlighter.rs b/src/highlighter.rs index fc883b75..a2b6b271 100644 --- a/src/highlighter.rs +++ b/src/highlighter.rs @@ -64,6 +64,7 @@ struct RuntimeLanguageDefinition { extensions: Vec, filenames: Vec, aliases: Vec, + shebangs: Vec, grammar: Option, highlight_queries: Vec, textobject_queries: Vec, @@ -79,6 +80,7 @@ pub struct LanguageRegistry { extensions: HashMap, filenames: HashMap, aliases: HashMap, + shebangs: HashMap, } impl LanguageRegistry { @@ -90,6 +92,7 @@ impl LanguageRegistry { extensions: HashMap::new(), filenames: HashMap::new(), aliases: HashMap::new(), + shebangs: HashMap::new(), }; for definition in language_definitions() { registry.insert(RuntimeLanguageDefinition { @@ -104,6 +107,10 @@ impl LanguageRegistry { .iter() .map(ToString::to_string) .collect(), + shebangs: bundled_shebangs(definition.id) + .iter() + .map(|name| (*name).to_string()) + .collect(), aliases: LANGUAGE_NAMES .iter() .filter(|(_, language)| *language == definition.id) @@ -166,6 +173,7 @@ impl LanguageRegistry { extensions: Vec::new(), filenames: Vec::new(), aliases: Vec::new(), + shebangs: Vec::new(), grammar: None, highlight_queries: Vec::new(), textobject_queries: Vec::new(), @@ -184,6 +192,15 @@ impl LanguageRegistry { definition.filenames.clone_from(&config.filenames); } definition.aliases.extend(config.aliases.iter().cloned()); + if !config.shebangs.is_empty() { + anyhow::ensure!( + config.shebangs.iter().all(|name| !name.is_empty() + && !name.contains(['/', '\\']) + && !name.chars().any(char::is_whitespace)), + "language `{id}` has an invalid shebang interpreter" + ); + definition.shebangs.clone_from(&config.shebangs); + } if let Some(grammar) = &config.grammar { if let Some(builtin) = &grammar.builtin { @@ -304,6 +321,7 @@ impl LanguageRegistry { self.filenames .retain(|_, language| language != &previous.id); self.aliases.retain(|_, language| language != &previous.id); + self.shebangs.retain(|_, language| language != &previous.id); } for extension in &definition.extensions { self.extensions.insert(extension.clone(), id.clone()); @@ -311,6 +329,9 @@ impl LanguageRegistry { for filename in &definition.filenames { self.filenames.insert(filename.clone(), id.clone()); } + for interpreter in &definition.shebangs { + self.shebangs.insert(interpreter.clone(), id.clone()); + } self.aliases.insert(id.clone(), id.clone()); for alias in &definition.aliases { self.aliases.insert(alias.to_ascii_lowercase(), id.clone()); @@ -766,6 +787,27 @@ impl Highlighter { self.language_id_for_extension(&extension) } + /// Detects from a known filename first, then the document's first line. + /// Callers supplying a viewport or fragment must resolve using the full document instead. + pub fn language_id_for_source(&self, file: Option<&str>, source: &str) -> Option<&str> { + self.language_id_for_file(file).or_else(|| { + let interpreter = shebang_interpreter(source)?; + self.registry + .shebangs + .get(interpreter) + .or_else(|| { + // Accept versioned interpreters only when the entire suffix is numeric. + let version = interpreter.find(|ch: char| ch.is_ascii_digit())?; + interpreter[version..] + .bytes() + .all(|ch| ch.is_ascii_digit() || ch == b'.') + .then(|| self.registry.shebangs.get(&interpreter[..version])) + .flatten() + }) + .map(String::as_str) + }) + } + /// Whether highlighting a language requires all text before the visible slice. /// /// YAML structure is indentation-sensitive, so parsing an arbitrary indented @@ -838,7 +880,7 @@ impl Highlighter { file: Option<&str>, code: &str, ) -> anyhow::Result> { - let Some(language_id) = self.language_id_for_file(file) else { + let Some(language_id) = self.language_id_for_source(file, code) else { return Ok(Vec::new()); }; let language_id = language_id.to_string(); @@ -2130,6 +2172,58 @@ fn collect_injections( injections } +// Bound detection even for a buffer whose first line is enormous. +pub(crate) const MAX_SHEBANG_CHARS: usize = 512; + +fn bundled_shebangs(id: &str) -> &'static [&'static str] { + match id { + "bash" => &["sh", "bash", "dash", "ash", "zsh"], + "fish" => &["fish"], + "powershell" => &["pwsh", "powershell"], + "javascript" => &["node", "nodejs"], + "lua" => &["lua", "luajit"], + "husk" => &["husk"], + _ => &[], + } +} + +fn shebang_interpreter(source: &str) -> Option<&str> { + let end = source + .char_indices() + .nth(MAX_SHEBANG_CHARS) + .map_or(source.len(), |(index, _)| index); + let prefix = &source[..end]; + let line = prefix.split(['\n', '\r']).next()?; + // Never interpret a truncated token as a complete executable name. + if end < source.len() && line.len() == prefix.len() { + return None; + } + // ponytail: whitespace-only env parsing; add quoting if real scripts require it. + let mut words = line + .strip_prefix('\u{feff}') + .unwrap_or(line) + .strip_prefix("#!")? + .split_ascii_whitespace(); + let executable = words.next()?.rsplit('/').next()?; + if executable != "env" { + return Some(executable); + } + while let Some(word) = words.next() { + match word { + "-S" | "--split-string" | "-i" | "--ignore-environment" => continue, + "-u" | "--unset" | "-C" | "--chdir" => { + words.next()?; + } + "--" => return words.next()?.rsplit('/').next(), + _ if word.starts_with("--unset=") || word.starts_with("--chdir=") => continue, + _ if word.starts_with('-') => return None, + _ if word.contains('=') => continue, + _ => return word.rsplit('/').next(), + } + } + None +} + fn file_extension(file: &str) -> Option { Path::new(file) .extension() @@ -4342,6 +4436,103 @@ mod tests { assert!(highlighter.highlighters["rust"].cached_tree.is_none()); } + #[test] + fn shebang_detection_uses_interpreters_and_preserves_path_precedence() { + let mut highlighter = highlighter(); + for (source, expected) in [ + ("#!/bin/bash\necho hello", Some("bash")), + ("#!/bin/zsh -f", Some("bash")), + ("#!/usr/bin/env fish", Some("fish")), + ("#!/usr/bin/env -S bash -eu", Some("bash")), + ( + "#!/usr/bin/env --split-string pwsh -NoProfile", + Some("powershell"), + ), + ( + "#!/usr/bin/env -i -u OLD --chdir=/tmp MODE=test node", + Some("javascript"), + ), + ("#!/usr/bin/env -- /bin/dash", Some("bash")), + ("\u{feff}#! /usr/bin/lua5.4\r\nprint(1)", Some("lua")), + ("#!/usr/bin/env python3.12", None), + ("#!/usr/bin/env missing bash", None), + ("#!/usr/bin/env --unknown bash", None), + ("#!/usr/bin/env -u", None), + ("#!/usr/bin/env", None), + ("#!", None), + ("\n#!/bin/bash", None), + (" #!/bin/bash", None), + ("#!/bin/bashful", None), + ("#!/bin/lua5bad", None), + ("#!/bin/csh", None), + ] { + assert_eq!( + highlighter.language_id_for_source(Some("script"), source), + expected, + "{source:?}" + ); + } + assert_eq!( + highlighter.language_id_for_source(Some("script.rs"), "#!/bin/bash"), + Some("rust") + ); + assert_eq!( + highlighter.language_id_for_source(Some("COMMIT_EDITMSG"), "#!/bin/bash"), + Some("gitcommit") + ); + assert_eq!( + highlighter.language_id_for_source(None, "#!/bin/fish"), + Some("fish") + ); + let long = format!("#!{}bash", " ".repeat(MAX_SHEBANG_CHARS)); + assert_eq!(highlighter.language_id_for_source(None, &long), None); + assert!(!highlighter + .highlight_for_file( + Some("script"), + "#!/bin/bash\nif true; then echo hello; fi\n" + ) + .unwrap() + .is_empty()); + } + + #[test] + fn shebang_configuration_is_registered_and_replaced() { + let directory = tempfile::tempdir().unwrap(); + let definition: LanguageConfig = + toml::from_str("shebangs = ['python', 'python3']").unwrap(); + let mut registry = LanguageRegistry::bundled(); + registry + .insert_configured("python", &definition, directory.path()) + .unwrap(); + let configured = + Highlighter::with_registry(&theme_with_scopes(&[]), Arc::new(registry.clone())) + .unwrap(); + assert_eq!( + configured.language_id_for_source(None, "#!/usr/bin/env python3.12"), + Some("python") + ); + registry + .insert_configured( + "python", + &LanguageConfig { + shebangs: vec!["pypy".into()], + ..definition + }, + directory.path(), + ) + .unwrap(); + let configured = + Highlighter::with_registry(&theme_with_scopes(&[]), Arc::new(registry)).unwrap(); + assert_eq!( + configured.language_id_for_source(None, "#!/bin/python3"), + None + ); + assert_eq!( + configured.language_id_for_source(None, "#!/bin/pypy"), + Some("python") + ); + } + #[test] fn resolves_language_by_file_extension() { let highlighter = highlighter(); diff --git a/src/plugin/markdown.rs b/src/plugin/markdown.rs index 912d366e..a12e947c 100644 --- a/src/plugin/markdown.rs +++ b/src/plugin/markdown.rs @@ -713,7 +713,7 @@ pub(crate) fn render_code_lines_with_highlighter( } let language = highlighter .as_ref() - .and_then(|value| value.language_id_for_file(Some(file))) + .and_then(|value| value.language_id_for_source(Some(file), source)) .unwrap_or_default() .to_owned(); highlighted_code_lines(&language, source, highlighter) @@ -736,11 +736,15 @@ pub(crate) fn render_diff_lines_with_highlighter( if width == 0 { return Vec::new(); } - let language = highlighter - .as_ref() - .and_then(|value| value.language_id_for_file(Some(file))) - .unwrap_or_default() - .to_owned(); + let detect = |source: &str| { + highlighter + .as_ref() + .and_then(|value| value.language_id_for_source(Some(file), source)) + .unwrap_or_default() + .to_owned() + }; + let old_language = detect(before); + let new_language = detect(after); let diff = similar::TextDiff::from_lines(before, after); let mut old_lines = BTreeSet::new(); let mut new_lines = BTreeSet::new(); @@ -756,12 +760,12 @@ pub(crate) fn render_diff_lines_with_highlighter( // Parse complete programs separately for correct multiline syntax, but // materialize spans only for source lines actually displayed in the diff. let old = highlighted_code_lines_selected( - &language, + &old_language, before, highlighter.as_deref_mut(), Some(&old_lines), ); - let new = highlighted_code_lines_selected(&language, after, highlighter, Some(&new_lines)); + let new = highlighted_code_lines_selected(&new_language, after, highlighter, Some(&new_lines)); let palette = DiffPalette::new(theme); let span = |text: String, style: Style| RenderedTextSpan { text, diff --git a/src/plugin/workspace.rs b/src/plugin/workspace.rs index b4c7c917..79ccca10 100644 --- a/src/plugin/workspace.rs +++ b/src/plugin/workspace.rs @@ -1687,8 +1687,21 @@ fn highlight_document_projection( }) .collect::>(); let source = source_lines.join("\n"); - let spans = highlighter - .highlight_for_file(Some(&document.path), &source) + let first_line = document.lines.iter().find(|line| { + if new_side { + line.new_line == Some(1) + } else { + line.old_line == Some(1) + } + }); + let language = highlighter + .language_id_for_source( + Some(&document.path), + first_line.map_or("", |line| line.text.as_str()), + ) + .map(str::to_owned); + let spans = language + .and_then(|language| highlighter.highlight(&language, &source).ok()) .unwrap_or_default(); let mut result = (0..document.lines.len()) .map(|_| Vec::new()) diff --git a/src/ui/picker.rs b/src/ui/picker.rs index fb2dac22..f1b78b59 100644 --- a/src/ui/picker.rs +++ b/src/ui/picker.rs @@ -290,6 +290,7 @@ struct PreviewHighlightSpan { } struct CachedPreviewHighlights { + detection_prefix: String, key: String, location: bool, source: String, @@ -312,7 +313,12 @@ impl PreviewHighlighter { } } - fn highlight(&self, preview: &PickerPreview, text: &str) -> Vec { + fn highlight( + &self, + preview: &PickerPreview, + text: &str, + detection_prefix: &str, + ) -> Vec { let mut highlighter = self.highlighter.borrow_mut(); let Some(highlighter) = highlighter.as_mut() else { return Vec::new(); @@ -331,7 +337,13 @@ impl PreviewHighlighter { } PickerPreview::Text { language: None, .. } => Ok(Vec::new()), PickerPreview::Location { path, .. } => { - highlighter.highlight_for_file(Some(path), text) + let Some(language) = highlighter + .language_id_for_source(Some(path), detection_prefix) + .map(str::to_owned) + else { + return Vec::new(); + }; + highlighter.highlight(&language, text) } } .unwrap_or_default(); @@ -2660,7 +2672,12 @@ impl Picker { || preview_lines(text, start, preview_height), |line_starts| preview_lines_with_starts(text, line_starts, start, preview_height), ); - let highlight_spans = self.preview_highlight_spans(preview, text, &lines); + let highlight_spans = self.preview_highlight_spans( + preview, + text, + &lines, + window_first_line.unwrap_or_default() == 0, + ); for (offset, line) in lines.iter().enumerate() { let line_index = window_first_line.unwrap_or_default() + start + offset; let focused = focus_line == Some(line_index); @@ -2840,6 +2857,7 @@ impl Picker { preview: &PickerPreview, text: &str, lines: &[PreviewLine<'_>], + starts_document: bool, ) -> Arc<[PreviewHighlightSpan]> { let Some(first) = lines.first() else { return Arc::from([]); @@ -2865,9 +2883,17 @@ impl Picker { PickerPreview::Text { language: None, .. } => return Arc::from([]), PickerPreview::Location { path, .. } => (path.as_str(), true), }; + let detection_prefix = if starts_document { + text.chars() + .take(crate::highlighter::MAX_SHEBANG_CHARS + 1) + .collect::() + } else { + String::new() + }; let source = &text[start..end]; if let Some(cached) = self.preview_highlight_cache.borrow().as_ref() { - if cached.key == key + if cached.detection_prefix == detection_prefix + && cached.key == key && cached.location == location && cached.source_start == start && cached.source == source @@ -2876,7 +2902,9 @@ impl Picker { } } - let mut spans = self.preview_highlighter.highlight(preview, source); + let mut spans = self + .preview_highlighter + .highlight(preview, source, &detection_prefix); for span in &mut spans { span.start += start; span.end += start; @@ -2884,6 +2912,7 @@ impl Picker { let spans: Arc<[PreviewHighlightSpan]> = Arc::from(spans); if spans.len() <= MAX_CACHED_PREVIEW_HIGHLIGHT_SPANS { *self.preview_highlight_cache.borrow_mut() = Some(CachedPreviewHighlights { + detection_prefix, key: key.to_string(), location, source: source.to_string(), @@ -6838,7 +6867,7 @@ mod tests { }; let lines = super::preview_lines(&text, /*start_line*/ 0, /*max_lines*/ 1); - let spans = picker.preview_highlight_spans(&preview, &text, &lines); + let spans = picker.preview_highlight_spans(&preview, &text, &lines, true); assert!(!spans.is_empty()); assert!(spans @@ -6846,6 +6875,41 @@ mod tests { .all(|span| span.end <= super::MAX_PREVIEW_HIGHLIGHT_BYTES)); } + #[test] + fn shebang_preview_uses_document_prefix_and_invalidates_cached_language() { + let mut theme = Theme::default(); + theme.token_styles.push(TokenStyle { + name: Some("keyword".into()), + scope: vec!["keyword".into()], + style: Style { + bold: true, + ..Style::default() + }, + }); + let editor = test_editor_with_theme_and_size(theme, 120, 24); + let picker = Picker::new(None, &editor, &[], None); + let preview = PickerPreview::Location { + path: "script".into(), + line: None, + column: None, + matches: vec![], + }; + let text = "#!/bin/bash\nif true; then echo hi; fi\n"; + let lines = super::preview_lines(text, 1, 1); + assert!(!picker + .preview_highlight_spans(&preview, text, &lines, true) + .is_empty()); + let changed = text.replacen("#!/bin/bash", "# plain txt", 1); + let lines = super::preview_lines(&changed, 1, 1); + assert!(picker + .preview_highlight_spans(&preview, &changed, &lines, true) + .is_empty()); + let lines = super::preview_lines(text, 0, 2); + assert!(picker + .preview_highlight_spans(&preview, text, &lines, false) + .is_empty()); + } + #[test] fn preview_syntax_highlighting_rebases_visible_window_offsets() { let mut theme = Theme::default(); @@ -6866,7 +6930,7 @@ mod tests { }; let lines = super::preview_lines(text, /*start_line*/ 1, /*max_lines*/ 1); - let spans = picker.preview_highlight_spans(&preview, text, &lines); + let spans = picker.preview_highlight_spans(&preview, text, &lines, true); let keyword_start = text.find("let").unwrap(); assert!(spans @@ -6894,8 +6958,8 @@ mod tests { }; let lines = super::preview_lines(text, /*start_line*/ 0, /*max_lines*/ 1); - let first = picker.preview_highlight_spans(&preview, text, &lines); - let repeated = picker.preview_highlight_spans(&preview, text, &lines); + let first = picker.preview_highlight_spans(&preview, text, &lines, true); + let repeated = picker.preview_highlight_spans(&preview, text, &lines, true); assert!(!first.is_empty()); assert!(Arc::ptr_eq(&first, &repeated)); @@ -6906,12 +6970,14 @@ mod tests { }; let updated_lines = super::preview_lines(updated, /*start_line*/ 0, /*max_lines*/ 1); - let changed = picker.preview_highlight_spans(&updated_preview, updated, &updated_lines); + let changed = + picker.preview_highlight_spans(&updated_preview, updated, &updated_lines, true); assert!(!Arc::ptr_eq(&first, &changed)); picker.apply_theme(&theme); assert!(picker.preview_highlight_cache.borrow().is_none()); - let rethemed = picker.preview_highlight_spans(&updated_preview, updated, &updated_lines); + let rethemed = + picker.preview_highlight_spans(&updated_preview, updated, &updated_lines, true); assert!(!Arc::ptr_eq(&changed, &rethemed)); } From b60c5dab26d8d6fb410dddeb34d1fef4dbe5221a Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 4 Sep 2026 23:56:09 -0300 Subject: [PATCH 2/2] fix(syntax): accept equals form of env split-string --- src/highlighter.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/highlighter.rs b/src/highlighter.rs index a2b6b271..fbcc881e 100644 --- a/src/highlighter.rs +++ b/src/highlighter.rs @@ -2209,8 +2209,9 @@ fn shebang_interpreter(source: &str) -> Option<&str> { return Some(executable); } while let Some(word) = words.next() { + let word = word.strip_prefix("--split-string=").unwrap_or(word); match word { - "-S" | "--split-string" | "-i" | "--ignore-environment" => continue, + "" | "-S" | "--split-string" | "-i" | "--ignore-environment" => continue, "-u" | "--unset" | "-C" | "--chdir" => { words.next()?; } @@ -4444,6 +4445,12 @@ mod tests { ("#!/bin/zsh -f", Some("bash")), ("#!/usr/bin/env fish", Some("fish")), ("#!/usr/bin/env -S bash -eu", Some("bash")), + ("#!/usr/bin/env --split-string=bash -eu", Some("bash")), + ("#!/usr/bin/env --split-string=/bin/fish", Some("fish")), + ("#!/usr/bin/env --split-string=-i bash", Some("bash")), + ("#!/usr/bin/env --split-string= bash", Some("bash")), + ("#!/usr/bin/env --split-string=missing bash", None), + ("#!/usr/bin/env --split-string=", None), ( "#!/usr/bin/env --split-string pwsh -NoProfile", Some("powershell"),