diff --git a/README.md b/README.md index 74fec8581..828fb1863 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,8 @@ The C frontend is currently parse-only. It supports: - Raw mutually exclusive function alternatives preserved for later semantic selection rather than collapsed into one signature. -C semantic IR conversion, C `.pyi` generation, and C wrap-readiness are still -intentionally disabled until the C semantic layer is implemented. +The supported C subset continues through semantic IR conversion, `.pyi` +generation, and wrap-readiness. ## Public APIs @@ -112,7 +112,9 @@ path when `--language` is omitted. C source/header files require explicit `--language fortran` or `--language c`. C parsing, semantic IR, `.pyi` generation, and wrap-readiness are available in explicit C mode. Selecting a frontend that conflicts with a recognized C or Fortran source suffix is an -error. +error. Once selected, a frontend also rejects unmistakable declarations or +program-unit syntax that are not from the selected language outside ignored execution/function +bodies, rather than silently dropping them. For parse output, `--show-vars` expands scope-level variables that are normally summarized as `vars=N`. Use `--print-limit N` to keep large repeated sections diff --git a/c_parser/parser.py b/c_parser/parser.py index 220afe1dc..eaf8da429 100644 --- a/c_parser/parser.py +++ b/c_parser/parser.py @@ -46,6 +46,7 @@ from .lexer import ( CTopLevelSegment, + lex_c_source, line_mappings_for_source, split_top_level_c_source, strip_c_comments, @@ -575,6 +576,36 @@ def _source_location(self, segment: CTopLevelSegment) -> CSourceLocation: """Return the original start location for a top-level segment.""" return self._source_location_at(segment, 0) + @staticmethod + def _could_start_c_external_declaration(text: str) -> bool: + stripped = text.lstrip() + return bool(stripped) and (stripped[0].isalpha() or stripped[0] == "_") + + @staticmethod + def _raise_for_invalid_top_level_syntax(segment: CTopLevelSegment) -> None: + text = segment.text.strip() + if not text or _looks_like_cxx_declaration(text): + return + tokens = lex_c_source(text) + has_scope_operator = any( + left.text == ":" and right.text == ":" + for left, right in zip(tokens, tokens[1:]) + ) + if ( + segment.terminator != "eof" + and not has_scope_operator + and CParser._could_start_c_external_declaration(text) + ): + return + raise CParseError( + f"Invalid C syntax at top level: {text}", + filename=segment.filename, + line_number=segment.original_start_line, + column=segment.original_start_column, + source_line=segment.original_source_line, + code="CPARSE_INVALID_SYNTAX", + ) + def _macro_dependencies( self, source: str, @@ -2475,6 +2506,7 @@ def _parse_translation_unit( filename=filename, use_linemarkers=use_linemarkers, ): + self._raise_for_invalid_top_level_syntax(segment) macro_dependency = self._segment_macro_dependency( segment, function_like_names, diff --git a/docs/c_parser/c_parser_cli_workflow.md b/docs/c_parser/c_parser_cli_workflow.md index e1ac2a14a..717b66706 100644 --- a/docs/c_parser/c_parser_cli_workflow.md +++ b/docs/c_parser/c_parser_cli_workflow.md @@ -44,7 +44,10 @@ recognizable Fortran files and `.pyi` readiness inputs, but a `.c`, `.h`, or `.i` path fails with guidance to pass `--language c`; directory and unknown-suffix source inputs require an explicit frontend selection. A known C path explicitly passed with `--language fortran` is rejected before parsing, -so it cannot silently produce an empty Fortran interface. +so it cannot silently produce an empty Fortran interface. The parser also +rejects foreign syntax found in C input, and the Fortran parser +rejects unsupported non-Fortran syntax outside execution +regions that are intentionally not modeled. The C parser output differs from Fortran parser output by using C-specific top-level sections: `functions`, `structs`, `unions`, `enums`, `typedefs`, diff --git a/docs/c_parser/c_parser_reference.md b/docs/c_parser/c_parser_reference.md index e1d501e46..feeffdd62 100644 --- a/docs/c_parser/c_parser_reference.md +++ b/docs/c_parser/c_parser_reference.md @@ -490,6 +490,8 @@ x2py path/to/api.h --language c --parse --out report.json There is no separate `--parse-c` alias: `--language c --parse` is the shared language-selection form. Auto-detection remains deferred: a `.c`, `.h`, or `.i` input without `--language c` exits with language-selection guidance. +Explicit C input containing unmistakable non-C syntax raises a fatal parser diagnostic instead of emitting a partial C +interface. ## Current JSON Output diff --git a/docs/fortran/fortran_parser.md b/docs/fortran/fortran_parser.md index b3b6cb5fb..7a03b2046 100644 --- a/docs/fortran/fortran_parser.md +++ b/docs/fortran/fortran_parser.md @@ -210,6 +210,10 @@ python -m x2py path/to/fortran_src --language fortran --parse Fortran directories are recursively scanned for `.f`, `.for`, `.ftn`, `.f90`, `.f95`, `.f03`, `.f08`. +The Fortran frontend rejects unsupported non-Fortran syntax before +wrapper-focused parsing when it appears outside executable procedure/program +bodies, which are intentionally not represented in the extracted interface. + The human-readable parse tree keeps scope variables compact by default as `vars=N`. Add `--show-vars` to print the variables, or `--print-limit N` to print only the first `N` items in each repeated section. diff --git a/fortran_parser/parser.py b/fortran_parser/parser.py index d56cd7280..e2500b581 100644 --- a/fortran_parser/parser.py +++ b/fortran_parser/parser.py @@ -900,6 +900,7 @@ def _helper_prepare_source_units( self._helper_validate_unit_headers(lines, filename) root_scope = _ParserScope(kind="file", name=None) units = self._helper_slice_child_units(lines, parent_scope=root_scope, filename=filename) + self._helper_validate_file_scope_unparsed_lines(lines, filename) self._helper_validate_sibling_units(units, parent_scope=root_scope, filename=filename) return lines, root_scope, units @@ -1136,6 +1137,86 @@ def _helper_validate_unit_headers(self, lines: _PreprocessedLines, filename: str source_line=source_line, ) + def _helper_validate_file_scope_unparsed_lines(self, lines: _PreprocessedLines, filename: str | None) -> None: + """Reject any non-Fortran syntax outside recognized unit bodies. + + This guard is intentionally language-agnostic: lines that are neither + valid file-scope Fortran constructs nor part of a recognized unit are + rejected via the generic invalid-syntax diagnostic path. + """ + index = 0 + pp_condition_stack: list[tuple[int, int]] = [] + pp_active_stack: list[bool] = [] + pp_group_counter = 0 + while index < len(lines): + line, lineno, source_line = lines[index] + stripped = line.strip() + if not stripped: + index += 1 + continue + handled_pp, pp_group_counter = self._handle_procedure_preprocessor_line( + stripped, + macro_selection_enabled=False, + macro_names=set(), + pp_condition_stack=pp_condition_stack, + pp_active_stack=pp_active_stack, + pp_group_counter=pp_group_counter, + ) + if handled_pp: + index += 1 + continue + start = self._helper_classify_unit_start(stripped) + if start is not None: + end_index = self._helper_find_unit_end(lines, index, start[0], filename=filename) + if end_index is not None: + index = end_index + 1 + continue + if self._is_allowed_unparsed_file_scope_line(stripped): + index += 1 + continue + if self._is_executable_statement_start(stripped): + index += 1 + continue + self._raise_invalid_fortran_syntax_line( + stripped, + context="file scope", + filename=filename, + lineno=lineno, + source_line=source_line, + ) + + @staticmethod + def _is_allowed_unparsed_file_scope_line(line: str) -> bool: + stripped = line.strip() + lowered = stripped.lower() + return ( + stripped.startswith("#") + or lowered == "contains" + or lowered.startswith("end ") + or lowered.startswith(("endif", "enddo")) + or lowered == "else" + or lowered.startswith(("elseif", "else if")) + or FortranParser._is_ignored_spec_statement(stripped) + or FortranParser._is_openmp_directive(stripped) + ) + + @staticmethod + def _raise_invalid_fortran_syntax_line( + line: str, + *, + context: str, + filename: str | None, + lineno: int | None, + source_line: str | None, + ) -> None: + raise FortranParseError( + f"Invalid Fortran syntax in {context}: {line.strip()}", + filename=filename, + line_number=lineno, + source_line=source_line, + code="PARSE_INVALID_SYNTAX", + ) + def _helper_slice_child_units( self, lines: _PreprocessedLines, @@ -1378,16 +1459,10 @@ def _helper_split_unit_parts( index = child_end + 1 continue - is_spec_statement = ( - self._parse_use_statement(stripped) is not None - or self._is_ignored_spec_statement(stripped) - or self._looks_like_declaration_or_spec(stripped) - ) if ( region == "specification" and grammar.has_execution_part and self._is_executable_statement_start(stripped) - and not is_spec_statement ): region = "execution" @@ -2047,6 +2122,8 @@ def _helper_visit_spec_part( stripped = line.strip() if not stripped: continue + if stripped.startswith("#"): + continue if scope.kind == "procedure": self._helper_visit_procedure_spec_line( stripped, @@ -2167,9 +2244,15 @@ def _helper_visit_module_like_spec_line( ) if parsed: return - if "::" not in stripped and not self._looks_like_declaration_or_spec(stripped): - return owner_kind, owner_name = self._variable_scope_label(target) + if "::" not in stripped and not self._looks_like_declaration_or_spec(stripped): + self._raise_invalid_fortran_syntax_line( + stripped, + context=f"{owner_kind} '{owner_name or ''}' specification part", + filename=filename, + lineno=lineno, + source_line=source_line, + ) raise FortranParseError( f"Unknown or unsupported datatype declaration in {owner_kind} '{owner_name or ''}': {stripped}", filename=filename, @@ -2223,6 +2306,8 @@ def _helper_visit_procedure_spec_line(self, line: str, proc_state: dict, filenam source_line=source_line, ): return + if self._is_statement_function_statement(stripped): + return parsed = self._helper_parse_declaration_line( stripped, @@ -2289,7 +2374,15 @@ def _helper_visit_type_spec_line(self, line: str, scope: _ParserScope, filename: if parsed: return if "::" not in stripped and not self._looks_like_declaration_or_spec(stripped): - return + if self._is_executable_statement_start(stripped): + return + self._raise_invalid_fortran_syntax_line( + stripped, + context=f"type '{dtype.name}' specification part", + filename=filename, + lineno=lineno, + source_line=source_line, + ) raise FortranParseError( f"Unknown or unsupported datatype declaration in type '{dtype.name}': {line.strip()}", filename=filename, @@ -2890,7 +2983,13 @@ def _handle_unknown_proc_declaration( as an unsupported datatype declaration for the active procedure. """ if not self._looks_like_unknown_proc_declaration(line): - return + self._raise_invalid_fortran_syntax_line( + line, + context=f"procedure '{proc_state['signature'].name}' specification part", + filename=filename, + lineno=lineno, + source_line=source_line, + ) if any(_REGEX[pattern_key].search(line) for pattern_key in _UNSUPPORTED_PATTERN_KEYS): return raise FortranParseError( @@ -3778,6 +3877,17 @@ def _looks_like_declaration_or_spec(line: str) -> bool: return True return bool(re.match(r"^[A-Za-z_]\w+\s+[A-Za-z_]\w*", stripped)) + @staticmethod + def _is_statement_function_statement(line: str) -> bool: + stripped = line.strip() + return bool( + re.match( + r"^[A-Za-z_]\w*\s*\([^()]*\)\s*=", + stripped, + flags=re.IGNORECASE, + ) + ) + @staticmethod def _is_ignored_spec_statement(line: str) -> bool: return bool( @@ -3819,9 +3929,19 @@ def _is_executable_statement_start(line: str) -> bool: stripped = line.strip() if not stripped: # pragma: no cover - callers skip blank lines before executable checks. return False + labeled = re.match(r"^\d+\s+(?P.*)$", stripped) + if labeled: + stripped = labeled.group("body").strip() + if not stripped: + return False lowered = stripped.lower() if FortranParser._is_openmp_directive(stripped): return not FortranParser._is_openmp_declarative_directive(stripped) + if ( + FortranParser._parse_use_statement(stripped) is not None + or FortranParser._is_ignored_spec_statement(stripped) + ): + return False first_match = re.match(r"([a-z_][a-z0-9_]*)", lowered) first = first_match.group(1) if first_match else lowered.split(None, 1)[0] if first.isdigit(): @@ -3836,6 +3956,8 @@ def _is_executable_statement_start(line: str) -> bool: return True if _REGEX["legacy_parameter"].match(stripped): return False + if FortranParser._is_statement_function_statement(stripped): + return False if "=" in stripped and "::" not in stripped: # Distinguish assignment/statements from declaration lines carrying # type specs with named arguments, e.g.: diff --git a/tests/parser/c/test_c_cli_skeleton.py b/tests/parser/c/test_c_cli_skeleton.py index 40242cdf0..2a9b25e6b 100644 --- a/tests/parser/c/test_c_cli_skeleton.py +++ b/tests/parser/c/test_c_cli_skeleton.py @@ -208,6 +208,23 @@ def test_cli_c_input_rejects_explicit_fortran_frontend(tmp_path: Path): assert not output.exists() +def test_cli_c_pyi_rejects_invalid_c_syntax(tmp_path: Path): + header = tmp_path / "api.h" + header.write_text( + "int add(int a, int b);\nvalue_type :: state;\n", + encoding="utf-8", + ) + result = subprocess.run( + [sys.executable, "-m", "x2py", str(header), "--language", "c", "--pyi"], + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert "CPARSE_INVALID_SYNTAX" in result.stderr + assert "Invalid C syntax" in result.stderr + + def test_cli_c_rejects_fortran_only_parse_flags(tmp_path: Path): header = tmp_path / "api.h" header.write_text("int add(int a, int b);\n", encoding="utf-8") diff --git a/tests/parser/c/test_c_functions.py b/tests/parser/c/test_c_functions.py index 4167e8bb3..98f5d8932 100644 --- a/tests/parser/c/test_c_functions.py +++ b/tests/parser/c/test_c_functions.py @@ -78,6 +78,93 @@ def test_old_style_knr_function_definition_raises_unsupported_diagnostic(): parse_c_file(source, filename="knr.c") +@pytest.mark.parametrize( + "source", + [ + "def solve():\n return 0\n", + "lambda x: x\n", + "int add(int a, int b);\ninteger :: state;\n", + "int add(int a, int b);\ntype(c_ptr) :: handle;\n", + ], +) +def test_c_parser_rejects_non_c_top_level_syntax(source): + from c_parser import CParseError, parse_c_file + + with pytest.raises(CParseError, match="Invalid C syntax") as exc_info: + parse_c_file(source, filename="mixed.h") + + assert exc_info.value.code == "CPARSE_INVALID_SYNTAX" + + +def test_c_parser_invalid_syntax_error_maps_preprocessed_source_location(): + from c_parser import CParseError, parse_c_file + + with pytest.raises(CParseError) as exc_info: + parse_c_file( + '# 80 "generated.input"\nvalue_type :: state;\n', + filename="translation.i", + preprocessing="preprocessed", + ) + + assert exc_info.value.code == "CPARSE_INVALID_SYNTAX" + assert exc_info.value.filename == "generated.input" + assert exc_info.value.line_number == 80 + + +def test_c_parser_skips_non_c_tokens_inside_function_body(): + from c_parser import parse_c_file + + parsed = parse_c_file( + """ +int run(void) +{ + value_type :: state; + return 0; +} +""", + filename="mixed_body.c", + ) + + assert [function.name for function in parsed.functions] == ["run"] + + +def test_c_parser_does_not_classify_valid_c_from_typedef_identifier_spelling(): + from c_parser import CTypedef, parse_c_file + + parsed = parse_c_file("subroutine solve(void);\n", filename="identifier_spelling.h") + + assert [function.name for function in parsed.functions] == ["solve"] + assert isinstance(parsed.functions[0].result_type, CTypedef) + assert parsed.functions[0].result_type.name == "subroutine" + + +@pytest.mark.parametrize("source", ["@@@\n", "int run(void);\n@@@;\n"]) +def test_c_parser_rejects_invalid_top_level_syntax(source): + from c_parser import CParseError, parse_c_file + + with pytest.raises(CParseError, match="Invalid C syntax") as exc_info: + parse_c_file(source, filename="invalid.c") + + assert exc_info.value.code == "CPARSE_INVALID_SYNTAX" + + +def test_c_parser_ignores_invalid_syntax_inside_function_body(): + from c_parser import parse_c_file + + parsed = parse_c_file( + """ +int run(void) +{ + @@@ + return 0; +} +""", + filename="invalid_body.c", + ) + + assert [function.name for function in parsed.functions] == ["run"] + + def test_control_flow_conditions_inside_function_body_do_not_look_like_knr_definitions(): from c_parser import parse_c_file diff --git a/tests/parser/test_cli.py b/tests/parser/test_cli.py index a82249203..1a3193098 100644 --- a/tests/parser/test_cli.py +++ b/tests/parser/test_cli.py @@ -550,6 +550,23 @@ def test_cli_rejects_fortran_file_with_explicit_c_frontend(tmp_path: Path): assert "pass --language fortran" in result.stderr +def test_cli_fortran_rejects_embedded_c_declaration_outside_execution_body(tmp_path: Path): + source = tmp_path / "solver.f90" + source.write_text( + "subroutine solve()\n int add(int a, int b);\nend subroutine solve\n", + encoding="utf-8", + ) + result = subprocess.run( + [sys.executable, "-m", "x2py", str(source), "--pyi"], + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert "PARSE001" in result.stderr + assert "Unknown or unsupported datatype declaration" in result.stderr + + def test_cli_parse_shows_module_derived_types_and_derived_arg_kinds(): fixture = Path(__file__).parent.parent / "data" / "fortran" / "general" / "modern_pyi_example.f90" cmd = [sys.executable, "-m", "x2py", str(fixture), "--parse"] diff --git a/tests/parser/test_error_handling.py b/tests/parser/test_error_handling.py index bdb67e0b2..68f621586 100644 --- a/tests/parser/test_error_handling.py +++ b/tests/parser/test_error_handling.py @@ -746,3 +746,90 @@ def test_slicer_reports_missing_end_unit(): """ with pytest.raises(FortranParseError, match="Missing end module for module 'missing_end'"): parse_fortran_file(code, filename="missing_end_module.f90") + + +@pytest.mark.parametrize( + "code", + [ + "int add(int a, int b);\n", + "api_size count(void);\n", + """ +subroutine mixed_spec() + api_size count(void); +end subroutine mixed_spec +""", + ], +) +def test_fortran_parser_rejects_invalid_non_fortran_syntax_outside_execution_bodies(code): + with pytest.raises( + FortranParseError, + match=r"Invalid Fortran syntax|Unknown or unsupported datatype declaration", + ) as exc_info: + parse_fortran_file(code, filename="mixed.f90") + + assert exc_info.value.code in {"PARSE_INVALID_SYNTAX", "PARSE001"} + + +def test_fortran_parser_ignores_non_fortran_syntax_after_execution_boundary(): + parsed = parse_fortran_file( + """ +subroutine mixed_body() + call noop() + api_size count(void); +end subroutine mixed_body +""", + filename="mixed_body.f90", + ) + + assert parsed.procedures[0].name == "mixed_body" + + +@pytest.mark.parametrize( + "code", + [ + "@@@\n", + """ +module bad_spec + @@@ +end module bad_spec +""", + """ +subroutine bad_spec() + @@@ +end subroutine bad_spec +""", + ], +) +def test_fortran_parser_rejects_invalid_syntax_outside_execution_bodies(code): + with pytest.raises(FortranParseError, match="Invalid Fortran syntax") as exc_info: + parse_fortran_file(code, filename="invalid_syntax.f90") + + assert exc_info.value.code == "PARSE_INVALID_SYNTAX" + + +def test_fortran_parser_ignores_invalid_syntax_after_execution_boundary(): + parsed = parse_fortran_file( + """ +subroutine ignored_body() + call noop() + @@@ +end subroutine ignored_body +""", + filename="invalid_body.f90", + ) + + assert parsed.procedures[0].name == "ignored_body" + + +def test_invalid_syntax_guard_preserves_valid_semicolon_separated_fortran_statements(): + parsed = parse_fortran_file( + """ +subroutine valid_body(x) + real :: x + call update(x); write(*,*) x +end subroutine valid_body +""", + filename="valid_body.f90", + ) + + assert parsed.procedures[0].name == "valid_body" diff --git a/tests/parser/test_parser_public_entrypoints.py b/tests/parser/test_parser_public_entrypoints.py index 4de59daba..d9892418d 100644 --- a/tests/parser/test_parser_public_entrypoints.py +++ b/tests/parser/test_parser_public_entrypoints.py @@ -73,13 +73,13 @@ def test_file_path_and_unknown_filename_public_parse_paths(tmp_path): """, filename="from_unknown.src", ) - parsed_literal = parse_fortran_file(12345) + with pytest.raises(FortranParseError, match="Invalid Fortran syntax"): + parse_fortran_file(12345) assert parsed_from_path.filename == str(source_path) assert parsed_from_path.format == "modern" assert parsed_from_path.procedures[0].name == "from_path" assert parsed_unknown_suffix.format == "unknown" - assert parsed_literal.procedures == [] def test_public_instance_visitor_entrypoints_use_source_strings(): parser = FortranParser()