hint: show the matching usage template for multi-form commands

A parse error in `add index …` showed the `add column` usage:
`add` and `drop` are multi-form commands, and both the
ambient hint and the submit-time usage block picked the
first-listed form unconditionally.

New `grammar::usage_key_for_input` disambiguates by the form
word after the entry keyword — `column` / `index` / `table` /
`relationship`, or the leading digit of `add 1:n …`. The
ambient hint now shows that one form; `render_usage_block`
shows the committed form's usage and falls back to the whole
family only for a bare `add` / `drop` with no form chosen.
This commit is contained in:
claude@clouddev1
2026-05-19 08:37:17 +00:00
parent 5dc0421bd2
commit 151ed084a3
3 changed files with 71 additions and 3 deletions
+12 -1
View File
@@ -1812,7 +1812,18 @@ fn parse_error_message(err: &ParseError) -> String {
/// `drop` show every variant. Otherwise the fallback lists every /// `drop` show every variant. Otherwise the fallback lists every
/// entry keyword alphabetically. /// entry keyword alphabetically.
fn render_usage_block(input: &str) -> String { fn render_usage_block(input: &str) -> String {
if let Some((_word, catalog_keys)) = crate::dsl::grammar::usage_keys_for_input(input) { // A multi-form command that has committed to a form
// (`add index …`) shows only that form's usage; a bare
// multi-form entry word (`add`) shows the whole family.
let catalog_keys: Vec<&'static str> =
crate::dsl::grammar::usage_key_for_input(input)
.map(|key| vec![key])
.or_else(|| {
crate::dsl::grammar::usage_keys_for_input(input)
.map(|(_word, all)| all.to_vec())
})
.unwrap_or_default();
if !catalog_keys.is_empty() {
let mut out = String::from("usage:"); let mut out = String::from("usage:");
for key in catalog_keys { for key in catalog_keys {
let template = crate::friendly::translate(key, &[]); let template = crate::friendly::translate(key, &[]);
+36
View File
@@ -414,6 +414,42 @@ pub fn usage_keys_for_input(source: &str) -> Option<(&'static str, &'static [&'s
Some((node.entry.primary, node.usage_ids)) Some((node.entry.primary, node.usage_ids))
} }
/// The single usage template most relevant to `source`, when
/// one is determinable.
///
/// A single-form command resolves to its one usage key. A
/// multi-form command (`add`, `drop`) disambiguates by the
/// form word after the entry keyword — so a parse error in
/// `add index …` resolves to the `add index` usage rather than
/// the first-listed `add column`. Returns `None` for a bare
/// multi-form entry word (`add` with nothing after it), where
/// no form has been chosen — the caller decides whether to
/// show the whole family or nothing.
#[must_use]
pub fn usage_key_for_input(source: &str) -> Option<&'static str> {
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
let (_entry, keys) = usage_keys_for_input(source)?;
let first = *keys.first()?;
if keys.len() == 1 {
return Some(first);
}
// Multi-form: the form is named by the token right after
// the entry keyword.
let start = skip_whitespace(source, 0);
let (_, entry_end) = consume_ident(source, start)?;
let after = skip_whitespace(source, entry_end);
// The `add 1:n relationship` form opens with a digit.
if source.as_bytes().get(after).is_some_and(u8::is_ascii_digit) {
return keys.iter().copied().find(|k| k.ends_with("relationship"));
}
// Otherwise the form word is an identifier — `column`,
// `index`, `table`, `relationship` — matched against the
// usage key's suffix.
let (s, e) = consume_ident(source, after)?;
let form = source[s..e].to_ascii_lowercase();
keys.iter().copied().find(|k| k.ends_with(form.as_str()))
}
/// Every command-entry word in the registry, sorted alphabetically /// Every command-entry word in the registry, sorted alphabetically
/// by primary literal. Replaces `dsl::usage::entry_keywords_alphabetised` /// by primary literal. Replaces `dsl::usage::entry_keywords_alphabetised`
/// which read the same data through the legacy `usage::REGISTRY`. /// which read the same data through the legacy `usage::REGISTRY`.
+23 -2
View File
@@ -341,8 +341,10 @@ pub fn ambient_hint(
} }
} else { } else {
let _ = position; let _ = position;
let usage = crate::dsl::grammar::usage_keys_for_input(input) // The form the user has committed to drives the
.and_then(|(_, keys)| keys.first().copied()) // usage template — `add index …` shows the
// `add index` usage, not the first `add` form.
let usage = crate::dsl::grammar::usage_key_for_input(input)
.map(|key| crate::friendly::translate(key, &[])); .map(|key| crate::friendly::translate(key, &[]));
Some(AmbientHint::Prose(match usage { Some(AmbientHint::Prose(match usage {
Some(u) => crate::t!( Some(u) => crate::t!(
@@ -876,6 +878,25 @@ mod tests {
assert_eq!(cs, vec!["data".to_string(), "table".to_string()]); assert_eq!(cs, vec!["data".to_string(), "table".to_string()]);
} }
#[test]
fn ambient_hint_usage_matches_the_add_form_typed() {
// A trailing-junk error after `add index …` must show
// the `add index` usage — `add` is a multi-form
// command and the hint used to always show the first
// form (`add column`).
let input = "add index on Customers (barg):";
let h = prose(input, input.len()).expect("prose hint");
assert!(h.contains("usage:"), "got {h:?}");
assert!(
h.contains("add index"),
"should show the `add index` usage, got {h:?}",
);
assert!(
!h.contains("add column"),
"should not show the `add column` usage, got {h:?}",
);
}
#[test] #[test]
fn ambient_hint_for_definite_error_includes_usage_template() { fn ambient_hint_for_definite_error_includes_usage_template() {
let h = prose("insert into T extra", 19).expect("prose hint"); let h = prose("insert into T extra", 19).expect("prose hint");