diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0d8e1b50..28062ae7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -43,6 +43,9 @@ jobs: - name: Lint Rust run: cargo clippy --workspace --all-targets --locked -- -D warnings + - name: Build Native Binding + run: pnpm --filter rstack build:native + - name: Lint run: node --run lint diff --git a/Cargo.lock b/Cargo.lock index 14806419..345a8ab6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,31 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -23,6 +42,31 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "ctor" version = "1.0.12" @@ -85,7 +129,7 @@ checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -117,6 +161,35 @@ dependencies = [ "slab", ] +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "libloading" version = "0.9.0" @@ -127,6 +200,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.3" @@ -165,7 +244,7 @@ dependencies = [ "napi-derive-backend", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -178,7 +257,7 @@ dependencies = [ "proc-macro2", "quote", "semver", - "syn", + "syn 2.0.119", ] [[package]] @@ -196,6 +275,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -220,6 +305,23 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rstack-binding" version = "0.1.0" @@ -227,6 +329,15 @@ dependencies = [ "napi", "napi-build", "napi-derive", + "rstack-ignore", +] + +[[package]] +name = "rstack-ignore" +version = "0.1.0" +dependencies = [ + "ignore", + "pathdiff", ] [[package]] @@ -235,12 +346,41 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[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.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "slab" version = "0.4.12" @@ -258,6 +398,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -270,8 +421,36 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[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/Cargo.toml b/Cargo.toml index e8ccf289..54a2d625 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/rstack-binding"] +members = ["crates/rstack-binding", "crates/rstack-ignore"] resolver = "2" [workspace.package] @@ -9,9 +9,12 @@ repository = "https://github.com/rstackjs/rstack-cli" rust-version = "1.88" [workspace.dependencies] +ignore = { version = "0.4.33", default-features = false } napi = { version = "3.12.0", default-features = false, features = ["napi9"] } napi-build = "2.4.0" napi-derive = "3.6.2" +pathdiff = "0.2.3" +rstack-ignore = { path = "crates/rstack-ignore" } [profile.release] lto = true diff --git a/crates/rstack-binding/Cargo.toml b/crates/rstack-binding/Cargo.toml index af4788c0..74dfbcbe 100644 --- a/crates/rstack-binding/Cargo.toml +++ b/crates/rstack-binding/Cargo.toml @@ -9,10 +9,12 @@ publish = false [lib] crate-type = ["cdylib"] +test = false [dependencies] napi.workspace = true napi-derive.workspace = true +rstack-ignore.workspace = true [build-dependencies] napi-build.workspace = true diff --git a/crates/rstack-binding/src/lib.rs b/crates/rstack-binding/src/lib.rs index 767cfa89..4a3e70f8 100644 --- a/crates/rstack-binding/src/lib.rs +++ b/crates/rstack-binding/src/lib.rs @@ -1,18 +1,44 @@ #![deny(clippy::all)] +use std::path::Path; + +use napi::Error; use napi_derive::napi; +use rstack_ignore::{IgnoreMatcher as CoreIgnoreMatcher, IgnoreSource as CoreIgnoreSource}; + +/// A Gitignore-compatible pattern source received from JavaScript. +#[napi(object, object_to_js = false)] +pub struct IgnoreSource { + /// Directory that patterns are resolved from. + pub root_path: String, + /// Newline-delimited Gitignore patterns. + pub patterns: String, +} +/// JavaScript-facing wrapper around the compiled Rust matcher. #[napi] -pub fn native_ping(input: String) -> String { - format!("pong:{input}") +pub struct IgnoreMatcher { + inner: CoreIgnoreMatcher, } -#[cfg(test)] -mod tests { - use super::native_ping; +#[napi] +impl IgnoreMatcher { + /// Compiles all pattern sources once and keeps the result for repeated path checks. + #[napi(constructor)] + pub fn new(sources: Vec) -> napi::Result { + let sources = sources + .into_iter() + .map(|source| CoreIgnoreSource::new(source.root_path, source.patterns)); + let inner = CoreIgnoreMatcher::new(sources).map_err(|error| { + Error::from_reason(format!("Failed to compile ignore patterns: {error}")) + })?; + + Ok(Self { inner }) + } - #[test] - fn formats_ping_response() { - assert_eq!(native_ping("rstack".to_string()), "pong:rstack"); + /// Returns whether a file or directory is ignored by any source. + #[napi] + pub fn is_ignored(&mut self, file_path: String, is_directory: bool) -> bool { + self.inner.is_ignored(Path::new(&file_path), is_directory) } } diff --git a/crates/rstack-ignore/Cargo.toml b/crates/rstack-ignore/Cargo.toml new file mode 100644 index 00000000..fe80d3c6 --- /dev/null +++ b/crates/rstack-ignore/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "rstack-ignore" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +ignore.workspace = true +pathdiff.workspace = true diff --git a/crates/rstack-ignore/src/lib.rs b/crates/rstack-ignore/src/lib.rs new file mode 100644 index 00000000..842db51f --- /dev/null +++ b/crates/rstack-ignore/src/lib.rs @@ -0,0 +1,149 @@ +#![deny(clippy::all)] + +use std::{borrow::Cow, collections::HashMap, path::Path, path::PathBuf}; + +use ignore::{ + gitignore::{Gitignore, GitignoreBuilder}, + Match, +}; + +/// Raw Gitignore patterns anchored to a base directory. +pub struct IgnoreSource { + root_path: PathBuf, + patterns: String, +} + +impl IgnoreSource { + /// Creates a pattern source whose rules are resolved from `root_path`. + pub fn new(root_path: impl Into, patterns: impl Into) -> Self { + Self { + root_path: root_path.into(), + patterns: patterns.into(), + } + } +} + +/// A reusable matcher compiled from one or more independent pattern sources. +pub struct IgnoreMatcher { + sources: Vec, +} + +impl IgnoreMatcher { + /// Compiles every source while preserving source-level ignore isolation. + pub fn new(sources: impl IntoIterator) -> Result { + let sources = sources + .into_iter() + .map(SourceMatcher::new) + .collect::>()?; + + Ok(Self { sources }) + } + + /// Returns whether a file or directory is ignored by any source. + pub fn is_ignored(&mut self, file_path: &Path, is_directory: bool) -> bool { + // Sources are independent: a negation in one source cannot re-include a path ignored by + // another source. + self.sources + .iter_mut() + .any(|source| source.is_ignored(file_path, is_directory)) + } +} + +struct SourceMatcher { + root_path: PathBuf, + matcher: Gitignore, + // File checks repeatedly consult their parents, so cache both ignored and included directories. + ignored_directories: HashMap, bool>, +} + +impl SourceMatcher { + fn new(source: IgnoreSource) -> Result { + // Paths are made relative to the source root before matching, so the builder uses a + // synthetic root instead of tying compiled patterns to an absolute path. + let mut builder = GitignoreBuilder::new("."); + // Match the case-insensitive default used by the previous JavaScript matcher. + builder.case_insensitive(true)?; + + // Accept ignore files with CRLF line endings or a UTF-8 byte-order mark. + for line in source.patterns.split('\n') { + let line = line.strip_suffix('\r').unwrap_or(line); + let line = line.strip_prefix('\u{feff}').unwrap_or(line); + builder.add_line(None, line)?; + } + + Ok(Self { + root_path: source.root_path, + matcher: builder.build()?, + ignored_directories: HashMap::new(), + }) + } + + fn is_ignored(&mut self, file_path: &Path, is_directory: bool) -> bool { + let relative_path = self.relative_path(file_path); + if relative_path.as_os_str().is_empty() { + return false; + } + + let relative_path = to_posix_path(&relative_path); + if is_directory { + return self.is_directory_ignored(&relative_path); + } + + // Gitignore cannot re-include a path below an ignored directory, so parent state wins. + parent_directory(&relative_path).is_some_and(|parent| self.is_directory_ignored(parent)) + || is_ignore_match(self.matcher.matched(relative_path.as_ref(), false)) + } + + fn relative_path<'path>(&self, file_path: &'path Path) -> Cow<'path, Path> { + if let Ok(relative_path) = file_path.strip_prefix(&self.root_path) { + return Cow::Borrowed(relative_path); + } + + // Patterns may intentionally target paths outside the source root with `../` segments. + Cow::Owned( + pathdiff::diff_paths(file_path, &self.root_path) + .unwrap_or_else(|| file_path.to_path_buf()), + ) + } + + fn is_directory_ignored(&mut self, relative_path: &str) -> bool { + let relative_path = relative_path.trim_end_matches('/'); + if relative_path.is_empty() { + return false; + } + + if let Some(ignored) = self.ignored_directories.get(relative_path) { + return *ignored; + } + + let ignored = parent_directory(relative_path) + .is_some_and(|parent| self.is_directory_ignored(parent)) + || is_ignore_match(self.matcher.matched(relative_path, true)); + self.ignored_directories + .insert(relative_path.into(), ignored); + ignored + } +} + +fn parent_directory(relative_path: &str) -> Option<&str> { + let separator = relative_path.rfind('/')?; + (separator > 0).then_some(&relative_path[..separator]) +} + +fn is_ignore_match(matched: Match<&ignore::gitignore::Glob>) -> bool { + matches!(matched, Match::Ignore(_)) +} + +fn to_posix_path(path: &Path) -> Cow<'_, str> { + let path = path.to_string_lossy(); + + #[cfg(windows)] + { + Cow::Owned(path.replace('\\', "/")) + } + + #[cfg(not(windows))] + { + path + } +} diff --git a/packages/rstack/binding.cjs b/packages/rstack/binding.cjs index f12fcbd7..57c34102 100644 --- a/packages/rstack/binding.cjs +++ b/packages/rstack/binding.cjs @@ -700,4 +700,4 @@ if (!nativeBinding) { } module.exports = nativeBinding -module.exports.nativePing = nativeBinding.nativePing +module.exports.IgnoreMatcher = nativeBinding.IgnoreMatcher diff --git a/packages/rstack/binding.d.cts b/packages/rstack/binding.d.cts index 78d57450..265f41b6 100644 --- a/packages/rstack/binding.d.cts +++ b/packages/rstack/binding.d.cts @@ -1,3 +1,17 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ -export declare function nativePing(input: string): string +/** JavaScript-facing wrapper around the compiled Rust matcher. */ +export declare class IgnoreMatcher { + /** Compiles all pattern sources once and keeps the result for repeated path checks. */ + constructor(sources: Array) + /** Returns whether a file or directory is ignored by any source. */ + isIgnored(filePath: string, isDirectory: boolean): boolean +} + +/** A Gitignore-compatible pattern source received from JavaScript. */ +export interface IgnoreSource { + /** Directory that patterns are resolved from. */ + rootPath: string + /** Newline-delimited Gitignore patterns. */ + patterns: string +} diff --git a/packages/rstack/src/fmt/ignore.ts b/packages/rstack/src/fmt/ignore.ts index b449318f..61ccfd1d 100644 --- a/packages/rstack/src/fmt/ignore.ts +++ b/packages/rstack/src/fmt/ignore.ts @@ -1,7 +1,7 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; -import createIgnore from 'ignore'; -import { createRelativePathResolver } from './pathHelpers.ts'; +import type { IgnoreSource } from '../../binding.cjs'; +import { loadNativeBinding } from '../native/index.ts'; import type { ResolvedFmtConfig } from './types.ts'; /** @@ -12,7 +12,7 @@ import type { ResolvedFmtConfig } from './types.ts'; */ const defaultIgnoreNames = ['package-lock.json', 'pnpm-lock.yaml']; -type IgnoreMatcher = (filePath: string, isDirectory?: boolean) => boolean; +type IgnorePredicate = (filePath: string, isDirectory?: boolean) => boolean; interface CreateIgnoreMatcherOptions { config: ResolvedFmtConfig; @@ -21,28 +21,18 @@ interface CreateIgnoreMatcherOptions { ignorePaths?: string[]; } -const createDefaultIgnoreMatcher = (): IgnoreMatcher => { +const createDefaultIgnoreMatcher = (): IgnorePredicate => { const suffixes = defaultIgnoreNames.map((name) => `${path.sep}${name}`); return (filePath) => suffixes.some((suffix) => filePath.endsWith(suffix)); }; -const createPatternMatcher = (rootPath: string, patterns: string): IgnoreMatcher => { - const matcher = createIgnore({ allowRelativePaths: true }).add(patterns); - const resolveRelativePath = createRelativePathResolver(rootPath); - - return (filePath, isDirectory = false) => { - const relativePath = resolveRelativePath(filePath); - if (relativePath === '') { - return false; - } - - const posixPath = path.sep === '\\' ? relativePath.replaceAll('\\', '/') : relativePath; - return matcher.ignores(isDirectory ? `${posixPath}/` : posixPath); - }; +const createPatternMatcherSet = (sources: IgnoreSource[]): IgnorePredicate => { + const matcher = new (loadNativeBinding().IgnoreMatcher)(sources); + return (filePath, isDirectory = false) => matcher.isIgnored(filePath, isDirectory); }; -const loadIgnoreMatcher = async (cwd: string, ignorePath: string): Promise => { +const loadIgnoreSource = async (cwd: string, ignorePath: string): Promise => { const filePath = path.resolve(cwd, ignorePath); let patterns: string; @@ -54,7 +44,10 @@ const loadIgnoreMatcher = async (cwd: string, ignorePath: string): Promise => { - const configMatcher = config.ignorePatterns.length - ? createPatternMatcher( - config.rootPath, - [...defaultIgnoreNames, ...config.ignorePatterns].join('\n'), - ) - : createDefaultIgnoreMatcher(); - if (ignorePaths.length === 0) { - return configMatcher; +}: CreateIgnoreMatcherOptions): Promise => { + const ignoreFileSources = await Promise.all( + ignorePaths.map((ignorePath) => loadIgnoreSource(cwd, ignorePath)), + ); + if (config.ignorePatterns.length) { + return createPatternMatcherSet([ + { + rootPath: config.rootPath, + patterns: [...defaultIgnoreNames, ...config.ignorePatterns].join('\n'), + }, + ...ignoreFileSources, + ]); } - const ignoreMatchers = await Promise.all( - ignorePaths.map((ignorePath) => loadIgnoreMatcher(cwd, ignorePath)), - ); + const defaultMatcher = createDefaultIgnoreMatcher(); + if (ignoreFileSources.length === 0) { + return defaultMatcher; + } + const cliMatcher = createPatternMatcherSet(ignoreFileSources); return (filePath, isDirectory = false) => - configMatcher(filePath, isDirectory) || - ignoreMatchers.some((matches) => matches(filePath, isDirectory)); + defaultMatcher(filePath, isDirectory) || cliMatcher(filePath, isDirectory); }; export { createIgnoreMatcher }; diff --git a/packages/rstack/tests/native/smoke.test.ts b/packages/rstack/tests/native/smoke.test.ts deleted file mode 100644 index 2e01b538..00000000 --- a/packages/rstack/tests/native/smoke.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { expect, test } from 'rstack/test'; -import { loadNativeBinding } from '../../dist/native/index.js'; - -test('passes a string through the private loader and Rust', () => { - expect(loadNativeBinding().nativePing('rstack')).toBe('pong:rstack'); -});