diff --git a/src/completion.rs b/src/completion.rs index f09d23e..8aac39d 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -146,6 +146,22 @@ pub fn candidates_at_cursor( 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 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('`')) } +/// 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 { + 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 /// list contains nothing matching the prefix (ADR-0022 stage 8e /// + the user's #5). @@ -680,6 +743,100 @@ mod tests { // need a separate probe mechanism (deferred — same shape as // 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] fn show_offers_data_and_table_alphabetised() { let cs = cands("show ", 5); diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index 25c8452..bc5b989 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -133,6 +133,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ &["kind", "found"], ), ("hint.ambient_typing_name", &[]), + ("hint.value_literal_slot", &[]), ( "hint.ambient_typing_name_then", &["next"], diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index ff13bec..e084bd4 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -282,6 +282,13 @@ hint: # falls through to `ambient_typing_name` instead. ambient_typing_name: "Type a name" 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: # Wrapper around chumsky's structural error message. The diff --git a/src/input_render.rs b/src/input_render.rs index 962e7bf..6ec998d 100644 --- a/src/input_render.rs +++ b/src/input_render.rs @@ -188,6 +188,13 @@ pub fn ambient_hint( 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 // "type a name" hint rather than the technical "next: …" // that the post-consumed-partial parse would otherwise