fix(input): thread the : one-shot escape into live SQL feedback
The `:` one-shot escape (ADR-0003) is stripped at submission, but the *live* feedback kept the leading `:` in the buffer it handed the walker — so Tab completion, the validity verdict, and the highlight overlays all bailed at the `:` and treated the SQL as an unknown command. Effect: in `:`-mode, Tab completed nothing and a valid query could flash an error, while the identical line in full `mode advanced` worked. (The ambient hint already stripped it, which is why the hint showed the right column name while Tab did nothing.) Add `App::feedback_view()` — the `:`-stripped SQL view, the cursor mapped into it, and the stripped byte offset — and route all four live paths through it: - completion (Tab): complete against the view, then shift the returned `replaced_range` back by the offset so the edit lands in the buffer; - validity verdict: verdict the SQL, not the sigil; - highlight/overlays: new `render_input_runs_feedback` highlights and diagnoses the view (shifted by the offset) while the `:` renders as plain text; - ambient hint: consolidated onto `feedback_view`, replacing the duplicate local `strip_one_shot_prefix`.
This commit is contained in:
+140
-19
@@ -646,6 +646,44 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// The input view the **live-feedback** walkers (completion, ambient
|
||||
/// hint, validity verdict, highlight overlays) should see, plus the
|
||||
/// byte offset stripped from the front and the cursor mapped into the
|
||||
/// view.
|
||||
///
|
||||
/// Under the `:` one-shot escape (ADR-0003) the buffer carries a
|
||||
/// leading `:` (and an auto-inserted space) that is *not* advanced
|
||||
/// SQL — submission already strips it before parsing, but the live
|
||||
/// feedback did not, so the walker bailed at the `:` and resolved
|
||||
/// nothing (no completion / hint, a spurious error overlay). This
|
||||
/// returns the stripped SQL exactly as submission sees it, so the
|
||||
/// feedback matches a real advanced-mode session. `offset` maps any
|
||||
/// walker-returned byte position (completion `replaced_range`,
|
||||
/// overlay spans) back to real-buffer coordinates.
|
||||
///
|
||||
/// For every non-one-shot input this is the identity
|
||||
/// `(&input, cursor, 0)`.
|
||||
#[must_use]
|
||||
pub fn feedback_view(&self) -> (&str, usize, usize) {
|
||||
if matches!(self.effective_mode(), EffectiveMode::AdvancedOneShot) {
|
||||
// The first non-whitespace char is the `:` (per
|
||||
// `effective_mode`); strip up to and including it, then any
|
||||
// following whitespace — mirroring submission's
|
||||
// `trimmed[1..].trim()`.
|
||||
let leading_ws = self.input.len() - self.input.trim_start().len();
|
||||
let mut offset = leading_ws + 1; // past the `:`
|
||||
while offset < self.input.len()
|
||||
&& self.input.as_bytes()[offset].is_ascii_whitespace()
|
||||
{
|
||||
offset += 1;
|
||||
}
|
||||
let view = &self.input[offset..];
|
||||
let cursor = self.input_cursor.saturating_sub(offset).min(view.len());
|
||||
return (view, cursor, offset);
|
||||
}
|
||||
(&self.input, self.input_cursor, 0)
|
||||
}
|
||||
|
||||
/// The validity-indicator verdict for the current input
|
||||
/// (ADR-0027 §3). `None` when the input would run clean.
|
||||
///
|
||||
@@ -667,11 +705,10 @@ impl App {
|
||||
EffectiveMode::AdvancedPersistent
|
||||
| EffectiveMode::AdvancedOneShot => Mode::Advanced,
|
||||
};
|
||||
crate::dsl::walker::input_verdict_in_mode(
|
||||
&self.input,
|
||||
Some(&self.schema_cache),
|
||||
mode,
|
||||
)
|
||||
// Strip the `:` one-shot prefix so the walker verdicts the SQL
|
||||
// itself, not the escape marker (which it can't parse).
|
||||
let (view, _cursor, _offset) = self.feedback_view();
|
||||
crate::dsl::walker::input_verdict_in_mode(view, Some(&self.schema_cache), mode)
|
||||
}
|
||||
|
||||
/// Process one event from the runtime, mutating state and
|
||||
@@ -1399,13 +1436,7 @@ impl App {
|
||||
}
|
||||
|
||||
fn start_or_complete_at(&mut self, multi_start_idx: usize) {
|
||||
let cursor = self.input_cursor.min(self.input.len());
|
||||
let Some(comp) = crate::completion::candidates_at_cursor_in_mode(
|
||||
&self.input,
|
||||
cursor,
|
||||
&self.schema_cache,
|
||||
self.effective_mode().as_mode(),
|
||||
) else {
|
||||
let Some(comp) = self.completion_for_feedback() else {
|
||||
return;
|
||||
};
|
||||
if comp.candidates.len() == 1 {
|
||||
@@ -1417,13 +1448,7 @@ impl App {
|
||||
}
|
||||
|
||||
fn start_or_complete_last(&mut self) {
|
||||
let cursor = self.input_cursor.min(self.input.len());
|
||||
let Some(comp) = crate::completion::candidates_at_cursor_in_mode(
|
||||
&self.input,
|
||||
cursor,
|
||||
&self.schema_cache,
|
||||
self.effective_mode().as_mode(),
|
||||
) else {
|
||||
let Some(comp) = self.completion_for_feedback() else {
|
||||
return;
|
||||
};
|
||||
if comp.candidates.len() == 1 {
|
||||
@@ -1434,6 +1459,22 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Completion at the cursor, computed against the `:`-stripped
|
||||
/// feedback view (ADR-0003 one-shot) with its `replaced_range`
|
||||
/// mapped back to real-buffer coordinates so `commit_*` edit the
|
||||
/// right span. Identity for non-one-shot input (offset 0).
|
||||
fn completion_for_feedback(&self) -> Option<crate::completion::Completion> {
|
||||
let (view, view_cursor, offset) = self.feedback_view();
|
||||
let mut comp = crate::completion::candidates_at_cursor_in_mode(
|
||||
view,
|
||||
view_cursor.min(view.len()),
|
||||
&self.schema_cache,
|
||||
self.effective_mode().as_mode(),
|
||||
)?;
|
||||
comp.replaced_range = (comp.replaced_range.0 + offset, comp.replaced_range.1 + offset);
|
||||
Some(comp)
|
||||
}
|
||||
|
||||
/// Single-candidate commit: insert "<text> " (with trailing
|
||||
/// space) and DO NOT create a memo. The user can keep
|
||||
/// typing or press Tab again to fresh-complete at the new
|
||||
@@ -4976,6 +5017,86 @@ mod tests {
|
||||
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedPersistent);
|
||||
}
|
||||
|
||||
/// Build a two-table cache (`Orders(id, customer_id)` +
|
||||
/// `Customers(id, name)`) for the `:` one-shot SQL-feedback tests.
|
||||
fn install_join_schema(app: &mut App) {
|
||||
use crate::completion::TableColumn;
|
||||
use crate::dsl::types::Type;
|
||||
app.schema_cache.tables = vec!["Orders".into(), "Customers".into()];
|
||||
app.schema_cache.table_columns.insert(
|
||||
"Orders".into(),
|
||||
vec![TableColumn::new("id", Type::Serial), TableColumn::new("customer_id", Type::Int)],
|
||||
);
|
||||
app.schema_cache.table_columns.insert(
|
||||
"Customers".into(),
|
||||
vec![TableColumn::new("id", Type::Serial), TableColumn::new("name", Type::Text)],
|
||||
);
|
||||
for t in app.schema_cache.tables.clone() {
|
||||
for c in &app.schema_cache.table_columns[&t] {
|
||||
app.schema_cache.columns.push(c.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colon_one_shot_gives_sql_completion_the_stripped_view() {
|
||||
// Bug (manual testing): the `:` one-shot escape (ADR-0003) left
|
||||
// the leading `:` in the buffer passed to the live SQL feedback,
|
||||
// so the walker bailed at `:` and Tab completed nothing — while
|
||||
// the identical line in full `mode advanced` completed. Now the
|
||||
// feedback view strips the `:`, so both behave the same.
|
||||
let body = "select c.name from Orders o join Customers c on c.id=o.cu";
|
||||
|
||||
// Full advanced mode: completes `o.cu` → `o.customer_id`.
|
||||
let mut adv = App::new();
|
||||
adv.mode = Mode::Advanced;
|
||||
install_join_schema(&mut adv);
|
||||
type_str(&mut adv, body);
|
||||
adv.update(key(KeyCode::Tab));
|
||||
assert!(
|
||||
adv.input.ends_with("o.customer_id "),
|
||||
"full advanced should complete: {:?}",
|
||||
adv.input
|
||||
);
|
||||
|
||||
// `:` one-shot from simple mode: must complete the same way, and
|
||||
// the `:` prefix must be preserved in the buffer.
|
||||
let mut one = App::new();
|
||||
one.mode = Mode::Simple;
|
||||
install_join_schema(&mut one);
|
||||
one.update(key(KeyCode::Char(':')));
|
||||
type_str(&mut one, body);
|
||||
assert_eq!(one.effective_mode(), EffectiveMode::AdvancedOneShot);
|
||||
one.update(key(KeyCode::Tab));
|
||||
assert!(
|
||||
one.input.trim_start().starts_with(':'),
|
||||
"the `:` prefix is kept: {:?}",
|
||||
one.input
|
||||
);
|
||||
assert!(
|
||||
one.input.ends_with("o.customer_id "),
|
||||
"`:` one-shot must complete the SQL column too: {:?}",
|
||||
one.input
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colon_one_shot_validity_is_clean_for_a_valid_query() {
|
||||
// A *valid* `:`-prefixed query must not light the `[ERR]`
|
||||
// indicator (the walker used to choke on the `:` and always
|
||||
// report Error).
|
||||
let mut app = App::new();
|
||||
install_join_schema(&mut app);
|
||||
app.update(key(KeyCode::Char(':')));
|
||||
type_str(&mut app, "select name from Customers");
|
||||
assert_eq!(
|
||||
app.input_validity_verdict(),
|
||||
None,
|
||||
"a valid one-shot query should verdict clean, got {:?}",
|
||||
app.input_validity_verdict(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_mode_flips_to_one_shot_when_colon_typed_in_simple_mode() {
|
||||
let mut app = App::new();
|
||||
|
||||
Reference in New Issue
Block a user