lean4-htt/tests/elab_fail/byStrictIndent.lean
Joachim Breitner 06fb4bec52
feat: require indentation in commands, allow empty tactic sequences (#13229)
This PR wraps the top-level command parser with `withPosition` to
enforce indentation in `by` blocks, combined with an empty-by fallback
for better error messages.

This subsumes #3215 (which introduced `withPosition commandParser` but
without the empty-by fallback). It is also related to #9524, which
explores elaboration with empty tactic sequences — this PR reuses that
idea for the empty-by fallback, so that a `by` not followed by an
indented tactic produces an elaboration error (unsolved goals) rather
than a parse error.

**Changes:**
- `topLevelCommandParserFn` now uses `(withPosition commandParser).fn`,
setting the saved position at the start of each top-level command
- `tacticSeqIndentGt` gains an empty tactic sequence fallback
(`pushNone`) so that missing indentation produces an elaboration error
(unsolved goals) instead of a parse error
- `isEmptyBy` in `goalsAt?` removed: with strict `by` indentation, empty
`by` blocks parse successfully via `pushNone` (producing empty nodes)
rather than producing `.missing` syntax, making the `isEmptyBy` check
dead code. The `isEmpty` helper in `isSyntheticTacticCompletion`
continues to work correctly because it handles both `.missing` and empty
nodes from `pushNone` (via the vacuously-true `args.all isEmpty` on
`#[]`)
- Test files updated to indent `by` blocks and expression continuations
that were previously at column 0

**Behavior:**
- Top-level `by` blocks now require indentation (column > 0 for commands
at column 0)
- Commands indented inside `section` require proofs to be indented past
the command's column
- `#guard_msgs in example : True := by` works because tactic indentation
is checked against the outermost command's column
- Expression continuations (not just `by`) must also be indented past
the command, which is slightly more strict but more consistent
- `have : True := by` followed by a dedent now correctly puts `this` in
scope in the outer tactic block (the `have` is structurally complete
with an unsolved-goal error, rather than a parse error)

**Code changes observed in practice (lean4 test suite + Mathlib):**

- `by` blocks: top-level `theorem ... := by` / `decreasing_by` followed
by tactics at column 0 must be indented
- `variable` continuations: `variable {A : Type*} [Foo A]\n{B : Type*}`
where the second line starts at column 0 must be indented (most common
category in Mathlib)
- Expression continuations: `def f : T :=\nexpr` or `#synth Foo\n[args]`
where the body/arguments start at column 0
- Structure literals: `.symm\n{ toFun := ...` where the struct literal
starts at column 0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 14:05:47 +00:00

99 lines
3.7 KiB
Text

-- Un-indented tactic after top-level `by` parses as empty `by` (unsolved goals)
-- followed by a command parse error for the stray tactic.
theorem byTopLevelBad (n : Nat) (h : n > 1) : n > 1 := by
exact h -- unsolved goals + unexpected identifier
-- Nested `by` without indentation: inner `by` is empty, `sorry` continues in outer scope.
theorem byNestedStrictIndent (n : Nat) (h : n > 0) : n > 1 := by
have helper : n > 1 := by
sorry -- part of outer `by`; inner `by` is empty (unsolved goals)
sorry
--
theorem byNestedBad₁ (n : Nat) (h : n > 0) : n > 1 := by
have helper : n > 1 := by
sorry
sorry -- expected command
sorry
theorem byNestedBad₂ (n : Nat) (h : n > 0) : n > 1 := by
have helper : n > 1 := by
sorry
sorry -- expected command
sorry
-- When stacking multiple `by` invocations on one line, the indentation of the
-- tactic block is checked against the innermost `withPosition` point (the column
-- of the innermost `have`/`let`/etc.), not against the outermost `by`.
-- So indenting past the outer `by` is not enough if the inner `have` is further right.
-- Good: each tactic block is indented past its respective `have`
theorem byStackedGood : True := by
have : True := by
exact trivial
exact this
-- Bad: tactic is indented past the outer `by` (column 0) but not past `have` (column 27)
-- Inner `by` is empty (unsolved goals), stray tactics produce command parse errors.
theorem byStackedBad₁ : True := by have : True := by
exact trivial -- unsolved goals + unexpected identifier
exact this
-- Bad: same issue with deeper stacking
theorem byStackedBad₂ : True := by have : True := by have : True := by
exact trivial -- unsolved goals + unexpected identifier
exact this
-- When `by` is inside a command that sets a position (like `#guard_msgs`),
-- `withPosition` at the command level checks against the command's column.
-- The `trivial` at column 2 must be > column of `#guard_msgs` (column 0).
#guard_msgs (drop info) in
example : True := by
trivial
-- Command-wrapping-command on one line: the proof needs to be indented
-- past the outer command (`#guard_msgs`, col 0), not the inner (`example`).
-- `#guard_msgs in` uses `commandParser` directly, not `topLevelCommandParserFn`,
-- so the inner command does not get its own `withPosition` scope.
#guard_msgs (drop info) in example : True := by
trivial
-- Same but with the inner command indented
#guard_msgs (drop info) in
example : True := by
trivial
-- Bad: tactic at column 0 inside #guard_msgs is not indented past the command
#guard_msgs (drop info) in
example : True := by
trivial -- unsolved goals + unexpected identifier
-- Bad: tactic at column 0 inside inline #guard_msgs
#guard_msgs (drop info) in example : True := by
trivial -- unsolved goals + unexpected identifier
-- Indented commands inside section: with `withPosition` at command level,
-- the position is saved at the command's column, not at column 0.
-- So proofs must be indented past the command, not just past column 0.
section
theorem indentedGood : True := by
trivial
end
-- Bad: tactic at column 2 is not indented past `theorem` (column 2)
section
theorem indentedBad : True := by
trivial -- unsolved goals + unexpected identifier
end
-- Indented non-tactic content: `123` doesn't parse as a tactic, so the
-- empty-by fallback fires (unsolved goals) and `123` is tried as a command.
-- This matches the behavior of non-tactic content on subsequent lines.
theorem fallbackOnNonTactic : True := by
123 -- unsolved goals + unexpected token
-- For comparison: non-tactic on second line (after a valid tactic).
-- Same pattern: `123` falls out of the tactic block and gets a command error.
theorem fallbackOnNonTacticSecondLine : True := by
skip
123 -- unsolved goals + unexpected token