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
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ The C frontend is currently parse-only. It supports:
compatible top-level redeclaration merging.
- Pointer, array, function, and parenthesized declarator shapes, including
function pointer typedefs/parameters and parameter adjustment metadata.
- Project include/index facts through `parse_c_project(...)`.
- Project include/index facts through `parse_c_project(...)`, with includes
recorded non-recursively: only explicitly supplied files or files below an
explicitly supplied directory are parsed.
- 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.
Expand All @@ -74,8 +78,8 @@ Public API entrypoints include:

- `x2py.parse_fortran_file(source_or_path, filename=None, macro_defines=None, encoding="utf-8") -> FortranFile`
- `x2py.parse_fortran_project(files, encoding="utf-8") -> FortranProject`
- `c_parser.parse_c_file(source_or_path, filename=None, macro_defines=None, include_dirs=None, preprocessing="raw", encoding="utf-8") -> CFile`
- `c_parser.parse_c_project(files, include_dirs=None, macro_defines=None, preprocessing="raw", encoding="utf-8") -> CProject`
- `x2py.parse_c_file(source_or_path, filename=None, macro_defines=None, include_dirs=None, preprocessing="raw", encoding="utf-8") -> CFile`
- `x2py.parse_c_project(files, include_dirs=None, macro_defines=None, preprocessing="raw", encoding="utf-8") -> CProject`
- `x2py.fortran_file_to_semantic_modules(parsed_file, standalone_module_name=None) -> list[SemanticModule]`
- `x2py.assess_semantic_wrap_readiness(semantic_ir, source=None) -> dict`
- `x2py.assess_pyi_wrap_readiness(path_or_paths, encoding="utf-8") -> dict`
Expand Down Expand Up @@ -611,7 +615,7 @@ types with `class` stubs, literal compile-time constants with
### Example 3: parse C from Python

```python
from c_parser import parse_c_file, parse_c_project
from x2py import parse_c_file, parse_c_project

header = parse_c_file("include/api.h")
print("functions:", [fn.name for fn in header.functions])
Expand All @@ -622,9 +626,11 @@ print("include graph:", project.include_graph)
print("header/source pairs:", project.header_source_pairs)
```

The C Python API is intentionally imported from `c_parser` while the C frontend
stabilizes. C semantic conversion will be added through the semantic layer in a
future phase.
The same C entrypoints remain available from `c_parser`. Includes are recorded
as project facts and are not recursively parsed; supply every header that
should contribute declarations (or supply its containing directory). C
semantic conversion will be added through the semantic layer in a future
phase.

## Running tests

Expand Down
14 changes: 13 additions & 1 deletion c_parser/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,23 @@ def c_model_to_dict(obj: Any, _seen: set[int] | None = None) -> Any:
and f.name == "original_source_paths"
and not getattr(obj, f.name)
)
or (
isinstance(obj, CFunction)
and f.name == "condition_set"
and not getattr(obj, f.name)
)
or (
isinstance(obj, CProject)
and f.name == "conditional_function_variants"
and not getattr(obj, f.name)
)
)
}
if isinstance(obj, list):
return [c_model_to_dict(v, _seen) for v in obj]
if isinstance(obj, dict):
return {k: c_model_to_dict(v, _seen) for k, v in obj.items()}
if isinstance(obj, set):
if isinstance(obj, (set, frozenset)):
return sorted(c_model_to_dict(v, _seen) for v in obj)
return obj

Expand Down Expand Up @@ -391,6 +401,7 @@ class CFunction:
start: CSourceLocation | None = None
end: CSourceLocation | None = None
declaration_locations: list[CSourceLocation] = field(default_factory=list)
condition_set: frozenset[str] = field(default_factory=frozenset)
origin: str | None = None

@property
Expand Down Expand Up @@ -561,6 +572,7 @@ class CProject:
system_includes: dict[str, set[str]] = field(default_factory=dict)
unresolved_includes: dict[str, set[str]] = field(default_factory=dict)
header_source_pairs: dict[str, set[str]] = field(default_factory=dict)
conditional_function_variants: dict[str, list[CFunction]] = field(default_factory=dict)
diagnostics: list[CDiagnostic] = field(default_factory=list)

def to_dict(self) -> dict[str, Any]:
Expand Down
162 changes: 128 additions & 34 deletions c_parser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@
)
_CXX_DECLARATION_KEYWORDS = {"using", "namespace", "template", "class"}
_CXX_ACCESS_SPECIFIERS = {"public", "private", "protected"}
_RAW_CONDITIONAL_DIRECTIVE_RE = re.compile(
r"^\s*#\s*(?P<directive>if|ifdef|ifndef|elif|else|endif)\b"
)
_PRIMITIVE_WORDS = {
"void",
"char",
Expand Down Expand Up @@ -365,6 +368,7 @@ def visit_file(
filename,
function_like_macros=function_like_macro_names,
object_like_macros=object_like_macro_names,
condition_sets_by_line=self._raw_conditional_condition_sets(source),
)
parsed.functions = functions
parsed.structs = structs
Expand Down Expand Up @@ -410,7 +414,12 @@ def visit_project(
preprocessing: str = "raw",
encoding: str = "utf-8",
) -> CProject:
"""Parse a mapping, file list, single file, or directory into a `CProject`."""
"""Parse explicit project inputs without recursively parsing includes.

A directory input explicitly supplies all supported source files below
that directory. Include directives are recorded and resolved as graph
facts where possible, but they never cause another file to be opened.
"""
if isinstance(files, Mapping):
parsed_files = {
name: self.visit_file(
Expand Down Expand Up @@ -675,6 +684,51 @@ def _append_declaration_location(
if location is not None and location not in locations:
locations.append(location)

@staticmethod
def _raw_conditional_condition_sets(source: str) -> dict[int, frozenset[str]]:
"""Track unselected raw preprocessor alternatives by physical line."""
conditions_by_line: dict[int, frozenset[str]] = {}
condition_stack: list[tuple[int, int]] = []
group_counter = 0
for line_number, line in enumerate(source.splitlines(), start=1):
match = _RAW_CONDITIONAL_DIRECTIVE_RE.match(line)
if match is None:
conditions_by_line[line_number] = frozenset(
f"g{group_id}:b{branch_id}"
for group_id, branch_id in condition_stack
)
continue

directive = match.group("directive")
if directive in {"if", "ifdef", "ifndef"}:
group_counter += 1
condition_stack.append((group_counter, 0))
elif directive in {"elif", "else"} and condition_stack:
group_id, branch_id = condition_stack.pop()
condition_stack.append((group_id, branch_id + 1))
elif directive == "endif" and condition_stack:
condition_stack.pop()
return conditions_by_line

@staticmethod
def _functions_are_mutually_exclusive(left: CFunction, right: CFunction) -> bool:
"""Return whether two raw function facts are in alternative branches."""
if (
not left.condition_set
or not right.condition_set
or left.source_location is None
or right.source_location is None
or left.source_location.filename != right.source_location.filename
):
return False
branches: dict[str, str] = {}
for token in left.condition_set | right.condition_set:
group, _, branch = token.partition(":")
if group in branches and branches[group] != branch:
return True
branches[group] = branch
return False

# ------------------------------------------------------------------
# Redeclaration compatibility and normalization
# ------------------------------------------------------------------
Expand Down Expand Up @@ -771,43 +825,48 @@ def _deduplicate_functions(
diagnostics: list[CDiagnostic],
) -> list[CFunction]:
"""Merge compatible functions and report duplicate/conflicting ones."""
by_name: dict[str, CFunction] = {}
order: list[str] = []
normalized: list[CFunction] = []

for function in functions:
existing = by_name.get(function.name)
if existing is None:
by_name[function.name] = function
order.append(function.name)
overlapping = [
index
for index, existing in enumerate(normalized)
if existing.name == function.name
and not self._functions_are_mutually_exclusive(existing, function)
]
if not overlapping:
normalized.append(function)
continue

if not self._functions_compatible(existing, function):
diagnostics.append(
self._redeclaration_diagnostic(
"C_CONFLICTING_FUNCTION_DECLARATION",
f"Conflicting declarations for function {function.name!r}.",
function.source_location,
"function",
function.name,
for index in overlapping:
existing = normalized[index]
if not self._functions_compatible(existing, function):
diagnostics.append(
self._redeclaration_diagnostic(
"C_CONFLICTING_FUNCTION_DECLARATION",
f"Conflicting declarations for function {function.name!r}.",
function.source_location,
"function",
function.name,
)
)
)
continue
continue

if existing.is_definition and function.is_definition:
diagnostics.append(
self._redeclaration_diagnostic(
"C_DUPLICATE_FUNCTION_DEFINITION",
f"Duplicate definition for function {function.name!r}.",
function.source_location,
"function",
function.name,
if existing.is_definition and function.is_definition:
diagnostics.append(
self._redeclaration_diagnostic(
"C_DUPLICATE_FUNCTION_DEFINITION",
f"Duplicate definition for function {function.name!r}.",
function.source_location,
"function",
function.name,
)
)
)
continue
continue

by_name[function.name] = self._merge_function_declaration(existing, function)
normalized[index] = self._merge_function_declaration(existing, function)

return [by_name[name] for name in order]
return normalized

def _is_variable_definition(self, variable: CVariable) -> bool:
"""Return whether a file-scope variable has an initializer."""
Expand Down Expand Up @@ -1018,6 +1077,17 @@ def _normalize_redeclarations(self, parsed: CFile) -> None:
parsed.typedefs = self._deduplicate_typedefs(parsed.typedefs, parsed.diagnostics)
parsed.variables = self._deduplicate_variables(parsed.variables, parsed.diagnostics)
parsed.functions = self._deduplicate_functions(parsed.functions, parsed.diagnostics)
function_counts: dict[str, int] = {}
for function in parsed.functions:
function_counts[function.name] = function_counts.get(function.name, 0) + 1
variant_names = {
function.name
for function in parsed.functions
if function_counts[function.name] > 1
}
for function in parsed.functions:
if function.name not in variant_names:
function.condition_set = frozenset()

def _end_location(self, segment: CTopLevelSegment) -> CSourceLocation:
"""Return the original end location for a top-level segment."""
Expand Down Expand Up @@ -2366,6 +2436,7 @@ def _parse_translation_unit(
function_like_macros: set[str] | None = None,
object_like_macros: set[str] | None = None,
use_linemarkers: bool = False,
condition_sets_by_line: Mapping[int, frozenset[str]] | None = None,
) -> tuple[
list[CFunction],
list[CStruct],
Expand Down Expand Up @@ -2398,6 +2469,7 @@ def _parse_translation_unit(

function_like_names = function_like_macros or set()
object_like_names = object_like_macros or set()
condition_sets = condition_sets_by_line or {}
for segment in split_top_level_c_source(
source,
filename=filename,
Expand Down Expand Up @@ -2432,6 +2504,11 @@ def _parse_translation_unit(
else:
enums.append(aggregate)
functions.extend(parsed_functions)
for function in parsed_functions:
function.condition_set = condition_sets.get(
segment.original_start_line,
frozenset(),
)
typedefs.extend(parsed_typedefs)
variables.extend(parsed_variables)
diagnostics.extend(parsed_diagnostics)
Expand All @@ -2443,6 +2520,10 @@ def _parse_translation_unit(
diagnostics.append(self._declarator_diagnostic(segment, str(error)))
continue
if function is not None:
function.condition_set = condition_sets.get(
segment.original_start_line,
frozenset(),
)
functions.append(function)
self._append_union_by_value_diagnostics(function, diagnostics)
continue
Expand All @@ -2461,6 +2542,11 @@ def _parse_translation_unit(
segment
)
functions.extend(parsed_functions)
for function in parsed_functions:
function.condition_set = condition_sets.get(
segment.original_start_line,
frozenset(),
)
typedefs.extend(parsed_typedefs)
variables.extend(parsed_variables)
for function in parsed_functions:
Expand Down Expand Up @@ -2509,12 +2595,20 @@ def _build_project(self, parsed_files: dict[str, CFile]) -> CProject:
self._index_file_includes(project, filename, file)
self._index_header_source_pairs(project)
resolve_project_types(project)
project.functions = {
function.name: function
for function in self._deduplicate_functions(all_functions, project.diagnostics)
}
normalized_functions = self._deduplicate_functions(all_functions, project.diagnostics)
functions_by_name: dict[str, list[CFunction]] = {}
for function in normalized_functions:
functions_by_name.setdefault(function.name, []).append(function)
for name, variants in functions_by_name.items():
if len(variants) == 1:
project.functions[name] = variants[0]
else:
project.conditional_function_variants[name] = variants
for function in project.functions.values():
self._append_union_by_value_diagnostics(function, project.diagnostics)
for variants in project.conditional_function_variants.values():
for function in variants:
self._append_union_by_value_diagnostics(function, project.diagnostics)
return project

def _index_struct(
Expand Down Expand Up @@ -2617,7 +2711,7 @@ def _index_file_includes(
filename: str,
file: CFile,
) -> None:
"""Populate include-graph, system-include, and unresolved-include sets."""
"""Populate include facts without extending the parsed input set."""
local_targets: set[str] = set()
system_targets: set[str] = set()
unresolved_targets: set[str] = set()
Expand Down
Loading
Loading