diff --git a/CHANGELOG.md b/CHANGELOG.md index 60547c811..7cfb8a43e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ Modifications by (in alphabetical order): * P. Vitt, University of Siegen, Germany * A. Voysey, UK Met Office +31/07/2025 PR #474 for #473. Syntax error when parsing a file containing purely comments if + ignore_comments=True + 21/07/2025 PR #462 for #457. Fix bug with backslash in strings. 21/07/2025 PR #467 for #466. Fix "==" when matching implicit loops in array constructors. diff --git a/src/fparser/common/readfortran.py b/src/fparser/common/readfortran.py index c5f96dd4b..9fd0d7a6f 100644 --- a/src/fparser/common/readfortran.py +++ b/src/fparser/common/readfortran.py @@ -141,7 +141,9 @@ import re import sys import traceback +from typing import Optional, Tuple from io import StringIO + import fparser.common.sourceinfo from fparser.common.splitline import String, string_replace_map, splitquote @@ -1186,20 +1188,26 @@ def handle_cf2py_start(self, line): return newline return line - def handle_inline_comment(self, line, lineno, quotechar=None): + def handle_inline_comment( + self, + line: str, + lineno: int, + quotechar: Optional[str] = None, + buffer_comments_to_fifo: bool = True, + ) -> Tuple[str, str, bool]: """ Any in-line comment is extracted from the line. If - keep_inline_comments==True then the extracted comments are put back - into the fifo sequence where they will subsequently be processed as + buffer_comments_to_fifo==True (the default) then the extracted comments are + put back into the fifo sequence where they will subsequently be processed as a comment line. - :param str line: line of code from which to remove in-line comment - :param int lineno: line-no. in orig. file + :param line: line of code from which to remove in-line comment + :param lineno: line-no. in orig. file :param quotechar: String to use as character-string delimiter - :type quotechar: {None, str} + :param buffer_comments_to_fifo: whether or not to put any comments back into + the fifo buffer for future processing. :return: line_with_no_comments, quotechar, had_comment - :rtype: 3-tuple of str, str, bool """ had_comment = False @@ -1212,8 +1220,13 @@ def handle_inline_comment(self, line, lineno, quotechar=None): # There's no comment on this line return line, quotechar, had_comment + if buffer_comments_to_fifo: + put_item = self.fifo_item.append + else: + # We're not putting any Comments into the FIFO buffer. + put_item = lambda x: None + idx = line.find("!") - put_item = self.fifo_item.append if quotechar is None and idx != -1: # first try a quick method: newline = line[:idx] @@ -1245,7 +1258,9 @@ def handle_inline_comment(self, line, lineno, quotechar=None): # go to next iteration: newline = "".join(noncomment_items) + commentline[5:] self.f2py_comment_lines.append(lineno) - return self.handle_inline_comment(newline, lineno, quotechar) + return self.handle_inline_comment( + newline, lineno, quotechar, buffer_comments_to_fifo + ) put_item(self.comment_item(commentline, lineno, lineno)) had_comment = True return "".join(noncomment_items), newquotechar, had_comment @@ -1627,6 +1642,25 @@ def get_source_item(self): # A blank line is represented as an empty comment return Comment("", (startlineno, endlineno), self) + def is_comment_line(self, line: str) -> bool: + """ + Utility that examines the supplied line of code and determines whether this + reader instance would identify it as a comment. + + :param line: the line of Fortran to examine. + + :returns: whether or not the supplied line is considered to be a comment. + + """ + if self._format.is_fixed: + return _is_fix_comment( + line, self._format.is_strict, self._format.f2py_enabled + ) + new_line, _, had_comment = self.handle_inline_comment( + line, 0, buffer_comments_to_fifo=False + ) + return not new_line.strip() and had_comment + class FortranFileReader(FortranReaderBase): """ diff --git a/src/fparser/common/tests/test_readfortran.py b/src/fparser/common/tests/test_readfortran.py index 411497bbd..1951cd46a 100644 --- a/src/fparser/common/tests/test_readfortran.py +++ b/src/fparser/common/tests/test_readfortran.py @@ -263,6 +263,25 @@ def test_base_handle_multilines(log): assert result == expected +def test_fortranreaderbase_is_comment_line(): + """ + Tests for the is_comment_line() utility method. + """ + reader = FortranStringReader(" ") + # Make the reader free-format. + reader.set_format(FortranFormat(True, True)) + assert not reader.is_comment_line(" ") + assert reader.is_comment_line("!") + assert reader.is_comment_line("! ") + assert not reader.is_comment_line("call a_func()") + # Make the reader fixed-format. + reader.set_format(FortranFormat(False, True)) + assert not reader.is_comment_line(" ") + assert reader.is_comment_line("! a comment") + assert reader.is_comment_line("c a comment") + assert reader.is_comment_line("c ") + + def test_base_handle_quoted_backslashes(log): """ Test that the reader isn't tripped-up when a string contains a backslash. diff --git a/src/fparser/two/Fortran2003.py b/src/fparser/two/Fortran2003.py index 93c919cc5..82268463a 100644 --- a/src/fparser/two/Fortran2003.py +++ b/src/fparser/two/Fortran2003.py @@ -315,7 +315,9 @@ def match(reader): try: while True: obj = Program_Unit(reader) - content.append(obj) + if obj: + # obj could be None if there are only Comments + content.append(obj) add_comments_includes_directives(content, reader) # cause a StopIteration exception if there are no more lines next_line = reader.next() diff --git a/src/fparser/two/tests/fortran2003/test_program_r201.py b/src/fparser/two/tests/fortran2003/test_program_r201.py index aa0f18bf7..ded1ed6b2 100644 --- a/src/fparser/two/tests/fortran2003/test_program_r201.py +++ b/src/fparser/two/tests/fortran2003/test_program_r201.py @@ -58,15 +58,20 @@ def test_empty_input(f2003_create): assert str(ast) == "" -def test_only_comments(f2003_create): +@pytest.mark.parametrize("ignore_comments", [True, False]) +def test_only_comments(f2003_create, ignore_comments): """Test that a file containing only comments can be parsed - successfully + successfully, irrespective of whether or not we are ignoring + comments. """ code = "! comment1\n! comment2" - reader = get_reader(code, ignore_comments=False) + reader = get_reader(code, ignore_comments=ignore_comments) ast = Program(reader) - assert code in str(ast) + if ignore_comments: + assert str(ast) == "" + else: + assert code in str(ast) # Test single program units @@ -262,6 +267,16 @@ def test_missing_prog(f2003_create): ) ast = Program(reader) assert "END" in str(ast) + reader = get_reader( + """\ + ! This is a program without a program declaration + end + """, + isfree=True, + ignore_comments=False, + ) + ast = Program(reader) + assert "END" in str(ast) @pytest.mark.xfail(reason="Only the main program is output") diff --git a/src/fparser/two/tests/test_utils.py b/src/fparser/two/tests/test_utils.py index 2803daf17..fd05a0d26 100644 --- a/src/fparser/two/tests/test_utils.py +++ b/src/fparser/two/tests/test_utils.py @@ -141,3 +141,26 @@ def test_endstmtbase_match(): require_stmt_type=True, ) assert result == ("SUBROUTINE", Fortran2003.Name("sub")) + + +@pytest.mark.parametrize("ignore_comments", [True, False]) +def test_base_all_comments(ignore_comments): + """ + Check that supplying a reader with text that consists purely of comments does + not result in a syntax error from the Base class. + + """ + # Check with free-format + reader = get_reader( + "! just a comment\n! and another", isfree=True, ignore_comments=ignore_comments + ) + result = utils.Base(reader) + assert result is None + # Check for fixed format + reader = get_reader( + "c just a comment\n! and another", + isfree=False, + ignore_comments=ignore_comments, + ) + result = utils.Base(reader) + assert result is None diff --git a/src/fparser/two/utils.py b/src/fparser/two/utils.py index d0ef35081..3c562e5c1 100644 --- a/src/fparser/two/utils.py +++ b/src/fparser/two/utils.py @@ -485,20 +485,13 @@ def __new__(cls, string, parent_cls=None, _deepcopy=False): raise AssertionError(repr(result)) # If we get to here then we've failed to match the current line if isinstance(string, FortranReaderBase): - content = False - for index in range(string.linecount): - # Check all lines up to this one for content. We - # should be able to only check the current line but - # but as the line number returned is not always - # correct (due to coding errors) we cannot assume the - # line pointed to is the line where the error actually - # happened. - if string.source_lines[index].strip(): - content = True - break - if not content: - # There are no lines in the input or all lines up to - # this one are empty or contain only white space. This + freader: FortranReaderBase = string + if not freader.source_lines or all( + (line.strip() == "" or freader.is_comment_line(line)) + for line in freader.source_lines + ): + # There are no lines in the input or all lines up to this one + # are empty, comments or contain only white space. This # is typically accepted by fortran compilers so we # follow their lead and do not raise an exception. return @@ -908,6 +901,8 @@ def tofortran(self, tab="", isfix=None): :rtype: str """ mylist = [] + if not self.content: + return "" start = self.content[0] end = self.content[-1] extra_tab = ""