Skip to content

Commit b56ecfe

Browse files
dhilstclaude
andcommitted
rewrite(<eq>)?: infer axiom arguments and format forward multi-line
Two ergonomics improvements to the rewrite suggestion hole: - Infer the equation's own arguments. Give a bare axiom name (`rewrite(pop_push)?`) or leave some as `?` holes (`rewrite(pop_push(A, ?x, ?s))?`) and the checker solves them by matching the axiom's left side against a subterm of the goal, suggesting the fully-applied `rewrite(pop_push(A, a, push(A, b, empty(A))))?` — which in turn suggests the forward step. Guards: proof-hypothesis args can't be inferred, params that appear only on the right are reported as unsolved, and a non-equation or no-match argument gets a fix-less diagnostic. - Format the forward suggestion one argument per line, indented two spaces past the `by`, so deep nested calls stay readable. Docs: update the rewrite(...)? admonition and the one_pop callout to show the multi-line format and bare/`?`-hole inference. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f2a9357 commit b56ecfe

2 files changed

Lines changed: 318 additions & 37 deletions

File tree

algae-kernel/src/elaborate/proof.rs

Lines changed: 294 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,14 +1097,72 @@ fn subst_occurrences(goal_nf: &Expr, from_nf: &Expr, to: &Expr) -> Expr {
10971097
sys.nf(goal_nf)
10981098
}
10991099

1100-
/// Report a `rewrite(<eq>)?` suggestion hole. Given the equation `lhs = rhs` the
1101-
/// argument proves and the current goal, synthesize the `forward` motive automatically
1102-
/// — abstracting *every* occurrence of `lhs` in the goal — and, when the resulting
1103-
/// `forward` step actually applies, offer it as a paste-able fix
1104-
/// `by forward(T, lhs, rhs, <eq>, P) then <ctx> ⊢ <newgoal>;`. When the rewrite does
1105-
/// not apply (no matching subterm, un-inferrable type, non-equation argument, or
1106-
/// `forward` not in scope), emit a plain diagnostic with no fix. The step is admitted,
1107-
/// so the proof stays incomplete — exactly like every other `?` inspect step.
1100+
/// Immediate subexpressions of `e`, for subterm traversal.
1101+
fn expr_children(e: &Expr) -> Vec<&Expr> {
1102+
match e {
1103+
Expr::App(f, args) => {
1104+
let mut v = Vec::with_capacity(args.len() + 1);
1105+
v.push(f.as_ref());
1106+
v.extend(args.iter());
1107+
v
1108+
}
1109+
Expr::Lam(a, b)
1110+
| Expr::Forall(a, b)
1111+
| Expr::Pi(a, b)
1112+
| Expr::Exists(a, b)
1113+
| Expr::Arrow(a, b)
1114+
| Expr::Eq(a, b)
1115+
| Expr::And(a, b)
1116+
| Expr::Or(a, b)
1117+
| Expr::Implies(a, b)
1118+
| Expr::Iff(a, b) => vec![a.as_ref(), b.as_ref()],
1119+
Expr::Not(a) => vec![a.as_ref()],
1120+
Expr::Product(xs) | Expr::Sum(xs) => xs.iter().collect(),
1121+
Expr::Bound(_) | Expr::Free(_) | Expr::Const(_) | Expr::Sort | Expr::Prop | Expr::False => {
1122+
Vec::new()
1123+
}
1124+
}
1125+
}
1126+
1127+
/// The metavariable binding from the first (pre-order, leftmost-outermost) subterm
1128+
/// of `goal` that matches `pat` (with `metas` as wildcards), or `None`.
1129+
fn first_subterm_match(goal: &Expr, pat: &Expr, metas: &[Sym]) -> Option<Vec<(Sym, Expr)>> {
1130+
let mut subst = Vec::new();
1131+
if crate::core::rewrite::match_pattern(pat, goal, metas, &mut subst) {
1132+
return Some(subst);
1133+
}
1134+
for child in expr_children(goal) {
1135+
if let Some(s) = first_subterm_match(child, pat, metas) {
1136+
return Some(s);
1137+
}
1138+
}
1139+
None
1140+
}
1141+
1142+
/// Format a `by forward(...)` call across lines, one argument per line indented two
1143+
/// spaces past the `by`. The first line carries no leading indent (it is spliced in
1144+
/// at the `by`'s existing column); callers prefix `indent` when embedding it in a
1145+
/// message.
1146+
fn format_forward_call(indent: &str, arg_strs: &[String]) -> String {
1147+
let arg_indent = format!("{indent} ");
1148+
let body = arg_strs.join(&format!(",\n{arg_indent}"));
1149+
format!("by forward(\n{arg_indent}{body})")
1150+
}
1151+
1152+
/// Report a `rewrite(<eq>)?` suggestion hole. The argument names an equation
1153+
/// `lhs = rhs` — either fully applied (`pop_push(A, a, s)`) or with some/all
1154+
/// arguments left as holes (`pop_push` bare, or `pop_push(?A, ?x, ?s)`).
1155+
///
1156+
/// * With holes, the missing arguments are **inferred** by matching the axiom's
1157+
/// left side against a subterm of the goal, and the suggestion is the same
1158+
/// `rewrite` step with those arguments filled in (`rewrite(pop_push(A, a, s))?`).
1159+
/// * Fully applied, the motive is synthesized automatically — abstracting *every*
1160+
/// occurrence of `lhs` in the goal — and the suggestion is the paste-able
1161+
/// `by forward(T, lhs, rhs, <eq>, P) then <ctx> ⊢ <newgoal>;` step.
1162+
///
1163+
/// When nothing applies (no matching subterm, an argument that can't be inferred,
1164+
/// a non-equation argument, or `forward` not in scope) a plain diagnostic explains
1165+
/// why and offers no fix. The step is admitted, so the proof stays incomplete.
11081166
fn report_rewrite_hole(
11091167
elab: &mut Elab,
11101168
ctx: &[CtxEntry],
@@ -1113,15 +1171,15 @@ fn report_rewrite_hole(
11131171
rs: &RewriteSystem,
11141172
) {
11151173
use crate::core::display::show;
1116-
use crate::core::tactic::apply;
1174+
use crate::core::tactic::{apply, subst_all};
11171175

11181176
let reference = stmt.reference.as_ref().expect("inspect step has a reference");
11191177
let mut msg = format!(
11201178
"found rewrite hole in `by rewrite`\n\nGoal:\n {}\n",
11211179
show(goal, &elab.interner)
11221180
);
11231181

1124-
// Exactly one argument: the equation proof.
1182+
// Exactly one argument: the equation (a proof reference, possibly with holes).
11251183
if reference.args.len() != 1 {
11261184
elab.err(
11271185
format!(
@@ -1132,23 +1190,171 @@ fn report_rewrite_hole(
11321190
);
11331191
return;
11341192
}
1193+
let axiom_arg = &reference.args[0];
11351194

1136-
// Resolve the argument to the equation it proves.
1137-
let mut scope = scope_from_ctx(ctx);
1138-
let eq_stmt = match resolve_proof_term(elab, ctx, &mut scope, &reference.args[0]) {
1139-
Some(e) => e,
1140-
None => return, // resolve_proof_term already reported the failure.
1195+
// Split the argument into its head reference and its surface arguments.
1196+
let (qname, surface_args) = match &axiom_arg.node {
1197+
ast::ExprNode::Var(q) => (q.clone(), Vec::new()),
1198+
ast::ExprNode::App(head, a) => match &head.node {
1199+
ast::ExprNode::Var(q) => (q.clone(), a.clone()),
1200+
_ => {
1201+
elab.err("expected a proof reference", axiom_arg.span);
1202+
return;
1203+
}
1204+
},
1205+
_ => {
1206+
elab.err("expected a proof reference", axiom_arg.span);
1207+
return;
1208+
}
1209+
};
1210+
1211+
// Resolve the equation's rule (a global axiom/lemma or a local hypothesis).
1212+
let pref = ast::ProofRef {
1213+
name: qname.clone(),
1214+
args: Vec::new(),
1215+
span: axiom_arg.span,
1216+
};
1217+
let (_akey, arule) = match resolve_tactic(elab, ctx, &pref) {
1218+
Some(x) => x,
1219+
None => return,
11411220
};
1142-
let (lhs, rhs) = match rs.nf(&eq_stmt) {
1143-
Expr::Eq(a, b) => (*a, *b),
1221+
if !arule.premises.is_empty() {
1222+
elab.err("a `rewrite` argument must reference a fact (no premises)", axiom_arg.span);
1223+
return;
1224+
}
1225+
match &arule.conclusion {
1226+
Expr::Eq(_, _) => {}
11441227
other => {
11451228
msg.push_str(&format!(
11461229
"\nThe argument proves `{}`, which is not an equation `a = b`.\n",
1147-
show(&other, &elab.interner)
1230+
show(other, &elab.interner)
11481231
));
11491232
elab.err(msg.trim_end().to_string(), stmt.span);
11501233
return;
11511234
}
1235+
}
1236+
1237+
let mut scope = scope_from_ctx(ctx);
1238+
// Classify each parameter as a supplied value (→ `concrete`) or a hole to infer
1239+
// (→ `metas`). A bare reference leaves every parameter a hole.
1240+
let mut concrete_subst: Vec<(Sym, Expr)> = Vec::new();
1241+
let mut metas: Vec<Sym> = Vec::new();
1242+
let n = arule.params.len();
1243+
if surface_args.is_empty() {
1244+
for p in &arule.params {
1245+
match p {
1246+
Param::Term { name, .. } => metas.push(*name),
1247+
Param::Proof { .. } => {
1248+
elab.err("`rewrite` cannot infer proof arguments — supply them explicitly", axiom_arg.span);
1249+
return;
1250+
}
1251+
}
1252+
}
1253+
} else {
1254+
if surface_args.len() != n {
1255+
elab.err(
1256+
format!("`{}` takes {} argument(s), got {}", qname.name.text, n, surface_args.len()),
1257+
axiom_arg.span,
1258+
);
1259+
return;
1260+
}
1261+
for (p, sa) in arule.params.iter().zip(&surface_args) {
1262+
let is_hole = matches!(sa.node, ast::ExprNode::NamedHole(_));
1263+
match p {
1264+
Param::Term { name, .. } => {
1265+
if is_hole {
1266+
metas.push(*name);
1267+
} else {
1268+
match elab.lower_expr(&mut scope, sa) {
1269+
Ok(v) => concrete_subst.push((*name, v)),
1270+
Err(_) => return,
1271+
}
1272+
}
1273+
}
1274+
Param::Proof { .. } => {
1275+
if is_hole {
1276+
elab.err("`rewrite` cannot infer proof arguments — supply them explicitly", sa.span);
1277+
return;
1278+
}
1279+
let _ = resolve_proof_term(elab, ctx, &mut scope, sa);
1280+
}
1281+
}
1282+
}
1283+
}
1284+
1285+
let goal_nf = rs.nf(goal);
1286+
let (lhs_pat, rhs_pat) = match &arule.conclusion {
1287+
Expr::Eq(a, b) => ((**a).clone(), (**b).clone()),
1288+
_ => unreachable!("conclusion checked to be an equation above"),
1289+
};
1290+
1291+
// --- Inference mode: solve the holes by matching the axiom's left side. ---
1292+
if !metas.is_empty() {
1293+
if arule.params.iter().any(|p| matches!(p, Param::Proof { .. })) {
1294+
elab.err("`rewrite` can only infer arguments for equations without proof hypotheses", axiom_arg.span);
1295+
return;
1296+
}
1297+
let lhs_inst = rs.nf(&subst_all(&lhs_pat, &concrete_subst));
1298+
let solved = match first_subterm_match(&goal_nf, &lhs_inst, &metas) {
1299+
Some(s) => s,
1300+
None => {
1301+
msg.push_str(&format!(
1302+
"\nNo subterm of the goal matches the left side `{}` of the equation.\n",
1303+
show(&lhs_inst, &elab.interner)
1304+
));
1305+
elab.err(msg.trim_end().to_string(), stmt.span);
1306+
return;
1307+
}
1308+
};
1309+
let full: Vec<(Sym, Expr)> = concrete_subst.iter().chain(solved.iter()).cloned().collect();
1310+
let unsolved: Vec<Sym> = metas.iter().copied().filter(|m| !full.iter().any(|(s, _)| s == m)).collect();
1311+
if !unsolved.is_empty() {
1312+
let names = unsolved
1313+
.iter()
1314+
.map(|s| elab.interner.resolve(*s))
1315+
.collect::<Vec<_>>()
1316+
.join(", ");
1317+
msg.push_str(&format!(
1318+
"\nCould not infer {names} from the goal — supply {} explicitly.\n",
1319+
if unsolved.len() == 1 { "it" } else { "them" }
1320+
));
1321+
elab.err(msg.trim_end().to_string(), stmt.span);
1322+
return;
1323+
}
1324+
// Spell the now-ground axiom application in parameter order.
1325+
let arg_strs: Vec<String> = arule
1326+
.params
1327+
.iter()
1328+
.filter_map(|p| match p {
1329+
Param::Term { name, .. } => {
1330+
full.iter().find(|(s, _)| s == name).map(|(_, v)| show(v, &elab.interner))
1331+
}
1332+
Param::Proof { .. } => None,
1333+
})
1334+
.collect();
1335+
let axiom_name = match &qname.module {
1336+
Some(m) => format!("{}.{}", m.text, qname.name.text),
1337+
None => qname.name.text.clone(),
1338+
};
1339+
let ground = format!("{axiom_name}({})", arg_strs.join(", "));
1340+
msg.push_str(&format!(
1341+
"\nInferred `{ground}` from the goal.\n\nContinue with:\n by rewrite({ground})?;\n"
1342+
));
1343+
let fix = Fix {
1344+
title: format!("rewrite({ground})?"),
1345+
replacement: format!("by rewrite({ground})?;"),
1346+
span: stmt.span,
1347+
};
1348+
elab.err_with_fixes(msg.trim_end().to_string(), stmt.span, vec![fix]);
1349+
return;
1350+
}
1351+
1352+
// --- Ground mode: the equation is fully concrete → suggest the forward step. ---
1353+
let _ = &rhs_pat; // rhs comes from the instantiated statement below.
1354+
let eq_stmt = rs.nf(&subst_all(&arule.conclusion, &concrete_subst));
1355+
let (lhs, rhs) = match &eq_stmt {
1356+
Expr::Eq(a, b) => ((**a).clone(), (**b).clone()),
1357+
_ => unreachable!("instantiated conclusion is still an equation"),
11521358
};
11531359

11541360
// The sort of both sides (forward's `T : Sort`).
@@ -1172,7 +1378,6 @@ fn report_rewrite_hole(
11721378
};
11731379

11741380
// Build the motive by abstracting every occurrence of `lhs` in the goal.
1175-
let goal_nf = rs.nf(goal);
11761381
let lhs_nf = rs.nf(&lhs);
11771382
let v = elab.interner.fresh("x");
11781383
let body = subst_occurrences(&goal_nf, &lhs_nf, &Expr::Free(v));
@@ -1220,22 +1425,25 @@ fn report_rewrite_hole(
12201425
}
12211426
};
12221427

1223-
// The concrete `by forward(...)` call: `T`, `lhs`, `rhs`, and the motive printed via
1224-
// `show` (all valid surface syntax); the axiom spelled exactly as the user wrote it.
1428+
// The concrete `by forward(...)` call, one argument per line: `T`, `lhs`, `rhs`,
1429+
// and the motive printed via `show` (all valid surface syntax); the axiom spelled
1430+
// exactly as the user wrote it.
12251431
let axiom_src = elab
1226-
.span_text(reference.args[0].span)
1432+
.span_text(axiom_arg.span)
12271433
.map(str::to_string)
12281434
.unwrap_or_else(|| show(&eq_stmt, &elab.interner));
1229-
let call = format!(
1230-
"by forward({}, {}, {}, {}, {})",
1231-
show(&t_sort, &elab.interner),
1232-
show(&lhs, &elab.interner),
1233-
show(&rhs, &elab.interner),
1234-
axiom_src,
1235-
show(&motive, &elab.interner),
1435+
let indent = line_indent(&elab.source, stmt.span.start);
1436+
let call = format_forward_call(
1437+
&indent,
1438+
&[
1439+
show(&t_sort, &elab.interner),
1440+
show(&lhs, &elab.interner),
1441+
show(&rhs, &elab.interner),
1442+
axiom_src,
1443+
show(&motive, &elab.interner),
1444+
],
12361445
);
12371446

1238-
let indent = line_indent(&elab.source, stmt.span.start);
12391447
let mut fixes: Vec<Fix> = Vec::new();
12401448
match next.first() {
12411449
None => {
@@ -2183,4 +2391,60 @@ mod fix_tests {
21832391
"should explain why: {ds:?}"
21842392
);
21852393
}
2394+
2395+
#[test]
2396+
fn rewrite_hole_forward_suggestion_is_multiline() {
2397+
// The forward suggestion breaks each argument onto its own line, indented
2398+
// two spaces past the `by`.
2399+
let src = "import core(forward);\nsort T : Sort;\nop a : -> T;\nop b : -> T;\nop f : T -> T;\naxiom ab |- a = b;\nlemma l\n |- f(a) = f(a);\nproof\n by rewrite(ab)?;\nwip;\n";
2400+
let ds = diags(src);
2401+
assert_spans_valid(src, &ds);
2402+
let fix = ds
2403+
.iter()
2404+
.flat_map(|d| &d.fixes)
2405+
.find(|f| f.replacement.starts_with("by forward("))
2406+
.expect("ground rewrite should offer a `forward` fix");
2407+
// `by forward(` then each argument on its own 4-space-indented line (the
2408+
// `by` sits at 2 spaces, arguments two further).
2409+
assert!(
2410+
fix.replacement.starts_with("by forward(\n T,\n a,\n b,\n ab,\n λ"),
2411+
"arguments should be one-per-line: {:?}",
2412+
fix.replacement
2413+
);
2414+
}
2415+
2416+
#[test]
2417+
fn rewrite_hole_infers_axiom_arguments() {
2418+
// A bare axiom reference: the arguments are solved by matching the axiom's
2419+
// left side `pop(A, push(A, x, s))` against the goal.
2420+
let stack = "import core(forward);\nsort Stack : Sort -> Sort;\nop empty : forall (A : Sort) st -> Stack(A);\nop push : forall (A : Sort) st A * Stack(A) -> Stack(A);\nop pop : forall (A : Sort) st Stack(A) -> Stack(A);\nop top : forall (A : Sort) st Stack(A) -> A;\naxiom pop_push(A : Sort, x : A, s : Stack(A)) |- pop(A, push(A, x, s)) = s;\nlemma one_pop(A : Sort, a b : A)\n |- top(A, pop(A, push(A, a, push(A, b, empty(A))))) = b;\nproof\n by rewrite(pop_push)?;\nwip;\n";
2421+
let ds = diags(stack);
2422+
assert_spans_valid(stack, &ds);
2423+
let fix = ds
2424+
.iter()
2425+
.flat_map(|d| &d.fixes)
2426+
.find(|f| f.replacement.starts_with("by rewrite("))
2427+
.expect("bare axiom ref should infer its arguments");
2428+
assert_eq!(
2429+
fix.replacement,
2430+
"by rewrite(pop_push(A, a, push(A, b, empty(A))))?;"
2431+
);
2432+
}
2433+
2434+
#[test]
2435+
fn rewrite_hole_infers_from_named_holes() {
2436+
// Explicit `?` holes (some concrete, some inferred) resolve the same way.
2437+
let stack = "import core(forward);\nsort Stack : Sort -> Sort;\nop empty : forall (A : Sort) st -> Stack(A);\nop push : forall (A : Sort) st A * Stack(A) -> Stack(A);\nop pop : forall (A : Sort) st Stack(A) -> Stack(A);\nop top : forall (A : Sort) st Stack(A) -> A;\naxiom pop_push(A : Sort, x : A, s : Stack(A)) |- pop(A, push(A, x, s)) = s;\nlemma one_pop(A : Sort, a b : A)\n |- top(A, pop(A, push(A, a, push(A, b, empty(A))))) = b;\nproof\n by rewrite(pop_push(A, ?x, ?s))?;\nwip;\n";
2438+
let ds = diags(stack);
2439+
assert_spans_valid(stack, &ds);
2440+
let fix = ds
2441+
.iter()
2442+
.flat_map(|d| &d.fixes)
2443+
.find(|f| f.replacement.starts_with("by rewrite("))
2444+
.expect("named holes should be inferred");
2445+
assert_eq!(
2446+
fix.replacement,
2447+
"by rewrite(pop_push(A, a, push(A, b, empty(A))))?;"
2448+
);
2449+
}
21862450
}

0 commit comments

Comments
 (0)