writeonce/compiler/src/lexer.ml
shoney.arickathil 23550e7021 feat(lang+wo-html): raw text literals, component layer, MVC samples
- lexer: backtick raw text literal — content verbatim, no escape
  processing, common source margin removed at lex time; `${ }` raw and
  `{{ }}` auto-escaping holes
- `{{ e }}` desugars to `esc(${e})` in parser.ml — a Call on the `esc`
  in scope, so types/owner/emit/.wob/VM are untouched
- WO-E004 unterminated raw literal; WO-E005 newline inside "..." —
  closes a hole where a missing quote silently ate the rest of the file
- wo-html: `Component` interface, `render_all`, `Layout`, README
- framework: `ok_html` joins ok_text/ok_json in http/types.wo
- site + shop restructured to one-feature-one-module MVC (view +
  controller per directory, model at the root, bootstrap-only main)
- removed the filler `pad: Int` convention — verified unnecessary for
  plain classes, interface dispatch, containers and actors
- corrected recorded claims: gap #1 blocks neither the build nor the
  layout; a class crosses module lines, only a free fn is scoped
- docs/guides/language-surface.md — the full grammar inventory
- story 37 landed and moved to done/

Gates: oop-accept MET, oop-e2e 116/0, woc-test 556/0, site 11/0,
web-app 46/0, fibers 10/0, db-actor 8/0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 03:58:41 +02:00

905 lines
36 KiB
OCaml

(* lexer.ml — tokenizer for `.wo` source (milestone-1 OOP subset).
Ported from crates/rt/src/lexer.rs; behavior kept identical wherever
it defines what the language literally is:
- newline tokens are emitted, never filtered, and consecutive
newlines collapse to a single Newline token — exactly rt's
`out.last() == Some(Newline) => don't push another` rule;
- `--` starts a line comment that runs to (not including) the next
newline;
- single- or double-quoted strings support the same backslash
escapes as rt: n, t, backslash, or either quote character, each
backslash-prefixed; anything else verbatim. `\r` and `\0` were added
2026-08-14 (a program writing HTTP needs CRLF, and rt never had to);
- integer literals are plain runs of ASCII digits;
- identifiers may contain internal dashes, exactly like rt's
read_ident_chars (crates/rt/src/lexer.rs) — so `foo-bar` lexes as
one identifier, not `foo`, Dash, `bar`. This is a faithful port of
an rt quirk, not a milestone-1 design choice: Task 5's expression
parser must therefore require whitespace around a binary minus
that immediately follows an identifier (`a - b`, not `a-b`) to
avoid the ambiguity, exactly as rt's schema layer already does.
Flagged here for whoever picks up Task 5.
Divergence from rt, deliberate: rt's `tokenize` returns
`anyhow::Result` and bails (stops the whole file) on the first bad
byte or malformed punctuation shape (a bare `!` not followed by
`=`, a `$` with no name, ...). This front end's diagnostics contract
is multi-error (compiler/src/diag.ml — every later stage accumulates
into one Collector rather than stopping at the first problem), so
every one of those cases becomes: report one WO-E001 unknown-
character diagnostic at the offending position, skip exactly that
one byte, and keep lexing. One bad byte must never stop the whole
file (Task 3 brief's Unknown character decision). *)
let unknown_char_code = Diag.lexing_prefix ^ "01" (* WO-E001 *)
(* rt's equivalent site (crates/rt/src/lexer.rs:106) bails outright: a
backslash as the very last byte of the file, with no character left
to escape, loses information silently otherwise (the intended escaped
character is simply gone, not skip-and-continue like the unknown-
character case). Ported as its own code rather than reusing WO-E001
because the shape is different (a truncated escape, not a byte the
lexer doesn't recognize at all) and diag.ml's convention is one code
per distinct lexing situation.
Deliberately NOT applied to a plain unterminated string: a string
that runs off the end of the file with no dangling backslash (say,
a quote-open let-binding with nothing after it and no closing quote)
— rt does not bail there either, its scanning loop just stops when
peek returns None and emits whatever was collected as the Str token.
Reporting nothing in that case is intentional rt parity, not an
oversight; pinned by the plain-unterminated-string-reports-nothing
assertion in compiler/test/runner.ml. *)
let unterminated_escape_code = Diag.lexing_prefix ^ "02" (* WO-E002 *)
let directive_code = Diag.lexing_prefix ^ "03" (* WO-E003: #if/#else/#end misuse *)
(* iteration 37, the raw text literal (backtick-delimited, verbatim
content, no backslash escapes). Two codes, because the two shapes
are genuinely different situations:
WO-E004 — a raw literal that runs off the end of the file. Unlike a
plain "..." string (silent, rt parity, see above), this one IS
reported: multi-line is the raw literal's normal case, so a missing
closing backtick would otherwise swallow every remaining line of the
file with nothing to show for it. Reported at the OPENING backtick,
which is the only position that helps -- EOF tells the reader
nothing about which literal never closed.
WO-E005 — a raw newline inside a "..." or '...' string. This used to
be accepted silently: the string scanner's catch-all appended the
newline like any other byte, so a forgotten closing quote ate the
rest of the file with no diagnostic at all. Nothing in the repo ever
relied on it (zero of the .wo sources span a line inside quotes) and
the backtick literal is now the spelling for multi-line text, so the
accident becomes an error. The scan stops at the newline WITHOUT
consuming it, so the Newline token is still emitted and the
statement terminates -- one diagnostic, and the next line parses
normally instead of being swallowed. The rt-parity silence for a
plain unterminated string with no newline is untouched. *)
let unterminated_raw_code = Diag.lexing_prefix ^ "04" (* WO-E004 *)
let newline_in_string_code = Diag.lexing_prefix ^ "05" (* WO-E005 *)
(* haxe-parity Task 8: build flags. `woc -D name` fills this before any
tokenize call; undefined flags are false. A module-level ref because the
compiler is a single-shot process — tests that care set it explicitly
and reset to empty. *)
module StringSet = Set.Make (String)
let defines : StringSet.t ref = ref StringSet.empty
type lexer = {
src : string;
len : int;
mutable pos : int;
mutable line : int;
mutable col : int;
}
let make src = { src; len = String.length src; pos = 0; line = 1; col = 1 }
let peek lx = if lx.pos < lx.len then Some lx.src.[lx.pos] else None
let peek_at lx n =
let i = lx.pos + n in
if i < lx.len then Some lx.src.[i] else None
let advance lx =
match peek lx with
| None -> None
| Some c ->
lx.pos <- lx.pos + 1;
if c = '\n' then begin
lx.line <- lx.line + 1;
lx.col <- 1
end
else lx.col <- lx.col + 1;
Some c
let is_digit c = c >= '0' && c <= '9'
let is_alpha c = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
let is_ident_start c = is_alpha c || c = '_'
let is_ident_cont c = is_alpha c || is_digit c || c = '_' || c = '-'
(* Consumes a run of identifier characters starting at the lexer's
current position (caller has already confirmed is_ident_start on
the character at that position) and returns the collected text.
Mirrors rt's read_ident_chars, dash-continuation included (see
module doc). *)
let read_ident_chars lx =
let start = lx.pos in
let continue_ = ref true in
while !continue_ do
match peek lx with
| Some c when is_ident_cont c -> ignore (advance lx)
| _ -> continue_ := false
done;
String.sub lx.src start (lx.pos - start)
(* The milestone-1 keyword set, exact per the Task 3 brief: type class
interface fn let mut take return if else while for in true false,
plus uppercase-only INSERT/SELECT. Deliberately absent: self, me,
subscribe, receive, and lowercase insert/select — those fall
through to the `_ -> None` case below and lex as plain Ident,
matching rt and the CLAUDE.md gotcha this task exists to preserve.
`use`/`pub` (haxe-parity Task 1, modules) added on top of that set. *)
let keyword_kind = function
| "type" -> Some Token.KwType
| "class" -> Some Token.KwClass
| "interface" -> Some Token.KwInterface
| "fn" -> Some Token.KwFn
| "let" -> Some Token.KwLet
| "mut" -> Some Token.KwMut
| "take" -> Some Token.KwTake
| "return" -> Some Token.KwReturn
| "if" -> Some Token.KwIf
| "else" -> Some Token.KwElse
| "while" -> Some Token.KwWhile
| "for" -> Some Token.KwFor
| "in" -> Some Token.KwIn
| "true" -> Some Token.KwTrue
| "false" -> Some Token.KwFalse
| "use" -> Some Token.KwUse
| "spawn" -> Some Token.KwSpawn
| "using" -> Some Token.KwUsing
| "pub" -> Some Token.KwPub
| "break" -> Some Token.KwBreak
| "continue" -> Some Token.KwContinue
| "do" -> Some Token.KwDo
| "const" -> Some Token.KwConst
| "and" -> Some Token.KwAnd
| "or" -> Some Token.KwOr
| "not" -> Some Token.KwNot
| "inline" -> Some Token.KwInline
| "switch" -> Some Token.KwSwitch
| "case" -> Some Token.KwCase
| "default" -> Some Token.KwDefault
| "typedef" -> Some Token.KwTypedef
| "try" -> Some Token.KwTry
| "catch" -> Some Token.KwCatch
| "nil" -> Some Token.KwNil
| "as" -> Some Token.KwAs
| "INSERT" -> Some Token.KwInsert
| "SELECT" -> Some Token.KwSelect
| _ -> None
(* haxe-parity Task 2: scans the raw source of one `${...}` interpolation
body, starting right after the `{` (caller already consumed `$` and
`{`). Returns that raw, unlexed text -- the parser re-tokenizes it as a
full expression (parser.ml's own desugar-to-Concat step; this is a
mechanical extraction only, no semantics). Tracks brace depth so a
nested `{}` (a constructor literal inside an interpolation,
`${Point{x:1}.x}`) doesn't end the scan early, and skips a nested
string literal verbatim (honoring its own backslash escapes) so a
quote or brace *inside* that nested string can't confuse either
count. Runs off the end of the file the same silent way an
unterminated outer string does -- the caller's own EOF handling picks
up right after. *)
let read_interp_expr lx =
let buf = Buffer.create 16 in
let depth = ref 0 in
let continue_ = ref true in
while !continue_ do
match peek lx with
| None -> continue_ := false
| Some '}' when !depth = 0 ->
ignore (advance lx);
continue_ := false
| Some ('{' as c) ->
incr depth;
Buffer.add_char buf c;
ignore (advance lx)
| Some ('}' as c) ->
decr depth;
Buffer.add_char buf c;
ignore (advance lx)
| Some (('"' | '\'') as q) ->
Buffer.add_char buf q;
ignore (advance lx);
let scanning = ref true in
while !scanning do
match peek lx with
| None -> scanning := false
| Some c when c = q ->
Buffer.add_char buf c;
ignore (advance lx);
scanning := false
| Some '\\' -> (
Buffer.add_char buf '\\';
ignore (advance lx);
match peek lx with
| Some c ->
Buffer.add_char buf c;
ignore (advance lx)
| None -> scanning := false)
| Some c ->
Buffer.add_char buf c;
ignore (advance lx)
done
| Some c ->
Buffer.add_char buf c;
ignore (advance lx)
done;
Buffer.contents buf
(* haxe-parity Task 8: the #if filter, run over the in-order token list at
the end of tokenize. A `#if <flag>` section is kept when the flag is
defined AND every enclosing section is kept; `#else` flips the section;
`#end` closes it. Nesting allowed; flag NAMES only (no expression
language — the spec's limit); undefined flags are false. Misuse is
WO-E003: a #if without a flag name, a second #else, a stray #else/#end,
or a #if left open at end of file. Eof always survives so the parser
still terminates after a reported error. *)
let preprocess (collector : Diag.Collector.t) ~(file : string)
(toks : Token.t list) : Token.t list =
let err line col msg =
Diag.Collector.add collector
(Diag.error ~code:directive_code ~file ~line ~col ~message:msg ())
in
(* frame: (emitting, seen_else, opening line, opening col) *)
let stack : (bool * bool * int * int) list ref = ref [] in
let emitting () = List.for_all (fun (e, _, _, _) -> e) !stack in
let out = ref [] in
let rec go = function
| [] -> (
match !stack with
| (_, _, l, c) :: _ -> err l c "#if left open — missing #end"
| [] -> ())
| { Token.kind = Token.HashIf; line; col } :: rest -> (
match rest with
| { Token.kind = Token.Ident flag; _ } :: rest2 ->
stack := (StringSet.mem flag !defines, false, line, col) :: !stack;
go rest2
| _ ->
err line col "#if needs a flag name (`#if portable`)";
stack := (false, false, line, col) :: !stack;
go rest)
| { Token.kind = Token.HashElse; line; col } :: rest ->
(match !stack with
| (e, false, l, c) :: tl -> stack := (not e, true, l, c) :: tl
| (_, true, _, _) :: _ -> err line col "second #else in one #if section"
| [] -> err line col "#else outside any #if");
go rest
| { Token.kind = Token.HashEnd; line; col } :: rest ->
(match !stack with
| _ :: tl -> stack := tl
| [] -> err line col "#end outside any #if");
go rest
| ({ Token.kind = Token.Eof; _ } as t) :: rest ->
out := t :: !out;
go rest
| t :: rest ->
if emitting () then out := t :: !out;
go rest
in
go toks;
List.rev !out
(* ---- the raw literal's margin rule (iteration 37) -------------------
A render() body is written at its method's indentation, but that
indentation is an artifact of the SOURCE, not of the markup -- nobody
wants six leading spaces on every line of the served HTML. So the
common margin is removed here, at lex time: the constant pool holds
the dedented text, no downstream stage ever sees the source
indentation, and the whole rule costs nothing at run time.
The rule (Java's text blocks, which solved exactly this):
- one newline immediately after the opening backtick is dropped,
so the first markup line can start on its own line;
- the smallest leading run of spaces/tabs across all non-blank
lines is removed from every line (characters counted, tabs NOT
expanded -- mixing them is the author's problem, and expanding
would need a tab width the language does not have);
- a whitespace-only final line (the usual case: the closing
backtick sits on its own line) loses its whitespace but keeps
its newline.
A literal with no newline in it is left completely alone -- there is
no margin to speak of, and silently eating the leading spaces of
` hi` would be a surprise, not a service.
Holes do not disturb any of this. A line's indentation is by
definition the run of whitespace at its start, and the only thing
that can split a line across segments is a hole, which ends that run
-- so an indentation run always lives whole inside one SText. The
measuring pass replaces each hole with a single non-whitespace
sentinel byte so that a line that is ` {{ x }}` correctly counts
as indent 4 and as NON-blank. *)
let is_indent_char c = c = ' ' || c = '\t'
let segments_shadow (segs : Token.str_part list) : string =
let b = Buffer.create 64 in
List.iter
(function
| Token.SText s -> Buffer.add_string b s
| Token.SExpr _ | Token.SEsc _ -> Buffer.add_char b '\001')
segs;
Buffer.contents b
let min_indent (shadow : string) : int =
let m = ref max_int in
List.iter
(fun line ->
let n = String.length line in
let i = ref 0 in
while !i < n && is_indent_char line.[!i] do
incr i
done;
(* a blank (or whitespace-only) line never sets the margin *)
if !i < n && !i < !m then m := !i)
(String.split_on_char '\n' shadow);
if !m = max_int then 0 else !m
let strip_margin (k : int) (segs : Token.str_part list) : Token.str_part list =
if k = 0 then segs
else begin
let at_line_start = ref true in
let one seg =
match seg with
| Token.SExpr _ | Token.SEsc _ ->
at_line_start := false;
seg
| Token.SText s ->
let n = String.length s in
let b = Buffer.create n in
let i = ref 0 in
while !i < n do
if !at_line_start then begin
let dropped = ref 0 in
while !dropped < k && !i < n && is_indent_char s.[!i] do
incr dropped;
incr i
done;
at_line_start := false
end
else begin
let c = s.[!i] in
Buffer.add_char b c;
if c = '\n' then at_line_start := true;
incr i
end
done;
Token.SText (Buffer.contents b)
in
(* fold_left, not List.map: `one` carries state across segments and
List.map's application order is unspecified. *)
List.rev (List.fold_left (fun acc seg -> one seg :: acc) [] segs)
end
let drop_trailing_margin (segs : Token.str_part list) : Token.str_part list =
match List.rev segs with
| Token.SText s :: rest_rev ->
let n = String.length s in
let i = ref n in
while !i > 0 && is_indent_char s.[!i - 1] do
decr i
done;
(* only a run that directly follows a newline is a closing line *)
if !i < n && !i > 0 && s.[!i - 1] = '\n' then
List.rev (Token.SText (String.sub s 0 !i) :: rest_rev)
else segs
| _ -> segs
let dedent (segs : Token.str_part list) : Token.str_part list =
let shadow = segments_shadow segs in
if not (String.contains shadow '\n') then segs
else begin
let segs =
match segs with
| Token.SText s :: rest when String.length s > 0 && s.[0] = '\n' ->
Token.SText (String.sub s 1 (String.length s - 1)) :: rest
| _ -> segs
in
let k = min_indent (segments_shadow segs) in
drop_trailing_margin (strip_margin k segs)
end
let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
Token.t list =
let lx = make src in
let out = ref [] in
let emit kind line col = out := { Token.kind; line; col } :: !out in
let last_is_newline () =
match !out with
| { Token.kind = Token.Newline; _ } :: _ -> true
| _ -> false
in
(* A line ending in `..` continues on the next line — the ONE newline
suppression in the language, so multi-line markup/text builds read
as one expression (the shop template's ask; story 37 rides it). *)
let last_is_dotdot () =
match !out with
| { Token.kind = Token.DotDot; _ } :: _ -> true
| _ -> false
in
let report_unknown line col c =
Diag.Collector.add collector
(Diag.error ~code:unknown_char_code ~file ~line ~col
~message:(Printf.sprintf "unknown character '%c'" c) ())
in
let report_unterminated_escape line col =
Diag.Collector.add collector
(Diag.error ~code:unterminated_escape_code ~file ~line ~col
~message:"unterminated string escape" ())
in
let report_unterminated_raw line col =
Diag.Collector.add collector
(Diag.error ~code:unterminated_raw_code ~file ~line ~col
~message:"unterminated raw text literal" ())
in
let report_newline_in_string line col =
Diag.Collector.add collector
(Diag.error ~code:newline_in_string_code ~file ~line ~col
~message:
"newline in string literal (use a `...` raw text literal for \
multi-line text)" ())
in
let running = ref true in
while !running do
match peek lx with
| None -> running := false
| Some c -> (
let line = lx.line and col = lx.col in
if c = '-' && peek_at lx 1 = Some '-' then begin
(* line comment: -- ... EOL (EOL itself is left for the next
iteration to turn into its own Newline token). *)
let scanning = ref true in
while !scanning do
match peek lx with
| Some '\n' | None -> scanning := false
| Some _ -> ignore (advance lx)
done
end
else if c = '\n' then begin
ignore (advance lx);
if not (last_is_newline ()) && not (last_is_dotdot ()) then
emit Token.Newline line col
end
else if c = ' ' || c = '\t' || c = '\r' then ignore (advance lx)
else if c = '"' || c = '\'' then begin
let quote = c in
ignore (advance lx);
let buf = Buffer.create 16 in
(* haxe-parity Task 2: segments accumulate here only when at
least one `${...}` is actually found (flush_text below); a
plain string never touches `parts` at all, so it emits the
exact same `Token.Str` it always did -- see the `match !parts`
dispatch after the loop. *)
let parts = ref [] in
let flush_text () =
parts := Token.SText (Buffer.contents buf) :: !parts;
Buffer.clear buf
in
let scanning = ref true in
while !scanning do
match peek lx with
| None ->
(* Plain unterminated string (ran off the end of the file
with no closing quote and no dangling backslash): rt does
not bail here either, it just stops and emits whatever was
collected. No diagnostic, deliberately — see
unterminated_escape_code's doc comment above. *)
scanning := false
| Some c when c = quote ->
ignore (advance lx);
scanning := false
| Some '$' when peek_at lx 1 = Some '{' ->
(* Unescaped `${` -- `\$` never reaches here, it is fully
consumed by the backslash branch below, one dispatch
earlier, so this is always a genuine interpolation start,
never an escaped `$` that happens to be followed by `{`. *)
flush_text ();
ignore (advance lx);
(* '$' *)
ignore (advance lx);
(* '{' *)
parts := Token.SExpr (read_interp_expr lx) :: !parts
| Some '\\' -> (
(* Captured before advancing: this is the backslash's own
position, so a dangling-escape diagnostic points at the
`\` itself rather than wherever the scan happens to stop. *)
let esc_line = lx.line and esc_col = lx.col in
ignore (advance lx);
match advance lx with
| Some 'n' -> Buffer.add_char buf '\n'
| Some 't' -> Buffer.add_char buf '\t'
(* `\r` — added 2026-08-14: without it a program cannot write CRLF
at all, and the driving workload's HTTP server needs it (its
`index_of(buf, "\r\n\r\n")` was searching for a literal
backslash-r, so it never found a header terminator). *)
| Some 'r' -> Buffer.add_char buf '\r'
| Some '0' -> Buffer.add_char buf '\000'
| Some '\\' -> Buffer.add_char buf '\\'
| Some '"' -> Buffer.add_char buf '"'
| Some '\'' -> Buffer.add_char buf '\''
(* `\$` -- not one of the escapes above, so it falls into
this catch-all exactly like any other unrecognized
backslash sequence, producing a literal `$` that the `$`
dispatch above never sees (it already advanced past it). *)
| Some other -> Buffer.add_char buf other
| None ->
report_unterminated_escape esc_line esc_col;
scanning := false)
| Some '\n' ->
(* WO-E005. Deliberately NOT consumed: the outer loop turns
it into the Newline token that terminates the statement,
so recovery is one bad line rather than the rest of the
file. *)
report_newline_in_string lx.line lx.col;
scanning := false
| Some other ->
ignore (advance lx);
Buffer.add_char buf other
done;
flush_text ();
(match List.rev !parts with
| [] -> emit (Token.Str "") line col
| [ Token.SText s ] -> emit (Token.Str s) line col
| segs -> emit (Token.InterpStr segs) line col)
end
else if c = '`' then begin
(* iteration 37: the raw text literal. Everything up to the
closing backtick is content -- newlines included, and with NO
escape processing at all, which is the whole point: markup
carries quotes and backslashes verbatim. A literal backtick
(or a literal `{{`) is written by concatenating an ordinary
"..." string with `..`; that door is one greppable operator,
which beats inventing an escape character for the one form
whose selling point is not having any.
Two hole forms, and ONLY here -- inside "..." a `{{` is still
two literal braces, so existing CSS/JS text is untouched:
${ expr } raw, exactly like a "..." string's hole
{{ expr }} HTML-escaped (the parser wraps it in esc()) *)
ignore (advance lx);
let buf = Buffer.create 64 in
let parts = ref [] in
let flush_text () =
parts := Token.SText (Buffer.contents buf) :: !parts;
Buffer.clear buf
in
let scanning = ref true in
while !scanning do
match peek lx with
| None ->
report_unterminated_raw line col;
scanning := false
| Some '`' ->
ignore (advance lx);
scanning := false
| Some '$' when peek_at lx 1 = Some '{' ->
flush_text ();
ignore (advance lx);
ignore (advance lx);
parts := Token.SExpr (read_interp_expr lx) :: !parts
| Some '{' when peek_at lx 1 = Some '{' ->
flush_text ();
ignore (advance lx);
ignore (advance lx);
(* read_interp_expr stops at the first `}` at depth 0 and
consumes it -- the second one closes this hole. Reusing it
means brace depth and nested string literals are already
handled, so `{{ Point{x:1}.x }}` scans correctly. *)
let raw = read_interp_expr lx in
(match peek lx with
| Some '}' -> ignore (advance lx)
| _ -> report_unterminated_raw line col);
parts := Token.SEsc raw :: !parts
| Some other ->
ignore (advance lx);
Buffer.add_char buf other
done;
flush_text ();
(match dedent (List.rev !parts) with
| [] -> emit (Token.Str "") line col
| [ Token.SText s ] -> emit (Token.Str s) line col
| segs -> emit (Token.InterpStr segs) line col)
end
else if is_digit c then begin
(* iteration 19: one scanner for both numeric worlds. The integer run
is scanned into a buffer as well as accumulated, because a fraction
or an exponent turns the whole thing into a Float and OCaml's
float_of_string wants the original text.
A digit run stays an Int unless it is followed by:
- '.' AND a digit -> `1.5`. The digit requirement is what keeps
`0..10` a range (Dot Dot after Int 0) and leaves any future
`1.method()` reachable; without it `0..10` would lex as
Float 0. followed by `.10`.
- 'e'/'E' with an optional sign AND a digit -> `2e10`. Checked
before consuming, so `2eggs` is still Int 2 then Ident. *)
(* `c` is PEEKED, not consumed — the loop below reads it. Adding it to
the buffer here as well would count the first digit twice. *)
(* iteration 36: hex (`0x`) and binary (`0b`) Int literals, and `_`
digit separators in every integer form. The prefix commits only
when the character AFTER it is a real digit of that base, so `0x`
followed by anything else stays Int 0 + Ident — a parse error at
its own position, no new lexer diagnostic. Accumulation uses
OCaml's native int (63-bit): a full-width 64-bit literal like
0xFFFFFFFFFFFFFFFF is out of reach — all-ones is spelled -1. The
float path below is untouched: neither prefix can reach it (a
fraction/exponent needs the decimal branch), and `_` is consumed
only between digits of an integer run. *)
let is_hex_digit ch =
is_digit ch || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')
in
let hex_val ch =
if is_digit ch then Char.code ch - Char.code '0'
else if ch >= 'a' && ch <= 'f' then Char.code ch - Char.code 'a' + 10
else Char.code ch - Char.code 'A' + 10
in
let scan_prefixed base is_base_digit digit_val =
(* consumes the peeked '0' and the prefix char, then the run *)
ignore (advance lx);
ignore (advance lx);
let n = ref 0 in
let scanning = ref true in
while !scanning do
match peek lx with
| Some d when is_base_digit d ->
n := (!n * base) + digit_val d;
ignore (advance lx)
| Some '_' when (match peek_at lx 1 with
| Some d -> is_base_digit d
| None -> false) ->
ignore (advance lx)
| _ -> scanning := false
done;
emit (Token.Int !n) line col
in
match (c, peek_at lx 1, peek_at lx 2) with
| '0', Some ('x' | 'X'), Some d when is_hex_digit d ->
scan_prefixed 16 is_hex_digit hex_val
| '0', Some ('b' | 'B'), Some ('0' | '1') ->
scan_prefixed 2 (fun ch -> ch = '0' || ch = '1') (fun ch -> Char.code ch - Char.code '0')
| _ ->
let buf = Buffer.create 16 in
let n = ref 0 in
let scanning = ref true in
while !scanning do
match peek lx with
| Some d when is_digit d ->
n := (!n * 10) + (Char.code d - Char.code '0');
Buffer.add_char buf d;
ignore (advance lx)
| Some '_' when (match peek_at lx 1 with
| Some d -> is_digit d
| None -> false) ->
(* separator only BETWEEN digits: `1_` stops the run and the
`_` lexes as its own ident, a parse error at its position *)
ignore (advance lx)
| _ -> scanning := false
done;
let is_float = ref false in
(match (peek lx, peek_at lx 1) with
| Some '.', Some d when is_digit d ->
is_float := true;
Buffer.add_char buf '.';
ignore (advance lx);
let frac = ref true in
while !frac do
match peek lx with
| Some d when is_digit d ->
Buffer.add_char buf d;
ignore (advance lx)
| _ -> frac := false
done
| _ -> ());
(* exponent, on an integer run (`2e10`) or after a fraction (`1.5e-3`) *)
(match (peek lx, peek_at lx 1, peek_at lx 2) with
| Some ('e' | 'E'), Some d, _ when is_digit d -> is_float := true
| Some ('e' | 'E'), Some ('+' | '-'), Some d when is_digit d -> is_float := true
| _ -> ());
if !is_float then begin
(match peek lx with
| Some (('e' | 'E') as e) ->
Buffer.add_char buf e;
ignore (advance lx);
(match peek lx with
| Some (('+' | '-') as s) ->
Buffer.add_char buf s;
ignore (advance lx)
| _ -> ());
let ex = ref true in
while !ex do
match peek lx with
| Some d when is_digit d ->
Buffer.add_char buf d;
ignore (advance lx)
| _ -> ex := false
done
| _ -> ());
(* float_of_string cannot fail here: the buffer is a well-formed
decimal by construction. Overflow is not an error either — it
yields infinity, which is a legitimate Float per IEEE quiet
semantics (`1e400` is `inf`, not a compile error). *)
emit (Token.Float (float_of_string (Buffer.contents buf))) line col
end
else emit (Token.Int !n) line col
end
else if c = '#' then begin
(* haxe-parity Task 8: `#if` / `#else` / `#end` build-flag
directives. Names only — anything else after '#' is WO-E003. *)
ignore (advance lx);
let name =
match peek lx with
| Some d when is_ident_start d -> read_ident_chars lx
| _ -> ""
in
match name with
| "if" -> emit Token.HashIf line col
| "else" -> emit Token.HashElse line col
| "end" -> emit Token.HashEnd line col
| other ->
Diag.Collector.add collector
(Diag.error ~code:directive_code ~file ~line ~col
~message:
(Printf.sprintf
"unknown directive `#%s` — the build-flag directives are #if <flag>, #else, #end"
other)
())
end
else if is_ident_start c then begin
let name = read_ident_chars lx in
let kind =
match keyword_kind name with Some k -> k | None -> Token.Ident name
in
emit kind line col
end
else
match c with
| '{' ->
ignore (advance lx);
emit Token.LBrace line col
| '}' ->
ignore (advance lx);
emit Token.RBrace line col
| '(' ->
ignore (advance lx);
emit Token.LParen line col
| ')' ->
ignore (advance lx);
emit Token.RParen line col
| '[' ->
ignore (advance lx);
emit Token.LBracket line col
| ']' ->
ignore (advance lx);
emit Token.RBracket line col
| ',' ->
ignore (advance lx);
emit Token.Comma line col
| ';' ->
ignore (advance lx);
emit Token.Semicolon line col
| ':' ->
ignore (advance lx);
emit Token.Colon line col
| '.' -> (
ignore (advance lx);
match peek lx with
| Some '.' ->
ignore (advance lx);
emit Token.DotDot line col
| _ -> emit Token.Dot line col)
| '?' ->
ignore (advance lx);
emit Token.Question line col
| '@' ->
ignore (advance lx);
emit Token.At line col
| '|' ->
ignore (advance lx);
emit Token.Pipe line col
| '-' -> (
ignore (advance lx);
match peek lx with
| Some '>' ->
ignore (advance lx);
emit Token.Arrow line col
| Some '=' ->
ignore (advance lx);
emit Token.MinusEq line col
| _ -> emit Token.Dash line col)
| '+' -> (
ignore (advance lx);
match peek lx with
| Some '=' ->
ignore (advance lx);
emit Token.PlusEq line col
| _ -> emit Token.Plus line col)
| '*' -> (
ignore (advance lx);
match peek lx with
| Some '=' ->
ignore (advance lx);
emit Token.StarEq line col
| _ -> emit Token.Star line col)
| '/' -> (
ignore (advance lx);
match peek lx with
| Some '=' ->
ignore (advance lx);
emit Token.SlashEq line col
| _ -> emit Token.Slash line col)
| '%' -> (
ignore (advance lx);
match peek lx with
| Some '=' ->
ignore (advance lx);
emit Token.PercentEq line col
| _ -> emit Token.Percent line col)
(* iteration 36: the bitwise operators. `&` and `^` were unknown
characters before this; `|` (Pipe, above) is reused in expression
position by the parser. No `&=`/`^=`/`<<=`/`>>=` — bitwise
compound assigns are out of scope per the story. *)
| '&' ->
ignore (advance lx);
emit Token.Amp line col
| '^' ->
ignore (advance lx);
emit Token.Caret line col
| '=' -> (
ignore (advance lx);
match peek lx with
| Some '=' ->
ignore (advance lx);
emit Token.EqEq line col
| Some '>' ->
ignore (advance lx);
emit Token.FatArrow line col
| _ -> emit Token.Eq line col)
| '!' -> (
ignore (advance lx);
match peek lx with
| Some '=' ->
ignore (advance lx);
emit Token.NotEq line col
| _ -> report_unknown line col '!')
| '<' -> (
ignore (advance lx);
match peek lx with
| Some '=' ->
ignore (advance lx);
emit Token.LtEq line col
| Some '<' ->
ignore (advance lx);
emit Token.Shl line col
| _ -> emit Token.Lt line col)
| '>' -> (
ignore (advance lx);
match peek lx with
| Some '=' ->
ignore (advance lx);
emit Token.GtEq line col
| Some '>' ->
ignore (advance lx);
emit Token.Shr line col
| _ -> emit Token.Gt line col)
| other ->
ignore (advance lx);
report_unknown line col other)
done;
emit Token.Eof lx.line lx.col;
preprocess collector ~file (List.rev !out)