logicaffeine_kernel/annotation.rs
1//! Annotations: what the source said about a subterm that no judgment of the kernel reads.
2//!
3//! [`Term::Annotated`] wraps one subterm with one [`Annotation`]:
4//!
5//! * [`Annotation::Binder`] — how the argument of the wrapped `fun` or `Π` is supplied, Lean's
6//! `BinderInfo`, for a binder no declaration name records the kind of. `set_binder_kinds` keys a
7//! declaration's telescope on its NAME; an anonymous `fun {α} a => a` passed as an argument, a
8//! match case or a motive has no name, and this is where its kind lives.
9//! * [`Annotation::Source`] — where the subterm came from ([`Provenance`]): a position in a source
10//! file, or one entry of a Lean `Expr.mdata` map, key and exported value.
11//!
12//! # Transparent
13//!
14//! An annotation changes no verdict and no result, and that is enforced at the representation
15//! rather than hoped for at each use:
16//!
17//! * `Term`'s `==` is the kernel's identity, so it does not see annotations: a term and its
18//! annotated copy are EQUAL. [`Term::identical`] is the comparison that does see them.
19//! * The kernel's judgments read kernel meaning only. Each checking door — `infer_type`,
20//! `is_subtype`, `def_eq`, the `normalize` family, `recheck`, `double_check` — erases the
21//! annotations of the terms it is handed ONCE, at the outermost call, before the depth door
22//! measures anything, and the [`Context`](crate::Context) stores every term erased: its locals,
23//! and every definition, declaration, inductive, constructor, instance, coercion and default it
24//! registers. Inside a kernel call no annotation exists; the arm every `match` on `Term` carries
25//! for this variant sees through it all the same, and a walk that only collects —
26//! `axiom_dependencies`, free variables, mentioned globals — reads straight through one.
27//! * The term store interns the ERASED term, so an annotation never splits sharing: a term and its
28//! annotated copy intern to the same id.
29//! * A certificate carries kernel meaning only: the variant has no serialized form, so a capture
30//! that forgot to erase fails loudly instead of widening the certificate language.
31//!
32//! What reads annotations is everything outside the kernel's judgments: the elaborator (a binder
33//! kind on an anonymous `fun` inserts implicit arguments exactly as a declaration's recorded kinds
34//! do) and the interface printer and parser (`@[…] t`, read back to the same annotated term).
35//!
36//! Every walk here runs on an explicit work stack: annotations sit on imported terms, which are
37//! deep, and asking whether a term carries one must never be what exhausts the stack.
38
39use crate::elaborate::ParamKind;
40use crate::term::Term;
41use std::fmt;
42
43/// One piece of information about how a subterm was written. The elaborator and the
44/// interface printer read it; every kernel judgment erases it first.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Annotation {
47 /// How the argument of the wrapped `fun` or `Π` is supplied — Lean's `BinderInfo`.
48 Binder(ParamKind),
49 /// Where the wrapped subterm came from.
50 Source(Provenance),
51}
52
53/// Where a subterm came from.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum Provenance {
56 /// A position in a source text.
57 Position(SourcePosition),
58 /// One entry of a Lean `Expr.mdata` map: its key, and the exporter's rendering of its
59 /// `DataValue` (`Lean.DataValue.ofBool true`).
60 LeanMetadata {
61 /// The metadata key (`noImplicitLambda`, `_inaccessible`, `pp.analysis`, …).
62 key: String,
63 /// The value, as the exporter spells it.
64 value: String,
65 },
66}
67
68/// A position in a source text, in Lean's convention: the LINE is 1-based and the COLUMN is
69/// 0-based and counted in Unicode scalar values. Held in its canonical spelling, the one Lean
70/// prints a diagnostic's position in — `file:line:column` — so a position IS its text: two
71/// positions are equal exactly when they print the same, and reading one back needs no rendering.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct SourcePosition {
74 text: String,
75 file_len: usize,
76 line: u32,
77 column: u32,
78}
79
80impl SourcePosition {
81 /// The position `file:line:column`.
82 pub fn new(file: impl Into<String>, line: u32, column: u32) -> SourcePosition {
83 let file = file.into();
84 let file_len = file.len();
85 SourcePosition {
86 text: format!("{file}:{line}:{column}"),
87 file_len,
88 line,
89 column,
90 }
91 }
92
93 /// Read a position in its canonical spelling, `file:line:column`. The LAST two `:`-separated
94 /// fields are the line and the column, so a file name may itself contain `:`; the file is not
95 /// empty, the line is at least 1, and both numbers are written in canonical decimal (no sign,
96 /// no leading zero) so that reading and printing are inverse.
97 pub fn parse(text: &str) -> Option<SourcePosition> {
98 let mut fields = text.rsplitn(3, ':');
99 let column = canonical_number(fields.next()?)?;
100 let line = canonical_number(fields.next()?)?;
101 let file = fields.next()?;
102 if file.is_empty() || line == 0 {
103 return None;
104 }
105 Some(SourcePosition::new(file, line, column))
106 }
107
108 /// The file (or other source unit) the subterm was written in.
109 pub fn file(&self) -> &str {
110 &self.text[..self.file_len]
111 }
112
113 /// The 1-based line.
114 pub fn line(&self) -> u32 {
115 self.line
116 }
117
118 /// The 0-based column, in Unicode scalar values.
119 pub fn column(&self) -> u32 {
120 self.column
121 }
122
123 /// The canonical spelling, `file:line:column`.
124 pub fn as_str(&self) -> &str {
125 &self.text
126 }
127}
128
129/// A `u32` in canonical decimal: digits only, no leading zero unless it is `0`.
130fn canonical_number(digits: &str) -> Option<u32> {
131 let canonical = !digits.is_empty()
132 && digits.bytes().all(|b| b.is_ascii_digit())
133 && (digits == "0" || !digits.starts_with('0'));
134 canonical.then(|| digits.parse().ok()).flatten()
135}
136
137impl fmt::Display for SourcePosition {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 f.write_str(&self.text)
140 }
141}
142
143impl fmt::Display for Provenance {
144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145 match self {
146 Provenance::Position(position) => write!(f, "{position}"),
147 Provenance::LeanMetadata { key, value } => write!(f, "{key} := {value}"),
148 }
149 }
150}
151
152impl fmt::Display for Annotation {
153 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154 match self {
155 Annotation::Binder(kind) => write!(f, "binder {}", kind.lean_binder_info()),
156 Annotation::Source(Provenance::Position(position)) => {
157 write!(f, "source {:?}", position.as_str())
158 }
159 Annotation::Source(Provenance::LeanMetadata { key, value }) => {
160 write!(f, "mdata {key:?} {value:?}")
161 }
162 }
163 }
164}
165
166impl ParamKind {
167 /// The name of Lean's `BinderInfo` constructor for this kind — one each, a bijection.
168 pub fn lean_binder_info(self) -> &'static str {
169 match self {
170 ParamKind::Explicit => "default",
171 ParamKind::Implicit => "implicit",
172 ParamKind::StrictImplicit => "strictImplicit",
173 ParamKind::Instance => "instImplicit",
174 }
175 }
176
177 /// The kind a Lean `BinderInfo` constructor names, or `None` for a name Lean does not have.
178 pub fn from_lean_binder_info(name: &str) -> Option<ParamKind> {
179 match name {
180 "default" => Some(ParamKind::Explicit),
181 "implicit" => Some(ParamKind::Implicit),
182 "strictImplicit" => Some(ParamKind::StrictImplicit),
183 "instImplicit" => Some(ParamKind::Instance),
184 _ => None,
185 }
186 }
187}
188
189/// Push `t`'s immediate subterms, the one child enumeration every walk in this file shares.
190fn push_children<'t>(t: &'t Term, work: &mut Vec<&'t Term>) {
191 match t {
192 Term::Sort(_)
193 | Term::Var(_)
194 | Term::Global(_)
195 | Term::Const { .. }
196 | Term::Lit(_)
197 | Term::Hole => {}
198 Term::Pi {
199 param_type,
200 body_type: body,
201 ..
202 }
203 | Term::Lambda {
204 param_type, body, ..
205 } => {
206 work.push(param_type);
207 work.push(body);
208 }
209 Term::App(f, a) => {
210 work.push(f);
211 work.push(a);
212 }
213 Term::Match {
214 discriminant,
215 motive,
216 cases,
217 } => {
218 work.push(discriminant);
219 work.push(motive);
220 work.extend(cases.iter());
221 }
222 Term::Fix { body, .. } => work.push(body),
223 Term::MutualFix { defs, .. } => work.extend(defs.iter().map(|(_, body)| body)),
224 Term::Let {
225 ty, value, body, ..
226 } => {
227 work.push(ty);
228 work.push(value);
229 work.push(body);
230 }
231 Term::Annotated { term, .. } => work.push(term),
232 }
233}
234
235/// Replace every annotated node of `root` by the term it wraps, in place.
236fn erase_in_place(root: &mut Term) {
237 let mut work: Vec<&mut Term> = vec![root];
238 while let Some(node) = work.pop() {
239 if let Term::Annotated { term, .. } = node {
240 let inner = std::mem::replace(term.as_mut(), Term::Hole);
241 *node = inner;
242 work.push(node);
243 continue;
244 }
245 match node {
246 Term::Sort(_)
247 | Term::Var(_)
248 | Term::Global(_)
249 | Term::Const { .. }
250 | Term::Lit(_)
251 | Term::Hole => {}
252 Term::Pi {
253 param_type,
254 body_type: body,
255 ..
256 }
257 | Term::Lambda {
258 param_type, body, ..
259 } => {
260 work.push(param_type);
261 work.push(body);
262 }
263 Term::App(f, a) => {
264 work.push(f);
265 work.push(a);
266 }
267 Term::Match {
268 discriminant,
269 motive,
270 cases,
271 } => {
272 work.push(discriminant);
273 work.push(motive);
274 work.extend(cases.iter_mut());
275 }
276 Term::Fix { body, .. } => work.push(body),
277 Term::MutualFix { defs, .. } => work.extend(defs.iter_mut().map(|(_, body)| body)),
278 Term::Let {
279 ty, value, body, ..
280 } => {
281 work.push(ty);
282 work.push(value);
283 work.push(body);
284 }
285 Term::Annotated { term, .. } => work.push(term),
286 }
287 }
288}
289
290impl Term {
291 /// `term`, carrying `annotation`.
292 pub fn annotated(annotation: Annotation, term: Term) -> Term {
293 Term::Annotated {
294 annotation,
295 term: Box::new(term),
296 }
297 }
298
299 /// The term under every annotation at this term's head.
300 pub fn peel_annotations(&self) -> &Term {
301 let mut current = self;
302 while let Term::Annotated { term, .. } = current {
303 current = term;
304 }
305 current
306 }
307
308 /// The annotations at this term's head, outermost first.
309 pub fn head_annotations(&self) -> impl Iterator<Item = &Annotation> {
310 let mut current = self;
311 std::iter::from_fn(move || match current {
312 Term::Annotated { annotation, term } => {
313 current = term;
314 Some(annotation)
315 }
316 _ => None,
317 })
318 }
319
320 /// The kind recorded on this binder: the outermost [`Annotation::Binder`] at the head of a
321 /// `fun` or `Π`. `None` for a binder that records none, and for a term that is not a binder.
322 pub fn binder_kind(&self) -> Option<ParamKind> {
323 if !matches!(
324 self.peel_annotations(),
325 Term::Lambda { .. } | Term::Pi { .. }
326 ) {
327 return None;
328 }
329 self.head_annotations()
330 .find_map(|annotation| match annotation {
331 Annotation::Binder(kind) => Some(*kind),
332 Annotation::Source(_) => None,
333 })
334 }
335
336 /// Where this term came from: the outermost [`Annotation::Source`] at its head.
337 pub fn provenance(&self) -> Option<&Provenance> {
338 self.head_annotations()
339 .find_map(|annotation| match annotation {
340 Annotation::Source(provenance) => Some(provenance),
341 Annotation::Binder(_) => None,
342 })
343 }
344
345 /// Whether an annotation stands anywhere in this term.
346 pub fn has_annotations(&self) -> bool {
347 let mut work = vec![self];
348 while let Some(t) = work.pop() {
349 if matches!(t, Term::Annotated { .. }) {
350 return true;
351 }
352 push_children(t, &mut work);
353 }
354 false
355 }
356
357 /// This term with every annotation erased — its kernel meaning, the only thing a kernel
358 /// judgment reads. Erasing is idempotent, and `t.strip_annotations() == t` always holds.
359 pub fn strip_annotations(&self) -> Term {
360 self.clone().into_stripped()
361 }
362
363 /// [`strip_annotations`](Self::strip_annotations) on a term the caller owns: a term with no
364 /// annotation is handed back as it is, without a copy.
365 pub fn into_stripped(mut self) -> Term {
366 if self.has_annotations() {
367 erase_in_place(&mut self);
368 }
369 self
370 }
371
372 /// Structural equality that SEES annotations: same shape, same names, same annotations in
373 /// the same places. `==` is the kernel's identity and ignores them.
374 pub fn identical(&self, other: &Term) -> bool {
375 let mut work = vec![(self, other)];
376 while let Some(pair) = work.pop() {
377 match pair {
378 (
379 Term::Annotated {
380 annotation: a,
381 term: s,
382 },
383 Term::Annotated {
384 annotation: b,
385 term: t,
386 },
387 ) => {
388 if a != b {
389 return false;
390 }
391 work.push((s, t));
392 }
393 (Term::Sort(a), Term::Sort(b)) if a == b => {}
394 (Term::Var(a), Term::Var(b)) | (Term::Global(a), Term::Global(b)) if a == b => {}
395 (Term::Const { name: m, levels: a }, Term::Const { name: n, levels: b })
396 if m == n && a == b => {}
397 (
398 Term::Pi {
399 param: p,
400 param_type: s1,
401 body_type: s2,
402 },
403 Term::Pi {
404 param: q,
405 param_type: t1,
406 body_type: t2,
407 },
408 )
409 | (
410 Term::Lambda {
411 param: p,
412 param_type: s1,
413 body: s2,
414 },
415 Term::Lambda {
416 param: q,
417 param_type: t1,
418 body: t2,
419 },
420 ) if p == q => {
421 work.push((s1, t1));
422 work.push((s2, t2));
423 }
424 (Term::App(f, a), Term::App(g, b)) => {
425 work.push((f, g));
426 work.push((a, b));
427 }
428 (
429 Term::Match {
430 discriminant: d,
431 motive: m,
432 cases: cs,
433 },
434 Term::Match {
435 discriminant: e,
436 motive: n,
437 cases: ds,
438 },
439 ) if cs.len() == ds.len() => {
440 work.push((d, e));
441 work.push((m, n));
442 work.extend(cs.iter().zip(ds.iter()));
443 }
444 (Term::Fix { name: m, body: s }, Term::Fix { name: n, body: t }) if m == n => {
445 work.push((s, t));
446 }
447 (Term::MutualFix { defs: a, index: i }, Term::MutualFix { defs: b, index: j })
448 if i == j
449 && a.len() == b.len()
450 && a.iter().zip(b.iter()).all(|((m, _), (n, _))| m == n) =>
451 {
452 work.extend(a.iter().zip(b.iter()).map(|((_, s), (_, t))| (s, t)));
453 }
454 (
455 Term::Let {
456 name: m,
457 ty: s1,
458 value: s2,
459 body: s3,
460 },
461 Term::Let {
462 name: n,
463 ty: t1,
464 value: t2,
465 body: t3,
466 },
467 ) if m == n => {
468 work.push((s1, t1));
469 work.push((s2, t2));
470 work.push((s3, t3));
471 }
472 (Term::Lit(a), Term::Lit(b)) if a == b => {}
473 (Term::Hole, Term::Hole) => {}
474 _ => return false,
475 }
476 }
477 true
478 }
479}
480
481thread_local! {
482 /// Whether a kernel door on this thread has already erased the terms of the call under way.
483 static ERASED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
484}
485
486/// Holds [`ERASED`] for the call a door opened, restoring it on the way out — including on a
487/// panic, so the flag is never left stale.
488struct ErasedScope(bool);
489
490impl Drop for ErasedScope {
491 fn drop(&mut self) {
492 ERASED.with(|erased| erased.set(self.0));
493 }
494}
495
496impl ErasedScope {
497 fn open() -> ErasedScope {
498 ErasedScope(ERASED.with(|erased| erased.replace(true)))
499 }
500}
501
502/// Whether the call under way on this thread was entered through a kernel door, so every term it
503/// reads — its input and everything the context holds — is annotation-free.
504pub(crate) fn within_erased_call() -> bool {
505 ERASED.with(|erased| erased.get())
506}
507
508/// THE DOOR of a kernel judgment over one term, in the shape of `term_store::deepen_stack`:
509/// `Some(judge(erased))` at the OUTERMOST call, where the term is erased once (and not copied at
510/// all when it carries no annotation), and `None` on the re-entrant call inside `judge`, which then
511/// runs its own body. The scan is paid once per outermost call, before the depth door measures
512/// anything — an annotation adds nesting to the representation and none to the term, so the kernel
513/// must refuse exactly the terms it would refuse without it.
514pub(crate) fn erased<R>(term: &Term, judge: impl FnOnce(&Term) -> R) -> Option<R> {
515 if within_erased_call() {
516 return None;
517 }
518 let _scope = ErasedScope::open();
519 Some(if term.has_annotations() {
520 judge(&term.strip_annotations())
521 } else {
522 judge(term)
523 })
524}
525
526/// [`erased`] for a judgment over two terms.
527pub(crate) fn erased_pair<R>(
528 a: &Term,
529 b: &Term,
530 judge: impl FnOnce(&Term, &Term) -> R,
531) -> Option<R> {
532 if within_erased_call() {
533 return None;
534 }
535 let _scope = ErasedScope::open();
536 Some(match (a.has_annotations(), b.has_annotations()) {
537 (false, false) => judge(a, b),
538 (true, false) => judge(&a.strip_annotations(), b),
539 (false, true) => judge(a, &b.strip_annotations()),
540 (true, true) => judge(&a.strip_annotations(), &b.strip_annotations()),
541 })
542}
543
544/// A term as the [`Context`](crate::Context) stores it: erased, so a lookup never hands an
545/// annotation into a kernel call. A term stored from inside a kernel call is already erased, and
546/// is not scanned again.
547pub(crate) fn stored(term: Term) -> Term {
548 if within_erased_call() {
549 term
550 } else {
551 term.into_stripped()
552 }
553}
554
555#[cfg(test)]
556#[path = "../../logicaffeine_tests/unit/logicaffeine_kernel/annotation/tests.rs"]
557mod tests;