When exploring intrinsically-typed terms we were creating representations that sit within a verified core. Concrete Syntax trees sit at our language’s periphery and have different properties.

The intrinsically-typed terms we were crafting contained neither.

Moreover, as our concrete syntax trees represent programmer input they must have the potential to represent ill-typed code.

In this section, we will first define some real-word syntax representations suitable for capturing the output of parsing. We will and look at how we can make sound and complete decisions in dependently-typed languages, and make them better for representing errors.

Location, Location, Location

Within Idris2, and my own libraries, we capture source file spans as pair of file locations. We will use these file spans, FileContext as a way to capture areas of interest in our concrete syntax trees.

A location is a potential source file name, paired with a line and column number. As an Idris record, we represent a location as:

record Location where
  constructor MkLoc
  source : Maybe String
  line   : Nat
  col    : Nat

A FileContext is, again a record, but pairing two file spans:

record FileContext where
  constructor MkFC
  source : Maybe String
  start  : Location
  end    : Location

We will use these to annotate our concrete syntax, and do so during parsing.

Olaf’s parser supports embedding and capturing locations during parsing.

Extra.Text.Location contains file context representations, and supporting operations on them.

Concrete Syntax

Reconsider the following uni-typed representation of expressions from lecture 1:

data Expr = B Bool
          | I Int
          | Add Expr Expr
          | And Expr Expr
          | Var String
          | Let String Expr Expr

This representation can work as a datatype for concrete syntax, but requires annotating with FileContext. Rather than directly embedding FileContext into each constructor explicitly, we will parameterise expressions by a state to allow arbitrary information to be added to each node. For example, location information or highlighting annotations!

We are really, making Expr a functor instance. Thus, Expr now becomes:

data Expr : (state : Type) -> Type where
  B : (state : a) -> (b : Bool) -> Expr a

  I : (state : a) -> (i : Int)  -> Expr a

  Add : (state : a)
     -> (lop   : Expr a)
     -> (rop   : Expr a)
              -> Expr a

  And : (state : a)
     -> (lop : Expr a)
     -> (rop : Expr a)
            -> Expr a

  Var : (state : a)
     -> (name  : String)
              -> Expr a

  Let : (state : a)
     -> (name  : String)
     -> (type  : (a, Ty))
     -> (expr  : Expr a)
     -> (scope : Expr a)
              -> Expr a

This is how Idris’ own concrete syntax is, almost, defined! As Idris is dependently-typed we do not have a distinct type for types.

Using this approach, when turning concrete syntax into abstract syntax, we now have file locations to point to better errors. We will see that in later on in this section.

Better Concrete Syntax for Statements

We must remember that we are dealing with an imperative language that has statements. With intrinsically-typed representations, we presented an inductive tree-based representation of syntax. Although this led to a dangling hole when evaluating statements the tree-based structure means a less complicated intrinsic setup. That being said, I like to keep my parser simple: A parser should Parse not compute.

Within Olaf, semi-colons (;) indicate sequencing of expressions we saw earlier in lecture 1 a more explicit blocked based structure for representing statements. Some thing like:

data Stmt = Return Expr
          | Let String Ty Expr
          | If Expr Stmt Stmt
          | While Expr Stmt
          | Block (List Stmt)

Like our intrinsically-typed version of Stmt, we can nonetheless represent somewhat badly shaped statements. That is, one in which a statement body may have one statement or a block of statements. We can use dependent types to parameterise our concrete version of Stmt to ensure that each nested statement within a statement is an actual block. When Stmt is parameterised with state information, like Expr, we can ensure that nested statements are blocks.

We do so by defining an ‘kind’ for statements indicating if a statement is a node or a leaf:

data KIND = BLOCK | LEAF

We can then index the type of Stmt to contain a type-level kind, and a value level state.

data Stmt : (kind : KIND) -> (state : Type) -> Type where

All statements that do not contain internal branches, (such as Return and Let) will be considered LEAF statements.

  Return : (state : a)
        -> (expr  : Expr a)
                 -> Stmt LEAF a

  LetTy : (state : a)
       -> (var   : String)
       -> (type  : (a, Types.Ty))
       -> (expr  : Expr a)
                -> Stmt LEAF a

Branching statements such as If and While will contain BLOCKS.

  Cond : (state : a)
      -> (expr  : Expr a)
      -> (whenT : Stmt BLOCK a)
      -> (whenF : Stmt BLOCK a)
               -> Stmt LEAF a

  While : (state : a)
       -> (expr  : Expr a)
       -> (scope : Stmt BLOCK a)
                -> Stmt LEAF a

Blocks contain a sequence of LEAF statements:

  Block : (state : a)
       -> (block : List $ Stmt LEAF a)
                -> Stmt BLOCK a

We will see when elaborating statements, how this KIND helps make elaboration a bit more regimented.

We have shown two examples of concrete syntax trees for expressions and statements.

Create a set of concrete trees for Olaf/Ola’s expressions, statements, methods, and programs.

Real-World Decisions

Decidablity is the work-horse of theorem proving in dependently-typed languages. Represented as the indexed datatype Dec, we either can produce an inhabited of a type or falsity, proof that we cannot produce an inhabited of a type.

data Dec : type -> Type
  where
    Yes : (prf : type) -> Dec type
    No  : (prf : type -> Void) -> Dec type

While Dec is great for theorem proving, proofs of void are compile time only constructs. When pattern matching on Dec instances to show its contents we cannot say anything about why the decision failed.

Consider the following Show instance for Dec:

Show a => Show (Dec a) where
  show (Yes prf) = show prf
  show (No prf) = ?rhs

If you add this to an Idris file, and check the type of the hole you will get:

 0 a : Type
   contra : a -> Void
------------------------------
rhs : String

Although there is some interesting work coming from Strathclyde on Datatypes with Negation and Being Positively Negative about Dependent Types we still need to make decisions more informative.

The easiest way to do so is to treat Dec like Either, adding a showable error component on the negative position.

data Dec : (emsg : Type)
        -> (pred : Type)
                -> Type
  where

The Yes constructor stays as before.

    Yes : (prf : p) -> Dec e p

Whilst the No data constructor includes a message of why not,

    No  : (msg : e)
       -> (no  : p -> Void)
              -> Dec e p