From 6ca2ead7cd9ccf9fc139918e9966ab45fccf53b7 Mon Sep 17 00:00:00 2001
From: webdevred <148627186+webdevred@users.noreply.github.com>
Date: Sun, 23 Aug 2026 19:06:13 +0200
Subject: [PATCH 1/7] Add a failing spec for an insignificant comma in an array
BeamNG treats a comma as insignificant, so an array with a stray one where
an element would be is a file the game reads. The grammar allows exactly one
comma between elements and has nowhere to put a second, so the parse stops.
---
test/Parsing/JbeamSpec.hs | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/test/Parsing/JbeamSpec.hs b/test/Parsing/JbeamSpec.hs
index ce699306..9302ee7c 100644
--- a/test/Parsing/JbeamSpec.hs
+++ b/test/Parsing/JbeamSpec.hs
@@ -4,6 +4,7 @@ module Parsing.JbeamSpec (
import Data.ByteString.Lazy (ByteString)
import Data.ByteString.Lazy qualified as BS (readFile)
+import Data.Either (isRight)
import Data.Vector (fromList)
import Data.Void (Void)
import JbeamEdit.Parsing.Common.Helpers
@@ -186,6 +187,22 @@ invalidSpec =
where
expLabels = foldMap elabel ["a valid scalar", "object", "array"]
+{- | BeamNG treats a comma as insignificant, closer to white space than to
+structure, so a stray one between two array elements is a file the game
+reads. The grammar allows exactly one comma between elements and has nowhere
+to put a second, which stops the parse. Issue #230.
+
+This says only that the input is accepted. What the formatter writes back for
+the empty slot, a comma or nothing, is a separate decision and needs its own
+spec once it is made.
+-}
+insignificantCommaSpec :: Spec
+insignificantCommaSpec =
+ describe "an array with a stray comma where an element would be"
+ . it "is accepted, because the game reads it"
+ $ parseNodesState' nodeParser "[\"id\", ,\"idRef:\"]"
+ `shouldSatisfy` isRight
+
invalidNumberSpec :: Spec
invalidNumberSpec =
describe
@@ -238,6 +255,7 @@ spec :: Spec
spec = do
mapM_ (applyParserSpec nodeParser) specs
invalidSpec
+ insignificantCommaSpec
invalidNumberSpec
invalidTopNodeSpec
topNodeSpecs
From 8874b58db77a1b1c0a92bf6fa3844026b249df4c Mon Sep 17 00:00:00 2001
From: webdevred <148627186+webdevred@users.noreply.github.com>
Date: Tue, 25 Aug 2026 17:38:35 +0200
Subject: [PATCH 2/7] Fix the parser to accept extra commas
---
src/JbeamEdit/Parsing/Jbeam.hs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/JbeamEdit/Parsing/Jbeam.hs b/src/JbeamEdit/Parsing/Jbeam.hs
index be8784e9..89c310ee 100644
--- a/src/JbeamEdit/Parsing/Jbeam.hs
+++ b/src/JbeamEdit/Parsing/Jbeam.hs
@@ -51,7 +51,7 @@ separatorParser :: JbeamParser ()
separatorParser = do
ws1 <- MP.takeWhileP Nothing wordIsSpace
comma <- MP.optional (MP.label "comma" $ byteChar ',')
- ws2 <- MP.takeWhileP Nothing wordIsSpace
+ ws2 <- MP.takeWhileP Nothing (\x -> wordIsSpace x || toChar x == ',')
let nl = toWord8 '\n'
ws1Newlines = LBS.count nl ws1
From 4e4b7477cd95764c149b978aa2a351d00e45c896 Mon Sep 17 00:00:00 2001
From: webdevred <148627186+webdevred@users.noreply.github.com>
Date: Tue, 25 Aug 2026 19:05:24 +0200
Subject: [PATCH 3/7] parser: consume and discard trailing period after
integers
numberParser now optionally consumes a lone "." following a
number with no decimal digits (e.g. "3." parses as 3, period
discarded). Previously the period was left unconsumed.
---
src/JbeamEdit/Parsing/Jbeam.hs | 31 +++++++++++++------------------
test/Parsing/JbeamSpec.hs | 22 +++++++++++++++++++++-
2 files changed, 34 insertions(+), 19 deletions(-)
diff --git a/src/JbeamEdit/Parsing/Jbeam.hs b/src/JbeamEdit/Parsing/Jbeam.hs
index 89c310ee..e55dd3b5 100644
--- a/src/JbeamEdit/Parsing/Jbeam.hs
+++ b/src/JbeamEdit/Parsing/Jbeam.hs
@@ -3,6 +3,7 @@ module JbeamEdit.Parsing.Jbeam (
JbeamParser,
nodeParser,
numberParser,
+ initialState,
parseNodes,
parseNodesState,
) where
@@ -18,7 +19,6 @@ import Data.Char (isSpace)
import Data.Functor (($>))
import Data.Maybe (isJust, isNothing)
import Data.Monoid.Extra (mwhen)
-import Data.Scientific (Scientific)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (decodeUtf8Lenient)
@@ -36,7 +36,7 @@ import JbeamEdit.Parsing.Common
import Text.Megaparsec ((>), (<|>))
import Text.Megaparsec qualified as MP
import Text.Megaparsec.Byte qualified as B
-import Text.Megaparsec.Byte.Lexer qualified as L (decimal, scientific)
+import Text.Megaparsec.Byte.Lexer qualified as L (scientific)
import Text.Megaparsec.Char qualified as C
data ParseState = ParseState
@@ -84,14 +84,12 @@ numberParser = do
else pure ""
let signFactor = if char == toWord8 '-' then negate else id
before <- MP.getInput
- value <- MP.try intAsScientific <|> L.scientific
+ value <- L.scientific > "decimal number or integer"
after <- MP.getInput
+ _ <- MP.optional (byteChar '.')
let rawBytes = LBS.take (LBS.length before - LBS.length after) before
rawText = decodeUtf8Lenient (LBS.toStrict rawBytes)
pure $ Number (mkNumberValue (signText <> rawText) (signFactor value))
- where
- intAsScientific :: JbeamParser Scientific
- intAsScientific = fromIntegral <$> intDecimalParser
associationDirection :: ParseState -> AssociationDirection
associationDirection st = bool PreviousNode NextNode (lastNodeEndedWithNewline st)
@@ -151,10 +149,6 @@ stringParser = parseWord8s String string
emptyString = C.string "\"\"" >> pure []
string = emptyString <|> validString
-intDecimalParser :: JbeamParser Integer
-intDecimalParser =
- L.decimal <* MP.notFollowedBy (byteChar '.' <|> byteChar 'e' <|> byteChar 'E')
-
scalarParser :: JbeamParser Node
scalarParser =
tryScalarParsers
@@ -217,18 +211,19 @@ objectParser = do
topNodeParser :: JbeamParser Node
topNodeParser = nodeParser <* skipWhiteSpace <* MP.eof
+initialState :: ParseState
+initialState =
+ ParseState
+ { lastNodeEndedWithNewline = True
+ , lastSeparatorHadBlankLine = False
+ , lastSeparatorHadComma = False
+ }
+
parseNodesState
:: JbeamParser a
-> LBS.ByteString
-> Either (MP.ParseErrorBundle LBS.ByteString Void) a
-parseNodesState parser input =
- let initialState =
- ParseState
- { lastNodeEndedWithNewline = True
- , lastSeparatorHadBlankLine = False
- , lastSeparatorHadComma = False
- }
- in evalState (MP.runParserT parser "" input) initialState
+parseNodesState parser input = evalState (MP.runParserT parser "" input) initialState
parseNodes :: LBS.ByteString -> Either Text Node
parseNodes input = first formatErrors (parseNodesState topNodeParser input)
diff --git a/test/Parsing/JbeamSpec.hs b/test/Parsing/JbeamSpec.hs
index 9302ee7c..aa1b4b1e 100644
--- a/test/Parsing/JbeamSpec.hs
+++ b/test/Parsing/JbeamSpec.hs
@@ -2,6 +2,7 @@ module Parsing.JbeamSpec (
spec,
) where
+import Control.Monad.State (State, evalState)
import Data.ByteString.Lazy (ByteString)
import Data.ByteString.Lazy qualified as BS (readFile)
import Data.Either (isRight)
@@ -9,6 +10,7 @@ import Data.Vector (fromList)
import Data.Void (Void)
import JbeamEdit.Parsing.Common.Helpers
import JbeamEdit.Parsing.Jbeam
+import JbeamEdit.Parsing.Jbeam qualified as P (initialState)
import SpecHelper
import Test.Hspec.Megaparsec
import Text.Megaparsec qualified as MP
@@ -209,7 +211,24 @@ invalidNumberSpec =
"should fail parsing Number when there is space after the negative sign"
. works
$ parseNodesState numberParser "- 0.3"
- `shouldFailWith` err 1 (utok (toWord8 ' ') <> elabel "digit" <> elabel "integer")
+ `shouldFailWith` err 1 (utok (toWord8 ' ') <> elabel "decimal number or integer")
+
+numberWithBadDecimalPoint :: Spec
+numberWithBadDecimalPoint =
+ describe
+ "should consume and discard a trailing period with no decimal digits"
+ . works
+ $ do
+ let input = "3."
+ case evalState
+ (MP.runParserT ((,) <$> numberParser <*> MP.getInput) "" input)
+ P.initialState of
+ Left bundle ->
+ expectationFailure $
+ "expected successful parse, got error:\n" <> MP.errorBundlePretty bundle
+ Right (node, remaining) -> do
+ node `shouldBe` Number (mkNumberValue "3" 3)
+ remaining `shouldBe` ""
topNodeSpec :: FilePath -> FilePath -> Spec
topNodeSpec inFilename outFilename = do
@@ -258,6 +277,7 @@ spec = do
insignificantCommaSpec
invalidNumberSpec
invalidTopNodeSpec
+ numberWithBadDecimalPoint
topNodeSpecs
where
specs =
From cae4c81393073d593eebddf6512cbc2de43aebda Mon Sep 17 00:00:00 2001
From: webdevred <148627186+webdevred@users.noreply.github.com>
Date: Tue, 25 Aug 2026 19:23:54 +0200
Subject: [PATCH 4/7] Cover the remaining shapes from issue 230
The parser rejects four more shapes that BeamNG reads: a comma after a key
colon, one before it, one after the root object, and a number ending on its
decimal point. Each spec asserts only that the input is accepted, since what
the formatter writes back is undecided.
---
test/Parsing/JbeamSpec.hs | 43 ++++++++++++++++++++++++++-------------
1 file changed, 29 insertions(+), 14 deletions(-)
diff --git a/test/Parsing/JbeamSpec.hs b/test/Parsing/JbeamSpec.hs
index aa1b4b1e..cacfad84 100644
--- a/test/Parsing/JbeamSpec.hs
+++ b/test/Parsing/JbeamSpec.hs
@@ -189,21 +189,36 @@ invalidSpec =
where
expLabels = foldMap elabel ["a valid scalar", "object", "array"]
-{- | BeamNG treats a comma as insignificant, closer to white space than to
-structure, so a stray one between two array elements is a file the game
-reads. The grammar allows exactly one comma between elements and has nowhere
-to put a second, which stops the parse. Issue #230.
+{- | Shapes that BeamNG reads and our grammar rejects, from issue #230. A comma
+is insignificant to the game, closer to white space than to structure, and a
+number may end on its decimal point.
-This says only that the input is accepted. What the formatter writes back for
-the empty slot, a comma or nothing, is a separate decision and needs its own
-spec once it is made.
+Each spec says only that the input is accepted. What the formatter writes back
+is a separate decision per shape, and needs its own spec once it is made.
-}
-insignificantCommaSpec :: Spec
-insignificantCommaSpec =
- describe "an array with a stray comma where an element would be"
- . it "is accepted, because the game reads it"
- $ parseNodesState' nodeParser "[\"id\", ,\"idRef:\"]"
- `shouldSatisfy` isRight
+acceptsFragment :: (String, String) -> Spec
+acceptsFragment (desc, input) =
+ describe desc . it "is accepted, because the game reads it" $
+ parseNodesState' nodeParser input `shouldSatisfy` isRight
+
+acceptsDocument :: (String, String) -> Spec
+acceptsDocument (desc, input) =
+ describe desc . it "is accepted, because the game reads it" $
+ parseNodes (textToLazyByteString input) `shouldSatisfy` isRight
+
+gameReadableSpecs :: [Spec]
+gameReadableSpecs =
+ map
+ acceptsFragment
+ [
+ ( "an array with a stray comma where an element would be"
+ , "[\"id\", ,\"idRef:\"]"
+ )
+ , ("a comma after the colon of a key", "{\"innerfender_R\":, {\"a\": 1}}")
+ , ("a comma before the colon of a key", "{\"spoke1\",: {\"a\": 1}}")
+ , ("a number ending on its decimal point", "[\"f1\", 0., -1.66]")
+ ]
+ ++ [acceptsDocument ("a comma after the root object", "{\"a\": 1},\n")]
invalidNumberSpec :: Spec
invalidNumberSpec =
@@ -274,7 +289,7 @@ spec :: Spec
spec = do
mapM_ (applyParserSpec nodeParser) specs
invalidSpec
- insignificantCommaSpec
+ sequence_ gameReadableSpecs
invalidNumberSpec
invalidTopNodeSpec
numberWithBadDecimalPoint
From 5366e9a882405775cbdc0c83fb920bb72686fc86 Mon Sep 17 00:00:00 2001
From: webdevred <148627186+webdevred@users.noreply.github.com>
Date: Tue, 25 Aug 2026 19:32:09 +0200
Subject: [PATCH 5/7] Keep initialState inside parseNodesState
It was exported so a spec could run a parser and look at the input left
over, but the already exported parseNodesState does that on its own.
---
src/JbeamEdit/Parsing/Jbeam.hs | 18 ++++++++----------
test/Parsing/JbeamSpec.hs | 24 +++++++++---------------
2 files changed, 17 insertions(+), 25 deletions(-)
diff --git a/src/JbeamEdit/Parsing/Jbeam.hs b/src/JbeamEdit/Parsing/Jbeam.hs
index e55dd3b5..c057ea9a 100644
--- a/src/JbeamEdit/Parsing/Jbeam.hs
+++ b/src/JbeamEdit/Parsing/Jbeam.hs
@@ -3,7 +3,6 @@ module JbeamEdit.Parsing.Jbeam (
JbeamParser,
nodeParser,
numberParser,
- initialState,
parseNodes,
parseNodesState,
) where
@@ -211,19 +210,18 @@ objectParser = do
topNodeParser :: JbeamParser Node
topNodeParser = nodeParser <* skipWhiteSpace <* MP.eof
-initialState :: ParseState
-initialState =
- ParseState
- { lastNodeEndedWithNewline = True
- , lastSeparatorHadBlankLine = False
- , lastSeparatorHadComma = False
- }
-
parseNodesState
:: JbeamParser a
-> LBS.ByteString
-> Either (MP.ParseErrorBundle LBS.ByteString Void) a
-parseNodesState parser input = evalState (MP.runParserT parser "" input) initialState
+parseNodesState parser input =
+ let initialState =
+ ParseState
+ { lastNodeEndedWithNewline = True
+ , lastSeparatorHadBlankLine = False
+ , lastSeparatorHadComma = False
+ }
+ in evalState (MP.runParserT parser "" input) initialState
parseNodes :: LBS.ByteString -> Either Text Node
parseNodes input = first formatErrors (parseNodesState topNodeParser input)
diff --git a/test/Parsing/JbeamSpec.hs b/test/Parsing/JbeamSpec.hs
index cacfad84..2d0824d6 100644
--- a/test/Parsing/JbeamSpec.hs
+++ b/test/Parsing/JbeamSpec.hs
@@ -2,7 +2,6 @@ module Parsing.JbeamSpec (
spec,
) where
-import Control.Monad.State (State, evalState)
import Data.ByteString.Lazy (ByteString)
import Data.ByteString.Lazy qualified as BS (readFile)
import Data.Either (isRight)
@@ -10,7 +9,6 @@ import Data.Vector (fromList)
import Data.Void (Void)
import JbeamEdit.Parsing.Common.Helpers
import JbeamEdit.Parsing.Jbeam
-import JbeamEdit.Parsing.Jbeam qualified as P (initialState)
import SpecHelper
import Test.Hspec.Megaparsec
import Text.Megaparsec qualified as MP
@@ -193,8 +191,8 @@ invalidSpec =
is insignificant to the game, closer to white space than to structure, and a
number may end on its decimal point.
-Each spec says only that the input is accepted. What the formatter writes back
-is a separate decision per shape, and needs its own spec once it is made.
+Each spec says only that the input is accepted, because what the formatter
+writes back for each shape is not decided yet.
-}
acceptsFragment :: (String, String) -> Spec
acceptsFragment (desc, input) =
@@ -233,17 +231,13 @@ numberWithBadDecimalPoint =
describe
"should consume and discard a trailing period with no decimal digits"
. works
- $ do
- let input = "3."
- case evalState
- (MP.runParserT ((,) <$> numberParser <*> MP.getInput) "" input)
- P.initialState of
- Left bundle ->
- expectationFailure $
- "expected successful parse, got error:\n" <> MP.errorBundlePretty bundle
- Right (node, remaining) -> do
- node `shouldBe` Number (mkNumberValue "3" 3)
- remaining `shouldBe` ""
+ $ case parseNodesState ((,) <$> numberParser <*> MP.getInput) "3." of
+ Left bundle ->
+ expectationFailure $
+ "expected successful parse, got error:\n" <> MP.errorBundlePretty bundle
+ Right (node, remaining) -> do
+ node `shouldBe` Number (mkNumberValue "3" 3)
+ remaining `shouldBe` ""
topNodeSpec :: FilePath -> FilePath -> Spec
topNodeSpec inFilename outFilename = do
From e3391c75392912947348f8ef2c3f82622ad598df Mon Sep 17 00:00:00 2001
From: webdevred <148627186+webdevred@users.noreply.github.com>
Date: Tue, 25 Aug 2026 19:47:20 +0200
Subject: [PATCH 6/7] Parser fixes
---
src/JbeamEdit/Parsing/Jbeam.hs | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/src/JbeamEdit/Parsing/Jbeam.hs b/src/JbeamEdit/Parsing/Jbeam.hs
index c057ea9a..8da2f587 100644
--- a/src/JbeamEdit/Parsing/Jbeam.hs
+++ b/src/JbeamEdit/Parsing/Jbeam.hs
@@ -196,7 +196,10 @@ objectKeyParser = do
_ <- skipWhiteSpace
key <- MP.try (stringParser > "string")
_ <- skipWhiteSpace
+ _ <- MP.optional (byteChar ',')
+ _ <- skipWhiteSpace
_ <- byteChar ':'
+ _ <- MP.optional (byteChar ',')
value <- nodeParser
pure $ ObjectKey (key, value)
@@ -208,7 +211,12 @@ objectParser = do
pure . Object $ ObjectValue (V.fromList elems)
topNodeParser :: JbeamParser Node
-topNodeParser = nodeParser <* skipWhiteSpace <* MP.eof
+topNodeParser =
+ nodeParser
+ <* skipWhiteSpace
+ <* MP.optional (byteChar ',')
+ <* skipWhiteSpace
+ <* MP.eof
parseNodesState
:: JbeamParser a
From 91e8b6b417257ed814d1058e3e5b1d86d5f69913 Mon Sep 17 00:00:00 2001
From: webdevred <148627186+webdevred@users.noreply.github.com>
Date: Tue, 25 Aug 2026 19:49:37 +0200
Subject: [PATCH 7/7] Fix
---
src/JbeamEdit/Parsing/Jbeam.hs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/JbeamEdit/Parsing/Jbeam.hs b/src/JbeamEdit/Parsing/Jbeam.hs
index 8da2f587..6ab0d7df 100644
--- a/src/JbeamEdit/Parsing/Jbeam.hs
+++ b/src/JbeamEdit/Parsing/Jbeam.hs
@@ -85,7 +85,7 @@ numberParser = do
before <- MP.getInput
value <- L.scientific > "decimal number or integer"
after <- MP.getInput
- _ <- MP.optional (byteChar '.')
+ _ <- MP.optional (MP.try (byteChar '.' <* MP.notFollowedBy B.digitChar))
let rawBytes = LBS.take (LBS.length before - LBS.length after) before
rawText = decodeUtf8Lenient (LBS.toStrict rawBytes)
pure $ Number (mkNumberValue (signText <> rawText) (signFactor value))