ADR-0022 stage 5/8: hint panel ambient typing assistance
ParseError::Invalid gains an `expected: Vec<String>` field —
the human-rendered names of the patterns chumsky was looking
for at the failure point (`\`create\``, `identifier`, etc.).
Empty for custom errors, which have no expected-set framing.
Populated by a new `describe_expected()` helper in parser.rs
that humanise() also delegates to (eliminates duplication).
`input_render::ambient_hint(input) -> Option<String>` returns
the hint-panel content per ADR-0022 §6:
- empty input → None (caller falls back to panel.hint_empty);
- Valid → t!("hint.ambient_complete") ("submit with Enter");
- IncompleteAtEof → t!("hint.ambient_expected", expected = …)
listing the parser's expected next tokens, oxford-joined;
- DefiniteErrorAt → t!("hint.ambient_error_with_usage", …)
composing the parse-error message with the matching
parse.usage.* template if a known entry keyword was
consumed, else the bare message.
Catalog gains the three hint.ambient_* keys + validator
declarations.
ui::render_hint_panel resolution order:
1. explicit app.hint (modal contexts) wins;
2. simple-mode + non-empty input → ambient_hint;
3. fallback to panel.hint_empty.
Advanced mode (persistent + one-shot `:`) bypasses ambient
hinting per ADR-0022 §12.
Snapshot: highlighted_input_all_token_classes rebaselined
because the hint panel now displays an ambient hint instead
of the empty placeholder when input is non-empty.
Tests: 698 passing, 0 failing, 1 ignored (693 baseline →
+5 ambient_hint cases). Clippy clean.
Stage 6 introduces the IdentSlot taxonomy + parser audit so
identifier-typed slots can yield schema-aware completion
candidates in stage 8.
This commit is contained in:
+46
-26
@@ -45,6 +45,15 @@ pub enum ParseError {
|
|||||||
/// fires on submit. A future refinement may carry an
|
/// fires on submit. A future refinement may carry an
|
||||||
/// explicit `is_definite` tag through custom errors.
|
/// explicit `is_definite` tag through custom errors.
|
||||||
at_eof: bool,
|
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<String>,
|
||||||
},
|
},
|
||||||
#[error("empty input")]
|
#[error("empty input")]
|
||||||
Empty,
|
Empty,
|
||||||
@@ -132,6 +141,7 @@ fn try_parse_replay_with_bare_path(
|
|||||||
message: "expected a path after `replay`".to_string(),
|
message: "expected a path after `replay`".to_string(),
|
||||||
position: after_replay,
|
position: after_replay,
|
||||||
at_eof: true,
|
at_eof: true,
|
||||||
|
expected: vec!["path".to_string()],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Some(Ok(Command::Replay {
|
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 chumsky_span = chosen.span();
|
||||||
let position = source_position_at(tokens, chumsky_span.start, source);
|
let position = source_position_at(tokens, chumsky_span.start, source);
|
||||||
let message = humanise(chosen, tokens, 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
|
// Structural failures know whether they ran out of
|
||||||
// input — `found = None` ⇔ EOF.
|
// input — `found = None` ⇔ EOF — and carry the
|
||||||
RichReason::ExpectedFound { found, .. } => found.is_none(),
|
// expected-pattern set chumsky was looking for.
|
||||||
|
RichReason::ExpectedFound { expected, found } => {
|
||||||
|
(found.is_none(), describe_expected(expected))
|
||||||
|
}
|
||||||
// Custom errors: see the docstring on
|
// Custom errors: see the docstring on
|
||||||
// `ParseError::Invalid::at_eof` for why we err on the
|
// `ParseError::Invalid::at_eof` for why we err on the
|
||||||
// side of `true` (no live overlay; on-submit error
|
// side of `true` (no live overlay; on-submit error
|
||||||
// still fires).
|
// still fires). Custom errors have no expected-set.
|
||||||
RichReason::Custom(_) => true,
|
RichReason::Custom(_) => (true, Vec::new()),
|
||||||
};
|
};
|
||||||
ParseError::Invalid {
|
ParseError::Invalid {
|
||||||
message,
|
message,
|
||||||
position,
|
position,
|
||||||
at_eof,
|
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<String> {
|
||||||
|
let has_concrete = expected.iter().any(|p| {
|
||||||
|
matches!(
|
||||||
|
p,
|
||||||
|
RichPattern::Token(_)
|
||||||
|
| RichPattern::Identifier(_)
|
||||||
|
| RichPattern::Label(_)
|
||||||
|
| RichPattern::EndOfInput
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let mut items: Vec<String> = 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
|
/// Translate a chumsky token-slice index into a byte position
|
||||||
/// in the original source. If the index points past the last
|
/// in the original source. If the index points past the last
|
||||||
/// token (an end-of-input failure), use the last token's end
|
/// 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),
|
|maybe_ref| describe_token(maybe_ref),
|
||||||
);
|
);
|
||||||
|
|
||||||
// If the expected set contains concrete patterns (token,
|
let described = describe_expected(expected);
|
||||||
// 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<String> = expected
|
|
||||||
.iter()
|
|
||||||
.filter(|p| {
|
|
||||||
!(has_concrete && matches!(p, RichPattern::Any | RichPattern::SomethingElse))
|
|
||||||
})
|
|
||||||
.map(describe_pattern)
|
|
||||||
.collect();
|
|
||||||
described.sort();
|
|
||||||
described.dedup();
|
|
||||||
let expected_str = oxford_or(&described);
|
let expected_str = oxford_or(&described);
|
||||||
|
|
||||||
let chumsky_span_start = err.span().start;
|
let chumsky_span_start = err.span().start;
|
||||||
|
|||||||
@@ -121,6 +121,13 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
// ---- Help text ----
|
// ---- Help text ----
|
||||||
("help.cli_banner", &[]),
|
("help.cli_banner", &[]),
|
||||||
("help.in_app_body", &[]),
|
("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 error rendering ----
|
||||||
("parse.available_commands", &["commands"]),
|
("parse.available_commands", &["commands"]),
|
||||||
("parse.caret", &["padding"]),
|
("parse.caret", &["padding"]),
|
||||||
|
|||||||
@@ -250,6 +250,15 @@ help:
|
|||||||
existing/null cells in the same operation.
|
existing/null cells in the same operation.
|
||||||
|
|
||||||
# ---- DSL parse error rendering --------------------------------------
|
# ---- 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:
|
parse:
|
||||||
# Wrapper around chumsky's structural error message. The
|
# Wrapper around chumsky's structural error message. The
|
||||||
# caret pointer (visualising the failure column) is printed
|
# caret pointer (visualising the failure column) is printed
|
||||||
|
|||||||
@@ -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<String> {
|
||||||
|
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) {
|
fn overlay_error(runs: &mut [StyledRun], error_byte: usize, theme: &Theme) {
|
||||||
// Failing tokens have their byte_range starting exactly at
|
// Failing tokens have their byte_range starting exactly at
|
||||||
// `error_byte`. Override the fg colour while preserving any
|
// `error_byte`. Override the fg colour while preserving any
|
||||||
@@ -323,6 +390,59 @@ mod tests {
|
|||||||
assert!(reversed(last));
|
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 <Table>"),
|
||||||
|
"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) ----
|
// ---- classify_input + error overlay (stage 4) ----
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ expression: snapshot
|
|||||||
│ ││insert into T values (1, 'hi', null) --all-rows $ │
|
│ ││insert into T values (1, 'hi', null) --all-rows $ │
|
||||||
│ │╰──────────────────────────────────────────────────╯
|
│ │╰──────────────────────────────────────────────────╯
|
||||||
│ │╭ Hint ────────────────────────────────────────────╮
|
│ │╭ Hint ────────────────────────────────────────────╮
|
||||||
│ ││(no active hint) │
|
│ ││after `insert into T values (1, 'hi', null)`, │
|
||||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||||
Project: Term Planner
|
Project: Term Planner
|
||||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||||
|
|||||||
@@ -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));
|
.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 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(
|
let paragraph = Paragraph::new(Line::from(Span::styled(
|
||||||
body.to_string(),
|
body,
|
||||||
Style::default().fg(theme.muted),
|
Style::default().fg(theme.muted),
|
||||||
)))
|
)))
|
||||||
.block(block)
|
.block(block)
|
||||||
|
|||||||
Reference in New Issue
Block a user