refactor(tui): drive cursor context from the token stream (T9) - #70
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Completion decided where the cursor was by scanning strings. Two near-identical scanners did it —
analyze_statementwhen the partial query happened to parse,analyze_partialwhen it did not — usingrfind('.'),split_whitespace().last(),ends_with(" AND")andquery_upper.contains("SELECT").analyze_partialeven 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