use hjkl_buffer::rope_line_str;
use hjkl_engine_tui::EditorRatatuiExt;
use hjkl_ex::QfCommand;
use hjkl_quickfix::{QfEntry, QfKind, QfList};
use ratatui::style::Style as RStyle;
use crate::app::types::DiagSeverity;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum QfWhich {
Quickfix,
Location,
}
impl QfWhich {
fn label(self) -> &'static str {
match self {
QfWhich::Quickfix => "quickfix",
QfWhich::Location => "location",
}
}
fn dock_kind(self) -> crate::app::dock::DockKind {
match self {
QfWhich::Quickfix => crate::app::dock::DockKind::Quickfix,
QfWhich::Location => crate::app::dock::DockKind::Loclist,
}
}
}
fn resolve_make_argv(makeprg: &str, extra: &str) -> Option<(String, Vec<String>)> {
let mut make_tokens = makeprg.split_whitespace().map(str::to_string);
let program = make_tokens.next()?;
let mut rest: Vec<String> = make_tokens.collect();
rest.extend(extra.split_whitespace().map(str::to_string));
Some((program, rest))
}
impl crate::app::App {
fn qf_list(&self, w: QfWhich) -> &QfList {
match w {
QfWhich::Quickfix => &self.quickfix,
QfWhich::Location => &self.loclist,
}
}
fn qf_list_mut(&mut self, w: QfWhich) -> &mut QfList {
match w {
QfWhich::Quickfix => &mut self.quickfix,
QfWhich::Location => &mut self.loclist,
}
}
pub(crate) fn quickfix_open(&self) -> bool {
self.bottom_dock
.as_ref()
.is_some_and(|d| d.kind == crate::app::dock::DockKind::Quickfix)
}
pub(crate) fn loclist_open(&self) -> bool {
self.bottom_dock
.as_ref()
.is_some_and(|d| d.kind == crate::app::dock::DockKind::Loclist)
}
fn qf_set_open(&mut self, w: QfWhich, open: bool) {
if open {
self.open_bottom_dock_for(w);
} else if self.qf_dock_shows(w) {
self.close_bottom_dock();
}
}
fn qf_dock_shows(&self, w: QfWhich) -> bool {
match w {
QfWhich::Quickfix => self.quickfix_open(),
QfWhich::Location => self.loclist_open(),
}
}
fn open_bottom_dock_for(&mut self, w: QfWhich) {
let kind = w.dock_kind();
self.sync_viewport_from_editor();
let win_id = match self.bottom_dock.as_ref().map(|d| d.kind) {
Some(k) if k == kind => self.bottom_dock.as_ref().unwrap().win_id,
Some(_) => {
let win_id = self.bottom_dock.as_ref().unwrap().win_id;
if let Some(d) = self.bottom_dock.as_mut() {
d.kind = kind;
}
win_id
}
None => self.new_qf_dock_slot(kind),
};
self.set_focused_window(win_id);
self.reconcile_window_editors();
self.sync_viewport_to_editor();
self.qf_rebuild_dock_buffer(w);
}
fn new_qf_dock_slot(
&mut self,
kind: crate::app::dock::DockKind,
) -> crate::app::window::WindowId {
use hjkl_buffer::View;
use hjkl_engine::Settings;
use std::time::Instant;
let buffer_id = self.next_buffer_id;
self.next_buffer_id += 1;
let settings = Settings {
filetype: "qf".to_string(),
number: false,
relativenumber: false,
signcolumn: hjkl_engine::types::SignColumnMode::No,
cursorline: true,
foldcolumn: 0,
readonly: true,
modifiable: false,
..Settings::default()
};
let slot = super::BufferSlot {
buffer_id,
is_explorer: false,
features: super::BufferFeatures {
syntax: false,
lsp: false,
hover: false,
end_of_buffer: false,
},
view: View::new(),
settings,
filename: None,
dirty: false,
is_new_file: false,
is_untracked: false,
diag_signs: Vec::new(),
diag_signs_lsp: Vec::new(),
lsp_diags: Vec::new(),
last_lsp_dirty_gen: None,
git_signs: Vec::new(),
last_git_dirty_gen: None,
last_git_refresh_at: Instant::now(),
blame: Vec::new(),
last_blame_dirty_gen: None,
last_blame_refresh_at: Instant::now(),
saved_hash: 0,
saved_len: 0,
signature_cache: None,
disk_mtime: None,
disk_len: None,
disk_state: super::DiskState::Synced,
swap_path: None,
last_swap_dirty_gen: None,
last_fold_dirty_gen: None,
git_repo_present: None,
commit_ctx: None,
};
self.slots.push(slot);
let slot_idx = self.slots.len() - 1;
self.install_bottom_dock(slot_idx, kind)
}
fn qf_rebuild_dock_buffer(&mut self, w: QfWhich) {
let Some(dock) = self.bottom_dock.as_ref() else {
return;
};
if dock.kind != w.dock_kind() {
return;
}
let win_id = dock.win_id;
let Some(slot_idx) = self
.windows
.get(win_id)
.and_then(|win| win.as_ref())
.map(|win| win.slot)
else {
return;
};
let layouts = qf_row_layouts(self.qf_list(w));
let text = layouts
.iter()
.map(|r| r.line.as_str())
.collect::<Vec<_>>()
.join("\n");
let spans = self.qf_dock_spans(
self.qf_list(w).entries(),
&layouts,
self.qf_list(w).search_pattern(),
);
self.slots[slot_idx].set_content(&text);
let _ = self.slots[slot_idx].take_content_edits();
let _ = self.slots[slot_idx].take_content_reset();
if let Some(editor) = self.window_editors.get_mut(&win_id) {
editor.install_ratatui_syntax_spans(spans);
}
let cursor_row = self.qf_list(w).cursor();
if let Some(editor) = self.window_editors.get_mut(&win_id) {
editor.jump_cursor(cursor_row, 0);
}
}
fn qf_dock_spans(
&self,
entries: &[QfEntry],
layouts: &[QfRowLayout],
pattern: Option<&str>,
) -> Vec<Vec<(usize, usize, RStyle)>> {
let path_style = RStyle::default().fg(self.theme.ui.text);
let dim_style = RStyle::default().fg(self.theme.ui.non_text);
let search_re = pattern.and_then(compile_qf_search_regex);
let search_style = self.theme.ui.search_match_style();
entries
.iter()
.zip(layouts.iter())
.map(|(entry, layout)| {
let mut spans: Vec<(usize, usize, RStyle)> = Vec::with_capacity(3);
if layout.path_end > 0 {
spans.push((0, layout.path_end, path_style));
}
if layout.loc_end > layout.path_end {
spans.push((layout.path_end, layout.loc_end, dim_style));
}
if !entry.message.is_empty() {
let code_end = layout.code_col_start + entry.message.len();
spans.push((layout.code_col_start, code_end, dim_style));
if let Some(re) = &search_re {
let matches: Vec<(usize, usize)> =
hjkl_buffer::search_match_ranges(re, &entry.message)
.into_iter()
.map(|(s, e)| {
(layout.code_col_start + s, layout.code_col_start + e)
})
.collect();
spans = overlay_search_ranges(spans, &matches, search_style);
}
}
spans
})
.collect()
}
fn qf_refresh_dock_if_open(&mut self, w: QfWhich) {
if self.qf_dock_shows(w) {
self.qf_rebuild_dock_buffer(w);
}
}
fn qf_sync_dock_cursor(&mut self, w: QfWhich) {
let Some(dock) = self.bottom_dock.as_ref() else {
return;
};
if dock.kind != w.dock_kind() {
return;
}
let win_id = dock.win_id;
let row = self.qf_list(w).cursor();
if let Some(editor) = self.window_editors.get_mut(&win_id) {
editor.jump_cursor(row, 0);
}
}
pub(crate) fn qf_dock_jump_at_cursor(&mut self) {
let Some(dock) = self.bottom_dock.as_ref() else {
return;
};
let w = match dock.kind {
crate::app::dock::DockKind::Quickfix => QfWhich::Quickfix,
crate::app::dock::DockKind::Loclist => QfWhich::Location,
crate::app::dock::DockKind::Explorer => return,
};
let win_id = dock.win_id;
let row = self.window_cursor(win_id).0;
if row < self.qf_list(w).len() {
self.qf_list_mut(w).set_cursor(row);
}
self.focus_editor_window_for_open();
self.qf_jump_to_current(w);
}
fn qf_older_mut(&mut self, w: QfWhich) -> &mut Vec<QfList> {
match w {
QfWhich::Quickfix => &mut self.quickfix_older,
QfWhich::Location => &mut self.loclist_older,
}
}
fn qf_newer_mut(&mut self, w: QfWhich) -> &mut Vec<QfList> {
match w {
QfWhich::Quickfix => &mut self.quickfix_newer,
QfWhich::Location => &mut self.loclist_newer,
}
}
pub(crate) fn qf_push_history(&mut self, w: QfWhich) {
self.qf_newer_mut(w).clear();
if !self.qf_list(w).is_empty() {
let snapshot = self.qf_list(w).clone();
let older = self.qf_older_mut(w);
if older.len() >= 9 {
older.remove(0); }
older.push(snapshot);
}
}
pub(crate) fn handle_quickfix_command(&mut self, cmd: QfCommand) {
self.handle_qf_command(QfWhich::Quickfix, cmd);
}
pub(crate) fn handle_loclist_command(&mut self, cmd: QfCommand) {
self.handle_qf_command(QfWhich::Location, cmd);
}
fn handle_qf_command(&mut self, w: QfWhich, cmd: QfCommand) {
match cmd {
QfCommand::Grep(pat) => self.qf_run_grep(w, &pat),
QfCommand::Make(extra) => self.qf_run_make(w, &extra),
QfCommand::Expr { text, append, jump } => {
self.qf_run_expr(w, &text, append, jump);
}
QfCommand::FromBuffer { append, jump } => {
self.qf_run_from_buffer(w, append, jump);
}
QfCommand::FromFile { path, append, jump } => {
self.qf_run_from_file(w, &path, append, jump);
}
QfCommand::Open => {
if self.qf_list(w).is_empty() {
self.bus.info(format!("{} list is empty", w.label()));
} else {
self.qf_set_open(w, true);
}
}
QfCommand::Close => self.qf_set_open(w, false),
QfCommand::Window => {
let open = !self.qf_list(w).is_empty();
self.qf_set_open(w, open);
}
QfCommand::Next => {
self.qf_list_mut(w).next();
self.qf_after_nav(w);
}
QfCommand::Prev => {
self.qf_list_mut(w).prev();
self.qf_after_nav(w);
}
QfCommand::First => {
self.qf_list_mut(w).first();
self.qf_after_nav(w);
}
QfCommand::Last => {
self.qf_list_mut(w).last();
self.qf_after_nav(w);
}
QfCommand::Nth(n) => {
if n > 0 {
self.qf_list_mut(w).nth(n);
}
self.qf_after_nav(w);
}
QfCommand::Older(n) => self.qf_do_older(w, n),
QfCommand::Newer(n) => self.qf_do_newer(w, n),
QfCommand::Do { cmd, per_file } => self.qf_run_do(w, &cmd, per_file),
QfCommand::Diagnostics => self.qf_run_diagnostics(w),
}
}
fn qf_do_older(&mut self, w: QfWhich, count: usize) {
let steps = count.max(1);
for _ in 0..steps {
if self.qf_older_mut(w).is_empty() {
self.bus
.info(format!("{} list is already the oldest", w.label()));
break;
}
let current = self.qf_list(w).clone();
self.qf_newer_mut(w).push(current);
let prev = self.qf_older_mut(w).pop().unwrap();
*self.qf_list_mut(w) = prev;
}
let older_len = self.qf_older_mut(w).len();
let newer_len = self.qf_newer_mut(w).len();
let total = older_len + 1 + newer_len;
let current_idx = older_len + 1;
self.bus
.info(format!("{} list {} of {}", w.label(), current_idx, total));
self.qf_refresh_dock_if_open(w);
}
fn qf_do_newer(&mut self, w: QfWhich, count: usize) {
let steps = count.max(1);
for _ in 0..steps {
if self.qf_newer_mut(w).is_empty() {
self.bus
.info(format!("{} list is already the newest", w.label()));
break;
}
let current = self.qf_list(w).clone();
self.qf_older_mut(w).push(current);
let next = self.qf_newer_mut(w).pop().unwrap();
*self.qf_list_mut(w) = next;
}
let older_len = self.qf_older_mut(w).len();
let newer_len = self.qf_newer_mut(w).len();
let total = older_len + 1 + newer_len;
let current_idx = older_len + 1;
self.bus
.info(format!("{} list {} of {}", w.label(), current_idx, total));
self.qf_refresh_dock_if_open(w);
}
fn qf_run_do(&mut self, w: QfWhich, cmd: &str, per_file: bool) {
let entries: Vec<QfEntry> = self.qf_list(w).entries().to_vec();
if entries.is_empty() {
self.bus.info("no entries");
return;
}
let indices: Vec<usize> = if per_file {
let mut seen = std::collections::HashSet::new();
entries
.iter()
.enumerate()
.filter_map(|(i, e)| {
if seen.insert(e.path.clone()) {
Some(i)
} else {
None
}
})
.collect()
} else {
(0..entries.len()).collect()
};
self.focus_editor_window_for_open();
let cmd_owned = cmd.to_string();
for i in indices {
self.qf_list_mut(w).set_cursor(i);
self.qf_jump_to_current(w);
self.dispatch_ex(&cmd_owned);
}
}
fn qf_after_nav(&mut self, w: QfWhich) {
if self.qf_list(w).is_empty() {
self.bus.info(format!("{} list is empty", w.label()));
return;
}
self.focus_editor_window_for_open();
self.qf_jump_to_current(w);
self.qf_sync_dock_cursor(w);
}
fn qf_jump_to_current(&mut self, w: QfWhich) {
let Some(entry) = self.qf_list(w).current() else {
return;
};
let entry_path = entry.path.clone();
let (row, col) = (entry.row, entry.col);
let needs_edit = if entry_path.as_os_str().is_empty() {
false
} else {
let current = self.active().filename.as_deref();
match current {
Some(cur) => cur != entry_path,
None => true, }
};
if needs_edit {
let path_str = entry_path.to_string_lossy().to_string();
self.do_edit(&path_str, false);
}
self.active_editor_mut().jump_cursor(row, col);
self.sync_after_engine_mutation();
}
fn qf_run_grep(&mut self, w: QfWhich, pat: &str) {
use hjkl_picker::source::rg::{
GrepBackend, detect_grep_backend, parse_grep_line, parse_rg_json_line,
};
const MAX_ENTRIES: usize = 10_000;
let root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let root_str = root.to_str().unwrap_or(".");
let backend = detect_grep_backend();
let output = match backend {
GrepBackend::Rg => std::process::Command::new("rg")
.args(["--json", "--no-config", "--smart-case", "--", pat, root_str])
.output(),
GrepBackend::Grep => std::process::Command::new("grep")
.args(["-rnH", "--", pat, root_str])
.output(),
GrepBackend::Findstr => std::process::Command::new("findstr")
.args(["/s", "/n", "/r", &format!("/c:{pat}"), "*"])
.output(),
GrepBackend::Neither => {
self.bus.error("no search backend found (install ripgrep)");
return;
}
};
let out = match output {
Ok(o) => o,
Err(e) => {
self.bus.error(format!("grep failed: {e}"));
return;
}
};
let text = String::from_utf8_lossy(&out.stdout);
let mut entries: Vec<QfEntry> = Vec::new();
for line in text.lines() {
let m = match backend {
GrepBackend::Rg => parse_rg_json_line(line, &root),
_ => parse_grep_line(line, &root),
};
if let Some(m) = m {
entries.push(QfEntry {
path: m.path,
row: (m.line.saturating_sub(1)) as usize, col: (m._col.saturating_sub(1)) as usize,
kind: QfKind::Grep,
message: m.text.trim_end().to_string(),
});
if entries.len() >= MAX_ENTRIES {
break;
}
}
}
let n = entries.len();
self.qf_push_history(w);
self.qf_list_mut(w).set(entries);
self.qf_list_mut(w)
.set_search_pattern(Some(pat.to_string()));
if n == 0 {
self.qf_set_open(w, false);
self.bus.info(format!("no matches for \"{pat}\""));
} else {
self.qf_set_open(w, true);
self.bus.info(format!("{n} matches"));
}
}
fn qf_run_make(&mut self, w: QfWhich, extra: &str) {
const MAX_ENTRIES: usize = 10_000;
let makeprg = self.active_editor().settings().makeprg.clone();
let Some((program, rest)) = resolve_make_argv(&makeprg, extra) else {
self.bus.error("makeprg is empty");
return;
};
let root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let output = std::process::Command::new(&program)
.args(&rest)
.current_dir(&root)
.output();
let out = match output {
Ok(o) => o,
Err(e) => {
self.bus.error(format!("make failed: {e}"));
return;
}
};
let mut text = String::from_utf8_lossy(&out.stderr).into_owned();
text.push('\n');
text.push_str(&String::from_utf8_lossy(&out.stdout));
let mut entries = hjkl_quickfix::parse_make_output(&text, &root);
entries.truncate(MAX_ENTRIES);
let n = entries.len();
self.qf_push_history(w);
self.qf_list_mut(w).set(entries);
if n == 0 {
self.qf_set_open(w, false);
self.bus.info(format!("{makeprg}: no errors"));
} else {
self.qf_set_open(w, true);
self.bus.info(format!("{n} entries"));
}
}
fn qf_run_expr(&mut self, w: QfWhich, text: &str, append: bool, jump: bool) {
let parsed = parse_expr_text(text);
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let efm = self.active_editor().settings().errorformat.clone();
let entries = hjkl_quickfix::parse_errorformat(&parsed, &efm, &cwd);
if append {
let new = entries;
self.qf_list_mut(w).extend(new);
} else {
self.qf_push_history(w);
self.qf_list_mut(w).set(entries);
}
if jump && !self.qf_list(w).is_empty() {
if !append {
} else {
self.qf_list_mut(w).first();
}
self.qf_jump_to_current(w);
}
}
fn qf_run_from_buffer(&mut self, w: QfWhich, append: bool, jump: bool) {
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let efm = self.active_editor().settings().errorformat.clone();
let rope = self.active_editor().buffer().rope();
let n_lines = rope.len_lines();
let mut text = String::with_capacity(rope.len_bytes() + n_lines);
for i in 0..n_lines {
let line = rope_line_str(&rope, i);
if !text.is_empty() {
text.push('\n');
}
text.push_str(&line);
}
drop(rope);
let entries = hjkl_quickfix::parse_errorformat(&text, &efm, &cwd);
if append {
self.qf_list_mut(w).extend(entries);
} else {
self.qf_push_history(w);
self.qf_list_mut(w).set(entries);
}
if jump && !self.qf_list(w).is_empty() {
if append {
self.qf_list_mut(w).first();
}
self.qf_jump_to_current(w);
}
}
fn qf_run_from_file(&mut self, w: QfWhich, path: &str, append: bool, jump: bool) {
let resolved = if path.is_empty() {
"errors.err".to_string()
} else {
path.to_string()
};
let text = match std::fs::read_to_string(&resolved) {
Ok(s) => s,
Err(e) => {
self.bus.error(format!("cannot read \"{resolved}\": {e}"));
return;
}
};
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let efm = self.active_editor().settings().errorformat.clone();
let entries = hjkl_quickfix::parse_errorformat(&text, &efm, &cwd);
if append {
self.qf_list_mut(w).extend(entries);
} else {
self.qf_push_history(w);
self.qf_list_mut(w).set(entries);
}
if jump && !self.qf_list(w).is_empty() {
if append {
self.qf_list_mut(w).first();
}
self.qf_jump_to_current(w);
}
}
pub(crate) fn set_loclist(&mut self, entries: Vec<QfEntry>) {
self.qf_push_history(QfWhich::Location);
self.loclist.set(entries);
self.qf_set_open(QfWhich::Location, false);
}
fn qf_run_diagnostics(&mut self, w: QfWhich) {
let mut entries: Vec<QfEntry> = match w {
QfWhich::Quickfix => {
self.slots
.iter()
.filter(|s| !s.is_explorer)
.flat_map(|s| {
let path = s.filename.clone().unwrap_or_default();
s.lsp_diags.iter().map(move |d| QfEntry {
path: path.clone(),
row: d.start_row,
col: d.start_col,
kind: diag_severity_to_qf_kind(d.severity),
message: d.message.clone(),
})
})
.collect()
}
QfWhich::Location => {
let slot = self.active();
let path = slot.filename.clone().unwrap_or_default();
slot.lsp_diags
.iter()
.map(|d| QfEntry {
path: path.clone(),
row: d.start_row,
col: d.start_col,
kind: diag_severity_to_qf_kind(d.severity),
message: d.message.clone(),
})
.collect()
}
};
entries.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.row.cmp(&b.row))
.then(a.col.cmp(&b.col))
});
let n = entries.len();
self.qf_push_history(w);
self.qf_list_mut(w).set(entries);
if n > 0 {
self.qf_set_open(w, true);
self.bus.info(format!("{n} diagnostics"));
} else {
self.qf_set_open(w, false);
self.bus.info("no diagnostics");
}
}
}
struct QfRowLayout {
line: String,
path_end: usize,
loc_end: usize,
code_col_start: usize,
}
fn qf_row_layouts(list: &QfList) -> Vec<QfRowLayout> {
list.entries()
.iter()
.map(|e| {
let path = if e.path.as_os_str().is_empty() {
"[No Name]".to_string()
} else {
e.path.display().to_string()
};
let loc = format!("{path}:{}:{}", e.row + 1, e.col + 1);
let path_end = path.len();
let loc_end = loc.len();
let code_col_start = loc_end + 1; let line = format!("{loc} {}", e.message);
QfRowLayout {
line,
path_end,
loc_end,
code_col_start,
}
})
.collect()
}
fn compile_qf_search_regex(pat: &str) -> Option<regex::Regex> {
regex::RegexBuilder::new(pat)
.case_insensitive(!pat.chars().any(|c| c.is_uppercase()))
.build()
.ok()
}
fn overlay_search_ranges(
spans: Vec<(usize, usize, RStyle)>,
overlay: &[(usize, usize)],
style: RStyle,
) -> Vec<(usize, usize, RStyle)> {
if overlay.is_empty() {
return spans;
}
let mut out: Vec<(usize, usize, RStyle)> = Vec::with_capacity(spans.len() + overlay.len());
for (start, end, st) in spans {
let mut cur = start;
for &(os, oe) in overlay {
if oe <= cur {
continue;
}
if os >= end {
break;
}
if os > cur {
out.push((cur, os, st));
}
cur = cur.max(oe);
if cur >= end {
break;
}
}
if cur < end {
out.push((cur, end, st));
}
}
for &(os, oe) in overlay {
if oe > os {
out.push((os, oe, style));
}
}
out.sort_by_key(|&(s, _, _)| s);
out
}
#[cfg(test)]
fn qf_format_list(list: &QfList) -> String {
qf_row_layouts(list)
.into_iter()
.map(|r| r.line)
.collect::<Vec<_>>()
.join("\n")
}
fn diag_severity_to_qf_kind(sev: DiagSeverity) -> QfKind {
match sev {
DiagSeverity::Error => QfKind::Error,
DiagSeverity::Warning => QfKind::Warning,
DiagSeverity::Info => QfKind::Info,
DiagSeverity::Hint => QfKind::Note,
}
}
fn parse_expr_text(text: &str) -> String {
if text.starts_with('"') && text.ends_with('"') && text.len() >= 2 {
let inner = &text[1..text.len() - 1];
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('\\') => out.push('\\'),
Some('"') => out.push('"'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
} else {
out.push(ch);
}
}
out
} else {
text.to_string()
}
}
#[cfg(test)]
mod tests {
use super::resolve_make_argv;
use crate::app::App;
use hjkl_quickfix::{QfEntry, QfKind, QfList};
use std::path::PathBuf;
#[test]
fn make_argv_appends_extra_after_makeprg() {
let (prog, args) = resolve_make_argv("cargo check", "--offline -j4").unwrap();
assert_eq!(prog, "cargo");
assert_eq!(args, vec!["check", "--offline", "-j4"]);
}
#[test]
fn make_argv_program_always_from_makeprg_not_extra() {
let (prog, args) = resolve_make_argv("make", "/tmp/evil --do-bad").unwrap();
assert_eq!(prog, "make", "program must come from makeprg");
assert_eq!(args, vec!["/tmp/evil", "--do-bad"]);
}
#[test]
fn make_argv_empty_makeprg_is_none_even_with_extra() {
assert!(resolve_make_argv("", "/tmp/evil").is_none());
assert!(resolve_make_argv(" ", "evilprog arg").is_none());
}
fn entry(path: &str, row: usize, col: usize, message: &str) -> QfEntry {
QfEntry {
path: PathBuf::from(path),
row,
col,
kind: QfKind::Grep,
message: message.to_string(),
}
}
#[test]
fn qf_format_list_renders_path_line_col_space_message() {
let mut list = QfList::new();
list.set(vec![
entry("a.rs", 0, 0, "short"),
entry("a_much_longer_path/name.rs", 11, 33, "longer one"),
]);
let text = super::qf_format_list(&list);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], "a.rs:1:1 short");
assert_eq!(lines[1], "a_much_longer_path/name.rs:12:34 longer one");
assert!(
!text.contains('│'),
"no separator glyph in the simplified format"
);
}
#[test]
fn qf_dock_spans_styles_path_loc_and_dim_code() {
let app = App::new(None, false, None, None).unwrap();
let entries = vec![entry("src/main.rs", 4, 7, "unused variable")];
let layouts = super::qf_row_layouts(&{
let mut l = QfList::new();
l.set(entries.clone());
l
});
let spans = app.qf_dock_spans(&entries, &layouts, None);
assert_eq!(spans.len(), 1);
let row = &spans[0];
assert_eq!(row.len(), 3, "expected exactly 3 spans, got {row:?}");
let path_len = "src/main.rs".len();
let (p_start, p_end, p_style) = row[0];
assert_eq!((p_start, p_end), (0, path_len), "path span boundaries");
assert_eq!(p_style.fg, Some(app.theme.ui.text));
let (l_start, l_end, l_style) = row[1];
assert_eq!(
l_start, path_len,
"loc-suffix span must start EXACTLY where the path span ends"
);
assert!(l_end > l_start);
assert_eq!(l_style.fg, Some(app.theme.ui.non_text));
let loc = "src/main.rs:5:8";
let code_start = loc.len() + 1;
let (c_start, c_end, c_style) = row[2];
assert_eq!(c_start, code_start, "code span starts after the space");
assert_eq!(c_end, code_start + "unused variable".len());
assert_eq!(
c_style.fg,
Some(app.theme.ui.non_text),
"the whole message is dim — only a search match gets a color"
);
}
#[test]
fn qf_dock_spans_overlays_search_matches_in_code_column() {
let app = App::new(None, false, None, None).unwrap();
let entries = vec![entry(
"src/x.qf-test-unknown-ext",
0,
0,
"alpha match beta match",
)];
let mut list = QfList::new();
list.set(entries.clone());
list.set_search_pattern(Some("match".to_string()));
let layouts = super::qf_row_layouts(&list);
let code_start = "src/x.qf-test-unknown-ext:1:1".len() + 1;
let spans = app.qf_dock_spans(&entries, &layouts, list.search_pattern());
let row = &spans[0];
let search_style = app.theme.ui.search_match_style();
let match_spans: Vec<_> = row
.iter()
.filter(|(_, _, st)| *st == search_style)
.collect();
assert_eq!(
match_spans.len(),
2,
"one search span per occurrence, got {row:?}"
);
assert_eq!(
(match_spans[0].0, match_spans[0].1),
(code_start + 6, code_start + 11),
"first occurrence must sit exactly over 'match' in the code column"
);
assert_eq!(
(match_spans[1].0, match_spans[1].1),
(code_start + 17, code_start + 22)
);
for &(s, e, st) in row {
if st == search_style {
continue;
}
for &&(ms, me, _) in &match_spans {
assert!(
e <= ms || s >= me,
"non-search span ({s},{e}) overlaps match ({ms},{me})"
);
}
}
}
#[test]
fn qf_dock_spans_no_overlay_without_a_pattern() {
let app = App::new(None, false, None, None).unwrap();
let entries = vec![entry("src/x.qf-test-unknown-ext", 0, 0, "alpha match beta")];
let mut list = QfList::new();
list.set(entries.clone()); let layouts = super::qf_row_layouts(&list);
let spans = app.qf_dock_spans(&entries, &layouts, list.search_pattern());
let search_style = app.theme.ui.search_match_style();
assert!(
spans[0].iter().all(|(_, _, st)| *st != search_style),
"no search-style spans without a stored pattern, got {:?}",
spans[0]
);
}
#[test]
fn overlay_search_ranges_wins_over_underlying_spans() {
use super::RStyle;
use ratatui::style::Color;
let syntax = RStyle::default().fg(Color::Rgb(1, 2, 3));
let search = RStyle::default().fg(Color::Rgb(9, 9, 9)).bg(Color::Black);
let merged = super::overlay_search_ranges(vec![(0, 10, syntax)], &[(3, 6)], search);
assert_eq!(
merged,
vec![(0, 3, syntax), (3, 6, search), (6, 10, syntax)],
"base span must split around the match; match carries ONLY the overlay style"
);
let merged =
super::overlay_search_ranges(vec![(0, 5, syntax), (5, 10, syntax)], &[(4, 7)], search);
assert_eq!(
merged,
vec![(0, 4, syntax), (4, 7, search), (7, 10, syntax)]
);
let merged = super::overlay_search_ranges(vec![(0, 5, syntax)], &[], search);
assert_eq!(merged, vec![(0, 5, syntax)]);
}
#[test]
fn compile_qf_search_regex_smart_case_and_invalid() {
let re = super::compile_qf_search_regex("match").expect("valid pattern");
assert!(
re.is_match("has MATCH here"),
"lowercase pattern → insensitive"
);
let re = super::compile_qf_search_regex("Match").expect("valid pattern");
assert!(
!re.is_match("has match here"),
"uppercase pattern → sensitive"
);
assert!(re.is_match("has Match here"));
assert!(
super::compile_qf_search_regex("(unclosed").is_none(),
"invalid regex must disable the overlay, not panic"
);
}
}