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
9 changes: 9 additions & 0 deletions PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
11 changes: 11 additions & 0 deletions docs/reference/path-tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions packages/python/e2e-attestations/claude-issue-951-0eease.json
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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)
147 changes: 146 additions & 1 deletion packages/rust/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 '~' \
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<String> {
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
}
Expand Down Expand Up @@ -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!(
Expand Down
26 changes: 26 additions & 0 deletions packages/rust/tests/path_table_cli_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}
46 changes: 46 additions & 0 deletions packages/rust/tests/path_table_query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}
7 changes: 7 additions & 0 deletions packages/ts/e2e-attestations/claude-issue-951-0eease.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "pnpm test:e2e",
"ran_at": 1787248532,
"exit_code": 0,
"commit": "5e319db70880c0384816e4ee594651cdf6f2e051",
"branch": "claude/issue-951-0eease"
}
Loading