singularity-forge/rust-engine/crates/engine/src/grep.rs

216 lines
6.8 KiB
Rust

//! N-API bindings for the grep module.
//!
//! Wraps `forge_grep` functions and exposes them as JS-callable N-API exports.
use napi::bindgen_prelude::*;
use napi_derive::napi;
use crate::task;
// ── N-API types (mirroring forge_grep types for the JS boundary) ────────
#[napi(object)]
pub struct NapiContextLine {
#[napi(js_name = "lineNumber")]
pub line_number: u32,
pub line: String,
}
#[napi(object)]
pub struct NapiSearchMatch {
#[napi(js_name = "lineNumber")]
pub line_number: u32,
pub line: String,
#[napi(js_name = "contextBefore")]
pub context_before: Vec<NapiContextLine>,
#[napi(js_name = "contextAfter")]
pub context_after: Vec<NapiContextLine>,
pub truncated: bool,
}
#[napi(object)]
pub struct NapiSearchResult {
pub matches: Vec<NapiSearchMatch>,
#[napi(js_name = "matchCount")]
pub match_count: u32,
#[napi(js_name = "limitReached")]
pub limit_reached: bool,
}
#[napi(object)]
pub struct NapiSearchOptions {
pub pattern: String,
#[napi(js_name = "ignoreCase")]
pub ignore_case: Option<bool>,
pub multiline: Option<bool>,
#[napi(js_name = "maxCount")]
pub max_count: Option<u32>,
#[napi(js_name = "contextBefore")]
pub context_before: Option<u32>,
#[napi(js_name = "contextAfter")]
pub context_after: Option<u32>,
#[napi(js_name = "maxColumns")]
pub max_columns: Option<u32>,
}
#[napi(object)]
pub struct NapiGrepMatch {
pub path: String,
#[napi(js_name = "lineNumber")]
pub line_number: u32,
pub line: String,
#[napi(js_name = "contextBefore")]
pub context_before: Vec<NapiContextLine>,
#[napi(js_name = "contextAfter")]
pub context_after: Vec<NapiContextLine>,
pub truncated: bool,
}
#[napi(object)]
pub struct NapiGrepResult {
pub matches: Vec<NapiGrepMatch>,
#[napi(js_name = "totalMatches")]
pub total_matches: u32,
#[napi(js_name = "filesWithMatches")]
pub files_with_matches: u32,
#[napi(js_name = "filesSearched")]
pub files_searched: u32,
#[napi(js_name = "limitReached")]
pub limit_reached: bool,
}
#[napi(object)]
pub struct NapiGrepOptions {
pub pattern: String,
pub path: String,
pub glob: Option<String>,
#[napi(js_name = "ignoreCase")]
pub ignore_case: Option<bool>,
pub multiline: Option<bool>,
pub hidden: Option<bool>,
pub gitignore: Option<bool>,
#[napi(js_name = "maxCount")]
pub max_count: Option<u32>,
#[napi(js_name = "contextBefore")]
pub context_before: Option<u32>,
#[napi(js_name = "contextAfter")]
pub context_after: Option<u32>,
#[napi(js_name = "maxColumns")]
pub max_columns: Option<u32>,
}
// ── Conversion helpers ────────────────────────────────────────────────
fn clamp_u32(value: u64) -> u32 {
value.min(u32::MAX as u64) as u32
}
fn convert_context_line(cl: forge_grep::ContextLine) -> NapiContextLine {
NapiContextLine {
line_number: clamp_u32(cl.line_number),
line: cl.line,
}
}
fn convert_search_match(m: forge_grep::SearchMatch) -> NapiSearchMatch {
NapiSearchMatch {
line_number: clamp_u32(m.line_number),
line: m.line,
context_before: m
.context_before
.into_iter()
.map(convert_context_line)
.collect(),
context_after: m
.context_after
.into_iter()
.map(convert_context_line)
.collect(),
truncated: m.truncated,
}
}
fn convert_file_match(m: forge_grep::FileMatch) -> NapiGrepMatch {
NapiGrepMatch {
path: m.path,
line_number: clamp_u32(m.line_number),
line: m.line,
context_before: m
.context_before
.into_iter()
.map(convert_context_line)
.collect(),
context_after: m
.context_after
.into_iter()
.map(convert_context_line)
.collect(),
truncated: m.truncated,
}
}
// ── Exported N-API functions ──────────────────────────────────────────
/// Search in-memory content for a regex pattern.
///
/// Accepts a Buffer/Uint8Array or a string. Returns matches with line numbers
/// and optional context lines.
#[napi(js_name = "search")]
pub fn search(content: Buffer, options: NapiSearchOptions) -> Result<NapiSearchResult> {
let opts = forge_grep::SearchOptions {
pattern: options.pattern,
ignore_case: options.ignore_case.unwrap_or(false),
multiline: options.multiline.unwrap_or(false),
max_count: options.max_count.map(u64::from),
context_before: options.context_before.unwrap_or(0),
context_after: options.context_after.unwrap_or(0),
max_columns: options.max_columns.map(|v| v as usize),
};
match forge_grep::search_content(content.as_ref(), &opts) {
Ok(result) => Ok(NapiSearchResult {
matches: result
.matches
.into_iter()
.map(convert_search_match)
.collect(),
match_count: clamp_u32(result.match_count),
limit_reached: result.limit_reached,
}),
Err(err) => Err(Error::from_reason(err)),
}
}
/// Search files on disk for a regex pattern.
///
/// Walks the directory tree respecting `.gitignore` and optional glob filters.
/// Returns matches with file paths, line numbers, and optional context.
#[napi(js_name = "grep")]
pub fn grep(options: NapiGrepOptions) -> task::Async<NapiGrepResult> {
task::blocking("grep", (), move |_ct| {
let opts = forge_grep::GrepOptions {
pattern: options.pattern,
path: options.path,
glob: options.glob,
ignore_case: options.ignore_case.unwrap_or(false),
multiline: options.multiline.unwrap_or(false),
hidden: options.hidden.unwrap_or(false),
gitignore: options.gitignore.unwrap_or(true),
max_count: options.max_count.map(u64::from),
context_before: options.context_before.unwrap_or(0),
context_after: options.context_after.unwrap_or(0),
max_columns: options.max_columns.map(|v| v as usize),
};
match forge_grep::search_path(&opts) {
Ok(result) => Ok(NapiGrepResult {
matches: result.matches.into_iter().map(convert_file_match).collect(),
total_matches: clamp_u32(result.total_matches),
files_with_matches: result.files_with_matches,
files_searched: result.files_searched,
limit_reached: result.limit_reached,
}),
Err(err) => Err(Error::from_reason(err)),
}
})
}