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.
They are not nameless representations; they contain real names;
They need to be aware of source file positions; to report errors accurately.;
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