Repository: gerlero/foamlib

Issue:
FoamFile.loads() parsing issues
Hi @gerlero,

Similar to one of the previous issues you fixed. I am able to parse some files with the FoamFile(path) syntax but it's failing with FoamFile.loads(content). 

It appears to because these are special statements (indicated by #) that are missing `;` at the end of the line. Maybe these should be handled specially (e.g. as a list because of repeated keys) but for now, it would be great if we could just get both methods showing the same behaviour.


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)
```
Input File:
```
FoamFile
{
    version     2.0;
    format      ascii;
    class       dictionary;
    location    system;
    object      postProcess;
}

#include "$FOAM_CASE/simControls"

functions
{
    #include "post/readFields"
    #includeIfPresent "post/derivedFields"
    #includeIfPresent "post/surfaces"
    #includeIfPresent "post/planes"
    #includeIfPresent "post/extraFields"
}
```
Expected output:

```
{'#include': '"$FOAM_CASE/simControls"', 'functions': {'#include': '"post/readFields"', '#includeIfPresent': '"post/extraFields"'}}
```

Error:
```pyparsing.exceptions.ParseException: Expected end of text, found '{'  (at char 184), (line:13, col:1)```

Thanks!

Hints from issue comments:
@gerlero: @cjbraley good catch! Yes, I didn't really account for #-directives with the standalone parser. I’ll take a look.@gerlero: I'm looking into these. 

Please do note that multiple #-directives with the same name at the same level are not currently supported: that's why your output shows only one `#includeIfPresent` even though the input had four.

It's definitely in the scope of the library, but major work would have to be done to support these repeated directives, since files with these violate the `Mapping` interface by design.@gerlero: Should be fixed in `0.9.5`@cjbraley: Thanks @gerlero ,

Based on some testing this is working now (at least in as far as it's not crashing and both parsing strategies are returning the same thing). Maybe supporting repeated directives is something that can be added to the roadmap but I think it's quite low value relative to other things.

Solution patch:
diff --git a/foamlib/_files/_parsing.py b/foamlib/_files/_parsing.py
index e026dde..54550f2 100644
--- a/foamlib/_files/_parsing.py
+++ b/foamlib/_files/_parsing.py
@@ -363,11 +363,14 @@ _FIELD = (Keyword("uniform", _IDENTBODYCHARS).suppress() + _TENSOR) | (
 _DIRECTIVE = Word("#", _IDENTBODYCHARS)
 _TOKEN = dbl_quoted_string | _DIRECTIVE | _IDENTIFIER
 _DATA = Forward()
+_DATA_ENTRY = Forward()
 _KEYWORD_ENTRY = _keyword_entry_of(
-    _TOKEN | _list_of(_IDENTIFIER), Opt(_DATA, default="")
+    _TOKEN | _list_of(_IDENTIFIER),
+    Opt(_DATA, default=""),
+    directive=_DIRECTIVE,
+    data_entry=_DATA_ENTRY,
 )
 _DICT = _dict_of(_TOKEN, _DATA)
-_DATA_ENTRY = Forward()
 _LIST_ENTRY = _DICT | _KEYWORD_ENTRY | _DATA_ENTRY
 _LIST = _list_of(_LIST_ENTRY)
 _NUMBER = (
diff --git a/foamlib/_files/_serialization.py b/foamlib/_files/_serialization.py
index 700a4b8..f302c6a 100644
--- a/foamlib/_files/_serialization.py
+++ b/foamlib/_files/_serialization.py
@@ -247,7 +247,8 @@ def dumps(
     if isinstance(data, tuple):
         if tuple_is_entry:
             k, v = data
-            ret = dumps(k)
+            ret = b"\n" if isinstance(k, str) and k[0] == "#" else b""
+            ret += dumps(k)
             val = dumps(
                 v,
                 keywords=(*keywords, k)
@@ -256,7 +257,9 @@ def dumps(
             )
             if val:
                 ret += b" " + val
-            if not isinstance(v, Mapping):
+            if isinstance(k, str) and k[0] == "#":
+                ret += b"\n"
+            elif not isinstance(v, Mapping):
                 ret += b";"
             return ret
 


Test patch:
diff --git a/tests/test_files/test_dumps.py b/tests/test_files/test_dumps.py
index 8422502..669d635 100644
--- a/tests/test_files/test_dumps.py
+++ b/tests/test_files/test_dumps.py
@@ -125,3 +125,7 @@ def test_serialize_file() -> None:
         )
         == b"FoamFile {format binary;} 3(\x01\x00\x00\x00\x02\x00\x00\x00\x03\x00\x00\x00)"
     )
+    assert (
+        FoamFile.dumps({"#include": "$FOAM_CASE/simControls"}, ensure_header=False)
+        == b"\n#include $FOAM_CASE/simControls\n"
+    )
diff --git a/tests/test_files/test_parsing/test_loads.py b/tests/test_files/test_parsing/test_loads.py
index 7ffed61..459ee18 100644
--- a/tests/test_files/test_parsing/test_loads.py
+++ b/tests/test_files/test_parsing/test_loads.py
@@ -71,3 +71,6 @@ def test_loads() -> 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": ""}
+    assert FoamFile.loads("#include $FOAM_CASE/simControls") == {
+        "#include": "$FOAM_CASE/simControls"
+    }