Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/reference/path-tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 --
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"}]
```
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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"}]
```
22 changes: 14 additions & 8 deletions packages/rust/src/brace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ fn steps(lo: i64, hi: i64, step: i64) -> Option<impl Iterator<Item = i128>> {

/// 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<char> = word.chars().collect();
let mut out = String::with_capacity(word.len());
Expand All @@ -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("[}]"),
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion packages/rust/src/matcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down
2 changes: 1 addition & 1 deletion packages/rust/src/parsed_vtab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down
2 changes: 1 addition & 1 deletion packages/rust/src/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion packages/rust/src/vtab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down
2 changes: 1 addition & 1 deletion packages/rust/src/vtab_scaffold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}"
Expand Down
9 changes: 9 additions & 0 deletions packages/rust/tests/config_glob_parity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
}
2 changes: 1 addition & 1 deletion packages/rust/tests/sdk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 { .. })));
}
Expand Down
110 changes: 110 additions & 0 deletions packages/rust/tests/unbalanced_glob_e2e.rs
Original file line number Diff line number Diff line change
@@ -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::<Vec<Value>>(&out.stdout)
.unwrap()
.len()
}

fn table_matches(glob: &str) -> usize {
count(&fixture(glob), "SELECT id FROM t")
}

fn path_matches(pattern: &str) -> Vec<String> {
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::<Vec<Value>>(&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"]);
}
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "pnpm test:e2e",
"ran_at": 1791325990,
"exit_code": 0,
"commit": "1b7fe8a7f8e4cd8a870c76fc6e26cd087c431dc3",
"branch": "claude/1268-unbalanced-brackets"
}
29 changes: 29 additions & 0 deletions packages/ts/migrations.d/2026-10-05-unbalanced-brackets-literal.md
Original file line number Diff line number Diff line change
@@ -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"}]
```
Loading