From a6491d025c8af072f37f094a917942c2da828124 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Fri, 5 Jun 2026 01:34:33 -0400 Subject: [PATCH] docs: add EBNF grammar for the reader syntax (doc/grammar.ebnf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specify the surface syntax Jolt's reader accepts as an EBNF grammar — the syntactic half of the contract (behavioural half is test/spec/). Grounded in src/jolt/reader.janet and verified against the running reader: whitespace (comma included), comments, #_ discard, scalars (nil/bool/number incl 0x hex & floats/string/char incl \uNNNN \oNNN & named/keyword incl ::auto/symbol), collections, reader macros (quote/syntax-quote/unquote/~@/deref/metadata), and dispatch (#{}, #(), #', #"regex", #?/#?@, tagged #inst/#uuid). Jolt-vs-Clojure deviations noted inline (no ratios/radix/BigInt literals; PEG regex limits). Referenced from README. --- README.md | 5 ++ doc/grammar.ebnf | 150 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 doc/grammar.ebnf diff --git a/README.md b/README.md index 5135b1f..dda5efb 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,11 @@ Tests are organized in three layers: are `["label" expected actual]`, compared with Jolt's own `=`) plus `expect=`/`expect-throws` for unit tests. +The syntactic half of the contract — the surface syntax the reader accepts — is +specified as an EBNF grammar in [`doc/grammar.ebnf`](doc/grammar.ebnf), with +Jolt-vs-Clojure deviations noted inline. `test/spec/reader-syntax-spec.janet` +exercises it. + ## License [Eclipse Public License 1.0](https://opensource.org/licenses/EPL-1.0) diff --git a/doc/grammar.ebnf b/doc/grammar.ebnf new file mode 100644 index 0000000..6c21c54 --- /dev/null +++ b/doc/grammar.ebnf @@ -0,0 +1,150 @@ +(* =========================================================================== + Jolt reader grammar (EBNF) + =========================================================================== + + This grammar specifies the surface syntax accepted by Jolt's reader + (src/jolt/reader.janet) — the text that `read`/`parse-string`/`load-string` + turn into data/forms. It is the syntactic half of Jolt's contract; the + behavioural half lives in test/spec/. Where Jolt diverges from Clojure the + difference is called out in a comment. + + Notation (ISO-ish EBNF): + = definition | alternation + , concatenation ( ) grouping + [ x ] optional (0 or 1) { x } repetition (0 or more) + "x" 'x' terminal (* *) comment + ? x ? informal/char-class terminal + + A source file is a sequence of forms; evaluation is form-by-form. + =========================================================================== *) + +file = { ws | form } ; + +(* -------------------------------------------------------------------------- + Whitespace, separators and comments + -------------------------------------------------------------------------- *) + +ws = { whitespace-char | comment | discard } ; +whitespace-char = " " | "\t" | "\n" | "\r" | "," ; (* comma is whitespace *) +comment = ";" , { ? any char except "\n" ? } , [ "\n" ] ; +discard = "#_" , ws , form ; (* read then drop next form *) + +(* -------------------------------------------------------------------------- + Forms + -------------------------------------------------------------------------- *) + +form = scalar + | collection + | reader-macro + | dispatch ; + +scalar = nil | boolean | number | string | character + | keyword | symbol ; + +collection = list | vector | map ; + +(* -------------------------------------------------------------------------- + Atoms / scalars + -------------------------------------------------------------------------- *) + +nil = "nil" ; +boolean = "true" | "false" ; + +(* Numbers: decimal integer, hexadecimal integer, or decimal float, each with + an optional leading sign. No ratios, radix (NrXXX), BigInt (N) or BigDecimal + (M) suffixes, octal, or exponents — Jolt numbers are Janet ints/doubles. *) +number = [ sign ] , ( hex-int | float | integer ) ; +sign = "+" | "-" ; +integer = digit , { digit } ; +hex-int = "0x" , hex-digit , { hex-digit } ; +float = digit , { digit } , "." , digit , { digit } ; +digit = "0".."9" ; +hex-digit = digit | "a".."f" | "A".."F" ; + +(* Strings: double-quoted with backslash escapes. *) +string = '"' , { string-char } , '"' ; +string-char = ? any char except '"' and "\" ? | escape ; +escape = "\" , ( "n" | "t" | "r" | "\" | '"' | ? other ? ) ; + +(* Characters: \x, a named char, or \uNNNN / \oNNN. *) +character = "\" , ( char-name | ? any single char ? ) ; +char-name = "newline" | "space" | "tab" | "return" + | "formfeed" | "backspace" | "nul" + | "u" , hex-digit , hex-digit , hex-digit , hex-digit + | "o" , octal-digit , { octal-digit } ; +octal-digit = "0".."7" ; + +(* Keywords: :name, :ns/name, or auto-resolved ::name. *) +keyword = "::" , sym-token (* auto-resolved to current ns *) + | ":" , sym-token ; + +(* Symbols: a token of symbol-constituent chars not parsed as a number. A "/" + separates an optional namespace from the name. *) +symbol = sym-token ; +sym-token = sym-start , { sym-constituent } ; +sym-start = sym-constituent - ( digit ) ; (* not a leading digit *) +sym-constituent = letter | digit + | "*" | "+" | "!" | "_" | "-" | "?" | "." | "<" | ">" + | "=" | "&" | "|" | "$" | "%" | "/" ; +letter = "a".."z" | "A".."Z" ; + +(* -------------------------------------------------------------------------- + Collections + -------------------------------------------------------------------------- *) + +list = "(" , { ws | form } , ")" ; (* -> a Clojure list *) +vector = "[" , { ws | form } , "]" ; (* -> a persistent vector *) +map = "{" , { ws | map-entry } , "}" ; +map-entry = form , ws , form ; (* an even number of forms *) + +(* -------------------------------------------------------------------------- + Reader macros (prefix sugar). Each expands to a 2-element form + (op operand), e.g. 'x -> (quote x), @a -> (deref a). + -------------------------------------------------------------------------- *) + +reader-macro = quote | syntax-quote | unquote | unquote-splice + | deref | metadata ; + +quote = "'" , form ; (* (quote form) *) +syntax-quote = "`" , form ; (* (syntax-quote form) *) +unquote-splice = "~@" , form ; (* (unquote-splicing form) *) +unquote = "~" , form ; (* (unquote form) *) +deref = "@" , form ; (* (deref form) *) +metadata = "^" , meta-form , ws , form ; (* attach metadata to form *) +meta-form = map | keyword | symbol | string ; + +(* -------------------------------------------------------------------------- + Dispatch forms — introduced by "#". + -------------------------------------------------------------------------- *) + +dispatch = set + | anon-fn + | var-quote + | regex + | reader-conditional + | discard (* #_form — see Whitespace above *) + | tagged-literal ; + +set = "#{" , { ws | form } , "}" ; (* a persistent set *) + +(* Anonymous function. %, %1.. are positional params; %& is the rest param. *) +anon-fn = "#(" , { ws | form } , ")" ; (* -> (fn* [..] body) *) +anon-arg = "%" | "%" , digit , { digit } | "%&" ; + +var-quote = "#'" , symbol ; (* (var symbol) *) + +(* Regex literal -> a Janet PEG-backed regex value. + Supported: groups, greedy/lazy quantifiers, (?:..), lookahead (?=..)/(?!..), + alternation, anchors ^ $ \b \B, classes, (?i). NOT: lookbehind, + backreferences, named groups. *) +regex = '#"' , { ? any char except '"' ? | "\" , ? any char ? } , '"' ; + +(* Reader conditional. Jolt reads as the :clj platform; #?@ splices a sequence. *) +reader-conditional + = "#?@" , "(" , { ws | feature , ws , form } , ")" + | "#?" , "(" , { ws | feature , ws , form } , ")" ; +feature = ":clj" | ":cljs" | ":default" | keyword ; + +(* Tagged literal: #tag form. Built-in tags include #inst and #uuid; others + dispatch to a registered data-reader (else error). *) +tagged-literal = "#" , symbol , ws , form ;