diff --git a/docs/reference/path-tables.md b/docs/reference/path-tables.md index 52b830ef5..0b316f8cc 100644 --- a/docs/reference/path-tables.md +++ b/docs/reference/path-tables.md @@ -59,7 +59,9 @@ alternation: `'./docs/{a,b}.md'` is `docs/a.md` and `docs/b.md`, and groups nest. A group holding a `..` range counts: `'./log/{1..3}.txt'`, `'./{a..c}.md'`, `'./{01..10}.md'` (zero-padded) and `'./{1..10..2}.md'` (every second value). Any other brace is literal text, so `'./br/{q}.md'` -names the file `br/{q}.md`, and `\{` is always a literal brace. +names the file `br/{q}.md`, and `\{` is always a literal brace. A `{`, `}` +or `[` with no partner is literal too, in a path-table and in a config +`[[table]] glob`. The scan starts at the last directory named outright before the first glob component, and descends only as deep as the pattern can match -- diff --git a/packages/python/changelog.d/2026-10-05-unbalanced-brackets-literal.md b/packages/python/changelog.d/2026-10-05-unbalanced-brackets-literal.md new file mode 100644 index 000000000..0d11f4c86 --- /dev/null +++ b/packages/python/changelog.d/2026-10-05-unbalanced-brackets-literal.md @@ -0,0 +1 @@ +**Fixed** An unmatched `{`, `}` or `[` in a path-table or a config `[[table]] glob` is a literal character, as in bash, instead of a glob compile error. diff --git a/packages/python/e2e-attestations/claude-1268-unbalanced-brackets.json b/packages/python/e2e-attestations/claude-1268-unbalanced-brackets.json new file mode 100644 index 000000000..df9b02e31 --- /dev/null +++ b/packages/python/e2e-attestations/claude-1268-unbalanced-brackets.json @@ -0,0 +1,7 @@ +{ + "command": "uv run python -m pytest tests/e2e/glob_speed_depth_bound_test.py tests/e2e/glob_speed_recursive_test.py tests/e2e/glob_speed_one_level_test.py tests/e2e/glob_speed_matchers_test.py tests/e2e/glob_speed_dotfiles_test.py tests/e2e/query_contract_test.py tests/e2e/fanout_test.py tests/e2e/capture_collision_test.py -x -q", + "ran_at": 1791325871, + "exit_code": 0, + "commit": "45535f123cfc3ea9cf9460c228196e2630d377db", + "branch": "claude/1268-unbalanced-brackets" +} diff --git a/packages/python/migrations.d/2026-10-05-unbalanced-brackets-literal.md b/packages/python/migrations.d/2026-10-05-unbalanced-brackets-literal.md new file mode 100644 index 000000000..4e88403e1 --- /dev/null +++ b/packages/python/migrations.d/2026-10-05-unbalanced-brackets-literal.md @@ -0,0 +1,29 @@ +### Core: unbalanced braces and brackets in a glob are literal + +#### Summary + +A `{`, `}` or `[` with no partner in a glob is now a literal character, as in +bash, on path-tables and on config `[[table]] glob`. It used to fail with a +glob compile error. Nothing that compiled before changes meaning. + +#### Required changes + +_None._ + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +- `'./{a.md'`, `'./a}.md'`, `'./[a.md'` and `'./a[b.md'` match files named + exactly that instead of failing to compile. + +#### Verification + +```bash +cd "$(mktemp -d)" +touch '[a.md' +dirsql query "SELECT path FROM './[a.md'" +# expected: [{"path":"[a.md"}] +``` diff --git a/packages/rust/changelog.d/2026-10-05-unbalanced-brackets-literal.md b/packages/rust/changelog.d/2026-10-05-unbalanced-brackets-literal.md new file mode 100644 index 000000000..0d11f4c86 --- /dev/null +++ b/packages/rust/changelog.d/2026-10-05-unbalanced-brackets-literal.md @@ -0,0 +1 @@ +**Fixed** An unmatched `{`, `}` or `[` in a path-table or a config `[[table]] glob` is a literal character, as in bash, instead of a glob compile error. diff --git a/packages/rust/migrations.d/2026-10-05-unbalanced-brackets-literal.md b/packages/rust/migrations.d/2026-10-05-unbalanced-brackets-literal.md new file mode 100644 index 000000000..4e88403e1 --- /dev/null +++ b/packages/rust/migrations.d/2026-10-05-unbalanced-brackets-literal.md @@ -0,0 +1,29 @@ +### Core: unbalanced braces and brackets in a glob are literal + +#### Summary + +A `{`, `}` or `[` with no partner in a glob is now a literal character, as in +bash, on path-tables and on config `[[table]] glob`. It used to fail with a +glob compile error. Nothing that compiled before changes meaning. + +#### Required changes + +_None._ + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +- `'./{a.md'`, `'./a}.md'`, `'./[a.md'` and `'./a[b.md'` match files named + exactly that instead of failing to compile. + +#### Verification + +```bash +cd "$(mktemp -d)" +touch '[a.md' +dirsql query "SELECT path FROM './[a.md'" +# expected: [{"path":"[a.md"}] +``` diff --git a/packages/rust/src/brace.rs b/packages/rust/src/brace.rs index b96fe195f..8d4f271c4 100644 --- a/packages/rust/src/brace.rs +++ b/packages/rust/src/brace.rs @@ -198,7 +198,7 @@ fn steps(lo: i64, hi: i64, step: i64) -> Option> { /// Escape the braces bash left as literal text, so globset matches them /// rather than reading an alternation. A complete `[...]` class is copied as -/// written; an unclosed one ends escaping so globset reports the error. +/// written; an unclosed `[` is a literal character. fn escape_braces(word: &str) -> String { let chars: Vec = word.chars().collect(); let mut out = String::with_capacity(word.len()); @@ -222,10 +222,7 @@ fn escape_braces(word: &str) -> String { out.extend(&chars[i..=end]); resume = end + 1; } - None => { - out.extend(&chars[i..]); - return out; - } + None => out.push_str("[[]"), }, ('{', _) => out.push_str("[{]"), ('}', _) => out.push_str("[}]"), @@ -411,9 +408,18 @@ mod tests { } #[test] - fn an_unclosed_class_ends_escaping() { - assert_eq!(escape_braces("x[{q}"), "x[{q}"); - assert_eq!(escape_braces("x[]{"), "x[]{"); + fn an_unclosed_bracket_is_literal() { + assert_eq!(escape_braces("x[{q}"), "x[[][{]q[}]"); + assert_eq!(escape_braces("x[]{"), "x[[]][{]"); + assert_eq!(escape_braces("[!a"), "[[]!a"); + assert_eq!(escape_braces("a].md"), "a].md"); + } + + #[test] + fn expand_leaves_an_unpartnered_brace_literal() { + assert_eq!(expand("{a.md"), vec!["[{]a.md"]); + assert_eq!(expand("a}.md"), vec!["a[}].md"]); + assert_eq!(expand("{a,b}}.md"), vec!["a[}].md", "b[}].md"]); } #[test] diff --git a/packages/rust/src/matcher.rs b/packages/rust/src/matcher.rs index 90ffb76e7..029f241e8 100644 --- a/packages/rust/src/matcher.rs +++ b/packages/rust/src/matcher.rs @@ -321,7 +321,7 @@ mod tests { #[test] fn invalid_glob_returns_error() { - let result = TableMatcher::new(&[("[invalid", "t")], &[]); + let result = TableMatcher::new(&[("[z-a]", "t")], &[]); assert!(result.is_err()); } diff --git a/packages/rust/src/parsed_vtab.rs b/packages/rust/src/parsed_vtab.rs index e07a4c64b..706b8e271 100644 --- a/packages/rust/src/parsed_vtab.rs +++ b/packages/rust/src/parsed_vtab.rs @@ -291,7 +291,7 @@ mod tests { #[test] fn parse_module_args_rejects_an_invalid_glob() { - let args = args_with(&[b"'/tmp'", b"'['", b"'cat'", b"'gitignore'", b"'/tmp'"]); + let args = args_with(&[b"'/tmp'", b"'[z-a]'", b"'cat'", b"'gitignore'", b"'/tmp'"]); assert!(parse_module_args(&args).is_err()); } diff --git a/packages/rust/src/scanner.rs b/packages/rust/src/scanner.rs index ffd08c24b..b846f9676 100644 --- a/packages/rust/src/scanner.rs +++ b/packages/rust/src/scanner.rs @@ -1009,7 +1009,7 @@ mod tests { #[test] fn compile_glob_rejects_an_invalid_pattern() { - assert!(compile_glob("[").is_err()); + assert!(compile_glob("[z-a]").is_err()); } #[test] diff --git a/packages/rust/src/vtab.rs b/packages/rust/src/vtab.rs index 4b627a2cc..102979bbc 100644 --- a/packages/rust/src/vtab.rs +++ b/packages/rust/src/vtab.rs @@ -620,7 +620,7 @@ mod tests { #[test] fn parse_module_args_rejects_an_invalid_glob() { - let args = args_with(&[b"'/tmp'", b"'['", b"''", b"'gitignore'"]); + let args = args_with(&[b"'/tmp'", b"'[z-a]'", b"''", b"'gitignore'"]); assert!(parse_module_args(&args).is_err()); } diff --git a/packages/rust/src/vtab_scaffold.rs b/packages/rust/src/vtab_scaffold.rs index d709f5b1d..d7e880281 100644 --- a/packages/rust/src/vtab_scaffold.rs +++ b/packages/rust/src/vtab_scaffold.rs @@ -669,7 +669,7 @@ mod tests { #[test] fn compile_glob_rejects_an_invalid_pattern() { - let err = compile_glob("[").unwrap_err(); + let err = compile_glob("[z-a]").unwrap_err(); assert!( matches!(err, Error::ModuleError(_)), "invalid globs surface as module errors, got {err:?}" diff --git a/packages/rust/tests/config_glob_parity.rs b/packages/rust/tests/config_glob_parity.rs index 8fadd6c24..2d6ab92b3 100644 --- a/packages/rust/tests/config_glob_parity.rs +++ b/packages/rust/tests/config_glob_parity.rs @@ -60,3 +60,12 @@ fn a_brace_range_expands_as_it_does_in_a_path_table() { assert_eq!(path_table_paths(root.path(), "f{1..3}.md"), expected); assert_eq!(config_paths(root.path(), "f{1..3}.md"), expected); } + +#[test] +fn an_unbalanced_brace_or_bracket_is_literal_as_it_is_in_a_path_table() { + let root = tree(&["{a.md", "a}.md", "[a.md", "a.md"]); + for (glob, expected) in [("{a.md", "{a.md"), ("a}.md", "a}.md"), ("[a.md", "[a.md")] { + assert_eq!(path_table_paths(root.path(), glob), vec![expected]); + assert_eq!(config_paths(root.path(), glob), vec![expected]); + } +} diff --git a/packages/rust/tests/sdk.rs b/packages/rust/tests/sdk.rs index 0863a0fbd..84a5e08f8 100644 --- a/packages/rust/tests/sdk.rs +++ b/packages/rust/tests/sdk.rs @@ -831,7 +831,7 @@ fn declared_name_absent_from_the_catalog_errors() { #[test] fn invalid_glob_errors() { let root = TempDir::new().unwrap(); - let table = Table::new("t", "CREATE TABLE t (x TEXT)", "a[b", |_| vec![]); + let table = Table::new("t", "CREATE TABLE t (x TEXT)", "a[z-a]", |_| vec![]); let result = DirSQL::new(root.path(), vec![table]); assert!(matches!(result, Err(dirsql::DirSqlError::Matcher { .. }))); } diff --git a/packages/rust/tests/unbalanced_glob_e2e.rs b/packages/rust/tests/unbalanced_glob_e2e.rs new file mode 100644 index 000000000..e28b1e638 --- /dev/null +++ b/packages/rust/tests/unbalanced_glob_e2e.rs @@ -0,0 +1,110 @@ +//! A `{`, `}` or `[` with no partner is a literal character, as in bash, on +//! both the path-table and the `[[table]] glob` surfaces. Real binary, real +//! files, nothing mocked. + +#![cfg(all(feature = "cli", unix))] + +use std::fs; +use std::process::Output; + +use assert_cmd::prelude::*; +use serde_json::Value; +use tempfile::TempDir; + +const NAMES: [&str; 5] = ["{a.json", "a}.json", "[a.json", "a[b.json", "a.json"]; + +fn fixture(glob: &str) -> TempDir { + let root = TempDir::new().unwrap(); + for name in NAMES { + fs::write(root.path().join(name), r#"[{"id": "row"}]"#).unwrap(); + } + fs::write( + root.path().join(".dirsql.toml"), + format!( + "[[table]]\nname = \"t\"\nddl = \"CREATE TABLE t (id TEXT)\"\nglob = \"{glob}\"\non-file = \"cat\"\n" + ), + ) + .unwrap(); + root +} + +fn query(root: &TempDir, sql: &str) -> Output { + std::process::Command::cargo_bin("dirsql") + .unwrap() + .arg("query") + .arg(sql) + .arg("--config") + .arg(root.path().join(".dirsql.toml")) + .current_dir(root.path()) + .output() + .unwrap() +} + +fn count(root: &TempDir, sql: &str) -> usize { + let out = query(root, sql); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + serde_json::from_slice::>(&out.stdout) + .unwrap() + .len() +} + +fn table_matches(glob: &str) -> usize { + count(&fixture(glob), "SELECT id FROM t") +} + +fn path_matches(pattern: &str) -> Vec { + let root = fixture("a.json"); + let out = query(&root, &format!("SELECT path FROM './{pattern}'")); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + serde_json::from_slice::>(&out.stdout) + .unwrap() + .iter() + .map(|r| r["path"].as_str().unwrap().to_string()) + .collect() +} + +#[test] +fn a_table_glob_with_an_unclosed_brace_is_literal() { + assert_eq!(table_matches("{a.json"), 1); +} + +#[test] +fn a_table_glob_with_an_unopened_brace_is_literal() { + assert_eq!(table_matches("a}.json"), 1); +} + +#[test] +fn a_table_glob_with_an_unclosed_bracket_is_literal() { + assert_eq!(table_matches("[a.json"), 1); + assert_eq!(table_matches("a[b.json"), 1); +} + +#[test] +fn a_table_glob_with_a_balanced_alternation_still_alternates() { + assert_eq!(table_matches("{a,b}.json"), 1); +} + +#[test] +fn a_path_table_with_an_unclosed_bracket_is_literal() { + assert_eq!(path_matches("[a.json"), vec!["[a.json"]); + assert_eq!(path_matches("a[b.json"), vec!["a[b.json"]); +} + +#[test] +fn a_path_table_with_an_unclosed_bracket_before_a_brace_group_is_literal() { + assert!(path_matches("[{q}.json").is_empty()); +} + +#[test] +fn a_path_table_with_unbalanced_braces_is_literal() { + assert_eq!(path_matches("{a.json"), vec!["{a.json"]); + assert_eq!(path_matches("a}.json"), vec!["a}.json"]); +} diff --git a/packages/ts/changelog.d/2026-10-05-unbalanced-brackets-literal.md b/packages/ts/changelog.d/2026-10-05-unbalanced-brackets-literal.md new file mode 100644 index 000000000..0d11f4c86 --- /dev/null +++ b/packages/ts/changelog.d/2026-10-05-unbalanced-brackets-literal.md @@ -0,0 +1 @@ +**Fixed** An unmatched `{`, `}` or `[` in a path-table or a config `[[table]] glob` is a literal character, as in bash, instead of a glob compile error. diff --git a/packages/ts/e2e-attestations/claude-1268-unbalanced-brackets.json b/packages/ts/e2e-attestations/claude-1268-unbalanced-brackets.json new file mode 100644 index 000000000..20d328d85 --- /dev/null +++ b/packages/ts/e2e-attestations/claude-1268-unbalanced-brackets.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1791325990, + "exit_code": 0, + "commit": "1b7fe8a7f8e4cd8a870c76fc6e26cd087c431dc3", + "branch": "claude/1268-unbalanced-brackets" +} diff --git a/packages/ts/migrations.d/2026-10-05-unbalanced-brackets-literal.md b/packages/ts/migrations.d/2026-10-05-unbalanced-brackets-literal.md new file mode 100644 index 000000000..4e88403e1 --- /dev/null +++ b/packages/ts/migrations.d/2026-10-05-unbalanced-brackets-literal.md @@ -0,0 +1,29 @@ +### Core: unbalanced braces and brackets in a glob are literal + +#### Summary + +A `{`, `}` or `[` with no partner in a glob is now a literal character, as in +bash, on path-tables and on config `[[table]] glob`. It used to fail with a +glob compile error. Nothing that compiled before changes meaning. + +#### Required changes + +_None._ + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +- `'./{a.md'`, `'./a}.md'`, `'./[a.md'` and `'./a[b.md'` match files named + exactly that instead of failing to compile. + +#### Verification + +```bash +cd "$(mktemp -d)" +touch '[a.md' +dirsql query "SELECT path FROM './[a.md'" +# expected: [{"path":"[a.md"}] +```