Skip to content
Prev Previous commit
Next Next commit
feat: Darwin-style framework search (-F, -iframework) with ordered pa…
…th lookup

This change teaches simplecpp to resolve headers from Apple-style Framework
directories while preserving the left-to-right order of interleaved
-I/-F/-iframework search paths (like GCC/Clang on Darwin).

This enables both:
- `__has_include(<Pkg/Hdr.h>)` -> `<Pkg.framework/Headers/Hdr.h>` (or `PrivateHeaders`)
- `#include <Pkg/Hdr.h>`       -> same framework layout when a package prefix exists

Changes:
- Add `DUI::SearchPath` with `PathKind {Include, Framework, SystemFramework}`.
- If `DUI::searchPaths` is non-empty, use it verbatim (interleaved -I/-F/-iframework).
  Otherwise preserve back-compat by mirroring `includePaths` as Include paths.
- Update `openHeader()` to consult typed paths, and only rewrite `<Pkg/Hdr.h>`
  to `Pkg.framework/{Headers,PrivateHeaders}/Hdr.h` when a package prefix exists.
- Implement `toAppleFrameworkRelatives()` returning prioritized candidates
  (Headers first, then PrivateHeaders).
- Tests use `PathKind::Framework` when checking framework layout.

CLI
- Support -F<dir> and -iframework<dir> (keep -I as before).

Behavior notes
- The order of -I/-F/-iframework is preserved exactly as provided.
- `Framework` vs `SystemFramework` differ only in diagnostic semantics (not lookup).
- Legacy users who only set `DUI::includePaths` see identical behavior.

Tests
- Add `appleFrameworkHasIncludeTest` for `__has_include` resolution.
- Add `appleFrameworkIncludeTest` for `#include` resolution.
- Add dummy fixture: `testsuite/Foundation.framework/Headers/Foundation.h`.

This brings simplecpp closer to GCC/Clang behavior on macOS and enables
robust resolution of framework headers like `Foundation/Foundation.h`.

Co-authored-by: Jean-Christophe Fillion-Robin <jchris.fillionr@kitware.com>
Co-authored-by: Hans Johnson <hans-johnson@uiowa.edu>
Suggested-by: glankk <glankk@users.noreply.github.com>
  • Loading branch information
hjmjohnson and jcfr committed Aug 30, 2025
commit aa8ef0e09b5c53637d4ab0ce716ee21bafcf0894
259 changes: 246 additions & 13 deletions integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,79 @@
import pathlib
import platform
import pytest
from testutils import simplecpp, format_include_path_arg, format_include
from testutils import (
simplecpp,
format_include_path_arg,
format_framework_path_arg,
format_iframework_path_arg,
format_include,
)

def __test_create_header(dir, hdr_relpath, with_pragma_once=True, already_included_error_msg=None):
"""
Creates a C header file under `dir/hdr_relpath` with simple include guards.

The file contains:
- optional `#pragma once` (when `with_pragma_once=True`)
- a header guard derived from `hdr_relpath` (e.g. "test.h" -> TEST_H_INCLUDED)
- optional `#error <already_included_error_msg>` if the guard is already defined
- a dummy non-preprocessor declaration to force line emission

Return absolute path to the created header file.
"""
inc_guard = hdr_relpath.upper().replace(".", "_") # "test.h" -> "TEST_H"
header_file = os.path.join(dir, hdr_relpath)
os.makedirs(os.path.dirname(header_file), exist_ok=True)
with open(header_file, 'wt') as f:
f.write(f"""
{"#pragma once" if with_pragma_once else ""}
#ifndef {inc_guard}_INCLUDED
#define {inc_guard}_INCLUDED
#else
{f"#error {already_included_error_msg}" if already_included_error_msg else ""}
#endif
int __force_line_emission; /* anything non-preprocessor */
""")
return header_file

def __test_create_source(dir, include, is_include_sys=False):
"""
Creates a minimal C source file that includes a single header.

The generated `<dir>/test.c` contains one `#include` directive.
If `is_include_sys` is True, the include is written as `<...>`;
otherwise it is written as `"..."`.

Returns absolute path to the created header file.
"""
src_file = os.path.join(dir, 'test.c')
with open(src_file, 'wt') as f:
f.write(f"""
#include {format_include(include, is_include_sys)}
""")
return src_file

def __test_create_framework(dir, fw_name, hdr_relpath, content="", private=False):
"""
Creates a minimal Apple-style framework layout containing one header.

The generated structure is:
`<dir>/<fw_name>.framework/{Headers|PrivateHeaders}/<hdr_relpath>`

The header file contains the given `content` followed by a dummy
declaration to force line emission.

Returns absolute path to the created header file.
"""
fwdir = os.path.join(dir, f"{fw_name}.framework", "PrivateHeaders" if private else "Headers")
header_file = os.path.join(fwdir, hdr_relpath)
os.makedirs(os.path.dirname(header_file), exist_ok=True)
with open(header_file, "wt", encoding="utf-8") as f:
f.write(f"""
{content}
int __force_line_emission; /* anything non-preprocessor */
""")
return header_file

def __test_relative_header_create_header(dir, with_pragma_once=True):
"""
Expand All @@ -18,18 +90,10 @@ def __test_relative_header_create_header(dir, with_pragma_once=True):
- absolute path to the created header file
- expected compiler error substring for duplicate inclusion
"""
header_file = os.path.join(dir, 'test.h')
with open(header_file, 'wt') as f:
f.write(f"""
{"#pragma once" if with_pragma_once else ""}
#ifndef TEST_H_INCLUDED
#define TEST_H_INCLUDED
#else
#error header_was_already_included
#endif
const int dummy = 1;
""")
return header_file, "error: #error header_was_already_included"
already_included_error_msg="header_was_already_included"
header_file = __test_create_header(
dir, "test.h", with_pragma_once=with_pragma_once, already_included_error_msg=already_included_error_msg)
return header_file, f"error: #error {already_included_error_msg}"

def __test_relative_header_create_source(dir, include1, include2, is_include1_sys=False, is_include2_sys=False, inv=False):
"""
Expand Down Expand Up @@ -258,6 +322,175 @@ def test_same_name_header(record_property, tmpdir):
assert "OK" in stdout
assert stderr == ""

@pytest.mark.parametrize("is_sys", (False, True))
@pytest.mark.parametrize("is_iframework", (False, True))
@pytest.mark.parametrize("is_private", (False, True))
def test_framework_lookup(record_property, tmpdir, is_sys, is_iframework, is_private):
# Arrange framework: <tmp>/FwRoot/MyKit.framework/(Headers|PrivateHeaders)/Component.h
fw_root = os.path.join(tmpdir, "FwRoot")
__test_create_framework(fw_root, "MyKit", "Component.h", private=is_private)

test_file = __test_create_source(tmpdir, "MyKit/Component.h", is_include_sys=is_sys)

args = [format_iframework_path_arg(fw_root) if is_iframework else format_framework_path_arg(fw_root), test_file]
_, stdout, stderr = simplecpp(args, cwd=tmpdir)
record_property("stdout", stdout)
record_property("stderr", stderr)

assert stderr == ""
relative = "PrivateHeaders" if is_private else "Headers"
assert f'#line 3 "{pathlib.PurePath(tmpdir).as_posix()}/FwRoot/MyKit.framework/{relative}/Component.h"' in stdout

@pytest.mark.parametrize("is_sys", (False, True))
@pytest.mark.parametrize(
"order,expected",
[
# Note:
# - `I1` / `F1` / `IFW1` point to distinct directories and contain `Component_1.h` (a decoy).
# - `I` / `F` / `IFW` point to directories that contain `Component.h`, which the
# translation unit (TU) includes via `#include "MyKit/Component.h"`.
#
# This makes the winning flag (-I, -F, or -iframework) uniquely identifiable
# in the preprocessor `#line` output.

# Sanity checks
(("I",), "I"),
(("F",), "F"),
(("IFW",), "IFW"),

# Includes (-I)
(("I1", "I"), "I"),
(("I", "I1"), "I"),
# Includes (-I) duplicates
(("I1", "I", "I1"), "I"),
(("I", "I1", "I"), "I"),

# Framework (-F)
(("F1", "F"), "F"),
(("F", "F1"), "F"),
# Framework (-F) duplicates
(("F1", "F", "F1"), "F"),
(("F", "F1", "F"), "F"),

# System framework (-iframework)
(("IFW1", "IFW"), "IFW"),
(("IFW", "IFW1"), "IFW"),
# System framework (-iframework) duplicates
(("IFW1", "IFW", "IFW1"), "IFW"),
(("IFW", "IFW1", "IFW"), "IFW"),

# -I and -F are processed as specified (left-to-right)
(("I", "F"), "I"),
(("I1", "I", "F"), "I"),
(("F", "I"), "F"),
(("F1", "F", "I"), "F"),

# -I and -F beat system framework (-iframework)
(("I", "IFW"), "I"),
(("F", "IFW"), "F"),
(("IFW", "F"), "F"),
(("IFW", "I", "F"), "I"),
(("IFW", "I1", "F1", "I", "F"), "I"),
(("IFW", "I"), "I"),
(("IFW", "F", "I"), "F"),
(("IFW", "F1", "I1", "F", "I"), "F"),
],
)
def test_searchpath_order(record_property, tmpdir, is_sys, order, expected):
"""
Validate include resolution order across -I (user include), -F (user framework),
and -iframework (system framework) using a minimal file layout, asserting which
physical header path appears in the preprocessor #line output.

The test constructs three parallel trees (two entries per kind):
- inc{,_1}/MyKit/Component{,_1}.h # for -I
- Fw{,_1}/MyKit.framework/Headers/Component{,_1}.h # for -F
- SysFw{,_1}/MyKit.framework/Headers/Component{,_1}.h # for -iframework

It then preprocesses a TU that includes "MyKit/Component.h" (or <...> when
is_sys=True), assembles compiler args in the exact `order`, and asserts that
only the expected path appears in #line. Distinct names (Component.h vs
Component_1.h) ensure a unique winner per bucket.

References:
- https://gcc.gnu.org/onlinedocs/cpp/Invocation.html#Invocation
- https://gcc.gnu.org/onlinedocs/gcc/Darwin-Options.html
"""

# Create two include dirs, two user framework dirs, and two system framework dirs
inc_dirs, fw_dirs, sysfw_dirs = [], [], []

def _suffix(idx: int) -> str:
return f"_{idx}" if idx > 0 else ""

for idx in range(2):
# -I paths
inc_dir = os.path.join(tmpdir, f"inc{_suffix(idx)}")
__test_create_header(inc_dir, hdr_relpath=f"MyKit/Component{_suffix(idx)}.h")
inc_dirs.append(inc_dir)

# -F paths (user frameworks)
fw_dir = os.path.join(tmpdir, f"Fw{_suffix(idx)}")
__test_create_framework(fw_dir, "MyKit", f"Component{_suffix(idx)}.h")
fw_dirs.append(fw_dir)

# -iframework paths (system frameworks)
sysfw_dir = os.path.join(tmpdir, f"SysFw{_suffix(idx)}")
__test_create_framework(sysfw_dir, "MyKit", f"Component{_suffix(idx)}.h")
sysfw_dirs.append(sysfw_dir)

# Translation unit under test: include MyKit/Component.h (quote or system form)
test_file = __test_create_source(tmpdir, "MyKit/Component.h", is_include_sys=is_sys)

def idx_from_flag(prefix: str, flag: str) -> int:
"""Extract numeric suffix from tokens like 'I1', 'F1', 'IFW1'.
Returns 0 when no suffix is present (e.g., 'I', 'F', 'IFW')."""
return int(flag[len(prefix):]) if len(flag) > len(prefix) else 0

# Build argv in the exact order requested by `order`
args = []
for flag in order:
if flag in ["I", "I1"]:
args.append(format_include_path_arg(inc_dirs[idx_from_flag("I", flag)]))
elif flag in ["F", "F1"]:
args.append(format_framework_path_arg(fw_dirs[idx_from_flag("F", flag)]))
elif flag in ["IFW", "IFW1"]:
args.append(format_iframework_path_arg(sysfw_dirs[idx_from_flag("IFW", flag)]))
else:
raise AssertionError(f"unknown flag in order: {flag}")
args.append(test_file)

# Run the preprocessor and capture outputs
_, stdout, stderr = simplecpp(args, cwd=tmpdir)
record_property("stdout", stdout)
record_property("stderr", stderr)

# Resolve the absolute expected/forbidden paths we want to see in #line output
root = pathlib.PurePath(tmpdir).as_posix()

inc_paths = [f"{root}/inc{_suffix(idx)}/MyKit/Component{_suffix(idx)}.h" for idx in range(2)]
fw_paths = [f"{root}/Fw{_suffix(idx)}/MyKit.framework/Headers/Component{_suffix(idx)}.h" for idx in range(2)]
ifw_paths = [f"{root}/SysFw{_suffix(idx)}/MyKit.framework/Headers/Component{_suffix(idx)}.h" for idx in range(2)]
all_candidate_paths = [*inc_paths, *fw_paths, *ifw_paths]

# Compute the single path we expect to appear
expected_path = None
if expected in ["I", "I1"]:
expected_path = inc_paths[idx_from_flag("I", expected)]
elif expected in ["F", "F1"]:
expected_path = fw_paths[idx_from_flag("F", expected)]
elif expected in ["IFW", "IFW1"]:
expected_path = ifw_paths[idx_from_flag("IFW", expected)]
assert expected_path is not None, "test configuration error: expected token not recognized"

# Assert ONLY the expected path appears in the preprocessor #line output
assert expected_path in stdout
for p in (p for p in all_candidate_paths if p != expected_path):
assert p not in stdout

# No diagnostics expected
assert stderr == ""

def test_pragma_once_matching(record_property, tmpdir):
test_dir = os.path.join(tmpdir, "test_dir")
test_subdir = os.path.join(test_dir, "test_subdir")
Expand Down
13 changes: 12 additions & 1 deletion main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ int main(int argc, char **argv)
}
case 'I': { // include path
const char * const value = arg[2] ? (argv[i] + 2) : argv[++i];
dui.includePaths.push_back(value);
dui.searchPaths.push_back({value, simplecpp::DUI::PathKind::Include});
found = true;
break;
}
case 'F': { // framework path
const char * const value = arg[2] ? (argv[i] + 2) : argv[++i];
dui.searchPaths.push_back({value, simplecpp::DUI::PathKind::Framework});
found = true;
break;
}
Expand All @@ -54,6 +60,9 @@ int main(int argc, char **argv)
} else if (std::strncmp(arg, "-is",3)==0) {
use_istream = true;
found = true;
} else if (std::strncmp(arg, "-iframework", 11) == 0) {
dui.searchPaths.push_back({arg + 11, simplecpp::DUI::PathKind::SystemFramework});
found = true;
}
break;
case 's':
Expand Down Expand Up @@ -100,6 +109,8 @@ int main(int argc, char **argv)
std::cout << "simplecpp [options] filename" << std::endl;
std::cout << " -DNAME Define NAME." << std::endl;
std::cout << " -IPATH Include path." << std::endl;
std::cout << " -FPATH Framework path." << std::endl;
std::cout << " -iframeworkPATH System framework path." << std::endl;
std::cout << " -include=FILE Include FILE." << std::endl;
std::cout << " -UNAME Undefine NAME." << std::endl;
std::cout << " -std=STD Specify standard." << std::endl;
Expand Down
Loading