Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 80 additions & 2 deletions capgen/ccpp_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@
# trailing ``&``" (safe to drop) from "real tokens past a ``&``"
# (leave alone so the parser surfaces a real error).
_IDENT_CHAR_RE = re.compile(r'[A-Za-z_0-9]')
_STRING_LIT_RE = re.compile(r"'[^']*'|\"[^\"]*\"")
_DECL_SEP_RE = re.compile(r'::')


class _ArgAttrs(NamedTuple):
Expand Down Expand Up @@ -498,6 +500,8 @@ def _next_starts_with_lead_cont(start_idx: int) -> bool:

result: List[str] = []
buf = ''
# (physical line number, text) for each piece in ``buf``
segs: List[Tuple[int, str]] = []
for i, stripped in enumerate(norm):
if buf and not stripped.strip():
# Mid-continuation, and this line is blank or a pure
Expand All @@ -512,7 +516,9 @@ def _next_starts_with_lead_cont(start_idx: int) -> bool:
stripped = _LEAD_CONT_RE.sub('', stripped, count=1)
has_trailing = bool(_CONT_RE.search(stripped))
if has_trailing:
buf += _CONT_RE.sub('', stripped)
piece = _CONT_RE.sub('', stripped)
buf += piece
segs.append((i + 1, piece))
continue
# No trailing ``&`` — but a fixed-form continuation may still
# be implied by the next line's column-6 ``&``. If so, keep
Expand All @@ -523,15 +529,87 @@ def _next_starts_with_lead_cont(start_idx: int) -> bool:
if _next_starts_with_lead_cont(i):
stripped = _repair_decorated_trailing_amp(stripped, filename, i + 1)
buf += stripped
segs.append((i + 1, stripped))
continue
buf += stripped
segs.append((i + 1, stripped))
_check_statement_run_on(segs, filename)
result.append(buf)
buf = ''
segs = []
if buf:
result.append(buf)
raise CCPPError(
"{}:{}: unterminated Fortran line continuation — the file ends "
"while the statement started here is still continued by a "
"trailing '&'".format(filename or '<unknown>', segs[0][0])
)
return result


def _decl_separator_count(line: str) -> int:
"""Count ``::`` in *line* that separate a declaration from its entities.

String literals and bracketed array constructors are blanked first, so
the typed-constructor form ``[character(len=128) :: 'a', 'b']`` — the
only legitimate second ``::`` is not counted.

>>> _decl_separator_count('real :: a, b')
1
>>> _decl_separator_count("character(*), parameter :: n(*) = [character(len=4) :: 'ab']")
1
>>> _decl_separator_count('real :: a integer :: b')
2
"""
blanked = _STRING_LIT_RE.sub('', line)
out, depth = [], 0
for ch in blanked:
if ch == '[':
depth += 1
elif ch == ']':
depth = max(0, depth - 1)
out.append(' ' if depth else ch)
return len(_DECL_SEP_RE.findall(''.join(out)))


def _check_statement_run_on(
segs: List[Tuple[int, str]],
filename: Optional[str],
) -> None:
"""Reject a logical line that joined two or more declarations.

A Fortran declaration carries exactly one ``::``. Two means a
continuation ran past the end of its statement — almost always a stray
trailing ``&`` — which silently attributes the second declaration's
entities to the first one's type and attributes.
"""
joined = ''.join(text for _, text in segs)
if joined.count('::') < 2 or _decl_separator_count(joined) < 2:
return
# Walk the pieces to find the one that opened the second declaration;
# the stray ``&`` is on the preceding piece.
running = ''
for idx, (line_no, text) in enumerate(segs):
running += text
if _decl_separator_count(running) < 2:
continue
if idx == 0:
# No continuation involved — the physical line itself carries
# two ``::`` (e.g. ``class(t) :: intent(in) :: this``).
raise CCPPError(
"{}:{}: malformed Fortran declaration — two '::' in one "
"statement".format(filename or '<unknown>', line_no)
)
raise CCPPError(
"{}:{}: Fortran statement continues past its end — the "
"declaration starting at line {} was joined with the one "
"starting here. Check for a stray trailing '&' on line "
"{}".format(
filename or '<unknown>', line_no, segs[0][0],
segs[idx - 1][0],
)
)


def _repair_decorated_trailing_amp(
line: str,
filename: Optional[str],
Expand Down
69 changes: 69 additions & 0 deletions unit-tests/test_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,75 @@ def emit(self, record):
self.assertIn(':2:', msg) # decoration is on line 2 of src_lines



class TestStatementRunOn(unittest.TestCase):
"""A continuation that runs past the end of its statement is a parse
error naming the stray ``&``, not a silent mis-parse that resurfaces
later as attribute mismatches on unrelated arguments (issue #788)."""

# The shape reported in #788: the last continuation line of an
# ``optional`` declaration gains a stray trailing ``&``, so the next
# declaration's entities inherit its type, intent and optionality.
_SRC = (
' subroutine s(edmf_a, t3d, qgrs_snow)\n'
' real, dimension(:,:), intent(inout), optional :: &\n'
' & edmf_a{amp}\n'
' real, dimension(:,:), intent(in) :: &\n'
' & t3d, qgrs_snow\n'
' end subroutine s\n'
)

def test_clean_source_parses(self):
subs = _parse_subroutines(self._SRC.format(amp=''), 'w.F90')
self.assertEqual(subs['s'].optional, {'edmf_a'})

def test_stray_trailing_amp_raises_naming_the_line(self):
src = self._SRC.format(amp=' &')
with self.assertRaises(CCPPError) as ctx:
_parse_subroutines(src, 'w.F90')
msg = str(ctx.exception)
self.assertIn('w.F90:4', msg) # second declaration starts here
self.assertIn('line 2', msg) # first declaration started here
self.assertIn("stray trailing '&' on line 3", msg)

def test_typed_array_constructor_is_not_a_run_on(self):
"""``[character(len=*) :: ...]`` legitimately carries a second
``::`` — the only such construct in the CCPP physics corpus."""
src = (
' subroutine s(a)\n'
" character(len=*), parameter :: names(*) = &\n"
" & [character(len=8) :: 'alpha', 'beta']\n"
' real :: a\n'
' end subroutine s\n'
)
subs = _parse_subroutines(src, 'w.F90')
self.assertIn('s', subs)

def test_two_separators_on_one_line_is_reported_as_malformed(self):
"""No continuation involved — do not blame a stray ``&``."""
src = (
' subroutine s(this)\n'
' class(t) :: intent(in) :: this\n'
' end subroutine s\n'
)
with self.assertRaises(CCPPError) as ctx:
_parse_subroutines(src, 'w.F90')
msg = str(ctx.exception)
self.assertIn('malformed', msg)
self.assertIn('w.F90:2', msg)
self.assertNotIn('&', msg)

def test_unterminated_continuation_at_eof_raises(self):
src = (
' subroutine s(a)\n'
' real :: a &\n'
)
with self.assertRaises(CCPPError) as ctx:
_parse_subroutines(src, 'w.F90')
self.assertIn('unterminated', str(ctx.exception))
self.assertIn('w.F90:2', str(ctx.exception))


class TestParseSubroutines(unittest.TestCase):

def test_simple_subroutine(self):
Expand Down
Loading