feat: empty by runs try? to suggest a proof (#13430)

This PR makes an empty `by` block run `try?` in the background and
surface its suggestions, while still producing the usual unsolved-goals
diagnostic. The implicit `try?` is informational only — it does not
change elaboration behavior beyond emitting messages. Behaviour is
controlled by a new option `tactic.tryOnEmptyBy`, disabled by default
for now; set it to `true` to opt in. The default may flip in a future
release.

Behaviour summary, when the option is enabled:
* The empty `by` reports unsolved goals immediately, before the
(possibly slow) `try?` has finished.
* The `try?` work is spawned as an asynchronous snapshot task
(`Term.wrapAsyncAsSnapshot` + `Core.logSnapshotTask`), so subsequent
elaboration is not blocked and the suggestions arrive when ready.
* `try?` is gated on its parser infrastructure being available, so
working on the prelude (before `Init.Try` is imported) keeps the regular
empty-`by` behaviour.
* No effect when the empty `by` appears inside a backtracking combinator
(e.g. `first | exact (by) | …`) or when `try?` finds no applicable
suggestion.

Implementation notes:
* `elabEmptyByAsTry` (in `Lean.Elab.Tactic.Try`) is registered as a
second `@[builtin_term_elab byTactic]`, alongside the existing
`elabByTactic` in `Lean.Elab.BuiltinTerm`. The gate
`shouldElabEmptyByAsTry` is checked in both elaborators so the
empty-`by` path takes the `try?` route while non-empty `by` follows the
regular path. The body shared between them is factored as
`elabByTacticCore`. The two-elaborator setup avoids a circular module
dependency between `BuiltinTerm.lean` and `Tactic/Try.lean`; an inline
comment in `Try.lean` explains this.
* A latent bug from #13229 is fixed along the way: `evalSepTactics`
returned at the very top for an empty tactic sequence without resolving
the `tacSnap` promise that `MutualDef.mkTacTask` sets up for `:= by …`
bodies. The dangling promise was harmless in typical use because the
cmd's cancellation token would fire shortly after elaboration and drop
it, but with a slow async snapshot task in the same command (as the
implicit `try?` here) the language-server info-tree walk would block on
it and the editor's Messages view would only update once the task
finished. Resolved at the early-return in `evalSepTactics`.
* The test infrastructure in `Lean.Server.Test.Cancel` gains a
label-keyed `testTasksRef` registry plus `mkTestTask` /
`wait_for_test_task`. The pre-existing `block_until_cancelled` is
reimplemented on top of `mkTestTask` and the redundant
`blockUntilCancelledOnce` ref is removed.

Tests:
* `tests/elab/tryOnEmptyBy.lean`, `tests/elab/try_prelude.lean` —
feature behaviour and prelude gating.
* `tests/server_interactive/cancellation_empty_by.lean` — verifies that
on document re-elaboration `cancelRec` reaches the empty-`by` snapshot's
cancel token registered with `Core.logSnapshotTask`. A
`[try_suggestion]` generator wires the outer cancel token's `onSet` to
resolve a `mkTestTask "T_outer"` promise, and the candidate
`wait_for_test_task "T_outer"` waits on it. If `cancelTk? := none` is
passed to `Core.logSnapshotTask`, `cancelRec` cannot reach the token,
the wait blocks, and the runner times out. If `cancelTk? := none` is
also passed to `wrapAsyncAsSnapshot`, no `onSet` resolver is registered,
the promise drops without resolution, and `wait_for_test_task` surfaces
a `"task dropped"` diagnostic on stderr.
* `tests/server_interactive/cancellation_try_plain.lean` — verifies
cancellation of plain `try?` (no `=>`) when its `[try_suggestion]`
candidate runs synchronously inside `expandUserTactic`, by chaining
through `wait_for_cancel_once_async`'s shared promise. Breaking
`SnapshotTask.cancelRec` to skip walking children causes a runner
timeout.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Joachim Breitner 2026-05-11 08:31:42 +02:00 committed by GitHub
parent d98f40cda2
commit 2229b077d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 382 additions and 68 deletions

View file

@ -157,7 +157,37 @@ private def getMVarFromUserName (ident : Syntax) : MetaM Expr := do
elabTerm b expectedType?
| _ => throwUnsupportedSyntax
@[builtin_term_elab byTactic] def elabByTactic : TermElab := fun stx expectedType? => do
register_builtin_option tactic.tryOnEmptyBy : Bool := {
defValue := false
descr := "when an empty `by` block is encountered interactively, run `try?` to suggest \
a proof (currently disabled by default; may become the default in a future release)"
}
/-- Returns `true` if `stx` is a `by` expression with an empty tactic body
(not a parse error producing `.missing`).
The structure is: `node byTactic [atom "by", node tacticSeq [node tacticSeq1Indented [node null []]]]` -/
def isEmptyByBlock (stx : Syntax) : Bool :=
stx.getNumArgs == 2 &&
stx[1].getNumArgs >= 1 &&
stx[1][0].isOfKind ``Lean.Parser.Tactic.tacticSeq1Indented &&
stx[1][0].getNumArgs >= 1 &&
stx[1][0][0].getNumArgs == 0 &&
!stx[1][0][0].isMissing
/-- Returns `true` if all conditions are met for empty `by` to be elaborated as `try?`:
the body is empty, the option is enabled, we are in an interactive (non-combinator) context,
and the `try?` infrastructure (parser `Lean.Parser.Tactic.tryTrace`) is available — the latter
matters when working on the prelude, before `Init.Try` is imported. -/
def shouldElabEmptyByAsTry (stx : Syntax) : TermElabM Bool := do
return isEmptyByBlock stx
&& tactic.tryOnEmptyBy.get (← getOptions)
&& (← read).errToSorry
&& (← getEnv).contains `Lean.Parser.Tactic.tryTrace
/-- Body of the `byTactic` term elaborator: registers a tactic mvar for the body, or
errors when there's no expected type. Shared between `elabByTactic` and
`Lean.Elab.Tactic.Try`'s `elabEmptyByAsTry` so the two paths can't drift. -/
def elabByTacticCore : TermElab := fun stx expectedType? => do
match expectedType? with
| some expectedType =>
-- `by` switches from an exported to a private context, so we must disallow unassigned
@ -168,6 +198,13 @@ private def getMVarFromUserName (ident : Syntax) : MetaM Expr := do
tryPostpone
throwError ("invalid 'by' tactic, expected type has not been provided")
@[builtin_term_elab byTactic] def elabByTactic : TermElab := fun stx expectedType? => do
-- When the conditions for `try?` on empty `by` are met, skip this elaborator so a later one
-- (in Lean.Elab.Tactic.Try) can handle it with try?.
if (← shouldElabEmptyByAsTry stx) then
throwUnsupportedSyntax
elabByTacticCore stx expectedType?
@[builtin_term_elab noImplicitLambda] def elabNoImplicitLambda : TermElab := fun stx expectedType? =>
elabTerm stx[1] (mkNoImplicitLambdaAnnotation <$> expectedType?)

View file

@ -38,6 +38,12 @@ where
-- `stx[0]` is the next tactic step, if any
goEven stx := do
if stx.getNumArgs == 0 then
-- No more tactic steps, but if the parent still passed us a tactic snapshot
-- bundle (e.g. for an empty `by` body) we must resolve its promise so that
-- consumers walking the snapshot tree (like the language-server info-tree
-- lookup) don't block waiting on a promise nothing else will resolve.
if let some snap := (← readThe Term.Context).tacSnap? then
snap.new.resolve default
return
let tac := stx[0]
/-

View file

@ -12,6 +12,7 @@ public import Lean.Elab.Tactic.LibrarySearch
public import Lean.Elab.Tactic.Grind.Main
public import Lean.Elab.Parallel
public meta import Lean.Elab.Command
import Lean.Elab.BuiltinTerm
import Init.Omega
public section
namespace Lean.Elab.Tactic
@ -1009,7 +1010,8 @@ private def wrapSuggestionWithBy (sugg : Tactic.TryThis.Suggestion) : TacticM Ta
| _ => return sugg
/-- Version of `evalAndSuggest` that wraps tactic suggestions with `by` for term mode. -/
private def evalAndSuggestWithBy (tk : Syntax) (tac : TSyntax `tactic) (originalMaxHeartbeats : Nat) (config : Try.Config) : TacticM Unit := do
private def evalAndSuggestWithBy (tk : Syntax) (tac : TSyntax `tactic) (originalMaxHeartbeats : Nat)
(config : Try.Config) (footer : MessageData := MessageData.nil) : TacticM Unit := do
-- Suppress "Try this" messages from intermediate tactic executions
let tac' ← withSuppressedMessages do
try
@ -1023,23 +1025,30 @@ private def evalAndSuggestWithBy (tk : Syntax) (tac : TSyntax `tactic) (original
-- Wrap each suggestion with `by `
let termSuggestions ← suggestions.mapM wrapSuggestionWithBy
if termSuggestions.size == 1 then
Tactic.TryThis.addSuggestion tk termSuggestions[0]! (origSpan? := (← getRef))
Tactic.TryThis.addSuggestion tk termSuggestions[0]! (origSpan? := (← getRef)) (footer := footer)
else
Tactic.TryThis.addSuggestions tk termSuggestions (origSpan? := (← getRef))
Tactic.TryThis.addSuggestions tk termSuggestions (origSpan? := (← getRef)) (footer := footer)
@[builtin_tactic Lean.Parser.Tactic.tryTrace] def evalTryTrace : Tactic := fun stx => do
match stx with
| `(tactic| try?%$tk $config:optConfig) => Tactic.focus do withMainContext do
let config ← elabTryConfig config
/-- Core implementation of `try?`: focus, collect info, build tactic, evaluate and suggest.
`tk` is the syntax token where "Try this:" appears. The optional `footer` is appended to the
suggestions message (only when `wrapWithBy := true`). -/
private def elabTryCore (tk : Syntax) (config : Try.Config) (footer : MessageData := MessageData.nil) :
TacticM Unit :=
Tactic.focus do withMainContext do
let originalMaxHeartbeats ← getMaxHeartbeats
withUnlimitedHeartbeats do
let goal ← getMainGoal
let info ← Try.collect goal config
let stx ← mkTryEvalSuggestStx goal info
if config.wrapWithBy then
evalAndSuggestWithBy tk stx originalMaxHeartbeats config
evalAndSuggestWithBy tk stx originalMaxHeartbeats config (footer := footer)
else
evalAndSuggest tk stx originalMaxHeartbeats config
@[builtin_tactic Lean.Parser.Tactic.tryTrace] def evalTryTrace : Tactic := fun stx => do
match stx with
| `(tactic| try?%$tk $config:optConfig) =>
elabTryCore tk (← elabTryConfig config)
| _ => throwUnsupportedSyntax
@[builtin_tactic Lean.Parser.Tactic.tryTraceWith] def evalTryTraceWith : Tactic := fun stx => do
@ -1055,4 +1064,37 @@ private def evalAndSuggestWithBy (tk : Syntax) (tac : TSyntax `tactic) (original
evalAndSuggest tk tac originalMaxHeartbeats config
| _ => throwUnsupportedSyntax
open Term in
/-- When the `by` body is empty and `tactic.tryOnEmptyBy` is set, run `try?` for its
informational side effect (the "Try this" suggestions) and then delegate to the normal
`by` elaborator so the empty body still produces an unsolved-goals error. The implicit
mode must not change elaboration behavior beyond emitting messages.
Disabled when `errToSorry` is false (nested in a combinator like `first`),
or when `try?` infrastructure is not yet available (e.g. while building the prelude).
We register a *second* `builtin_term_elab` for `byTactic` (rather than folding the
gate-and-dispatch into `elabByTactic` directly) because `Lean.Elab.Tactic.Try` already
imports `Lean.Elab.BuiltinTerm`, so the `try?` infrastructure can't be referenced
from `BuiltinTerm.lean` without breaking the dependency direction. The gate in
`elabByTactic` skips this elaborator (via `throwUnsupportedSyntax`) when the `try?`
path doesn't apply. This could be cleaned up later, e.g. via a registered handler ref
in `BuiltinTerm.lean` populated by `Try.lean`. -/
@[builtin_term_elab byTactic] def elabEmptyByAsTry : TermElab := fun stx expectedType? => do
unless (← shouldElabEmptyByAsTry stx) do
throwUnsupportedSyntax
let some expectedType := expectedType? | do tryPostpone; throwUnsupportedSyntax
-- Run the same body the normal `by` elaborator would.
let mvar ← elabByTacticCore stx expectedType?
let cancelTk ← IO.CancelToken.new
let footer := m!"\n\n(Disable this with `set_option tactic.tryOnEmptyBy false`.)"
let act ← Term.wrapAsyncAsSnapshot (cancelTk? := cancelTk) fun (_ : Unit) => do
let scratch ← mkFreshExprMVar expectedType MetavarKind.syntheticOpaque
try
discard <| Tactic.run scratch.mvarId! <|
withRef stx do elabTryCore stx[0] { wrapWithBy := true } (footer := footer)
catch _ => pure ()
let t ← BaseIO.asTask (act ())
Core.logSnapshotTask { stx? := none, reportingRange := .skip, task := t, cancelTk? := cancelTk }
return mvar
end Lean.Elab.Tactic.Try

View file

@ -91,14 +91,15 @@ The parameters are:
-/
def addSuggestion (ref : Syntax) (s : Suggestion) (origSpan? : Option Syntax := none)
(header : String := "Try this:") (codeActionPrefix? : Option String := none)
(diffGranularity : Hint.DiffGranularity := .none) : CoreM Unit := do
(diffGranularity : Hint.DiffGranularity := .none)
(footer : MessageData := MessageData.nil) : CoreM Unit := do
let hintSuggestion := {
span? := origSpan?
diffGranularity
toTryThisSuggestion := s
}
let suggs ← Hint.mkSuggestionsMessage #[hintSuggestion] ref codeActionPrefix? (forceList := false)
logInfoAt ref m!"{header}{suggs}"
logInfoAt ref m!"{header}{suggs}{footer}"
set_option linter.unusedVariables false in
/-- Add a list of "try this" suggestions as a single "try these" suggestion. This has two effects:
@ -134,7 +135,8 @@ def addSuggestions (ref : Syntax) (suggestions : Array Suggestion)
(origSpan? : Option Syntax := none) (header : String := "Try these:")
(style? : Option SuggestionStyle := none)
(codeActionPrefix? : Option String := none)
(diffGranularity : Hint.DiffGranularity := .none) : CoreM Unit := do
(diffGranularity : Hint.DiffGranularity := .none)
(footer : MessageData := MessageData.nil) : CoreM Unit := do
if suggestions.isEmpty then throwErrorAt ref "No suggestions available"
let hintSuggestions := suggestions.map fun s => {
span? := origSpan?
@ -142,7 +144,7 @@ def addSuggestions (ref : Syntax) (suggestions : Array Suggestion)
toTryThisSuggestion := s
}
let suggs ← Hint.mkSuggestionsMessage hintSuggestions ref codeActionPrefix? (forceList := true)
logInfoAt ref m!"{header}{suggs}"
logInfoAt ref m!"{header}{suggs}{footer}"
/-! # Tactic-specific widget hooks -/
/--

View file

@ -192,39 +192,6 @@ elab_rules : tactic
dbg_trace "blocked!"
log "blocked"
/-! ## Helpers for end-to-end testing of cancellation propagation -/
meta initialize blockUntilCancelledOnce : IO.Ref (Std.HashMap String (Task Unit)) ← IO.mkRef {}
/--
Tactic for testing cancellation propagation. On the first invocation for a given `<label>`,
prints `<label>: blocked` to stderr and loops on `Core.checkInterrupted` until the tactic's
cancel token fires (at which point the loop throws and `finally` resolves the shared promise).
Subsequent invocations (e.g. on re-elaboration) wait on that promise: they return as soon as
the first invocation has actually exited the loop, and hang otherwise. So if cancellation
propagates correctly, the test completes; if propagation is broken, the second invocation's
`IO.wait` blocks forever and the test hangs (timeout = failure).
-/
scoped syntax "block_until_cancelled" str : tactic
elab_rules : tactic
| `(tactic| block_until_cancelled $label) => do
let lbl := label.getString
let prom ← IO.Promise.new
let prior ← blockUntilCancelledOnce.modifyGet fun m =>
match m[lbl]? with
| some t => (some t, m)
| none => (none, m.insert lbl prom.result!)
if let some t := prior then
IO.wait t
return
IO.eprintln s!"{lbl}: blocked"
try
while true do
Core.checkInterrupted
IO.sleep 10
finally
prom.resolve ()
meta initialize cmdOnceRef : IO.Ref (Option (Task Unit)) ← IO.mkRef none
/--
@ -252,3 +219,61 @@ elab_rules : command
let t ← BaseIO.asTask (act ())
(Core.logSnapshotTask { stx? := none, task := t, cancelTk? := cancelTk })
logInfo "blocked"
/-- Registry of label-keyed `Task (Option Unit)` values for use by `mkTestTask` and
`wait_for_test_task`. The stored task is `prom.result?` of the promise returned by
`mkTestTask`; the registry itself does not keep that promise alive, so if no other
reference exists, the promise drops and the task fires `none`. -/
meta initialize testTasksRef : IO.Ref (Std.HashMap String (Task (Option Unit))) ← IO.mkRef {}
/-- Register a fresh test task under `label`, returning the underlying `IO.Promise`.
Returns `none` if a task is already registered under `label`. The caller is responsible
for keeping the returned promise alive and arranging its resolution -- typically by
capturing it in a `cancelTk.onSet` closure that calls `prom.resolve`. -/
meta def mkTestTask (label : String) : BaseIO (Option (IO.Promise Unit)) := do
let prom ← IO.Promise.new
testTasksRef.modifyGet fun m =>
if m.contains label then (none, m) else (some prom, m.insert label prom.result?)
/-- Block until the test task named `label` fires. Prints a diagnostic to stderr if
the underlying promise was dropped without resolution, or if no task is registered for
`label`. The diagnostic uses stderr rather than `throwError` so that the failure is
visible even when this tactic is evaluated inside `try?` (or any other combinator that
swallows tactic errors). -/
scoped syntax "wait_for_test_task " str : tactic
elab_rules : tactic
| `(tactic| wait_for_test_task $label) => do
let label := label.getString
match (← testTasksRef.get).get? label with
| none =>
IO.eprintln s!"wait_for_test_task: no task registered for {label}"
| some t =>
match (← IO.wait t) with
| some _ => return
| none => IO.eprintln s!"wait_for_test_task: task {label} dropped without resolution"
/--
Tactic for testing cancellation propagation. On the first invocation for a given `<label>`,
prints `<label>: blocked` to stderr and loops on `Core.checkInterrupted` until the tactic's
cancel token fires (at which point the loop throws and `finally` resolves the shared task).
Subsequent invocations (e.g. on re-elaboration) wait on that task: they return as soon as
the first invocation has actually exited the loop, and hang otherwise. So if cancellation
propagates correctly, the test completes; if propagation is broken, the second invocation's
wait blocks forever and the test hangs (timeout = failure).
-/
scoped syntax "block_until_cancelled" str : tactic
elab_rules : tactic
| `(tactic| block_until_cancelled $label) => do
let lbl := label.getString
match (← mkTestTask lbl) with
| none =>
let some t := (← testTasksRef.get).get? lbl | unreachable!
discard <| IO.wait t
| some prom =>
IO.eprintln s!"{lbl}: blocked"
try
while true do
Core.checkInterrupted
IO.sleep 10
finally
prom.resolve ()

View file

@ -3,27 +3,28 @@
-- it is fine to simply remove the `#guard_msgs` and expected output.
/--
info: • [Command] @ ⟨79, 0⟩-⟨79, 40⟩ @ Lean.Elab.Command.elabDeclaration
• [Term] Nat : Type @ ⟨79, 15⟩-⟨79, 18⟩ @ Lean.Elab.Term.elabIdent
• [Completion-Id] Nat : some Sort.{?_uniq.1} @ ⟨79, 15⟩-⟨79, 18⟩
• [Term] Nat : Type @ ⟨79, 15⟩-⟨79, 18⟩
• [Term] n (isBinder := true) : Nat @ ⟨79, 11⟩-⟨79, 12⟩
• [Term] 0 ≤ n : Prop @ ⟨79, 22⟩-⟨79, 27⟩ @ «_aux_Init_Notation___macroRules_term_≤__2»
info: • [Command] @ ⟨80, 0⟩-⟨80, 40⟩ @ Lean.Elab.Command.elabDeclaration
• [Term] Nat : Type @ ⟨80, 15⟩-⟨80, 18⟩ @ Lean.Elab.Term.elabIdent
• [Completion-Id] Nat : some Sort.{?_uniq.1} @ ⟨80, 15⟩-⟨80, 18⟩
• [Term] Nat : Type @ ⟨80, 15⟩-⟨80, 18⟩
• [Term] n (isBinder := true) : Nat @ ⟨80, 11⟩-⟨80, 12⟩
• [Term] 0 ≤ n : Prop @ ⟨80, 22⟩-⟨80, 27⟩ @ «_aux_Init_Notation___macroRules_term_≤__2»
• [MacroExpansion]
0 ≤ n
===>
binrel% LE.le✝ 0 n
• [Term] 0 ≤ n : Prop @ ⟨79, 22⟩†-⟨79, 27⟩† @ Lean.Elab.Term.Op.elabBinRel
• [Term] 0 ≤ n : Prop @ ⟨79, 22⟩†-⟨79, 27⟩†
• [Completion-Id] LE.le✝ : none @ ⟨79, 22⟩†-⟨79, 27⟩†
• [Term] 0 : Nat @ ⟨79, 22⟩-⟨79, 23⟩ @ Lean.Elab.Term.elabNumLit
• [Term] n : Nat @ ⟨79, 26⟩-⟨79, 27⟩ @ Lean.Elab.Term.elabIdent
• [Completion-Id] n : none @ ⟨79, 26⟩-⟨79, 27⟩
• [Term] n : Nat @ ⟨79, 26⟩-⟨79, 27⟩
• [Term] 0 ≤ n : Prop @ ⟨80, 22⟩†-⟨80, 27⟩† @ Lean.Elab.Term.Op.elabBinRel
• [Term] 0 ≤ n : Prop @ ⟨80, 22⟩†-⟨80, 27⟩†
• [Completion-Id] LE.le✝ : none @ ⟨80, 22⟩†-⟨80, 27⟩†
• [Term] 0 : Nat @ ⟨80, 22⟩-⟨80, 23⟩ @ Lean.Elab.Term.elabNumLit
• [Term] n : Nat @ ⟨80, 26⟩-⟨80, 27⟩ @ Lean.Elab.Term.elabIdent
• [Completion-Id] n : none @ ⟨80, 26⟩-⟨80, 27⟩
• [Term] n : Nat @ ⟨80, 26⟩-⟨80, 27⟩
• [CustomInfo(Lean.Elab.Term.AsyncBodyInfo)]
• [Term] n (isBinder := true) : Nat @ ⟨79, 11⟩-⟨79, 12⟩
• [Term] n (isBinder := true) : Nat @ ⟨80, 11⟩-⟨80, 12⟩
• [CustomInfo(Lean.Elab.Term.BodyInfo)]
• [Tactic] @ ⟨79, 31⟩-⟨79, 40⟩
• [PartialTerm] @ ⟨80, 31⟩-⟨80, 40⟩ @ Lean.Elab.Tactic.Try.elabEmptyByAsTry
• [Tactic] @ ⟨80, 31⟩-⟨80, 40⟩
(Term.byTactic
"by"
(Tactic.tacticSeq (Tactic.tacticSeq1Indented [(Tactic.exact? "exact?" (Tactic.optConfig []) [])])))
@ -31,31 +32,31 @@ info: • [Command] @ ⟨79, 0⟩-⟨79, 40⟩ @ Lean.Elab.Command.elabDeclarati
n : Nat
⊢ 0 ≤ n
after no goals
• [Tactic] @ ⟨79, 31⟩-⟨79, 33⟩
• [Tactic] @ ⟨80, 31⟩-⟨80, 33⟩
"by"
before ⏎
n : Nat
⊢ 0 ≤ n
after no goals
• [Tactic] @ ⟨79, 34⟩-⟨79, 40⟩ @ Lean.Elab.Tactic.evalTacticSeq
• [Tactic] @ ⟨80, 34⟩-⟨80, 40⟩ @ Lean.Elab.Tactic.evalTacticSeq
(Tactic.tacticSeq (Tactic.tacticSeq1Indented [(Tactic.exact? "exact?" (Tactic.optConfig []) [])]))
before ⏎
n : Nat
⊢ 0 ≤ n
after no goals
• [Tactic] @ ⟨79, 34⟩-⟨79, 40⟩ @ Lean.Elab.Tactic.evalTacticSeq1Indented
• [Tactic] @ ⟨80, 34⟩-⟨80, 40⟩ @ Lean.Elab.Tactic.evalTacticSeq1Indented
(Tactic.tacticSeq1Indented [(Tactic.exact? "exact?" (Tactic.optConfig []) [])])
before ⏎
n : Nat
⊢ 0 ≤ n
after no goals
• [Tactic] @ ⟨79, 34⟩-⟨79, 40⟩ @ Lean.Elab.LibrarySearch.evalExact
• [Tactic] @ ⟨80, 34⟩-⟨80, 40⟩ @ Lean.Elab.LibrarySearch.evalExact
(Tactic.exact? "exact?" (Tactic.optConfig []) [])
before ⏎
n : Nat
⊢ 0 ≤ n
after no goals
• [Tactic] @ ⟨79, 34⟩†-⟨79, 40⟩† @ Lean.Elab.Tactic.evalExact
• [Tactic] @ ⟨80, 34⟩†-⟨80, 40⟩† @ Lean.Elab.Tactic.evalExact
(Tactic.exact "exact" (Term.app `Nat.zero_le [`n]))
before ⏎
n : Nat
@ -68,8 +69,8 @@ info: • [Command] @ ⟨79, 0⟩-⟨79, 40⟩ @ Lean.Elab.Command.elabDeclarati
• [Completion-Id] n : some Nat @ ⟨1, 5⟩†-⟨1, 5⟩†
• [Term] n : Nat @ ⟨1, 5⟩†-⟨1, 5⟩†
• [CustomInfo(Lean.Meta.Tactic.TryThis.TryThisInfo)]
• [Term] t (isBinder := true) : ∀ (n : Nat), 0 ≤ n @ ⟨79, 8⟩-⟨79, 9⟩
• [Term] t (isBinder := true) : ∀ (n : Nat), 0 ≤ n @ ⟨79, 8⟩-⟨79, 9⟩
• [Term] t (isBinder := true) : ∀ (n : Nat), 0 ≤ n @ ⟨80, 8⟩-⟨80, 9⟩
• [Term] t (isBinder := true) : ∀ (n : Nat), 0 ≤ n @ ⟨80, 8⟩-⟨80, 9⟩
---
info: Try this:
[apply] exact Nat.zero_le n

View file

@ -0,0 +1,68 @@
/-
Tests for `tactic.tryOnEmptyBy`: empty `by` blocks run `try?` and suggest proofs.
-/
set_option tactic.tryOnEmptyBy true
-- Basic: empty by reports unsolved goals first (so the user sees it immediately
-- even when `try?` is slow), then `try?` emits its suggestions as a single info
-- message with the option-disabling hint at the end.
/--
error: unsolved goals
⊢ True
---
info: Try these:
[apply] by solve_by_elim
[apply] by simp
[apply] by simp only
[apply] by grind
[apply] by grind only
[apply] by simp_all
(Disable this with `set_option tactic.tryOnEmptyBy false`.)
-/
#guard_msgs in
example : True := by
-- Disabled: empty by gives unsolved goals
/--
error: unsolved goals
⊢ True
-/
#guard_msgs in
set_option tactic.tryOnEmptyBy false in
example : True := by
-- Non-empty by is not affected
example : True := by
trivial
-- by { } (braces) is not affected
example : True := by { trivial }
-- by { } (empty braces) does not trigger try?
/--
error: unsolved goals
⊢ True
-/
#guard_msgs in
example : True := by { }
-- Unprovable goal: try? finds no suggestions, so the implicit mode is fully silent
-- (no "Try this", no error or warning from try? itself — only the unsolved-goals error).
/--
error: unsolved goals
⊢ False
-/
#guard_msgs in
example : False := by
-- Nested in a backtracking combinator (`errToSorry = false`): try? must stay silent.
-- We only assert the absence of the try? info message; the unsolved-goals error is expected
-- because `exact (by)` succeeds at term-elab time (the empty tactic block fails later).
/--
error: unsolved goals
⊢ True
-/
#guard_msgs in
example : True := by first | exact (by) | trivial

View file

@ -0,0 +1,15 @@
prelude
import Init.Notation
/-!
Tests that while working on the prelude, try?-on-by does not run when not all infrastructure is
availbe.
-/
set_option tactic.tryOnEmptyBy true
/--
error: unsolved goals
⊢ True
-/
#guard_msgs in
theorem test : True := by

View file

@ -0,0 +1,68 @@
import Lean.Server.Test.Cancel
import Lean.Elab.Tactic.Try
open Lean Lean.Meta Lean.Elab Lean.Elab.Term Lean.Elab.Tactic
open Lean.Server.Test.Cancel
/-!
Test cancellation propagation through `elabEmptyByAsTry`. With
`tactic.tryOnEmptyBy true` an empty `by` (the proof `:= by` with nothing
following) spawns a `wrapAsyncAsSnapshot` task that runs `try?`. The
outer snapshot's cancel token (`T_outer`) is registered with
`Core.logSnapshotTask`; on re-elaboration `cancelRec` walks the
snapshot tree and sets it.
The `[try_suggestion]` generator runs in MetaM inside the empty-`by`
snapshot task, so `(← read).cancelTk?` is `T_outer`. It registers a
test task under label `"T_outer"` (via `mkTestTask`) on the first
invocation and arranges `T_outer.onSet` to resolve it; the returned
candidate `wait_for_test_task "T_outer"` blocks until the task fires.
`t1`'s `wait_for_cancel_once_async` is used purely to publish the
`"blocked"` diagnostic the runner waits for. On re-elaboration
`cancelRec` walks both `t1`'s tree and the empty-`by` example's
tree; firing `T_outer` resolves the shared task and the snapshot
task body completes. The second elaboration's invocation observes
the already-resolved task and returns immediately.
If `elabEmptyByAsTry` passes `cancelTk? := none` to
`Core.logSnapshotTask`, `cancelRec` cannot reach `T_outer`. The
snapshot task body's wait blocks until `T_outer` is dropped; since
the body holds `T_outer` alive (via `wrapAsync`'s ctx) until it
returns, `T_outer` does not drop, the wait does not return, and the
runner times out. If `elabEmptyByAsTry` additionally fails to plumb
`cancelTk` into `wrapAsyncAsSnapshot`, no `onSet` resolver is
registered, the promise drops, and `wait_for_test_task` surfaces
the explicit "task dropped" diagnostic on stderr.
-/
namespace TestEmptyBy
opaque UnsolvableProp : Prop
@[try_suggestion]
def tracerSuggestion (_goal : MVarId) (_info : Try.Info) :
MetaM (Array (TSyntax `tactic)) := do
-- Register the test task on the first call only; later calls reuse the
-- existing entry and so observe the same (eventually resolved) task.
if let some prom ← mkTestTask "T_outer" then
if let some cancelTk := (← readThe Core.Context).cancelTk? then
cancelTk.onSet (do prom.resolve ())
return #[← `(tactic| wait_for_test_task "T_outer")]
end TestEmptyBy
set_option tactic.tryOnEmptyBy true
example : True := by
trivial
--^ waitFor: blocked
--^ insert: "; skip"
--^ sync
theorem t1 : True := by
wait_for_cancel_once_async
trivial
example : TestEmptyBy.UnsolvableProp := by

View file

@ -0,0 +1,2 @@
blocked!
cancelled!

View file

@ -0,0 +1,46 @@
import Lean.Server.Test.Cancel
import Lean.Elab.Tactic.Try
open Lean Lean.Meta Lean.Elab Lean.Elab.Term Lean.Elab.Tactic
open Lean.Server.Test.Cancel
/-!
Test cancellation propagation through plain `try?` (no `=>`, no
`wrapAsyncAsSnapshot` wrapping). Plain `try?` calls
`mkTryEvalSuggestStx`, which calls `expandUserTactic` for each
`[try_suggestion]` candidate. `expandUserTactic` runs the candidate via
`Tactic.run + evalTactic` on the *main* elaboration thread. Cancellation
must reach the candidate's body promptly via `Core.checkInterrupted`
embedded in `evalTactic` and inside the candidate.
We register a `[try_suggestion]` whose tactic is `wait_for_cancel_once_async`.
The first invocation (in the outer theorem body) spawns a snapshot task
that polls the cancel token; the second invocation (inside
`expandUserTactic`) waits on the first invocation's promise. On
re-elaboration the snapshot task observes its cancel token, resolves the
shared promise, and the inner `IO.wait` returns. After re-elaboration the
chain construction continues, the user attempt-all is built and run, and
the further nested invocations wait on the already-resolved promise. If
cancellation propagation is broken at any step, the test runner times out.
-/
namespace TestTryPlain
opaque UnsolvableProp : Prop
@[try_suggestion]
def waitSuggestion (_goal : MVarId) (_info : Try.Info) :
MetaM (Array (TSyntax `tactic)) := do
let stx ← `(tactic| wait_for_cancel_once_async)
return #[stx]
end TestTryPlain
example : True := by
trivial
--^ waitFor: blocked
--^ insert: "; skip"
--^ sync
theorem t : TestTryPlain.UnsolvableProp := by
wait_for_cancel_once_async
try?

View file

@ -0,0 +1,2 @@
blocked!
cancelled!