Skip to content

Commit dfa0ced

Browse files
authored
Merge pull request #39 from PyNumLab/feature/compiler-preprocessing
Implement compiler preprocessing module for handling includes in Fort…
2 parents 358b872 + 95513b7 commit dfa0ced

44 files changed

Lines changed: 4812 additions & 644 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,6 @@ Ignore:
1111
- *.json
1212

1313
Do not spend context window or analysis on those files unless explicitly requested.
14+
When updating tests, remove obsolete tests that only assert removed/old implementation behavior does not exist. Do not preserve rejection or absence checks for API/features that were intentionally removed unless explicitly requested.
15+
When investigating coverage failures, mirror the GitHub Actions workflow before deciding the fix: run coverage with `COVERAGE_PROCESS_START=pyproject.toml`, combine parallel data with `python -m coverage combine`, then run `python -m coverage report`. Do not assume a plain local coverage run matches CI, especially when subprocess tests are involved.
1416
When you create a commit add this prefix to the message to know that you did push the commit "codex: ..."

README.md

Lines changed: 104 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ Current handled coverage:
5555
The C frontend is currently parse-only. It supports:
5656

5757
- Raw-source directive metadata for includes, simple macros, conditionals, and
58-
pragmas.
58+
pragmas. Raw mode records these facts but does not expand macros or select
59+
conditional branches.
5960
- Compiler-assisted preprocessing through the shared CLI flags, with `#line`
6061
and GCC/Clang linemarker remapping back to original source locations.
6162
- Top-level variables, typedefs, function declarations/definitions, structs,
@@ -66,8 +67,9 @@ The C frontend is currently parse-only. It supports:
6667
- Project include/index facts through `parse_c_project(...)`, with includes
6768
recorded non-recursively: only explicitly supplied files or files below an
6869
explicitly supplied directory are parsed.
69-
- Raw mutually exclusive function alternatives preserved for later semantic
70-
selection rather than collapsed into one signature.
70+
- Compiler mode is the wrapper-facing path for macro-dependent APIs: it parses
71+
one compiler-expanded translation unit and keeps mutually exclusive branches
72+
separate across build configurations.
7173

7274
The supported C subset continues through semantic IR conversion, `.pyi`
7375
generation, and wrap-readiness.
@@ -76,11 +78,16 @@ generation, and wrap-readiness.
7678

7779
Public API entrypoints include:
7880

79-
- `x2py.parse_fortran_file(source_or_path, filename=None, macro_defines=None, encoding="utf-8") -> FortranFile`
81+
- `x2py.parse_fortran_file(source_or_path, filename=None, encoding="utf-8") -> FortranFile`
8082
- `x2py.parse_fortran_project(files, encoding="utf-8") -> FortranProject`
81-
- `x2py.parse_c_file(source_or_path, filename=None, macro_defines=None, include_dirs=None, preprocessing="raw", encoding="utf-8") -> CFile`
82-
- `x2py.parse_c_project(files, include_dirs=None, macro_defines=None, preprocessing="raw", encoding="utf-8") -> CProject`
83+
- `x2py.parse_c_file(source_or_path, filename=None, include_dirs=None, preprocessing="raw", encoding="utf-8") -> CFile`
84+
- `x2py.parse_c_project(files, include_dirs=None, preprocessing="raw", encoding="utf-8") -> CProject`
8385
- `x2py.fortran_file_to_semantic_modules(parsed_file, standalone_module_name=None) -> list[SemanticModule]`
86+
- `x2py.fortran_project_to_semantic_modules(project) -> list[SemanticModule]`
87+
- `x2py.c_file_to_semantic_modules(parsed_file) -> list[SemanticModule]`
88+
- `x2py.c_project_to_semantic_modules(project) -> list[SemanticModule]`
89+
- `x2py.emit_module_stubs(module_or_modules) -> dict[str, str]`
90+
- `x2py.load_pyi_modules(path_or_paths, encoding="utf-8") -> list[SemanticModule]`
8491
- `x2py.assess_semantic_wrap_readiness(semantic_ir, source=None) -> dict`
8592
- `x2py.assess_pyi_wrap_readiness(path_or_paths, encoding="utf-8") -> dict`
8693
- `x2py.c_type_probe.probe_c_standard_types(config, runner=None) -> CStandardTypeProbeReport`
@@ -152,7 +159,15 @@ python -m x2py path/to/c_src --language c --parse
152159
Fortran directories scan `.f`, `.for`, `.ftn`, `.f90`, `.f95`, `.f03`,
153160
`.f08`; C directories scan `.c`, `.h`, and `.i` files.
154161

155-
### Compiler preprocessing and target probes
162+
### Compiler preprocessing, includes, and target probes
163+
164+
Wrapper-facing source parsing should use compiler preprocessing whenever the
165+
input contains C/CPP preprocessing. The selected compiler is authoritative for
166+
macro expansion, `#if`/`#ifdef` branch selection, C `#include`, Fortran CPP
167+
`#include`, predefined macros, `-D`/`-U`, include paths, target flags, and
168+
sysroot behavior. Internal parser mode remains available for plain source,
169+
already-preprocessed source, and focused parser tests; it does not evaluate CPP
170+
branches.
156171

157172
The shared compiler mode is:
158173

@@ -168,9 +183,45 @@ python -m x2py path/to/source.f90 --language fortran --parse \
168183
```
169184

170185
For C, `--language c --preprocess compiler` runs the exact compiler
171-
preprocessor and parses stdout. C also supports `--compile-commands
172-
build/compile_commands.json`; the matching entry supplies the compiler and
173-
project flags.
186+
preprocessor and parses stdout. C and Fortran can use `--compile-commands
187+
build/compile_commands.json` when a matching entry supplies the compiler and
188+
project flags. GCC-compatible C/Clang invocations use `-E -x c`; GNU Fortran
189+
invocations use `-E -cpp`. Linemarkers are preserved so parser locations can be
190+
mapped back to original files. For unsupported compiler families, use
191+
`--preprocessor-adapter command-template --preprocess-template '...'`; the
192+
minimum adapter contract is expanded source on stdout.
193+
194+
Fortran native `include "file.inc"` is resolved after compiler CPP output and
195+
before parsing. This is textual insertion into the current module, procedure,
196+
interface, or execution scope; it is not the same as `use module_name`. Native
197+
includes are resolved relative to the including file first, then configured
198+
`-I` directories, and duplicate textual inclusion is preserved. Missing
199+
includes and cycles are reported as preprocessing diagnostics.
200+
201+
Preprocessing JSON records the exact recipe: compiler or adapter, argv, working
202+
directory, include directories, defines, undefs, standard, extra compiler
203+
arguments, included files, source mappings, diagnostics, and optional macro
204+
metadata when the adapter output exposes it. System-header declarations are
205+
classified private by default. Reachable project includes are public by
206+
default; use `--include-exposure roots-only`, `--public-include`, and
207+
`--private-include` to control wrapper export. Private declarations remain
208+
available internally for type resolution. Public signatures that refer to
209+
private C handle types can use private opaque classes rather than exposing data
210+
members.
211+
212+
The C parser tolerates common compiler-expanded declaration syntax from system
213+
headers, including GNU attributes, `__declspec(...)`, alternate qualifier
214+
spellings, declaration-level `asm(...)`, calling-convention keywords,
215+
`typeof(...)`, `_BitInt(...)`, and selected extended scalar names. Harmless
216+
syntax is accepted without exposing private header declarations. Ignored
217+
extensions that can affect ABI, layout, symbol identity, or type identity
218+
produce `C_UNMODELED_COMPILER_EXTENSION` warnings.
219+
220+
Preprocessing failures print explicit categories such as
221+
`PREPROCESSOR_NOT_FOUND`, `PREPROCESSOR_FAILED`,
222+
`INVALID_COMPILER_ARGUMENTS`, `UNSUPPORTED_COMPILER_CAPABILITY`,
223+
`PROVENANCE_UNAVAILABLE`, `INCLUDE_NOT_FOUND`, and `INCLUDE_CYCLE` without a
224+
Python traceback. Pass `--debug` to re-raise and show the traceback.
174225

175226
Target-dependent type facts are not hard-coded. They are probed with the same
176227
compiler path and target-relevant flags because results may change with ABI,
@@ -757,3 +808,46 @@ source/target mapping. A non-renamed `use iso_c_binding, only: c_int` maps
757808
`source="delete_input_list"` and `target="delete_input"`. The semantic layer
758809
uses that information to emit Python stub imports such as
759810
`from list_input import delete_input_list as delete_input`.
811+
812+
Fortran `use` dependencies are not parsed or wrapped recursively. If a
813+
procedure refers to an imported derived type, semantic IR records its defining
814+
module and represents the reference as an opaque handle unless the defining
815+
module is explicitly part of the wrapping target. Explicitly supplied modules
816+
share one wrapped-type registry, so the imported reference resolves to the
817+
single class emitted by its owner module without being re-exported by the
818+
importing module. Reachable include exposure is already handled separately by
819+
the preprocessing include policy; a future dependency-expansion option would
820+
apply specifically to recursive Fortran `use` traversal.
821+
822+
When an imported derived type remains external, `.pyi` generation emits an
823+
owner-module dependency stub. For example, wrapping only `physics.f90` may
824+
produce:
825+
826+
```python
827+
# physics.pyi
828+
from types_mod import particle
829+
830+
def move(p: Ptr(particle)) -> None: ...
831+
```
832+
833+
```python
834+
# types_mod.pyi
835+
class particle(Opaque):
836+
pass
837+
```
838+
839+
`python -m x2py physics.f90 --pyi --out` writes both files beside the source.
840+
`load_pyi_modules(...)` loads a file set or directory, preserves opaque classes,
841+
and reconciles imported references against edited owner stubs. Replacing the
842+
opaque placeholder with a concrete edited class changes the semantic reference
843+
from `representation="opaque"` to `representation="wrapped"`. Existing
844+
`Annotated[...]` constraints also round-trip through this editable interface;
845+
richer coercion syntax can be added to the same `.pyi` format later.
846+
847+
The same opaque-handle file-set model applies to C. A local forward declaration
848+
such as `struct context;` emits `class context(Opaque): pass`. When a public C
849+
header uses a struct from another explicitly supplied header, its generated
850+
stub imports the class from that header's stub. A private included struct used
851+
through a public pointer boundary emits an opaque owner-module dependency stub.
852+
An unresolved C typedef is left unresolved rather than guessed to be opaque,
853+
because its ABI may not be pointer-shaped.

c_parser/cli.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from pathlib import Path
1010
from typing import Any
1111

12-
from .models import CFile, CParseError, c_model_to_dict
12+
from .models import CFile, CMacro, CParseError, CSourceLocation, c_model_to_dict
1313
from .parser import CParser
1414

1515

@@ -44,6 +44,38 @@ def expand_c_paths(paths: list[str]) -> list[Path]:
4444
return sorted(set(expanded))
4545

4646

47+
def attach_preprocessing_recipe(parsed: CFile, preprocessing_recipe: dict[str, Any] | None) -> None:
48+
"""Attach compiler recipe side-channel facts to a parsed C file."""
49+
50+
parsed.preprocessing_recipe = preprocessing_recipe
51+
if not preprocessing_recipe:
52+
return
53+
existing = {(macro.name, macro.source_location.filename if macro.source_location else None, macro.source_location.line if macro.source_location else None) for macro in parsed.macros}
54+
for item in preprocessing_recipe.get("macros") or []:
55+
if not isinstance(item, dict):
56+
continue
57+
name = item.get("name")
58+
if not isinstance(name, str) or not name:
59+
continue
60+
location = CSourceLocation(
61+
filename=item.get("path") if isinstance(item.get("path"), str) else None,
62+
line=item.get("line") if isinstance(item.get("line"), int) else None,
63+
column=1,
64+
)
65+
key = (name, location.filename, location.line)
66+
if key in existing:
67+
continue
68+
parsed.macros.append(
69+
CMacro(
70+
name=name,
71+
value=item.get("value") if isinstance(item.get("value"), str) else None,
72+
function_like=bool(item.get("function_like")),
73+
source_location=location,
74+
)
75+
)
76+
existing.add(key)
77+
78+
4779
def parse_c_report(
4880
paths: list[str],
4981
*,
@@ -70,7 +102,7 @@ def parse_c_report(
70102
include_dirs=include_dirs,
71103
preprocessing=preprocessing,
72104
)
73-
parsed.preprocessing_recipe = preprocessing_recipe
105+
attach_preprocessing_recipe(parsed, preprocessing_recipe)
74106
out[str(p)] = c_model_to_dict(parsed)
75107
return out
76108

c_parser/lexer.py

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ class CTopLevelSegment:
9393
_LINE_DIRECTIVE_RE = re.compile(
9494
r'^\s*#\s*line\s+(?P<line>\d+)(?:\s+(?:"(?P<quoted>(?:[^"\\]|\\.)*)"|(?P<bare>\S+)))?'
9595
)
96+
_AGGREGATE_HEADER_ATTRIBUTE_RE = re.compile(r"\b(?:__attribute__?|__declspec(?:__)?)\b")
9697

9798

9899
def _source_line(lines: list[str], line_number: int) -> str | None:
@@ -317,18 +318,76 @@ def top_level_partition(text: str, delimiter: str = "=") -> tuple[str, str | Non
317318
return text.strip(), None
318319

319320

320-
def _is_aggregate_definition_header(header: str) -> bool:
321+
def _balanced_invocation_end(text: str, open_index: int) -> int | None:
322+
"""Return the offset after one balanced parenthesized invocation."""
323+
depth = 0
324+
quote = ""
325+
escaped = False
326+
for index in range(open_index, len(text)):
327+
char = text[index]
328+
if quote:
329+
if escaped:
330+
escaped = False
331+
elif char == "\\":
332+
escaped = True
333+
elif char == quote:
334+
quote = ""
335+
continue
336+
if char in {'"', "'"}:
337+
quote = char
338+
elif char == "(":
339+
depth += 1
340+
elif char == ")":
341+
depth -= 1
342+
if depth == 0:
343+
return index + 1
344+
return None
345+
346+
347+
def _strip_aggregate_header_attributes(header: str) -> str:
348+
"""Blank attributes that can appear between an aggregate keyword and tag."""
349+
characters = list(header)
350+
for match in _AGGREGATE_HEADER_ATTRIBUTE_RE.finditer(header):
351+
end = match.end()
352+
open_index = end
353+
while open_index < len(header) and header[open_index].isspace():
354+
open_index += 1
355+
if open_index < len(header) and header[open_index] == "(":
356+
end = _balanced_invocation_end(header, open_index) or end
357+
for index in range(match.start(), end):
358+
if characters[index] != "\n":
359+
characters[index] = " "
360+
return "".join(characters)
361+
362+
363+
def _is_aggregate_definition_header(
364+
header: str,
365+
*,
366+
tolerate_compiler_extensions: bool = False,
367+
) -> bool:
321368
"""Identify a tag definition before deciding that a brace starts a body."""
369+
if tolerate_compiler_extensions:
370+
header = _strip_aggregate_header_attributes(header)
322371
compact = " ".join(header.split())
323372
if "(" in compact or "=" in compact:
324373
return False
325374
words = compact.split()
326375
return any(word in {"struct", "union", "enum"} for word in words)
327376

328377

329-
def _is_braced_declaration_header(header: str) -> bool:
378+
def _is_braced_declaration_header(
379+
header: str,
380+
*,
381+
tolerate_compiler_extensions: bool = False,
382+
) -> bool:
330383
"""Return whether a brace belongs to a declaration preserved through `;`."""
331-
return _is_aggregate_definition_header(header) or top_level_partition(header, "=")[1] is not None
384+
return (
385+
_is_aggregate_definition_header(
386+
header,
387+
tolerate_compiler_extensions=tolerate_compiler_extensions,
388+
)
389+
or top_level_partition(header, "=")[1] is not None
390+
)
332391

333392

334393
def split_top_level_c_source(
@@ -337,6 +396,7 @@ def split_top_level_c_source(
337396
*,
338397
skip_preprocessor: bool = True,
339398
use_linemarkers: bool = False,
399+
tolerate_compiler_extensions: bool = False,
340400
) -> list[CTopLevelSegment]:
341401
"""Split C source into top-level declarations and definition headers."""
342402
stripped = strip_c_comments(source)
@@ -420,7 +480,10 @@ def split_top_level_c_source(
420480
block_start_line = start_line
421481
block_start_column = start_column
422482
block_source_line = start_mapping.source_line
423-
braced_declaration = _is_braced_declaration_header(header)
483+
braced_declaration = _is_braced_declaration_header(
484+
header,
485+
tolerate_compiler_extensions=tolerate_compiler_extensions,
486+
)
424487
brace_depth = 1
425488
if not braced_declaration:
426489
start_index = None

0 commit comments

Comments
 (0)