Skip to content

Commit 0ffa561

Browse files
authored
Merge pull request #35 from PyNumLab/errors
raise errors if it not part of the language
2 parents b8947d2 + a3920bf commit 0ffa561

11 files changed

Lines changed: 389 additions & 16 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ The C frontend is currently parse-only. It supports:
6969
- Raw mutually exclusive function alternatives preserved for later semantic
7070
selection rather than collapsed into one signature.
7171

72-
C semantic IR conversion, C `.pyi` generation, and C wrap-readiness are still
73-
intentionally disabled until the C semantic layer is implemented.
72+
The supported C subset continues through semantic IR conversion, `.pyi`
73+
generation, and wrap-readiness.
7474

7575
## Public APIs
7676

@@ -112,7 +112,9 @@ path when `--language` is omitted. C source/header files require explicit
112112
`--language fortran` or `--language c`. C parsing, semantic IR, `.pyi`
113113
generation, and wrap-readiness are available in explicit C mode. Selecting a
114114
frontend that conflicts with a recognized C or Fortran source suffix is an
115-
error.
115+
error. Once selected, a frontend also rejects unmistakable declarations or
116+
program-unit syntax that are not from the selected language outside ignored execution/function
117+
bodies, rather than silently dropping them.
116118

117119
For parse output, `--show-vars` expands scope-level variables that are normally
118120
summarized as `vars=N`. Use `--print-limit N` to keep large repeated sections

‎c_parser/parser.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646

4747
from .lexer import (
4848
CTopLevelSegment,
49+
lex_c_source,
4950
line_mappings_for_source,
5051
split_top_level_c_source,
5152
strip_c_comments,
@@ -575,6 +576,36 @@ def _source_location(self, segment: CTopLevelSegment) -> CSourceLocation:
575576
"""Return the original start location for a top-level segment."""
576577
return self._source_location_at(segment, 0)
577578

579+
@staticmethod
580+
def _could_start_c_external_declaration(text: str) -> bool:
581+
stripped = text.lstrip()
582+
return bool(stripped) and (stripped[0].isalpha() or stripped[0] == "_")
583+
584+
@staticmethod
585+
def _raise_for_invalid_top_level_syntax(segment: CTopLevelSegment) -> None:
586+
text = segment.text.strip()
587+
if not text or _looks_like_cxx_declaration(text):
588+
return
589+
tokens = lex_c_source(text)
590+
has_scope_operator = any(
591+
left.text == ":" and right.text == ":"
592+
for left, right in zip(tokens, tokens[1:])
593+
)
594+
if (
595+
segment.terminator != "eof"
596+
and not has_scope_operator
597+
and CParser._could_start_c_external_declaration(text)
598+
):
599+
return
600+
raise CParseError(
601+
f"Invalid C syntax at top level: {text}",
602+
filename=segment.filename,
603+
line_number=segment.original_start_line,
604+
column=segment.original_start_column,
605+
source_line=segment.original_source_line,
606+
code="CPARSE_INVALID_SYNTAX",
607+
)
608+
578609
def _macro_dependencies(
579610
self,
580611
source: str,
@@ -2475,6 +2506,7 @@ def _parse_translation_unit(
24752506
filename=filename,
24762507
use_linemarkers=use_linemarkers,
24772508
):
2509+
self._raise_for_invalid_top_level_syntax(segment)
24782510
macro_dependency = self._segment_macro_dependency(
24792511
segment,
24802512
function_like_names,

‎docs/c_parser/c_parser_cli_workflow.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ recognizable Fortran files and `.pyi` readiness inputs, but a `.c`, `.h`, or
4444
`.i` path fails with guidance to pass `--language c`; directory and
4545
unknown-suffix source inputs require an explicit frontend selection. A known C
4646
path explicitly passed with `--language fortran` is rejected before parsing,
47-
so it cannot silently produce an empty Fortran interface.
47+
so it cannot silently produce an empty Fortran interface. The parser also
48+
rejects foreign syntax found in C input, and the Fortran parser
49+
rejects unsupported non-Fortran syntax outside execution
50+
regions that are intentionally not modeled.
4851

4952
The C parser output differs from Fortran parser output by using C-specific
5053
top-level sections: `functions`, `structs`, `unions`, `enums`, `typedefs`,

‎docs/c_parser/c_parser_reference.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,8 @@ x2py path/to/api.h --language c --parse --out report.json
490490
There is no separate `--parse-c` alias: `--language c --parse` is the shared
491491
language-selection form. Auto-detection remains deferred: a `.c`, `.h`, or
492492
`.i` input without `--language c` exits with language-selection guidance.
493+
Explicit C input containing unmistakable non-C syntax raises a fatal parser diagnostic instead of emitting a partial C
494+
interface.
493495

494496
## Current JSON Output
495497

‎docs/fortran/fortran_parser.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,10 @@ python -m x2py path/to/fortran_src --language fortran --parse
210210
Fortran directories are recursively scanned for `.f`, `.for`, `.ftn`, `.f90`,
211211
`.f95`, `.f03`, `.f08`.
212212

213+
The Fortran frontend rejects unsupported non-Fortran syntax before
214+
wrapper-focused parsing when it appears outside executable procedure/program
215+
bodies, which are intentionally not represented in the extracted interface.
216+
213217
The human-readable parse tree keeps scope variables compact by default as
214218
`vars=N`. Add `--show-vars` to print the variables, or `--print-limit N` to
215219
print only the first `N` items in each repeated section.

‎fortran_parser/parser.py‎

Lines changed: 132 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,7 @@ def _helper_prepare_source_units(
900900
self._helper_validate_unit_headers(lines, filename)
901901
root_scope = _ParserScope(kind="file", name=None)
902902
units = self._helper_slice_child_units(lines, parent_scope=root_scope, filename=filename)
903+
self._helper_validate_file_scope_unparsed_lines(lines, filename)
903904
self._helper_validate_sibling_units(units, parent_scope=root_scope, filename=filename)
904905
return lines, root_scope, units
905906

@@ -1136,6 +1137,86 @@ def _helper_validate_unit_headers(self, lines: _PreprocessedLines, filename: str
11361137
source_line=source_line,
11371138
)
11381139

1140+
def _helper_validate_file_scope_unparsed_lines(self, lines: _PreprocessedLines, filename: str | None) -> None:
1141+
"""Reject any non-Fortran syntax outside recognized unit bodies.
1142+
1143+
This guard is intentionally language-agnostic: lines that are neither
1144+
valid file-scope Fortran constructs nor part of a recognized unit are
1145+
rejected via the generic invalid-syntax diagnostic path.
1146+
"""
1147+
index = 0
1148+
pp_condition_stack: list[tuple[int, int]] = []
1149+
pp_active_stack: list[bool] = []
1150+
pp_group_counter = 0
1151+
while index < len(lines):
1152+
line, lineno, source_line = lines[index]
1153+
stripped = line.strip()
1154+
if not stripped:
1155+
index += 1
1156+
continue
1157+
handled_pp, pp_group_counter = self._handle_procedure_preprocessor_line(
1158+
stripped,
1159+
macro_selection_enabled=False,
1160+
macro_names=set(),
1161+
pp_condition_stack=pp_condition_stack,
1162+
pp_active_stack=pp_active_stack,
1163+
pp_group_counter=pp_group_counter,
1164+
)
1165+
if handled_pp:
1166+
index += 1
1167+
continue
1168+
start = self._helper_classify_unit_start(stripped)
1169+
if start is not None:
1170+
end_index = self._helper_find_unit_end(lines, index, start[0], filename=filename)
1171+
if end_index is not None:
1172+
index = end_index + 1
1173+
continue
1174+
if self._is_allowed_unparsed_file_scope_line(stripped):
1175+
index += 1
1176+
continue
1177+
if self._is_executable_statement_start(stripped):
1178+
index += 1
1179+
continue
1180+
self._raise_invalid_fortran_syntax_line(
1181+
stripped,
1182+
context="file scope",
1183+
filename=filename,
1184+
lineno=lineno,
1185+
source_line=source_line,
1186+
)
1187+
1188+
@staticmethod
1189+
def _is_allowed_unparsed_file_scope_line(line: str) -> bool:
1190+
stripped = line.strip()
1191+
lowered = stripped.lower()
1192+
return (
1193+
stripped.startswith("#")
1194+
or lowered == "contains"
1195+
or lowered.startswith("end ")
1196+
or lowered.startswith(("endif", "enddo"))
1197+
or lowered == "else"
1198+
or lowered.startswith(("elseif", "else if"))
1199+
or FortranParser._is_ignored_spec_statement(stripped)
1200+
or FortranParser._is_openmp_directive(stripped)
1201+
)
1202+
1203+
@staticmethod
1204+
def _raise_invalid_fortran_syntax_line(
1205+
line: str,
1206+
*,
1207+
context: str,
1208+
filename: str | None,
1209+
lineno: int | None,
1210+
source_line: str | None,
1211+
) -> None:
1212+
raise FortranParseError(
1213+
f"Invalid Fortran syntax in {context}: {line.strip()}",
1214+
filename=filename,
1215+
line_number=lineno,
1216+
source_line=source_line,
1217+
code="PARSE_INVALID_SYNTAX",
1218+
)
1219+
11391220
def _helper_slice_child_units(
11401221
self,
11411222
lines: _PreprocessedLines,
@@ -1378,16 +1459,10 @@ def _helper_split_unit_parts(
13781459
index = child_end + 1
13791460
continue
13801461

1381-
is_spec_statement = (
1382-
self._parse_use_statement(stripped) is not None
1383-
or self._is_ignored_spec_statement(stripped)
1384-
or self._looks_like_declaration_or_spec(stripped)
1385-
)
13861462
if (
13871463
region == "specification"
13881464
and grammar.has_execution_part
13891465
and self._is_executable_statement_start(stripped)
1390-
and not is_spec_statement
13911466
):
13921467
region = "execution"
13931468

@@ -2047,6 +2122,8 @@ def _helper_visit_spec_part(
20472122
stripped = line.strip()
20482123
if not stripped:
20492124
continue
2125+
if stripped.startswith("#"):
2126+
continue
20502127
if scope.kind == "procedure":
20512128
self._helper_visit_procedure_spec_line(
20522129
stripped,
@@ -2167,9 +2244,15 @@ def _helper_visit_module_like_spec_line(
21672244
)
21682245
if parsed:
21692246
return
2170-
if "::" not in stripped and not self._looks_like_declaration_or_spec(stripped):
2171-
return
21722247
owner_kind, owner_name = self._variable_scope_label(target)
2248+
if "::" not in stripped and not self._looks_like_declaration_or_spec(stripped):
2249+
self._raise_invalid_fortran_syntax_line(
2250+
stripped,
2251+
context=f"{owner_kind} '{owner_name or '<unnamed>'}' specification part",
2252+
filename=filename,
2253+
lineno=lineno,
2254+
source_line=source_line,
2255+
)
21732256
raise FortranParseError(
21742257
f"Unknown or unsupported datatype declaration in {owner_kind} '{owner_name or '<unnamed>'}': {stripped}",
21752258
filename=filename,
@@ -2223,6 +2306,8 @@ def _helper_visit_procedure_spec_line(self, line: str, proc_state: dict, filenam
22232306
source_line=source_line,
22242307
):
22252308
return
2309+
if self._is_statement_function_statement(stripped):
2310+
return
22262311

22272312
parsed = self._helper_parse_declaration_line(
22282313
stripped,
@@ -2289,7 +2374,15 @@ def _helper_visit_type_spec_line(self, line: str, scope: _ParserScope, filename:
22892374
if parsed:
22902375
return
22912376
if "::" not in stripped and not self._looks_like_declaration_or_spec(stripped):
2292-
return
2377+
if self._is_executable_statement_start(stripped):
2378+
return
2379+
self._raise_invalid_fortran_syntax_line(
2380+
stripped,
2381+
context=f"type '{dtype.name}' specification part",
2382+
filename=filename,
2383+
lineno=lineno,
2384+
source_line=source_line,
2385+
)
22932386
raise FortranParseError(
22942387
f"Unknown or unsupported datatype declaration in type '{dtype.name}': {line.strip()}",
22952388
filename=filename,
@@ -2890,7 +2983,13 @@ def _handle_unknown_proc_declaration(
28902983
as an unsupported datatype declaration for the active procedure.
28912984
"""
28922985
if not self._looks_like_unknown_proc_declaration(line):
2893-
return
2986+
self._raise_invalid_fortran_syntax_line(
2987+
line,
2988+
context=f"procedure '{proc_state['signature'].name}' specification part",
2989+
filename=filename,
2990+
lineno=lineno,
2991+
source_line=source_line,
2992+
)
28942993
if any(_REGEX[pattern_key].search(line) for pattern_key in _UNSUPPORTED_PATTERN_KEYS):
28952994
return
28962995
raise FortranParseError(
@@ -3778,6 +3877,17 @@ def _looks_like_declaration_or_spec(line: str) -> bool:
37783877
return True
37793878
return bool(re.match(r"^[A-Za-z_]\w+\s+[A-Za-z_]\w*", stripped))
37803879

3880+
@staticmethod
3881+
def _is_statement_function_statement(line: str) -> bool:
3882+
stripped = line.strip()
3883+
return bool(
3884+
re.match(
3885+
r"^[A-Za-z_]\w*\s*\([^()]*\)\s*=",
3886+
stripped,
3887+
flags=re.IGNORECASE,
3888+
)
3889+
)
3890+
37813891
@staticmethod
37823892
def _is_ignored_spec_statement(line: str) -> bool:
37833893
return bool(
@@ -3819,9 +3929,19 @@ def _is_executable_statement_start(line: str) -> bool:
38193929
stripped = line.strip()
38203930
if not stripped: # pragma: no cover - callers skip blank lines before executable checks.
38213931
return False
3932+
labeled = re.match(r"^\d+\s+(?P<body>.*)$", stripped)
3933+
if labeled:
3934+
stripped = labeled.group("body").strip()
3935+
if not stripped:
3936+
return False
38223937
lowered = stripped.lower()
38233938
if FortranParser._is_openmp_directive(stripped):
38243939
return not FortranParser._is_openmp_declarative_directive(stripped)
3940+
if (
3941+
FortranParser._parse_use_statement(stripped) is not None
3942+
or FortranParser._is_ignored_spec_statement(stripped)
3943+
):
3944+
return False
38253945
first_match = re.match(r"([a-z_][a-z0-9_]*)", lowered)
38263946
first = first_match.group(1) if first_match else lowered.split(None, 1)[0]
38273947
if first.isdigit():
@@ -3836,6 +3956,8 @@ def _is_executable_statement_start(line: str) -> bool:
38363956
return True
38373957
if _REGEX["legacy_parameter"].match(stripped):
38383958
return False
3959+
if FortranParser._is_statement_function_statement(stripped):
3960+
return False
38393961
if "=" in stripped and "::" not in stripped:
38403962
# Distinguish assignment/statements from declaration lines carrying
38413963
# type specs with named arguments, e.g.:

‎tests/parser/c/test_c_cli_skeleton.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,23 @@ def test_cli_c_input_rejects_explicit_fortran_frontend(tmp_path: Path):
208208
assert not output.exists()
209209

210210

211+
def test_cli_c_pyi_rejects_invalid_c_syntax(tmp_path: Path):
212+
header = tmp_path / "api.h"
213+
header.write_text(
214+
"int add(int a, int b);\nvalue_type :: state;\n",
215+
encoding="utf-8",
216+
)
217+
result = subprocess.run(
218+
[sys.executable, "-m", "x2py", str(header), "--language", "c", "--pyi"],
219+
capture_output=True,
220+
text=True,
221+
)
222+
223+
assert result.returncode == 1
224+
assert "CPARSE_INVALID_SYNTAX" in result.stderr
225+
assert "Invalid C syntax" in result.stderr
226+
227+
211228
def test_cli_c_rejects_fortran_only_parse_flags(tmp_path: Path):
212229
header = tmp_path / "api.h"
213230
header.write_text("int add(int a, int b);\n", encoding="utf-8")

0 commit comments

Comments
 (0)