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))