diff --git a/CHANGELOG.md b/CHANGELOG.md index f8932671..dbcfce80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ Modifications by (in alphabetical order): * P. Vitt, University of Siegen, Germany * A. Voysey, UK Met Office +26/08/2026 PR #526 for #484. Adds a 'format-missing-comma' extension which + permits commas to be omitted between character-string edit + descriptors and neighbouring format items. + 24/08/2026 PR #525 for #524. Bug fix for Io_implied_Do rejecting a minimal- length io-implied-do. diff --git a/doc/source/fparser2.rst b/doc/source/fparser2.rst index 60aaf85f..573c3b05 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 5ed465a5..e703b06b 100644 --- a/src/fparser/two/Fortran2003.py +++ b/src/fparser/two/Fortran2003.py @@ -70,11 +70,12 @@ # Original author: Pearu Peterson # First version created: Oct 2006 +from __future__ import annotations import inspect import re import sys -from typing import Union +from typing import Optional, Union from fparser.common.splitline import string_replace_map from fparser.two import pattern_tools as pattern @@ -10078,13 +10079,54 @@ 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 = [] use_names = ["K", "W", "D", "E", "Format_Item", "R"] @staticmethod - def match(string): + def match( + string: str, + ) -> Optional[ + tuple[ + Union[Control_Edit_Desc, Format_Item], Union[Control_Edit_Desc, Format_Item] + ] + ]: + """Implements the matching for the C1002 Format Item constraint, + optionally relaxed by the 'format-missing-comma' extension. + + :param string: the string to check for conformance with a + C1002 format item constraint. + + :returns: `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. + + """ + 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: str, + ) -> Optional[ + tuple[ + Union[Control_Edit_Desc, Format_Item], Union[Control_Edit_Desc, Format_Item] + ] + ]: """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 @@ -10092,19 +10134,12 @@ def match(string): separately and match them in this class. As a result the generated class hierarchy is a little more complicated. - :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. + :param string: the string to check for conformance with a + C1002 format item constraint. - :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`) + :returns: `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. """ if not string: @@ -10186,6 +10221,60 @@ def match(string): Format_Item(option + repmap(right.lstrip())), ) + return None + + @staticmethod + def _extension_match(string: str) -> Optional[tuple[Format_Item, Format_Item]]: + """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. + + Quote/escape handling is delegated to string_replace_map() + (the same helper _standard_match() uses to locate the '/' and + ':' edit descriptors), rather than re-implementing it here: it + masks character literals - simple ones are left untouched and + complex ones (e.g. containing doubled quotes) are replaced + with a placeholder - so the masked line can never contain the + literal's own quote character before its closing quote. + + :param string: the string to check for conformance with the + 'format-missing-comma' extension. + + :returns: `None` if there is no match, otherwise a tuple of + size 2 containing two Format_Item classes. + + """ + if not string: + return None + strip_string = string.strip() + line, repmap = string_replace_map(strip_string) + if line and line[0] in "'\"": + # The item starts with a character literal, e.g. "'a' 1x". + quote = line[0] + end = line.find(quote, 1) + if end == -1: + return None + literal = repmap(line[: end + 1]) + rest = repmap(line[end + 1 :]).lstrip() + if not rest or rest.startswith(","): + # Nothing follows the literal, or standard syntax. + return None + return (Format_Item(literal), Format_Item(rest)) + indices = [line.find(quote) for quote in "'\"" if line.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(repmap(line[:index]).rstrip()), + Format_Item(repmap(line[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..37e78931 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,53 @@ 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", []) + 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. + + """ + 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..2ba42cd3 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,21 @@ 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", []) + 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 +298,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 +311,13 @@ 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", []) + with pytest.raises(NoMatchError): + _ = Format_Specification("('hello' 'hello')") diff --git a/src/fparser/two/utils.py b/src/fparser/two/utils.py index 0fdefa2a..79f0cd67 100644 --- a/src/fparser/two/utils.py +++ b/src/fparser/two/utils.py @@ -122,6 +122,13 @@ # with a directory, using e.g. INQUIRE (DIRECTORY=".", DIRSPEC=C_DIRSPEC, ...) _EXTENSIONS += ["inquire-directory"] +# 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(): """