From 62afc681cda0376aac393d010669404f2d5c57b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 17:28:57 +0000 Subject: [PATCH 1/4] test: red tests for an unquoted path used as a table name `dirsql 'SELECT * FROM ./'` fails with SQLite's raw syntax error, which says nothing about the missing quotes. Pin the hint that should carry the fix, plus the negative control that an ordinary syntax error stays bare. Refs thekevinscott/dirsql#951 skip-changelog: red tests only; no source change yet Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QmQHSMTjNCmjKzhyfuHXdL --- packages/rust/tests/path_table_cli_e2e.rs | 26 +++++++++++++ packages/rust/tests/path_table_query.rs | 46 +++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/packages/rust/tests/path_table_cli_e2e.rs b/packages/rust/tests/path_table_cli_e2e.rs index 058db34e5..117245062 100644 --- a/packages/rust/tests/path_table_cli_e2e.rs +++ b/packages/rust/tests/path_table_cli_e2e.rs @@ -220,3 +220,29 @@ fn a_typoed_table_name_fails_without_a_hint() { "a typo must carry no path-table hint, got: {stderr}" ); } + +#[test] +fn an_unquoted_path_fails_with_the_quoting_hint() { + let dir = fixture(); + let out = run(&dir, "SELECT * FROM ./"); + + assert!(!out.status.success(), "an unquoted path must not succeed"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains(r#"did you mean "./"?"#), + "expected the quoting hint, got: {stderr}" + ); +} + +#[test] +fn an_ordinary_syntax_error_carries_no_quoting_hint() { + let dir = fixture(); + let out = run(&dir, "SELECT * FROM"); + + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("did you mean"), + "a syntax error with no path in it must stay unhinted, got: {stderr}" + ); +} diff --git a/packages/rust/tests/path_table_query.rs b/packages/rust/tests/path_table_query.rs index 7f7baa1db..74b4f535b 100644 --- a/packages/rust/tests/path_table_query.rs +++ b/packages/rust/tests/path_table_query.rs @@ -323,3 +323,49 @@ fn path_tables_never_leak_into_the_persisted_schema() { "a path-table lives in temp and must not appear in main's schema: {names:?}" ); } + +#[test] +fn an_unquoted_path_fails_with_a_quoting_hint() { + let root = fixture(); + let db = open(&root); + + let err = db.query("SELECT * FROM ./").unwrap_err().to_string(); + + assert!( + err.contains("syntax error"), + "SQLite's own error must survive, got: {err}" + ); + assert!( + err.contains(r#"did you mean "./"?"#), + "an unquoted path must name its quoted form, got: {err}" + ); +} + +#[test] +fn an_unquoted_nested_path_names_the_whole_path_in_the_hint() { + let root = fixture(); + let db = open(&root); + + let err = db + .query("SELECT * FROM ./docs/a.md") + .unwrap_err() + .to_string(); + + assert!( + err.contains(r#"did you mean "./docs/a.md"?"#), + "the hint must quote the whole path, not the token SQLite choked on, got: {err}" + ); +} + +#[test] +fn an_ordinary_syntax_error_carries_no_quoting_hint() { + let root = fixture(); + let db = open(&root); + + let err = db.query("SELECT * FROM").unwrap_err().to_string(); + + assert!( + !err.contains("did you mean"), + "a syntax error with no path in it must stay unhinted, got: {err}" + ); +} From a4c7c1da17d99d040f01b9bab77cd12014b76320 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 17:43:13 +0000 Subject: [PATCH 2/4] fix: hint at quoting when an unquoted path is used as a table name `SELECT * FROM ./` dies in SQLite's parser -- `./` is punctuation, not an identifier -- so it never reaches the `no such table` error the path-table fallback rides on, and the user sees only `near ".": syntax error`. `query()` now widens SQLite's reported error offset over the surrounding path characters and appends the quoting hint when the recovered token holds a `/`. Widening matters in both directions: `FROM ./` rejects the leading `.` while `FROM src/main.rs` rejects the `/` in the middle, and the same token comes back either way. Detection stays conservative -- a token with no `/`, an error SQLite reports without a token offset, and an index with no path-table root (where quoting would fail too) are all left exactly as they were. Fixes thekevinscott/dirsql#951 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QmQHSMTjNCmjKzhyfuHXdL --- PARITY.md | 9 ++ docs/reference/path-tables.md | 11 ++ .../2026-08-20-unquoted-path-table-hint.md | 3 + packages/rust/src/db.rs | 147 +++++++++++++++++- 4 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 packages/rust/changelog.d/2026-08-20-unquoted-path-table-hint.md diff --git a/PARITY.md b/PARITY.md index 8f27a49b8..e8d7f91cb 100644 --- a/PARITY.md +++ b/PARITY.md @@ -188,6 +188,15 @@ left unchanged). No implementation was needed — the tests passed on first run, confirming the core logic crosses both the PyO3 and napi boundaries unchanged. This closes epic #622. +**Unquoted-path hint (#951) — parity by construction, no drift.** A path +written without quotes (`FROM ./`) is punctuation to SQLite's parser, so the +statement dies before the `no such table` fallback above can see it. `db::query` +now widens SQLite's reported error offset over the surrounding path characters +and appends `hint: paths used as table names must be quoted; did you mean +"./"?` when the recovered token contains a `/`. Like every other path-table +diagnostic this lives in the shared core, so all three SDKs and the CLI get the +identical message with no per-binding surface and no SDK signature changes. + ## AsyncDirSQL | API | Python | Rust | diff --git a/docs/reference/path-tables.md b/docs/reference/path-tables.md index d4fe3f478..fd76dd138 100644 --- a/docs/reference/path-tables.md +++ b/docs/reference/path-tables.md @@ -61,6 +61,17 @@ SELECT * FROM '**/*.md'; -- no such table: **/*.md; did you mean './**/*.md'? ``` +**The quotes are required too.** A path is not a bare SQL identifier — `./` is +punctuation to SQLite's parser, so an unquoted path fails at parse time, before +dirsql ever sees a table name to resolve. Single or double quotes both work; +the error names the quoted form: + +``` +SELECT * FROM ./; +-- near ".": syntax error in SELECT * FROM ./ at offset 14 +-- hint: paths used as table names must be quoted; did you mean "./"? +``` + ### Paths outside the index root Three other prefixes resolve, with their usual shell meanings: diff --git a/packages/rust/changelog.d/2026-08-20-unquoted-path-table-hint.md b/packages/rust/changelog.d/2026-08-20-unquoted-path-table-hint.md new file mode 100644 index 000000000..f1c823416 --- /dev/null +++ b/packages/rust/changelog.d/2026-08-20-unquoted-path-table-hint.md @@ -0,0 +1,3 @@ +**Fixed** + +- **A path written unquoted where a table name goes now says so.** `dirsql "SELECT * FROM ./"` died on SQLite's raw `near ".": syntax error` with no indication that the fix was quoting the path. `./` is punctuation to SQLite's parser, so the statement never reached the `no such table` fallback the rest of path-table resolution rides on — the hints for a bare glob or a missing `files` table could not fire either. `query()` now widens SQLite's reported error offset over the surrounding path characters and, when the recovered token contains a `/`, appends `hint: paths used as table names must be quoted; did you mean "./"?` beneath the original error. Detection is conservative: a syntax error whose offending token holds no `/` (and any error SQLite reports without a token offset) is left exactly as it was. (#951) diff --git a/packages/rust/src/db.rs b/packages/rust/src/db.rs index ed1dbd302..354495b2f 100644 --- a/packages/rust/src/db.rs +++ b/packages/rust/src/db.rs @@ -87,6 +87,43 @@ fn legacy_files_table_hint() -> String { format!("{NO_SUCH_TABLE}{LEGACY_DEFAULT_TABLE}; did you mean FROM './'?") } +/// The characters a bare filesystem path can be spelled with where a table +/// name goes. Widening SQLite's error offset over these recovers the whole +/// path from whichever character its tokenizer happened to reject. +fn is_path_char(c: char) -> bool { + c.is_alphanumeric() || matches!(c, '.' | '/' | '_' | '-' | '~' | '*' | '?') +} + +/// The bare path around `offset` in `sql`, if there is one. +/// +/// SQLite reports the offset of the character it choked on, which is not where +/// the path starts: `FROM ./` rejects the leading `.`, while `FROM src/main.rs` +/// rejects the `/` in the middle. Widening in both directions recovers the same +/// token either way. A `/` is required, which keeps an ordinary syntax error +/// over an identifier or a number out of the hint. +fn path_token_at(sql: &str, offset: usize) -> Option<&str> { + if !sql.is_char_boundary(offset) { + return None; + } + let start = sql[..offset] + .char_indices() + .rev() + .take_while(|(_, c)| is_path_char(*c)) + .last() + .map_or(offset, |(i, _)| i); + let end = offset + + sql[offset..] + .find(|c: char| !is_path_char(c)) + .unwrap_or(sql.len() - offset); + + let token = &sql[start..end]; + token.contains('/').then_some(token) +} + +fn unquoted_path_hint(token: &str) -> String { + format!("hint: paths used as table names must be quoted; did you mean {token:?}?") +} + fn no_home_path_table(name: &str) -> String { format!( "path-table {name:?} cannot be resolved: no home directory for '~' \ @@ -678,7 +715,7 @@ impl Db { Err(e) => e, }; let Some((name, table)) = self.path_table_for(&error)? else { - return Err(error); + return Err(self.hint_unquoted_path(error)); }; if !attempted.insert(name.clone()) { return Err(error); @@ -787,6 +824,29 @@ impl Db { } } + /// Append the quoting hint when `error` is a syntax error over a bare + /// filesystem path. SQLite parses `./` as punctuation, so `FROM ./` dies in + /// the parser and never reaches the `no such table` fallback the rest of + /// this module rides on -- quoting the path is what gets it there. + fn hint_unquoted_path(&self, error: DbError) -> DbError { + match self.unquoted_path_hint_for(&error) { + Some(hint) => DbError::PathTable(format!("{error}\n{hint}")), + None => error, + } + } + + /// The hint `error` earns, or `None` when it is not a syntax error over a + /// path. Without a root the fallback is off, so a quoted path would fail + /// too and the hint would be a dead end. + fn unquoted_path_hint_for(&self, error: &DbError) -> Option { + self.path_table_root.as_ref()?; + let DbError::Sqlite(rusqlite::Error::SqlInputError { sql, offset, .. }) = error else { + return None; + }; + let offset = usize::try_from(*offset).ok()?; + path_token_at(sql, offset).map(unquoted_path_hint) + } + fn hints_legacy_files_table(&self, name: &str) -> bool { self.hint_legacy_files_table && name == LEGACY_DEFAULT_TABLE } @@ -2140,6 +2200,91 @@ mod tests { assert_eq!(missing_table_name("no such table: "), None); } + #[test] + fn path_token_at_recovers_a_path_from_its_first_character() { + assert_eq!( + path_token_at("SELECT * FROM ./docs/a.md", 14), + Some("./docs/a.md") + ); + } + + #[test] + fn path_token_at_widens_left_when_sqlite_points_mid_path() { + assert_eq!( + path_token_at("SELECT * FROM src/main.rs", 17), + Some("src/main.rs") + ); + } + + #[test] + fn path_token_at_stops_at_the_first_non_path_character() { + assert_eq!(path_token_at("SELECT * FROM a/b, c", 15), Some("a/b")); + } + + #[test] + fn path_token_at_ignores_a_token_with_no_slash() { + assert_eq!(path_token_at("SELECT * FROM 1nvalid", 14), None); + } + + #[test] + fn path_token_at_ignores_an_offset_inside_a_character() { + // `é` is two bytes, so offset 15 splits it; slicing there would panic. + assert_eq!(path_token_at("SELECT * FROM é/x", 15), None); + } + + #[test] + fn unquoted_path_hint_names_the_quoted_form() { + assert_eq!( + unquoted_path_hint("./"), + r#"hint: paths used as table names must be quoted; did you mean "./"?"# + ); + } + + #[test] + fn query_hints_at_quoting_for_an_unquoted_path() { + let mut db = Db::new().unwrap(); + db.set_path_table_root(PathBuf::from("/nonexistent-dirsql-root")); + + let err = db.query("SELECT * FROM ./").unwrap_err().to_string(); + + assert!(err.contains("syntax error"), "got: {err}"); + assert!(err.contains(r#"did you mean "./"?"#), "got: {err}"); + } + + #[test] + fn query_leaves_an_unquoted_path_unhinted_without_a_root() { + let db = Db::new().unwrap(); + + let err = db.query("SELECT * FROM ./").unwrap_err().to_string(); + + assert!(err.contains("syntax error"), "got: {err}"); + assert!( + !err.contains("did you mean"), + "quoting leads nowhere without a root, got: {err}" + ); + } + + #[test] + fn query_leaves_a_pathless_syntax_error_unhinted() { + let mut db = Db::new().unwrap(); + db.set_path_table_root(PathBuf::from("/nonexistent-dirsql-root")); + + let err = db.query("SELECT * FROM 1nvalid").unwrap_err().to_string(); + + assert!(!err.contains("did you mean"), "got: {err}"); + } + + #[test] + fn query_leaves_an_offsetless_syntax_error_unhinted() { + let mut db = Db::new().unwrap(); + db.set_path_table_root(PathBuf::from("/nonexistent-dirsql-root")); + + // "incomplete input" names no token, so there is no offset to widen from. + let err = db.query("SELECT * FROM").unwrap_err().to_string(); + + assert!(!err.contains("did you mean"), "got: {err}"); + } + #[test] fn bare_glob_hint_names_the_dot_slash_form() { assert_eq!( From 5e319db70880c0384816e4ee594651cdf6f2e051 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 17:46:42 +0000 Subject: [PATCH 3/4] e2e attestation for claude/issue-951-0eease --- .../python/e2e-attestations/claude-issue-951-0eease.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/python/e2e-attestations/claude-issue-951-0eease.json diff --git a/packages/python/e2e-attestations/claude-issue-951-0eease.json b/packages/python/e2e-attestations/claude-issue-951-0eease.json new file mode 100644 index 000000000..19ce52ae5 --- /dev/null +++ b/packages/python/e2e-attestations/claude-issue-951-0eease.json @@ -0,0 +1,7 @@ +{ + "command": "uv run python -m pytest tests/e2e/ -x -q", + "ran_at": 1787248002, + "exit_code": 0, + "commit": "a4c7c1da17d99d040f01b9bab77cd12014b76320", + "branch": "claude/issue-951-0eease" +} From 63b83d3cf4a9e11eb72cd985489de32ad02a0af7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 17:55:32 +0000 Subject: [PATCH 4/4] e2e attestation for claude/issue-951-0eease --- packages/ts/e2e-attestations/claude-issue-951-0eease.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/ts/e2e-attestations/claude-issue-951-0eease.json diff --git a/packages/ts/e2e-attestations/claude-issue-951-0eease.json b/packages/ts/e2e-attestations/claude-issue-951-0eease.json new file mode 100644 index 000000000..1adafbb4d --- /dev/null +++ b/packages/ts/e2e-attestations/claude-issue-951-0eease.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1787248532, + "exit_code": 0, + "commit": "5e319db70880c0384816e4ee594651cdf6f2e051", + "branch": "claude/issue-951-0eease" +}