diff --git a/CHANGELOG.md b/CHANGELOG.md index 53589708..ec98c77a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ Modifications by (in alphabetical order): * P. Vitt, University of Siegen, Germany * A. Voysey, UK Met Office +18/08/2026 PR #526 for #484. Add the 'format-missing-comma' extension: accept a + missing comma between a character-string edit descriptor and a + neighbouring format item, as many compilers do. + 16/07/2026 PR #520 for #519. Fix truncation of a character length expression that contains a comma (e.g. the arguments to an intrinsic such as MAX) when the kind appears before the length in a char-selector. diff --git a/doc/source/fparser2.rst b/doc/source/fparser2.rst index c9e8f51b..eb9b556c 100644 --- a/doc/source/fparser2.rst +++ b/doc/source/fparser2.rst @@ -319,6 +319,23 @@ omitted, the value is implicitly assumed to be one. For example:: For more information see https://gcc.gnu.org/onlinedocs/gfortran/X-format-descriptor-without-count-field.html +Missing Comma in Format Specifications +++++++++++++++++++++++++++++++++++++++ + +Standard Fortran only permits the comma separating format items to be +omitted in a small number of situations (constraint C1002). However, +many compilers (e.g. gfortran, ifort, ifx) additionally accept a +missing comma between a character-string edit descriptor and a +neighbouring format item. For example:: + + 100 format('a' 1x,'b') + 200 format(15x'a') + 300 format('a' 'b') + +The 'format-missing-comma' extension adds support in fparser for this +relaxation. Note that when such a format specification is re-generated +from the parse tree the omitted commas are re-introduced. + Hollerith Constant ++++++++++++++++++ diff --git a/src/fparser/two/Fortran2003.py b/src/fparser/two/Fortran2003.py index ecc89765..ced72700 100644 --- a/src/fparser/two/Fortran2003.py +++ b/src/fparser/two/Fortran2003.py @@ -10039,6 +10039,35 @@ def skip_digits(string): return found, index +def split_leading_char_literal(string): + """Splits a string that starts with a character literal into the + literal and the remainder, honouring doubled (escaped) quotes + inside the literal. + + :param str string: the string to split. + + :returns: a 2-tuple containing the character literal and the \ + remainder of the string, or None if the string does not start \ + with a complete character literal. + :rtype: Optional[Tuple[str, str]] + + """ + if not string or string[0] not in "'\"": + return None + quote = string[0] + index = 1 + while index < len(string): + if string[index] == quote: + if index + 1 < len(string) and string[index + 1] == quote: + # A doubled quote is an escaped quote, still inside the + # literal. + index += 2 + continue + return string[: index + 1], string[index + 1 :] + index += 1 + return None + + class Format_Item_C1002(Base): # pylint: disable=invalid-name """ Fortran 2003 constraint C1002:: @@ -10063,6 +10092,12 @@ class Format_Item_C1002(Base): # pylint: disable=invalid-name (4) Before or after a colon edit descriptor. + If 'format-missing-comma' is specified in the EXTENSIONS list then + the comma may additionally be omitted between a character-string + edit descriptor and any neighbouring format item, e.g. + FORMAT('a' 1x,'b'), FORMAT(15x'a') or FORMAT('a' 'b'), as accepted + by many compilers (e.g. gfortran, ifort, ifx). + """ subclass_names = [] @@ -10070,6 +10105,36 @@ class Format_Item_C1002(Base): # pylint: disable=invalid-name @staticmethod def match(string): + """Implements the matching for the C1002 Format Item constraint, + optionally relaxed by the 'format-missing-comma' extension. + + :param str string: The string to check for conformance with a \ + C1002 format item constraint. + :return: `None` if there is no match, otherwise a tuple of \ + size 2 containing a mixture of Control_Edit_Descriptor and \ + Format_Item classes depending on what has been matched. + + :rtype: `NoneType` or ( \ + :py:class:`fparser.two.Control_Edit_Desc`, \ + :py:class:`fparser.two.Format_Item` ) or \ + (:py:class:`fparser.two.Format_Item`, \ + :py:class:`fparser.two.Control_Edit_Desc`) or \ + (:py:class:`fparser.two.Format_Item`, \ + :py:class:`fparser.two.Format_Item`) + + """ + try: + result = Format_Item_C1002._standard_match(string) + except NoMatchError: + result = None + if result: + return result + if "format-missing-comma" not in EXTENSIONS(): + return None + return Format_Item_C1002._extension_match(string) + + @staticmethod + def _standard_match(string): """Implements the matching for the C1002 Format Item constraint. The constraints specify certain combinations of format items that do not need a comma to separate them. Rather than sorting this @@ -10171,6 +10236,52 @@ def match(string): Format_Item(option + repmap(right.lstrip())), ) + return None + + @staticmethod + def _extension_match(string): + """Implements the matching for the 'format-missing-comma' + extension. Various compilers (e.g. gfortran, ifort, ifx) accept + a missing comma between a character-string edit descriptor and + the neighbouring format item, e.g. FORMAT('a' 1x,'b'), + FORMAT(15x'a') or FORMAT('a' 'b'). The item is split at the + boundary of the first character literal and both sides are + matched separately. + + :param str string: The string to check for conformance with the \ + 'format-missing-comma' extension. + :return: `None` if there is no match, otherwise a tuple of \ + size 2 containing two Format_Item classes. + :rtype: `NoneType` or \ + (:py:class:`fparser.two.Format_Item`, \ + :py:class:`fparser.two.Format_Item`) + + """ + if not string: + return None + strip_string = string.strip() + split = split_leading_char_literal(strip_string) + if split: + # The item starts with a character literal, e.g. "'a' 1x". + literal, rest = split + rest = rest.lstrip() + if not rest or rest.startswith(","): + # Nothing follows the literal, or standard syntax. + return None + return (Format_Item(literal), Format_Item(rest)) + indices = [ + strip_string.find(quote) for quote in "'\"" if strip_string.find(quote) > 0 + ] + if indices: + # The item contains a character literal preceded by another + # format item, e.g. "15x'a'". Split at the earliest quote. + index = min(indices) + return ( + Format_Item(strip_string[:index].rstrip()), + Format_Item(strip_string[index:]), + ) + return None + def tostr(self): """ :return: Parsed representation of two format items diff --git a/src/fparser/two/tests/fortran2003/test_format_item_c1002.py b/src/fparser/two/tests/fortran2003/test_format_item_c1002.py index 8d00209f..a49c5a25 100644 --- a/src/fparser/two/tests/fortran2003/test_format_item_c1002.py +++ b/src/fparser/two/tests/fortran2003/test_format_item_c1002.py @@ -42,6 +42,7 @@ import pytest from fparser.two.Fortran2003 import Format_Item_C1002 +from fparser.two import utils from fparser.two.utils import InternalError, NoMatchError @@ -106,3 +107,58 @@ def test_internal_error3(f2003_create, monkeypatch): assert ( "items entry 1 should contain a format items object but it " "is empty or None" ) in str(excinfo.value) + + +@pytest.mark.parametrize( + "my_input,expected", + [ + ("'a' 1x", "'a', 1X"), + ("'a' 'b'", "'a', 'b'"), + ("'a''b' 'c'", "'a''b', 'c'"), + ("15x'a'", "15X, 'a'"), + ("21x ' if it is the case '", "21X, ' if it is the case '"), + ('1x"don\'t"', '1X, "don\'t"'), + ("'a' 1x 'b'", "'a', 1X, 'b'"), + ], +) +def test_format_missing_comma_extension(f2003_create, my_input, expected): + """Check that a missing comma between a character-string edit + descriptor and a neighbouring format item is matched when the + 'format-missing-comma' extension is enabled (which it is by + default). + + """ + ast = Format_Item_C1002(my_input) + assert str(ast) == expected + + +@pytest.mark.parametrize("my_input", ["'a' 1x", "15x'a'", "'a' 'b'"]) +def test_format_missing_comma_disabled(f2003_create, monkeypatch, my_input): + """Check that a missing comma between a character-string edit + descriptor and a neighbouring format item is not matched when the + 'format-missing-comma' extension is not enabled. + + """ + monkeypatch.setattr( + utils, + "_EXTENSIONS", + [ext for ext in utils.EXTENSIONS() if ext != "format-missing-comma"], + ) + with pytest.raises(NoMatchError): + _ = Format_Item_C1002(my_input) + + +def test_format_missing_comma_format_statement(f2003_create): + """Check that format statements making use of the + 'format-missing-comma' extension are parsed (see issue #484 and + the MODFLOW-2005 codebase). + + """ + from fparser.two.Fortran2003 import Format_Stmt + + ast = Format_Stmt("FORMAT(//1X,'SWR PROCESS REQUIRES LAYCON'1X,'FOR',//)") + assert ( + str(ast) == "FORMAT(/, /, 1X, 'SWR PROCESS REQUIRES LAYCON', 1X, 'FOR', /, /)" + ) + ast = Format_Stmt("FORMAT(/1x,'unable to find:',1x,A,/15x' in file:')") + assert str(ast) == "FORMAT(/, 1X, 'unable to find:', 1X, A, /, 15X, ' in file:')" diff --git a/src/fparser/two/tests/fortran2003/test_format_specification_r1002.py b/src/fparser/two/tests/fortran2003/test_format_specification_r1002.py index 53e06e3a..4a2dd0e5 100644 --- a/src/fparser/two/tests/fortran2003/test_format_specification_r1002.py +++ b/src/fparser/two/tests/fortran2003/test_format_specification_r1002.py @@ -38,6 +38,7 @@ """ import pytest +from fparser.two import utils from fparser.two.Fortran2003 import Format_Specification from fparser.two.utils import NoMatchError @@ -247,7 +248,7 @@ def test_syntaxerror(f2003_create): _ = Format_Specification(my_input) -def test_syntaxerror_c1002(f2003_create): +def test_syntaxerror_c1002(f2003_create, monkeypatch): """Test that we get an exception in situations where no comma is supplied and the C1002 constraints for optional commas do not apply. @@ -259,9 +260,25 @@ def test_syntaxerror_c1002(f2003_create): for my_input in ["(2P, 2/)", "('hello', 2/)", "(2E2.2, 3/)"]: ast = Format_Specification(my_input) # invalid syntax - for my_input in ["(2P 2/)", "(2P2/)", "('hello' 2/)", "('hello'2/)"]: + for my_input in ["(2P 2/)", "(2P2/)"]: with pytest.raises(NoMatchError): _ = Format_Specification(my_input) + # A missing comma after a character-string edit descriptor is + # accepted by the 'format-missing-comma' extension (enabled by + # default) ... + for my_input in ["('hello' 2/)", "('hello'2/)"]: + ast = Format_Specification(my_input) + assert str(ast) == "('hello', 2/)" + # ... and is invalid syntax without the extension. + with monkeypatch.context() as mpatch: + mpatch.setattr( + utils, + "_EXTENSIONS", + [ext for ext in utils.EXTENSIONS() if ext != "format-missing-comma"], + ) + for my_input in ["('hello' 2/)", "('hello'2/)"]: + with pytest.raises(NoMatchError): + _ = Format_Specification(my_input) # Comma is mandatory after a P descriptor if not one of ['F', 'E', # 'EN', 'ES', 'D', 'G'] or not a '/' or a ':' # Test valid syntax. @@ -285,7 +302,6 @@ def test_syntaxerror_c1002(f2003_create): _ = Format_Specification(my_input) # Comma is mandatory if C1002 is not relevant for my_input in [ - "('hello' 'hello')", "(2P 2P)", "(2P2P)", "(F2.2 F2.2)", @@ -299,3 +315,17 @@ def test_syntaxerror_c1002(f2003_create): ]: with pytest.raises(NoMatchError): _ = Format_Specification(my_input) + # A missing comma between two character-string edit descriptors is + # accepted by the 'format-missing-comma' extension (enabled by + # default) ... + ast = Format_Specification("('hello' 'hello')") + assert str(ast) == "('hello', 'hello')" + # ... and is invalid syntax without the extension. + with monkeypatch.context() as mpatch: + mpatch.setattr( + utils, + "_EXTENSIONS", + [ext for ext in utils.EXTENSIONS() if ext != "format-missing-comma"], + ) + with pytest.raises(NoMatchError): + _ = Format_Specification("('hello' 'hello')") diff --git a/src/fparser/two/utils.py b/src/fparser/two/utils.py index b7985a80..37a03b95 100644 --- a/src/fparser/two/utils.py +++ b/src/fparser/two/utils.py @@ -118,6 +118,13 @@ # With this extension, these statements will be allowed. _EXTENSIONS += ["extended-stop-args"] +# Many compilers (e.g. gfortran, ifort, ifx) accept a missing comma +# between a character-string edit descriptor and a neighbouring format +# item in a format specification, e.g. FORMAT('a' 1x,'b'), FORMAT(15x'a') +# or FORMAT('a' 'b'). This is supported by fparser if +# 'format-missing-comma' is specified in the EXTENSIONS list. +_EXTENSIONS += ["format-missing-comma"] + def EXTENSIONS(): """