Skip to content

Commit 83b4c6c

Browse files
refactor: handle error and warning logs consistently (#862)
Before, most Verso monads and functions threaded IO actions for logging errors, and each monad had a bespoke error reporting function. These grew organically, and were largely a set of IO actions attached to various reader contexts. Now, they're all unified with a single logging type class, a single canonical monad transformer, as well as a single way to actually print the error report to the console when finished. Many type parameters on contexts could be removed. This makes it much easier to work on Verso. Additionally, warning logs were added in order to prepare for an upcoming feature.
1 parent 8bcbd2b commit 83b4c6c

44 files changed

Lines changed: 942 additions & 616 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

doc/UsersGuide/Markup.lean

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,10 @@ block_extension MarkupExample (title : String) where
134134
traverse _ _ _ := pure none
135135
toHtml := some fun _goI goB _id data contents => open Verso.Output.Html in do
136136
let .str title := data
137-
| Verso.Doc.Html.HtmlT.logError s!"Expected a string, got {data.compress}"
137+
| reportError s!"Expected a string, got {data.compress}"
138138
return .empty
139139
let #[stx, parsed] := contents
140-
| Verso.Doc.Html.HtmlT.logError s!"Expected two blocks, got {contents.size}"
140+
| reportError s!"Expected two blocks, got {contents.size}"
141141
return .empty
142142
pure {{
143143
<div class="markup-example">
@@ -212,10 +212,10 @@ r#"
212212
]
213213
toTeX := some fun _goI _goB _id data contents => open Verso.Output.TeX in open Verso.Doc.TeX in do
214214
let .str title := data
215-
| logError s!"Expected title as string, got {data.compress}"
215+
| reportError s!"Expected title as string, got {data.compress}"
216216
return \TeX{}
217217
let #[.code stx, .code parsed] := contents
218-
| logError s!"Expected two code blocks, got {contents.size}"
218+
| reportError s!"Expected two code blocks, got {contents.size}"
219219
return \TeX{}
220220
pure \TeX{
221221
\begin{markupexample}{\Lean{title}}

doc/UsersGuide/Releases.lean

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Author: Emilio J. Gallego Arias
66

77
import VersoManual
88

9+
import UsersGuide.Releases.«v4_31_0»
910
import UsersGuide.Releases.«v4_30_0»
1011
import UsersGuide.Releases.«v4_29_0»
1112
import UsersGuide.Releases.«v4_28_0»
@@ -28,6 +29,7 @@ Verso versioning follows Lean's.
2829
This means that we release a new version for each Lean release, usually once per month.
2930
In particular, note that Verso doesn't follow the [semantic versioning model](https://semver.org/).
3031

32+
{include 0 UsersGuide.Releases.«v4_31_0»}
3133
{include 0 UsersGuide.Releases.«v4_30_0»}
3234
{include 0 UsersGuide.Releases.«v4_29_0»}
3335
{include 0 UsersGuide.Releases.«v4_28_0»}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/-
2+
Copyright (c) 2026 Lean FRO LLC. All rights reserved.
3+
Released under Apache 2.0 license as described in the file LICENSE.
4+
Author: David Thrane Christiansen
5+
-/
6+
7+
import VersoManual
8+
9+
open Verso.Genre Manual InlineLean
10+
11+
12+
#doc (Manual) "Verso 4.31.0 (unreleased)" =>
13+
%%%
14+
tag := "release-v4.31.0"
15+
file := "v4.31.0"
16+
%%%
17+
18+
* Refactored the build's error reporting into a {ref "feat-build-log"}[logging abstraction] with severities and structured source locations, improving the consistency of Verso's internal APIs and external error reports.
19+
While this was primarily an internal change, there is a {ref "feat-build-log-breaking"}[breaking change] to the signature of {name}`Verso.Genre.Manual.ExtraStep`.
20+
21+
# Logging Abstraction
22+
%%%
23+
tag := "feat-build-log"
24+
%%%
25+
26+
The build pipeline previously threaded a bare `String → IO Unit` error callback through traversal and output generation, and several monads carried their own ad-hoc error loggers.
27+
There was no way to emit a warning.
28+
29+
This release introduces {name}`Verso.MonadBuildLog`, a uniform logging interface shared across the genres.
30+
A message carries a {name}`Verso.Severity` (either {name}`Verso.Severity.error` or {name}`Verso.Severity.warning`) and an optional source location.
31+
32+
33+
## Breaking Change: `ExtraStep`
34+
%%%
35+
tag := "feat-build-log-breaking"
36+
%%%
37+
38+
{name}`Verso.Genre.Manual.ExtraStep` no longer takes a `String → IO Unit` error callback.
39+
Instead, it runs in a monad that has an instance of {name}`Verso.MonadBuildLog`, so a step can emit both errors and warnings with {name}`Verso.reportError` and {name}`Verso.reportWarning`.

src/tests/TestMain.lean

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ def testTexOutput
5656

5757
let runTest : IO Unit :=
5858
open Verso Genre Manual in do
59-
let logError (msg : String) := IO.eprintln msg
60-
ReaderT.run (emitTeX logError versoConfig doc.toPart) extension_impls%
59+
let logger ← Verso.Logger.new
60+
emitTeX versoConfig doc.toPart |>.run extension_impls% |>.run logger
6161

6262
Verso.Integration.runTests { config with
6363
testDir := "src/tests/integration" / dir,
@@ -259,8 +259,99 @@ def testSetupLiterate (_ : Config) : IO Unit := do
259259

260260
IO.println " All setup-literate tests passed."
261261

262+
open Verso in
263+
def testBuildLog (_ : Config) : IO Unit := do
264+
IO.println "Running build-log tests..."
265+
-- A message logged with a position is saved with that location (a location always names a file).
266+
let logger ← Logger.new
267+
let pos : Lean.Lsp.Position := { line := 4, character := 2 }
268+
(reportError "boom" (some { file := "PosSave.lean", span := .pos pos }) : BuildLogT IO Unit).run logger
269+
let errs ← logger.errors
270+
let some m := errs[0]?
271+
| throw <| IO.userError s!"expected 1 saved error, got {errs.size}"
272+
unless m.severity == .error do throw <| IO.userError "expected error severity"
273+
match m.loc with
274+
| some { file := "PosSave.lean", span := .pos p } =>
275+
unless p.line == 4 && p.character == 2 do
276+
throw <| IO.userError "saved position does not match the logged span"
277+
| _ => throw <| IO.userError "expected a saved `PosSave.lean` `pos` location"
278+
279+
-- A `range` span is likewise saved.
280+
let logger2 ← Logger.new
281+
let r : Lean.Lsp.Range :=
282+
{ start := { line := 1, character := 0 }, «end» := { line := 1, character := 5 } }
283+
(reportWarning "careful" (some { file := "RangeSave.lean", span := .range r }) : BuildLogT IO Unit).run logger2
284+
let some w := (← logger2.warnings)[0]?
285+
| throw <| IO.userError "expected 1 saved warning"
286+
match w.loc with
287+
| some { span := .range _, .. } => pure ()
288+
| _ => throw <| IO.userError "expected a `range` span to be saved"
289+
290+
-- Range formatting defers to Lean's `mkErrorStringWithPos`: `file:line:col-line:col`, 1-based
291+
-- line, 0-based column, with the full end position even within one line (no `line:col-col` collapse).
292+
let crossLine : LogMessage :=
293+
{ severity := .error, text := "msg",
294+
loc := some { file := "CrossLine.lean",
295+
span := .range { start := { line := 19, character := 4 }, «end» := { line := 20, character := 7 } } } }
296+
unless crossLine.format == "CrossLine.lean:20:4-21:7: msg" do
297+
throw <| IO.userError s!"cross-line range formatted as \"{crossLine.format}\""
298+
let sameLine : LogMessage :=
299+
{ severity := .error, text := "msg",
300+
loc := some { file := "SameLine.lean",
301+
span := .range { start := { line := 42, character := 4 }, «end» := { line := 42, character := 21 } } } }
302+
unless sameLine.format == "SameLine.lean:43:4-43:21: msg" do
303+
throw <| IO.userError s!"same-line range formatted as \"{sameLine.format}\""
304+
305+
-- A located message is formatted uniformly as `file:line:col: text`.
306+
let loggerF ← Logger.new
307+
let errBufF ← IO.mkRef ({} : IO.FS.Stream.Buffer)
308+
IO.withStderr (IO.FS.Stream.ofBuffer errBufF) <|
309+
(reportError "bad term" (some { file := "FileLoc.lean", span := .pos { line := 6, character := 3 } })
310+
: BuildLogT IO Unit).run loggerF
311+
let some mF := (← loggerF.errors)[0]?
312+
| throw <| IO.userError "expected 1 saved error with a file location"
313+
unless mF.loc.map (·.file) == some "FileLoc.lean" do
314+
throw <| IO.userError "saved location should carry the filename"
315+
unless hasSubstring (String.fromUTF8! (← errBufF.get).data) "FileLoc.lean:7:3: bad term" do
316+
throw <| IO.userError "a file location should format as file:line:col:"
317+
318+
-- A single logging action can emit both severities; errors set the exit code, warnings do not.
319+
let logger3 ← Logger.new
320+
(do reportError "e1"; reportWarning "w1"; reportError "e2" : BuildLogT IO Unit).run logger3
321+
unless (← logger3.errors).size == 2 do throw <| IO.userError "expected 2 errors"
322+
unless (← logger3.warnings).size == 1 do throw <| IO.userError "expected 1 warning"
323+
unless (← logger3.exitCode) == 1 do throw <| IO.userError "errors must yield a non-zero exit code"
324+
325+
let logger4 ← Logger.new
326+
(reportWarning "just a warning" : BuildLogT IO Unit).run logger4
327+
unless (← logger4.exitCode) == 0 do
328+
throw <| IO.userError "warnings must not affect the exit code"
329+
330+
-- Logging prints to the *ambient* stderr, resolved at log time: a logger created before a
331+
-- stderr redirection still writes into the redirected stream, and nothing goes to stdout.
332+
let logger5 ← Logger.new
333+
let outBuf ← IO.mkRef ({} : IO.FS.Stream.Buffer)
334+
let errBuf ← IO.mkRef ({} : IO.FS.Stream.Buffer)
335+
IO.withStdout (IO.FS.Stream.ofBuffer outBuf) <|
336+
IO.withStderr (IO.FS.Stream.ofBuffer errBuf) <|
337+
(do
338+
reportError "first problem" (some { file := "X.lean", span := .pos { line := 0, character := 0 } })
339+
reportWarning "second problem" : BuildLogT IO Unit).run logger5
340+
let errText := String.fromUTF8! (← errBuf.get).data
341+
let outText := String.fromUTF8! (← outBuf.get).data
342+
unless hasSubstring errText "X.lean:1:0: first problem" do
343+
throw <| IO.userError s!"stderr buffer is missing the formatted error; got: {errText}"
344+
unless hasSubstring errText "second problem" do
345+
throw <| IO.userError s!"stderr buffer is missing the warning; got: {errText}"
346+
unless outText.isEmpty do
347+
throw <| IO.userError s!"logging must not write to stdout; got: {outText}"
348+
unless (← logger5.errors).size == 1 && (← logger5.warnings).size == 1 do
349+
throw <| IO.userError "redirected logging should still accumulate into the logger's buffers"
350+
IO.println " All build-log tests passed."
351+
262352
open Verso.Integration in
263353
def tests := [
354+
testBuildLog,
264355
testSerialization,
265356
testSearchJs,
266357
testBlog,

src/tests/Tests/GenericCode.lean

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ info: Verso.Output.Html.tag
5858
(Verso.Output.Html.text true "(define (zero f z) z)\n(define (succ n) (lambda (f x) (f (n f z))))\n")])])
5959
-/
6060
#guard_msgs in
61-
#eval Doc.Genre.none.toHtml (m:=Id) {logError := fun _ => ()} () () {} {} {} code1.toPart |>.run .empty |>.fst
61+
#eval Doc.Genre.none.toHtml (m := Id) {} () () {} {} {} code1.toPart |>.run .empty |>.fst
6262

6363

6464
/- ----- -/

src/tests/Tests/TexUtil.lean

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,24 @@ This requires the `IO` monad because `TraverseM` does.
1919
-/
2020
def toTex (block : Doc.Block Genre.Manual) : IO Output.TeX := do
2121
let extension_impls := extension_impls%
22+
let logger ← Verso.Logger.new
2223

2324
-- Traversal monadic data
24-
let traverseContext : TraverseContext := {
25-
logError msg := IO.println msg,
26-
}
25+
let traverseContext : TraverseContext := {}
2726
let traverseState : TraverseState := .initialize {}
2827

2928
-- Traverse the block. This shadows both `block` and `traverseState`.
3029
-- This is where we engage with the IO at the bottom of TraverseM.
31-
let ⟨block, traverseState⟩ ← Doc.Genre.traverseBlock Genre.Manual block
32-
|>.run extension_impls traverseContext traverseState
30+
let ⟨block, traverseState⟩ ←
31+
Doc.Genre.traverseBlock Genre.Manual block
32+
|>.run extension_impls traverseContext traverseState logger
3333

3434
-- Options for TeX
35-
let options : Doc.TeX.Options Genre.Manual (ReaderT ExtensionImpls IO) := {
36-
headerLevel := none,
37-
logError msg := IO.println msg,
35+
let options : Doc.TeX.Options Genre.Manual := {
36+
headerLevel := none
3837
}
3938

4039
-- Convert the block to TeX
41-
block.toTeX.run ⟨options, traverseContext, traverseState, {}⟩ |>.run' {} |>.run extension_impls
40+
block.toTeX
41+
|>.run ⟨options, traverseContext, traverseState, {}⟩
42+
|>.run' {} |>.run extension_impls |>.run logger

src/verso-blog/VersoBlog.lean

Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -835,39 +835,33 @@ Optional parameters:
835835
def blogMain (theme : Theme) (site : Site) (linkTargets : Code.LinkTargets TraverseContext := {})
836836
(options : List String) (components : Components := by exact %registered_components)
837837
(header : String := Html.doctype) :
838-
IO UInt32 := do
839-
let hasError ← IO.mkRef false
840-
let logError msg := do hasError.set true; IO.eprintln msg
841-
let cfg ← opts {logError := logError} options
842-
let (site, xref) ← site.traverse cfg components
843-
let initGenCtx : Generate.Context := {
844-
theme, site,
845-
ctxt := { path := .root, config := cfg, components },
846-
xref := xref,
847-
dir := cfg.destination,
848-
config := cfg,
849-
header := header,
850-
linkTargets := linkTargets,
851-
components := components
852-
}
853-
let (((), st), _) ← site.generate theme initGenCtx .empty {}
854-
IO.FS.writeFile (cfg.destination.join "-verso-docs.json") (toString st.dedup.docJson)
855-
for (name, content, srcMap?) in xref.jsFiles do
856-
FS.ensureDir (cfg.destination.join "-verso-data")
857-
IO.FS.writeFile (cfg.destination.join "-verso-data" |>.join name) content
858-
if let some (name, content) := srcMap? then
838+
IO UInt32 :=
839+
withLogger fun logger => do
840+
let cfg ← opts {} options
841+
let (site, xref) ← site.traverse cfg components |>.run logger
842+
let initGenCtx : Generate.Context := {
843+
theme, site,
844+
ctxt := { path := .root, config := cfg, components },
845+
xref := xref,
846+
dir := cfg.destination,
847+
config := cfg,
848+
header := header,
849+
linkTargets := linkTargets,
850+
components := components
851+
}
852+
let (((), st), _) ← site.generate theme initGenCtx .empty {} |>.run logger
853+
IO.FS.writeFile (cfg.destination.join "-verso-docs.json") (toString st.dedup.docJson)
854+
for (name, content, srcMap?) in xref.jsFiles do
855+
FS.ensureDir (cfg.destination.join "-verso-data")
856+
IO.FS.writeFile (cfg.destination.join "-verso-data" |>.join name) content
857+
if let some (name, content) := srcMap? then
858+
IO.FS.writeFile (cfg.destination.join "-verso-data" |>.join name) content
859+
for (name, content, _) in theme.jsFiles do
860+
FS.ensureDir (cfg.destination.join "-verso-data")
861+
IO.FS.writeFile (cfg.destination.join "-verso-data" |>.join name) content
862+
for (name, content) in theme.cssFiles ++ xref.cssFiles do
863+
FS.ensureDir (cfg.destination.join "-verso-data")
859864
IO.FS.writeFile (cfg.destination.join "-verso-data" |>.join name) content
860-
for (name, content, _) in theme.jsFiles do
861-
FS.ensureDir (cfg.destination.join "-verso-data")
862-
IO.FS.writeFile (cfg.destination.join "-verso-data" |>.join name) content
863-
for (name, content) in theme.cssFiles ++ xref.cssFiles do
864-
FS.ensureDir (cfg.destination.join "-verso-data")
865-
IO.FS.writeFile (cfg.destination.join "-verso-data" |>.join name) content
866-
if (← hasError.get) then
867-
IO.eprintln "Errors were encountered!"
868-
return 1
869-
else
870-
return 0
871865
where
872866
opts (cfg : Config)
873867
| ("--output"::dir::more) => opts {cfg with destination := dir} more

src/verso-blog/VersoBlog/Basic.lean

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,6 @@ structure Config where
220220
destination : System.FilePath := "./_site"
221221
showDrafts : Bool := false
222222
postName : Date → String → String := defaultPostName
223-
logError : String → IO Unit
224223
remoteInfoConfigPath : Option System.FilePath := none
225224
verbose : Bool := false
226225
deriving Inhabited
@@ -230,8 +229,9 @@ class MonadConfig (m : Type → Type u) where
230229

231230
export MonadConfig (currentConfig)
232231

233-
def logError [Monad m] [MonadConfig m] [MonadLiftT IO m] (message : String) : m Unit := do
234-
(← currentConfig).logError message
232+
@[deprecated "Use `Verso.reportError` instead." (since := "2026-05-29")]
233+
def logError [MonadBuildLog m] (message : String) : m Unit :=
234+
Verso.reportError message
235235

236236
structure Components where
237237
/--
@@ -483,7 +483,7 @@ instance : BEq TraverseState where
483483
err1.toList == err2.toList &&
484484
rem1 == rem2
485485

486-
abbrev TraverseM := ReaderT Blog.TraverseContext (StateT Blog.TraverseState IO)
486+
abbrev TraverseM := ReaderT Blog.TraverseContext (StateT Blog.TraverseState (BuildLogT IO))
487487

488488
instance : MonadConfig TraverseM where
489489
currentConfig := do pure (← read).config

src/verso-blog/VersoBlog/Component.lean

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def State.freshId (state : State) : String × State := Id.run do
8383

8484
end Component
8585

86-
abbrev ComponentM := ReaderT Components (StateT Component.State IO)
86+
abbrev ComponentM := ReaderT Components (StateT Component.State (BuildLogT IO))
8787

8888
abbrev HtmlM genre := HtmlT genre ComponentM
8989

@@ -259,7 +259,7 @@ def deJson [Monad m] [MonadQuotation m]
259259
let (x, t) := b
260260
`(Lean.Parser.Term.doSeqItem| let $x ← match FromJson.fromJson? (α := $t) $x with
261261
| .error e => do
262-
(HtmlT.logError e : HtmlM Page Unit)
262+
(reportError e : HtmlM Page Unit)
263263
return Html.empty
264264
| .ok v => pure v)
265265

@@ -288,7 +288,7 @@ elab_rules : command
288288
$noJson*
289289
($tm id json goI goB contents)
290290
| _ => do
291-
HtmlT.logError s!"Expected array, got {json}"
291+
reportError s!"Expected array, got {json}"
292292
return .empty)
293293
let other := toHtml.toArray ++ other
294294
let cmd2 ←
@@ -340,7 +340,7 @@ elab_rules : command
340340
$noJson*
341341
($tm id json goI contents)
342342
| _ => do
343-
HtmlT.logError s!"Expected array, got {json}"
343+
reportError s!"Expected array, got {json}"
344344
return .empty)
345345
let other := toHtml.toArray ++ other
346346
let cmd2 ←

0 commit comments

Comments
 (0)