quillflow formulas

Rules in a form (relevant, required, readonly, constraint, calculate, data-source params and when) are written as Excel-style formulas:

AND(applicant.age >= 18, loan.amount <= 500000)
IF(employment = "self", income * 12, salary)
MATCHES(., "^[0-9]{8}$")

The compiler parses formulas into a small JSON AST. Runtimes only evaluate the AST, so a new runtime (C#, Java, …) needs an evaluator but no parser. Behaviour is pinned by conformance/formulas.json. A runtime is conformant when it passes every case.

Syntax (for authors)

Element Examples Notes
Number 18, 0.8 . is the decimal separator.
Text "self", "say ""hi""" Double quotes; "" inside text is one quote. Word/Excel "smart quotes" are accepted.
TRUE / FALSE TRUE, false, TRUE() Not case-sensitive.
Field income, applicant.income Full path, or a name relative to the current group, or a name that is unique in the form.
This field . Only in a field's own rules.
Data source @company.name The result of a data source, optionally drilling into it.
Function ROUND(x, 2), round(x; 2) Names are not case-sensitive. , or ; separate arguments (Danish Excel uses ;).
Leading = =AND(a, b) Allowed and ignored, as in Excel.

Operators, lowest to highest precedence (all left-associative):

Precedence Operators AST
1 = <> < <= > >= (also ==, !=) EQ NE LT LE GT GE
2 & (join text) CONCAT (one call with all parts)
3 + - ADD SUB
4 * / MUL DIV
5 unary - NEG; a negative number literal is folded to {"lit": -5}

AND, OR and NOT are functions, as in Excel.

AST (for runtimes)

{ "lit": 18 }                              // number | string | boolean | null
{ "var": "applicant.age" }                 // field path, fully resolved
{ "var": "@company.address.city" }         // data-source result + optional property path
{ "fn": "GE", "args": [ {…}, {…} ] }       // function call; operators are calls too

Values

Type Representation
blank null. "" and [] also count as blank.
number IEEE 754 double. Round money with ROUND.
text string
boolean true / false
date text YYYY-MM-DD. There is no separate date type; ISO dates compare correctly as text.
list array (a select_multiple value, or a data-source result)
record object (a data-source result)

A variable resolves to the field's effective value. A field that is not relevant (hidden) reads as blank. A data-source path that does not exist reads as blank.

Semantics

  • Blank in arithmetic gives blank: income * 12 is blank while income is empty (Excel would say 0).
  • Errors give blank. A wrong type ("a" * 2), division by zero or a non-finite result makes the whole formula blank. For TRUE/FALSE rules, blank or an error means FALSE.
    • relevant fails → hidden; required fails → not required; constraint fails → the value is rejected.
  • Truthiness (AND, OR, NOT, IF, rules): blank → FALSE; numbers → non-zero; text → error.
  • Equality (=, <>): blank equals only blank. Numbers compare numerically. Text is case-sensitive. Values of different types are unequal. Lists compare element-wise.
  • Ordering (< etc.): FALSE if either side is blank. Numbers compare numerically, and text compares ordinally by UTF-16 code unit (which orders ISO dates correctly). Mixed types are an error.
  • & joins blank as "", numbers in shortest round-trip form (1.5, 100), and booleans as TRUE/FALSE.
  • AND, OR, IF and IFBLANK only evaluate the arguments they need.

Functions

Function Result
AND(a, …), OR(a, …), NOT(a) Logic.
IF(cond, then, [else]) else defaults to blank.
ISBLANK(x) TRUE for blank, "" and [].
IFBLANK(x, fallback) x, or fallback when x is blank.
LEN(text) Length in UTF-16 code units; blank → 0.
UPPER(text), LOWER(text) Culture-invariant case mapping. Characters whose case mapping changes length (ß → SS) are implementation-defined.
TRIM(text) Removes leading/trailing whitespace and collapses runs of spaces to one.
LEFT(text, [n=1]), RIGHT(text, [n=1]) First/last n characters.
CONTAINS(text, part) Case-sensitive substring test.
CONTAINS(list, value) List membership (e.g. a select_multiple answer).
MATCHES(text, pattern) Regular-expression search (use ^…$ to match the whole text). Blank → FALSE. Use the portable subset: literals, ., […], \d \w \s, * + ? {n,m}, groups, `
ROUND(x, [digits=0]) Half away from zero: sign(x) * round_half_up(abs(x) * 10^digits) / 10^digits, computed in doubles exactly like that.
ABS(x), POWER(x, y)
MIN(…), MAX(…) Over numbers and lists, ignoring blanks; blank if nothing is left.
SUM(…) As above; 0 if nothing is left.
COUNT(…) Number of non-blank values (lists are flattened).
TODAY() The evaluation date, supplied by the host so results are reproducible.
DATE(y, m, d) Months and days overflow as in Excel: DATE(2024, 13, 1) = 2025-01-01.
YEAR(d), MONTH(d), DAY(d)
DAYS(end, start) Days from start to end (Excel argument order).
EDATE(d, months) Adds months, clamping to month end: EDATE("2024-01-31", 1) = 2024-02-29.
ADDDAYS(d, n) Adds days.
AGE(birthdate, [asOf=TODAY()]) Completed years. A 29 February birthday counts from 1 March in non-leap years.

Arguments marked as numbers must be numbers, and integer arguments (digits, n, months, DATE parts) must be whole numbers. Anything else is an error, and the formula gives blank.