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/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" +} 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!( 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}" + ); +} 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" +}