diff --git a/native/.cargo/config.toml b/native/.cargo/config.toml new file mode 100644 index 000000000..347c5add7 --- /dev/null +++ b/native/.cargo/config.toml @@ -0,0 +1,4 @@ +# Optimize for the current machine's CPU features in development. +# CI/release builds should override via RUSTFLAGS for portability. +[target.'cfg(not(target_env = "msvc"))'] +rustflags = ["-C", "target-cpu=native"] diff --git a/native/.gitignore b/native/.gitignore new file mode 100644 index 000000000..97d6c9d7d --- /dev/null +++ b/native/.gitignore @@ -0,0 +1,2 @@ +target/ +addon/ diff --git a/native/Cargo.lock b/native/Cargo.lock new file mode 100644 index 000000000..ba8fa03da --- /dev/null +++ b/native/Cargo.lock @@ -0,0 +1,458 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "grep-matcher" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d7b71093325ab22d780b40d7df3066ae4aebb518ba719d38c697a8228a8023" +dependencies = [ + "memchr", +] + +[[package]] +name = "grep-regex" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef" +dependencies = [ + "bstr", + "grep-matcher", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "grep-searcher" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac63295322dc48ebb20a25348147905d816318888e64f531bfc2a2bc0577dc34" +dependencies = [ + "bstr", + "encoding_rs", + "encoding_rs_io", + "grep-matcher", + "log", + "memchr", + "memmap2", +] + +[[package]] +name = "gsd-engine" +version = "0.1.0" +dependencies = [ + "gsd-grep", + "napi", + "napi-build", + "napi-derive", +] + +[[package]] +name = "gsd-grep" +version = "0.1.0" +dependencies = [ + "grep-matcher", + "grep-regex", + "grep-searcher", + "ignore", + "rayon", +] + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[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 = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "napi" +version = "2.16.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +dependencies = [ + "bitflags", + "ctor", + "napi-derive", + "napi-sys", + "once_cell", +] + +[[package]] +name = "napi-build" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1" + +[[package]] +name = "napi-derive" +version = "2.16.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +dependencies = [ + "cfg-if", + "convert_case", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "napi-derive-backend" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +dependencies = [ + "convert_case", + "once_cell", + "proc-macro2", + "quote", + "regex", + "semver", + "syn", +] + +[[package]] +name = "napi-sys" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +dependencies = [ + "libloading", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/native/Cargo.toml b/native/Cargo.toml new file mode 100644 index 000000000..1a96d837f --- /dev/null +++ b/native/Cargo.toml @@ -0,0 +1,21 @@ +[workspace] +members = ["crates/*"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = ["GSD Contributors"] +repository = "https://github.com/gsd-build/gsd-2" + +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +strip = true +panic = "abort" + +[profile.dev] +codegen-units = 256 +incremental = true diff --git a/native/README.md b/native/README.md new file mode 100644 index 000000000..bf818e9d5 --- /dev/null +++ b/native/README.md @@ -0,0 +1,80 @@ +# GSD Native Engine + +Rust N-API addon providing high-performance native modules for GSD. + +## Architecture + +``` +JS (packages/native) -> N-API -> Rust crates + ├── engine/ (N-API bindings, cdylib) + └── grep/ (ripgrep internals, pure Rust lib) +``` + +Inspired by [Oh My Pi's pi-natives](https://github.com/can1357/oh-my-pi), adapted for GSD's Node.js runtime. + +## Prerequisites + +- **Rust** (stable, 1.70+): https://rustup.rs +- **Node.js** (20.6+) + +## Build + +```bash +# Release build (optimized) +npm run build:native + +# Debug build (fast compile, no optimizations) +npm run build:native:dev +``` + +The build script compiles the Rust code and copies the `.node` shared library to `native/addon/`. + +## Test + +```bash +# Rust unit tests +cd native && cargo test + +# Node.js integration tests +npm run test:native +``` + +## Modules + +### grep + +Ripgrep-backed regex search using the `grep-regex`, `grep-searcher`, and `grep-matcher` crates. + +**Functions:** + +- `search(content, options)` — Search in-memory Buffer/Uint8Array content +- `grep(options)` — Search files on disk with glob filtering and .gitignore support + +**TypeScript usage:** + +```typescript +import { grep, searchContent } from "@gsd/native"; + +// Search files +const result = grep({ + pattern: "TODO", + path: "./src", + glob: "*.ts", + ignoreCase: true, + maxCount: 100, +}); + +// Search content +const contentResult = searchContent(Buffer.from(fileContent), { + pattern: "function\\s+\\w+", + contextBefore: 2, + contextAfter: 2, +}); +``` + +## Adding New Modules + +1. Create a new crate in `native/crates/` (pure Rust library) +2. Add N-API bindings in `native/crates/engine/src/` +3. Add TypeScript wrapper in `packages/native/src/` +4. Add the crate to `engine/Cargo.toml` dependencies diff --git a/native/crates/engine/Cargo.toml b/native/crates/engine/Cargo.toml new file mode 100644 index 000000000..dcd61ef0c --- /dev/null +++ b/native/crates/engine/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "gsd-engine" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "N-API native addon for GSD — exposes high-performance Rust modules to Node.js" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +gsd-grep = { path = "../grep" } +napi = { version = "2", features = ["napi8"] } +napi-derive = "2" + +[build-dependencies] +napi-build = "2" diff --git a/native/crates/engine/build.rs b/native/crates/engine/build.rs new file mode 100644 index 000000000..0f1b01002 --- /dev/null +++ b/native/crates/engine/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/native/crates/engine/src/grep.rs b/native/crates/engine/src/grep.rs new file mode 100644 index 000000000..ad696abd6 --- /dev/null +++ b/native/crates/engine/src/grep.rs @@ -0,0 +1,192 @@ +//! N-API bindings for the grep module. +//! +//! Wraps `gsd_grep` functions and exposes them as JS-callable N-API exports. + +use napi::bindgen_prelude::*; +use napi_derive::napi; + +// ── N-API types (mirroring gsd_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, + #[napi(js_name = "contextAfter")] + pub context_after: Vec, + pub truncated: bool, +} + +#[napi(object)] +pub struct NapiSearchResult { + pub matches: Vec, + #[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, + pub multiline: Option, + #[napi(js_name = "maxCount")] + pub max_count: Option, + #[napi(js_name = "contextBefore")] + pub context_before: Option, + #[napi(js_name = "contextAfter")] + pub context_after: Option, + #[napi(js_name = "maxColumns")] + pub max_columns: Option, +} + +#[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, + #[napi(js_name = "contextAfter")] + pub context_after: Vec, + pub truncated: bool, +} + +#[napi(object)] +pub struct NapiGrepResult { + pub matches: Vec, + #[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, + #[napi(js_name = "ignoreCase")] + pub ignore_case: Option, + pub multiline: Option, + pub hidden: Option, + pub gitignore: Option, + #[napi(js_name = "maxCount")] + pub max_count: Option, + #[napi(js_name = "contextBefore")] + pub context_before: Option, + #[napi(js_name = "contextAfter")] + pub context_after: Option, + #[napi(js_name = "maxColumns")] + pub max_columns: Option, +} + +// ── Conversion helpers ──────────────────────────────────────────────── + +fn clamp_u32(value: u64) -> u32 { + value.min(u32::MAX as u64) as u32 +} + +fn convert_context_line(cl: gsd_grep::ContextLine) -> NapiContextLine { + NapiContextLine { + line_number: clamp_u32(cl.line_number), + line: cl.line, + } +} + +fn convert_search_match(m: gsd_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: gsd_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 { + let opts = gsd_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 gsd_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) -> Result { + let opts = gsd_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 gsd_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)), + } +} diff --git a/native/crates/engine/src/lib.rs b/native/crates/engine/src/lib.rs new file mode 100644 index 000000000..82985849b --- /dev/null +++ b/native/crates/engine/src/lib.rs @@ -0,0 +1,11 @@ +//! N-API addon for GSD. +//! +//! Exposes high-performance Rust modules to Node.js via napi-rs. +//! Architecture mirrors Oh My Pi's pi-natives crate: +//! ```text +//! JS (packages/native) -> N-API -> Rust modules (grep, ...) +//! ``` + +#![allow(clippy::needless_pass_by_value)] + +mod grep; diff --git a/native/crates/grep/Cargo.toml b/native/crates/grep/Cargo.toml new file mode 100644 index 000000000..d77fb6faf --- /dev/null +++ b/native/crates/grep/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "gsd-grep" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "Ripgrep-backed search library for GSD native engine" + +[dependencies] +grep-regex = "0.1" +grep-searcher = "0.1" +grep-matcher = "0.1" +ignore = "0.4" +rayon = "1.10" diff --git a/native/crates/grep/src/lib.rs b/native/crates/grep/src/lib.rs new file mode 100644 index 000000000..5ee377277 --- /dev/null +++ b/native/crates/grep/src/lib.rs @@ -0,0 +1,538 @@ +//! Ripgrep-backed search library for GSD. +//! +//! Provides two search modes: +//! - `search_content()`: search in-memory content (a byte slice). +//! - `search_path()`: search files on disk with glob/gitignore filtering. +//! +//! Built on the `grep-*` family of crates (the same internals as ripgrep). + +use std::{ + fs::File, + io::{self, Cursor, Read}, + path::PathBuf, +}; + +use grep_regex::RegexMatcherBuilder; +use grep_searcher::{ + BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkContextKind, SinkMatch, +}; +use ignore::WalkBuilder; +use rayon::prelude::*; + +/// Maximum file size to search (4 MiB). Files larger than this are skipped. +const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024; + +// ── Public types ────────────────────────────────────────────────────── + +/// A single match result. +#[derive(Debug, Clone)] +pub struct SearchMatch { + /// 1-indexed line number. + pub line_number: u64, + /// The matched line content (trailing newline stripped). + pub line: String, + /// Context lines before the match. + pub context_before: Vec, + /// Context lines after the match. + pub context_after: Vec, + /// Whether the line was truncated due to `max_columns`. + pub truncated: bool, +} + +/// A context line adjacent to a match. +#[derive(Debug, Clone)] +pub struct ContextLine { + pub line_number: u64, + pub line: String, +} + +/// Result of an in-memory content search. +#[derive(Debug, Clone)] +pub struct ContentSearchResult { + pub matches: Vec, + pub match_count: u64, + pub limit_reached: bool, +} + +/// A match from a filesystem search, including the file path. +#[derive(Debug, Clone)] +pub struct FileMatch { + /// Relative path from the search root. + pub path: String, + pub line_number: u64, + pub line: String, + pub context_before: Vec, + pub context_after: Vec, + pub truncated: bool, +} + +/// Result of a filesystem search. +#[derive(Debug, Clone)] +pub struct FileSearchResult { + pub matches: Vec, + pub total_matches: u64, + pub files_with_matches: u32, + pub files_searched: u32, + pub limit_reached: bool, +} + +/// Options controlling search behavior. +#[derive(Debug, Clone, Default)] +pub struct SearchOptions { + /// Regex pattern. + pub pattern: String, + /// Case-insensitive matching. + pub ignore_case: bool, + /// Enable multiline regex mode. + pub multiline: bool, + /// Maximum number of matches to collect. + pub max_count: Option, + /// Lines of context before each match. + pub context_before: u32, + /// Lines of context after each match. + pub context_after: u32, + /// Truncate lines longer than this many characters. + pub max_columns: Option, +} + +/// Options for filesystem search (extends `SearchOptions`). +#[derive(Debug, Clone, Default)] +pub struct GrepOptions { + /// Regex pattern. + pub pattern: String, + /// Root directory or file to search. + pub path: String, + /// Glob filter for filenames (e.g. `"*.ts"`). + pub glob: Option, + /// Case-insensitive matching. + pub ignore_case: bool, + /// Enable multiline regex mode. + pub multiline: bool, + /// Include hidden files (default: false). + pub hidden: bool, + /// Respect `.gitignore` files (default: true). + pub gitignore: bool, + /// Maximum number of matches to collect. + pub max_count: Option, + /// Lines of context before each match. + pub context_before: u32, + /// Lines of context after each match. + pub context_after: u32, + /// Truncate lines longer than this many characters. + pub max_columns: Option, +} + +// ── Internal collector ──────────────────────────────────────────────── + +struct MatchCollector { + matches: Vec, + match_count: u64, + collected_count: u64, + max_count: Option, + limit_reached: bool, + pending_context_before: Vec, + max_columns: Option, +} + +impl MatchCollector { + fn new(max_count: Option, max_columns: Option) -> Self { + Self { + matches: Vec::new(), + match_count: 0, + collected_count: 0, + max_count, + limit_reached: false, + pending_context_before: Vec::new(), + max_columns, + } + } + + fn truncate_line(&self, line: &str) -> (String, bool) { + match self.max_columns { + Some(max) if line.len() > max => { + let cut = max.saturating_sub(3); + // Find a valid char boundary + let mut boundary = cut; + while boundary > 0 && !line.is_char_boundary(boundary) { + boundary -= 1; + } + let truncated = format!("{}...", &line[..boundary]); + (truncated, true) + } + _ => (line.to_string(), false), + } + } +} + +fn bytes_to_trimmed_string(bytes: &[u8]) -> String { + match std::str::from_utf8(bytes) { + Ok(text) => text.trim_end().to_string(), + Err(_) => String::from_utf8_lossy(bytes).trim_end().to_string(), + } +} + +impl Sink for MatchCollector { + type Error = io::Error; + + fn matched( + &mut self, + _searcher: &Searcher, + mat: &SinkMatch<'_>, + ) -> Result { + self.match_count += 1; + + if self.limit_reached { + return Ok(false); + } + + let raw_line = bytes_to_trimmed_string(mat.bytes()); + let (line, truncated) = self.truncate_line(&raw_line); + let line_number = mat.line_number().unwrap_or(0); + + self.matches.push(SearchMatch { + line_number, + line, + context_before: std::mem::take(&mut self.pending_context_before), + context_after: Vec::new(), + truncated, + }); + + self.collected_count += 1; + + if let Some(max) = self.max_count { + if self.collected_count >= max { + self.limit_reached = true; + } + } + + Ok(true) + } + + fn context( + &mut self, + _searcher: &Searcher, + ctx: &SinkContext<'_>, + ) -> Result { + let raw_line = bytes_to_trimmed_string(ctx.bytes()); + let (line, _) = self.truncate_line(&raw_line); + let line_number = ctx.line_number().unwrap_or(0); + + match ctx.kind() { + SinkContextKind::Before => { + self.pending_context_before.push(ContextLine { line_number, line }); + } + SinkContextKind::After => { + if let Some(last_match) = self.matches.last_mut() { + last_match.context_after.push(ContextLine { line_number, line }); + } + } + SinkContextKind::Other => {} + } + + Ok(true) + } +} + +// ── Core search functions ───────────────────────────────────────────── + +fn build_matcher( + pattern: &str, + ignore_case: bool, + multiline: bool, +) -> Result { + RegexMatcherBuilder::new() + .case_insensitive(ignore_case) + .multi_line(multiline) + .build(pattern) + .map_err(|err| format!("Regex error: {err}")) +} + +fn build_searcher(before_context: u32, after_context: u32) -> Searcher { + SearcherBuilder::new() + .binary_detection(BinaryDetection::quit(b'\x00')) + .line_number(true) + .before_context(before_context as usize) + .after_context(after_context as usize) + .build() +} + +fn search_reader( + matcher: &grep_regex::RegexMatcher, + reader: R, + max_count: Option, + before_context: u32, + after_context: u32, + max_columns: Option, +) -> io::Result<(Vec, u64, bool)> { + let mut searcher = build_searcher(before_context, after_context); + let mut collector = MatchCollector::new(max_count, max_columns); + searcher.search_reader(matcher, reader, &mut collector)?; + Ok((collector.matches, collector.match_count, collector.limit_reached)) +} + +/// Search in-memory content for a regex pattern. +pub fn search_content(content: &[u8], options: &SearchOptions) -> Result { + let matcher = build_matcher(&options.pattern, options.ignore_case, options.multiline)?; + let (matches, match_count, limit_reached) = search_reader( + &matcher, + Cursor::new(content), + options.max_count, + options.context_before, + options.context_after, + options.max_columns, + ) + .map_err(|e| e.to_string())?; + + Ok(ContentSearchResult { + matches, + match_count, + limit_reached, + }) +} + +/// Search files on disk for a regex pattern. +/// +/// Walks the directory tree respecting `.gitignore` rules and optional glob filters. +/// Uses rayon for parallel file searching. +pub fn search_path(options: &GrepOptions) -> Result { + let search_root = PathBuf::from(&options.path); + if !search_root.exists() { + return Err(format!("Path not found: {}", options.path)); + } + + let matcher = build_matcher(&options.pattern, options.ignore_case, options.multiline)?; + + // Single file search + if search_root.is_file() { + let file = File::open(&search_root).map_err(|e| e.to_string())?; + let reader = file.take(MAX_FILE_BYTES); + let (matches, match_count, limit_reached) = search_reader( + &matcher, + reader, + options.max_count, + options.context_before, + options.context_after, + options.max_columns, + ) + .map_err(|e| e.to_string())?; + + let path_str = search_root.to_string_lossy().into_owned(); + let file_matches: Vec = matches + .into_iter() + .map(|m| FileMatch { + path: path_str.clone(), + line_number: m.line_number, + line: m.line, + context_before: m.context_before, + context_after: m.context_after, + truncated: m.truncated, + }) + .collect(); + + let has_matches = !file_matches.is_empty(); + return Ok(FileSearchResult { + matches: file_matches, + total_matches: match_count, + files_with_matches: if has_matches { 1 } else { 0 }, + files_searched: 1, + limit_reached, + }); + } + + // Directory search — collect files using ignore crate's WalkBuilder + let mut walk_builder = WalkBuilder::new(&search_root); + walk_builder + .hidden(!options.hidden) + .git_ignore(options.gitignore) + .git_global(options.gitignore) + .git_exclude(options.gitignore); + + if let Some(ref glob_pattern) = options.glob { + let mut overrides = ignore::overrides::OverrideBuilder::new(&search_root); + overrides + .add(glob_pattern) + .map_err(|e| format!("Invalid glob: {e}"))?; + let built = overrides.build().map_err(|e| format!("Glob build error: {e}"))?; + walk_builder.overrides(built); + } + + let entries: Vec = walk_builder + .build() + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file())) + .map(|entry| entry.into_path()) + .collect(); + + let files_searched = entries.len() as u32; + + // Parallel search across all files + struct PerFileResult { + relative_path: String, + matches: Vec, + match_count: u64, + } + + let root_path = search_root.clone(); + let mut results: Vec = entries + .par_iter() + .filter_map(|file_path| { + let file = File::open(file_path).ok()?; + let reader = file.take(MAX_FILE_BYTES); + let (matches, match_count, _) = search_reader( + &matcher, + reader, + None, // no per-file limit in parallel mode + options.context_before, + options.context_after, + options.max_columns, + ) + .ok()?; + + if match_count == 0 { + return None; + } + + let relative = file_path + .strip_prefix(&root_path) + .unwrap_or(file_path) + .to_string_lossy() + .into_owned(); + + Some(PerFileResult { + relative_path: relative, + matches, + match_count, + }) + }) + .collect(); + + results.sort_by(|a, b| a.relative_path.cmp(&b.relative_path)); + + // Aggregate results, applying global max_count + let mut all_matches = Vec::new(); + let mut total_matches = 0u64; + let mut files_with_matches = 0u32; + let mut limit_reached = false; + + for result in results { + files_with_matches += 1; + total_matches = total_matches.saturating_add(result.match_count); + + for m in result.matches { + if let Some(max) = options.max_count { + if all_matches.len() as u64 >= max { + limit_reached = true; + break; + } + } + all_matches.push(FileMatch { + path: result.relative_path.clone(), + line_number: m.line_number, + line: m.line, + context_before: m.context_before, + context_after: m.context_after, + truncated: m.truncated, + }); + } + + if limit_reached { + break; + } + } + + Ok(FileSearchResult { + matches: all_matches, + total_matches, + files_with_matches, + files_searched, + limit_reached, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn search_content_basic() { + let content = b"hello world\nfoo bar\nhello rust\n"; + let options = SearchOptions { + pattern: "hello".to_string(), + ..Default::default() + }; + let result = search_content(content, &options).unwrap(); + assert_eq!(result.match_count, 2); + assert_eq!(result.matches.len(), 2); + assert_eq!(result.matches[0].line, "hello world"); + assert_eq!(result.matches[0].line_number, 1); + assert_eq!(result.matches[1].line, "hello rust"); + assert_eq!(result.matches[1].line_number, 3); + } + + #[test] + fn search_content_case_insensitive() { + let content = b"Hello World\nhello world\n"; + let options = SearchOptions { + pattern: "hello".to_string(), + ignore_case: true, + ..Default::default() + }; + let result = search_content(content, &options).unwrap(); + assert_eq!(result.match_count, 2); + } + + #[test] + fn search_content_max_count() { + let content = b"aaa\naaa\naaa\naaa\n"; + let options = SearchOptions { + pattern: "aaa".to_string(), + max_count: Some(2), + ..Default::default() + }; + let result = search_content(content, &options).unwrap(); + assert_eq!(result.matches.len(), 2); + assert!(result.limit_reached); + } + + #[test] + fn search_content_with_context() { + let content = b"line1\nline2\nmatch_here\nline4\nline5\n"; + let options = SearchOptions { + pattern: "match_here".to_string(), + context_before: 1, + context_after: 1, + ..Default::default() + }; + let result = search_content(content, &options).unwrap(); + assert_eq!(result.matches.len(), 1); + assert_eq!(result.matches[0].context_before.len(), 1); + assert_eq!(result.matches[0].context_before[0].line, "line2"); + assert_eq!(result.matches[0].context_after.len(), 1); + assert_eq!(result.matches[0].context_after[0].line, "line4"); + } + + #[test] + fn search_content_truncation() { + let content = b"this is a very long line that should be truncated\n"; + let options = SearchOptions { + pattern: "long".to_string(), + max_columns: Some(20), + ..Default::default() + }; + let result = search_content(content, &options).unwrap(); + assert_eq!(result.matches.len(), 1); + assert!(result.matches[0].truncated); + assert!(result.matches[0].line.ends_with("...")); + } + + #[test] + fn search_content_invalid_regex() { + let content = b"hello"; + let options = SearchOptions { + pattern: "[invalid".to_string(), + ..Default::default() + }; + let result = search_content(content, &options); + assert!(result.is_err()); + } +} diff --git a/native/scripts/build.js b/native/scripts/build.js new file mode 100644 index 000000000..25a52ba54 --- /dev/null +++ b/native/scripts/build.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node + +/** + * Build script for the GSD native Rust addon. + * + * Usage: + * node native/scripts/build.js # release build + * node native/scripts/build.js --dev # debug build + * + * Runs `cargo build` in the engine crate directory and copies the resulting + * shared library to `native/addon/` with a `.node` extension so Node.js + * can load it via `require()`. + */ + +import { execSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const nativeRoot = path.resolve(__dirname, ".."); +const engineDir = path.join(nativeRoot, "crates", "engine"); +const addonDir = path.join(nativeRoot, "addon"); + +const isDev = process.argv.includes("--dev"); +const profile = isDev ? "debug" : "release"; +const cargoArgs = ["build"]; +if (!isDev) cargoArgs.push("--release"); + +console.log(`Building gsd-engine (${profile})...`); + +try { + execSync(`cargo ${cargoArgs.join(" ")}`, { + cwd: engineDir, + stdio: "inherit", + env: { + ...process.env, + // Optimize for native CPU when building locally + RUSTFLAGS: process.env.RUSTFLAGS || "-C target-cpu=native", + }, + }); +} catch { + process.exit(1); +} + +// Locate the built library +const targetDir = path.join(nativeRoot, "target", profile); +const platformTag = `${process.platform}-${process.arch}`; + +const libraryNames = { + darwin: "libgsd_engine.dylib", + linux: "libgsd_engine.so", + win32: "gsd_engine.dll", +}; + +const libName = libraryNames[process.platform]; +if (!libName) { + console.error(`Unsupported platform: ${process.platform}`); + process.exit(1); +} + +const sourcePath = path.join(targetDir, libName); +if (!fs.existsSync(sourcePath)) { + console.error(`Built library not found at: ${sourcePath}`); + process.exit(1); +} + +fs.mkdirSync(addonDir, { recursive: true }); + +const destFilename = isDev + ? "gsd_engine.dev.node" + : `gsd_engine.${platformTag}.node`; +const destPath = path.join(addonDir, destFilename); + +fs.copyFileSync(sourcePath, destPath); +console.log(`Installed: ${destPath}`); +console.log("Build complete."); diff --git a/package.json b/package.json index 089c0b424..c08e82870 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,9 @@ "copy-themes": "node -e \"const{mkdirSync,cpSync}=require('fs');const{resolve}=require('path');const src=resolve(__dirname,'packages/pi-coding-agent/dist/modes/interactive/theme');mkdirSync('pkg/dist/modes/interactive/theme',{recursive:true});cpSync(src,'pkg/dist/modes/interactive/theme',{recursive:true})\"", "test": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts", "test:browser-tools": "node --test src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs src/resources/extensions/browser-tools/tests/browser-tools-integration.test.mjs", + "test:native": "node --test packages/native/src/__tests__/grep.test.mjs", + "build:native": "node native/scripts/build.js", + "build:native:dev": "node native/scripts/build.js --dev", "dev": "tsc --watch", "postinstall": "node scripts/postinstall.js", "pi:install-global": "node scripts/install-pi-global.js", diff --git a/packages/native/package.json b/packages/native/package.json new file mode 100644 index 000000000..84de3dfb3 --- /dev/null +++ b/packages/native/package.json @@ -0,0 +1,27 @@ +{ + "name": "@gsd/native", + "version": "0.1.0", + "description": "Native Rust bindings for GSD — high-performance grep via N-API", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "build:native": "node ../../native/scripts/build.js", + "build:native:dev": "node ../../native/scripts/build.js --dev", + "test": "node --test src/__tests__/grep.test.mjs" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./grep": { + "types": "./src/grep/index.ts", + "import": "./src/grep/index.ts" + } + }, + "files": [ + "src" + ], + "license": "MIT" +} diff --git a/packages/native/src/__tests__/grep.test.mjs b/packages/native/src/__tests__/grep.test.mjs new file mode 100644 index 000000000..4f92c5706 --- /dev/null +++ b/packages/native/src/__tests__/grep.test.mjs @@ -0,0 +1,162 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as fs from "node:fs"; +import * as os from "node:os"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +// Load the native addon directly +const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); +const platformTag = `${process.platform}-${process.arch}`; +const candidates = [ + path.join(addonDir, `gsd_engine.${platformTag}.node`), + path.join(addonDir, "gsd_engine.dev.node"), +]; + +let native; +for (const candidate of candidates) { + try { + native = require(candidate); + break; + } catch { + // try next + } +} + +if (!native) { + console.error("Native addon not found. Run `npm run build:native -w @gsd/native` first."); + process.exit(1); +} + +describe("native grep: search()", () => { + test("finds matches in buffer content", () => { + const content = Buffer.from("hello world\nfoo bar\nhello rust\n"); + const result = native.search(content, { pattern: "hello" }); + + assert.equal(result.matchCount, 2); + assert.equal(result.matches.length, 2); + assert.equal(result.matches[0].line, "hello world"); + assert.equal(result.matches[0].lineNumber, 1); + assert.equal(result.matches[1].line, "hello rust"); + assert.equal(result.matches[1].lineNumber, 3); + assert.equal(result.limitReached, false); + }); + + test("supports case-insensitive search", () => { + const content = Buffer.from("Hello World\nhello world\nHELLO\n"); + const result = native.search(content, { + pattern: "hello", + ignoreCase: true, + }); + + assert.equal(result.matchCount, 3); + }); + + test("respects maxCount limit", () => { + const content = Buffer.from("aaa\naaa\naaa\naaa\n"); + const result = native.search(content, { + pattern: "aaa", + maxCount: 2, + }); + + assert.equal(result.matches.length, 2); + assert.equal(result.limitReached, true); + }); + + test("returns context lines", () => { + const content = Buffer.from("line1\nline2\nmatch_here\nline4\nline5\n"); + const result = native.search(content, { + pattern: "match_here", + contextBefore: 1, + contextAfter: 1, + }); + + assert.equal(result.matches.length, 1); + assert.equal(result.matches[0].contextBefore.length, 1); + assert.equal(result.matches[0].contextBefore[0].line, "line2"); + assert.equal(result.matches[0].contextAfter.length, 1); + assert.equal(result.matches[0].contextAfter[0].line, "line4"); + }); + + test("throws on invalid regex", () => { + const content = Buffer.from("hello"); + assert.throws(() => { + native.search(content, { pattern: "[invalid" }); + }); + }); +}); + +describe("native grep: grep()", () => { + let tmpDir; + + test("searches files on disk", (t) => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-grep-test-")); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + fs.writeFileSync(path.join(tmpDir, "file1.txt"), "hello world\nfoo bar\n"); + fs.writeFileSync(path.join(tmpDir, "file2.txt"), "hello rust\nbaz qux\n"); + fs.writeFileSync(path.join(tmpDir, "file3.log"), "no match here\n"); + + const result = native.grep({ + pattern: "hello", + path: tmpDir, + }); + + assert.equal(result.totalMatches, 2); + assert.equal(result.filesWithMatches, 2); + assert.equal(result.matches.length, 2); + + // Matches should be sorted by file path + const paths = result.matches.map((m) => m.path); + assert.deepEqual(paths, [...paths].sort()); + }); + + test("respects glob filter", (t) => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-grep-test-")); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + fs.writeFileSync(path.join(tmpDir, "code.ts"), "hello typescript\n"); + fs.writeFileSync(path.join(tmpDir, "code.js"), "hello javascript\n"); + fs.writeFileSync(path.join(tmpDir, "readme.md"), "hello markdown\n"); + + const result = native.grep({ + pattern: "hello", + path: tmpDir, + glob: "*.ts", + }); + + assert.equal(result.totalMatches, 1); + assert.equal(result.matches[0].line, "hello typescript"); + }); + + test("respects maxCount", (t) => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-grep-test-")); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + for (let i = 0; i < 10; i++) { + fs.writeFileSync(path.join(tmpDir, `file${i}.txt`), "match_me\n"); + } + + const result = native.grep({ + pattern: "match_me", + path: tmpDir, + maxCount: 3, + }); + + assert.ok(result.matches.length <= 3); + assert.equal(result.limitReached, true); + }); + + test("errors on non-existent path", () => { + assert.throws(() => { + native.grep({ + pattern: "test", + path: "/nonexistent/path/that/does/not/exist", + }); + }); + }); +}); diff --git a/packages/native/src/grep/index.ts b/packages/native/src/grep/index.ts new file mode 100644 index 000000000..8da90ea0f --- /dev/null +++ b/packages/native/src/grep/index.ts @@ -0,0 +1,48 @@ +/** + * Native ripgrep wrapper using N-API. + * + * High-performance regex search backed by Rust's grep-* crates + * (the same internals as ripgrep). + */ + +import { native } from "../native.js"; +import type { + ContextLine, + GrepMatch, + GrepOptions, + GrepResult, + SearchMatch, + SearchOptions, + SearchResult, +} from "./types.js"; + +export type { + ContextLine, + GrepMatch, + GrepOptions, + GrepResult, + SearchMatch, + SearchOptions, + SearchResult, +}; + +/** + * Search in-memory content for a regex pattern. + * + * Accepts a Buffer/Uint8Array of UTF-8 encoded content. + */ +export function searchContent( + content: Buffer | Uint8Array, + options: SearchOptions, +): SearchResult { + return native.search(content, options) as SearchResult; +} + +/** + * Search files on disk for a regex pattern. + * + * Walks the directory tree respecting .gitignore and optional glob filters. + */ +export function grep(options: GrepOptions): GrepResult { + return native.grep(options) as GrepResult; +} diff --git a/packages/native/src/grep/types.ts b/packages/native/src/grep/types.ts new file mode 100644 index 000000000..518e5d07a --- /dev/null +++ b/packages/native/src/grep/types.ts @@ -0,0 +1,105 @@ +/** A context line adjacent to a match. */ +export interface ContextLine { + /** 1-indexed line number. */ + lineNumber: number; + /** Line content (trailing newline stripped). */ + line: string; +} + +/** A single match from in-memory content search. */ +export interface SearchMatch { + /** 1-indexed line number. */ + lineNumber: number; + /** The matched line content. */ + line: string; + /** Context lines before the match. */ + contextBefore: ContextLine[]; + /** Context lines after the match. */ + contextAfter: ContextLine[]; + /** Whether the line was truncated due to maxColumns. */ + truncated: boolean; +} + +/** Result of searching in-memory content. */ +export interface SearchResult { + /** All matches found. */ + matches: SearchMatch[]; + /** Total number of matches (may exceed matches.length due to limit). */ + matchCount: number; + /** Whether the limit was reached. */ + limitReached: boolean; +} + +/** Options for in-memory content search. */ +export interface SearchOptions { + /** Regex pattern to search for. */ + pattern: string; + /** Case-insensitive matching. */ + ignoreCase?: boolean; + /** Enable multiline regex mode. */ + multiline?: boolean; + /** Maximum number of matches to return. */ + maxCount?: number; + /** Lines of context before matches. */ + contextBefore?: number; + /** Lines of context after matches. */ + contextAfter?: number; + /** Truncate lines longer than this (characters). */ + maxColumns?: number; +} + +/** A single match from filesystem search. */ +export interface GrepMatch { + /** File path (relative for directory searches). */ + path: string; + /** 1-indexed line number. */ + lineNumber: number; + /** The matched line content. */ + line: string; + /** Context lines before the match. */ + contextBefore: ContextLine[]; + /** Context lines after the match. */ + contextAfter: ContextLine[]; + /** Whether the line was truncated. */ + truncated: boolean; +} + +/** Result of a filesystem search. */ +export interface GrepResult { + /** All matches found. */ + matches: GrepMatch[]; + /** Total matches across all files. */ + totalMatches: number; + /** Number of files with at least one match. */ + filesWithMatches: number; + /** Number of files searched. */ + filesSearched: number; + /** Whether the limit stopped the search early. */ + limitReached: boolean; +} + +/** Options for filesystem search. */ +export interface GrepOptions { + /** Regex pattern to search for. */ + pattern: string; + /** Directory or file to search. */ + path: string; + /** Glob filter for filenames (e.g. "*.ts"). */ + glob?: string; + /** Case-insensitive matching. */ + ignoreCase?: boolean; + /** Enable multiline regex mode. */ + multiline?: boolean; + /** Include hidden files (default: false). */ + hidden?: boolean; + /** Respect .gitignore files (default: true). */ + gitignore?: boolean; + /** Maximum number of matches to return. */ + maxCount?: number; + /** Lines of context before matches. */ + contextBefore?: number; + /** Lines of context after matches. */ + contextAfter?: number; + /** Truncate lines longer than this (characters). */ + maxColumns?: number; +} diff --git a/packages/native/src/index.ts b/packages/native/src/index.ts new file mode 100644 index 000000000..3c5cfdf83 --- /dev/null +++ b/packages/native/src/index.ts @@ -0,0 +1,17 @@ +/** + * @gsd/native — High-performance Rust modules exposed via N-API. + * + * Modules: + * - grep: ripgrep-backed regex search (content + filesystem) + */ + +export { searchContent, grep } from "./grep/index.js"; +export type { + ContextLine, + GrepMatch, + GrepOptions, + GrepResult, + SearchMatch, + SearchOptions, + SearchResult, +} from "./grep/index.js"; diff --git a/packages/native/src/native.ts b/packages/native/src/native.ts new file mode 100644 index 000000000..93aa1a09d --- /dev/null +++ b/packages/native/src/native.ts @@ -0,0 +1,46 @@ +/** + * Native addon loader. + * + * Locates and loads the compiled Rust N-API addon (`.node` file). + * Tries platform-tagged release builds first, then falls back to dev builds. + */ + +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +const addonDir = path.resolve(__dirname, "..", "..", "..", "native", "addon"); +const platformTag = `${process.platform}-${process.arch}`; + +const candidates = [ + path.join(addonDir, `gsd_engine.${platformTag}.node`), + path.join(addonDir, "gsd_engine.dev.node"), +]; + +function loadNative(): Record { + const errors: string[] = []; + + for (const candidate of candidates) { + try { + return require(candidate) as Record; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + errors.push(`${candidate}: ${message}`); + } + } + + const details = errors.map((e) => ` - ${e}`).join("\n"); + throw new Error( + `Failed to load gsd_engine native addon for ${platformTag}.\n\n` + + `Tried:\n${details}\n\n` + + `Build with: npm run build:native -w @gsd/native`, + ); +} + +export const native = loadNative() as { + search: (content: Buffer | Uint8Array, options: unknown) => unknown; + grep: (options: unknown) => unknown; +}; diff --git a/packages/native/tsconfig.json b/packages/native/tsconfig.json new file mode 100644 index 000000000..49e05cea1 --- /dev/null +++ b/packages/native/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +}