// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// What created a path, which is what decides whether touching it again is legal.
///
/// TOML's redefinition rules are not one rule but several, and they turn on how a
/// table came to exist: one opened by a header may be extended by dotted keys under
/// it, one created by a dotted key may not later be opened by a header, and an
/// inline table is closed the moment its brace does.
priv enum Kind {
Direct
Header
Dotted
Inline
Tables
}
///|
/// Where the reader is and what it has built.
priv struct Reader {
src : StringView
mut pos : Int
mut line : Int
mut bol : Int
flavor : Flavor
root : Map[String, Json]
// The table that bare keys land in, and its dotted path.
mut here : Map[String, Json]
mut prefix : String
// How each dotted path came to exist.
seen : Map[String, Kind]
}
///|
fn Reader::new(src : StringView, flavor : Flavor) -> Reader {
let root : Map[String, Json] = Map([])
{
src,
pos: 0,
line: 1,
bol: 0,
flavor,
root,
here: root,
prefix: "",
seen: Map([]),
}
}
// -- the cursor ---------------------------------------------------------------
///|
fn Reader::at(self : Reader) -> At {
{ at: self.pos, line: self.line, column: self.pos - self.bol + 1, }
}
///|
fn Reader::eof(self : Reader) -> Bool {
self.pos >= self.src.length()
}
///|
fn Reader::cur(self : Reader) -> Int {
if self.pos < self.src.length() {
self.src[self.pos].to_int()
} else {
-1
}
}
///|
fn Reader::ahead(self : Reader, k : Int) -> Int {
if self.pos + k < self.src.length() {
self.src[self.pos + k].to_int()
} else {
-1
}
}
///|
fn Reader::bump(self : Reader) -> Unit {
if self.pos >= self.src.length() {
return
}
let c = self.src[self.pos].to_int()
self.pos = self.pos + 1
if c == 0x0a {
self.line = self.line + 1
self.bol = self.pos
}
}
///|
/// Skip spaces and tabs, which TOML allows anywhere between tokens.
fn Reader::gap(self : Reader) -> Unit {
while self.cur() == 0x20 || self.cur() == 0x09 {
self.bump()
}
}
///|
/// Skip whitespace, comments and line breaks, up to the next thing that means
/// something.
fn Reader::skip(self : Reader) -> Unit {
for ;; {
self.gap()
if self.cur() == 0x23 {
while !self.eof() && self.cur() != 0x0a {
self.bump()
}
continue
}
if self.cur() == 0x0a || self.cur() == 0x0d {
self.bump()
continue
}
return
}
}
///|
/// Everything to the end of the line must be a comment or nothing.
fn Reader::end_of_line(self : Reader) -> Unit raise Refused {
self.gap()
if self.cur() == 0x23 {
while !self.eof() && self.cur() != 0x0a {
self.bump()
}
return
}
if self.eof() || self.cur() == 0x0a || self.cur() == 0x0d {
return
}
raise Trailing(self.at())
}
///|
fn Reader::printable(self : Reader) -> Char {
if self.eof() {
' '
} else {
self.src[self.pos].unsafe_to_char()
}
}
// -- the document -------------------------------------------------------------
///|
/// The whole document: a run of key-value pairs and table headers.
fn Reader::document(self : Reader) -> Json raise Refused {
for ;; {
self.skip()
if self.eof() {
break
}
if self.cur() == 0x5b {
self.header()
continue
}
self.pair()
}
Json::object(self.root)
}
///|
/// A `[table]` or `[[array of tables]]` header (§Table, §Array of Tables).
fn Reader::header(self : Reader) -> Unit raise Refused {
let at = self.at()
self.bump()
let array = self.cur() == 0x5b
if array {
self.bump()
}
let path = self.path()
self.gap()
if self.cur() != 0x5d {
raise Unexpected(self.at(), self.printable())
}
self.bump()
if array {
if self.cur() != 0x5d {
raise Unexpected(self.at(), self.printable())
}
self.bump()
}
self.end_of_line()
self.here = if array {
self.open_tables(path, at)
} else {
self.open_table(path, at)
}
self.prefix = join(path)
}
///|
/// Walk to the table `path` names, creating what is missing, and mark it opened.
fn Reader::open_table(
self : Reader,
path : Array[String],
at : At,
) -> Map[String, Json] raise Refused {
let full = join(path)
// A header may not repeat, and may not open something a dotted key already made,
// a value already took, or an inline table closed.
match self.seen.get(full) {
Some(Header) | Some(Direct) | Some(Dotted) | Some(Inline) =>
raise Redefined(at, full)
_ => ()
}
let table = self.walk(path, at)
self.seen[full] = Header
table
}
///|
/// The same for `[[…]]`, which appends a table to an array rather than opening one.
fn Reader::open_tables(
self : Reader,
path : Array[String],
at : At,
) -> Map[String, Json] raise Refused {
let parent = self.walk(path[0:path.length() - 1].to_owned(), at)
let name = path[path.length() - 1]
let full = join(path)
match self.seen.get(full) {
Some(Tables) | None => ()
Some(_) => raise Redefined(at, full)
}
self.seen[full] = Tables
let fresh : Map[String, Json] = Map([])
match parent.get(name) {
Some(Array(items)) => {
items.push(Json::object(fresh))
()
}
Some(_) => raise Redefined(at, full)
None => parent[name] = Json::array([Json::object(fresh)])
}
fresh
}
///|
/// Walk `path` from the root, creating tables as needed, and answer the last one.
///
/// Walking into an array of tables lands in its last element, which is what makes
/// `[[a]]` followed by `[a.b]` put `b` inside the table just appended.
fn Reader::walk(
self : Reader,
path : Array[String],
at : At,
) -> Map[String, Json] raise Refused {
let mut table = self.root
let mut so_far = ""
for i = 0; i < path.length(); i = i + 1 {
let name = path[i]
so_far = if so_far == "" { name } else { so_far + "." + name }
match table.get(name) {
Some(Object(inner)) => {
if self.seen.get(so_far) is Some(Inline) {
raise Redefined(at, so_far)
}
table = inner
}
Some(Array(items)) =>
if self.seen.get(so_far) is Some(Tables) && items.length() > 0 {
match items[items.length() - 1] {
Object(inner) => table = inner
_ => raise Redefined(at, so_far)
}
} else {
raise Redefined(at, so_far)
}
Some(_) => raise Redefined(at, so_far)
None => {
// A table created on the way to somewhere else is implicit and stays
// unmarked, so its own header may still open it later (§Table).
let fresh : Map[String, Json] = Map([])
table[name] = Json::object(fresh)
table = fresh
}
}
}
table
}
///|
/// One `key = value` line, where the key may be dotted.
fn Reader::pair(self : Reader) -> Unit raise Refused {
let at = self.at()
let path = self.path()
self.gap()
if self.cur() != 0x3d {
raise Unexpected(self.at(), self.printable())
}
self.bump()
self.gap()
let value = self.value(0)
self.end_of_line()
// A dotted key walks into tables under the current one; the last segment is the
// key itself.
let mut table = self.here
let mut so_far = self.prefix
for i = 0; i < path.length() - 1; i = i + 1 {
let name = path[i]
so_far = if so_far == "" { name } else { so_far + "." + name }
match table.get(name) {
Some(Object(inner)) => {
// A dotted key may extend a table it made itself, but not one that a
// header opened or an inline table closed.
match self.seen.get(so_far) {
Some(Inline) | Some(Header) => raise Redefined(at, so_far)
_ => ()
}
table = inner
}
Some(_) => raise Redefined(at, so_far)
None => {
let fresh : Map[String, Json] = Map([])
table[name] = Json::object(fresh)
self.seen[so_far] = Dotted
table = fresh
}
}
}
let name = path[path.length() - 1]
let full = if so_far == "" { name } else { so_far + "." + name }
if table.get(name) is Some(_) {
raise Redefined(at, full)
}
table[name] = value
self.seen[full] = if value is Object(_) { Inline } else { Direct }
}
///|
/// A key, bare or quoted, possibly dotted.
fn Reader::path(self : Reader) -> Array[String] raise Refused {
let out : Array[String] = []
for ;; {
self.gap()
out.push(self.key())
self.gap()
if self.cur() == 0x2e {
self.bump()
continue
}
break
}
out
}
///|
/// One key segment.
fn Reader::key(self : Reader) -> String raise Refused {
match self.cur() {
0x22 => self.basic()
0x27 => self.literal()
_ => {
let start = self.pos
while !self.eof() {
let c = self.cur()
let bare = (c >= 0x41 && c <= 0x5a) ||
(c >= 0x61 && c <= 0x7a) ||
(c >= 0x30 && c <= 0x39) ||
c == 0x5f ||
c == 0x2d
if !bare {
break
}
self.bump()
}
if self.pos == start {
raise Unexpected(self.at(), self.printable())
}
self.src[start:self.pos].to_owned()
}
}
}
///|
/// The dotted form of a path, for an error message and for the `seen` table.
fn join(path : Array[String]) -> String {
let out = StringBuilder()
for i = 0; i < path.length(); i = i + 1 {
if i > 0 {
out.write_char('.')
}
out.write_string(path[i])
}
out.to_string()
}
// -- values -------------------------------------------------------------------
///|
/// A value of any kind.
fn Reader::value(self : Reader, depth : Int) -> Json raise Refused {
if depth > self.flavor.depth {
raise TooDeep(self.at())
}
match self.cur() {
0x22 =>
if self.ahead(1) == 0x22 && self.ahead(2) == 0x22 {
Json::string(self.multiline_basic())
} else {
Json::string(self.basic())
}
0x27 =>
if self.ahead(1) == 0x27 && self.ahead(2) == 0x27 {
Json::string(self.multiline_literal())
} else {
Json::string(self.literal())
}
0x5b => self.array(depth)
0x7b => self.inline(depth)
_ => self.scalar()
}
}
///|
/// An array, which may span lines and may end with a comma (§Array).
fn Reader::array(self : Reader, depth : Int) -> Json raise Refused {
let at = self.at()
self.bump()
let out : Array[Json] = []
for ;; {
self.skip()
if self.eof() {
raise Truncated(at)
}
if self.cur() == 0x5d {
self.bump()
break
}
out.push(self.value(depth + 1))
self.skip()
if self.cur() == 0x2c {
self.bump()
} else if self.cur() != 0x5d {
raise Unexpected(self.at(), self.printable())
}
}
Json::array(out)
}
///|
/// An inline table (§Inline Table).
///
/// It is closed the moment its brace closes: §Inline Table says a key may not be
/// added to it afterwards, which is why it is marked `Inline` rather than `Direct`.
/// TOML 1.0.0 also keeps it on one line and forbids a trailing comma, both of which
/// are refused here rather than tolerated.
fn Reader::inline(self : Reader, depth : Int) -> Json raise Refused {
let at = self.at()
self.bump()
let fields : Map[String, Json] = Map([])
self.gap()
if self.cur() == 0x7d {
self.bump()
return Json::object(fields)
}
for ;; {
self.gap()
let kat = self.at()
let path = self.path()
self.gap()
if self.cur() != 0x3d {
raise Unexpected(self.at(), self.printable())
}
self.bump()
self.gap()
let value = self.value(depth + 1)
let mut table = fields
for i = 0; i < path.length() - 1; i = i + 1 {
let name = path[i]
match table.get(name) {
Some(Object(inner)) => table = inner
Some(_) => raise Redefined(kat, name)
None => {
let fresh : Map[String, Json] = Map([])
table[name] = Json::object(fresh)
table = fresh
}
}
}
let name = path[path.length() - 1]
if table.get(name) is Some(_) {
raise Redefined(kat, name)
}
table[name] = value
self.gap()
if self.cur() == 0x2c {
self.bump()
self.gap()
// A trailing comma is not allowed in 1.0.0.
if self.cur() == 0x7d {
raise Unexpected(self.at(), '}')
}
continue
}
if self.cur() == 0x7d {
self.bump()
break
}
if self.eof() {
raise Truncated(at)
}
raise Unexpected(self.at(), self.printable())
}
Json::object(fields)
}
///|
/// A bare value: a boolean, a date-time, or a number.
fn Reader::scalar(self : Reader) -> Json raise Refused {
let at = self.at()
let start = self.pos
while !self.eof() {
let c = self.cur()
if c == 0x0a ||
c == 0x0d ||
c == 0x2c ||
c == 0x5d ||
c == 0x7d ||
c == 0x23 {
break
}
// A space ends a value unless it is the one joining a date to a time.
if c == 0x20 || c == 0x09 {
if !self.looks_like_time(self.pos + 1) {
break
}
}
self.bump()
}
let text = trim(self.src[start:self.pos].to_owned())
if text.length() == 0 {
raise Unexpected(at, self.printable())
}
match text {
"true" => return Json::boolean(true)
"false" => return Json::boolean(false)
_ => ()
}
// A date-time is the one kind TOML gives a type of its own, so it is read as
// one and normalised; everything else numeric is a number.
if dateish(text) {
let m = @moondate.parse(text[:]) catch { _ => raise BadDate(at) }
return Json::string(m.to_string())
}
number(text, at)
}
///|
/// Whether the run at `from` begins `HH:MM`, which is the only thing that may
/// follow the space inside a date-time.
fn Reader::looks_like_time(self : Reader, from : Int) -> Bool {
if from + 5 > self.src.length() {
return false
}
let d = fn(i : Int) -> Bool {
let c = self.src[i].to_int()
c >= 0x30 && c <= 0x39
}
d(from) && d(from + 1) && self.src[from + 2].to_int() == 0x3a
}
///|
/// Whether text looks like one of TOML's four date-time forms rather than a number.
///
/// A date has a dash after four digits; a time has a colon after two. Neither
/// shape can be a number, so the test is exact rather than a guess.
fn dateish(s : String) -> Bool {
if s.length() >= 5 && s[2].to_int() == 0x3a {
return true
}
s.length() >= 10 && s[4].to_int() == 0x2d && s[7].to_int() == 0x2d
}
///|
/// A number in any of the four bases TOML writes, or a float.
fn number(text : String, at : At) -> Json raise Refused {
// Underscores group digits and mean nothing else (§Integer).
let bare = strip_underscores(text, at)
if bare.length() == 0 {
raise BadNumber(at)
}
let (sign, body) = if bare[0].to_int() == 0x2d {
(-1.0, bare[1:].to_owned())
} else if bare[0].to_int() == 0x2b {
(1.0, bare[1:].to_owned())
} else {
(1.0, bare)
}
match body {
"inf" => return Json::number(sign * @double.infinity)
"nan" => return Json::number(@double.not_a_number)
_ => ()
}
if body.length() > 2 && body[0].to_int() == 0x30 {
let radix = match body[1].to_int() {
0x78 => 16
0x6f => 8
0x62 => 2
_ => 0
}
if radix != 0 {
let mut acc = 0.0
for i = 2; i < body.length(); i = i + 1 {
guard nibble(body[i].to_int()) is Some(d) else { raise BadNumber(at) }
if d >= radix {
raise BadNumber(at)
}
acc = acc * radix.to_double() + d.to_double()
}
// No `repr`: `0xDEADBEEF` is not a JSON number, and the tree this builds
// has to stay one.
return Json::number(sign * acc)
}
}
// A leading zero is only legal on its own (§Integer), which is what stops
// `0123` from being read as 123 or as octal.
if body.length() > 1 &&
body[0].to_int() == 0x30 &&
body[1].to_int() >= 0x30 &&
body[1].to_int() <= 0x39 {
raise BadNumber(at)
}
match decimal(body) {
// The text is kept when it is itself a JSON number, so a value past what a
// Double holds exactly writes back as the digits it was read from. A `+`, an
// underscore or another base is not, so those carry no text and are written
// from the value.
Some(v) =>
if json_number(text) {
Json::number(sign * v, repr=text)
} else {
Json::number(sign * v)
}
None => raise BadNumber(at)
}
}
///|
/// A decimal integer or float, with an optional exponent.
fn decimal(s : String) -> Double? {
let mut i = 0
let mut whole = 0.0
let mut digits = false
while i < s.length() && s[i].to_int() >= 0x30 && s[i].to_int() <= 0x39 {
whole = whole * 10.0 + (s[i].to_int() - 0x30).to_double()
digits = true
i = i + 1
}
let mut value = whole
if i < s.length() && s[i].to_int() == 0x2e {
i = i + 1
let mut frac = 0.0
let mut scale = 1.0
let mut any = false
while i < s.length() && s[i].to_int() >= 0x30 && s[i].to_int() <= 0x39 {
frac = frac * 10.0 + (s[i].to_int() - 0x30).to_double()
scale = scale * 10.0
any = true
i = i + 1
}
// TOML requires a digit either side of the dot.
if !any || !digits {
return None
}
value = value + frac / scale
}
if !digits {
return None
}
if i < s.length() && (s[i].to_int() == 0x65 || s[i].to_int() == 0x45) {
i = i + 1
let mut esign = 1
if i < s.length() && (s[i].to_int() == 0x2b || s[i].to_int() == 0x2d) {
if s[i].to_int() == 0x2d {
esign = -1
}
i = i + 1
}
let mut exp = 0
let mut any = false
while i < s.length() && s[i].to_int() >= 0x30 && s[i].to_int() <= 0x39 {
exp = exp * 10 + (s[i].to_int() - 0x30)
any = true
i = i + 1
}
if !any {
return None
}
let mut factor = 1.0
for k = 0; k < exp; k = k + 1 {
factor = factor * 10.0
}
value = if esign < 0 { value / factor } else { value * factor }
}
if i != s.length() {
return None
}
Some(value)
}
///|
/// The text with its digit-grouping underscores removed.
///
/// An underscore must sit between two digits, so a leading, trailing or doubled one
/// is a fault rather than decoration.
fn strip_underscores(s : String, at : At) -> String raise Refused {
if index_of(s, 0x5f) < 0 {
return s
}
let out = StringBuilder()
for i = 0; i < s.length(); i = i + 1 {
if s[i].to_int() != 0x5f {
out.write_char(s[i].unsafe_to_char())
continue
}
if i == 0 || i + 1 >= s.length() {
raise BadNumber(at)
}
if !is_alnum(s[i - 1].to_int()) || !is_alnum(s[i + 1].to_int()) {
raise BadNumber(at)
}
}
out.to_string()
}
///|
fn is_alnum(c : Int) -> Bool {
(c >= 0x30 && c <= 0x39) ||
(c >= 0x41 && c <= 0x46) ||
(c >= 0x61 && c <= 0x66)
}
///|
fn nibble(c : Int) -> Int? {
if c >= 0x30 && c <= 0x39 {
Some(c - 0x30)
} else if c >= 0x61 && c <= 0x66 {
Some(c - 0x61 + 10)
} else if c >= 0x41 && c <= 0x46 {
Some(c - 0x41 + 10)
} else {
None
}
}
///|
fn index_of(s : String, u : Int) -> Int {
for i = 0; i < s.length(); i = i + 1 {
if s[i].to_int() == u {
return i
}
}
-1
}
///|
fn trim(s : String) -> String {
let mut a = 0
let mut b = s.length()
while a < b && (s[a].to_int() == 0x20 || s[a].to_int() == 0x09) {
a = a + 1
}
while b > a && (s[b - 1].to_int() == 0x20 || s[b - 1].to_int() == 0x09) {
b = b - 1
}
s[a:b].to_owned()
}
// -- strings ------------------------------------------------------------------
///|
/// A basic string `"…"`, which carries escapes.
fn Reader::basic(self : Reader) -> String raise Refused {
let at = self.at()
self.bump()
let out = StringBuilder()
for ;; {
if self.eof() || self.cur() == 0x0a {
raise Truncated(at)
}
let c = self.cur()
if c == 0x22 {
self.bump()
break
}
if c == 0x5c {
self.escape(out)
continue
}
out.write_char(self.src[self.pos].unsafe_to_char())
self.bump()
}
out.to_string()
}
///|
/// A literal string `'…'`, which carries none — which is why a Windows path goes
/// in one.
fn Reader::literal(self : Reader) -> String raise Refused {
let at = self.at()
self.bump()
let start = self.pos
for ;; {
if self.eof() || self.cur() == 0x0a {
raise Truncated(at)
}
if self.cur() == 0x27 {
break
}
self.bump()
}
let text = self.src[start:self.pos].to_owned()
self.bump()
text
}
///|
/// A multi-line basic string `"""…"""`.
fn Reader::multiline_basic(self : Reader) -> String raise Refused {
let at = self.at()
for i = 0; i < 3; i = i + 1 {
self.bump()
}
// A newline immediately after the opening delimiter is not part of the value.
if self.cur() == 0x0d {
self.bump()
}
if self.cur() == 0x0a {
self.bump()
}
let out = StringBuilder()
for ;; {
if self.eof() {
raise Truncated(at)
}
if self.cur() == 0x22 && self.ahead(1) == 0x22 && self.ahead(2) == 0x22 {
for i = 0; i < 3; i = i + 1 {
self.bump()
}
// Up to two more quotes belong to the value, so `""""""` is two quotes.
let mut extra = 0
while self.cur() == 0x22 && extra < 2 {
out.write_char('"')
self.bump()
extra = extra + 1
}
break
}
if self.cur() == 0x5c {
// A backslash at the end of a line eats the break and the whitespace after
// it, which is how a long line is written without one.
let save = self.pos
self.bump()
self.gap()
if self.cur() == 0x0a || self.cur() == 0x0d {
while self.cur() == 0x20 ||
self.cur() == 0x09 ||
self.cur() == 0x0a ||
self.cur() == 0x0d {
self.bump()
}
continue
}
self.pos = save
self.escape(out)
continue
}
if self.cur() == 0x0d {
self.bump()
continue
}
out.write_char(self.src[self.pos].unsafe_to_char())
self.bump()
}
out.to_string()
}
///|
/// A multi-line literal string `'''…'''`.
fn Reader::multiline_literal(self : Reader) -> String raise Refused {
let at = self.at()
for i = 0; i < 3; i = i + 1 {
self.bump()
}
if self.cur() == 0x0d {
self.bump()
}
if self.cur() == 0x0a {
self.bump()
}
let out = StringBuilder()
for ;; {
if self.eof() {
raise Truncated(at)
}
if self.cur() == 0x27 && self.ahead(1) == 0x27 && self.ahead(2) == 0x27 {
for i = 0; i < 3; i = i + 1 {
self.bump()
}
let mut extra = 0
while self.cur() == 0x27 && extra < 2 {
out.write_char('\'')
self.bump()
extra = extra + 1
}
break
}
if self.cur() == 0x0d {
self.bump()
continue
}
out.write_char(self.src[self.pos].unsafe_to_char())
self.bump()
}
out.to_string()
}
///|
/// One escape past the backslash (§String).
fn Reader::escape(self : Reader, out : StringBuilder) -> Unit raise Refused {
let at = self.at()
self.bump()
let e = self.cur()
self.bump()
match e {
0x62 => out.write_char('\u{08}')
0x74 => out.write_char('\t')
0x6e => out.write_char('\n')
0x66 => out.write_char('\u{0c}')
0x72 => out.write_char('\r')
0x22 => out.write_char('"')
0x5c => out.write_char('\\')
0x75 => self.hex(out, 4, at)
0x55 => self.hex(out, 8, at)
_ => raise BadEscape(at)
}
}
///|
fn Reader::hex(
self : Reader,
out : StringBuilder,
n : Int,
at : At,
) -> Unit raise Refused {
let mut v = 0
for i = 0; i < n; i = i + 1 {
guard nibble(self.cur()) is Some(d) else { raise BadEscape(at) }
v = (v << 4) | d
self.bump()
}
if v > 0x10ffff || (v >= 0xd800 && v <= 0xdfff) {
raise BadEscape(at)
}
out.write_char(v.unsafe_to_char())
}
///|
/// Whether `s` is spelled the way JSON spells a number (RFC 8259 §6).
///
/// TOML writes several that JSON does not — a leading `+`, underscores between
/// digits, hexadecimal, `inf` — and the tree this module builds is a JSON tree, so
/// only a spelling JSON shares may be carried through as text.
fn json_number(s : String) -> Bool {
let mut i = 0
if i < s.length() && s[i].to_int() == 0x2d {
i = i + 1
}
let start = i
while i < s.length() && s[i].to_int() >= 0x30 && s[i].to_int() <= 0x39 {
i = i + 1
}
if i == start {
return false
}
// JSON allows no leading zero on a multi-digit integer part.
if i - start > 1 && s[start].to_int() == 0x30 {
return false
}
if i < s.length() && s[i].to_int() == 0x2e {
i = i + 1
let frac = i
while i < s.length() && s[i].to_int() >= 0x30 && s[i].to_int() <= 0x39 {
i = i + 1
}
if i == frac {
return false
}
}
if i < s.length() && (s[i].to_int() == 0x65 || s[i].to_int() == 0x45) {
i = i + 1
if i < s.length() && (s[i].to_int() == 0x2b || s[i].to_int() == 0x2d) {
i = i + 1
}
let exp = i
while i < s.length() && s[i].to_int() >= 0x30 && s[i].to_int() <= 0x39 {
i = i + 1
}
if i == exp {
return false
}
}
i == s.length()
}