Repository: leanprover/lean4
Pull request #7366: feat: 'unsolved goals' & 'goals accomplished' diagnostics
Author: login: mhuisi
Labels: name: builds-mathlib
description: CI has verified that Mathlib builds against this PR
name: toolchain-available
description: A toolchain is available for this PR, at leanprover/lean4-pr-releases:pr-release-NNNN
name: changelog-server
description: Language server, widgets, and IDE extensions
Created: 2025-03-06T14:47:28Z | Merged: 2025-03-07T14:30:41Z

This PR adds server-side support for dedicated 'unsolved goals' and 'goals accomplished' diagnostics that will have special support in the Lean 4 VS Code extension. The special 'unsolved goals' diagnostic is adapted from the 'unsolved goals' error diagnostic, while the 'goals accomplished' diagnostic is issued when a `theorem` or `Prop`-typed `example` has no errors or `sorry`s. The Lean 4 VS Code extension companion PR is at leanprover/vscode-lean4#585.

Specifically, this PR extends the diagnostics served by the language server with the following fields:
- `leanTags`: Custom tags that denote the kind of diagnostic that is being served. As opposed to the `code`, `leanTags` should never be displayed in the UI. Examples introduced by this PR are a tag to distinguish 'unsolved goals' errors from other diagnostics, as well as a tag to distinguish the new 'goals accomplished' diagnostic from other diagnostics.
- `isSilent`: Whether a diagnostic should not be displayed as a regular diagnostic in the editor. In VS Code, this means that the diagnostic is displayed in the InfoView under 'Messages', but that it will not be displayed under 'All Messages' and that it will also not be displayed with a squiggly line.

The `isSilent` field is also implemented for `Message` so that silent diagnostics can be logged in the elaborator. All code paths except for the language server that display diagnostics to users are adjusted to filter `Message`s with `isSilent := true`.


Diff:
diff --git a/src/Lean/AddDecl.lean b/src/Lean/AddDecl.lean
index ba20c3799550..f65ed7dcd2ae 100644
--- a/src/Lean/AddDecl.lean
+++ b/src/Lean/AddDecl.lean
@@ -81,7 +81,7 @@ where doAdd := do
   profileitM Exception "type checking" (← getOptions) do
     withTraceNode `Kernel (fun _ => return m!"typechecking declarations {decl.getNames}") do
       if !(← MonadLog.hasErrors) && decl.hasSorry then
-        logWarning m!"declaration uses 'sorry'"
+        logWarning <| .tagged `hasSorry m!"declaration uses 'sorry'"
       let env ← (← getEnv).addDeclAux (← getOptions) decl (← read).cancelTk?
         |> ofExceptKernelException
       setEnv env
diff --git a/src/Lean/Data/Lsp/Capabilities.lean b/src/Lean/Data/Lsp/Capabilities.lean
index 8b6c6d12a991..4af49333afbf 100644
--- a/src/Lean/Data/Lsp/Capabilities.lean
+++ b/src/Lean/Data/Lsp/Capabilities.lean
@@ -59,12 +59,29 @@ structure WorkspaceClientCapabilities where
   workspaceEdit? : Option WorkspaceEditClientCapabilities := none
   deriving ToJson, FromJson
 
+structure LeanClientCapabilities where
+  /--
+  Whether the client supports `DiagnosticWith.isSilent = true`.
+  If `none` or `false`, silent diagnostics will not be served to the client.
+  -/
+  silentDiagnosticSupport? : Option Bool := none
+  deriving ToJson, FromJson
+
 structure ClientCapabilities where
   textDocument? : Option TextDocumentClientCapabilities := none
   window?       : Option WindowClientCapabilities       := none
   workspace?    : Option WorkspaceClientCapabilities    := none
+  /-- Capabilties for Lean language server extensions. -/
+  lean?         : Option LeanClientCapabilities         := none
   deriving ToJson, FromJson
 
+def ClientCapabilities.silentDiagnosticSupport (c : ClientCapabilities) : Bool := Id.run do
+  let some lean := c.lean?
+    | return false
+  let some silentDiagnosticSupport := lean.silentDiagnosticSupport?
+    | return false
+  return silentDiagnosticSupport
+
 -- TODO largely unimplemented
 structure ServerCapabilities where
   textDocumentSync?         : Option TextDocumentSyncOptions := none
diff --git a/src/Lean/Data/Lsp/Diagnostics.lean b/src/Lean/Data/Lsp/Diagnostics.lean
index da81c222a3bd..9dca235f30bd 100644
--- a/src/Lean/Data/Lsp/Diagnostics.lean
+++ b/src/Lean/Data/Lsp/Diagnostics.lean
@@ -66,13 +66,42 @@ inductive DiagnosticTag where
 
 instance : FromJson DiagnosticTag := ⟨fun j =>
   match j.getNat? with
-  | Except.ok 1 => return DiagnosticTag.unnecessary
-  | Except.ok 2 => return DiagnosticTag.deprecated
-  | _      => throw "unknown DiagnosticTag"⟩
+  | Except.ok 1  => return DiagnosticTag.unnecessary
+  | Except.ok 2  => return DiagnosticTag.deprecated
+  | _            => throw "unknown DiagnosticTag"⟩
 
 instance : ToJson DiagnosticTag := ⟨fun
-  | DiagnosticTag.unnecessary => (1 : Nat)
-  | DiagnosticTag.deprecated  => (2 : Nat)⟩
+  | DiagnosticTag.unnecessary   => (1 : Nat)
+  | DiagnosticTag.deprecated    => (2 : Nat)⟩
+
+/--
+Custom diagnostic tags provided by the language server.
+We use a separate diagnostic field for this to avoid confusing LSP clients with our custom tags.
+-/
+inductive LeanDiagnosticTag where
+  /--
+  Diagnostics representing an "unsolved goals" error.
+  Corresponds to `MessageData.tagged `Tactic.unsolvedGoals ..`.
+  -/
+  | unsolvedGoals
+  /--
+  Diagnostics representing a "goals accomplished" silent message.
+  Corresponds to `MessageData.tagged `goalsAccomplished ..`.
+  -/
+  | goalsAccomplished
+  deriving Inhabited, BEq, Ord
+
+instance : FromJson LeanDiagnosticTag where
+  fromJson? j :=
+    match j.getNat? with
+    | .ok 1 => return LeanDiagnosticTag.unsolvedGoals
+    | .ok 2 => return LeanDiagnosticTag.goalsAccomplished
+    | _     => throw "unknown LeanDiagnosticTag"
+
+instance : ToJson LeanDiagnosticTag where
+  toJson
+  | .unsolvedGoals => (1 : Nat)
+  | .goalsAccomplished => (2 : Nat)
 
 /-- Represents a related message and source code location for a diagnostic.
     This should be used to point to code locations that cause or are related to
@@ -94,6 +123,13 @@ structure DiagnosticWith (α : Type) where
   /-- Extension: preserve semantic range of errors when truncating them for display purposes. -/
   fullRange? : Option Range := some range
   severity? : Option DiagnosticSeverity := none
+  /--
+  Extension: whether this diagnostic should not be displayed as a regular diagnostic in the editor.
+  In VS Code, this means that the diagnostic does not have a squiggly and is not displayed in
+  the InfoView under 'All Messages', but it is still displayed under 'Messages'.
+  Defaults to `false`.
+  -/
+  isSilent? : Option Bool := none
   /-- The diagnostic's code, which might appear in the user interface. -/
   code? : Option DiagnosticCode := none
   /-- A human-readable string describing the source of this diagnostic, e.g. 'typescript' or 'super lint'. -/
@@ -104,6 +140,8 @@ structure DiagnosticWith (α : Type) where
   message : α
   /-- Additional metadata about the diagnostic. -/
   tags? : Option (Array DiagnosticTag) := none
+  /-- Additional Lean-specific metadata about the diagnostic. -/
+  leanTags? : Option (Array LeanDiagnosticTag) := none
   /-- An array of related diagnostic information, e.g. when symbol-names within a scope collide all definitions can be marked via this property. -/
   relatedInformation? : Option (Array DiagnosticRelatedInformation) := none
   /-- A data entry field that is preserved between a `textDocument/publishDiagnostics` notification and `textDocument/codeAction` request. -/
diff --git a/src/Lean/Elab/GuardMsgs.lean b/src/Lean/Elab/GuardMsgs.lean
index a19bb73a1e17..846a4e0cdf85 100644
--- a/src/Lean/Elab/GuardMsgs.lean
+++ b/src/Lean/Elab/GuardMsgs.lean
@@ -152,6 +152,8 @@ def MessageOrdering.apply (mode : MessageOrdering) (msgs : List String) : List S
     let mut toCheck : MessageLog := .empty
     let mut toPassthrough : MessageLog := .empty
     for msg in msgs.toList do
+      if msg.isSilent then
+        continue
       match specFn msg with
       | .check       => toCheck := toCheck.add msg
       | .drop        => pure ()
diff --git a/src/Lean/Elab/MutualDef.lean b/src/Lean/Elab/MutualDef.lean
index f15157c6bce6..9cca94947d51 100644
--- a/src/Lean/Elab/MutualDef.lean
+++ b/src/Lean/Elab/MutualDef.lean
@@ -1068,6 +1068,47 @@ where
             unless (← processDefDeriving className header.declName) do
               throwError "failed to synthesize instance '{className}' for '{header.declName}'"
 
+/--
+Logs a snapshot task that waits for the entire snapshot tree in `defsParsedSnap` and then logs a
+`goalsAccomplished` silent message for theorems and `Prop`-typed examples if the entire mutual block
+is error-free and contains no syntactical `sorry`s.
+-/
+private def logGoalsAccomplishedSnapshotTask (views : Array DefView)
+    (defsParsedSnap : DefsParsedSnapshot) : TermElabM Unit := do
+  let snaps := #[SnapshotTask.finished none (toSnapshotTree defsParsedSnap)] ++
+    (← getThe Core.State).snapshotTasks
+  let tree := SnapshotTree.mk { diagnostics := .empty } snaps
+  let logGoalsAccomplishedAct ← Term.wrapAsyncAsSnapshot (cancelTk? := none) fun () => do
+    -- NOTE: `waitAll` below ensures `getAll` will not block here
+    let logs := tree.getAll.map (·.diagnostics.msgLog)
+    let hasErrorOrSorry := logs.any fun log =>
+      log.reportedPlusUnreported.any fun msg =>
+        msg.severity matches .error || msg.data.hasTag (· == `hasSorry)
+    if hasErrorOrSorry then
+      return
+    for d in defsParsedSnap.defs, view in views do
+      let logGoalsAccomplished :=
+        let msgData := .tagged `goalsAccomplished m!"Goals accomplished!"
+        logAt view.ref msgData (severity := .information) (isSilent := true)
+      match view.kind with
+      | .theorem =>
+        logGoalsAccomplished
+      | .example =>
+        let some processedSnap := d.headerProcessedSnap.get
+          | continue
+        if ! (← isProp processedSnap.view.type) then
+          continue
+        logGoalsAccomplished
+      | _ => continue
+  let logGoalsAccomplishedTask ← BaseIO.mapTask (t := ← tree.waitAll) fun _ =>
+    logGoalsAccomplishedAct
+  Core.logSnapshotTask {
+    stx? := none
+    -- Use first line of the mutual block to avoid covering the progress of the whole mutual block
+    reportingRange? := (← getRef).getPos?.map fun pos => ⟨pos, pos⟩
+    task := logGoalsAccomplishedTask
+  }
+
 end Term
 namespace Command
 
@@ -1110,11 +1151,14 @@ def elabMutualDef (ds : Array Syntax) : CommandElabM Unit := do
       }
       reusedAllHeaders := reusedAllHeaders && view.headerSnap?.any (·.old?.isSome)
     views := views.push view
+  let defsParsedSnap := { defs, diagnostics := .empty : DefsParsedSnapshot }
   if let some snap := snap? then
     -- no non-fatal diagnostics at this point
-    snap.new.resolve <| .ofTyped { defs, diagnostics := .empty : DefsParsedSnapshot }
+    snap.new.resolve <| .ofTyped defsParsedSnap
   let sc ← getScope
-  runTermElabM fun vars => Term.elabMutualDef vars sc views
+  runTermElabM fun vars => do
+    Term.elabMutualDef vars sc views
+    Term.logGoalsAccomplishedSnapshotTask views defsParsedSnap
 
 builtin_initialize
   registerTraceClass `Elab.definition.mkClosure
diff --git a/src/Lean/Language/Basic.lean b/src/Lean/Language/Basic.lean
index 40acaa916b6d..3876b24e05df 100644
--- a/src/Lean/Language/Basic.lean
+++ b/src/Lean/Language/Basic.lean
@@ -287,12 +287,13 @@ def reportMessages (msgLog : MessageLog) (opts : Options)
         {msg with severity}
       else
         msg
-    if json then
-      let j ← msg.toJson
-      IO.println j.compress
-    else
-      let s ← msg.toString includeEndPos
-      IO.print s
+    unless msg.isSilent do
+      if json then
+        let j ← msg.toJson
+        IO.println j.compress
+      else
+        let s ← msg.toString includeEndPos
+        IO.print s
     return hasErrors || msg.severity matches .error
 
 /--
diff --git a/src/Lean/Language/Util.lean b/src/Lean/Language/Util.lean
index 24751c4cb07e..e9a187575898 100644
--- a/src/Lean/Language/Util.lean
+++ b/src/Lean/Language/Util.lean
@@ -22,7 +22,8 @@ where go range? s := do
   let mut desc := f!"{s.element.desc}"
   if let some range := range? then
     desc := desc ++ f!"{file.toPosition range.start}-{file.toPosition range.stop} "
-  desc := desc ++ .prefixJoin "\n• " (← s.element.diagnostics.msgLog.toList.mapM (·.toString))
+  let msgs ← s.element.diagnostics.msgLog.toList.mapM (·.toString)
+  desc := desc ++ .prefixJoin "\n• " msgs
   withTraceNode `Elab.snapshotTree (fun _ => pure desc) do
     s.children.toList.forM fun c => go c.reportingRange? c.get
     if let some t := s.element.infoTree? then
diff --git a/src/Lean/Log.lean b/src/Lean/Log.lean
index 6485e15a1ffc..d3a09b3ad563 100644
--- a/src/Lean/Log.lean
+++ b/src/Lean/Log.lean
@@ -58,7 +58,8 @@ Log the message `msgData` at the position provided by `ref` with the given `seve
 If `getRef` has position information but `ref` does not, we use `getRef`.
 We use the `fileMap` to find the line and column numbers for the error message.
 -/
-def logAt (ref : Syntax) (msgData : MessageData) (severity : MessageSeverity := MessageSeverity.error) : m Unit :=
+def logAt (ref : Syntax) (msgData : MessageData)
+    (severity : MessageSeverity := MessageSeverity.error) (isSilent : Bool := false) : m Unit :=
   unless severity == .error && msgData.hasSyntheticSorry do
     let severity := if severity == .warning && warningAsError.get (← getOptions) then .error else severity
     let ref    := replaceRef ref (← MonadLog.getRef)
@@ -66,7 +67,14 @@ def logAt (ref : Syntax) (msgData : MessageData) (severity : MessageSeverity :=
     let endPos := ref.getTailPos?.getD pos
     let fileMap ← getFileMap
     let msgData ← addMessageContext msgData
-    logMessage { fileName := (← getFileName), pos := fileMap.toPosition pos, endPos := fileMap.toPosition endPos, data := msgData, severity := severity }
+    logMessage {
+      fileName := (← getFileName)
+      pos := fileMap.toPosition pos
+      endPos := fileMap.toPosition endPos
+      data := msgData
+      severity
+      isSilent
+    }
 
 /-- Log a new error message using the given message data. The position is provided by `ref`. -/
 def logErrorAt (ref : Syntax) (msgData : MessageData) : m Unit :=
diff --git a/src/Lean/Message.lean b/src/Lean/Message.lean
index e7e6bf80255b..9730764f1517 100644
--- a/src/Lean/Message.lean
+++ b/src/Lean/Message.lean
@@ -347,6 +347,12 @@ structure BaseMessage (α : Type u) where
   /-- If `true`, report range as given; see `msgToInteractiveDiagnostic`. -/
   keepFullRange : Bool := false
   severity      : MessageSeverity := .error
+  /--
+  If `true`, filter this message from non-language server output.
+  In the language server, silent messages are served as silent diagnostics.
+  See also `DiagnosticWith.isSilent?`.
+  -/
+  isSilent      : Bool := false
   caption       : String          := ""
   /-- The content of the message. -/
   data          : α
diff --git a/src/Lean/Server/FileWorker.lean b/src/Lean/Server/FileWorker.lean
index fab55b498d3a..8486dcbe9d6c 100644
--- a/src/Lean/Server/FileWorker.lean
+++ b/src/Lean/Server/FileWorker.lean
@@ -268,7 +268,10 @@ This option can only be set on the command line, not in the lakefile or via `set
               return (← ref.get).bind (·.get? MemorizedInteractiveDiagnostics) then
             pure memorized.diags
           else
-            let diags ← node.element.diagnostics.msgLog.toArray.mapM
+            let mut msgs := node.element.diagnostics.msgLog.toArray
+            if ! ctx.initParams.capabilities.silentDiagnosticSupport then
+              msgs := msgs.filter (! ·.isSilent)
+            let diags ← msgs.mapM
               (Widget.msgToInteractiveDiagnostic doc.meta.text · ctx.clientHasWidgets)
             if let some cacheRef := node.element.diagnostics.interactiveDiagsRef? then
               cacheRef.set <| some <| .mk { diags : MemorizedInteractiveDiagnostics }
diff --git a/src/Lean/Widget/InteractiveDiagnostic.lean b/src/Lean/Widget/InteractiveDiagnostic.lean
index 25d3d02801db..c7fd6cbe28b2 100644
--- a/src/Lean/Widget/InteractiveDiagnostic.lean
+++ b/src/Lean/Widget/InteractiveDiagnostic.lean
@@ -234,14 +234,19 @@ def msgToInteractiveDiagnostic (text : FileMap) (m : Message) (hasWidgets : Bool
     | .information => .information
     | .warning     => .warning
     | .error       => .error
+  let isSilent? := if m.isSilent then some true else none
   let source? := some "Lean 4"
   let tags? :=
     if m.data.isDeprecationWarning then some #[.deprecated]
     else if m.data.isUnusedVariableWarning then some #[.unnecessary]
     else none
+  let leanTags? :=
+    if m.data.hasTag (· == `Tactic.unsolvedGoals) then some #[.unsolvedGoals]
+    else if m.data.hasTag (· == `goalsAccomplished) then some #[.goalsAccomplished]
+    else none
   let message := match (← msgToInteractive m.data hasWidgets |>.toBaseIO) with
     | .ok msg => msg
     | .error ex => TaggedText.text s!"[error when printing message: {ex.toString}]"
-  pure { range, fullRange? := some fullRange, severity?, source?, message, tags? }
+  pure { range, fullRange? := some fullRange, severity?, source?, message, tags?, leanTags?, isSilent? }
 
 end Lean.Widget
diff --git a/src/lake/Lake/Build/Actions.lean b/src/lake/Lake/Build/Actions.lean
index 356ecae7af02..c8159f8570e1 100644
--- a/src/lake/Lake/Build/Actions.lean
+++ b/src/lake/Lake/Build/Actions.lean
@@ -56,7 +56,8 @@ def compileLeanModule
       if let .ok (msg : SerialMessage) := Json.parse ln >>= fromJson? then
         unless txt.isEmpty do
           logInfo s!"stdout:\n{txt}"
-        logSerialMessage msg
+        unless msg.isSilent do
+          logSerialMessage msg
         return txt
       else if txt.isEmpty && ln.isEmpty then
         return txt
diff --git a/src/lake/Lake/Load/Lean/Elab.lean b/src/lake/Lake/Load/Lean/Elab.lean
index e05aa21b299d..bc9cf3e00be5 100644
--- a/src/lake/Lake/Load/Lean/Elab.lean
+++ b/src/lake/Lake/Load/Lean/Elab.lean
@@ -70,6 +70,8 @@ def elabConfigFile (pkgDir : FilePath) (lakeOpts : NameMap String)
 
   -- Log messages
   for msg in s.commandState.messages.toList do
+    if msg.isSilent then
+      continue
     match msg.severity with
     | MessageSeverity.information => logInfo (← msg.toString)
     | MessageSeverity.warning     => logWarning (← msg.toString)
diff --git a/tests/lean/interactive/4880.lean.expected.out b/tests/lean/interactive/4880.lean.expected.out
index c98090e74904..5ebca885aeb2 100644
--- a/tests/lean/interactive/4880.lean.expected.out
+++ b/tests/lean/interactive/4880.lean.expected.out
@@ -47,6 +47,7 @@
    {"start": {"line": 34, "character": 13},
     "end": {"line": 35, "character": 0}},
    "message": "unsolved goals\n⊢ A",
+   "leanTags": [1],
    "fullRange":
    {"start": {"line": 34, "character": 13},
     "end": {"line": 36, "character": 0}}}]}
diff --git a/tests/lean/interactive/haveInfo.lean.expected.out b/tests/lean/interactive/haveInfo.lean.expected.out
index 786f0ecea57e..7cb553e8e420 100644
--- a/tests/lean/interactive/haveInfo.lean.expected.out
+++ b/tests/lean/interactive/haveInfo.lean.expected.out
@@ -6,6 +6,7 @@
    "range":
    {"start": {"line": 4, "character": 17}, "end": {"line": 5, "character": 0}},
    "message": "unsolved goals\n⊢ True",
+   "leanTags": [1],
    "fullRange":
    {"start": {"line": 4, "character": 17}, "end": {"line": 7, "character": 8}}},
   {"source": "Lean 4",
@@ -14,6 +15,7 @@
    {"start": {"line": 11, "character": 17},
     "end": {"line": 12, "character": 0}},
    "message": "unsolved goals\n⊢ True",
+   "leanTags": [1],
    "fullRange":
    {"start": {"line": 11, "character": 17},
     "end": {"line": 14, "character": 8}}},
@@ -23,6 +25,7 @@
    {"start": {"line": 18, "character": 17},
     "end": {"line": 19, "character": 0}},
    "message": "unsolved goals\n⊢ True",
+   "leanTags": [1],
    "fullRange":
    {"start": {"line": 18, "character": 17},
     "end": {"line": 21, "character": 8}}},
@@ -32,6 +35,7 @@
    {"start": {"line": 25, "character": 17},
     "end": {"line": 26, "character": 0}},
    "message": "unsolved goals\n⊢ True",
+   "leanTags": [1],
    "fullRange":
    {"start": {"line": 25, "character": 17},
     "end": {"line": 28, "character": 8}}}]}
diff --git a/tests/lean/interactive/incrementalCombinator.lean.expected.out b/tests/lean/interactive/incrementalCombinator.lean.expected.out
index 23e110d3d726..8a4f7f9bb146 100644
--- a/tests/lean/interactive/incrementalCombinator.lean.expected.out
+++ b/tests/lean/interactive/incrementalCombinator.lean.expected.out
@@ -33,6 +33,7 @@ sh
    "range":
    {"start": {"line": 2, "character": 17}, "end": {"line": 3, "character": 0}},
    "message": "unsolved goals\n⊢ True",
+   "leanTags": [1],
    "fullRange":
    {"start": {"line": 2, "character": 17},
     "end": {"line": 6, "character": 18}}},
@@ -41,6 +42,7 @@ sh
    "range":
    {"start": {"line": 1, "character": 24}, "end": {"line": 2, "character": 0}},
    "message": "unsolved goals\nthis : True\n⊢ True",
+   "leanTags": [1],
    "fullRange":
    {"start": {"line": 1, "character": 24},
     "end": {"line": 6, "character": 18}}}]}
@@ -53,6 +55,7 @@ sh
    "range":
    {"start": {"line": 2, "character": 18}, "end": {"line": 3, "character": 0}},
    "message": "unsolved goals\n⊢ True",
+   "leanTags": [1],
    "fullRange":
    {"start": {"line": 2, "character": 18},
     "end": {"line": 6, "character": 18}}},
@@ -61,6 +64,7 @@ sh
    "range":
    {"start": {"line": 1, "character": 24}, "end": {"line": 2, "character": 0}},
    "message": "unsolved goals\nthis : True\n⊢ True",
+   "leanTags": [1],
    "fullRange":
    {"start": {"line": 1, "character": 24},
     "end": {"line": 6, "character": 18}}}]}
diff --git a/tests/lean/interactive/incrementalTactic.lean.expected.out b/tests/lean/interactive/incrementalTactic.lean.expected.out
index b429e40063b2..d80721785ba3 100644
--- a/tests/lean/interactive/incrementalTactic.lean.expected.out
+++ b/tests/lean/interactive/incrementalTactic.lean.expected.out
@@ -23,6 +23,7 @@ t 2
    "range":
    {"start": {"line": 2, "character": 22}, "end": {"line": 3, "character": 0}},
    "message": "unsolved goals\nthis : Nat → Nat → True\n⊢ True",
+   "leanTags": [1],
    "fullRange":
    {"start": {"line": 2, "character": 22},
     "end": {"line": 10, "character": 11}}}]}
@@ -52,6 +53,7 @@ t 2
    "range":
    {"start": {"line": 3, "character": 9}, "end": {"line": 3, "character": 16}},
    "message": "unsolved goals\n⊢ Nat",
+   "leanTags": [1],
    "fullRange":
    {"start": {"line": 3, "character": 9},
     "end": {"line": 3, "character": 16}}}]}
@@ -83,6 +85,7 @@ s
    "range":
    {"start": {"line": 3, "character": 12}, "end": {"line": 3, "character": 16}},
    "message": "unsolved goals\ncase zero\n⊢ True",
+   "leanTags": [1],
    "fullRange":
    {"start": {"line": 3, "character": 12},
     "end": {"line": 3, "character": 16}}}]}
@@ -94,6 +97,7 @@ s
    "range":
    {"start": {"line": 2, "character": 6}, "end": {"line": 2, "character": 10}},
    "message": "unsolved goals\n⊢ True",
+   "leanTags": [1],
    "fullRange":
    {"start": {"line": 2, "character": 6}, "end": {"line": 2, "character": 10}}},
   {"source": "Lean 4",
diff --git a/tests/lean/interactive/issue4527.lean.expected.out b/tests/lean/interactive/issue4527.lean.expected.out
index 0a542d8842ca..2a9be78d5ea8 100644
--- a/tests/lean/interactive/issue4527.lean.expected.out
+++ b/tests/lean/interactive/issue4527.lean.expected.out
@@ -6,5 +6,6 @@
    "range":
    {"start": {"line": 1, "character": 2}, "end": {"line": 2, "character": 0}},
    "message": "unsolved goals\n⊢ True",
+   "leanTags": [1],
    "fullRange":
    {"start": {"line": 1, "character": 2}, "end": {"line": 3, "character": 8}}}]}


Reviews:
user: login: tydeu
body: LGTM! I only have a minor style suggestion.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-06T19:22:15Z
user: login: Kha
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-07T10:06:03Z
user: login: Kha
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-07T13:26:27Z

Review comments:
diff_hunk: @@ -56,7 +56,8 @@ def compileLeanModule
       if let .ok (msg : SerialMessage) := Json.parse ln >>= fromJson? then
         unless txt.isEmpty do
           logInfo s!"stdout:\n{txt}"
-        logSerialMessage msg
+        if ! msg.isSilent then
path: src/lake/Lake/Build/Actions.lean
user: login: tydeu
body: ```suggestion
        unless msg.isSilent do
```
created_at: 2025-03-06T19:18:13Z
author_association: MEMBER
diff_hunk: @@ -287,12 +287,13 @@ def reportMessages (msgLog : MessageLog) (opts : Options)
         {msg with severity}
       else
         msg
-    if json then
-      let j ← msg.toJson
-      IO.println j.compress
-    else
-      let s ← msg.toString includeEndPos
-      IO.print s
+    if ! msg.isSilent then
path: src/Lean/Language/Basic.lean
user: login: tydeu
body: ```suggestion
    unless msg.isSilent do
```
created_at: 2025-03-06T19:19:36Z
author_association: MEMBER
diff_hunk: @@ -1068,6 +1068,44 @@ where
             unless (← processDefDeriving className header.declName) do
               throwError "failed to synthesize instance '{className}' for '{header.declName}'"
 
+private def logGoalsAccomplishedSnapshotTask (views : Array DefView)
+    (defsParsedSnap : DefsParsedSnapshot) : TermElabM Unit := do
+  let tree := toSnapshotTree defsParsedSnap
+  let logGoalsAccomplishedAct ← Term.wrapAsyncAsSnapshot (cancelTk? := none) fun () => do
+    let logs := tree.getAll.map (·.diagnostics.msgLog)
path: src/Lean/Elab/MutualDef.lean
user: login: Kha
body: ```suggestion
    -- NOTE: `waitAll` below ensures `getAll` will not block here
    let logs := tree.getAll.map (·.diagnostics.msgLog)
```
created_at: 2025-03-07T09:59:30Z
author_association: MEMBER
diff_hunk: @@ -22,7 +22,8 @@ where go range? s := do
   let mut desc := f!"{s.element.desc}"
   if let some range := range? then
     desc := desc ++ f!"{file.toPosition range.start}-{file.toPosition range.stop} "
-  desc := desc ++ .prefixJoin "\n• " (← s.element.diagnostics.msgLog.toList.mapM (·.toString))
+  let msgs ← s.element.diagnostics.msgLog.toList.filter (! ·.isSilent) |>.mapM (·.toString)
path: src/Lean/Language/Util.lean
user: login: Kha
body: Wouldn't it be helpful to have silent messages included in the trace?
created_at: 2025-03-07T10:04:38Z
author_association: MEMBER
diff_hunk: @@ -347,6 +347,7 @@ structure BaseMessage (α : Type u) where
   /-- If `true`, report range as given; see `msgToInteractiveDiagnostic`. -/
   keepFullRange : Bool := false
   severity      : MessageSeverity := .error
+  isSilent      : Bool := false
path: src/Lean/Message.lean
user: login: Kha
body: Needs a docstring
created_at: 2025-03-07T10:05:03Z
author_association: MEMBER
diff_hunk: @@ -1068,6 +1068,44 @@ where
             unless (← processDefDeriving className header.declName) do
               throwError "failed to synthesize instance '{className}' for '{header.declName}'"
 
+private def logGoalsAccomplishedSnapshotTask (views : Array DefView)
path: src/Lean/Elab/MutualDef.lean
user: login: Kha
body: Please add a docstring
created_at: 2025-03-07T10:05:59Z
author_association: MEMBER

PR comments:
user: login: ghost
created_at: 2025-03-06T16:05:33Z
body: Mathlib CI status ([docs](https://leanprover-community.github.io/contribute/tags_and_branches.html)):
- ✅ Mathlib branch [lean-pr-testing-7366](https://github.com/leanprover-community/mathlib4/compare/nightly-testing...lean-pr-testing-7366) has successfully built against this PR. (2025-03-06 16:05:32) [View Log](https://github.com/leanprover-community/mathlib4/actions/runs/13701958299)
- ✅ Mathlib branch [lean-pr-testing-7366](https://github.com/leanprover-community/mathlib4/compare/nightly-testing...lean-pr-testing-7366) has successfully built against this PR. (2025-03-06 17:05:35) [View Log](https://github.com/leanprover-community/mathlib4/actions/runs/13702906612)
- ✅ Mathlib branch [lean-pr-testing-7366](https://github.com/leanprover-community/mathlib4/compare/nightly-testing...lean-pr-testing-7366) has successfully built against this PR. (2025-03-07 10:35:51) [View Log](https://github.com/leanprover-community/mathlib4/actions/runs/13718053060)
- ✅ Mathlib branch [lean-pr-testing-7366](https://github.com/leanprover-community/mathlib4/compare/nightly-testing...lean-pr-testing-7366) has successfully built against this PR. (2025-03-07 11:41:06) [View Log](https://github.com/leanprover-community/mathlib4/actions/runs/13719201634)
- ✅ Mathlib branch [lean-pr-testing-7366](https://github.com/leanprover-community/mathlib4/compare/nightly-testing...lean-pr-testing-7366) has successfully built against this PR. (2025-03-07 14:34:56) [View Log](https://github.com/leanprover-community/mathlib4/actions/runs/13721983002)
author_association: NONE
user: login: Vtec234
created_at: 2025-03-06T22:44:29Z
body: Will an off-the-shelf LSP client without special support for Lean ignore silent diagnostics, i.e., not show them as squiggles? The `vscode-lean4` diff in which you explicitly filter them makes me think they would not be ignored which is not great for editors other than VSc.
author_association: MEMBER
user: login: mhuisi
created_at: 2025-03-07T08:17:12Z
body: > Will an off-the-shelf LSP client without special support for Lean ignore silent diagnostics, i.e., not show them as squiggles? The `vscode-lean4` diff in which you explicitly filter them makes me think they would not be ignored which is not great for editors other than VSc.

No, they won't, similar to how our handling of diagnostic ranges will already result in a bad editing experience for clients that do not support `fullRange`. Fortunately, most LSP clients also support some sort of middleware that Lean plugins can use to implement support for silent diagnostics. NeoVim support for silent diagnostics is upcoming.

I considered turning all silent diagnostics into "hint" severity diagnostics, but it doesn't look like LSP clients render those without a squiggly consistently across the board, either, so I think I would prefer to retain the severity axis for silent diagnostics.

I also considered implementing an entirely separate notification for this that is identical to `publishDiagnostics`, except that all diagnostics served by it are silent, but concluded that it would simply be too annoying for clients to have to synchronize these two separate notifications whenever they need to funnel them into a shared UI, like 'Messages'. 
Essentially, we would need something like an additional version field on both diagnostic notifications for this that allows versioning the messages in the stream of diagnostics we serve for each elaboration run of a document version, so that clients can then use this version to implement a consistent non-racey merge logic. Alternatively, they could also rely on language server internals like how and in which order these notifications are served, which is not great either. I much prefer the `isSilent` field over both of these approaches.

Two other ideas by @Kha are to have the silent diagnostics in a separate field in the `publishDiagnostics` notification so that they are synchronized by default, or to introduce a client capability for silent diagnostics. I think the last option might be the best one of these, if only to not display the squigglies for users who are stuck on an old vscode-lean4 version for some reason.
author_association: CONTRIBUTOR
user: login: mhuisi
created_at: 2025-03-07T12:27:38Z
body: @Vtec234 We now define a client capability for silent diagnostics and filter silent diagnostics from the server if the capability isn't set.
author_association: CONTRIBUTOR
user: login: Kha
created_at: 2025-03-08T09:23:32Z
body: Speedcenter results are concerning: https://speed.lean-lang.org/lean4/run-detail/70829899-6d71-46c7-a58c-039cb3bd0671
author_association: MEMBER