import argparse
import dataclasses
import io
import itertools
import os
import pathlib
import re
import shlex
import shutil
import sys
import subprocess
import tempfile
from typing import Any, Callable, List, Tuple
import modulemap_config
_HEADER_RE = re.compile(r'(?:(private)\s+)?(?:(textual)\s+)?header\s+"([^"]+)"')
_SIMPLE_HEADER_RE = re.compile(r'(\bheader\s+")([^"]+)(")')
_REQUIRES_RE = re.compile(r'^\s*requires\s+(.*)')
_TRIPLE = re.compile('^(.*)-(linux|cros)-(gnu|gnueabi|gnueabihf|android)$')
_DEBUG_SOURCE = '/tmp/debug_generate_system_modulemap.cc'
_DEBUG_SCRIPT = pathlib.Path('/tmp/debug_generate_system_modulemap.sh')
_MODULEMAP_START = re.compile(
r'^[ \t]*\b(?:(?:explicit|framework)\s+)*module\s+"?([^" ]+)"?\s.*\{',
re.MULTILINE)
def _absolute(p: pathlib.Path) -> pathlib.Path:
return pathlib.Path(os.path.abspath(p))
def _format_clang_args(args, os):
if os != 'win':
return args
else:
return [f'-clang:{arg}' for arg in args]
_SRC_PREFIX = pathlib.Path(
os.path.relpath(pathlib.Path(__file__).parents[3], os.getcwd()))
class AllowList:
def __init__(self, headers: list[modulemap_config.Header]):
self.default = modulemap_config.Header(path='')
self.hdrs = {hdr.path: hdr for hdr in headers if hdr.exists}
def textual(self, path: str) -> bool | None:
hdr = self.hdrs.get(path)
if hdr is not None and hdr.textual is not None:
return hdr.textual
if 'bits' in pathlib.Path(path).parts:
return True
def exports(self, path: str) -> list[str]:
return self.hdrs.get(path, self.default).exports
def module_name(self, path: str) -> str | None:
return self.hdrs.get(path, self.default).module_name
def includes(self) -> list[str]:
return [path for path, hdr in self.hdrs.items() if not hdr.lazy]
def private(self, path: str) -> bool:
return path not in self.hdrs
def forced(self) -> list[modulemap_config.Header]:
return [h for h in self.hdrs.values() if h.force]
_DISABLED_HEADERS = {
'mm3dnow.h', }
def _requires_met(require: str, os: str, cpu: str) -> bool:
invert = require.startswith('!')
require = require.lstrip('!')
met = False
if require == 'altivec':
met = False
elif require == 'arm':
met = cpu == 'arm' or cpu == 'arm64'
elif require == 'arm64':
met = cpu == 'arm64'
elif require == 'freestanding':
met = False
elif require == 'gnuinlineasm':
met = True
elif require == 'opencl':
met = False
elif require == 'systemz':
met = cpu == 's390x'
elif require == 'x86':
met = cpu in ['x86', 'x64']
elif require == 'windows':
met = os == 'win'
elif require == 'neon':
met = cpu == 'arm64'
elif require == 'sve':
met = False
else:
raise NotImplementedError(f'Unknown require: {require}')
return not met if invert else met
@dataclasses.dataclass(order=True)
class Header:
path: pathlib.Path
private: bool
textual: bool
requires: list[str] = dataclasses.field(default_factory=list)
module_name: str | None = None
exports: list[str] = dataclasses.field(default_factory=lambda: ['*'])
def _module_end(modulemap: str, start: int) -> int:
depth = 0
for c in range(start, len(modulemap)):
if modulemap[c] == '{':
depth += 1
elif modulemap[c] == '}':
depth -= 1
if depth == 0:
return c + 1
raise ValueError(f'No closing brace for {modulemap[start:start+100]}...')
def modify_modulemap(content: str, modify_modules: dict[str, Callable[[str],
str]],
modified_modules: set[str]) -> str:
@dataclasses.dataclass
class Module:
name: str
start: int
end: int
submodules: list = dataclasses.field(default_factory=list)
root = Module('root', 0, len(content))
stack = [root]
for m in _MODULEMAP_START.finditer(content):
module = Module(name=m.group(1),
start=m.start(),
end=_module_end(content, m.start()))
while stack and stack[-1].end <= module.start:
stack.pop()
stack[-1].submodules.append(module)
stack.append(module)
module_path = []
def render(submodules: list[Module], start: int, end: int) -> str:
result = []
upto = start
for m in submodules:
result.append(content[upto:m.start])
module_path.append(m.name)
result.append(render(m.submodules, m.start, m.end))
module_path.pop()
upto = m.end
result.append(content[upto:end])
result = ''.join(result)
key = '.'.join(module_path)
if key in modify_modules:
modified_modules.add(key)
result = modify_modules[key](result)
return result
return render(root.submodules, root.start, root.end)
def split_modulemap(modulemap: str) -> dict[str, str]:
results = {}
upto = 0
for m in _MODULEMAP_START.finditer(modulemap):
if m.start() < upto:
continue
upto = _module_end(modulemap, m.start())
results[m.group(1)] = modulemap[m.start():upto]
return results
def parse_modulemap(
modulemap_path: pathlib.Path, modify_modules: dict[str, Callable[[str],
str]],
modified_modules: set[str]) -> Tuple[pathlib.Path, list[Header]]:
matches = []
requires_stack = []
content = modify_modulemap(modulemap_path.read_text(), modify_modules,
modified_modules)
for line in content.splitlines():
if '{' in line:
requires_stack.append([])
m_req = _REQUIRES_RE.search(line)
if m_req:
req_str = m_req.group(1).split('//')[0].strip()
reqs = [r.strip() for r in req_str.split(',') if r.strip()]
requires_stack[-1].extend(reqs)
m = _HEADER_RE.search(line)
if m:
matches.append((
m.group(3),
bool(m.group(1)),
bool(m.group(2)),
itertools.chain(*requires_stack),
))
if '}' in line:
requires_stack.pop()
assert not requires_stack
common_prefix = os.path.commonpath([m[0] for m in matches])
include_dir = _absolute(modulemap_path.parent / common_prefix)
headers = []
for rel, is_private, is_textual, reqs in matches:
path = pathlib.Path(rel[len(common_prefix):].lstrip('/'))
is_private |= any(part.startswith('__') for part in path.parts)
headers.append(
Header(
path=path,
private=is_private,
textual=is_textual,
requires=list(reqs),
))
return include_dir, headers
def parse_depfile(content: str) -> list[pathlib.Path]:
content = content.replace('\\\n', '').split(': ', 1)[1]
deps = []
current = []
for part in content.split():
if part.endswith('\\'):
current.append(part[:-1])
else:
current.append(part)
deps.append(pathlib.Path(' '.join(current)))
current = []
return deps
def calculate_transitive_headers(clang_args: list[str],
include_dirs: list[Tuple[pathlib.Path,
List[Header]]],
sysroot_dirs: list[pathlib.Path],
allowlist: AllowList,
target_os: str,
target_cpu: str,
debug: bool = False) -> list[Header]:
sysroot_dirs = sorted([_absolute(d) for d in sysroot_dirs],
key=lambda p: len(p.parts),
reverse=True)
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
source_file = tmpdir_path / 'dummy.cpp'
dep_file = tmpdir_path / 'dummy.d'
input_headers = {source_file.absolute()}
with open(source_file, 'w') as f:
for include_dir, headers in include_dirs:
for h in headers:
if not h.private and str(h.path) not in _DISABLED_HEADERS:
if all(_requires_met(r, target_os, target_cpu) for r in h.requires):
f.write(f'#include <{h.path}>\n')
input_headers.add(include_dir / h.path)
for h in allowlist.includes():
f.write(f'#if __has_include(<{h}>)\n')
f.write(f'#include <{h}>\n')
f.write(f'#endif\n')
cmd = [
*clang_args,
*_format_clang_args(['-E'], target_os),
str(source_file),
*_format_clang_args(
[
'-o',
'NUL' if os.name == 'nt' else '/dev/null',
'-MD',
'-MF',
str(dep_file),
],
target_os)
]
if debug:
shutil.copyfile(source_file, _DEBUG_SOURCE)
content = f"""#!/bin/bash
cd "{os.getcwd()}"
{shlex.join(cmd)}
"""
content = content.replace(str(source_file), _DEBUG_SOURCE)
content = content.replace(str(dep_file), _DEBUG_SOURCE + '.o.d')
_DEBUG_SCRIPT.write_text(content)
_DEBUG_SCRIPT.chmod(0o755)
print(f'Saved debug script to {_DEBUG_SCRIPT}')
sys.exit(0)
ps = subprocess.run(cmd, check=False)
if ps.returncode != 0:
print(f'Suggestion: Run `cd {os.getcwd()} && {sys.argv[0]} --debug',
f'{shlex.join(sys.argv[1:])}` to debug')
sys.exit(ps.returncode)
deps = parse_depfile(dep_file.read_text())
headers = {}
for dep in deps:
full = _absolute(dep)
if full in input_headers:
continue
rel = full.name
found = False
for d in sysroot_dirs:
if full.is_relative_to(d):
rel = str(full.relative_to(d))
found = True
break
textual = allowlist.textual(rel)
if textual is None:
textual = not found
headers[full] = Header(
path=full,
private=allowlist.private(rel),
textual=textual,
module_name=allowlist.module_name(rel),
exports=allowlist.exports(rel),
)
for h in allowlist.forced():
for d in sysroot_dirs:
full = d / h.path
if full.exists():
if full not in headers:
headers[full] = Header(
path=full,
private=False,
textual=True,
)
else:
headers[full].private = False
break
return list(headers.values())
def combine_modulemaps(out: pathlib.Path, modulemaps: list[pathlib.Path],
headers: List[Header], module_name: str,
extra_modules: list[Tuple[str, pathlib.Path]],
modify_modules: dict[str, Callable[[str], str]],
modified_modules: set[str]) -> str:
custom_header_prefix = os.path.relpath(
_SRC_PREFIX / 'buildtools/third_party/libc++', out.parent)
with io.StringIO() as s:
modules = {'system': 1}
if module_name:
s.write(f'module "{module_name}" [system] {{\n')
for mm in modulemaps:
prefix = os.path.relpath(mm.parent, out.parent)
def rebase_path(p: str) -> str:
if p == '__assertion_handler':
return f'{custom_header_prefix}/__assertion_handler'
return os.path.normpath(os.path.join(prefix, p))
mm_content = _SIMPLE_HEADER_RE.sub(
lambda m: f'{m.group(1)}{rebase_path(m.group(2))}{m.group(3)}',
modify_modulemap(mm.read_text(), modify_modules, modified_modules))
mm_content = mm_content.replace(
'@LIBCXX_CONFIG_SITE_MODULE_ENTRY@ // generated via CMake',
f'textual header "{custom_header_prefix}/__config_site"')
s.write(mm_content)
s.write('\n')
for header in headers:
header.path = pathlib.Path(os.path.relpath(header.path, out.parent))
headers.sort()
for header in headers:
if header.private and header.textual:
continue
private = 'private ' if header.private else ''
textual = 'textual ' if header.textual else ''
if header.module_name:
module_name = header.module_name
elif header.path.name in modules:
modules[header.path.name] += 1
module_name = f'"{header.path.name}_{modules[header.path.name]}"'
else:
modules[header.path.name] = 1
module_name = f'"{header.path.name}"'
s.write(f'module {module_name} {{\n')
s.write(f' {private}{textual}header "{header.path}"\n')
for exp in header.exports:
s.write(f' export {exp}\n')
s.write('}\n')
for content, source_modulemap in extra_modules:
prefix = os.path.relpath(source_modulemap.parent, out.parent)
def rebase_path(p: str) -> str:
return os.path.normpath(os.path.join(prefix, p))
mm_content = _SIMPLE_HEADER_RE.sub(
lambda m: f'{m.group(1)}{rebase_path(m.group(2))}{m.group(3)}',
modify_modulemap(content, modify_modules, modified_modules))
s.write(mm_content)
s.write('\n')
if module_name:
s.write('}\n')
return s.getvalue()
def main(args, extra_args):
modify_modules = {}
for mod in args.exclude_module:
modify_modules[mod] = lambda s: ''
def make_appender(content):
return lambda s: s[:-1] + '\n' + content + '\n}'
for app in args.append_module:
mod_name, appended = app.split(':', 1)
modify_modules[mod_name] = make_appender(appended.replace('\\n', '\n'))
modified_modules = set()
deps = []
if args.sysroot or args.os == 'win':
sysroot_dirs = []
if args.sysroot:
extra_args.append(f'--sysroot={args.sysroot}')
subdir = args.sysroot / 'usr/include'
sysroot_dirs.append(subdir)
for d in subdir.iterdir():
if d.is_dir() and _TRIPLE.match(d.name) is not None:
sysroot_dirs.append(d)
if args.os == 'win':
for arg in extra_args:
if arg.startswith('/I'):
sysroot_dirs.append(pathlib.Path(arg.removeprefix('/I')))
clang_args = [
str(args.clang),
*_format_clang_args(
[
'-O2',
'-D_FORTIFY_SOURCE=3',
'-nostdinc++',
f'-I{_SRC_PREFIX}/third_party/libc++/src/include',
f'-I{_SRC_PREFIX}/third_party/libc++abi/src/include',
f'-I{_SRC_PREFIX}/buildtools/third_party/libc++',
'-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE',
'-D_LIBCPP_BUILDING_LIBRARY',
'-std=c++23',
'-no-canonical-prefixes',
],
args.os),
*extra_args
]
allowlist = AllowList(modulemap_config.headers(os=args.os))
deps = calculate_transitive_headers(
clang_args=clang_args,
include_dirs=[
parse_modulemap(mm, modify_modules, modified_modules)
for mm in args.modulemap
],
sysroot_dirs=sysroot_dirs,
allowlist=allowlist,
target_os=args.os,
target_cpu=args.cpu,
debug=args.debug,
)
known_modules = {}
for modulemap in args.partial_modulemap:
for name, content in split_modulemap(modulemap.read_text()).items():
known_modules[name] = (content, modulemap)
for module in args.module:
if module not in known_modules:
available = ', '.join(sorted(known_modules))
raise ValueError(f'Module \'{module}\' not found in partial modulemaps. '
f'Available: {available}')
args.output.write_text(
combine_modulemaps(
out=args.output,
modulemaps=args.modulemap,
headers=deps,
module_name=args.module_name,
extra_modules=[known_modules[module] for module in args.module],
modify_modules=modify_modules,
modified_modules=modified_modules))
unused = modify_modules.keys() - modified_modules
if unused:
raise ValueError(f'Unable to find modules to modify: {unused}')
if __name__ == '__main__':
parser = argparse.ArgumentParser(
description='Generate a system modulemap using clang to discover deps')
parser.add_argument('--clang',
type=pathlib.Path,
help='Path to the Clang compiler binary.')
parser.add_argument('--sysroot',
type=pathlib.Path,
help='Path to the sysroot directory.')
parser.add_argument('--output',
type=pathlib.Path,
required=True,
help='Path where the merged modulemap will be written.')
parser.add_argument('--module-name', help='Name of the module')
parser.add_argument(
'--modulemap',
action='append',
type=pathlib.Path,
required=True,
help='Path to a modulemap to merge. Can be specified multiple times.')
parser.add_argument(
'--partial-modulemap',
action='append',
type=pathlib.Path,
default=[],
help=('Path to a modulemap to partially merge. '
'Top-level modules must be specified via --module.'))
parser.add_argument('--module',
action='append',
type=str,
default=[],
help=('Name of a top-level module to selectively extract '
'from partial modulemaps.'))
parser.add_argument(
'--exclude-module',
action='append',
type=str,
default=[],
help='Name of a module to exclude from the generated modulemap.')
parser.add_argument(
'--append-module',
action='append',
type=str,
default=[],
help='Name of a module and content to append, in the format name:content.'
)
parser.add_argument('--os', help='GN\'s $target_os variable')
parser.add_argument('--cpu', help='GN\'s $target_cpu variable')
parser.add_argument(
'--debug',
action='store_true',
help=(
f'Instead of compiling, generate a bash script {str(_DEBUG_SCRIPT)} '
'that attempts to compile'))
args, remaining_args = parser.parse_known_args()
if remaining_args and remaining_args[0] == '--':
remaining_args.pop(0)
main(args, remaining_args)