ADR-0022 stage 4/8: render-time parse + error overlay

Add `classify_input(&str) -> InputState` that returns one of
{Empty, Valid, IncompleteAtEof, DefiniteErrorAt(byte)}.
The renderer uses this to overlay tok_error on the failing
token of mid-typed input that can never be valid.

ParseError::Invalid gains an `at_eof: bool` field populated
by `into_parse_error`:
  - structural failures: at_eof = found.is_none()
    (chumsky's own "ran out of input" discriminator);
  - custom errors from try_map: at_eof = true,
    conservatively.

The conservative custom-error classification is a deliberate
under-highlighting bias. It means three classes of error
currently DO NOT get a live red overlay (only on submit):
  - "tables need at least one column" (correct: this is
    genuinely an incomplete state — adding `with pk ...` fixes it);
  - "unknown type 'varchar'" (sub-optimal: should overlay);
  - "--force-conversion and --dont-convert are mutually
    exclusive" (sub-optimal: should overlay).
The trade-off is documented inline on the at_eof field. A
future refinement could carry an explicit definite/incomplete
tag through Custom errors (would change RichReason::Custom's
payload from String to a typed value).

render_input_runs now applies the overlay on the failing
token's run before injecting the cursor. Tokens after the
error keep their lex-class colour — fixes one thing at a
time per ADR-0022 §4. Lex errors continue to render in
tok_error from stage 2.

Pattern-matches on ParseError::Invalid throughout the
codebase use `..` and are unaffected; only the two
constructions in parser.rs needed updating.

Tests: 693 passing, 0 failing, 1 ignored (683 baseline →
+10: 7 classify + overlay tests, +1 adapted full-command
test, +2 valid-vs-incomplete coverage). Clippy clean.

Stage 5 lights up the hint panel as the verbose-feedback
surface — needs the InputState classifier from this stage.
This commit is contained in:
claude@clouddev1
2026-05-10 17:37:50 +00:00
parent 39da399add
commit 313d4f8346
2 changed files with 228 additions and 15 deletions
+45 -2
View File
@@ -25,7 +25,27 @@ use crate::dsl::value::Value;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ParseError {
#[error("could not parse command: {message}")]
Invalid { message: String, position: usize },
Invalid {
message: String,
position: usize,
/// True when the parse failed because more input was
/// expected — i.e. a structural failure with no
/// next-token to point at. Used by the input renderer
/// (ADR-0022 §4) to distinguish "incomplete but
/// plausible" from "definite error" mid-typing.
///
/// Custom errors raised by `try_map` are conservatively
/// classified as `at_eof = true` because we cannot, at
/// this layer, tell apart "tables need at least one
/// column" (incomplete: more input would help) from
/// "--force-conversion and --dont-convert are mutually
/// exclusive" (definite: user must remove a token).
/// Erring on `true` means custom-error inputs do not
/// get a live error overlay; the parse error still
/// fires on submit. A future refinement may carry an
/// explicit `is_definite` tag through custom errors.
at_eof: bool,
},
#[error("empty input")]
Empty,
}
@@ -38,6 +58,14 @@ impl ParseError {
Self::Empty => None,
}
}
#[must_use]
pub const fn at_eof(&self) -> bool {
match self {
Self::Invalid { at_eof, .. } => *at_eof,
Self::Empty => true,
}
}
}
/// Parse a single DSL command end-to-end.
@@ -103,6 +131,7 @@ fn try_parse_replay_with_bare_path(
return Some(Err(ParseError::Invalid {
message: "expected a path after `replay`".to_string(),
position: after_replay,
at_eof: true,
}));
}
Some(Ok(Command::Replay {
@@ -657,7 +686,21 @@ 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);
ParseError::Invalid { message, position }
let at_eof = match chosen.reason() {
// Structural failures know whether they ran out of
// input — `found = None` ⇔ EOF.
RichReason::ExpectedFound { found, .. } => found.is_none(),
// 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,
};
ParseError::Invalid {
message,
position,
at_eof,
}
}
/// Translate a chumsky token-slice index into a byte position