Skip to content

Axis 2 — language specification

Axis is a language that compiles to Desmos graphs. This document is the contract the v2 implementation is built against: the lexer, the parser, the checker, the compiler and every editor service read the language the way it is written here, and where the code and this document disagree, one of them is a bug.

The syntax tree the parser produces is defined in packages/syntax/src/ast.ts; this document refers to its node kinds by name.

// A comment runs to the end of the line.
config { showGrid: true; xmin: -7; xmax: 7 }
style emphasis { color: RED; lineWidth: 4 }
macro wave(k, phase) = sin(k * x + phase)
"A note"
folder "Waves" { @ collapsed
a = 1 @ slider: -5..5 step 0.5
y = a * wave(2, tau / 4) @ use: emphasis
y = wave(1, 0) @{
color: rgb(40, 120, 200)
lineStyle: DASHED
}
}
table { x = [1, 2, 3]; y = [1, 4, 9] @ lines }
n = 0
ticker n -> n + dt @ minStep: 50, playing
Open in playground →

Source is UTF-16 text; every position in the implementation is a UTF-16 offset.

  • Whitespace: spaces, tabs, \r.
  • Comments: // to the end of the line, not counting the \r of a \r\n line ending. There are no block comments.
  • Newlines are not trivia. A newline is a token, and it ends a statement (§3.1) — except inside an open (, [ or expression { (§2.4), where the lexer still emits it but the parser skips it.

The lexer keeps trivia in the token stream, so the concatenation of every token’s text is the source, exactly. Lexing never fails: anything it cannot read becomes an error token and a diagnostic.

TokenExamplesNotes
number3, 0.5, .5, 1e-3No sign; - is an operator. 1e-3 is one token only when a digit follows e/e-.
identifierx, amp, x_1, x_12, theta[A-Za-z][A-Za-z0-9]*(_[A-Za-z0-9]+)* (see below)
string"a \"b\" c"Escapes: \", \\, \n. Unterminated at end of line is an error.
color#c74440, #fff# followed by exactly 3 or 6 hex digits. Any other # is an error.
keywordsfolder table config import image ticker style macro as for with step softReserved: never identifiers. min/max are contextual (§4.4), not keywords.
punctuation( ) [ ] { } , ; : . .. ... = < <= > >= + - * / ^ ! ~ | -> @ @{ '@{ is one token only with no space between. ' is a prime, one to a token.
newline
errorAnything else.

.. and ... are distinct: ... is Desmos’ list range ([1...10]), .. is the Axis range literal (§4.4).

Details the table leaves open:

  • A decimal point needs a digit after it. 0.5 and .5 are numbers, but a number never takes a . that is not followed by a digit - so 0..5 is 0 .. 5, 0.. is 0 .., 1...10 is 1 ... 10, and 1. is 1 and ..
  • An identifier may carry more than one _ part (LOOP_FORWARD_REVERSE, above_left), because Desmos spells some enum values that way and they have to be writable (§4.2). In an expression a name has at most one subscript (§2.3), and the checker reports x_1_2; the lexer reads it as one name so the error is about the name, not about a stray _.
  • A string escape other than \", \\ and \n is an error (invalid-escape) and stands for the character after the backslash.
  • A bad colour - # and anything but exactly 3 or 6 hex digits - is one error token covering the # and every letter and digit after it (invalid-color), so #ff00 is one mistake rather than a colour and a name.
  • A run of characters the lexer does not know is one error token and one unexpected-character diagnostic, not one per character.

An identifier is written as the author wants it read; the compiler turns it into Desmos’ spelling:

  • one letter stays itself: x → x
  • more than one becomes a subscript: amp → a_{mp}, L1 → L_{1}
  • an explicit subscript is braced: x_1 → x_{1}, x_12 → x_{12}
  • a Greek letter or constant from the manifest becomes its command: theta → \theta, and theta2 → \theta_{2}
  • pm and mp are \pm and \mp, the two symbols Desmos lets a graph name something with - on their own only, so pmax is p_{max}
  • a geometry token, $ and digits, is \token{…}: $12 → \token{12}. The geometry calculator names the constructions nobody named this way, and a token is a name like any other - defined once ($12 = segment(A, B)), used anywhere - but only on that calculator (§5.3)

( [ always open an expression bracket. { opens an expression bracket (a piecewise, §5.8) except where the grammar expects a block — after a block keyword (folder, table, config, style), and as @{. Inside an expression bracket newlines do not end anything, so a long list or piecewise may be spread over lines freely. Inside a block they separate entries.

A statement ends at a newline or at ;. That holds at the top level and inside every block. Blank statements (two separators in a row) are ignored.

a = 1; b = 2 // two statements
table { x = [1, 2]; y = [3, 4] }
Open in playground →

A comma never separates statements. Commas belong to expressions: points, lists, calls, action runs, and with/for bindings.

file = { separator } { statement { separator } } ;
separator = newline | ";" ;
statement = config | folder | table | style | macro
| import | image | ticker | note | expressionStatement ;
config = "config" "{" { separator } { property { separator } } "}" ;
folder = "folder" [ string ] "{" [ metadata ] { separator } { statement { separator } } "}" ;
table = "table" "{" [ metadata ] { separator } { column { separator } } "}" ;
column = expression [ metadata ] ;
style = "style" identifier "{" { separator } { property { separator } } "}" ;
macro = "macro" identifier [ "(" [ identifier { "," identifier } ] ")" ] "=" expression ;
import = "import" string [ "as" string ] [ metadata ] ;
image = "image" string [ metadata ] ;
ticker = "ticker" actionRun [ metadata ] ;
note = string [ metadata ] ;
expressionStatement = statementExpression [ metadata ] ;

Notes on each:

  • config — entries are properties (§4). A file has at most one. Only at the top level.
  • folder — the title is optional: folder { … } is an untitled folder. Folders do not nest (Desmos has one level), so a folder inside a folder is an error. A metadata clause immediately after the { (before the first separator) annotates the folder itself. Metadata anywhere else that trails nothing - on a line of its own - is an error (misplaced-metadata).
  • table — each entry is a column. x = [1, 2, 3] is a column with a header and values; a bare expression (x ^ 2) is a computed column. A cell left blank is an empty slot, y = [4, , 6], which only a column’s values may have (misplaced-blank). The same after-{ rule gives the table metadata; a column’s own metadata trails it. Only header = [ … ] splits into a header and values: anything else, x = 5 included, is a computed column as written, and the checker decides whether it can be one. Table metadata takes column properties, which apply to every column as defaults; a column’s own metadata wins.
  • style — a named, reusable set of properties (§4.5). Top level only.
  • macro — §6. Top level only. The body is read as a statement’s value is (§5.5), so it may be an action run: macro reset = a -> 0, b -> 0.
  • import — §7.
  • image — the string is a path, an http(s): URL, or a data: URI.
  • ticker — the graph’s ticker. Its handler is an action or an action run. Top level only; a graph has one. dt (milliseconds since the last tick) is available inside the handler.
  • note — a string on its own is a text note. A string followed by anything but metadata or the end of the statement starts an expression instead.
  • expression statement — everything else: a definition (f(x) = x^2, a = 1), an equation or inequality (y = x, y < x), a point, a list, a bare expression, an action run.

Metadata styles the statement it trails.

metadata = "@" property { "," property } (* inline *)
| "@{" { separator } { property { separator } } "}" ; (* block *)
property = identifier [ ":" propertyValue ] ;
propertyValue = range | expression ;

A property with no value is a bare flag and means true. Any boolean property may be written bare: @ hidden, fill.

Comma rule. Inside an inline @ …, a comma starts a new property only if what follows it is identifier : or a bare identifier followed by ,, a separator or the end. Otherwise the comma belongs to the value. So:

(1, 2) @ onClick: a -> 1, b -> 2, color: RED
// └──────── one value ───┘ └ property ┘

The rule applies to every comma at the top of the value: those of an action run and those between with/for bindings alike. Inside a bracket a comma always belongs to the bracket.

Inside @{ … } and in config/style blocks properties are separated by newlines or ;, and commas are always part of the value - with one exception, since it can never be part of one: a comma followed by identifier : is reported as comma-between-properties and read as the separator it was meant as, so config { a: 1, b: 2 } still has both entries.

The parser reads every property value the same way, whatever the property: an expression, which may be an action run, or a range. Whether that is the right kind of value for the property is the checker’s business (§4.2), so color: RED, 3 parses (as a run) and is then rejected.

Every property has a valueType in the manifest, and the checker enforces it:

valueTypeAcceptsLowered as
expressionany expressionlatex (lineWidth: a + 1 is legal)
numbera numeric literal, optionally negateda JSON number
stringa string literalthe string
booleantrue, false, or barea boolean
enumone of the manifest’s listed identifiersthe manifest’s spelling of it
color§4.3color or colorLatex
range§4.4the Desmos bounds object
actionan action or action runlatex
stylea style nameresolved away (§4.5)
namethe name of a variablelatex (residuals: e1 is e_{1})

number is for what Desmos holds as a JSON number rather than latex: most config numbers, and a slider’s playDirection and animationPeriod.

Enums are case-insensitive. The manifest lists each value as Desmos spells it - NONE, DASHED, LOOP_FORWARD, but above and linear - and a value written in any case is lowered to that spelling: dragMode: none is NONE, labelOrientation: ABOVE is above. histogramMode: count is the one value Desmos spells as nothing at all; it is its default, and written as leaving the mode off.

color accepts three things:

  • a hex literal #rgb / #rrggbb → color: "#rrggbb"
  • a Desmos palette name — RED, BLUE, GREEN, PURPLE, ORANGE, BLACK → color set to that palette colour’s hex
  • any other expression — rgb(255, 0, 0), hsv(h, 1, 1), a variable c → colorLatex set to the expression’s latex

There is no separate colorLatex property.

A hex literal is lowered written out in full and in lower case - #ABC is #aabbcc - which is the only spelling Desmos writes back. Palette names, unlike enum values, are case-sensitive, because any other spelling is an expression: color: red is r·e·d. So a palette name in the wrong case is an error (invalid-color) unless the file defines that name itself, in which case it is the variable. A number, a string, an action or an equation is never a colour.

The config colours - backgroundColor, textColor, accentColor - are color too, but Desmos wants a hex string there, so they take only a hex literal or a palette name, never an expression.

calculator picks which Desmos calculator draws the graph: GRAPHING, the default, GEOMETRY or GRAPHING_3D. The geometry and 3D calculators draw the same expressions the graphing one does; nothing of their own - constructions, a z axis - is in Axis yet.

range = [ expression ] ".." [ expression ] [ "step" expression ] [ "soft" [ "min" | "max" ] ] ;
  • Either end may be left off to keep Desmos’ default for it: ..5, 0...
  • Every end and the step may be any expression: -a..a step a / 10.
  • Ends are hard by default (the slider will not go past them). soft makes both ends soft; soft min / soft max just the one.
  • min and max are contextual words here, not keywords.
  • step comes before soft when both are written.
  • A range is only ever a whole property value: .. anywhere else is an unexpected token. Its ends and step are read at the level of a comparison (§5.1), so nothing looser - an action, a run, a with - can be one.

Ranges are the value of slider, domain, parametricDomain and polarDomain. A domain is its two ends and nothing else: step and soft on one are an error (invalid-value). An end left off a domain is lowered as the empty string Desmos stores for it.

a = 1 @ slider: -5..5 step 0.5
b = 0 @ slider: 0.. soft
t = 0 @ slider: 0..2pi soft max, playing
(cos(t), sin(t)) @ domain: 0..tau
Open in playground →
style swatch { pointSize: 14; showLabel }
style loud { use: swatch; color: RED }
(-2, 1) @ use: swatch, color: BLUE
Open in playground →

use: name may appear any number of times in one metadata clause or style; styles are applied in the order written, and the clause’s own properties win over every style it uses. Styles may use other styles; a cycle is an error. Style names are global across the compilation (including imports), like macros, and live in their own namespace.

A style is checked as a style where it is defined, and where it is used for what it cannot know there: a property it sets that the place it is used does not take - showLabel from a style used on a table column - is misplaced-property, reported against the use:. A statement that uses a style stays writable back from the graph, since the use: is itself source.

Which properties are legal where is the manifest’s business: expression metadata, folder metadata, table metadata, column metadata, image metadata, ticker metadata, import metadata and config entries each have their own list. A property in the wrong place is an error.

PlacementTakes
expressionevery styling, slider, domain, click and label property; use
columncolor, lineStyle, lineWidth, lineOpacity, pointStyle, pointSize, movablePointSize, pointOpacity, hidden, points, lines, dragMode, use
tableexactly what a column takes, as defaults for every column
styleanything an expression or a column takes, slider included
foldercollapsed, hidden, secret, inFrontOfEverything
importcollapsed, hidden, secret, inFrontOfEverything
imagename, center, width, height, angle, opacity, foreground, hidden, secret, dragMode, onClick, clickable, disableGraphInteractions
tickerminStep, playing, open
notesecret
configthe calculator settings, and nothing that goes anywhere else

playing is two properties: a slider’s on an expression, the ticker’s own on a ticker. The manifest (appliesTo, propertiesFor, findProperty) is the authority; this table is its summary.

A property may be given once per clause (duplicate-property); only use is repeatable. An image’s dragMode is lowered to the draggable flag Desmos keeps for an image - any mode but NONE makes it draggable - because Desmos ignores dragMode on one.

Loosest first. Everything is left-associative unless noted.

LevelFormsNode
1body with a = 1, b = 2, body for i = L, j = MWith, For
2action run a -> 1, b -> 2 (statement values and action properties only)Sequence
3target -> valueAction
4= < <= > >= ~, chainable: 1 < x < 2Comparison
5+ -Binary
6* / and implicit multiplicationBinary (op: 'implicit' for juxtaposition)
7prefix -, +; d/dx, whose operand is a whole product (§5.9)Unary, Derivative
8^, right-associative; the exponent may start with -Binary
9postfix: call f(…), prime f'(…), index L[…], member .x, !Call, Prime, Index, Member, Factorial
10atomssee §5.2

Consequences, all deliberate:

1/2x = (1/2)·x → \frac{1}{2}x
a/b^2 = a/(b^2) → \frac{a}{b^{2}}
x^2/3 = (x^2)/3 → \frac{x^{2}}{3}
a/b/c = (a/b)/c → \frac{\frac{a}{b}}{c}
-x^2 = -(x^2) → -x^{2}
2^-1 = 2^(-1) → 2^{-1}
x^10 → x^{10}
2^3^2 = 2^(3^2)

cross(u, v) is Desmos’ \times, which has no function of its own: it multiplies numbers and lists as * does, but between two 3D points it is their cross product, where * - \cdot - is their dot product. It is written u\times v, and a graph’s \times reads back as cross(…).

Implicit multiplication is juxtaposition of two operands with nothing between them: 2x, 2pi x, 3cos(t), (a)(b), x y. An operand that starts with a sign is never juxtaposed - a -b is a subtraction - and a [ straight after an operand indexes it rather than multiplying it. Inside | … | a | closes the bar rather than opening a juxtaposed one.

A statement’s = is the exception to the table. In an expression statement, a macro body and a table column, lhs = rhs takes the rest of the statement as its right-hand side at the loosest level there is - an action, a run, a with or a for:

reset = E -> (2, -6), n -> 0 = reset = (E -> (2, -6), n -> 0)
R = A, B = R = (A, B)
f(x) = x n with n = 3 = f(x) = (x n with n = 3)

That is the only place = binds more loosely than -> and ,. A chain (1 < x < 2), or an = inside a bracket, keeps its place in the table: (R = a -> 1) is ((R = a) -> 1).

FormNode
3, 0.5Number
x, amp, thetaIdentifier
"text"String
#c74440Color
(e)Paren
(a, b), (a, b, c)Tuple (a point)
[a, b, c], [1...10], [1, 3...9], [1, ..., 10]List (with ListRange elements for ...)
[f(i) for i = [1...10]]List holding a For
{c1: v1, c2: v2, v3}, {x > 0}Piecewise
|e|Abs
sum(n = 1..10, e), prod(…), int(t = 0..1, e)BigOperator (§5.9)

Desmos’ own spelling of a range with commas round the dots, [1, ..., 10] or [1, 3, ..., 9], is read as the same ListRange as [1...10] and [1, 3...9]. In an index a range may leave an end off, meaning the start or the end of the list: L[2...] is everything from the second element on, L[...3] the first three, and L[[2, 4...]] every other one from the second - written in the index’s own brackets, L\left[2,4...\right], since that is the one place Desmos takes an open end. Anywhere else a range needs both ends, and one without is open-range. A list spread over lines may end with a trailing comma, and so may a call’s arguments; a trailing comma in plain parentheses, (a,), makes a one-element Tuple. An index holds exactly one expression: L[1, 2] is an error.

name(args) where name is an identifier parses as a Call node. Whether it is really a call is decided by the checker: a builtin or a user-defined function makes it a call; anything else with exactly one argument is a product (a(b + 1) is a·(b + 1)) and the checker marks it so. Both spellings lower to the same latex, so the distinction matters only for diagnostics.

A product needs a name Desmos reads as a value: a single letter, with or without a subscript (k(x - 1), k_1(x)); a variable the compilation defines; a parameter or a with/for binding in scope; a constant or an operator (pi(2)). A longer name that is none of those - sine(x) - is far more likely a misspelt function than a coefficient nobody defined, so it is unknown-function, as is a call on anything but a function with no arguments or more than one.

Only identifiers are callable. (f)(x) is a product.

real, imag, conj and arg exist only in complex mode, which config { allowComplex: true } turns on - in the entry file’s config, or an imported one’s where the entry says nothing. Anywhere else Desmos rejects them, so a use of one, called or written as a member (z.real), is requires-complex-mode.

The geometry functions - segment, line, ray, circle, arc, glider, intersection, angle, rotate and the rest - exist only on the geometry calculator, and triangle and sphere only on the 3D one; segment, vector, start and end on both. The manifest says which (calculators). Used in a graph for any other calculator, as a call or a member, one is requires-calculator, and so is a $ token outside a geometry graph. Their names are built-in everywhere all the same, so none of them can be defined.

.name after an expression is a Member: point coordinates (P.x, P.y) and list statistics written postfix (L.count, L.mean) — any single-argument function in the manifest may be used this way.

A member may be called: D.cdf(1), L.quantile(0.5), T.conf(0.95). It is the function called with the member’s target first - D.cdf(1) is cdf(D, 1) - and it is how Desmos writes a distribution’s and a test’s methods, so it is how the decompiler writes them too. A member called has to be a built-in function; P.x(2) is the coordinate times 2 and P.x(1, 2) the coordinate times the point, as they always were, and anything else is unknown-function.

D = normaldist(0, 1)
y = D.pdf(x)
p = D.cdf(-1, 1)
T = ttest([12, 15, 11, 14])
lo = T.conf(0.95).lower
Open in playground →

= is a comparison at the syntax level; what it means is the checker’s call:

  • f(x) = … where the left is a call on fresh identifiers: a function definition
  • a = … where a is an identifier other than x, y, r or theta: a variable definition
  • anything else: an equation

theta = … is reported as theta-equation: Desmos graphs r in terms of theta and never the reverse, in a polar graph or a cartesian one, so the curve is written r = ….

lhs ~ model is a regression: Desmos fits the model’s free names - its parameters - so that it matches lhs as closely as it can, and defines each of them for the rest of the graph. It is \sim in latex. residuals: e1 names the list the differences are kept in, which Desmos otherwise names e_1, e_2 and up; logMode fits in log space. The fitted values are Desmos’ to work out: they are no part of the file, the decompiler does not write them, and write-back does not count a refit as a change.

xs = [1, 2, 3, 4]
ys = [2.1, 3.9, 6.2, 7.8]
ys ~ m xs + b @ residuals: e1
Open in playground →

A regression is a statement of its own, and so is a chart - histogram, dotplot, boxplot, stats (§4, and the charts’ properties in the manifest). Assigned, nested in an expression, or chained, either is statement-only: Desmos draws neither anywhere else.

a -> e is an action; a -> 1, b -> 2 is an action run (a Sequence). A definition whose value is an action run names it: R = a -> 1, b -> 2. A sequence of names that are themselves actions is also a run: R = A, B.

for binds list variables for a comprehension; with substitutes values into the expression before it. Both take a comma-separated run of name = value bindings that extends to the end of the enclosing bracket or statement.

A with binding can also be a case of a function the file defines, f(1) = value: where the arguments match, f is the case’s value instead of its body. It is how Desmos writes the base of a recursion, and as many cases as it takes can follow one with. Such a binding binds no name, and one on a name that is not a function the file defines is unknown-function. The same cases can be written as statements of their own, f(1) = 1 beside f(x) = …, which Desmos reads the same way.

f(x) = 2x with f(1) = 1
g(n) = g(n - 1) + 3 with g(1) = 1, g(2) = 7
h(n) = h(n - 1) + 3
h(1) = 1
h(2) = 7
Open in playground →

{condition: value, condition: value, otherwise}; a branch without : value is a restriction ({x > 0}). A trailing entry without : value is the otherwise when some entry before it has a :; when none does, every entry is a bare condition, so {x > 0, x < 2} is two restrictions. A piecewise immediately after an expression is an implicit product and reads as a domain restriction: y = x^2 {x > 0}.

Sums, products, integrals, derivatives and logarithms to a base:

f(x) = x ^ 3
a = sum(n = 1..10, n ^ 2)
b = prod(k = 1..5, k)
c = int(t = 0..1, f(t))
y = d/dx f(x)
y = f'(x) + f''(x)
z = log(8, 2)
Open in playground →

sum, prod and int are built-in names, followed always by (name = from..to, body): the variable, the range it runs over - both ends included, and each end any expression up to a sum - and then the body. A sum or a product runs over the integers from from to to; an integral is a definite one, from to to. The variable is bound in the body and nowhere else, not in either bound. It shadows a variable the file defines, but it cannot be a name already bound where it stands - a parameter, a with/for binding, or the variable of a sum around it - which is rebound-variable. Anything but this shape after sum( is expected-bounds. The form is bracketed, so it is an atom: sum(n = 1..3, n) x is the sum times x. The latex is \sum_{n=1}^{10}, \prod_{n=1}^{10} and \int_{0}^{1}…dt.

d/dx body is the derivative of body with respect to x, and any name can follow the d: d/dt, d/dtheta, d/dx_1. It stands at level 7 like a sign, and its operand is the whole product after it, as Desmos reads one: d/dx x^2 + 1 is the derivative, then plus 1, and d/dx 3x * x is the derivative of 3x * x. It is a derivative only where an operand follows without a sign in between. Otherwise it is the division it looks like, so d/dx on its own is d over dx. \frac{d}{dx} in latex.

f'(x) is the derivative of the function f, and f''(x) its second derivative. A prime is written on a call to a function, a built-in or one the file defines: sin'(x) is cos(x), and a prime on anything else is unknown-function.

log(x, b) is the logarithm of x to the base b, \log_{b}(x). With one argument log is base 10.

macro TAU2 = 2tau
macro wave(k, phase) = sin(k * x + phase)
Open in playground →

A macro is an expression with a name. A use of it — TAU2, wave(2, 0) — is replaced by its body in the syntax tree, with the arguments substituted for the parameters as trees. Because substitution happens on trees, precedence is never an issue: macro double(a) = 2 * a used as double(1 + 2) ^ 2 is (2 · (1 + 2))².

  • Macros are hoisted: in scope for the whole compilation, including every file imported and every file importing the one that defines it.
  • Two definitions of one name are an error, even if identical.
  • A macro name shadows nothing: it may not collide with a builtin, a user function or variable, or another macro.
  • Arity must match; a parameterless macro is used without parentheses.
  • Recursion (direct or mutual) is an error. A macro that collides with a builtin or with a name the file defines is reported and left out, so the name keeps its other meaning everywhere it is used.
  • A macro expands only in expression positions; it cannot stand for a statement, a block or metadata. Reusable metadata is what styles are for.
  • A statement containing an expansion is not writable back from the graph (the graph holds the expansion, not the call).
  • A macro’s body is checked once, where it is defined, with its parameters in scope; a use is checked only for its arity. dt is allowed in a body, since a macro may be written for a ticker.
import "./lib/waves" // folder titled "waves"
import "./lib/waves.axis" as "Waves"
import "./lib/waves" @ collapsed: false

The path is relative to the importing file; a leading / is relative to the workspace root; .axis may be omitted. The imported file’s statements land in one folder of their own, flattened: folders inside it are dropped and their contents join the import’s folder. An import inside a folder joins that folder rather than opening one. Import folders start collapsed.

The imported file’s config applies too, with the importing file’s settings winning; likewise the importing file’s ticker replaces an imported one (and of several imported tickers, the last to be read). A cycle is an error (import-cycle), reported against the import that closes it.

A file imported more than once is included the first time and is nothing the other times, wherever the imports are: a second copy would define every name in it again, which Desmos rejects. An import that cannot be resolved is unresolved-import, and the rest of the file still compiles.

Every problem is a diagnostic with a stable code, a severity, a message and a span. The parser, the checker and the compiler all produce the same type, and the compiler never throws on bad input: it returns every diagnostic it found alongside whatever graph it could still build.

The lexer and parser report these codes. The parser never throws: it reports what it could not read, resynchronises at the next newline or ; (or the } of the block it is in), and leaves an ErrorStatement or ErrorExpression where the unreadable text was. One problem is reported once, however many rules trip over it.

CodeWhat
unexpected-charactera character no token starts with
unterminated-stringa string still open at the end of its line
invalid-escapea string escape other than \", \\, \n
invalid-color# without exactly 3 or 6 hex digits
unexpected-tokena token no rule could use where it stands
expected-expressionan operand, element or value missing
unclosed-bracketa (, [, expression { or | never closed
unclosed-blocka block { or @{ never closed
expected-blocka block keyword without its {
expected-identifiera style or macro name, a macro parameter, or a member name missing
expected-stringan import path, as title or image source missing
expected-equalsa macro without its =
expected-bindingwith or for not followed by name = value
expected-boundssum, prod or int not opened with name = from..to
expected-propertymetadata or a block entry that does not start with a property name
expected-colona property name followed by something other than : or the next entry
expected-valuekey: with nothing after it
comma-between-propertiesblock entries separated by a comma (§4.1)
misplaced-metadatametadata trailing nothing

An unclosed bracket costs one line rather than the file: brackets are paired before parsing starts, and inside one that is never closed newlines end the statement after all.

Errors include, beyond syntax:

  • an unknown function (notAFunction(x)) — a call on a name that is neither a builtin nor a user function, with more than one argument or in call position where a product is impossible
  • assigning to a builtin (mean = 3)
  • a property that does not exist, is not allowed where it is written, or has a value of the wrong type (color: red, lineWidth: 2 +)
  • misplaced config, ticker, style, macro; nested folders; a second config in one file
  • macro and style errors (§4.5, §6)
  • an import or image that cannot be resolved, or an import cycle

An undefined variable is not an error: Desmos offers it as a slider.

The checker and the compiler report these codes. Every one is an error, and none of them stops the rest of the file compiling: a value that is wrong is left off, and a statement that cannot be written at all is left out.

CodeWhat
unknown-functiona call on a name that is not a function (§5.3)
assign-to-builtindefining or binding a function’s name, an operator, pi, tau, e, infinity, true/false
theta-equationtheta = …, which Desmos will not graph - write r = … (§5.5)
requires-complex-modereal, imag, conj or arg in a graph without allowComplex: true (§5.3)
requires-calculatora function or a $ token the calculator the graph is for does not have
statement-onlya chart or a regression ~ anywhere but as a statement of its own
open-rangea range with an end left off anywhere but an index (L[2...])
misplaced-blankan empty slot, [4, , 6], anywhere but a table column’s values
multiple-subscriptsa name in an expression with more than one _ part (x_1_2)
boolean-in-expressiontrue or false in an expression - Desmos has no booleans
dt-outside-tickerdt anywhere but the ticker’s handler (or a macro’s body)
rebound-variablea sum, prod or int variable already bound where it stands
unexpected-stringa string where a value belongs
unknown-propertya property no placement has
misplaced-propertya property this placement does not take, directly or through a style
duplicate-propertya property given twice in one clause
invalid-valuea value of the wrong type for its property (§4.2)
invalid-enuman enum value the property does not list
invalid-colora colour that is not one (§4.3)
unexpected-rangea range for a property that takes none
invalid-columna table column that is an equation (x = 5)
misplaced-configconfig inside a folder
misplaced-tickerticker inside a folder
misplaced-stylestyle inside a folder
misplaced-macromacro inside a folder
nested-foldera folder inside a folder
duplicate-configa second config in one file
duplicate-tickera second ticker in one file
duplicate-macroa second macro of one name anywhere in the compilation
macro-collisiona macro named after a builtin, a function or a variable
macro-aritya macro used with the wrong number of arguments, or with or without () wrongly
macro-recursiona macro that expands into itself
duplicate-stylea second style of one name anywhere in the compilation
unknown-styleuse: naming no style
style-cyclea style that uses itself, reported at the use: that closes the loop
unresolved-importan import that cannot be read
import-cyclean import that closes a cycle
unresolved-imagean image file that cannot be read
invalid-imagean image path that is not a picture by its extension

A diagnostic about an imported file carries that file’s path, and its span is into that file; one about the entry file carries none. A misplaced config or ticker is not applied, and the contents of a nested folder join the folder it is in.

compileAxis returns:

interface CompilationResult {
state: GraphState; // the whole setState payload: list, ticker, graph, top-level flags
options: CalculatorOptions; // updateSettings
diagnostics: Diagnostic[];
sourceMap: Map<string, StatementOrigin>;
configOrigin?: StatementOrigin;
dependencies: { imports: string[]; images: string[] };
}

A host applies it with calculator.setState(state) and calculator.updateSettings(options) — nothing else.

So the state is complete. It is version 11; it carries doNotMigrateMovablePointStyle: true, without which Desmos substitutes its own style for any point it decides is movable; includeFunctionParametersInRandomSeed at the top level, where Desmos reads it; the viewport under graph, with any edge the file did not give filled in from ±10; graph.product, when calculator is GEOMETRY ("geometry-calculator") or GRAPHING_3D ("graphing-3d") - those calculators drop a state that does not name them, and a host builds the calculator the state names; graph.complex: true for allowComplex: true, since the option only permits complex mode and the graph is what turns it on; a token’s definition ($12 = …, or $13(x) = …) in the geometry calculator’s hidden folder, **dcg_geo_folder**, first in the list with its members after it, wherever the file wrote it - Desmos accepts the definition nowhere else; and the ticker beside the list only when there is one. The options are the Axis defaults under the merged config, with actions: true added for a file with a ticker and no actions of its own - Desmos decides auto from the list, which the ticker is not in.

Each item in the list has a deterministic id - expr_N, folder_N, note_N, table_N, image_N, and col_N for a column, numbered in the order they are lowered - and an entry in sourceMap saying where it was written:

interface StatementOrigin {
path: string; // the file, as the resolver named it
line: number; // zero-based, first line of the statement
endLine: number; // zero-based, last line, inclusive
span: Span; // the statement's exact characters, metadata included
writable: boolean; // false when a macro expanded into it
reason?: string;
}

Two statements on one line have spans of their own, so sharing a line does not make either unwritable.

format (in @axis-dsl/syntax) prints a file back from its tree, so there is one way every file is laid out. The decompiler and write-back print the nodes they build with the same printer, so generated source looks typed by hand.

  • Spacing. One space either side of + - * / ^ = < <= > >= -> and after every comma and :. A sign sits against what it negates: -x, 2 - -3, 2 ^ -1. A range is written tight, -5..5 step 0.5, and a list range [1...10] - Desmos’ [1, ..., 10] becomes that.
  • Juxtaposition. A number sits against the name or bracket it multiplies (2x, 3cos(t), 2(x + 1), 2|x|); anything else is spaced (2pi x, x y, sin(x) cos(x)), and a restriction always is (x ^ 2 {x > 0}). A name followed by a bracket would be a call (§5.3), so that product is written (x)(a + b).
  • Brackets. An author’s brackets are kept. A tree built without any gets exactly the ones §5.1 needs to read back as itself.
  • Metadata stays inline when it fits, and becomes a @{ … } block, one property to a line, when it does not and there is more than one property. Metadata written as a block stays one. A run whose bare names would read as flags inline (onClick: A, B) is always written as a block.
  • Blocks written on one line stay on one line, entries separated by ; , while they fit; otherwise one entry to a line, indented one level (four spaces by default). A folder’s or a table’s own metadata is written inline on the line of its {.
  • Wrapping. A line longer than the width (100 by default) is broken at the first bracket of several elements that runs past it, one element to a line. A bracket the author opened onto a new line stays open.
  • Comments and blank lines are kept where they were: on a line of their own, or after the entry whose line they end. A run of blank lines is one, and none is kept at the start or end of a block. A statement with a comment inside a bracket spread over lines is kept as written, re-indented.
  • Spelling is the author’s: enum values keep their case (§4.2), property order is as written, number literals keep their digits.
  • A file with a syntax error is returned exactly as it was.

decompileAxis({ state, options? }) is the compiler run backwards: a graph state - what compileAxis hands a host, or what a calculator’s getState hands back - into Axis source, as { source, statements, diagnostics }. It builds the statements as tree nodes and prints them with the printer (§10), so the source is already formatted and parses without a word. The contract is the round trip: compileAxis(decompileAxis(compileAxis(s)).source) builds the same state and options as compileAxis(s).

  • Properties are one Property each, in a fixed order: the slider and its animation, the colour, the styling, the labels, the domains, the click. A true boolean is a bare flag and a false one is written out.
  • Colours: a hex that is a palette colour is its name, unless the graph defines that name itself; any other hex is a literal, in full and lower case; colorLatex is the expression. A calculator keeps a cycled color beside a colorLatex; the expression wins.
  • Ranges: slider is lo..hi step s, an end Desmos left off is left off, and an end without its hardMin/hardMax is soft on that end. A domain end that is "" is left off.
  • What lowering fills in is left out: movablePointSize equal to pointSize, parametricDomain equal to domain, a viewport edge of ±10, an option or state flag equal to Axis’ default, and actions: true beside a ticker. A state without includeFunctionParametersInRandomSeed is the legacy behaviour and is written false.
  • What a calculator adds is read back: a point style stashed under __stashed_V12PointStyle is the pointStyle; the settings it mirrors into graph are config, with options winning; its graph.product is calculator; its graph.complex is allowComplex, and an allowComplex option without it is left out, since a calculator allows complex mode by default whether or not it is on; its randomSeed is kept only for a graph that calls random or shuffle. The hidden folder a geometry calculator keeps its constructions in is not written, and whatever it holds is written at the top level.
  • Structure: folders gather their members wherever the list keeps them; a folder with no title is folder { … }; a folder claiming to sit in another is written beside it. Tables write each column’s own metadata, trailing blank cells trimmed and a blank among them an empty slot. An image’s draggable is dragMode: XY. The ticker is written last. A blank row is not written.
  • What a graph cannot say: imports come back as the folders they were flattened into, macros and styles as what they expanded to, and an inlined picture as its data: URI.
  • Statements read as statements: g=a-b\operatorname{with}a=2,b=1 is how Desmos writes a definition whose value has bindings, and it is written gap = a - b with a = 2, b = 1 (§5.1), not (gap = a - b) with …. A latex name that would close up into a keyword or true/false keeps its subscript apart: f_{or} is f_or.
  • One name for each function: the other spellings Desmos accepts are read as the name Axis has - arsinh as arcsinh, and arcosh, artanh, arcsch, arsech, arcoth alike; inverseCdf and inversecdf as quantile; TScore as tscore; gcf as gcd; and ittest, the old name of the two-sample test, as ttest.
  • A table’s own regression - the kind picked from its menu - is written as the ~ statement after the table that fits the same model, with fresh names for its parameters, since a table’s are its own and a statement’s the graph’s. Desmos builds the one from the same model, fitted the same way, so the graph draws and computes the same.
  • Regressions and charts: a regression’s residualVariable is residuals, isLogModeRegression is logMode, and its regressionParameters - the fit - is not written. A chart’s vizProps are written as properties of their own, each left out at Desmos’ default.
  • What a person typed is kept to what it means: \token{12} is $12, and the token definitions in the geometry calculator’s hidden folder are written at the top level, where compiling puts them back. A number with nothing after its point, 3., is 3, and so is the 1. of 1.y, which is 1 times y. Digits grouped with a space, 20\ 000, are one number. \pm and \mp are names, pm and mp. A colour typed with space round it, or as an opaque rgb(…), is its hex, and a table column with no header and no values is left out, since it holds and draws nothing.
  • A curve over an interval of its own parameter, (f(a))\operatorname{for}0<a<2, is written as the same curve in t with domain: 0..2, which Desmos draws identically.
  • A name before a bracket that is not a function is a product. Desmos reads r\left(a,b\right) as r times the point when r is a number, but the Axis parser reads r(a, b) as a call (§5.3). So a call of two or more on a name that is neither a builtin nor a function the graph defines is written (r)(a, b).
  • Brackets that only group are dropped: round all of a script, x^{\left(n\right)}, or of a value with bindings, and a product with a number on its right is written * - so decompiling what a decompiled file compiles to gives back the same file.
  • Random draws are the one thing not kept. Desmos seeds each random and shuffle partly from the id of the expression it is in, and a compiled graph’s ids are Axis’s own (§9), so a graph read back and compiled draws different numbers - the same kind of numbers, from the same seed.
  • What Axis cannot write is reported, never thrown. Latex parseLatex has no reading for (\sum, \int, …) leaves out the expression - or only the property, if that is where it is - and a // unsupported: <latex> comment stands where it would have been. Every one is a warning whose span is that comment in source:
CodeWhat
unsupported-latexlatex the expression tree has no node for
unsupported-itema list item Axis has no statement for, or an image with no URL
unsupported-valuea colour or enum value Axis cannot write

decompileExpression, decompileSettings and decompileTicker hand back one item’s node - a folder as its header, with an empty body - for write-back to print in place.