///|
/// Recursive-descent parser for `.kiln` schema files.
///
/// Errors are reported as `line N: ...` strings and always point at the token
/// that broke the grammar, never at an internal function.
pub suberror SchemaError {
SchemaError(String)
}
///|
priv struct Parser {
toks : Array[Token]
mut pos : Int32
}
///|
fn Parser::new(toks : Array[Token]) -> Parser {
{ toks, pos: 0, }
}
///|
fn Parser::peek(self : Parser) -> Token {
if self.pos >= self.toks.length() {
self.toks[self.toks.length() - 1]
} else {
self.toks[self.pos]
}
}
///|
fn Parser::advance(self : Parser) -> Token {
let t = self.peek()
if self.pos < self.toks.length() - 1 {
self.pos = self.pos + 1
}
t
}
///|
fn Parser::at_eof(self : Parser) -> Bool {
match self.peek().kind {
TEof => true
_ => false
}
}
///|
fn Parser::at_punct(self : Parser, p : String) -> Bool {
match self.peek().kind {
TPunct(q) => q == p
_ => false
}
}
///|
fn Parser::at_ident(self : Parser, name : String) -> Bool {
match self.peek().kind {
TIdent(s) => s == name
_ => false
}
}
///|
fn Parser::eat_punct(self : Parser, p : String) -> Bool {
if self.at_punct(p) {
let _ = self.advance()
true
} else {
false
}
}
///|
fn Parser::expect_punct(self : Parser, p : String) -> Unit raise SchemaError {
if !self.eat_punct(p) {
let t = self.peek()
raise SchemaError(
"line \{t.line}: expected '\{p}' but found \{describe(t.kind)}",
)
}
}
///|
fn Parser::expect_ident(self : Parser) -> String raise SchemaError {
let t = self.advance()
match t.kind {
TIdent(s) => s
k =>
raise SchemaError(
"line \{t.line}: expected an identifier but found \{describe(k)}",
)
}
}
///|
fn Parser::expect_string(self : Parser) -> String raise SchemaError {
let t = self.advance()
match t.kind {
TStr(s) => s
k =>
raise SchemaError(
"line \{t.line}: expected a string but found \{describe(k)}",
)
}
}
///|
fn describe(k : Tok) -> String {
match k {
TIdent(s) => "identifier '\{s}'"
TNum(n) => "number \{n}"
TStr(_) => "string"
TPunct(p) => "'\{p}'"
TBad(c) => "unexpected character '\{c}'"
TEof => "end of file"
}
}
///|
fn Parser::parse_family(self : Parser) -> Family raise SchemaError {
let t = self.peek()
let name = self.expect_ident()
match name {
"quadratic" => FQuadratic
"rational" => FRational
other =>
raise SchemaError(
"line \{t.line}: unknown family '\{other}' (supported: quadratic, rational)",
)
}
}
///|
fn Parser::parse_domain(self : Parser) -> (Int32, Int32) raise SchemaError {
self.expect_punct("[")
let lo = self.parse_signed_int()
self.expect_punct(",")
let hi = self.parse_signed_int()
self.expect_punct("]")
if lo > hi {
raise SchemaError("empty domain [\{lo}, \{hi}]: lower bound exceeds upper")
}
(lo, hi)
}
///|
fn Parser::parse_signed_int(self : Parser) -> Int32 raise SchemaError {
let t = self.peek()
let neg = self.eat_punct("-")
let n = self.advance()
match n.kind {
TNum(v) => if neg { (-v).to_int() } else { v.to_int() }
k =>
raise SchemaError(
"line \{t.line}: expected an integer bound but found \{describe(k)}",
)
}
}
///|
fn Parser::parse_vardecls(self : Parser) -> Array[VarDecl] raise SchemaError {
self.expect_punct("{")
let out : Array[VarDecl] = []
while !self.at_punct("}") {
if self.at_eof() {
raise SchemaError("unexpected end of file inside a block")
}
let name = self.expect_ident()
self.expect_punct(":")
if self.at_ident("int") {
let _ = self.advance()
if !self.at_ident("in") {
raise SchemaError("line \{self.peek().line}: expected 'in' after 'int'")
}
let _ = self.advance()
let (lo, hi) = self.parse_domain()
out.push({ name, domain: Some((lo, hi)), expr: None, })
} else {
let e = self.parse_expr()
out.push({ name, domain: None, expr: Some(e), })
}
let _ = self.eat_punct(",")
}
self.expect_punct("}")
out
}
///|
fn Parser::parse_requires(self : Parser) -> Array[Require] raise SchemaError {
self.expect_punct("{")
let out : Array[Require] = []
while !self.at_punct("}") {
if self.at_eof() {
raise SchemaError("unexpected end of file inside a require block")
}
let left = self.parse_expr()
let op = self.parse_cmp()
let right = self.parse_expr()
out.push({ left, op, right, })
let _ = self.eat_punct(",")
}
self.expect_punct("}")
out
}
///|
fn Parser::parse_cmp(self : Parser) -> CmpOp raise SchemaError {
let t = self.advance()
match t.kind {
TPunct("<") => CLt
TPunct("<=") => CLe
TPunct(">") => CGt
TPunct(">=") => CGe
TPunct("==") => CEq
TPunct("!=") => CNe
k =>
raise SchemaError(
"line \{t.line}: expected a comparison operator but found \{describe(k)}",
)
}
}
///|
fn Parser::parse_expr(self : Parser) -> Expr raise SchemaError {
self.parse_add()
}
///|
fn Parser::parse_add(self : Parser) -> Expr raise SchemaError {
let mut left = self.parse_mul()
let mut go = true
while go {
if self.eat_punct("+") {
left = EAdd(left, self.parse_mul())
} else if self.eat_punct("-") {
left = ESub(left, self.parse_mul())
} else {
go = false
}
}
left
}
///|
fn Parser::parse_mul(self : Parser) -> Expr raise SchemaError {
let mut left = self.parse_unary()
let mut go = true
while go {
if self.eat_punct("*") {
left = EMul(left, self.parse_unary())
} else if self.eat_punct("/") {
left = EDiv(left, self.parse_unary())
} else {
go = false
}
}
left
}
///|
fn Parser::parse_unary(self : Parser) -> Expr raise SchemaError {
if self.eat_punct("-") {
ENeg(self.parse_unary())
} else {
self.parse_pow()
}
}
///|
fn Parser::parse_pow(self : Parser) -> Expr raise SchemaError {
let base = self.parse_atom()
if self.eat_punct("^") {
let t = self.peek()
let neg = self.eat_punct("-")
let n = self.advance()
match n.kind {
TNum(v) =>
if neg {
raise SchemaError(
"line \{t.line}: negative exponents are not allowed",
)
} else {
EPow(base, v.to_int())
}
k =>
raise SchemaError(
"line \{t.line}: expected an integer exponent but found \{describe(k)}",
)
}
} else {
base
}
}
///|
fn Parser::parse_atom(self : Parser) -> Expr raise SchemaError {
let t = self.advance()
match t.kind {
TNum(v) => ELit(rat_int(v))
TIdent(s) => EVar(s)
TPunct("(") => {
let e = self.parse_expr()
self.expect_punct(")")
e
}
k =>
raise SchemaError(
"line \{t.line}: expected a number, variable or '(' but found \{describe(k)}",
)
}
}
///|
fn Parser::parse_schema(self : Parser) -> Schema raise SchemaError {
let mut name = ""
let mut family = FQuadratic
let mut title = ""
let mut answers : Array[VarDecl] = []
let mut params : Array[VarDecl] = []
let mut requires : Array[Require] = []
let mut ask = ""
while true {
let t = self.peek()
match t.kind {
TEof => break
TIdent(kw) =>
if kw == "schema" {
let _ = self.advance()
name = self.expect_string()
} else if kw == "family" {
let _ = self.advance()
family = self.parse_family()
} else if kw == "title" {
let _ = self.advance()
title = self.expect_string()
} else if kw == "answer" {
let _ = self.advance()
answers = self.parse_vardecls()
} else if kw == "param" {
let _ = self.advance()
params = self.parse_vardecls()
} else if kw == "require" {
let _ = self.advance()
requires = self.parse_requires()
} else if kw == "ask" {
let _ = self.advance()
ask = self.expect_string()
} else {
raise SchemaError("line \{t.line}: unexpected keyword '\{kw}'")
}
k => raise SchemaError("line \{t.line}: unexpected \{describe(k)}")
}
}
{ name, family, title, answers, params, requires, ask, }
}
///|
/// Load and validate a schema. Validation is separate from parsing so that a
/// malformed *program* (undefined variable, no answers, duplicate name) is
/// reported as clearly as a malformed *file*.
pub fn load_schema(src : String) -> Result[Schema, String] {
let parsed : Result[Schema, String] = Ok(
Parser::new(tokenize(src)).parse_schema(),
) catch {
SchemaError(msg) => Err(msg)
}
match parsed {
Err(e) => Err(e)
Ok(s) =>
match validate(s) {
Some(e) => Err(e)
None => Ok(s)
}
}
}
///|
fn validate(s : Schema) -> String? {
if s.name == "" {
return Some("schema: missing `schema \"name\"` header")
}
if s.answers.length() == 0 {
return Some("schema '\{s.name}': at least one answer variable is required")
}
let seen : Array[String] = []
for v in s.answers {
if contains_str(seen, v.name) {
return Some("schema '\{s.name}': duplicate variable '\{v.name}'")
}
seen.push(v.name)
if v.domain is None {
return Some(
"schema '\{s.name}': answer variable '\{v.name}' needs a domain, e.g. `[1, 9]`",
)
}
}
for v in s.params {
if contains_str(seen, v.name) {
return Some("schema '\{s.name}': duplicate variable '\{v.name}'")
}
match v.expr {
Some(e) =>
for used in e.vars() {
if !contains_str(seen, used) {
return Some(
"schema '\{s.name}': '\{v.name}' refers to undefined variable '\{used}'",
)
}
}
None => ()
}
seen.push(v.name)
}
for r in s.requires {
for side in [r.left, r.right] {
for used in side.vars() {
if !contains_str(seen, used) {
return Some(
"schema '\{s.name}': require clause refers to undefined variable '\{used}'",
)
}
}
}
}
if s.ask == "" {
return Some("schema '\{s.name}': missing `ask \"...\"` template")
}
None
}
///|
fn contains_str(xs : Array[String], x : String) -> Bool {
let mut found = false
for y in xs {
if y == x {
found = true
}
}
found
}
///|
const SAMPLE_SCHEMA : String =
#|schema "quadratic_int_roots"
#|family quadratic
#|title "一元二次方程(整数根)"
#|
#|answer {
#| r1: int in [-9, 9]
#| r2: int in [-9, 9]
#|}
#|
#|param {
#| a: 1
#| b: -(a * (r1 + r2))
#| c: a * r1 * r2
#|}
#|
#|require {
#| r1 < r2
#|}
#|
#|ask "解方程 {poly} = 0"
#|
///|
test "parse the sample schema" {
match load_schema(SAMPLE_SCHEMA) {
Ok(s) => {
inspect(s.name, content="quadratic_int_roots")
inspect(s.answers.length(), content="2")
inspect(s.params.length(), content="3")
inspect(s.requires.length(), content="1")
inspect(s.ask, content="解方程 {poly} = 0")
}
Err(e) => abort("unexpected error: \{e}")
}
}
///|
test "parse rejects an undefined variable" {
let src = "schema \"s\"\nfamily quadratic\nanswer {\n x: int in [1, 2]\n}\nparam {\n y: x + z\n}\nask \"{poly}\"\n"
match load_schema(src) {
Ok(_) => abort("expected a validation error")
Err(e) => inspect(e.contains("undefined variable"), content="true")
}
}
///|
test "parse rejects an empty domain" {
let src = "schema \"s\"\nfamily quadratic\nanswer {\n x: int in [5, 2]\n}\nask \"{poly}\"\n"
match load_schema(src) {
Ok(_) => abort("expected an error")
Err(e) => inspect(e.contains("empty domain"), content="true")
}
}
///|
test "parse reports the line of a syntax error" {
let src = "schema \"s\"\nfamily quadratic\nanswer {\n x: int in [1, 2\n}\n"
match load_schema(src) {
Ok(_) => abort("expected an error")
Err(e) =>
inspect(e.contains("line 5") || e.contains("line 4"), content="true")
}
}