moeix 0.12.5

Sub-millisecond code search via sparse trigram indexing.
use std::path::PathBuf;

use ix::executor::{Match, QueryOptions, QueryStats};

pub(crate) fn format_uptime(secs: u64) -> String {
    let days = secs / 86_400;
    let hours = (secs % 86_400) / 3_600;
    let minutes = (secs % 3_600) / 60;
    let seconds = secs % 60;
    if days > 0 {
        format!("{days}d {hours}h {minutes}m {seconds}s")
    } else if hours > 0 {
        format!("{hours}h {minutes}m {seconds}s")
    } else if minutes > 0 {
        format!("{minutes}m {seconds}s")
    } else {
        format!("{seconds}s")
    }
}

#[allow(clippy::cast_precision_loss)]
pub(crate) fn format_bytes(bytes: u64) -> String {
    if bytes >= 1_073_741_824 {
        format!("{:.2} GB", bytes as f64 / 1_073_741_824.0)
    } else if bytes >= 1_048_576 {
        format!("{:.2} MB", bytes as f64 / 1_048_576.0)
    } else if bytes >= 1024 {
        format!("{:.2} KB", bytes as f64 / 1024.0)
    } else {
        format!("{bytes} B")
    }
}

pub(crate) fn truncate_safe(s: &mut String, max_bytes: usize) {
    if max_bytes >= s.len() {
        return;
    }
    let mut end = max_bytes;
    while end > 0 && !s.is_char_boundary(end) {
        end -= 1;
    }
    s.truncate(end);
}

pub(crate) fn looks_like_regex(pattern: &str) -> bool {
    use regex_syntax::hir::HirKind;
    let mut parser = regex_syntax::ParserBuilder::new().utf8(false).build();
    let Ok(hir) = parser.parse(pattern) else {
        return false;
    };
    !matches!(hir.kind(), HirKind::Literal(_))
}

pub(crate) fn print_results(
    matches: &[Match],
    stats: &QueryStats,
    options: &QueryOptions,
    json: bool,
    start_time: std::time::Instant,
    show_stats: bool,
) {
    if options.count_only {
        if json {
            println!("{{\"count\": {}}}", stats.total_matches);
        } else {
            println!("{}", stats.total_matches);
        }
    } else if options.files_only {
        let mut unique_files: std::collections::HashSet<PathBuf> =
            matches.iter().map(|m| m.file_path.clone()).collect();
        let mut sorted_files: Vec<_> = unique_files.drain().collect();
        sorted_files.sort();

        if json {
            let paths: Vec<String> = sorted_files
                .iter()
                .map(|p| p.display().to_string())
                .collect();
            println!("{{\"files\": {paths:?}}}");
        } else {
            for f in sorted_files {
                println!("{}", f.display());
            }
        }
    } else {
        let mut last_file = PathBuf::new();
        let mut printed_lines = std::collections::HashSet::new();

        for m in matches {
            if m.file_path != last_file {
                if options.context_lines > 0 && !json && !last_file.as_os_str().is_empty() {
                    println!("--");
                }
                printed_lines.clear();
                last_file.clone_from(&m.file_path);
            } else if options.context_lines > 0 && !json {
                let match_start = (m.line_number as usize).saturating_sub(options.context_lines);
                let prev_end = printed_lines.iter().max().copied().unwrap_or(0) as usize;
                if match_start > prev_end + 1 && prev_end > 0 {
                    println!("--");
                }
            }

            print_match(m, json, options.context_lines, &mut printed_lines);
        }

        if options.max_results > 0 && stats.total_matches >= options.max_results as u32 {
            eprintln!(
                "ix: output capped at {} results (use -n 0 for all)",
                options.max_results
            );
        }
    }

    if show_stats {
        print_stats(stats, start_time.elapsed());
    }
}

fn print_match(
    m: &Match,
    json: bool,
    context: usize,
    printed_lines: &mut std::collections::HashSet<u32>,
) {
    if !json && m.is_binary {
        println!("Binary file {} matches", m.file_path.display());
        return;
    }

    let truncate = |s: &str| -> String {
        let mut string = s.to_string();
        if string.len() > 200 {
            truncate_safe(&mut string, 200);
            string.push_str("...");
        }
        string
    };

    if json {
        let line_content = truncate(&m.line_content);
        let context_before: Vec<String> = m.context_before.iter().map(|s| truncate(s)).collect();
        let context_after: Vec<String> = m.context_after.iter().map(|s| truncate(s)).collect();

        println!(
            "{{\"file\":\"{}\",\"line\":{},\"col\":{},\"content\":\"{}\",\"byte_offset\":{},\"context_before\":{:?},\"context_after\":{:?},\"is_binary\":{}}}",
            m.file_path.display(),
            m.line_number,
            m.col,
            line_content
                .replace('\\', "\\\\")
                .replace('"', "\\\"")
                .replace('\n', "\\n"),
            m.byte_offset,
            context_before,
            context_after,
            m.is_binary
        );
    } else {
        if context > 0 {
            for (i, line) in m.context_before.iter().enumerate() {
                let line_num = (m.line_number as usize - m.context_before.len() + i) as u32;
                if printed_lines.insert(line_num) {
                    println!(
                        "{}:{}:- :{}",
                        m.file_path.display(),
                        line_num,
                        truncate(line)
                    );
                }
            }
        }

        if printed_lines.insert(m.line_number) {
            println!(
                "{}:{}: {}",
                m.file_path.display(),
                m.line_number,
                truncate(&m.line_content)
            );
        }

        if context > 0 {
            for (i, line) in m.context_after.iter().enumerate() {
                let line_num = (m.line_number as usize + 1 + i) as u32;
                if printed_lines.insert(line_num) {
                    println!(
                        "{}:{}:- :{}",
                        m.file_path.display(),
                        line_num,
                        truncate(line)
                    );
                }
            }
        }
    }
}

fn print_stats(stats: &QueryStats, elapsed: std::time::Duration) {
    eprintln!("--- ix stats ---");
    eprintln!("trigrams_queried: {}", stats.trigrams_queried);
    eprintln!("posting_lists_decoded: {}", stats.posting_lists_decoded);
    eprintln!("candidate_files: {}", stats.candidate_files);
    eprintln!("files_verified: {}", stats.files_verified);
    if stats.files_failed_verify > 0 {
        eprintln!(
            "[WARNING] {} file(s) could not be verified (I/O error) — results may be incomplete",
            stats.files_failed_verify
        );
    }
    eprintln!("bytes_verified: {}", stats.bytes_verified);
    if stats.lines_read > 0 {
        eprintln!("lines_read: {}", stats.lines_read);
    }
    eprintln!("total_matches: {}", stats.total_matches);
    if stats.posting_cache_hits > 0 || stats.posting_cache_misses > 0 {
        eprintln!(
            "posting_cache: {} hits / {} misses",
            stats.posting_cache_hits, stats.posting_cache_misses
        );
    }
    if stats.neg_cache_hits > 0 || stats.neg_cache_misses > 0 {
        eprintln!(
            "neg_cache: {} hits / {} misses",
            stats.neg_cache_hits, stats.neg_cache_misses
        );
    }
    eprintln!("search_time_ms: {}", elapsed.as_millis());
}