Skip to content
Merged
9 changes: 9 additions & 0 deletions docs/reference/path-tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,15 @@ SELECT path FROM './dist'; -- scans dist/ even when gitignored
The `.gitignore` files in force there, including those above it, still
filter what lies beneath it.

A `node_modules` component after a glob names it too, at any depth:

```sql
SELECT path FROM './**/node_modules/*/package.json'; -- every node_modules
SELECT path FROM './*/node_modules/**'; -- one level down
```

Only the literal name counts; `'./**/*.js'` still skips every `node_modules`.

### Hidden files

A path component starting with `.` is skipped unless the path spells it, the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**Fixed** A path-table pattern that names `node_modules` after a glob
component now scans it: `'./**/node_modules/**'` lists every `node_modules`
in the tree and `'./*/node_modules/*/*'` the ones one level down. Only a
`node_modules` in the literal prefix used to count, so these returned nothing.
A pattern that does not name `node_modules`, such as `'./**'` or
`'./**/*.js'`, still skips it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "just test-e2e",
"ran_at": 1791224899,
"exit_code": 0,
"commit": "e48a79c9fc67e5c2d69ec6c77116e5d2730684bb",
"branch": "claude/1271-node-modules-named"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
### Core: `node_modules` named after a glob is scanned

#### Summary

A path-table pattern with a literal `node_modules` component after a glob,
such as `'./**/node_modules/**'`, now scans the `node_modules` directories it
names; it used to return nothing. Every surface that resolves a path-table is
affected: the CLI, the REPL, the HTTP server, the SDKs, and the `--on-file`
form. Nothing breaks; such a query can return more rows. A pattern that does
not name `node_modules` still skips it, and declared `[[table]]` globs are
unchanged.

#### Required changes

_None._

#### Deprecations removed

_None._

#### Behavior changes without code changes

- `'./**/node_modules/**'` lists files under every `node_modules`.
- `'./*/node_modules/*/*'` lists files two levels into each `node_modules`
one level down.

#### Verification

```bash
cd "$(mktemp -d)"
mkdir -p node_modules/pkg pkg/node_modules/dep
touch node_modules/pkg/index.js pkg/node_modules/dep/i.js
dirsql query "SELECT path FROM './**/node_modules/*/*' ORDER BY path"
# expected: [{"path":"node_modules/pkg/index.js"},{"path":"pkg/node_modules/dep/i.js"}]
dirsql query "SELECT path FROM './**/*.js'"
# expected: []
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**Fixed** A path-table pattern that names `node_modules` after a glob
component now scans it: `'./**/node_modules/**'` lists every `node_modules`
in the tree and `'./*/node_modules/*/*'` the ones one level down. Only a
`node_modules` in the literal prefix used to count, so these returned nothing.
A pattern that does not name `node_modules`, such as `'./**'` or
`'./**/*.js'`, still skips it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
### Core: `node_modules` named after a glob is scanned

#### Summary

A path-table pattern with a literal `node_modules` component after a glob,
such as `'./**/node_modules/**'`, now scans the `node_modules` directories it
names; it used to return nothing. Every surface that resolves a path-table is
affected: the CLI, the REPL, the HTTP server, the SDKs, and the `--on-file`
form. Nothing breaks; such a query can return more rows. A pattern that does
not name `node_modules` still skips it, and declared `[[table]]` globs are
unchanged.

#### Required changes

_None._

#### Deprecations removed

_None._

#### Behavior changes without code changes

- `'./**/node_modules/**'` lists files under every `node_modules`.
- `'./*/node_modules/*/*'` lists files two levels into each `node_modules`
one level down.

#### Verification

```bash
cd "$(mktemp -d)"
mkdir -p node_modules/pkg pkg/node_modules/dep
touch node_modules/pkg/index.js pkg/node_modules/dep/i.js
dirsql query "SELECT path FROM './**/node_modules/*/*' ORDER BY path"
# expected: [{"path":"node_modules/pkg/index.js"},{"path":"pkg/node_modules/dep/i.js"}]
dirsql query "SELECT path FROM './**/*.js'"
# expected: []
```
41 changes: 13 additions & 28 deletions packages/rust/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,8 @@ pub struct Db {
/// exactly where `files` used to exist implicitly. A user who declared
/// tables and forgot `files` gets the plain SQLite error.
hint_legacy_files_table: bool,
/// Skip rules a path-table scan applies, seeded with the built-in defaults.
/// Configured skip rules a path-table scan applies on top of the table's
/// [`PathTable::default_ignores`].
path_table_ignore: Vec<String>,
/// Whether a path-table scan respects `.gitignore` files. On by default;
/// the CLI's `--no-ignore` turns it off.
Expand Down Expand Up @@ -351,14 +352,6 @@ fn worker_error(message: String) -> DbError {
DbError::Sqlite(rusqlite::Error::UserFunctionError(message.into()))
}

/// The skip rules a fresh `Db` starts with.
fn default_path_table_ignore() -> Vec<String> {
path_table::DEFAULT_IGNORES
.iter()
.map(|s| (*s).to_string())
.collect()
}

impl Db {
/// Open the default, ephemeral `Db`: an **anonymous disk-backed temp
/// database** (`Connection::open("")`), not `:memory:`.
Expand All @@ -382,7 +375,7 @@ impl Db {
scope,
path_table_root: None,
hint_legacy_files_table: false,
path_table_ignore: default_path_table_ignore(),
path_table_ignore: Vec::new(),
path_table_gitignore: true,
path_table_parser: None,
calls: CallReporter::new(),
Expand All @@ -407,7 +400,7 @@ impl Db {
scope,
path_table_root: None,
hint_legacy_files_table: false,
path_table_ignore: default_path_table_ignore(),
path_table_ignore: Vec::new(),
path_table_gitignore: true,
path_table_parser: None,
calls: CallReporter::new(),
Expand Down Expand Up @@ -1170,10 +1163,15 @@ impl Db {
.path_table_root
.as_deref()
.expect("a path-table resolves only under an index root");
let ignore: Vec<String> = table
.default_ignores()
.map(str::to_string)
.chain(self.path_table_ignore.iter().cloned())
.collect();
self.conn.execute_batch(&path_table_ddl(
name,
table,
&self.path_table_ignore,
&ignore,
self.path_table_gitignore,
self.path_table_parser.as_deref(),
index_root,
Expand Down Expand Up @@ -3083,14 +3081,6 @@ mod tests {
);
}

#[test]
fn default_path_table_ignore_carries_the_documented_defaults() {
assert_eq!(
default_path_table_ignore(),
vec!["**/node_modules/**".to_string(), "**/.git/**".to_string()]
);
}

fn docs_path_table() -> PathTable {
PathTable {
root: PathBuf::from("/root"),
Expand Down Expand Up @@ -3304,17 +3294,12 @@ mod tests {
}

#[test]
fn add_path_table_ignore_extends_the_defaults() {
fn add_path_table_ignore_accumulates_configured_rules() {
let mut db = Db::new().unwrap();
db.add_path_table_ignore(vec!["*.tmp".to_string()]);
db.add_path_table_ignore(vec!["*.bak".to_string()]);

assert_eq!(db.path_table_ignore.last(), Some(&"*.tmp".to_string()));
assert!(
db.path_table_ignore
.contains(&"**/node_modules/**".to_string()),
"the built-in defaults must survive: {:?}",
db.path_table_ignore
);
assert_eq!(db.path_table_ignore, ["*.tmp", "*.bak"]);
}

#[test]
Expand Down
45 changes: 43 additions & 2 deletions packages/rust/src/path_table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ use typed_path::{

/// Directories a path-table scan skips, at any depth. They are skipped only
/// *beneath* the literal part of the path you write, so naming one explicitly
/// still scans it.
pub const DEFAULT_IGNORES: [&str; 2] = ["**/node_modules/**", "**/.git/**"];
/// still scans it. See [`PathTable::default_ignores`] for `node_modules`
/// named after a glob.
pub const DEFAULT_IGNORES: [&str; 2] = [NODE_MODULES_IGNORE, "**/.git/**"];

const NODE_MODULES_IGNORE: &str = "**/node_modules/**";

/// The glob a directory expands to: one level, like `ls`. Any depth is
/// spelled explicitly as `**`.
Expand All @@ -36,6 +39,18 @@ pub struct PathTable {
pub path_prefix: String,
}

impl PathTable {
/// The [`DEFAULT_IGNORES`] this table applies. A glob with a
/// `node_modules` component names that directory wherever the component
/// sits, so its skip rule is dropped.
pub fn default_ignores(&self) -> impl Iterator<Item = &'static str> {
let names_node_modules = self.glob.split('/').any(|c| c == "node_modules");
DEFAULT_IGNORES
.into_iter()
.filter(move |rule| !(names_node_modules && *rule == NODE_MODULES_IGNORE))
}
}

/// What a name SQLite could not find turns out to be.
#[derive(Debug, PartialEq)]
pub enum Resolution {
Expand Down Expand Up @@ -315,6 +330,32 @@ mod tests {
assert_eq!(DEFAULT_IGNORES, ["**/node_modules/**", "**/.git/**"]);
}

fn table_globbing(glob: &str) -> PathTable {
PathTable {
root: PathBuf::from("/r"),
glob: glob.to_string(),
path_prefix: String::new(),
}
}

fn default_ignores_of(glob: &str) -> Vec<&'static str> {
table_globbing(glob).default_ignores().collect()
}

#[test]
fn a_glob_not_naming_node_modules_keeps_every_default_ignore() {
assert_eq!(default_ignores_of("**"), DEFAULT_IGNORES);
assert_eq!(default_ignores_of("**/*.js"), DEFAULT_IGNORES);
assert_eq!(default_ignores_of("node_modules*/*"), DEFAULT_IGNORES);
}

#[test]
fn a_glob_naming_node_modules_anywhere_drops_its_ignore() {
assert_eq!(default_ignores_of("**/node_modules/**"), ["**/.git/**"]);
assert_eq!(default_ignores_of("*/node_modules/*/*"), ["**/.git/**"]);
assert_eq!(default_ignores_of("node_modules"), ["**/.git/**"]);
}

#[test]
fn has_glob_metacharacter_spots_each_metacharacter() {
assert!(has_glob_metacharacter("a*"));
Expand Down
2 changes: 2 additions & 0 deletions packages/rust/src/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,7 @@ mod tests {
ignore: &ignore,
glob: Some(&glob),
gitignore: false,
in_repo: false,
frames: Vec::new(),
};
assert!(walk.follows(false, &[1], Kind::Dir));
Expand All @@ -1046,6 +1047,7 @@ mod tests {
ignore: &ignore,
glob: Some(&glob),
gitignore: false,
in_repo: false,
frames: Vec::new(),
};
assert!(walk.follows(false, &[], Kind::Dir));
Expand Down
51 changes: 51 additions & 0 deletions packages/rust/tests/path_table_globs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,57 @@ fn naming_a_nested_skipped_directory_explicitly_still_scans_it() {
);
}

fn nested_dependencies() -> TempDir {
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("node_modules/pkg")).unwrap();
fs::create_dir_all(root.path().join("pkg/node_modules/dep")).unwrap();
fs::create_dir_all(root.path().join("src")).unwrap();
fs::write(root.path().join("node_modules/top.js"), "js").unwrap();
fs::write(root.path().join("node_modules/pkg/index.js"), "js").unwrap();
fs::write(root.path().join("pkg/node_modules/dep/i.js"), "js").unwrap();
fs::write(root.path().join("src/a.js"), "js").unwrap();
root
}

#[test]
fn node_modules_named_after_a_globstar_is_scanned() {
let root = nested_dependencies();
let db = open(&root);

assert_eq!(
paths(&db, "SELECT path FROM './**/node_modules/**'"),
vec![
"node_modules/pkg/index.js",
"node_modules/top.js",
"pkg/node_modules/dep/i.js",
],
);
assert_eq!(
paths(&db, "SELECT path FROM './**/node_modules/*/*'"),
vec!["node_modules/pkg/index.js", "pkg/node_modules/dep/i.js"],
);
}

#[test]
fn node_modules_named_after_a_star_is_scanned() {
let root = nested_dependencies();
let db = open(&root);

assert_eq!(
paths(&db, "SELECT path FROM './*/node_modules/*/*'"),
vec!["pkg/node_modules/dep/i.js"],
);
}

#[test]
fn node_modules_the_glob_does_not_name_stays_skipped() {
let root = nested_dependencies();
let db = open(&root);

assert_eq!(paths(&db, "SELECT path FROM './**'"), vec!["src/a.js"]);
assert_eq!(paths(&db, "SELECT path FROM './**/*.js'"), vec!["src/a.js"]);
}

#[test]
fn configured_ignore_patterns_apply_to_a_path_table() {
let root = fixture();
Expand Down
18 changes: 18 additions & 0 deletions packages/rust/tests/path_table_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,24 @@ fn a_parsed_scan_honors_the_default_ignore_rules() {
);
}

#[test]
fn a_parsed_scan_enters_node_modules_the_glob_names() {
let root = fixture();
fs::create_dir_all(root.path().join("node_modules/pkg")).unwrap();
fs::write(
root.path().join("node_modules/pkg/dep.md"),
r#"[{"title":"dependency","n":9}]"#,
)
.unwrap();

let db = open_with_parser(&root);
let rows = db
.query("SELECT title FROM './**/node_modules/**/*.md'")
.unwrap();

assert_eq!(texts(&rows, "title"), vec!["dependency"]);
}

#[test]
fn a_file_the_parser_cannot_handle_fails_the_whole_table() {
let root = fixture();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**Fixed** A path-table pattern that names `node_modules` after a glob
component now scans it: `'./**/node_modules/**'` lists every `node_modules`
in the tree and `'./*/node_modules/*/*'` the ones one level down. Only a
`node_modules` in the literal prefix used to count, so these returned nothing.
A pattern that does not name `node_modules`, such as `'./**'` or
`'./**/*.js'`, still skips it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "pnpm test:e2e",
"ran_at": 1791224962,
"exit_code": 0,
"commit": "6db0260198699f42a75c81e07c514fbcfaf487af",
"branch": "claude/1271-node-modules-named"
}
Loading
Loading