diff --git a/src/dsl/parser.rs b/src/dsl/parser.rs index df4aef6..7218619 100644 --- a/src/dsl/parser.rs +++ b/src/dsl/parser.rs @@ -45,6 +45,15 @@ pub enum ParseError { /// fires on submit. A future refinement may carry an /// explicit `is_definite` tag through custom errors. at_eof: bool, + /// Human-rendered names of patterns the parser was + /// looking for at the failure point: `\`create\``, + /// `identifier`, etc. Same forms `humanise()` uses + /// inside the `message` sentence, but as discrete + /// items so callers (the hint panel, ADR-0022 §6) + /// can render them in their own framing. Empty for + /// custom errors (which have no expected-set + /// framing). + expected: Vec, }, #[error("empty input")] Empty, @@ -132,6 +141,7 @@ fn try_parse_replay_with_bare_path( message: "expected a path after `replay`".to_string(), position: after_replay, at_eof: true, + expected: vec!["path".to_string()], })); } Some(Ok(Command::Replay { @@ -686,23 +696,53 @@ fn into_parse_error(errs: &[Rich<'_, Token>], tokens: &[Token], source: &str) -> let chumsky_span = chosen.span(); let position = source_position_at(tokens, chumsky_span.start, source); let message = humanise(chosen, tokens, source); - let at_eof = match chosen.reason() { + let (at_eof, expected) = match chosen.reason() { // Structural failures know whether they ran out of - // input — `found = None` ⇔ EOF. - RichReason::ExpectedFound { found, .. } => found.is_none(), + // input — `found = None` ⇔ EOF — and carry the + // expected-pattern set chumsky was looking for. + RichReason::ExpectedFound { expected, found } => { + (found.is_none(), describe_expected(expected)) + } // Custom errors: see the docstring on // `ParseError::Invalid::at_eof` for why we err on the // side of `true` (no live overlay; on-submit error - // still fires). - RichReason::Custom(_) => true, + // still fires). Custom errors have no expected-set. + RichReason::Custom(_) => (true, Vec::new()), }; ParseError::Invalid { message, position, at_eof, + expected, } } +/// Render a chumsky expected-pattern set into the same +/// human-readable forms `humanise()` uses, but as discrete +/// items rather than an oxford-joined string. Stable order +/// (sorted, deduplicated) so callers don't have to. +fn describe_expected(expected: &[RichPattern<'_, Token>]) -> Vec { + let has_concrete = expected.iter().any(|p| { + matches!( + p, + RichPattern::Token(_) + | RichPattern::Identifier(_) + | RichPattern::Label(_) + | RichPattern::EndOfInput + ) + }); + let mut items: Vec = expected + .iter() + .filter(|p| { + !(has_concrete && matches!(p, RichPattern::Any | RichPattern::SomethingElse)) + }) + .map(describe_pattern) + .collect(); + items.sort(); + items.dedup(); + items +} + /// Translate a chumsky token-slice index into a byte position /// in the original source. If the index points past the last /// token (an end-of-input failure), use the last token's end @@ -728,27 +768,7 @@ fn humanise(err: &Rich<'_, Token>, tokens: &[Token], source: &str) -> String { |maybe_ref| describe_token(maybe_ref), ); - // If the expected set contains concrete patterns (token, - // identifier, label), drop the generic Any/SomethingElse - // wildcards — they add noise, not information. - let has_concrete = expected.iter().any(|p| { - matches!( - p, - RichPattern::Token(_) - | RichPattern::Identifier(_) - | RichPattern::Label(_) - | RichPattern::EndOfInput - ) - }); - let mut described: Vec = expected - .iter() - .filter(|p| { - !(has_concrete && matches!(p, RichPattern::Any | RichPattern::SomethingElse)) - }) - .map(describe_pattern) - .collect(); - described.sort(); - described.dedup(); + let described = describe_expected(expected); let expected_str = oxford_or(&described); let chumsky_span_start = err.span().start; diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index 69c020f..e52b0b2 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -121,6 +121,13 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ // ---- Help text ---- ("help.cli_banner", &[]), ("help.in_app_body", &[]), + // ---- Hint panel ambient typing assistance (ADR-0022 §6) ---- + ("hint.ambient_complete", &[]), + ( + "hint.ambient_error_with_usage", + &["message", "usage"], + ), + ("hint.ambient_expected", &["expected"]), // ---- Parse error rendering ---- ("parse.available_commands", &["commands"]), ("parse.caret", &["padding"]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 502c590..830dae6 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -250,6 +250,15 @@ help: existing/null cells in the same operation. # ---- DSL parse error rendering -------------------------------------- +# ---- Hint panel ambient typing assistance (ADR-0022 §6) ------------- +hint: + # The hint panel goes ambient as soon as the user types + # anything — empty input keeps the existing + # `panel.hint_empty` content. + ambient_complete: "submit with Enter" + ambient_expected: "expected: {expected}" + ambient_error_with_usage: "{message} — usage: {usage}" + parse: # Wrapper around chumsky's structural error message. The # caret pointer (visualising the failure column) is printed diff --git a/src/input_render.rs b/src/input_render.rs index 310e5ce..7b05f34 100644 --- a/src/input_render.rs +++ b/src/input_render.rs @@ -113,6 +113,73 @@ pub fn classify_input(input: &str) -> InputState { } } +/// Ambient hint-panel content for the user's current input +/// (ADR-0022 §6). Returns `None` for empty input — the caller +/// then falls back to the existing `panel.hint_empty` content. +/// +/// One of three sub-states: +/// - **Valid** — `t!("hint.ambient_complete")` (e.g. "submit +/// with Enter"). +/// - **IncompleteAtEof** — `t!("hint.ambient_expected", …)` +/// listing what the parser was looking for next. +/// - **DefiniteErrorAt** — `t!("hint.ambient_error_with_usage", …)` +/// composing the parse-error message with the matching +/// `parse.usage.*` template if a known command-entry +/// keyword was consumed; falls back to the bare message +/// otherwise. +#[must_use] +pub fn ambient_hint(input: &str) -> Option { + if input.trim().is_empty() { + return None; + } + match parse_command(input) { + Ok(_) => Some(crate::t!("hint.ambient_complete")), + Err(ParseError::Empty) => None, + Err(ParseError::Invalid { + message, + position, + at_eof, + expected, + }) => { + if at_eof { + if expected.is_empty() { + Some(message) + } else { + let joined = oxford_or(&expected); + Some(crate::t!("hint.ambient_expected", expected = joined)) + } + } else { + let tokens = lex(input); + let usage = crate::dsl::usage::matched_entry(&tokens, position) + .and_then(|(_, keys)| keys.first().copied()) + .map(|key| crate::friendly::translate(key, &[])); + match usage { + Some(u) => Some(crate::t!( + "hint.ambient_error_with_usage", + message = message, + usage = u, + )), + None => Some(message), + } + } + } + } +} + +/// "A, B, or C" / "A or B" / "A". Local copy because the +/// parser's identical helper is private. +fn oxford_or(items: &[String]) -> String { + match items { + [] => String::new(), + [a] => a.clone(), + [a, b] => format!("{a} or {b}"), + rest => { + let (last, head) = rest.split_last().expect("len >= 3"); + format!("{}, or {last}", head.join(", ")) + } + } +} + fn overlay_error(runs: &mut [StyledRun], error_byte: usize, theme: &Theme) { // Failing tokens have their byte_range starting exactly at // `error_byte`. Override the fg colour while preserving any @@ -323,6 +390,59 @@ mod tests { assert!(reversed(last)); } + // ---- ambient_hint (stage 5) ---- + + #[test] + fn ambient_hint_is_none_for_empty_input() { + assert_eq!(ambient_hint(""), None); + assert_eq!(ambient_hint(" "), None); + } + + #[test] + fn ambient_hint_for_valid_input_invites_submit() { + let h = ambient_hint("create table T with pk").expect("some hint"); + assert!(h.contains("Enter"), "got {h:?}"); + } + + #[test] + fn ambient_hint_for_partial_keyword_lists_expected_set() { + let h = ambient_hint("show").expect("some hint"); + assert!(h.starts_with("expected:"), "got {h:?}"); + assert!(h.contains("`data`"), "got {h:?}"); + assert!(h.contains("`table`"), "got {h:?}"); + } + + #[test] + fn ambient_hint_for_definite_error_includes_usage_template() { + let h = ambient_hint("insert into T extra").expect("some hint"); + // Definite error after `insert into T` (parser expected + // values clause / column list / values keyword). + // Composes message with insert usage template. + assert!( + h.contains("usage:"), + "definite-error hint should include usage template, got {h:?}", + ); + assert!( + h.contains("insert into "), + "should reference the insert usage template, got {h:?}", + ); + } + + #[test] + fn ambient_hint_for_unknown_command_falls_back_to_message() { + // No consumed entry keyword → no usage template; the + // message itself is shown. + let h = ambient_hint("frobulate widgets").expect("some hint"); + assert!( + !h.contains("usage:"), + "no entry keyword consumed → no usage template; got {h:?}", + ); + assert!( + h.contains("frobulate"), + "message should mention the unknown word; got {h:?}", + ); + } + // ---- classify_input + error overlay (stage 4) ---- #[test] diff --git a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap index b0f7e56..1a55edb 100644 --- a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap @@ -22,7 +22,7 @@ expression: snapshot │ ││insert into T values (1, 'hi', null) --all-rows $ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Hint ────────────────────────────────────────────╮ -│ ││(no active hint) │ +│ ││after `insert into T values (1, 'hi', null)`, │ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/ui.rs b/src/ui.rs index feab6f9..29c7bf7 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -683,10 +683,26 @@ fn render_hint_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect )) .style(Style::default().bg(theme.bg).fg(theme.fg)); + // Resolution order for the hint panel body: + // 1. An explicit app-set hint (e.g. modal contexts) wins. + // 2. Otherwise, in simple mode with non-empty input, + // the ambient typing-assistance hint (ADR-0022 §6). + // 3. Otherwise, the existing empty-state placeholder. + // Advanced mode skips ambient hinting (ADR-0022 §12) — + // the DSL lexer/parser don't speak SQL. let empty_hint = crate::t!("panel.hint_empty"); - let body = app.hint.as_deref().unwrap_or(empty_hint.as_str()); + let ambient = match app.effective_mode() { + EffectiveMode::Simple => crate::input_render::ambient_hint(&app.input), + EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => None, + }; + let body: String = app + .hint + .as_deref() + .map(ToString::to_string) + .or(ambient) + .unwrap_or(empty_hint); let paragraph = Paragraph::new(Line::from(Span::styled( - body.to_string(), + body, Style::default().fg(theme.muted), ))) .block(block)