hint: replace misleading "null true false" suggestions at value slots
At value-literal slots (`insert into T values (`, `update T set
col=`, `where col=`, comma positions) the expected-token set
contains null/true/false/number/string-literal. The completion
engine was surfacing the three keyword candidates as Tab options
— actively misleading because the user is usually about to enter
a number, quoted text, or date, and seeing "null true false"
implies those are *the* options. User report (round-6 testing):
"especially not when I'm trying to insert a datetime value and
don't know the correct format for the literal".
Fix: detect the value-literal slot by its expected-set
fingerprint. Suppress Tab candidates at empty prefix. Surface a
prose hint listing all literal forms with format examples
('YYYY-MM-DD' for dates, 'YYYY-MM-DDTHH:MM:SS' for datetimes).
Once the user starts typing a prefix (n / tr / fa), normal
keyword completion still applies.
Schema-aware narrowing (show ONLY the datetime format at a
datetime column) waits on ADR-0023.
Tests: 769 -> 777 passing (+8). Clippy clean.
This commit is contained in:
@@ -146,6 +146,22 @@ pub fn candidates_at_cursor(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Value-literal slot: at an empty-prefix position the only
|
||||||
|
// candidates we'd surface are `null`/`true`/`false` (the
|
||||||
|
// keyword literals — number / string-literal slots are
|
||||||
|
// descriptive labels, not Tab candidates). Surfacing those
|
||||||
|
// three is actively misleading — the user usually wants a
|
||||||
|
// number, a quoted string, or a date, and seeing just
|
||||||
|
// "null true false" implies those are *the* options. We
|
||||||
|
// suppress the keyword candidates here; the ambient_hint
|
||||||
|
// ladder falls through to a prose hint with format
|
||||||
|
// examples instead. Once the user starts typing a prefix
|
||||||
|
// (`n`, `tr`, `fa`) the normal keyword-completion path
|
||||||
|
// applies — the suppression only triggers at empty prefix.
|
||||||
|
if partial_prefix.is_empty() && is_value_literal_signature(&expected) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let lowered_prefix = partial_prefix.to_lowercase();
|
let lowered_prefix = partial_prefix.to_lowercase();
|
||||||
let matches_prefix = |s: &str| s.to_lowercase().starts_with(&lowered_prefix);
|
let matches_prefix = |s: &str| s.to_lowercase().starts_with(&lowered_prefix);
|
||||||
|
|
||||||
@@ -290,6 +306,53 @@ fn strip_backticks(s: &str) -> Option<&str> {
|
|||||||
s.strip_prefix('`').and_then(|s| s.strip_suffix('`'))
|
s.strip_prefix('`').and_then(|s| s.strip_suffix('`'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect a value-literal expected-set signature. A value-literal
|
||||||
|
/// slot is the only position where chumsky's expected-set
|
||||||
|
/// simultaneously contains all five forms `null` / `true` /
|
||||||
|
/// `false` / number / string literal. See the suppression rationale
|
||||||
|
/// at the call site in `candidates_at_cursor`.
|
||||||
|
fn is_value_literal_signature(expected: &[String]) -> bool {
|
||||||
|
let has = |needle: &str| expected.iter().any(|e| e == needle);
|
||||||
|
has("`null`") && has("`true`") && has("`false`") && has("number") && has("string literal")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Some(prose)` when the cursor sits at an empty-prefix value-literal slot.
|
||||||
|
///
|
||||||
|
/// The hint panel surfaces format guidance (number, quoted text,
|
||||||
|
/// date, datetime, bool, null) instead of the misleading "null
|
||||||
|
/// true false" keyword-only candidate list.
|
||||||
|
///
|
||||||
|
/// Note: this is a stopgap until ADR-0023 lands schema-aware
|
||||||
|
/// completion (which would surface format examples specific to
|
||||||
|
/// the column's type — e.g. just the datetime format at a
|
||||||
|
/// datetime column). Today the hint lists all valid literal
|
||||||
|
/// shapes regardless of context.
|
||||||
|
#[must_use]
|
||||||
|
pub fn value_literal_hint_at_cursor(input: &str, cursor: usize) -> Option<String> {
|
||||||
|
let cursor = cursor.min(input.len());
|
||||||
|
let bytes = input.as_bytes();
|
||||||
|
let mut start = cursor;
|
||||||
|
while start > 0 {
|
||||||
|
let prev = bytes[start - 1];
|
||||||
|
if prev.is_ascii_alphanumeric() || prev == b'_' {
|
||||||
|
start -= 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start != cursor {
|
||||||
|
// Partial prefix is non-empty — keyword completion
|
||||||
|
// handles it (e.g. `n` → `null`).
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let leading = &input[..start];
|
||||||
|
let expected = expected_set(leading);
|
||||||
|
if !is_value_literal_signature(&expected) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(crate::t!("hint.value_literal_slot"))
|
||||||
|
}
|
||||||
|
|
||||||
/// What the user has typed in an identifier slot whose schema
|
/// What the user has typed in an identifier slot whose schema
|
||||||
/// list contains nothing matching the prefix (ADR-0022 stage 8e
|
/// list contains nothing matching the prefix (ADR-0022 stage 8e
|
||||||
/// + the user's #5).
|
/// + the user's #5).
|
||||||
@@ -680,6 +743,100 @@ mod tests {
|
|||||||
// need a separate probe mechanism (deferred — same shape as
|
// need a separate probe mechanism (deferred — same shape as
|
||||||
// the post-complete-parse gap for `--create-fk` etc.).
|
// the post-complete-parse gap for `--create-fk` etc.).
|
||||||
|
|
||||||
|
// ---- Value-literal slot suppression (round-6) -----------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_literal_slot_suppresses_keyword_candidates_at_empty_prefix() {
|
||||||
|
// After `insert into T values (` the parser's expected
|
||||||
|
// set contains null/true/false/number/string literal.
|
||||||
|
// The keyword pipeline would otherwise surface `null`,
|
||||||
|
// `true`, `false` as Tab candidates — actively
|
||||||
|
// misleading at a slot where the user is more likely
|
||||||
|
// entering a number / text / date. Suppress.
|
||||||
|
let cs = cands("insert into T values (", 22);
|
||||||
|
assert!(cs.is_empty(), "got misleading candidates {cs:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_literal_slot_with_partial_prefix_still_completes() {
|
||||||
|
// Once the user types a prefix, normal keyword
|
||||||
|
// completion applies — `n` → `null`, `tr` → `true`,
|
||||||
|
// `fa` → `false`.
|
||||||
|
assert_eq!(
|
||||||
|
cands("insert into T values (n", 23),
|
||||||
|
vec!["null".to_string()],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cands("insert into T values (tr", 24),
|
||||||
|
vec!["true".to_string()],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cands("insert into T values (fa", 24),
|
||||||
|
vec!["false".to_string()],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_literal_slot_after_first_value_also_suppresses() {
|
||||||
|
// Comma-separated value positions all hit the same slot
|
||||||
|
// signature. `insert into T values (1, ` → expected:
|
||||||
|
// null/true/false/number/string. Suppress.
|
||||||
|
let cs = cands("insert into T values (1, ", 25);
|
||||||
|
assert!(cs.is_empty(), "got {cs:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_set_value_slot_suppresses() {
|
||||||
|
// `update T set col=` is also a value-literal slot.
|
||||||
|
let cs = cands("update T set col=", 17);
|
||||||
|
assert!(cs.is_empty(), "got {cs:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn where_value_slot_suppresses() {
|
||||||
|
// `where col=` is also a value-literal slot.
|
||||||
|
let cs = cands("delete from T where col=", 24);
|
||||||
|
assert!(cs.is_empty(), "got {cs:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_literal_hint_fires_at_empty_value_slot() {
|
||||||
|
let hint = value_literal_hint_at_cursor("insert into T values (", 22);
|
||||||
|
let s = hint.expect("hint should fire at value-literal slot");
|
||||||
|
// Lists each literal form so the user sees the full set
|
||||||
|
// of valid inputs rather than just three keywords.
|
||||||
|
assert!(s.contains("number"), "got {s:?}");
|
||||||
|
assert!(s.contains("text") || s.contains("'"), "got {s:?}");
|
||||||
|
assert!(s.contains("true"), "got {s:?}");
|
||||||
|
assert!(s.contains("false") || s.contains("/false"), "got {s:?}");
|
||||||
|
assert!(s.contains("null"), "got {s:?}");
|
||||||
|
// Format examples for the cases users typically can't
|
||||||
|
// guess (date, datetime).
|
||||||
|
assert!(
|
||||||
|
s.contains("YYYY-MM-DD"),
|
||||||
|
"should include date format, got {s:?}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
s.contains("HH:MM:SS"),
|
||||||
|
"should include datetime format, got {s:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_literal_hint_does_not_fire_at_partial_prefix() {
|
||||||
|
// With a partial prefix the keyword-completion path
|
||||||
|
// handles it; the prose hint short-circuit only
|
||||||
|
// applies to empty-prefix positions.
|
||||||
|
assert!(value_literal_hint_at_cursor("insert into T values (n", 23).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_literal_hint_does_not_fire_at_keyword_slot() {
|
||||||
|
// Entry keyword position is not a value-literal slot.
|
||||||
|
assert!(value_literal_hint_at_cursor("", 0).is_none());
|
||||||
|
assert!(value_literal_hint_at_cursor("insert ", 7).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn show_offers_data_and_table_alphabetised() {
|
fn show_offers_data_and_table_alphabetised() {
|
||||||
let cs = cands("show ", 5);
|
let cs = cands("show ", 5);
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
&["kind", "found"],
|
&["kind", "found"],
|
||||||
),
|
),
|
||||||
("hint.ambient_typing_name", &[]),
|
("hint.ambient_typing_name", &[]),
|
||||||
|
("hint.value_literal_slot", &[]),
|
||||||
(
|
(
|
||||||
"hint.ambient_typing_name_then",
|
"hint.ambient_typing_name_then",
|
||||||
&["next"],
|
&["next"],
|
||||||
|
|||||||
@@ -282,6 +282,13 @@ hint:
|
|||||||
# falls through to `ambient_typing_name` instead.
|
# falls through to `ambient_typing_name` instead.
|
||||||
ambient_typing_name: "Type a name"
|
ambient_typing_name: "Type a name"
|
||||||
ambient_typing_name_then: "Type a name, then {next}"
|
ambient_typing_name_then: "Type a name, then {next}"
|
||||||
|
# Value-literal slot — `insert ... values (`, `update ... set
|
||||||
|
# col=`, `where col=`. Replaces the misleading "null true
|
||||||
|
# false" keyword candidate list with format guidance for all
|
||||||
|
# accepted literal forms. Schema-aware narrowing (showing only
|
||||||
|
# the relevant format for the column's type) waits on
|
||||||
|
# ADR-0023.
|
||||||
|
value_literal_slot: "Type a value: number, 'text', true/false, null (dates as 'YYYY-MM-DD', datetimes as 'YYYY-MM-DDTHH:MM:SS')"
|
||||||
|
|
||||||
parse:
|
parse:
|
||||||
# Wrapper around chumsky's structural error message. The
|
# Wrapper around chumsky's structural error message. The
|
||||||
|
|||||||
@@ -188,6 +188,13 @@ pub fn ambient_hint(
|
|||||||
selected: None,
|
selected: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Value-literal slot at empty prefix: candidates_at_cursor
|
||||||
|
// returned None (it suppresses null/true/false here to avoid
|
||||||
|
// implying they're the only options). Surface a prose hint
|
||||||
|
// that names all valid literal forms with format examples.
|
||||||
|
if let Some(prose) = crate::completion::value_literal_hint_at_cursor(input, cursor) {
|
||||||
|
return Some(AmbientHint::Prose(prose));
|
||||||
|
}
|
||||||
// User typing into a NewName slot — show the friendlier
|
// User typing into a NewName slot — show the friendlier
|
||||||
// "type a name" hint rather than the technical "next: …"
|
// "type a name" hint rather than the technical "next: …"
|
||||||
// that the post-consumed-partial parse would otherwise
|
// that the post-consumed-partial parse would otherwise
|
||||||
|
|||||||
Reference in New Issue
Block a user