diff --git a/CMakeLists.txt b/CMakeLists.txt index fbe59734..50270f93 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,7 +25,7 @@ cmake_minimum_required(VERSION 3.24) project (PFUNIT - VERSION 4.17.0 + VERSION 4.17.1 LANGUAGES Fortran C) cmake_policy(SET CMP0077 NEW) diff --git a/ChangeLog.md b/ChangeLog.md index e1c3e851..60f8e1d3 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -5,6 +5,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [4.17.1] - 2026-04-09 + +### Fixed + +- Ordinary Fortran `&` continuation lines are now passed through unchanged (issue #537) + - Previously, the preprocessor incorrectly joined all `&`-continued lines, breaking + multi-line array constructors and embedding Fortran comments mid-statement + - Continuation joining now only applies to pFUnit `@`-directive lines + ## [4.17.0] - 2026-04-08 ### Changed diff --git a/bin/funit/pFUnitParser.py b/bin/funit/pFUnitParser.py index a94b3cd6..7338662f 100644 --- a/bin/funit/pFUnitParser.py +++ b/bin/funit/pFUnitParser.py @@ -835,6 +835,9 @@ def getBaseName(fileName): self.userTestMethods = [] # each entry is a dictionary + self.looking_for_test_name = False + self.current_method = {} + self.wrapModuleName = "Wrap" + getBaseName(inputFileName) self.currentLineNumber = 0 @@ -864,8 +867,47 @@ def getBaseName(fileName): def commentLine(self, line): self.outputFile.write(re.sub("@", "!@", line)) + def isDirectiveLine(self, line): + """Return True if line begins a pFUnit @-directive (ignoring leading whitespace).""" + return bool(re.match(r"\s*@", line)) + + def joinContinuationLines(self, line): + """Join Fortran & continuation lines for a single @-directive. + + Reads additional physical lines from the input file as long as the + current logical line ends with '&' (before any inline comment). + Returns the complete logical line with a single trailing newline. + Only called for lines that start a pFUnit @-directive. + """ + logical = line.rstrip("\r\n") + while True: + stripped = logical.rstrip() + amp_pos = stripped.rfind("&") + comment_pos = stripped.find("!") + is_continued = ( + amp_pos != -1 + and (comment_pos == -1 or amp_pos < comment_pos) + and amp_pos == len(stripped) - 1 + ) + if not is_continued: + break + prefix = logical[:amp_pos] + self.currentLineNumber += 1 + next_raw = self.inputFile.readline() + if not next_raw: + break + tail = next_raw.lstrip() + if tail.startswith("&"): + tail = tail[1:] + logical = prefix + tail.rstrip("\r\n") + return logical + "\n" + def run(self): def parse(line): + # Join & continuation lines only for @-directive lines so that + # ordinary Fortran code is passed through unchanged (issue #537). + if self.isDirectiveLine(line): + line = self.joinContinuationLines(line) for action in self.actions: if action.apply(line): return @@ -903,9 +945,6 @@ def isComment(self, line): return re.match(r"\s*(!.*|)$", line) def nextLine(self): - # Loop until we get a non-comment, non-blank (possibly continued) line - logical_line = "" - start_line_number = None while True: self.currentLineNumber += 1 line = self.inputFile.readline() @@ -913,48 +952,9 @@ def nextLine(self): break if self.isComment(line): self.outputFile.write(line) - continue else: - # Start tracking line number for multi-line error reporting - if start_line_number is None: - start_line_number = self.currentLineNumber - - candidate_line = line.rstrip("\r\n") - # New logic for multi-line Fortran with ampersand continuation: - while True: - # Fortran-style continuation, check trailing '&' before any comment - stripped = candidate_line.rstrip() - comment_pos = stripped.find("!") - amp_pos = stripped.rfind("&") - is_continued = ( - amp_pos != -1 - and (comment_pos == -1 or amp_pos < comment_pos) - and amp_pos == len(stripped) - 1 - ) - if is_continued: - # Keep everything before the '&', preserving whitespace - prefix = candidate_line[:amp_pos] - logical_line += prefix - # Get the next physical line - self.currentLineNumber += 1 - continuation = self.inputFile.readline() - if not continuation: - break - # Remove leading whitespace up to and including a leading '&' - # but preserve any whitespace after the '&' - tail = continuation.lstrip() - if tail.startswith("&"): - tail = tail[1:] # Remove only the '&', keep spaces after it - candidate_line = tail.rstrip("\r\n") - continue - else: - logical_line += candidate_line - break break - # Add newline back since readline() includes it and callers expect it - if logical_line: - logical_line += "\n" - return logical_line + return line def printHeader(self): self.outputFile.write("\n") diff --git a/bin/funit/tests/test_parser_continuation.py b/bin/funit/tests/test_parser_continuation.py index 3d16b9e9..905e9ee0 100644 --- a/bin/funit/tests/test_parser_continuation.py +++ b/bin/funit/tests/test_parser_continuation.py @@ -1,6 +1,12 @@ -"""Tests for Fortran line continuation handling in parser.""" +"""Tests for Fortran line continuation handling in parser (issue #537). + +Only @-directive lines should have & continuations joined. +Ordinary Fortran continuation lines must be passed through unchanged. +""" import io +import os +import tempfile import unittest import sys @@ -9,107 +15,173 @@ from funit.tests.parser_test_utils import MockWriter -class TestParserContinuation(unittest.TestCase): - def test_fortran_continuation_in_macro(self): - """ - Check that macro line continued with '&' is joined properly. - """ - # Simulated input lines as if read from a Fortran source file - logical_macro = '@test(timeout = 3.0, foo="long string" &\n' - logical_macro2 = " & , bar=99)\n" - input_lines = [logical_macro, logical_macro2] - p = Parser.__new__(Parser) # bypass __init__ - test_input = "".join(input_lines) - p.inputFile = io.StringIO(test_input) - p.currentLineNumber = 0 - p.outputFile = MockWriter(p) - # Patch isComment to always return False for every input line here - p.isComment = lambda line: False - - full_line = p.nextLine() - expected = '@test(timeout = 3.0, foo="long string" , bar=99)\n' - self.assertEqual(full_line, expected) - - def test_assert_equal_multiline(self): - """ - Check that an @assertEqual statement split over multiple lines - (via Fortran continuation & syntax) is joined correctly. - """ - input_lines = [ - "@assertEqual(lhs_value, &\n", - " &rhs_function(arg1, arg2), &\n", - ' &"Comparison failed message")\n', - ] - combined = "".join(input_lines) - p = Parser.__new__(Parser) - p.inputFile = io.StringIO(combined) - p.currentLineNumber = 0 - p.outputFile = MockWriter(p) - p.isComment = lambda line: False - full_line = p.nextLine() - expected = '@assertEqual(lhs_value, rhs_function(arg1, arg2), "Comparison failed message")\n' - self.assertEqual(full_line, expected) - - def test_lines_without_continuation_not_collapsed(self): - """ - Check that separate lines without '&' continuation are returned - one at a time, not collapsed together. - """ - input_lines = [ - "module Test_Foo\n", - " use funit\n", - " implicit none\n", - "contains\n", - ] - combined = "".join(input_lines) - p = Parser.__new__(Parser) - p.inputFile = io.StringIO(combined) - p.currentLineNumber = 0 - p.outputFile = MockWriter(p) - p.isComment = lambda line: False - - # Each call to nextLine() should return exactly one line - line1 = p.nextLine() - self.assertEqual(line1, "module Test_Foo\n") - - line2 = p.nextLine() - self.assertEqual(line2, " use funit\n") - - line3 = p.nextLine() - self.assertEqual(line3, " implicit none\n") - - line4 = p.nextLine() - self.assertEqual(line4, "contains\n") - - def test_test_directive_not_collapsed_with_next_line(self): - """ - Check that @test directive is NOT collapsed with the following - subroutine line when there's no '&' continuation. - """ - input_lines = [ - "@test\n", - "subroutine test_foo()\n", - " call do_something()\n", - "end subroutine test_foo\n", - ] - combined = "".join(input_lines) - p = Parser.__new__(Parser) - p.inputFile = io.StringIO(combined) - p.currentLineNumber = 0 - p.outputFile = MockWriter(p) - p.isComment = lambda line: False - - line1 = p.nextLine() - self.assertEqual(line1, "@test\n") - - line2 = p.nextLine() - self.assertEqual(line2, "subroutine test_foo()\n") - - line3 = p.nextLine() - self.assertEqual(line3, " call do_something()\n") - - line4 = p.nextLine() - self.assertEqual(line4, "end subroutine test_foo\n") +def _make_parser_with_input(text): + """Return a Parser-like object reading from a StringIO, with a MockWriter.""" + p = Parser.__new__(Parser) + p.inputFile = io.StringIO(text) + p.currentLineNumber = 0 + p.outLines = [] + p.outputFile = MockWriter(p) + p.isComment = lambda line: ( + False + ) # treat all lines as non-comment for nextLine tests + return p + + +def _run_full_parser(source, module_name): + """Write *source* to a named temp file, run the full Parser, return output text.""" + tmpdir = tempfile.mkdtemp() + in_name = os.path.join(tmpdir, module_name + ".pf") + out_name = in_name + ".F90" + try: + with open(in_name, "w") as f: + f.write(source) + p = Parser(in_name, out_name) + p.run() + p.final() + with open(out_name) as f: + return f.read() + finally: + if os.path.exists(in_name): + os.unlink(in_name) + if os.path.exists(out_name): + os.unlink(out_name) + os.rmdir(tmpdir) + + +class TestNextLinePassthrough(unittest.TestCase): + """nextLine() must return one physical line at a time without joining.""" + + def test_ordinary_lines_not_joined(self): + """Separate lines without '&' are returned one at a time.""" + p = _make_parser_with_input( + "module Test_Foo\n use funit\n implicit none\ncontains\n" + ) + self.assertEqual(p.nextLine(), "module Test_Foo\n") + self.assertEqual(p.nextLine(), " use funit\n") + self.assertEqual(p.nextLine(), " implicit none\n") + self.assertEqual(p.nextLine(), "contains\n") + + def test_continuation_line_not_joined_by_nextLine(self): + """nextLine() must NOT join ordinary Fortran & continuation lines.""" + p = _make_parser_with_input( + " vals = [ &\n 1, &\n 2 &\n ]\n" + ) + self.assertEqual(p.nextLine(), " vals = [ &\n") + self.assertEqual(p.nextLine(), " 1, &\n") + self.assertEqual(p.nextLine(), " 2 &\n") + self.assertEqual(p.nextLine(), " ]\n") + + def test_directive_not_joined_with_next_line_without_ampersand(self): + """@test (no &) must NOT be joined with the following subroutine line.""" + p = _make_parser_with_input("@test\nsubroutine test_foo()\n") + self.assertEqual(p.nextLine(), "@test\n") + self.assertEqual(p.nextLine(), "subroutine test_foo()\n") + + +class TestJoinContinuationLines(unittest.TestCase): + """joinContinuationLines() must join & continuations for @-directive lines.""" + + def test_single_line_directive_unchanged(self): + """A directive with no continuation is returned as-is.""" + p = _make_parser_with_input("") # no more lines needed + result = p.joinContinuationLines("@assertEqual(1, 2)\n") + self.assertEqual(result, "@assertEqual(1, 2)\n") + + def test_two_line_directive_joined(self): + """A directive split across two lines with & is joined correctly.""" + p = _make_parser_with_input(" 1)\n") + result = p.joinContinuationLines("@assertEqual(1, &\n") + # Leading whitespace on the continuation line is stripped after the optional & + self.assertEqual(result, "@assertEqual(1, 1)\n") + + def test_three_line_directive_joined(self): + """A directive split across three lines is fully joined.""" + p = _make_parser_with_input( + ' &rhs_function(arg1, arg2), &\n &"Comparison failed")\n' + ) + result = p.joinContinuationLines("@assertEqual(lhs_value, &\n") + self.assertEqual( + result, + '@assertEqual(lhs_value, rhs_function(arg1, arg2), "Comparison failed")\n', + ) + + def test_leading_ampersand_on_continuation_stripped(self): + """A leading & on a continuation line is stripped.""" + p = _make_parser_with_input(" &, bar=99)\n") + result = p.joinContinuationLines('@test(timeout=3.0, foo="long string" &\n') + self.assertEqual(result, '@test(timeout=3.0, foo="long string" , bar=99)\n') + + +class TestFullParserContinuationPassthrough(unittest.TestCase): + """End-to-end tests: ordinary Fortran continuation must survive the full parser.""" + + def test_multiline_array_constructor_preserved(self): + """Multi-line array constructors must NOT be joined into a single line.""" + source = """\ +module test_continuation + use funit + implicit none +contains + @test + subroutine check() + integer :: vals(3) + vals = [ & + 1, & + 2, & + 3 & + ] + @assertEqual(3, size(vals)) + end subroutine check +end module test_continuation +""" + output = _run_full_parser(source, "test_continuation") + self.assertIn(" vals = [ &\n", output) + self.assertIn(" 1, &\n", output) + self.assertIn(" 2, &\n", output) + self.assertIn(" 3 &\n", output) + self.assertIn(" ]\n", output) + + def test_commented_continuation_line_not_embedded(self): + """A commented-out element in a continuation block must stay on its own line.""" + source = """\ +module test_comment_continuation + use funit + implicit none +contains + @test + subroutine check() + integer :: vals(2) + vals = [ & + 1, & + ! 99, & + 2 & + ] + @assertEqual(2, size(vals)) + end subroutine check +end module test_comment_continuation +""" + output = _run_full_parser(source, "test_comment_continuation") + self.assertIn(" ! 99, &\n", output) + self.assertIn(" 1, &\n", output) + self.assertIn(" 2 &\n", output) + + def test_directive_continuation_still_joined(self): + """@assert directives spanning multiple lines via & must still be processed.""" + source = """\ +module test_directive_continuation + use funit + implicit none +contains + @test + subroutine check() + @assertEqual(1, & + 1) + end subroutine check +end module test_directive_continuation +""" + output = _run_full_parser(source, "test_directive_continuation") + self.assertIn("call assertEqual(1,", output) if __name__ == "__main__":