Skip to content

refactor(tui): drive cursor context from the token stream (T9) - #70

Merged
TimelordUK merged 1 commit into
mainfrom
refactor/t9-token-driven-cursor-context
Sep 8, 2026
Merged

refactor(tui): drive cursor context from the token stream (T9)#70
TimelordUK merged 1 commit into
mainfrom
refactor/t9-token-driven-cursor-context

Conversation

@TimelordUK

Copy link
Copy Markdown
Owner

Completion decided where the cursor was by scanning strings. Two near-identical scanners did it — analyze_statement when the partial query happened to parse, analyze_partial when it did not — using rfind('.'), split_whitespace().last(), ends_with(" AND") and query_upper.contains("SELECT"). analyze_partial even built a token stream first and then ignored it, and a third heuristic (determine_context) ran when both returned Unknown.

Measured on data/countries.csv:

WHERE region = ' WhereClause, 76 column names inside the
string literal

WHERE region = 'Am WhereClause, nothing
WHERE region IN ('Asia', ' WhereClause, 76 column names
WHERE "name.common" = no comparison operator found at all
WHERE region= no comparison operator found at all
GROUP BY suggested WHERE and ORDER BY
LIMIT suggested a table name

Replaced by src/sql/cursor_context.rs, the one owner of where is the cursor: truncate at the cursor, tokenize once, and match on the tail of the stream. The last token says what kind of position this is; the token before it disambiguates a bare word. Keywords are Token variants, so a keyword the lexer learns the completer learns with it — the completer knew six, Token::from_keyword knows fifty-five.

Three fixes fall out as consequences rather than as separate changes: quoted and dotted columns reach their operator, because columns come from Identifier/QuotedIdentifier joined across Dot instead of chars().all(|c| c.is_alphanumeric() || c == '_'); operators no longer need surrounding spaces; and keywords inside a literal stay inside it, because the unterminated-StringLiteral check runs before anything else looks at the stream.

The AST the old code went to the trouble of building was consulted only for stmt.where_clause.is_some()-shaped questions the tokens answer directly, so detect_cursor_context no longer parses the query — one less full parse per keystroke. 539 lines out of recursive_parser.rs.

Settles char-vs-byte offsets rather than deferring them: the lexer gained tokenize_all_with_byte_positions, and the analyzer speaks bytes throughout. tokenize_all_with_positions indexes the internal Vec, while cursor_pos and replace_start (T1) are byte offsets; they agree only on ASCII, and a wrong offset would have silently corrupted a spliced value.

T4 is now "put values in the empty vector":
CursorContext::InStringLiteral { column, in_list, value_start } is produced, threaded through ParseResult::replace_start and exempted from identifier filtering. The completer deliberately offers nothing there, which is still an improvement on offering column names inside quotes.

determine_context / ParseState survive as the Unknown fallback. Removing them is T12, kept separate because ParseState also has users in src/completer.rs and main.rs validation.

One test changed rather than added: test_order_by_quoted_partial_completion asserted partial_word == Some(""Customer"), opening quote included, which was an artefact of the deleted scanner. The partial now comes from the lexer, which has consumed the quote. Nothing filters on partial_word — that is find_completion_token's job and it still sees the quote — so the test's two substantive assertions are untouched.

Tests: 20 unit tests in cursor_context.rs, 8 in tests/completion_schema.rs driving the real get_completions entry point against countries.csv. Full suite green: 800 lib, 482 integration, 534 Python, 152/153 examples (the one failure is a pre-existing smoke test with no expectations, on a path completion cannot reach).

Claude-Session: https://claude.ai/code/session_01JkqLCLn1Usij41xUjMUoPz

Completion decided where the cursor was by scanning strings. Two
near-identical scanners did it — `analyze_statement` when the partial
query happened to parse, `analyze_partial` when it did not — using
`rfind('.')`, `split_whitespace().last()`, `ends_with(" AND")` and
`query_upper.contains("SELECT")`. `analyze_partial` even built a token
stream first and then ignored it, and a third heuristic
(`determine_context`) ran when both returned Unknown.

Measured on data/countries.csv:

  WHERE region = '              WhereClause, 76 column names *inside the
                               string literal*
  WHERE region = 'Am           WhereClause, nothing
  WHERE region IN ('Asia', '   WhereClause, 76 column names
  WHERE "name.common" =        no comparison operator found at all
  WHERE region=                no comparison operator found at all
  GROUP BY                     suggested WHERE and ORDER BY
  LIMIT                        suggested a table name

Replaced by src/sql/cursor_context.rs, the one owner of *where is the
cursor*: truncate at the cursor, tokenize once, and match on the tail of
the stream. The last token says what kind of position this is; the token
before it disambiguates a bare word. Keywords are Token variants, so a
keyword the lexer learns the completer learns with it — the completer
knew six, Token::from_keyword knows fifty-five.

Three fixes fall out as consequences rather than as separate changes:
quoted and dotted columns reach their operator, because columns come
from Identifier/QuotedIdentifier joined across Dot instead of
`chars().all(|c| c.is_alphanumeric() || c == '_')`; operators no longer
need surrounding spaces; and keywords inside a literal stay inside it,
because the unterminated-StringLiteral check runs before anything else
looks at the stream.

The AST the old code went to the trouble of building was consulted only
for `stmt.where_clause.is_some()`-shaped questions the tokens answer
directly, so detect_cursor_context no longer parses the query — one less
full parse per keystroke. 539 lines out of recursive_parser.rs.

Settles char-vs-byte offsets rather than deferring them: the lexer gained
tokenize_all_with_byte_positions, and the analyzer speaks bytes
throughout. tokenize_all_with_positions indexes the internal Vec<char>,
while cursor_pos and replace_start (T1) are byte offsets; they agree only
on ASCII, and a wrong offset would have silently corrupted a spliced
value.

T4 is now "put values in the empty vector":
CursorContext::InStringLiteral { column, in_list, value_start } is
produced, threaded through ParseResult::replace_start and exempted from
identifier filtering. The completer deliberately offers nothing there,
which is still an improvement on offering column names inside quotes.

determine_context / ParseState survive as the Unknown fallback. Removing
them is T12, kept separate because ParseState also has users in
src/completer.rs and main.rs validation.

One test changed rather than added: test_order_by_quoted_partial_completion
asserted partial_word == Some("\"Customer"), opening quote included,
which was an artefact of the deleted scanner. The partial now comes from
the lexer, which has consumed the quote. Nothing filters on partial_word
— that is find_completion_token's job and it still sees the quote — so
the test's two substantive assertions are untouched.

Tests: 20 unit tests in cursor_context.rs, 8 in tests/completion_schema.rs
driving the real get_completions entry point against countries.csv.
Full suite green: 800 lib, 482 integration, 534 Python, 152/153 examples
(the one failure is a pre-existing smoke test with no expectations, on a
path completion cannot reach).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkqLCLn1Usij41xUjMUoPz
@TimelordUK
TimelordUK merged commit ad92446 into main Sep 8, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant