From 94770e204636b839d211b26f414a1afd9d9d5112 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 8 Sep 2026 09:49:57 +0800 Subject: [PATCH] feat: added additional language highlighters --- Cargo.lock | 103 ++++- Cargo.toml | 11 +- scripts/fetch-grammars.sh | 125 ++++++ src/gui/controller/diff_mode.rs | 7 +- src/gui/mod.rs | 245 ++++++++++- src/gui/popup.rs | 74 ++++ src/gui/views.rs | 18 + src/pager/highlight/config.rs | 651 ++++++++++++++++++++++------- src/pager/highlight/dynamic.rs | 706 ++++++++++++++++++++++++++++++++ src/pager/highlight/mod.rs | 395 ++++++++++++++++-- src/pager/highlight/queries.rs | 419 ------------------- 11 files changed, 2136 insertions(+), 618 deletions(-) create mode 100644 scripts/fetch-grammars.sh create mode 100644 src/pager/highlight/dynamic.rs delete mode 100644 src/pager/highlight/queries.rs diff --git a/Cargo.lock b/Cargo.lock index a7cfeef..87eca69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,27 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arborium-dockerfile" +version = "2.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f567b2f106cea694126516b8ad20d44cddc40df8c16c77acf61ada303bfd9e" +dependencies = [ + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-sysroot" +version = "2.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd9d120dd5df8b918cd96676a88f1233a66f10687d415b69f70b3396acddddf" +dependencies = [ + "cc", + "dlmalloc", +] + [[package]] name = "bitflags" version = "2.13.1" @@ -270,6 +291,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dlmalloc" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad5208a115eaba24916f7456929832e310a81518c641f93fee4f89aa93aa3675" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "either" version = "1.18.0" @@ -444,11 +476,13 @@ name = "lazygitrs" version = "0.0.36" dependencies = [ "anyhow", + "arborium-dockerfile", "clap", "crossterm", "dirs", "include_dir", "libc", + "libloading", "once_cell", "ratatui", "serde", @@ -462,17 +496,22 @@ dependencies = [ "tracing-subscriber", "tree-sitter", "tree-sitter-bash", + "tree-sitter-c", "tree-sitter-css", "tree-sitter-go", "tree-sitter-highlight", "tree-sitter-html", + "tree-sitter-java", "tree-sitter-javascript", "tree-sitter-json", + "tree-sitter-language", + "tree-sitter-lua", "tree-sitter-md", "tree-sitter-python", "tree-sitter-rust", "tree-sitter-toml-ng", "tree-sitter-typescript", + "tree-sitter-yaml", "tui-textarea", "unicode-width 0.2.0", ] @@ -483,6 +522,16 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libredox" version = "0.1.23" @@ -766,6 +815,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -1077,13 +1127,13 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.24.7" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75" +checksum = "2038684e0058edba0d17302619f62eabce4a8e11c6ac59506996a8d79848851d" dependencies = [ "cc", "regex", - "regex-syntax", + "serde_json", "streaming-iterator", "tree-sitter-language", ] @@ -1098,6 +1148,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-c" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-css" version = "0.23.2" @@ -1120,14 +1180,13 @@ dependencies = [ [[package]] name = "tree-sitter-highlight" -version = "0.24.7" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6411813e4a9ebc87d391b98b0f3ce65d5361cd80c54de8651d8b85b555ea5d95" +checksum = "fa86266a9dc1aae627034551d45e84427a45c70914a8629608ed1bd093214c28" dependencies = [ - "lazy_static", "regex", "streaming-iterator", - "thiserror 1.0.69", + "thiserror 2.0.20", "tree-sitter", ] @@ -1141,6 +1200,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-javascript" version = "0.23.1" @@ -1167,6 +1236,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" +[[package]] +name = "tree-sitter-lua" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8daaf5f4235188a58603c39760d5fa5d4b920d36a299c934adddae757f32a10c" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-md" version = "0.3.2" @@ -1217,6 +1296,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-yaml" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tui-textarea" version = "0.7.0" diff --git a/Cargo.toml b/Cargo.toml index 2ea56f7..13f8f87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,8 +26,8 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } once_cell = "1" include_dir = "0.7" similar = { version = "2", features = ["unicode"] } -tree-sitter = "0.24" -tree-sitter-highlight = "0.24" +tree-sitter = "0.27" +tree-sitter-highlight = "0.27" tree-sitter-rust = "0.23" tree-sitter-javascript = "0.23" tree-sitter-typescript = "0.23" @@ -39,10 +39,17 @@ tree-sitter-toml-ng = "0.7" tree-sitter-css = "0.23" tree-sitter-html = "0.23" tree-sitter-md = "0.3" +tree-sitter-language = "0.1" +libloading = "0.8" unicode-width = "0.2" tui-textarea = { version = "0.7", features = ["crossterm"] } textwrap = "0.16.2" libc = "0.2" +tree-sitter-c = "0.24" +tree-sitter-java = "0.23" +tree-sitter-lua = "0.5" +tree-sitter-yaml = "0.7" +arborium-dockerfile = "2.18.2" [profile.release] lto = true diff --git a/scripts/fetch-grammars.sh b/scripts/fetch-grammars.sh new file mode 100644 index 0000000..e1cebde --- /dev/null +++ b/scripts/fetch-grammars.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Fetch + build heavy tree-sitter grammars as dynamic .so files. +# +# Usage: +# scripts/fetch-grammars.sh swift c-sharp c++ php ruby # what you need +# scripts/fetch-grammars.sh all # all five +# +# Output (auto-created, auto-loaded on next diff): +# /syntax/grammars/.so +# /syntax/queries//highlights.scm (+ injections/locals if upstream ships them) +# +# Why dynamic: these five grammars are ~19MB (swift 5.2 + c-sharp 5.5 + +# cpp 3.7 + php 2.7 + ruby 2.3MB debug). Colors don't need an LSP or a +# bigger binary — a .so loaded on demand highlights identically. +set -euo pipefail + +ALL="swift c-sharp c++ php ruby" + +if [ $# -eq 0 ]; then + echo "usage: $0 ... | all" + echo "langs: $ALL" + exit 1 +fi + +if [ "$1" = "all" ]; then + set -- $ALL +fi + +repo_for() { + case "$1" in + swift) echo "https://github.com/alex-pinkus/tree-sitter-swift" ;; + c-sharp) echo "https://github.com/tree-sitter/tree-sitter-c-sharp" ;; + c++) echo "https://github.com/tree-sitter/tree-sitter-cpp" ;; + php) echo "https://github.com/tree-sitter/tree-sitter-php" ;; + ruby) echo "https://github.com/tree-sitter/tree-sitter-ruby" ;; + *) echo "" ;; + esac +} + +# Query source per lang, relative to the cloned repo. +# Most grammars ship queries/ at top level; php keeps per-variant dirs. +queries_for() { + case "$1" in + php) echo "queries" ;; # handled specially below (php/ subdir fallback) + *) echo "queries" ;; + esac +} + +CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/lazygitrs/syntax" +GRAMMARS="$CONFIG_DIR/grammars" +QUERIES="$CONFIG_DIR/queries" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +need() { + command -v "$1" >/dev/null 2>&1 || { + echo "error: need '$1' (git/cc)" >&2 + exit 1 + } +} +need git +need cc + +mkdir -p "$GRAMMARS" "$QUERIES" + +build_one() { + lang="$1" + # Normalize display names to install ids (C++ -> cpp file/symbol). + case "$lang" in + c++) lang="cpp" ;; + esac + repo="$(repo_for "$lang")" + if [ -z "$repo" ]; then + echo "! unknown lang '$lang' (try: $ALL, or add it manually — see syntax/README.md)" + return 1 + fi + echo "== $lang ==" + src="$WORK/$lang" + git clone --depth 1 --quiet "$repo" "$src" + + # Find parser.c (+ optional scanner.c / scanner.cc). + parser="$(find "$src" -maxdepth 4 -name parser.c | head -1)" + if [ -z "$parser" ]; then + echo "! $lang: no parser.c found in $repo" + return 1 + fi + pdir="$(dirname "$parser")" + sources="$parser" + for s in "$pdir/scanner.c" "$pdir/scanner.cc"; do + [ -f "$s" ] && sources="$sources $s" + done + + # shellcheck disable=SC2086 + cc -shared -fPIC -O2 -I"$pdir" $sources -o "$GRAMMARS/$lang.so" + echo " built grammars/$lang.so" + + # Queries: prefer top-level queries/, fall back to first subdir with highlights.scm (php). + qsrc="$src/$(queries_for "$lang")" + if [ ! -f "$qsrc/highlights.scm" ]; then + qsrc="$(dirname "$(find "$src" -name highlights.scm -not -path "*/test/*" | head -1)")" + fi + if [ -z "$qsrc" ] || [ ! -f "$qsrc/highlights.scm" ]; then + echo "! $lang: no highlights.scm in $repo — copy one from nvim-treesitter:" + echo " $QUERIES/$lang/highlights.scm" + return 1 + fi + mkdir -p "$QUERIES/$lang" + for q in highlights injections locals; do + [ -f "$qsrc/$q.scm" ] && cp "$qsrc/$q.scm" "$QUERIES/$lang/$q.scm" + done + echo " copied queries/$lang/*.scm" +} + +failed=0 +for lang in "$@"; do + build_one "$lang" || failed=1 +done + +if [ "$failed" = 0 ]; then + echo "done → $CONFIG_DIR" + echo "open any diff, or check \`?\` → Syntax highlighting health…" +else + echo "some langs failed — see messages above (manual steps in $CONFIG_DIR/README.md)" + exit 1 +fi diff --git a/src/gui/controller/diff_mode.rs b/src/gui/controller/diff_mode.rs index fe2dd2b..0153064 100644 --- a/src/gui/controller/diff_mode.rs +++ b/src/gui/controller/diff_mode.rs @@ -6,7 +6,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::config::keybindings::parse_key; use crate::gui::modes::diff_mode::{DiffModeFocus, DiffModeSelector}; -use crate::gui::popup::{CommandEntry, CommandSection, MenuItem, PopupState}; +use crate::gui::popup::{CommandAction, CommandEntry, CommandSection, MenuItem, PopupState}; use crate::gui::{DiffPayload, Gui, textarea_input}; use crate::model::FileChangeStatus; use crate::os::platform::Platform; @@ -825,6 +825,11 @@ fn show_diff_mode_command_palette(gui: &mut Gui) { CommandEntry::keybinding("n/N".into(), "Next / previous search match".into()), CommandEntry::keybinding("y".into(), "Copy to clipboard".into()), CommandEntry::keybinding("?".into(), "Show command palette".into()), + CommandEntry::action( + "".into(), + "Syntax highlighting...".into(), + CommandAction::ShowSyntaxHealth, + ), ], }; diff --git a/src/gui/mod.rs b/src/gui/mod.rs index bbd099f..b4fc0e6 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -767,6 +767,11 @@ pub struct Gui { /// Whether the commit-details box is visible. Toggled with `.` in any /// commit-related context. pub show_commit_details: bool, + /// Receiver for a background extra-language install (`Ok` = ready). + /// While `Some`, the Loading overlay belongs to the install. + grammar_install_rx: Option>>, + /// Display name of the language being installed (for result messages). + grammar_install_pretty: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1092,6 +1097,8 @@ impl Gui { commit_details_scroll: 0, commit_details_scroll_hash: String::new(), show_commit_details, + grammar_install_rx: None, + grammar_install_pretty: String::new(), }) } @@ -1349,6 +1356,9 @@ impl Gui { // Check for completed background menu item operations self.receive_menu_async_results(); + // Check for a finished background language install + self.receive_grammar_install_results(); + // Advance spinner animation self.spinner_frame = self.spinner_frame.wrapping_add(1); @@ -4265,6 +4275,7 @@ impl Gui { let was_ref_picker = matches!(self.popup, PopupState::RefPicker { .. }); let was_list_picker = matches!(self.popup, PopupState::ListPicker { .. }); let was_theme_picker = matches!(self.popup, PopupState::ThemePicker { .. }); + let was_syntax_menu = matches!(self.popup, PopupState::SyntaxMenu { .. }); match &self.popup { PopupState::Confirm { .. } => { @@ -4966,11 +4977,18 @@ impl Gui { } } PopupState::Loading { .. } => { - // Block all input while loading — user must wait + // Grammar installs run behind this overlay — Esc stops waiting + // and goes back to the menu (the install keeps running; the + // menu refreshes if still open when it finishes). + if key.code == KeyCode::Esc && self.grammar_install_rx.is_some() { + self.show_syntax_menu(); + } + // Otherwise block all input while loading — user must wait. } PopupState::CommandPalette { .. } => {} PopupState::RefPicker { .. } => {} PopupState::ListPicker { .. } => {} + PopupState::SyntaxMenu { .. } => {} PopupState::ThemePicker { .. } => {} PopupState::None => {} } @@ -4985,6 +5003,8 @@ impl Gui { self.handle_ref_picker_key(key)?; } else if was_list_picker && matches!(self.popup, PopupState::ListPicker { .. }) { self.handle_list_picker_key(key)?; + } else if was_syntax_menu && matches!(self.popup, PopupState::SyntaxMenu { .. }) { + self.handle_syntax_menu_key(key)?; } else if was_theme_picker && matches!(self.popup, PopupState::ThemePicker { .. }) { self.handle_theme_picker_key(key); } @@ -5126,6 +5146,9 @@ impl Gui { self.popup = PopupState::None; self.show_theme_picker(); } + CommandAction::ShowSyntaxHealth => { + self.show_syntax_menu(); + } CommandAction::Unavailable => {} } } @@ -5388,7 +5411,8 @@ impl Gui { fn show_theme_picker(&mut self) { use crate::gui::popup::{ - ListPickerCore, ListPickerItem, make_command_palette_search_textarea, + ListPickerCore, ListPickerItem, list_picker_initial_scroll, + make_command_palette_search_textarea, }; let original = self.current_theme_index; @@ -5401,13 +5425,18 @@ impl Gui { description: Some(ct.appearance.as_str().to_string()), }) .collect(); + let scroll_offset = list_picker_initial_scroll( + &items, + original.min(items.len().saturating_sub(1)), + list_picker_visible_height(self.layout.height as usize), + ); self.popup = PopupState::ThemePicker { core: ListPickerCore { items, selected: original, search_textarea: make_command_palette_search_textarea(), - scroll_offset: 0, + scroll_offset, }, original_theme_index: original, }; @@ -5517,6 +5546,186 @@ impl Gui { items } + /// Syntax highlighting menu (`?` → "Syntax highlighting..."). + /// A searchable [`PopupState::SyntaxMenu`] reusing the shared list-picker + /// core (search, categories, hint bar, mouse). Ready languages just work; + /// Enter on an available one installs it on a background thread. + fn show_syntax_menu(&mut self) { + use crate::gui::popup::{ + ListPickerCore, ListPickerItem, list_picker_initial_scroll, + make_command_palette_search_textarea, + }; + use crate::pager::highlight::syntax_menu_rows; + + let items: Vec = syntax_menu_rows() + .into_iter() + .map(|row| ListPickerItem { + value: row.value, + label: row.label, + category: row.category, + description: Some(row.description), + }) + .collect(); + // Start on the first installable row so Enter just works, scrolled + // into view — otherwise the highlight sits below the fold and the + // first Down press appears to skip straight to the second row. + let selected = items + .iter() + .position(|i| i.category == "Available") + .unwrap_or(0); + let scroll_offset = list_picker_initial_scroll( + &items, + selected, + list_picker_visible_height(self.layout.height as usize), + ); + self.popup = PopupState::SyntaxMenu { + core: ListPickerCore { + items, + selected, + search_textarea: make_command_palette_search_textarea(), + scroll_offset, + }, + }; + } + + /// Key handling for the syntax menu: list-picker navigation + filter, + /// without the free-entry row (fixed language list). Enter installs. + fn handle_syntax_menu_key(&mut self, key: KeyEvent) -> Result<()> { + use crate::gui::popup::{ + list_picker_clamp_selection_to_matches, list_picker_filtered_display_idx, + list_picker_matching_indices, list_picker_next_match, list_picker_prev_match, + }; + use crate::pager::highlight::syntax_confirm_for; + + if let PopupState::SyntaxMenu { core } = &mut self.popup { + let search = core.search_textarea.lines().join(""); + let matching = list_picker_matching_indices(&core.items, &search); + + let h = self.layout.height as usize; + let list_height = list_picker_visible_height(h); + + match key.code { + KeyCode::Esc => { + self.popup = PopupState::None; + return Ok(()); + } + KeyCode::Enter => { + let value = core + .items + .get(core.selected) + .filter(|_| matching.contains(&core.selected)) + .map(|i| i.value.clone()) + .or_else(|| { + matching + .first() + .and_then(|&idx| core.items.get(idx).map(|i| i.value.clone())) + }); + let Some(value) = value else { + return Ok(()); + }; + match syntax_confirm_for(&value) { + crate::pager::highlight::SyntaxConfirm::Reload => { + crate::pager::highlight::dynamic::reload(); + self.show_syntax_menu(); + } + crate::pager::highlight::SyntaxConfirm::AlreadyReady(pretty) => { + self.popup = PopupState::Message { + title: pretty, + message: "Highlighting is ready — nothing to install.".to_string(), + kind: MessageKind::Info, + }; + } + crate::pager::highlight::SyntaxConfirm::Install { id, pretty } => { + self.start_grammar_install(id, pretty); + } + } + return Ok(()); + } + KeyCode::Down => { + if let Some(next) = list_picker_next_match(&matching, core.selected) { + core.selected = next; + } + list_picker_scroll_after_nav(core, &matching, list_height, true); + } + KeyCode::Up => { + if let Some(prev) = list_picker_prev_match(&matching, core.selected) { + core.selected = prev; + } + list_picker_scroll_after_nav(core, &matching, list_height, false); + } + _ => { + textarea_input(&mut core.search_textarea, key); + let new_search = core.search_textarea.lines().join(""); + if new_search != search { + let matching = list_picker_matching_indices(&core.items, &new_search); + if let Some(sel) = + list_picker_clamp_selection_to_matches(&matching, core.selected) + { + core.selected = sel; + } + if !new_search.is_empty() { + let sdi = list_picker_filtered_display_idx( + &core.items, + &matching, + core.selected, + ); + core.scroll_offset = sdi.saturating_sub(list_height / 2); + } else { + core.scroll_offset = 0; + } + } + } + } + } + Ok(()) + } + + /// Download + build an extra language without freezing the UI. + /// Shows the Loading overlay; completion reopens the menu (or reports). + fn start_grammar_install(&mut self, id: String, pretty: String) { + let (tx, rx) = mpsc::channel(); + self.grammar_install_rx = Some(rx); + self.grammar_install_pretty = pretty.clone(); + self.popup = PopupState::Loading { + title: format!("Installing {pretty}…"), + message: "Downloading and building — this can take a minute.".to_string(), + }; + std::thread::spawn(move || { + let result = crate::pager::highlight::dynamic::install_lang(&id).map_err(|msg| msg); + let _ = tx.send(result); + }); + } + + /// Poll a finished background language install (called every event-loop tick). + fn receive_grammar_install_results(&mut self) { + // Single `try_recv` — it consumes the message, so poll exactly once. + let result = match self.grammar_install_rx.as_ref() { + Some(rx) => match rx.try_recv() { + Ok(result) => result, + Err(_) => return, + }, + None => return, + }; + self.grammar_install_rx = None; + let pretty = std::mem::take(&mut self.grammar_install_pretty); + let waiting = matches!(self.popup, PopupState::Loading { .. }); + let viewing_menu = matches!(self.popup, PopupState::SyntaxMenu { .. }); + match result { + Ok(()) if waiting || viewing_menu => self.show_syntax_menu(), + Ok(()) => {} + Err(msg) if waiting => { + self.popup = PopupState::Message { + title: pretty, + message: msg, + kind: MessageKind::Error, + }; + } + // Escaped mid-install and moved on: stay where the user is; the + // menu (or its retry row) reflects the outcome on next open. + Err(_) => {} + } + } + fn show_command_palette(&mut self) { let kb = &self.config.user_config.keybinding; let active = self.context_mgr.active(); @@ -5593,6 +5802,11 @@ impl Gui { "Color theme...".into(), CommandAction::OpenThemePicker, ), + CommandEntry::action( + "".into(), + "Syntax highlighting...".into(), + CommandAction::ShowSyntaxHealth, + ), ], }; @@ -6008,6 +6222,11 @@ impl Gui { "Color theme...".into(), CommandAction::OpenThemePicker, ), + CommandEntry::action( + "".into(), + "Syntax highlighting...".into(), + CommandAction::ShowSyntaxHealth, + ), ], }; @@ -6863,12 +7082,14 @@ impl Gui { // Free-entry list pickers (RefPicker / ListPicker) intercept mouse scroll and click if matches!( self.popup, - PopupState::RefPicker { .. } | PopupState::ListPicker { .. } + PopupState::RefPicker { .. } + | PopupState::ListPicker { .. } + | PopupState::SyntaxMenu { .. } ) { let (core, w, h) = match &mut self.popup { - PopupState::RefPicker { core, .. } | PopupState::ListPicker { core, .. } => { - (core, self.layout.width, self.layout.height) - } + PopupState::RefPicker { core, .. } + | PopupState::ListPicker { core, .. } + | PopupState::SyntaxMenu { core } => (core, self.layout.width, self.layout.height), _ => unreachable!(), }; handle_list_picker_mouse(core, mouse, w, h); @@ -7151,12 +7372,14 @@ impl Gui { // Free-entry list pickers (RefPicker / ListPicker) intercept mouse scroll and click if matches!( self.popup, - PopupState::RefPicker { .. } | PopupState::ListPicker { .. } + PopupState::RefPicker { .. } + | PopupState::ListPicker { .. } + | PopupState::SyntaxMenu { .. } ) { let (core, w, h) = match &mut self.popup { - PopupState::RefPicker { core, .. } | PopupState::ListPicker { core, .. } => { - (core, self.layout.width, self.layout.height) - } + PopupState::RefPicker { core, .. } + | PopupState::ListPicker { core, .. } + | PopupState::SyntaxMenu { core } => (core, self.layout.width, self.layout.height), _ => unreachable!(), }; handle_list_picker_mouse(core, mouse, w, h); diff --git a/src/gui/popup.rs b/src/gui/popup.rs index d00f8bc..ba3dff8 100644 --- a/src/gui/popup.rs +++ b/src/gui/popup.rs @@ -577,6 +577,13 @@ pub enum PopupState { free_entry_category: String, on_confirm: ListPickerAction, }, + /// Syntax highlighting menu: searchable fixed language list, no free entry. + /// Reuses [`ListPickerCore`] + `render_list_picker` (search, categories, + /// hint bar, mouse); Enter installs the selected language on a background + /// thread instead of confirming a value. + SyntaxMenu { + core: ListPickerCore, + }, /// Color theme picker with live preview and search. ThemePicker { core: ListPickerCore, @@ -718,6 +725,7 @@ pub struct CommandSection { pub enum CommandAction { Dispatch(KeyEvent), OpenThemePicker, + ShowSyntaxHealth, Unavailable, } @@ -961,6 +969,26 @@ pub fn list_picker_filtered_display_idx( di } +/// Initial scroll offset so `selected` is visible on open (with its +/// category header when possible). Without this the highlight can sit +/// below the fold and the first Down press looks like it skips a row. +pub fn list_picker_initial_scroll( + items: &[ListPickerItem], + selected: usize, + list_height: usize, +) -> usize { + if list_height == 0 { + return 0; + } + let matching = list_picker_matching_indices(items, ""); + let sdi = list_picker_filtered_display_idx(items, &matching, selected); + if sdi >= list_height { + sdi - list_height + 1 + } else { + 0 + } +} + /// Next matching item index after `selected` (cycles within `matching`). pub fn list_picker_next_match(matching: &[usize], selected: usize) -> Option { if matching.is_empty() { @@ -1231,3 +1259,49 @@ mod checklist_free_entry_tests { assert!(items[0].checked); } } + +#[cfg(test)] +mod initial_scroll_tests { + use super::*; + + fn categorized(labels: &[(&str, &str)]) -> Vec { + labels + .iter() + .map(|(label, category)| ListPickerItem { + value: (*label).to_string(), + label: (*label).to_string(), + category: (*category).to_string(), + description: None, + }) + .collect() + } + + #[test] + fn selected_at_top_needs_no_scroll() { + let items = categorized(&[("a", ""), ("b", ""), ("c", "")]); + assert_eq!(list_picker_initial_scroll(&items, 0, 3), 0); + } + + #[test] + fn selected_below_fold_scrolls_into_view_with_header() { + // Ready header + 3 ready rows, Available header + 2 available rows. + let items = categorized(&[ + ("r1", "Ready"), + ("r2", "Ready"), + ("r3", "Ready"), + ("a1", "Available"), + ("a2", "Available"), + ]); + // Display rows: Ready(0) r1(1) r2(2) r3(3) Available(4) a1(5). + // Height 3 -> scroll 3 shows Available header + a1 + a2. + assert_eq!(list_picker_initial_scroll(&items, 3, 3), 3); + // Height covers it -> no scroll. + assert_eq!(list_picker_initial_scroll(&items, 3, 6), 0); + } + + #[test] + fn zero_height_never_scrolls() { + let items = categorized(&[("a", ""), ("b", "")]); + assert_eq!(list_picker_initial_scroll(&items, 1, 0), 0); + } +} diff --git a/src/gui/views.rs b/src/gui/views.rs index c9aa6f4..cf71e27 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -3588,6 +3588,24 @@ pub fn render_popup( ], ); } + PopupState::SyntaxMenu { core } => { + render_list_picker( + frame, + area, + theme, + core, + "Syntax highlighting", + 65, + 70, + 36, + &[ + ("↑↓", "navigate"), + ("type", "filter"), + ("enter", "install"), + ("esc", "close"), + ], + ); + } PopupState::None => {} } diff --git a/src/pager/highlight/config.rs b/src/pager/highlight/config.rs index 0cf94c1..dd264ba 100644 --- a/src/pager/highlight/config.rs +++ b/src/pager/highlight/config.rs @@ -1,183 +1,542 @@ -use once_cell::sync::Lazy; -use tree_sitter_highlight::HighlightConfiguration; +use std::sync::OnceLock; -use super::queries::*; +use tree_sitter_highlight::HighlightConfiguration; +/// Every capture name used by our bundled queries, plus the standard +/// nvim-treesitter/Helix set so future grammars highlight without code changes. +/// Captures missing here simply don't highlight; `highlight_color` in `mod.rs` +/// prefix-matches, so `keyword.function`, `markup.heading`, etc. just work. pub const HIGHLIGHT_NAMES: &[&str] = &[ "attribute", + "boolean", + "character", + "character.special", "comment", + "comment.documentation", "constant", "constant.builtin", "constructor", + "definition.class", + "definition.constant", + "definition.function", + "definition.interface", + "definition.macro", + "definition.method", + "definition.module", + "definition.type", + "diff.delta", + "diff.minus", + "diff.plus", + "doc", + "embedded", + "error", + "escape", "function", "function.builtin", - "function.method", + "function.call", "function.macro", + "function.method", + "function.method.call", + "import", + "injection.content", + "injection.language", "keyword", + "keyword.conditional", + "keyword.control", + "keyword.debug", + "keyword.directive", + "keyword.exception", + "keyword.function", + "keyword.import", + "keyword.operator", + "keyword.repeat", + "keyword.return", + "keyword.storage", "label", + "literal", + "local.definition", + "local.reference", + "local.scope", + "markup", + "markup.bold", + "markup.heading", + "markup.italic", + "markup.link", + "markup.link.label", + "markup.link.url", + "markup.list", + "markup.quote", + "markup.raw", + "markup.strikethrough", "module", + "name", + "namespace", "number", + "number.float", "operator", "property", "punctuation", "punctuation.bracket", "punctuation.delimiter", + "punctuation.special", + "reference.call", + "reference.class", + "reference.implementation", + "reference.type", "string", + "string.documentation", + "string.escape", + "string.regexp", "string.special", + "string.special.key", + "string.special.symbol", "tag", + "tag.attribute", + "tag.delimiter", + "tag.error", "type", "type.builtin", + "type.definition", "variable", "variable.builtin", - "variable.parameter", "variable.member", + "variable.parameter", ]; -pub struct LanguageConfig { - pub config: HighlightConfiguration, +/// One built-in language. Adding a new static language is: +/// 1. `cargo add tree-sitter-` +/// 2. one `static_lang!` + one `StaticEntry` line below. No query writing — +/// queries come bundled with the grammar crate (Helix-style). +pub struct StaticEntry { + pub name: &'static str, + pub extensions: &'static [&'static str], + pub filenames: &'static [&'static str], + /// Prefix match on the basename (e.g. `Dockerfile.dev`, `Containerfile.cuda`). + pub filename_prefixes: &'static [&'static str], + pub get: fn() -> Option<&'static HighlightConfiguration>, } -fn load_config( - language: tree_sitter::Language, - name: &str, - highlights: &str, - ext: &'static str, - configs: &mut Vec<(&'static str, LanguageConfig)>, -) { - match HighlightConfiguration::new(language, name, highlights, "", "") { - Ok(mut config) => { - config.configure(HIGHLIGHT_NAMES); - configs.push((ext, LanguageConfig { config })); +/// Boilerplate for the common case: bundled highlight/injection/locals queries, +/// compiled once per language (not all-at-once like the old `warm_configs`). +macro_rules! static_lang { + ($func:ident, $lang:expr, $name:literal, $hl:expr, $inj:expr, $loc:expr) => { + fn $func() -> Option<&'static HighlightConfiguration> { + static CELL: OnceLock> = OnceLock::new(); + CELL.get_or_init(|| { + HighlightConfiguration::new($lang, $name, $hl, $inj, $loc) + .map(|mut c| { + c.configure(HIGHLIGHT_NAMES); + c + }) + .ok() + }) + .as_ref() } - Err(_e) => { - #[cfg(debug_assertions)] - eprintln!("[WARN] Failed to load {} highlight config: {:?}", name, _e); + }; +} + +static_lang!( + cfg_typescript, + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + "typescript", + tree_sitter_typescript::HIGHLIGHTS_QUERY, + "", + tree_sitter_typescript::LOCALS_QUERY +); + +static_lang!( + cfg_javascript, + tree_sitter_javascript::LANGUAGE.into(), + "javascript", + tree_sitter_javascript::HIGHLIGHT_QUERY, + tree_sitter_javascript::INJECTIONS_QUERY, + tree_sitter_javascript::LOCALS_QUERY +); + +static_lang!( + cfg_rust, + tree_sitter_rust::LANGUAGE.into(), + "rust", + tree_sitter_rust::HIGHLIGHTS_QUERY, + tree_sitter_rust::INJECTIONS_QUERY, + "" +); + +static_lang!( + cfg_python, + tree_sitter_python::LANGUAGE.into(), + "python", + tree_sitter_python::HIGHLIGHTS_QUERY, + "", + "" +); + +static_lang!( + cfg_go, + tree_sitter_go::LANGUAGE.into(), + "go", + tree_sitter_go::HIGHLIGHTS_QUERY, + "", + "" +); + +static_lang!( + cfg_json, + tree_sitter_json::LANGUAGE.into(), + "json", + tree_sitter_json::HIGHLIGHTS_QUERY, + "", + "" +); + +static_lang!( + cfg_bash, + tree_sitter_bash::LANGUAGE.into(), + "bash", + tree_sitter_bash::HIGHLIGHT_QUERY, + "", + "" +); + +static_lang!( + cfg_css, + tree_sitter_css::LANGUAGE.into(), + "css", + tree_sitter_css::HIGHLIGHTS_QUERY, + "", + "" +); + +static_lang!( + cfg_html, + tree_sitter_html::LANGUAGE.into(), + "html", + tree_sitter_html::HIGHLIGHTS_QUERY, + tree_sitter_html::INJECTIONS_QUERY, + "" +); + +static_lang!( + cfg_toml, + tree_sitter_toml_ng::LANGUAGE.into(), + "toml", + tree_sitter_toml_ng::HIGHLIGHTS_QUERY, + "", + "" +); + +static_lang!( + cfg_markdown, + tree_sitter_md::LANGUAGE.into(), + "markdown", + tree_sitter_md::HIGHLIGHT_QUERY_BLOCK, + tree_sitter_md::INJECTION_QUERY_BLOCK, + "" +); + +static_lang!( + cfg_c, + tree_sitter_c::LANGUAGE.into(), + "c", + tree_sitter_c::HIGHLIGHT_QUERY, + "", + "" +); + +static_lang!( + cfg_java, + tree_sitter_java::LANGUAGE.into(), + "java", + tree_sitter_java::HIGHLIGHTS_QUERY, + "", + "" +); + +static_lang!( + cfg_dockerfile, + arborium_dockerfile::language().into(), + "dockerfile", + arborium_dockerfile::HIGHLIGHTS_QUERY, + arborium_dockerfile::INJECTIONS_QUERY, + arborium_dockerfile::LOCALS_QUERY +); + +static_lang!( + cfg_lua, + tree_sitter_lua::LANGUAGE.into(), + "lua", + tree_sitter_lua::HIGHLIGHTS_QUERY, + tree_sitter_lua::INJECTIONS_QUERY, + tree_sitter_lua::LOCALS_QUERY +); + +static_lang!( + cfg_yaml, + tree_sitter_yaml::LANGUAGE.into(), + "yaml", + tree_sitter_yaml::HIGHLIGHTS_QUERY, + "", + "" +); + +/// TSX ships no dedicated query upstream; combine the TypeScript highlights +/// with JavaScript's JSX highlights (same pattern jsr/vim-clap use). +fn cfg_tsx() -> Option<&'static HighlightConfiguration> { + static CELL: OnceLock> = OnceLock::new(); + CELL.get_or_init(|| { + let combined = format!( + "{}\n{}", + tree_sitter_typescript::HIGHLIGHTS_QUERY, + tree_sitter_javascript::JSX_HIGHLIGHT_QUERY + ); + HighlightConfiguration::new( + tree_sitter_typescript::LANGUAGE_TSX.into(), + "tsx", + &combined, + "", + tree_sitter_typescript::LOCALS_QUERY, + ) + .or_else(|_| { + HighlightConfiguration::new( + tree_sitter_typescript::LANGUAGE_TSX.into(), + "tsx", + tree_sitter_typescript::HIGHLIGHTS_QUERY, + "", + tree_sitter_typescript::LOCALS_QUERY, + ) + }) + .map(|mut c| { + c.configure(HIGHLIGHT_NAMES); + c + }) + .ok() + }) + .as_ref() +} + +pub static STATIC_LANGS: &[StaticEntry] = &[ + StaticEntry { + name: "typescript", + extensions: &["ts", "mts", "cts"], + filenames: &[], + filename_prefixes: &[], + get: cfg_typescript, + }, + StaticEntry { + name: "tsx", + extensions: &["tsx"], + filenames: &[], + filename_prefixes: &[], + get: cfg_tsx, + }, + StaticEntry { + name: "javascript", + extensions: &["js", "mjs", "cjs"], + filenames: &[], + filename_prefixes: &[], + get: cfg_javascript, + }, + StaticEntry { + name: "jsx", + extensions: &["jsx"], + filenames: &[], + filename_prefixes: &[], + get: cfg_javascript, + }, + StaticEntry { + name: "rust", + extensions: &["rs"], + filenames: &[], + filename_prefixes: &[], + get: cfg_rust, + }, + StaticEntry { + name: "json", + extensions: &["json", "jsonc"], + filenames: &[], + filename_prefixes: &[], + get: cfg_json, + }, + StaticEntry { + name: "python", + extensions: &["py", "pyi"], + filenames: &[], + filename_prefixes: &[], + get: cfg_python, + }, + StaticEntry { + name: "go", + extensions: &["go"], + filenames: &[], + filename_prefixes: &[], + get: cfg_go, + }, + StaticEntry { + name: "css", + extensions: &["css"], + filenames: &[], + filename_prefixes: &[], + get: cfg_css, + }, + StaticEntry { + name: "html", + extensions: &["html", "htm"], + filenames: &[], + filename_prefixes: &[], + get: cfg_html, + }, + StaticEntry { + name: "toml", + extensions: &["toml"], + filenames: &[], + filename_prefixes: &[], + get: cfg_toml, + }, + StaticEntry { + name: "bash", + extensions: &["sh", "bash", "zsh"], + filenames: &[".bashrc", ".bash_profile", ".zshrc", ".profile", "PKGBUILD"], + filename_prefixes: &[], + get: cfg_bash, + }, + StaticEntry { + name: "markdown", + extensions: &["md", "mdx", "markdown"], + filenames: &[], + filename_prefixes: &[], + get: cfg_markdown, + }, + StaticEntry { + name: "c", + extensions: &["c", "h"], + filenames: &[], + filename_prefixes: &[], + get: cfg_c, + }, + StaticEntry { + name: "java", + extensions: &["java"], + filenames: &[], + filename_prefixes: &[], + get: cfg_java, + }, + StaticEntry { + name: "dockerfile", + extensions: &["dockerfile"], + filenames: &["Dockerfile", "Containerfile"], + filename_prefixes: &["Dockerfile.", "Containerfile."], + get: cfg_dockerfile, + }, + StaticEntry { + name: "lua", + extensions: &["lua"], + filenames: &[], + filename_prefixes: &[], + get: cfg_lua, + }, + StaticEntry { + name: "yaml", + extensions: &["yml", "yaml"], + filenames: &[], + filename_prefixes: &[], + get: cfg_yaml, + }, +]; + +/// Friendly display name for the `?` panel. Internal ids like +/// `c-sharp` / `cpp` mean nothing to someone without the codebase. +pub fn pretty_name(name: &str) -> &'static str { + match name { + "typescript" => "TypeScript", + "tsx" => "TSX", + "javascript" => "JavaScript", + "jsx" => "JSX", + "rust" => "Rust", + "json" => "JSON", + "python" => "Python", + "go" => "Go", + "css" => "CSS", + "html" => "HTML", + "toml" => "TOML", + "bash" => "Shell", + "markdown" => "Markdown", + "c" => "C", + "java" => "Java", + "lua" => "Lua", + "yaml" => "YAML", + "dockerfile" => "Dockerfile", + "cpp" => "C++", + "c-sharp" => "C#", + "php" => "PHP", + "ruby" => "Ruby", + "swift" => "Swift", + _ => "Unknown", + } +} + +fn basename_of(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} + +fn config_for_shebang(first_line: &str) -> Option<&'static StaticEntry> { + let line = first_line.strip_prefix("#!")?.to_ascii_lowercase(); + let find = |names: &[&str]| STATIC_LANGS.iter().find(|e| names.contains(&e.name)); + if line.contains("python") { + return find(&["python"]); + } + if line.contains("bash") || line.contains("zsh") || line.contains("/sh") { + return find(&["bash"]); + } + if line.contains("lua") && !line.contains("evaluate") { + return find(&["lua"]); + } + if line.contains("node") || line.contains("deno") || line.contains("bun") { + return find(&["javascript"]); + } + None +} + +/// Static lookup: exact filename → extension → `#!` shebang. +pub fn config_for_file( + filename: &str, + first_line: Option<&str>, +) -> Option<(&'static StaticEntry, &'static HighlightConfiguration)> { + let base = basename_of(filename); + if let Some(e) = STATIC_LANGS.iter().find(|e| { + e.filenames.contains(&base) || e.filename_prefixes.iter().any(|p| base.starts_with(p)) + }) { + if let Some(c) = (e.get)() { + return Some((e, c)); } } + if let Some(ext) = std::path::Path::new(base) + .extension() + .and_then(|e| e.to_str()) + { + let ext = ext.to_ascii_lowercase(); + if let Some(e) = STATIC_LANGS + .iter() + .find(|e| e.extensions.iter().any(|x| *x == ext)) + { + if let Some(c) = (e.get)() { + return Some((e, c)); + } + } + } + if let Some(line) = first_line { + if let Some(e) = config_for_shebang(line) { + if let Some(c) = (e.get)() { + return Some((e, c)); + } + } + } + None } -pub static CONFIGS: Lazy> = Lazy::new(|| { - let mut configs = Vec::new(); - - load_config( - tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), - "typescript", - TS_HIGHLIGHTS, - "ts", - &mut configs, - ); - - load_config( - tree_sitter_typescript::LANGUAGE_TSX.into(), - "tsx", - TSX_HIGHLIGHTS, - "tsx", - &mut configs, - ); - - load_config( - tree_sitter_javascript::LANGUAGE.into(), - "javascript", - JS_HIGHLIGHTS, - "js", - &mut configs, - ); - - load_config( - tree_sitter_javascript::LANGUAGE.into(), - "javascript", - JS_HIGHLIGHTS, - "jsx", - &mut configs, - ); - - load_config( - tree_sitter_rust::LANGUAGE.into(), - "rust", - RUST_HIGHLIGHTS, - "rs", - &mut configs, - ); - - load_config( - tree_sitter_json::LANGUAGE.into(), - "json", - JSON_HIGHLIGHTS, - "json", - &mut configs, - ); - - load_config( - tree_sitter_python::LANGUAGE.into(), - "python", - PYTHON_HIGHLIGHTS, - "py", - &mut configs, - ); - - load_config( - tree_sitter_go::LANGUAGE.into(), - "go", - GO_HIGHLIGHTS, - "go", - &mut configs, - ); - - load_config( - tree_sitter_css::LANGUAGE.into(), - "css", - CSS_HIGHLIGHTS, - "css", - &mut configs, - ); - - load_config( - tree_sitter_html::LANGUAGE.into(), - "html", - HTML_HIGHLIGHTS, - "html", - &mut configs, - ); - - load_config( - tree_sitter_toml_ng::LANGUAGE.into(), - "toml", - TOML_HIGHLIGHTS, - "toml", - &mut configs, - ); - - load_config( - tree_sitter_bash::LANGUAGE.into(), - "bash", - BASH_HIGHLIGHTS, - "sh", - &mut configs, - ); - - load_config( - tree_sitter_bash::LANGUAGE.into(), - "bash", - BASH_HIGHLIGHTS, - "bash", - &mut configs, - ); - - load_config( - tree_sitter_md::LANGUAGE.into(), - "markdown", - MD_HIGHLIGHTS, - "md", - &mut configs, - ); - - load_config( - tree_sitter_md::LANGUAGE.into(), - "markdown", - MD_HIGHLIGHTS, - "mdx", - &mut configs, - ); - - configs -}); +/// Touch every static config. Called from a background thread (see +/// `Gui::new`), so the first diff never pays the compile cost. +pub fn warm_all() { + for e in STATIC_LANGS { + let _ = (e.get)(); + } +} diff --git a/src/pager/highlight/dynamic.rs b/src/pager/highlight/dynamic.rs new file mode 100644 index 0000000..075df3b --- /dev/null +++ b/src/pager/highlight/dynamic.rs @@ -0,0 +1,706 @@ +//! User-installed grammars — new languages without rebuilding. +//! +//! Layout under `/syntax/` (usually `~/.config/lazygitrs/syntax/`): +//! ```text +//! grammars/.so compiled grammar exporting `tree_sitter_` +//! queries//highlights.scm required (copy from nvim-treesitter or Helix) +//! queries//injections.scm optional +//! queries//locals.scm optional +//! languages.yml optional extension/filename/shebang overrides +//! ``` +//! Drop files in, open a diff — they load automatically on next highlight. +//! `?` → `Syntax highlighting health…` shows status; `Reload syntax grammars` +//! retries after fixing errors. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{OnceLock, RwLock}; + +use serde::{Deserialize, Serialize}; +use tree_sitter_highlight::HighlightConfiguration; + +use super::config::HIGHLIGHT_NAMES; + +const README: &str = r#"# Extra syntax highlighting + +Most languages already work. These need extra files: +Swift, C#, C++, PHP, Ruby. + +Easiest way: open `?` in lazygitrs, pick "Syntax highlighting...", +then press Enter on the language. It downloads and sets it up. + +Any other language: add 2 files per language under this folder: + + grammars/.so + queries//highlights.scm + +Example for Zig: + grammars/zig.so + queries/zig/highlights.scm + +Where do the files come from? +- The .so is a compiled tree-sitter grammar for that language. + Search " tree-sitter grammar", build it, and copy + the .so here. It must export a `tree_sitter_` symbol. +- The highlights.scm is a color file. Copy it from the + nvim-treesitter or Helix project for that language. + +It then shows up in `?` → "Syntax highlighting..." under Ready. +If the extension is unusual (not just .), add languages.yml: + + zig: + extensions: [zig, zon] +"#; + +const EXAMPLE_YML: &str = r#"# Only needed when the extension is not just the language name. +# zig: +# extensions: [zig, zon] +"#; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(default)] +struct LangOverride { + extensions: Option>, + filenames: Option>, + filename_prefixes: Option>, + shebang: Option>, +} + +/// Built-in detection defaults for the heavy grammars we ship as dynamic. +/// (Static langs are matched first, so `.h` stays with C.) +struct KnownDynamic { + name: &'static str, + extensions: &'static [&'static str], + filenames: &'static [&'static str], + filename_prefixes: &'static [&'static str], + shebang: &'static [&'static str], +} + +const KNOWN_DYNAMIC: &[KnownDynamic] = &[ + KnownDynamic { + name: "cpp", + extensions: &["cpp", "hpp", "cc", "hh", "cxx", "hxx"], + filenames: &[], + filename_prefixes: &[], + shebang: &[], + }, + KnownDynamic { + name: "c-sharp", + extensions: &["cs", "csx"], + filenames: &[], + filename_prefixes: &[], + shebang: &[], + }, + KnownDynamic { + name: "php", + extensions: &["php", "phtml"], + filenames: &[], + filename_prefixes: &[], + shebang: &["php"], + }, + KnownDynamic { + name: "ruby", + extensions: &["rb", "rake", "gemspec"], + filenames: &["Gemfile", "Rakefile", "Vagrantfile", "Brewfile", "Podfile"], + filename_prefixes: &[], + shebang: &["ruby"], + }, + KnownDynamic { + name: "swift", + extensions: &["swift"], + filenames: &[], + filename_prefixes: &[], + shebang: &[], + }, +]; + +fn known(name: &str) -> Option<&'static KnownDynamic> { + KNOWN_DYNAMIC.iter().find(|k| k.name == name) +} + +/// All extra languages installable from the `?` menu (id + file extensions). +pub fn known_langs() -> Vec<(&'static str, Vec)> { + KNOWN_DYNAMIC + .iter() + .map(|k| (k.name, k.extensions.iter().map(|s| s.to_string()).collect())) + .collect() +} + +/// Upstream grammar repo for an extra language. `None` = not installable +/// in-app (user drops files in manually instead). +pub fn repo_for(lang: &str) -> Option<&'static str> { + match lang { + "swift" => Some("https://github.com/alex-pinkus/tree-sitter-swift"), + "c-sharp" => Some("https://github.com/tree-sitter/tree-sitter-c-sharp"), + "cpp" | "c++" => Some("https://github.com/tree-sitter/tree-sitter-cpp"), + "php" => Some("https://github.com/tree-sitter/tree-sitter-php"), + "ruby" => Some("https://github.com/tree-sitter/tree-sitter-ruby"), + _ => None, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InstallState { + Installed, + Failed(String), + Missing, +} + +/// Where an extra language stands: working, broken (with raw loader error), +/// or nothing installed yet. +pub fn install_state(lang: &str) -> InstallState { + scan(false); + match state().read() { + Ok(st) => { + if st.entries.iter().any(|e| e.name == lang) { + InstallState::Installed + } else if let Some(err) = st.errors.iter().find(|e| e.starts_with(lang)) { + InstallState::Failed(err.clone()) + } else { + InstallState::Missing + } + } + Err(_) => InstallState::Missing, + } +} + +fn run(cmd: &mut std::process::Command) -> Result { + let out = cmd.output().map_err(|e| format!("could not run: {e}"))?; + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout).to_string()) + } else { + Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) + } +} + +/// Download + build an extra language into `syntax/` (same files the manual +/// flow uses), then rescan so it lights up immediately. +/// All errors are already plain-language for direct display. +pub fn install_lang(lang: &str) -> Result<(), String> { + let repo = + repo_for(lang).ok_or_else(|| "That language cannot be installed yet.".to_string())?; + if std::process::Command::new("git") + .arg("--version") + .output() + .is_err() + { + return Err("Could not find git.".to_string()); + } + if std::process::Command::new("cc") + .arg("--version") + .output() + .is_err() + { + return Err("Could not find a C compiler.".to_string()); + } + + let dir = ensure_skeleton(); + let work = + std::env::temp_dir().join(format!("lazygitrs-grammar-{lang}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&work); + std::fs::create_dir_all(&work).map_err(|_| "Could not use a temporary folder.".to_string())?; + let cleanup = || { + let _ = std::fs::remove_dir_all(&work); + }; + + let src = work.join("src"); + let clone_err = run(std::process::Command::new("git") + .args(["clone", "--depth", "1", "--quiet", repo]) + .arg(&src)); + if let Err(log) = clone_err { + cleanup(); + let _ = log; + return Err("Could not download it. Check your internet connection.".to_string()); + } + + let parser = walk_find(&src, "parser.c").ok_or_else(|| { + cleanup(); + "Downloaded, but it did not look like a grammar.".to_string() + })?; + let pdir = parser.parent().unwrap_or(&src).to_path_buf(); + let mut sources = vec![parser]; + for s in ["scanner.c", "scanner.cc"] { + let p = pdir.join(s); + if p.is_file() { + sources.push(p); + } + } + let out_so = dir.join("grammars").join(format!("{lang}.so")); + let mut cc = std::process::Command::new("cc"); + cc.args(["-shared", "-fPIC", "-O2", "-I"]); + cc.arg(&pdir); + cc.args(&sources); + cc.args(["-o"]); + cc.arg(&out_so); + if run(&mut cc).is_err() { + cleanup(); + return Err("Downloaded, but it would not build.".to_string()); + } + + // Queries: top-level queries/ first, else first highlights.scm found (php). + let top = src.join("queries").join("highlights.scm"); + let qsrc = if top.is_file() { + src.join("queries") + } else { + match walk_find(&src, "highlights.scm") { + Some(p) => p.parent().unwrap_or(&src).to_path_buf(), + None => { + cleanup(); + return Err("Built it, but its color file was missing.".to_string()); + } + } + }; + let dest = dir.join("queries").join(lang); + std::fs::create_dir_all(&dest).map_err(|_| "Could not save the files.".to_string())?; + for q in ["highlights.scm", "injections.scm", "locals.scm"] { + let from = qsrc.join(q); + if from.is_file() && std::fs::copy(&from, dest.join(q)).is_err() { + cleanup(); + return Err("Could not save the files.".to_string()); + } + } + cleanup(); + + reload(); + match install_state(lang) { + InstallState::Installed => Ok(()), + InstallState::Failed(_) => { + Err("Installed, but its color file could not be read.".to_string()) + } + InstallState::Missing => Err("Installed, but it did not load. Try reloading.".to_string()), + } +} + +fn walk_find(root: &Path, file: &str) -> Option { + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(rd) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in rd.flatten() { + let path = entry.path(); + if path.is_dir() { + // Skip test corpora (large, and php keeps per-variant queries + // we don't want to mistake for the top-level one). + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name == "test" || name == ".git" { + continue; + } + } + stack.push(path); + } else if path.file_name().and_then(|n| n.to_str()) == Some(file) { + return Some(path); + } + } + } + None +} + +pub struct DynamicEntry { + pub name: String, + pub extensions: Vec, + pub filenames: Vec, + pub filename_prefixes: Vec, + pub shebang: Vec, + pub config: &'static HighlightConfiguration, +} + +struct State { + entries: Vec, + errors: Vec, + attempted: HashSet, +} + +static STATE: OnceLock> = OnceLock::new(); + +fn state() -> &'static RwLock { + STATE.get_or_init(|| { + RwLock::new(State { + entries: Vec::new(), + errors: Vec::new(), + attempted: HashSet::new(), + }) + }) +} + +/// Always the lazygitrs dir (never legacy lazygit) so we don't pollute it. +pub fn syntax_dir() -> PathBuf { + crate::config::config_dir_candidates() + .into_iter() + .next() + .unwrap_or_else(|| PathBuf::from(".")) + .join("syntax") +} + +pub fn ensure_skeleton() -> PathBuf { + let dir = syntax_dir(); + let _ = std::fs::create_dir_all(dir.join("grammars")); + let _ = std::fs::create_dir_all(dir.join("queries")); + if !dir.join("README.md").exists() { + let _ = std::fs::write(dir.join("README.md"), README); + } + if !dir.join("languages.yml").exists() { + let _ = std::fs::write(dir.join("languages.yml"), EXAMPLE_YML); + } + dir +} + +fn sanitize(name: &str) -> String { + name.replace('-', "_") +} + +/// C symbol suffix for a language id (`tree_sitter_`). +/// `c++` is never a valid C identifier — upstream exports `tree_sitter_cpp`. +pub fn symbol_suffix(lang: &str) -> String { + match lang { + "c++" | "cpp" => "cpp".to_string(), + _ => sanitize(lang), + } +} + +fn load_language( + lib_path: &Path, + lang: &str, +) -> Result<(tree_sitter::Language, libloading::Library), String> { + let lib = + unsafe { libloading::Library::new(lib_path) }.map_err(|e| format!("open .so: {e}"))?; + let sym_name = format!("tree_sitter_{}", symbol_suffix(lang)); + unsafe { + let func: libloading::Symbol *const ()> = lib + .get(sym_name.as_bytes()) + .map_err(|_| format!("missing symbol `{sym_name}`"))?; + let ptr = func(); + if ptr.is_null() { + return Err("null language pointer".into()); + } + let lang_fn = tree_sitter_language::LanguageFn::from_raw(*func); + Ok((tree_sitter::Language::new(lang_fn), lib)) + } +} + +fn find_library(grammars: &Path, lang: &str) -> Option { + let flat = sanitize(lang); + [ + grammars.join(format!("{lang}.so")), + grammars.join(format!("{flat}.so")), + grammars.join(format!("libtree-sitter-{lang}.so")), + ] + .into_iter() + .find(|p| p.is_file()) +} + +fn read_opt(path: &Path) -> String { + std::fs::read_to_string(path).unwrap_or_default() +} + +fn attempt_lang( + dir: &Path, + lang: &str, + ov: &LangOverride, +) -> (Option, Option) { + let qdir = dir.join("queries").join(lang); + let hl_path = qdir.join("highlights.scm"); + if !hl_path.is_file() { + return (None, None); // not a language dir, ignore silently + } + let highlights = read_opt(&hl_path); + if highlights.trim().is_empty() { + return (None, Some(format!("{lang}: highlights.scm is empty"))); + } + let injections = read_opt(&qdir.join("injections.scm")); + let locals = read_opt(&qdir.join("locals.scm")); + let lib_path = match find_library(&dir.join("grammars"), lang) { + Some(p) => p, + None => { + return (None, Some(format!("{lang}: no grammars/{lang}.so"))); + } + }; + let (language, lib) = match load_language(&lib_path, lang) { + Ok(v) => v, + Err(e) => return (None, Some(format!("{lang}: {e}"))), + }; + let name_static: &'static str = Box::leak(lang.to_string().into_boxed_str()); + match HighlightConfiguration::new(language, name_static, &highlights, &injections, &locals) { + Ok(mut config) => { + config.configure(HIGHLIGHT_NAMES); + // The grammar's machine code lives in `lib`; never unload it. + Box::leak(Box::new(lib)); + let k = known(lang); + let strs = |v: Option<&Vec>, known: &[&str], fallback: Vec| { + v.cloned().unwrap_or_else(|| { + if known.is_empty() { + fallback + } else { + known.iter().map(|s| s.to_string()).collect() + } + }) + }; + ( + Some(DynamicEntry { + name: lang.to_string(), + extensions: strs( + ov.extensions.as_ref(), + k.map(|k| k.extensions).unwrap_or(&[]), + vec![lang.to_string()], + ), + filenames: strs( + ov.filenames.as_ref(), + k.map(|k| k.filenames).unwrap_or(&[]), + Vec::new(), + ), + filename_prefixes: strs( + ov.filename_prefixes.as_ref(), + k.map(|k| k.filename_prefixes).unwrap_or(&[]), + Vec::new(), + ), + shebang: strs( + ov.shebang.as_ref(), + k.map(|k| k.shebang).unwrap_or(&[]), + Vec::new(), + ) + .iter() + .map(|s| s.to_ascii_lowercase()) + .collect(), + config: Box::leak(Box::new(config)), + }), + None, + ) + } + Err(e) => (None, Some(format!("{lang}: bad query: {e:?}"))), + } +} + +/// Idempotent scan: loads language dirs never attempted before. +/// Cheap (one dir listing) so misses can trigger it on demand. +fn scan(force: bool) { + let dir = ensure_skeleton(); + let overrides: HashMap = + std::fs::read_to_string(dir.join("languages.yml")) + .ok() + .and_then(|s| serde_yaml::from_str(&s).ok()) + .unwrap_or_default(); + let Ok(rd) = std::fs::read_dir(dir.join("queries")) else { + return; + }; + let mut langs: Vec = rd + .flatten() + .filter(|e| e.path().join("highlights.scm").is_file()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + langs.sort(); + if langs.is_empty() { + return; + } + let Ok(mut st) = state().write() else { + return; + }; + if force { + st.entries.clear(); + st.errors.clear(); + st.attempted.clear(); + } + for lang in langs { + if st.attempted.contains(&lang) { + continue; + } + st.attempted.insert(lang.clone()); + let ov = overrides.get(&lang).cloned().unwrap_or_default(); + let (entry, err) = attempt_lang(&dir, &lang, &ov); + if let Some(entry) = entry { + st.entries.push(entry); + } + if let Some(err) = err { + st.errors.push(err); + } + } +} + +fn basename_of(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} + +fn find_in<'a>( + entries: &'a [DynamicEntry], + filename: &str, + first_line: Option<&str>, +) -> Option<&'a DynamicEntry> { + let base = basename_of(filename); + if let Some(e) = entries.iter().find(|e| { + e.filenames.iter().any(|f| f == base) + || e.filename_prefixes.iter().any(|p| base.starts_with(p)) + }) { + return Some(e); + } + if let Some(ext) = Path::new(base).extension().and_then(|e| e.to_str()) { + let ext = ext.to_ascii_lowercase(); + if let Some(e) = entries + .iter() + .find(|e| e.extensions.iter().any(|x| x.to_ascii_lowercase() == ext)) + { + return Some(e); + } + } + if let Some(line) = first_line { + if let Some(rest) = line.strip_prefix("#!") { + let rest = rest.to_ascii_lowercase(); + if let Some(e) = entries + .iter() + .find(|e| e.shebang.iter().any(|s| !s.is_empty() && rest.contains(s))) + { + return Some(e); + } + } + } + None +} + +pub fn get_config_for_file( + filename: &str, + first_line: Option<&str>, +) -> Option<&'static HighlightConfiguration> { + if let Ok(st) = state().read() { + if let Some(e) = find_in(&st.entries, filename, first_line) { + return Some(e.config); + } + } + scan(false); + state() + .read() + .ok() + .and_then(|st| find_in(&st.entries, filename, first_line).map(|e| e.config)) +} + +pub fn preload() { + scan(false); +} + +pub fn reload() { + scan(true); +} + +/// Installed dynamic languages + raw loader errors. Used by the `?` menu +/// to list user-added grammars (anything in `syntax/queries/` with a .so). +pub fn installed() -> (Vec, Vec) { + scan(false); + match state().read() { + Ok(st) => ( + st.entries.iter().map(|e| e.name.clone()).collect(), + st.errors.clone(), + ), + Err(_) => (Vec::new(), Vec::new()), + } +} + +#[cfg(test)] +mod dynamic_tests { + use super::*; + + #[test] + fn known_defaults_cover_heavy_languages() { + // Extension mapping works out-of-box once the .so + queries exist — + // no languages.yml needed for these five. + let cases = [ + ("c-sharp", "cs"), + ("cpp", "cpp"), + ("php", "php"), + ("ruby", "rb"), + ("swift", "swift"), + ]; + for (lang, ext) in cases { + let k = known(lang).unwrap_or_else(|| panic!("no defaults for {lang}")); + assert!( + k.extensions.contains(&ext), + "{lang} defaults missing .{ext}" + ); + } + // Ruby filenames + shebangs, PHP shebang. + let ruby = known("ruby").expect("ruby defaults"); + assert!(ruby.filenames.contains(&"Gemfile")); + assert!(ruby.shebang.contains(&"ruby")); + let php = known("php").expect("php defaults"); + assert!(php.shebang.contains(&"php")); + } + + #[test] + fn find_in_matches_known_shapes() { + // Build entries the way attempt_lang would with defaults. + let entries: Vec = KNOWN_DYNAMIC + .iter() + .map(|k| DynamicEntry { + name: k.name.to_string(), + extensions: k.extensions.iter().map(|s| s.to_string()).collect(), + filenames: k.filenames.iter().map(|s| s.to_string()).collect(), + filename_prefixes: k.filename_prefixes.iter().map(|s| s.to_string()).collect(), + shebang: k.shebang.iter().map(|s| s.to_string()).collect(), + config: Box::leak(Box::new( + HighlightConfiguration::new( + tree_sitter_bash::LANGUAGE.into(), + Box::leak(k.name.to_string().into_boxed_str()), + tree_sitter_bash::HIGHLIGHT_QUERY, + "", + "", + ) + .expect("bash query builds"), + )), + }) + .collect(); + assert_eq!( + find_in(&entries, "main.cpp", None).map(|e| e.name.as_str()), + Some("cpp") + ); + assert_eq!( + find_in(&entries, "A.cs", None).map(|e| e.name.as_str()), + Some("c-sharp") + ); + assert_eq!( + find_in(&entries, "Gemfile", None).map(|e| e.name.as_str()), + Some("ruby") + ); + assert_eq!( + find_in(&entries, "run.php", None).map(|e| e.name.as_str()), + Some("php") + ); + assert_eq!( + find_in(&entries, "x", Some("#!/usr/bin/env ruby")).map(|e| e.name.as_str()), + Some("ruby") + ); + } +} + +#[cfg(test)] +mod install_tests { + use super::*; + + #[test] + fn symbol_suffix_maps_cpp() { + assert_eq!(symbol_suffix("cpp"), "cpp"); + assert_eq!(symbol_suffix("c++"), "cpp"); + assert_eq!(symbol_suffix("c-sharp"), "c_sharp"); + assert_eq!(symbol_suffix("swift"), "swift"); + } + + #[test] + fn repo_for_covers_known_langs() { + for (id, _) in known_langs() { + assert!( + repo_for(id).is_some_and(|u| u.starts_with("https://")), + "{id} has no install repo" + ); + } + assert!(repo_for("zig").is_none()); + } + + #[test] + fn install_unknown_lang_fails_fast_without_network() { + let err = install_lang("klingon").unwrap_err(); + assert!(!err.is_empty()); + } + + #[test] + fn missing_lang_reports_missing_state() { + assert_eq!( + install_state("definitely-not-a-language"), + InstallState::Missing + ); + } +} diff --git a/src/pager/highlight/mod.rs b/src/pager/highlight/mod.rs index 11d3aa2..0ddf793 100644 --- a/src/pager/highlight/mod.rs +++ b/src/pager/highlight/mod.rs @@ -1,50 +1,212 @@ -mod config; -mod queries; +pub mod config; +pub mod dynamic; use std::collections::HashMap; -use std::path::Path; +use std::path::PathBuf; use ratatui::prelude::*; -use tree_sitter_highlight::{HighlightEvent, Highlighter}; +use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter}; use crate::config::Theme; -use config::{CONFIGS, HIGHLIGHT_NAMES, LanguageConfig}; +use config::{HIGHLIGHT_NAMES, STATIC_LANGS}; /// Map a highlight index to a ratatui Color using the active theme. +/// Prefix-matches so upstream captures (`keyword.function`, `markup.heading`, +/// `string.special.symbol`, …) work without touching this function. pub fn highlight_color(index: usize, theme: &Theme) -> Color { - match HIGHLIGHT_NAMES.get(index) { - Some(&"comment") => theme.syntax_comment, - Some(&"keyword") => theme.syntax_keyword, - Some(&"string" | &"string.special") => theme.syntax_string, - Some(&"number" | &"constant" | &"constant.builtin") => theme.syntax_number, - Some(&"function" | &"function.builtin" | &"function.method") => theme.syntax_function, - Some(&"function.macro") => theme.syntax_function_macro, - Some(&"type" | &"type.builtin" | &"constructor") => theme.syntax_type, - Some(&"variable.builtin") => theme.syntax_variable_builtin, - Some(&"variable.member" | &"property") => theme.syntax_variable_member, - Some(&"module") => theme.syntax_module, - Some(&"operator") => theme.syntax_operator, - Some(&"tag") => theme.syntax_tag, - Some(&"attribute") => theme.syntax_attribute, - Some(&"label") => theme.syntax_label, - Some(&"punctuation" | &"punctuation.bracket" | &"punctuation.delimiter") => { - theme.syntax_punctuation + let name = HIGHLIGHT_NAMES.get(index).copied().unwrap_or(""); + match name { + "function.macro" => theme.syntax_function_macro, + "variable.builtin" => theme.syntax_variable_builtin, + "variable.member" | "property" => theme.syntax_variable_member, + "tag.attribute" => theme.syntax_attribute, + _ => { + let base = name.split('.').next().unwrap_or(name); + match base { + "comment" => theme.syntax_comment, + "keyword" => theme.syntax_keyword, + "string" => theme.syntax_string, + "number" | "constant" | "boolean" => theme.syntax_number, + "function" => theme.syntax_function, + "type" | "constructor" => theme.syntax_type, + "module" | "namespace" => theme.syntax_module, + "operator" => theme.syntax_operator, + "tag" => theme.syntax_tag, + "attribute" => theme.syntax_attribute, + "label" => theme.syntax_label, + "punctuation" => theme.syntax_punctuation, + "markup" if name.contains("heading") => theme.syntax_keyword, + "markup" + if name.contains("link") || name.contains("url") || name.contains("raw") => + { + theme.syntax_string + } + "markup" if name.contains("quote") => theme.syntax_comment, + "diff" if name.contains("plus") => theme.syntax_string, + "diff" if name.contains("minus") => theme.syntax_keyword, + "diff" => theme.syntax_type, + "error" => theme.syntax_keyword, + _ => theme.syntax_default, + } } - _ => theme.syntax_default, } } -fn get_config_for_file(filename: &str) -> Option<&'static LanguageConfig> { - let ext = Path::new(filename).extension().and_then(|e| e.to_str())?; - CONFIGS.iter().find(|(e, _)| *e == ext).map(|(_, c)| c) +fn first_line(content: &str) -> Option<&str> { + let line = content.lines().next().unwrap_or(""); + if line.is_empty() { None } else { Some(line) } +} + +/// Static (built-in) → dynamic (`~/.config/lazygitrs/syntax/`) → plain text. +fn config_for_file(filename: &str, content: &str) -> Option<&'static HighlightConfiguration> { + let first = first_line(content); + if let Some((_, c)) = config::config_for_file(filename, first) { + return Some(c); + } + dynamic::get_config_for_file(filename, first) } -/// Compile the tree-sitter highlight queries (~40-60ms for all languages). -/// They live in a lazy static, so without this the first diff ever rendered -/// pays the whole cost on its critical path. +/// Compile static queries + prepare the dynamic folder, off the critical path. +/// Called from a background thread (see `Gui::new`). pub fn warm_configs() { - once_cell::sync::Lazy::force(&CONFIGS); + config::warm_all(); + let _ = dynamic::ensure_skeleton(); + dynamic::preload(); +} + +/// One row of the `?` → "Syntax highlighting..." menu. +/// Plain data (no widget types) so it stays unit-testable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyntaxRow { + /// Stable id the menu confirms with: a known language id (`swift`), + /// `static:` for built-ins, or `__reload__` for the rescan row. + pub value: String, + /// What the row shows, e.g. `✓ Rust`, `○ Swift`. + pub label: String, + /// Group header: `Ready` or `Available` (empty for the rescan row). + pub category: String, + /// Right-side note, e.g. `Ready`, `Enter to install`. + pub description: String, +} + +pub const SYNTAX_RELOAD_VALUE: &str = "__reload__"; + +/// Rows for the syntax menu: built-ins + installed extras under `Ready`, +/// missing/broken extras under `Available`, rescan row last. +/// Alphabetical within each group; pretty display names, no ids or paths. +pub fn syntax_menu_rows() -> Vec { + use config::pretty_name; + + let mut ready_static: Vec<&str> = STATIC_LANGS + .iter() + .filter(|e| (e.get)().is_some()) + .map(|e| e.name) + .collect(); + ready_static.sort_by_key(|n| pretty_name(n).to_string()); + + let mut ready_extra: Vec<&str> = Vec::new(); + let mut available: Vec = Vec::new(); + for (id, _exts) in dynamic::known_langs() { + match dynamic::install_state(id) { + dynamic::InstallState::Installed => ready_extra.push(id), + dynamic::InstallState::Missing => available.push(SyntaxRow { + value: id.to_string(), + label: format!("○ {}", pretty_name(id)), + category: "Available".to_string(), + description: "Enter to install".to_string(), + }), + dynamic::InstallState::Failed(_) => available.push(SyntaxRow { + value: id.to_string(), + label: format!("! {}", pretty_name(id)), + category: "Available".to_string(), + description: "Enter to retry".to_string(), + }), + } + } + ready_extra.sort_by_key(|n| pretty_name(n).to_string()); + available.sort_by(|a, b| a.label.cmp(&b.label)); + + let mut rows: Vec = ready_static + .iter() + .map(|n| SyntaxRow { + value: format!("static:{n}"), + label: format!("✓ {}", pretty_name(n)), + category: "Ready".to_string(), + description: "Ready".to_string(), + }) + .collect(); + rows.extend(ready_extra.iter().map(|n| SyntaxRow { + value: n.to_string(), + label: format!("✓ {}", pretty_name(n)), + category: "Ready".to_string(), + description: "Ready".to_string(), + })); + rows.extend(available); + // User-added grammars (via the folder) that aren't one of the 5 + // installable extras: show them as Ready under their own name. + let (dyn_langs, _) = dynamic::installed(); + let mut custom: Vec<&String> = dyn_langs + .iter() + .filter(|name| !dynamic::known_langs().iter().any(|(k, _)| k == name)) + .collect(); + custom.sort(); + rows.extend(custom.iter().map(|name| SyntaxRow { + value: format!("custom:{name}"), + label: format!("✓ {}", pretty_name(name)), + category: "Ready".to_string(), + description: "Added by you".to_string(), + })); + rows.push(SyntaxRow { + value: SYNTAX_RELOAD_VALUE.to_string(), + label: "Check again".to_string(), + category: String::new(), + description: "Rescan folder".to_string(), + }); + rows +} + +/// What confirming a menu row value means. Pure + tested. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SyntaxConfirm { + /// Already working — show a nothing-to-do note. + AlreadyReady(String), + /// Needs download + build on a background thread. + Install { id: String, pretty: String }, + /// Rescan the folder, then reopen the menu. + Reload, +} + +pub fn syntax_confirm_for(value: &str) -> SyntaxConfirm { + use config::pretty_name; + if value == SYNTAX_RELOAD_VALUE { + return SyntaxConfirm::Reload; + } + // User-added (folder) and built-in languages are already working. + if let Some(id) = value.strip_prefix("static:") { + let pretty = pretty_name(id).to_string(); + return SyntaxConfirm::AlreadyReady(pretty); + } + if let Some(name) = value.strip_prefix("custom:") { + let label = pretty_name(name); + let pretty = if label == "Unknown" { + name.to_string() + } else { + label.to_string() + }; + return SyntaxConfirm::AlreadyReady(pretty); + } + match dynamic::install_state(value) { + dynamic::InstallState::Installed => { + SyntaxConfirm::AlreadyReady(pretty_name(value).to_string()) + } + dynamic::InstallState::Missing | dynamic::InstallState::Failed(_) => { + SyntaxConfirm::Install { + id: value.to_string(), + pretty: pretty_name(value).to_string(), + } + } + } } /// Pre-computed highlights for an entire file, organized by line number. @@ -56,13 +218,13 @@ pub struct FileHighlighter { impl FileHighlighter { pub fn new(content: &str, filename: &str) -> Self { - let Some(lang_config) = get_config_for_file(filename) else { + let Some(lang_config) = config_for_file(filename, content) else { return Self::default(); }; let mut highlighter = Highlighter::new(); let highlights = - highlighter.highlight(&lang_config.config, content.as_bytes(), None, |_| None); + highlighter.highlight(lang_config, content.as_bytes(), None, None, |_| None); let Ok(highlights) = highlights else { return Self::default(); @@ -155,3 +317,172 @@ impl FileHighlighter { .unwrap_or_default() } } + +#[cfg(test)] +mod highlight_tests { + use super::*; + + #[test] + fn all_static_configs_compile() { + for e in STATIC_LANGS { + assert!((e.get)().is_some(), "static grammar failed: {}", e.name); + } + } + + #[test] + fn rust_snippet_highlights() { + let theme = Theme::dark(); + let hl = FileHighlighter::new("fn main() {\n // hi\n let x = \"s\";\n}\n", "main.rs"); + assert!(!hl.get_line_spans(1, None, &theme).is_empty()); + assert!(!hl.get_line_spans(2, None, &theme).is_empty()); + } + + #[test] + fn unknown_extension_is_plain() { + let theme = Theme::dark(); + let hl = FileHighlighter::new("hello", "file.zzzunknown"); + assert!(hl.get_line_spans(1, None, &theme).is_empty()); + } + + /// End-to-end: parse + highlight a snippet per static language. Catches + /// parser ABI mismatches, not just query-compile failures. + /// (Heavy grammars — swift, c-sharp, c++, php, ruby — are dynamic and + /// covered by `dynamic::known_defaults` tests instead.) + #[test] + fn new_languages_highlight_end_to_end() { + let theme = Theme::dark(); + let cases = [ + ("main.c", "int main() {\n return 0;\n}\n"), + ( + "A.java", + "class A {\n public static void main(String[] a) {}\n}\n", + ), + ("Dockerfile", "FROM ubuntu:22.04\nRUN apt-get update\n"), + ("Dockerfile.dev", "FROM ubuntu:22.04\n"), + ("a.lua", "local x = 1 -- comment\n"), + ("a.yml", "key: value\nlist:\n - one\n"), + ]; + for (file, content) in cases { + let line1 = content.lines().next().unwrap_or(""); + let hl = FileHighlighter::new(content, file); + assert!( + !hl.get_line_spans(1, None, &theme).is_empty(), + "{file} ({line1:?}) produced no highlights" + ); + } + } + + #[test] + fn heavy_languages_fall_through_without_grammars() { + // No .so in the test env → plain text, no panic. + let theme = Theme::dark(); + for file in ["a.swift", "A.cs", "main.cpp", "a.php", "a.rb"] { + let hl = FileHighlighter::new("hello world", file); + assert!( + hl.get_line_spans(1, None, &theme).is_empty(), + "{file} should be plain without its grammar installed" + ); + } + } +} + +#[cfg(test)] +mod syntax_menu_tests { + use super::*; + + fn joined(rows: &[SyntaxRow], field: impl Fn(&SyntaxRow) -> &str) -> String { + rows.iter().map(field).collect::>().join("\n") + } + + #[test] + fn rows_cover_every_language_exactly_once() { + let rows = syntax_menu_rows(); + let mut values: Vec<&str> = rows.iter().map(|r| r.value.as_str()).collect(); + values.sort(); + values.dedup(); + assert_eq!(values.len(), rows.len(), "duplicate row values"); + + // Every static language + every installable extra + rescan row. + let mut ids: Vec = STATIC_LANGS + .iter() + .map(|e| format!("static:{}", e.name)) + .collect(); + ids.extend(dynamic::known_langs().iter().map(|(id, _)| id.to_string())); + ids.push(SYNTAX_RELOAD_VALUE.to_string()); + for id in &ids { + assert!( + rows.iter().any(|r| &r.value == id), + "missing row for {id}\n{}", + joined(&rows, |r| r.value.as_str()) + ); + } + // Installed extras confirm by id (AlreadyReady), not as static. + for (id, _) in dynamic::known_langs() { + if matches!(dynamic::install_state(id), dynamic::InstallState::Installed) { + assert!(rows.iter().any(|r| r.value == id)); + } + } + } + + #[test] + fn rows_use_plain_language() { + let rows = syntax_menu_rows(); + let text = + joined(&rows, |r| r.label.as_str()) + "\n" + &joined(&rows, |r| r.description.as_str()); + for banned in [ + "c-sharp", + "cpp", + "tsx", + ".so", + ".scm", + "grammars/", + "queries/", + "scripts/", + "nvim", + "helix", + "QueryError", + "language-server", + "LSP", + ] { + assert!(!text.contains(banned), "row leaks jargon: {banned}\n{text}"); + } + assert!(text.contains("Swift"), "missing Swift row:\n{text}"); + assert!(text.contains("C#"), "missing C# row:\n{text}"); + assert!(text.contains("C++"), "missing C++ row:\n{text}"); + } + + #[test] + fn confirm_mapping() { + assert_eq!( + syntax_confirm_for(SYNTAX_RELOAD_VALUE), + SyntaxConfirm::Reload + ); + assert_eq!( + syntax_confirm_for("static:rust"), + SyntaxConfirm::AlreadyReady("Rust".to_string()) + ); + // Unknown ids can never be installed in the test env — deterministic + // regardless of what the developer has in their real syntax folder. + assert_eq!( + syntax_confirm_for("klingon"), + SyntaxConfirm::Install { + id: "klingon".to_string(), + pretty: "Unknown".to_string(), + } + ); + // Known extras map to Install (or AlreadyReady when installed). + match dynamic::install_state("swift") { + dynamic::InstallState::Installed => assert_eq!( + syntax_confirm_for("swift"), + SyntaxConfirm::AlreadyReady("Swift".to_string()) + ), + _ => assert_eq!( + syntax_confirm_for("swift"), + SyntaxConfirm::Install { + id: "swift".to_string(), + pretty: "Swift".to_string(), + } + ), + } + } +} diff --git a/src/pager/highlight/queries.rs b/src/pager/highlight/queries.rs deleted file mode 100644 index 00f9885..0000000 --- a/src/pager/highlight/queries.rs +++ /dev/null @@ -1,419 +0,0 @@ -pub const TS_HIGHLIGHTS: &str = r#" -(comment) @comment -(string) @string -(template_string) @string -(number) @number -(true) @constant.builtin -(false) @constant.builtin -(null) @constant.builtin -(undefined) @constant.builtin -(regex) @string.special - -["const" "let" "var" "function" "class" "interface" "type" "enum" "namespace" "module" "declare" "implements" "extends" "public" "private" "protected" "readonly" "static" "abstract" "async" "await" "return" "if" "else" "for" "while" "do" "switch" "case" "default" "break" "continue" "try" "catch" "finally" "throw" "new" "delete" "typeof" "instanceof" "in" "of" "as" "is" "import" "export" "from" "default" "void"] @keyword - -(type_identifier) @type -(predefined_type) @type.builtin - -(function_declaration name: (identifier) @function) -(method_definition name: (property_identifier) @function.method) -(call_expression function: (identifier) @function) -(call_expression function: (member_expression property: (property_identifier) @function.method)) -(arrow_function) @function - -(property_identifier) @property -(shorthand_property_identifier) @property -(shorthand_property_identifier_pattern) @property - -["(" ")" "[" "]" "{" "}"] @punctuation.bracket -["." "," ";" ":"] @punctuation.delimiter -"#; - -pub const TSX_HIGHLIGHTS: &str = r#" -(comment) @comment -(string) @string -(template_string) @string -(number) @number -(true) @constant.builtin -(false) @constant.builtin -(null) @constant.builtin -(undefined) @constant.builtin -(regex) @string.special - -["const" "let" "var" "function" "class" "interface" "type" "enum" "namespace" "module" "declare" "implements" "extends" "public" "private" "protected" "readonly" "static" "abstract" "async" "await" "return" "if" "else" "for" "while" "do" "switch" "case" "default" "break" "continue" "try" "catch" "finally" "throw" "new" "delete" "typeof" "instanceof" "in" "of" "as" "is" "import" "export" "from" "default" "void"] @keyword - -(type_identifier) @type -(predefined_type) @type.builtin - -(function_declaration name: (identifier) @function) -(method_definition name: (property_identifier) @function.method) -(call_expression function: (identifier) @function) -(call_expression function: (member_expression property: (property_identifier) @function.method)) -(arrow_function) @function - -(property_identifier) @property -(shorthand_property_identifier) @property -(shorthand_property_identifier_pattern) @property - -(jsx_element open_tag: (jsx_opening_element name: (identifier) @tag)) -(jsx_element close_tag: (jsx_closing_element name: (identifier) @tag)) -(jsx_self_closing_element name: (identifier) @tag) -(jsx_attribute (property_identifier) @attribute) - -["(" ")" "[" "]" "{" "}"] @punctuation.bracket -["." "," ";" ":"] @punctuation.delimiter -"#; - -pub const JS_HIGHLIGHTS: &str = r#" -(comment) @comment -(string) @string -(template_string) @string -(number) @number -(true) @constant.builtin -(false) @constant.builtin -(null) @constant.builtin -(undefined) @constant.builtin -(regex) @string.special - -["const" "let" "var" "function" "class" "extends" "async" "await" "return" "if" "else" "for" "while" "do" "switch" "case" "default" "break" "continue" "try" "catch" "finally" "throw" "new" "delete" "typeof" "instanceof" "in" "of" "import" "export" "from" "default" "void"] @keyword - -(function_declaration name: (identifier) @function) -(method_definition name: (property_identifier) @function.method) -(call_expression function: (identifier) @function) -(call_expression function: (member_expression property: (property_identifier) @function.method)) -(arrow_function) @function - -(property_identifier) @property -(shorthand_property_identifier) @property - -(jsx_element open_tag: (jsx_opening_element name: (identifier) @tag)) -(jsx_element close_tag: (jsx_closing_element name: (identifier) @tag)) -(jsx_self_closing_element name: (identifier) @tag) -(jsx_attribute (property_identifier) @attribute) - -["(" ")" "[" "]" "{" "}"] @punctuation.bracket -["." "," ";" ":"] @punctuation.delimiter -"#; - -pub const RUST_HIGHLIGHTS: &str = r#" -(line_comment) @comment -(block_comment) @comment -(outer_doc_comment_marker) @comment -(inner_doc_comment_marker) @comment -(doc_comment) @comment - -(string_literal) @string -(raw_string_literal) @string -(char_literal) @string -(integer_literal) @number -(float_literal) @number -(boolean_literal) @constant.builtin - -(type_identifier) @type -(primitive_type) @type.builtin - -(function_item (identifier) @function) -(function_signature_item (identifier) @function) -(call_expression function: (identifier) @function) -(call_expression function: (field_expression field: (field_identifier) @function.method)) -(call_expression function: (scoped_identifier name: (identifier) @function)) -(generic_function function: (identifier) @function) -(generic_function function: (scoped_identifier name: (identifier) @function)) - -(macro_invocation macro: (identifier) @function.macro "!" @function.macro) -(macro_definition "macro_rules!" @function.macro) - -(field_identifier) @variable.member -(shorthand_field_identifier) @variable.member - -(lifetime (identifier) @label) -(parameter (identifier) @variable.parameter) - -(mod_item name: (identifier) @module) -(scoped_identifier path: (identifier) @module) - -(self) @variable.builtin -(crate) @keyword -(super) @keyword -(mutable_specifier) @keyword - -"as" @keyword -"async" @keyword -"await" @keyword -"break" @keyword -"const" @keyword -"continue" @keyword -"dyn" @keyword -"else" @keyword -"enum" @keyword -"extern" @keyword -"fn" @keyword -"for" @keyword -"if" @keyword -"impl" @keyword -"in" @keyword -"let" @keyword -"loop" @keyword -"match" @keyword -"mod" @keyword -"move" @keyword -"pub" @keyword -"ref" @keyword -"return" @keyword -"static" @keyword -"struct" @keyword -"trait" @keyword -"type" @keyword -"unsafe" @keyword -"use" @keyword -"where" @keyword -"while" @keyword - -"*" @operator -"&" @operator -"=" @operator -"+" @operator -"-" @operator -"%" @operator -"<" @operator -">" @operator -"==" @operator -"!=" @operator -"<=" @operator -">=" @operator -"&&" @operator -"||" @operator -"+=" @operator -"-=" @operator -"*=" @operator -"/=" @operator -".." @operator -"..=" @operator -"=>" @operator -"->" @operator -"?" @operator - -(binary_expression "/" @operator) -(unary_expression "!" @operator) - -"(" @punctuation.bracket -")" @punctuation.bracket -"[" @punctuation.bracket -"]" @punctuation.bracket -"{" @punctuation.bracket -"}" @punctuation.bracket -"::" @punctuation.delimiter -":" @punctuation.delimiter -"#; - -pub const JSON_HIGHLIGHTS: &str = r#" -(string) @string -(number) @number -(true) @constant.builtin -(false) @constant.builtin -(null) @constant.builtin -(pair key: (string) @property) - -"[" @punctuation.bracket -"]" @punctuation.bracket -"{" @punctuation.bracket -"}" @punctuation.bracket -":" @punctuation.delimiter -"," @punctuation.delimiter -"#; - -pub const PYTHON_HIGHLIGHTS: &str = r#" -(comment) @comment -(string) @string -(escape_sequence) @string.special - -(integer) @number -(float) @number -(none) @constant.builtin -(true) @constant.builtin -(false) @constant.builtin - -(type (identifier) @type) -(attribute attribute: (identifier) @property) - -(function_definition name: (identifier) @function) -(call function: (identifier) @function) -(call function: (attribute attribute: (identifier) @function.method)) -(decorator) @function -(decorator (identifier) @function) - -"as" @keyword -"assert" @keyword -"async" @keyword -"await" @keyword -"break" @keyword -"class" @keyword -"continue" @keyword -"def" @keyword -"del" @keyword -"elif" @keyword -"else" @keyword -"except" @keyword -"finally" @keyword -"for" @keyword -"from" @keyword -"global" @keyword -"if" @keyword -"import" @keyword -"lambda" @keyword -"nonlocal" @keyword -"pass" @keyword -"raise" @keyword -"return" @keyword -"try" @keyword -"while" @keyword -"with" @keyword -"yield" @keyword -"match" @keyword -"case" @keyword -"and" @operator -"or" @operator -"not" @operator -"in" @operator -"is" @operator -"#; - -pub const GO_HIGHLIGHTS: &str = r#" -(comment) @comment -(interpreted_string_literal) @string -(raw_string_literal) @string -(rune_literal) @string - -(int_literal) @number -(float_literal) @number -(true) @constant.builtin -(false) @constant.builtin -(nil) @constant.builtin - -(type_identifier) @type -(type_spec name: (type_identifier) @type) - -(function_declaration name: (identifier) @function) -(method_declaration name: (field_identifier) @function.method) -(call_expression function: (identifier) @function) -(call_expression function: (selector_expression field: (field_identifier) @function.method)) - -(field_identifier) @property -(package_identifier) @module - -"break" @keyword -"case" @keyword -"chan" @keyword -"const" @keyword -"continue" @keyword -"default" @keyword -"defer" @keyword -"else" @keyword -"fallthrough" @keyword -"for" @keyword -"func" @keyword -"go" @keyword -"goto" @keyword -"if" @keyword -"import" @keyword -"interface" @keyword -"map" @keyword -"package" @keyword -"range" @keyword -"return" @keyword -"select" @keyword -"struct" @keyword -"switch" @keyword -"type" @keyword -"var" @keyword - -"=" @operator -"+" @operator -"-" @operator -"*" @operator -"/" @operator -"%" @operator -"!" @operator -"<" @operator -">" @operator -"&" @operator -"|" @operator -"^" @operator -":=" @operator -"==" @operator -"!=" @operator -"<=" @operator -">=" @operator -"&&" @operator -"||" @operator -"++" @operator -"--" @operator -"+=" @operator -"-=" @operator -"<-" @operator - -"(" @punctuation.bracket -")" @punctuation.bracket -"[" @punctuation.bracket -"]" @punctuation.bracket -"{" @punctuation.bracket -"}" @punctuation.bracket -"." @punctuation.delimiter -"," @punctuation.delimiter -";" @punctuation.delimiter -":" @punctuation.delimiter -"#; - -pub const CSS_HIGHLIGHTS: &str = r#" -(comment) @comment -(string_value) @string -(integer_value) @number -(float_value) @number -(color_value) @constant -(property_name) @property -(tag_name) @tag -(class_name) @type -(id_name) @constant -(at_keyword) @keyword -"#; - -pub const HTML_HIGHLIGHTS: &str = r#" -(comment) @comment -(quoted_attribute_value) @string -(tag_name) @tag -(attribute_name) @attribute -"#; - -pub const TOML_HIGHLIGHTS: &str = r#" -(comment) @comment -(string) @string -(integer) @number -(float) @number -(boolean) @constant.builtin -(bare_key) @property -(dotted_key) @property -"#; - -pub const BASH_HIGHLIGHTS: &str = r#" -(comment) @comment -(string) @string -(raw_string) @string -(number) @number -(command_name) @function -(variable_name) @variable -"#; - -pub const MD_HIGHLIGHTS: &str = r#" -(atx_heading) @keyword -(setext_heading) @keyword -(thematic_break) @punctuation.delimiter -(fenced_code_block) @string -(indented_code_block) @string -(block_quote) @comment -(list_marker_plus) @punctuation -(list_marker_minus) @punctuation -(list_marker_star) @punctuation -(list_marker_dot) @punctuation -(list_marker_parenthesis) @punctuation -(link_destination) @string -(link_title) @string -"#;