The Journey of a Dotty Diagnostic

ScalaIO 2022

Chris Kipp

@ckipp01

A little about me

  • Big Neovim fan
  • You'll find me working in Scala Tooling
  • Host the Tooling Talks Podcast
  • Software engineer at Lunatech
  • chris-kipp.io/talks

Diagnostics

Why?

What is a Diagnostic?

Or rather, what makes up a Diagnostic?

1. Message

2. Severity (Error)

2. Severity (Warning)

2. Severity (Info)

3. Severity (Hint)

¯\_(ツ)_/¯

 

Not in Scala

3. Position

No Help

Diagnostic Usefulness Scale

All the help

  • Message

  • Severity

  • Position

Diagnostic Usefulness Scale

All the help

  • Message

  • Severity

  • Position

???

  1. Where is Scala on this scale?
  2. What else is on this scale?
  3. Where are other languages on the scale?

Where is Scala on the Scale?

What is the structure of a Dotty Diagnostic?

Well how does the diagnostic get from the compiler to your editor?

It depends

  • Are you in the REPL?

  • Are you using X build tool?

  • Are you using a build server?

  • Are you using IntelliJ? Metals? Ensime?

  • Or what combination of all these?

Compiler

Build Tool

Build Server

Metals

Editor

Compiler

if (param.is(Erased))
  report.error("value class first parameter cannot be `erased`", param.srcPos)
else
  for (p <- params if !p.is(Erased))
    report.error("value class can only have one non `erased` parameter", p.srcPos)

An error just reported on the fly

Compiler

def fail(msg: Message) = report.error(msg, sym.srcPos)

def checkWithDeferred(flag: FlagSet) =
  if (sym.isOneOf(flag))
    fail(AbstractMemberMayNotHaveModifier(sym, flag))

Reporting a Message

Message

abstract class Message(val errorId: ErrorMessageID) { self =>

  protected def msg: String

  def kind: MessageKind

  protected def explain: String

  protected def msgSuffix: String = ""

  def canExplain: Boolean = explain.nonEmpty

  private var myMsg: String | Null = null
  private var myIsNonSensical: Boolean = false

  private def dropNonSensical(msg: String): String = ???

  def rawMessage = message

  @threadUnsafe lazy val message: String = dropNonSensical(msg + msgSuffix)

  @threadUnsafe lazy val explanation: String = dropNonSensical(explain)

  def isNonSensical: Boolean = { message; myIsNonSensical }

  def persist: Message = ???

  def append(suffix: => String): Message = mapMsg(_ ++ suffix)

  def mapMsg(f: String => String): Message = ???

  def appendExplanation(suffix: => String): Message = ???

  def showAlways = false

  override def toString = msg
}

Diagnostic

class Diagnostic(
  val msg: Message,
  val pos: SourcePosition,
  val level: Int
) extends Exception with interfaces.Diagnostic:
  private var verbose: Boolean = false
  def isVerbose: Boolean = verbose
  def setVerbose(): this.type =
    verbose = true
    this

  override def position: Optional[interfaces.SourcePosition] =
    if (pos.exists && pos.source.exists) Optional.of(pos) else Optional.empty()
    
  override def message: String =
    msg.message.replaceAll("\u001B\\[[;\\d]*m", "")

  override def toString: String = s"$getClass at $pos: $message"
  override def getMessage(): String = message
end Diagnostic

Diagnostic

Something

Using 

Zinc

Other

Zinc

❯ tree -L 5 sbt-bridge/
sbt-bridge
├── resources
│  └── META-INF
│     └── services
│        └── xsbti.compile.CompilerInterface2
└── src
   ├── dotty
   │  └── tools
   │     └── xsbt
   │        ├── CompilerBridge.java
   │        ├── CompilerBridgeDriver.java
   │        ├── DelegatingReporter.java
   │        ├── DiagnosticCode.java
   │        ├── InterfaceCompileFailed.java
   │        ├── PositionBridge.java
   │        ├── Problem.java
   │        ├── ZincPlainFile.java
   │        └── ZincVirtualFile.java
   └── xsbt
      ├── CachedCompilerImpl.java
      ├── CompilerClassLoader.java
      ├── CompilerInterface.java
      ├── ConsoleInterface.java
      ├── DottydocRunner.java
      └── ScaladocInterface.java

Zinc

The compiler bridge classes are loaded using java.util.ServiceLoader. In other words, the class implementing xsbti.compile.CompilerInterface2 must be mentioned in a file named: /META-INF/services/xsbti.compile.CompilerInterface2.

Problem

Old Problem.java in sbt-interfaces

public interface Problem {
  String category();

  Severity severity();

  String message();

  Position position();

  // Default value to avoid breaking binary compatibility
  /**
   * If present, the string shown to the user when displaying this Problem. Otherwise, the Problem
   * will be shown in an implementation-defined way based on the values of its other fields.
   */
  default Optional<String> rendered() {
    return Optional.empty();
  }
}

sbt-bridge

public void doReport(Diagnostic dia, Context ctx) {
  Severity severity = severityOf(dia.level());
  Position position = positionOf(dia.pos().nonInlined());

  StringBuilder rendered = new StringBuilder();
  rendered.append(messageAndPos(dia, ctx));
  Message message = dia.msg();
  boolean shouldExplain = Diagnostic.shouldExplain(dia, ctx);
  if (shouldExplain && !message.explanation().isEmpty()) {
    rendered.append(explanation(message, ctx));
  }

  delegate.log(new Problem(position, message.msg(), severity, rendered.toString()));
}

Compiler

Build Tool

Build Server

Metals

Editor

Build Tool

Running with the tool

BSP

Compiler

Build Tool

Build Server

Metals

Editor

sbt-bridge

BSP

LSP

Diagnostic

export interface Diagnostic {
	/** The range at which the message applies.*/
	range: Range;
	/**
	 * The diagnostic's severity. Can be omitted. If omitted it is up to the
	 * client to interpret diagnostics as error, warning, info or hint.
	 */
	severity?: DiagnosticSeverity;
	/** The diagnostic's code, which might appear in the user interface. */
	code?: integer | string;
	/** An optional property to describe the error code. */
	codeDescription?: CodeDescription;
	/**
	 * A human-readable string describing the source of this
	 * diagnostic, e.g. 'typescript' or 'super lint'.
	 */
	source?: string;
	/** The diagnostic's message. */
	message: string;
	/** Additional metadata about the diagnostic. */
	tags?: DiagnosticTag[];
	/**
	 * An array of related diagnostic information, e.g. when symbol-names within
	 * a scope collide all definitions can be marked via this property.
	 */
	relatedInformation?: DiagnosticRelatedInformation[];
	/**
	 * A data entry field that is preserved between a
	 * `textDocument/publishDiagnostics` notification and
	 * `textDocument/codeAction` request.
	 */
	data?: unknown;
}

sbt BSP

protected override def publishDiagnostic(problem: Problem): Unit = {
  for {
  id <- problem.position.sourcePath.toOption
  diagnostic <- toDiagnostic(problem)
  filePath <- toSafePath(VirtualFileRef.of(id))
} {
  problemsByFile(filePath) = problemsByFile.getOrElse(filePath, Vector.empty) :+ diagnostic
  val params = PublishDiagnosticsParams(
    TextDocumentIdentifier(filePath.toUri),
    buildTarget,
    originId = None,
    Vector(diagnostic),
    reset = false
  )
  exchange.notifyEvent("build/publishDiagnostics", params)
}

Metals

private def publishDiagnostics(
    path: AbsolutePath,
    queue: ju.Queue[Diagnostic],
): Unit = {
  if (!path.isFile) return didDelete(path)
  val current = path.toInputFromBuffers(buffers)
  val snapshot = snapshots.getOrElse(path, current)
  val edit = TokenEditDistance(
    snapshot,
    current,
    trees,
    doNothingWhenUnchanged = false,
  )
  val uri = path.toURI.toString
  val all = new ju.ArrayList[Diagnostic](queue.size() + 1)
  for {
    diagnostic <- queue.asScala
    freshDiagnostic <- toFreshDiagnostic(edit, diagnostic, snapshot)
  } {
    all.add(freshDiagnostic)
  }
  // Removed some deduplication stuff here to save room
  languageClient.publishDiagnostics(new PublishDiagnosticsParams(uri, all))
}

Where is Scala on the Scale?

All the help

  • Message

  • Severity

  • Position

Sort of over here

But wait... maybe that's not fair

trait Foo:
  def hello(): Unit
  def goodBye(): Unit

class Greeting extends Foo

This won't work

And it knows what you need

This won't work

trait Foo[A]:
  def foo: Int

def foo =
  second[String]

inline def first[A]: Int =
  compiletime.summonInline[Foo[A]].foo

inline def second[A]: Int =
  first[A] + 1

It knows the inlined locations

But not in your editor...

All the help

  • Message

  • Severity

  • Position

What else is on this scale?

Where are the other languages on this scale?

Example - Scala

object Main:

  val greeting = "hello"

  greeting = "hi"

Example - Scala

  • Where is the original assignment?
  • Can I get an explanation of this error?
  • What are my alternatives?

Example - Rust

fn main() {

    let greeting = "hello";

    greeting = "hi";
    
}

Example - Rust

Where is the original assignment?

Can I get an explanation of this error?

Can I get an explanation of this error?

What are my alternatives?

Bonus: Just do it for me

Example: Elm

All the help

  • Message

  • Severity

  • Position

Well, why is this?

Diagnostic Structure

struct Diagnostic {
    /// The primary error message.
    message: String,
    code: Option<DiagnosticCode>,
    /// "error: internal compiler error", "error", "warning", "note", "help".
    level: &'static str,
    spans: Vec<DiagnosticSpan>,
    /// Associated diagnostic messages.
    children: Vec<Diagnostic>,
    /// The message as rustc would render it.
    rendered: Option<String>,
}

Diagnostic Code

struct DiagnosticCode {
    /// The code itself.
    code: String,
    /// An explanation for the code.
    explanation: Option<&'static str>,
}

Diagnostic Span

struct DiagnosticSpan {
    file_name: String,
    byte_start: u32,
    byte_end: u32,
    /// 1-based.
    line_start: usize,
    line_end: usize,
    /// 1-based, character offset.
    column_start: usize,
    column_end: usize,
    /// Is this a "primary" span -- meaning the point, or one of the points,
    /// where the error occurred?
    is_primary: bool,
    /// Source text from the start of line_start to the end of line_end.
    text: Vec<DiagnosticSpanLine>,
    /// Label that should be placed at this location (if any)
    label: Option<String>,
    /// If we are suggesting a replacement, this will contain text
    /// that should be sliced in atop this span.
    suggested_replacement: Option<String>,
    /// If the suggestion is approximate
    suggestion_applicability: Option<Applicability>,
    /// Macro invocations that created the code at this span, if any.
    expansion: Option<Box<DiagnosticSpanMacroExpansion>>,
}

It's not just Rust

"Fix-it" hints provide advice for fixing small, localized problems in source code. When Clang produces a diagnostic about a particular problem that it can work around (e.g., non-standard or redundant syntax, missing keywords, common mistakes, etc.), it may also provide specific guidance in the form of a code transformation to correct the problem.

Compiler Error Index

There is some progress being made

Metals Code Action

object TypeMismatch {
  private val regexStart = """type mismatch;""".r
  private val regexMiddle = """(F|f)ound\s*: (.*)""".r
  private val regexEnd = """(R|r)equired: (.*)""".r

  def unapply(d: l.Diagnostic): Option[(String, l.Diagnostic)] = {
    d.getMessage().split("\n").map(_.trim()) match {
      /* Scala 3:
       * Found:    ("" : String)
       * Required: Int
       */
      case Array(regexMiddle(_, toType), regexEnd(_, _)) =>
        Some((toType.trim(), d))
      /* Scala 2:
       * type mismatch;
       * found   : Int(122)
       * required: String
       */
      case Array(regexStart(), regexMiddle(_, toType), regexEnd(_, _)) =>
        Some((toType.trim(), d))
      case _ =>
        None
    }
  }
}

Problem

New Problem.java in sbt-interfaces

public interface Problem {
  String category();

  Severity severity();

  String message();

  Position position();

  default Optional<String> rendered() {
    return Optional.empty();
  }

  default Optional<DiagnosticCode> diagnosticCode() {
    return Optional.empty();
  }

  default List<DiagnosticRelatedInformation> diagnosticRelatedInforamation() {
    return Collections.emptyList();
  }
}

This Required Changes In...

Just to get the code

"diagnostics": [
  {
    "range": {
      "start": {
        "line": 9,
        "character": 15
      },
      "end": {
        "line": 9,
        "character": 19
      }
    },
    "severity": 1,
    "code": "7",
    "source": "sbt",
    "message": "Found:    (\u001b[32m\"hi\"\u001b[0m : String)\nRequired: Int\n\nThe following import might make progress towards fixing the problem:\n\n  import sourcecode.Text.generate\n\n"
  }
],

scala-cli Example

lwronski just merged in a great example of this in https://github.com/scalameta/metals/pull/4297

scala-cli Example

"diagnostics": [
    {
      "range": {
        "start": {
          "line": 0,
          "character": 15
        },
        "end": {
          "line": 0,
          "character": 40
        }
      },
      "severity": 4,
      "source": "scala-cli",
      "message": "com.lihaoyi::os-lib:0.7.8 is outdated, update to 0.8.1\n     com.lihaoyi::os-lib:0.7.8 -\u003e com.lihaoyi::os-lib:0.8.1",
      "data": {
        "range": {
          "start": {
            "line": 0,
            "character": 15
          },
          "end": {
            "line": 0,
            "character": 40
          }
        },
        "newText": "com.lihaoyi::os-lib:0.8.1"
      }
    }
  ],

What if instead of this...

What if instead of this...

"diagnostics": [
    {
      "range": {
        "start": {
          "line": 11,
          "character": 6
        },
        "end": {
          "line": 11,
          "character": 14
        }
      },
      "severity": 1,
      "source": "bloop",
      "message": "class Greeting needs to be abstract, since:\nit has 2 unimplemented members.\n/** As seen from class Greeting, the missing signatures are as follows.\n *  For convenience, these are usable as stub implementations.\n */\n  def goodBye(): Unit \u003d ???\n  def hello(): Unit \u003d ???\n"
    }
  ]

We got this...

"diagnostics": [
    {
      "range": {
        "start": { ... },
        "end": { ... }
      },
      "severity": 1,
      "code": "034",
      "source": "dotty",
      "message": "class Greeting needs to be abstract, since:\nit has 2 unimplemented members.\n/** As seen from class Greeting, the missing signatures are as follows.\n *  For convenience, these are usable as stub implementations.\n */\n",
      "data": {
        "range": {
          "start": { ... },
          "end": { ... }
        },
        "newText": "def goodBye(): Unit \u003d ???\n  def hello(): Unit \u003d ???\n"
      }
    }
  ]

And could reference something like this...

There is also more to be unlocked

You can track progress of a lot of this here:

Thanks!