Repository: gerlero/foamlib

Issue:
FoamFile.loads() failing to parse system files
Hi @gerlero,

After some further testing, I am experiencing some issues with the FoamField.loads() function. My expectation is that these two should give the same result but instead, the FoamFile.loads() version is failing to parse. I am using foamlib version `0.9.2`.

Other files I have tested (the field and polyMesh files) work as expected.

**This works**
```
U = FoamFile(path)
print(U.as_dict())
```

**This fails**
```
with open(path, 'rb') as file:
    file_content = file.read()

U2 = FoamFile.loads(file_content)
print(U2)
```

**Error message**:
` Expected end of text, found '{'  (at char 3211), (line:129, col:1)`

**Input file:**
```
/*--------------------------------*- C++ -*----------------------------------*\
|       o          |                                                          |
|    o     o       | HELYX(R) Core: Open-source CFD for Enterprise            |
|   o   O   o      | Version: 4.1.1                                           |
|    o     o       | ENGYS Ltd. <http://engys.com/>                           |
|       o          |                                                          |
\*---------------------------------------------------------------------------*/
FoamFile
{
    version     2.0;
    format      binary;
    class       dictionary;
    arch        "LSB;label=32;scalar=64";
    location    "../system";
    object      fvSolution;
}
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //

solvers
{
    k
    {
        solver          PBiCGStab;
        smoother        symGaussSeidel;
        minIter         1;
        tolerance       1e-12;
        relTol          0.1;
        preconditioner  DILU;
        maxIter         10;
    }
    omega
    {
        solver          PBiCGStab;
        smoother        symGaussSeidel;
        minIter         1;
        tolerance       1e-12;
        relTol          0.1;
        preconditioner  DILU;
        maxIter         10;
    }
    p
    {
        solver          PBiCGStab;
        agglomerator    faceAreaPair;
        mergeLevels     1;
        cacheAgglomeration true;
        nCellsInCoarsestLevel 200;
        smoother        symGaussSeidel;
        minIter         1;
        scaleCorrection true;
        tolerance       1e-12;
        relTol          0.05;
        preconditioner
        {
            preconditioner  GAMG;
            smoother        FDIC;
        }
        maxIter         50;
    }
    U
    {
        solver          PBiCGStab;
        smoother        symGaussSeidel;
        minIter         1;
        tolerance       1e-12;
        relTol          0.1;
        nSweeps         2;
        preconditioner  DILU;
        maxIter         10;
    }
    h
    {
        solver          PBiCGStab;
        preconditioner  DILU;
        tolerance       1e-12;
        relTol          0.1;
        minIter         1;
        maxIter         10;
    }
    e
    {
        solver          PBiCGStab;
        preconditioner  DILU;
        tolerance       1e-12;
        relTol          0.1;
        minIter         1;
        maxIter         10;
    }
    epsilon
    {
        solver          PBiCGStab;
        preconditioner  DILU;
        minIter         1;
        tolerance       1e-12;
        relTol          0.1;
        maxIter         10;
    }
    nuTilda
    {
        solver          PBiCGStab;
        preconditioner  DILU;
        minIter         1;
        tolerance       1e-12;
        relTol          0.1;
        maxIter         10;
    }
}

relaxationFactors
{
    fields
    {
        p               0.3;
        rho             0.01;
    }
    equations
    {
        k               0.5;
        omega           0.5;
        U               0.7;
        epsilon         0.5;
        nuTilda         0.5;
        h               0.5;
        e               0.5;
    }
}

cache
{
    grad(U)         ;
}

SIMPLE
{
    nNonOrthogonalCorrectors 2;
    pRefCell        0;
    pRefValue       0;
    pMin            -1000000;
    pMax            1000000;
    modifiedMomentumInterp true;
    consistent      no;
    transonic       no;
}

potentialFlow
{
    nNonOrthogonalCorrectors 10;
}


// ************************************************************************* //
```

Expected output:
`{'solvers': {'k': {'solver': 'PBiCGStab', 'smoother': 'symGaussSeidel', 'minIter': 1, 'tolerance': 1e-12, 'relTol': 0.1, 'preconditioner': 'DILU', 'maxIter': 10}, 'omega': {'solver': 'PBiCGStab', 'smoother': 'symGaussSeidel', 'minIter': 1, 'tolerance': 1e-12, 'relTol': 0.1, 'preconditioner': 'DILU', 'maxIter': 10}, 'p': {'solver': 'PBiCGStab', 'agglomerator': 'faceAreaPair', 'mergeLevels': 1, 'cacheAgglomeration': True, 'nCellsInCoarsestLevel': 200, 'smoother': 'symGaussSeidel', 'minIter': 1, 'scaleCorrection': True, 'tolerance': 1e-12, 'relTol': 0.05, 'preconditioner': {'preconditioner': 'GAMG', 'smoother': 'FDIC'}, 'maxIter': 50}, 'U': {'solver': 'PBiCGStab', 'smoother': 'symGaussSeidel', 'minIter': 1, 'tolerance': 1e-12, 'relTol': 0.1, 'nSweeps': 2, 'preconditioner': 'DILU', 'maxIter': 10}, 'h': {'solver': 'PBiCGStab', 'preconditioner': 'DILU', 'tolerance': 1e-12, 'relTol': 0.1, 'minIter': 1, 'maxIter': 10}, 'e': {'solver': 'PBiCGStab', 'preconditioner': 'DILU', 'tolerance': 1e-12, 'relTol': 0.1, 'minIter': 1, 'maxIter': 10}, 'epsilon': {'solver': 'PBiCGStab', 'preconditioner': 'DILU', 'minIter': 1, 'tolerance': 1e-12, 'relTol': 0.1, 'maxIter': 10}, 'nuTilda': {'solver': 'PBiCGStab', 'preconditioner': 'DILU', 'minIter': 1, 'tolerance': 1e-12, 'relTol': 0.1, 'maxIter': 10}}, 'relaxationFactors': {'fields': {'p': 0.3, 'rho': 0.01}, 'equations': {'k': 0.5, 'omega': 0.5, 'U': 0.7, 'epsilon': 0.5, 'nuTilda': 0.5, 'h': 0.5, 'e': 0.5}}, 'cache': {'grad(U)': ''}, 'SIMPLE': {'nNonOrthogonalCorrectors': 2, 'pRefCell': 0, 'pRefValue': 0, 'pMin': -1000000, 'pMax': 1000000, 'modifiedMomentumInterp': True, 'consistent': False, 'transonic': False}, 'potentialFlow': {'nNonOrthogonalCorrectors': 10}}`

Discussion:
@gerlero: @cjbraley Thanks for reporting.

I'll try to come up with a fix.@gerlero: Should be fixed in `0.9.3`

Solution patch:
diff --git a/foamlib/_files/_parsing.py b/foamlib/_files/_parsing.py
index 415cf24..e026dde 100644
--- a/foamlib/_files/_parsing.py
+++ b/foamlib/_files/_parsing.py
@@ -363,7 +363,9 @@ _FIELD = (Keyword("uniform", _IDENTBODYCHARS).suppress() + _TENSOR) | (
 _DIRECTIVE = Word("#", _IDENTBODYCHARS)
 _TOKEN = dbl_quoted_string | _DIRECTIVE | _IDENTIFIER
 _DATA = Forward()
-_KEYWORD_ENTRY = _keyword_entry_of(_TOKEN | _list_of(_IDENTIFIER), _DATA)
+_KEYWORD_ENTRY = _keyword_entry_of(
+    _TOKEN | _list_of(_IDENTIFIER), Opt(_DATA, default="")
+)
 _DICT = _dict_of(_TOKEN, _DATA)
 _DATA_ENTRY = Forward()
 _LIST_ENTRY = _DICT | _KEYWORD_ENTRY | _DATA_ENTRY


Test patch:
diff --git a/tests/test_files/test_parsing/test_loads.py b/tests/test_files/test_parsing/test_loads.py
index d634305..7ffed61 100644
--- a/tests/test_files/test_parsing/test_loads.py
+++ b/tests/test_files/test_parsing/test_loads.py
@@ -18,6 +18,7 @@ def test_loads() -> None:
     assert FoamFile.loads("word word") == ("word", "word")
     assert FoamFile.loads('"a string"') == '"a string"'
     assert FoamFile.loads("(word word)") == ["word", "word"]
+    assert FoamFile.loads("keyword value;") == {"keyword": "value"}
     assert FoamFile.loads("uniform 1") == 1
     assert FoamFile.loads("uniform 1.0") == 1.0
     assert FoamFile.loads("uniform 1.0e-3") == 1.0e-3
@@ -69,3 +70,4 @@ def test_loads() -> None:
     assert dimensioned.name is None
     assert FoamFile.loads("a {b c; d e;}") == {"a": {"b": "c", "d": "e"}}
     assert FoamFile.loads("(a b; c d;)") == [("a", "b"), ("c", "d")]
+    assert FoamFile.loads("keyword;") == {"keyword": ""}