[swift unboxed]

Safely unboxing the Swift language & standard library


Swift Diagnostics: #warning and #error

New diagnostic directives in Swift 4.2. What are they and how are they implemented?

11 June 2018 ∙ Swift Internals ∙ written by Swift 4.2 Beta

Aside from straight-up code, our source files are filled with two other categories of things: comments and compiler directives.

Comments are great, but we often leave a TODO somewhere and then it stays for all eternity. Sometimes you need a little friction (OK, maybe even a lot) to remind yourself or others to pay attention to certain parts of the code.

New in Swift 4.2 are two handy diagnostic directives that can help: #error and #warning. What do they do and how do they work?

Prepare for a dive into some C++ code as we examine how these directives are implemented. Along the way, we’ll look at the basics of how the Swift compiler works.

Diagnostic Directives

As their names suggest, the two directives will bring up diagnostic messages of different severity levels in the compiler:

  • #warning will show a warning; compilation can still continue.
  • #error will show an error and stop compilation.

In both cases, you need to provide some message text as well:

#warning("Needs to be refactored")
// some dodgy code here

#if !canImport(UIKit)
  #error("This framework requires UIKit!")
#endif

You can use these warnings and errors to raise issues and highlight bits of code, and the messages will show up in build logs or the Xcode console.

How It’s Made

The interesting thing to me is: how does one add a new bit of syntax to a language? I can imagine several things you’d need to think about:

  • Adding something to the parser to recognize the new directives.
  • Adding something (also to the parser?) that will check for the correct syntax: #error or #warning followed by parentheses with a string inside.
  • We need to actually trigger a diagnostic warning or error in the compiler.

Rather than look at every single detail, we’ll cover the highlights of the implementation in three parts: parsing, semantic analysis, and code generation.

Serious tip of the hat to Harlan Haskins for writing the proposal and implementing it. The code you see below is all from his commits bringing us this feature. 🙏

Parsing

The parsing step turns source code into an abstract syntax tree (AST) — a structured representation of your code.

Parser: Swift code to AST representation

Consider this single-line Swift file:

print("Hello parser!")

The raw AST (without type checking) looks like this:

1: (source_file
2:  (top_level_code_decl
3:    (brace_stmt
4:      (call_expr type='<null>' arg_labels=_:
5:        (unresolved_decl_ref_expr type='<null>' name=print function_ref=unapplied)
6:         (paren_expr type='<null>'
7:          (string_literal_expr type='<null>' encoding=utf8 value="Hello parser!" builtin_initializer=**NULL** initializer=**NULL**))))))

Each important bit, or token is on its own line. For example, the call expression (4), something named “print” (5), a parenthetical expression (6) and the string literal “Hello parser!” (7).

Tokens

The first step to #error and #warning is to get the parser to recognize these new words.

TokenKinds.def lists all the tokens and keywords for the parser to pay attention too, and has a section for # directives such as #available and #if and #selector.

We can add new definitions pretty easily thanks to the POUND_KEYWORD macro:

// Keywords prefixed with a '#'.
POUND_KEYWORD(warning)
POUND_KEYWORD(error)

That should get the keywords recognized. Next is to parse them, and extract the message portion. We’ll need some kind of storage to hold two bits of information:

  1. Whether it’s a warning or an error
  2. The message string

Declaration

Swift has the concept of declarations to distinguish parts of the language. An import, a variable declared with var or a constant with let, a new class or an extension — these are all different kinds of declarations.

We’ll define a new “diagnostic” declaration with a flag on whether it’s a warning or error, rather than have two separate declarations. Here’s the start of the class definition:

class PoundDiagnosticDecl : public Decl {
  SourceLoc StartLoc;
  SourceLoc EndLoc;
  StringLiteralExpr *Message;

  // ...
}

We have some member variables here to store the location in the source file as well as the message.

What about whether it’s a warning or an error?

DiagnosticKind getKind() {
  return isError() ? DiagnosticKind::Error : DiagnosticKind::Warning;
}

First, we have getKind() to return the correct DiagnosticKind — either error or warning. We already have errors and warnings in the compiler, so DiagnosticKind already exists.

The method here calls through to isError():

bool isError() {
  return Bits.PoundDiagnosticDecl.IsError;
}

What is this Bits thing? Every kind of declaration has 64 bits of space for some flags and metadata. For example, var has a flag on whether the declaration is static or not; enumeration cases have a flag on whether the case has associated values.

Our PoundDiagnosticDecl has a one-bit IsError field defined. If true (1) it’s an error; if false (0) it’s a warning.

Extra credit for the curious: see the rest of PoundDiagnosticDecl.

The Parser

OK, we’ve defined the tokens and set up our declaration subclass.

The parser runs through the source and finds a #warning or #error in the code. There’s a big loop and switch statement in ParseDecl.cpp and we’ll need to add two more cases:

case tok::pound_warning:
  DeclParsingContext.setCreateSyntax(SyntaxKind::PoundWarningDecl);
  DeclResult = parseDeclPoundDiagnostic();
  break;
case tok::pound_error:
  DeclParsingContext.setCreateSyntax(SyntaxKind::PoundErrorDecl);
  DeclResult = parseDeclPoundDiagnostic();
  break;

That takes us to parseDeclPoundDiagnostic() to do the string parsing work of pulling out the message text.

Of course, programmers can get things wrong. What if someone wrote this:

#warning "Here be dragons!"

Hey, they forget the parentheses around the string! We could throw a generic error here but why not be friendly and detect this particular error?

if (!hadLParen && !hadRParen) {
  // Catch if the user forgot parentheses around the string, e.g.
  // #warning "foo"
  diagnose(lParenLoc, diag::pound_diagnostic_expected_parens, isError)
      .highlight(messageExpr->getSourceRange())
      .fixItInsert(messageExpr->getStartLoc(), "(")
      .fixItInsertAfter(messageExpr->getEndLoc(), ")");
  return makeParserError();
}

We’ve already tokenized the line of code to look for the left and right parentheses (hadLParen and hadRParen). If both of them are missing, show the pound_diagnostic_expected_parens error.

To be extra friendly, note that this particular diagnostic has fix-its! Xcode will suggest adding the parentheses at the correct locations.

If all goes well, we need to instantiate one of our PoundDiagnosticDecl objects with the correct information: source location, whether it’s an error or warning, and the message text.

Extra credit:

Semantic Analysis

At the end of the parsing step, we’re left with an abstract syntax tree, a structured form of the original Swift source code. 🌲

The next compiler step is semantic analysis where it walks through the tree and does things like type checking.

Semantic analysis: decorate the AST

This is the phase where we’ll want to trigger actual warnings and errors from #warning and #error.

So we’re walking down the tree and come across one of our new PoundDiagnosticDecl objects. How do we handle it? TypeCheckDecl.cpp has the details:

TC.diagnose(PDD->getMessage()->getStartLoc(),
  PDD->isError() ? diag::pound_error : diag::pound_warning,
  PDD->getMessage()->getValue())
  .highlight(PDD->getMessage()->getSourceRange());

TC is the type checker object. You’ve already seen a diagnose() call in the parser, and the type checker has a similar method to raise warnings and errors.

In this case it’s not a diagnostic about the code itself, i.e. there isn’t a type mismatch or a syntax error. Instead, #warning and #error are meant to trigger diagnostics directly, and they use the same diagnose() as would be used for any other error or warning.

Code Generation

In the case of #error, compilation would have stopped already.

For #warning, compilation can continue on to the next phase: code generation. We’ve finished semantic analysis of the AST, and now we need to generate Swift Intermediate Language (SIL) code.

Code generation: AST to SIL and beyond

Again, we’re walking down the tree, writing some SIL as we go, and come across a PoundDiagnosticDecl instance. How do we handle it? Take a look at SILGen.cpp:

void SILGenModule::visitPoundDiagnosticDecl(PoundDiagnosticDecl *PDD) {
  // Nothing to do for #error/#warning; they've already been emitted.
}

Ha! It was a trick question. 😈

We’ve already generated the compiler warning, which was the whole point. There’s no functional difference in the program itself, so there’s no corresponding SIL.

That means it’s the end of the line for our diagnostic directives. If there’s no SIL then there’s nothing to carry on to the Intermediate Representation (IR) and beyond to the final compiled object file.

The Closing Brace

That was a new language feature in a nutshell. For #error and #warning, the steps were:

  • Add tokens so the new directives are recognized.
  • Parse the declarations and check for syntax errors.
  • Store the declarations in the AST
  • Add handlers for what to do when you come across one of these diagnostic directives.

For the seriously curious and nerdy, the full diff is here for your reading pleasure: Implement #warning and #error.

There are some more extra credit things to look at — check out all the tests as well as the delightful additions to the emacs mode and vim syntax. 😍

Are you ready to add your own directives to the language? Might I suggest adding #troll() or #rickRoll() as good starter tasks? 😜

}