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 tooValues
| 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 * 12is blank whileincomeis 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.relevantfails → hidden;requiredfails → not required;constraintfails → 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 asTRUE/FALSE.AND,OR,IFandIFBLANKonly 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.