Skip to content
Merged
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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions c_parser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@

from .lexer import (
CTopLevelSegment,
lex_c_source,
line_mappings_for_source,
split_top_level_c_source,
strip_c_comments,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion docs/c_parser/c_parser_cli_workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
2 changes: 2 additions & 0 deletions docs/c_parser/c_parser_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions docs/fortran/fortran_parser.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
142 changes: 132 additions & 10 deletions fortran_parser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 '<unnamed>'}' specification part",
filename=filename,
lineno=lineno,
source_line=source_line,
)
raise FortranParseError(
f"Unknown or unsupported datatype declaration in {owner_kind} '{owner_name or '<unnamed>'}': {stripped}",
filename=filename,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<body>.*)$", 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():
Expand All @@ -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.:
Expand Down
17 changes: 17 additions & 0 deletions tests/parser/c/test_c_cli_skeleton.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading