Skip to content

Commit d94df61

Browse files
authored
Merge pull request #77 from robotpy/gcc-preprocessor
Add GCC compatible preprocessing function
2 parents 2a17b27 + 8f9e862 commit d94df61

File tree

3 files changed

+180
-37
lines changed

3 files changed

+180
-37
lines changed

cxxheaderparser/lexer.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class LexError(CxxParseError):
1717
else:
1818
Protocol = object
1919

20-
_line_re = re.compile(r'^\#[\t ]*line (\d+) "(.*)"')
20+
_line_re = re.compile(r'^\#[\t ]*(line)? (\d+) "(.*)"')
2121
_multicomment_re = re.compile("\n[\\s]+\\*")
2222

2323

@@ -448,8 +448,8 @@ def t_PP_DIRECTIVE(self, t: LexToken):
448448
# handle line macros
449449
m = _line_re.match(t.value)
450450
if m:
451-
self.filename = m.group(2)
452-
self.line_offset = 1 + self.lex.lineno - int(m.group(1))
451+
self.filename = m.group(3)
452+
self.line_offset = 1 + self.lex.lineno - int(m.group(2))
453453
return None
454454
# ignore C++23 warning directive
455455
if t.value.startswith("#warning"):

cxxheaderparser/preprocessor.py

Lines changed: 130 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,141 @@
11
"""
2-
Contains optional preprocessor support via pcpp
2+
Contains optional preprocessor support functions
33
"""
44

55
import io
66
import re
77
import os
8+
import subprocess
9+
import sys
810
import typing
9-
from .options import PreprocessorFunction
1011

11-
from pcpp import Preprocessor, OutputDirective, Action
12+
from .options import PreprocessorFunction
1213

1314

1415
class PreprocessorError(Exception):
1516
pass
1617

1718

18-
class _CustomPreprocessor(Preprocessor):
19-
def __init__(
20-
self,
21-
encoding: typing.Optional[str],
22-
passthru_includes: typing.Optional["re.Pattern"],
23-
):
24-
Preprocessor.__init__(self)
25-
self.errors: typing.List[str] = []
26-
self.assume_encoding = encoding
27-
self.passthru_includes = passthru_includes
19+
#
20+
# GCC preprocessor support
21+
#
22+
23+
24+
def _gcc_filter(fname: str, fp: typing.TextIO) -> str:
25+
new_output = io.StringIO()
26+
keep = True
27+
fname = fname.replace("\\", "\\\\")
28+
29+
for line in fp:
30+
if line.startswith("# "):
31+
last_quote = line.rfind('"')
32+
if last_quote != -1:
33+
keep = line[:last_quote].endswith(fname)
34+
35+
if keep:
36+
new_output.write(line)
37+
38+
new_output.seek(0)
39+
return new_output.read()
40+
41+
42+
def make_gcc_preprocessor(
43+
*,
44+
defines: typing.List[str] = [],
45+
include_paths: typing.List[str] = [],
46+
retain_all_content: bool = False,
47+
encoding: typing.Optional[str] = None,
48+
gcc_args: typing.List[str] = ["g++"],
49+
print_cmd: bool = True,
50+
) -> PreprocessorFunction:
51+
"""
52+
Creates a preprocessor function that uses g++ to preprocess the input text.
53+
54+
gcc is a high performance and accurate precompiler, but if an #include
55+
directive can't be resolved or other oddity exists in your input it will
56+
throw an error.
57+
58+
:param defines: list of #define macros specified as "key value"
59+
:param include_paths: list of directories to search for included files
60+
:param retain_all_content: If False, only the parsed file content will be retained
61+
:param encoding: If specified any include files are opened with this encoding
62+
:param gcc_args: This is the path to G++ and any extra args you might want
63+
:param print_cmd: Prints the gcc command as its executed
64+
65+
.. code-block:: python
66+
67+
pp = make_gcc_preprocessor()
68+
options = ParserOptions(preprocessor=pp)
69+
70+
parse_file(content, options=options)
71+
72+
"""
73+
74+
if not encoding:
75+
encoding = "utf-8"
76+
77+
def _preprocess_file(filename: str, content: str) -> str:
78+
cmd = gcc_args + ["-w", "-E", "-C"]
79+
80+
for p in include_paths:
81+
cmd.append(f"-I{p}")
82+
for d in defines:
83+
cmd.append(f"-D{d.replace(' ', '=')}")
84+
85+
kwargs = {"encoding": encoding}
86+
if filename == "<str>":
87+
cmd.append("-")
88+
filename = "<stdin>"
89+
kwargs["input"] = content
90+
else:
91+
cmd.append(filename)
92+
93+
if print_cmd:
94+
print("+", " ".join(cmd), file=sys.stderr)
95+
96+
result: str = subprocess.check_output(cmd, **kwargs) # type: ignore
97+
if not retain_all_content:
98+
result = _gcc_filter(filename, io.StringIO(result))
99+
100+
return result
101+
102+
return _preprocess_file
103+
104+
105+
#
106+
# PCPP preprocessor support (not installed by default)
107+
#
28108

29-
def on_error(self, file, line, msg):
30-
self.errors.append(f"{file}:{line} error: {msg}")
31109

32-
def on_include_not_found(self, *ignored):
33-
raise OutputDirective(Action.IgnoreAndPassThrough)
110+
try:
111+
import pcpp
112+
from pcpp import Preprocessor, OutputDirective, Action
34113

35-
def on_comment(self, *ignored):
36-
return True
114+
class _CustomPreprocessor(Preprocessor):
115+
def __init__(
116+
self,
117+
encoding: typing.Optional[str],
118+
passthru_includes: typing.Optional["re.Pattern"],
119+
):
120+
Preprocessor.__init__(self)
121+
self.errors: typing.List[str] = []
122+
self.assume_encoding = encoding
123+
self.passthru_includes = passthru_includes
37124

125+
def on_error(self, file, line, msg):
126+
self.errors.append(f"{file}:{line} error: {msg}")
38127

39-
def _filter_self(fname: str, fp: typing.TextIO) -> str:
128+
def on_include_not_found(self, *ignored):
129+
raise OutputDirective(Action.IgnoreAndPassThrough)
130+
131+
def on_comment(self, *ignored):
132+
return True
133+
134+
except ImportError:
135+
pcpp = None
136+
137+
138+
def _pcpp_filter(fname: str, fp: typing.TextIO) -> str:
40139
# the output of pcpp includes the contents of all the included files, which
41140
# isn't what a typical user of cxxheaderparser would want, so we strip out
42141
# the line directives and any content that isn't in our original file
@@ -69,6 +168,13 @@ def make_pcpp_preprocessor(
69168
Creates a preprocessor function that uses pcpp (which must be installed
70169
separately) to preprocess the input text.
71170
171+
If missing #include files are encountered, this preprocessor will ignore the
172+
error. This preprocessor is pure python so it's very portable, and is a good
173+
choice if performance isn't critical.
174+
175+
:param defines: list of #define macros specified as "key value"
176+
:param include_paths: list of directories to search for included files
177+
:param retain_all_content: If False, only the parsed file content will be retained
72178
:param encoding: If specified any include files are opened with this encoding
73179
:param passthru_includes: If specified any #include directives that match the
74180
compiled regex pattern will be part of the output.
@@ -82,6 +188,9 @@ def make_pcpp_preprocessor(
82188
83189
"""
84190

191+
if pcpp is None:
192+
raise PreprocessorError("pcpp is not installed")
193+
85194
def _preprocess_file(filename: str, content: str) -> str:
86195
pp = _CustomPreprocessor(encoding, passthru_includes)
87196
if include_paths:
@@ -119,6 +228,6 @@ def _preprocess_file(filename: str, content: str) -> str:
119228
filename = filename.replace(os.sep, "/")
120229
break
121230

122-
return _filter_self(filename, fp)
231+
return _pcpp_filter(filename, fp)
123232

124233
return _preprocess_file

tests/test_preprocessor.py

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import os
22
import pathlib
3+
import pytest
34
import re
5+
import shutil
6+
import subprocess
7+
import typing
48

5-
from cxxheaderparser.options import ParserOptions
6-
from cxxheaderparser.preprocessor import make_pcpp_preprocessor
9+
from cxxheaderparser.options import ParserOptions, PreprocessorFunction
10+
from cxxheaderparser import preprocessor
711
from cxxheaderparser.simple import (
812
NamespaceScope,
913
ParsedData,
@@ -22,12 +26,33 @@
2226
)
2327

2428

25-
def test_basic_preprocessor() -> None:
29+
@pytest.fixture(params=["gcc", "pcpp"])
30+
def make_pp(request) -> typing.Callable[..., PreprocessorFunction]:
31+
param = request.param
32+
if param == "gcc":
33+
gcc_path = shutil.which("g++")
34+
if not gcc_path:
35+
pytest.skip("g++ not found")
36+
37+
subprocess.run([gcc_path, "--version"])
38+
return preprocessor.make_gcc_preprocessor
39+
elif param == "pcpp":
40+
if preprocessor.pcpp is None:
41+
pytest.skip("pcpp not installed")
42+
return preprocessor.make_pcpp_preprocessor
43+
else:
44+
assert False
45+
46+
47+
def test_basic_preprocessor(
48+
make_pp: typing.Callable[..., PreprocessorFunction]
49+
) -> None:
2650
content = """
2751
#define X 1
2852
int x = X;
2953
"""
30-
options = ParserOptions(preprocessor=make_pcpp_preprocessor())
54+
55+
options = ParserOptions(preprocessor=make_pp())
3156
data = parse_string(content, cleandoc=True, options=options)
3257

3358
assert data == ParsedData(
@@ -45,7 +70,10 @@ def test_basic_preprocessor() -> None:
4570
)
4671

4772

48-
def test_preprocessor_omit_content(tmp_path: pathlib.Path) -> None:
73+
def test_preprocessor_omit_content(
74+
make_pp: typing.Callable[..., PreprocessorFunction],
75+
tmp_path: pathlib.Path,
76+
) -> None:
4977
"""Ensure that content in other headers is omitted"""
5078
h_content = '#include "t2.h"' "\n" "int x = X;\n"
5179
h2_content = "#define X 2\n" "int omitted = 1;\n"
@@ -56,7 +84,7 @@ def test_preprocessor_omit_content(tmp_path: pathlib.Path) -> None:
5684
with open(tmp_path / "t2.h", "w") as fp:
5785
fp.write(h2_content)
5886

59-
options = ParserOptions(preprocessor=make_pcpp_preprocessor())
87+
options = ParserOptions(preprocessor=make_pp())
6088
data = parse_file(tmp_path / "t1.h", options=options)
6189

6290
assert data == ParsedData(
@@ -74,7 +102,10 @@ def test_preprocessor_omit_content(tmp_path: pathlib.Path) -> None:
74102
)
75103

76104

77-
def test_preprocessor_omit_content2(tmp_path: pathlib.Path) -> None:
105+
def test_preprocessor_omit_content2(
106+
make_pp: typing.Callable[..., PreprocessorFunction],
107+
tmp_path: pathlib.Path,
108+
) -> None:
78109
"""
79110
Ensure that content in other headers is omitted while handling pcpp
80111
relative path quirk
@@ -91,9 +122,7 @@ def test_preprocessor_omit_content2(tmp_path: pathlib.Path) -> None:
91122
with open(tmp_path2 / "t2.h", "w") as fp:
92123
fp.write(h2_content)
93124

94-
options = ParserOptions(
95-
preprocessor=make_pcpp_preprocessor(include_paths=[str(tmp_path)])
96-
)
125+
options = ParserOptions(preprocessor=make_pp(include_paths=[str(tmp_path)]))
97126

98127
# Weirdness happens here
99128
os.chdir(tmp_path)
@@ -114,7 +143,9 @@ def test_preprocessor_omit_content2(tmp_path: pathlib.Path) -> None:
114143
)
115144

116145

117-
def test_preprocessor_encoding(tmp_path: pathlib.Path) -> None:
146+
def test_preprocessor_encoding(
147+
make_pp: typing.Callable[..., PreprocessorFunction], tmp_path: pathlib.Path
148+
) -> None:
118149
"""Ensure we can handle alternate encodings"""
119150
h_content = b"// \xa9 2023 someone\n" b'#include "t2.h"' b"\n" b"int x = X;\n"
120151

@@ -126,7 +157,7 @@ def test_preprocessor_encoding(tmp_path: pathlib.Path) -> None:
126157
with open(tmp_path / "t2.h", "wb") as fp:
127158
fp.write(h2_content)
128159

129-
options = ParserOptions(preprocessor=make_pcpp_preprocessor(encoding="cp1252"))
160+
options = ParserOptions(preprocessor=make_pp(encoding="cp1252"))
130161
data = parse_file(tmp_path / "t1.h", options=options, encoding="cp1252")
131162

132163
assert data == ParsedData(
@@ -144,6 +175,7 @@ def test_preprocessor_encoding(tmp_path: pathlib.Path) -> None:
144175
)
145176

146177

178+
@pytest.mark.skipif(preprocessor.pcpp is None, reason="pcpp not installed")
147179
def test_preprocessor_passthru_includes(tmp_path: pathlib.Path) -> None:
148180
"""Ensure that all #include pass through"""
149181
h_content = '#include "t2.h"\n'
@@ -155,7 +187,9 @@ def test_preprocessor_passthru_includes(tmp_path: pathlib.Path) -> None:
155187
fp.write("")
156188

157189
options = ParserOptions(
158-
preprocessor=make_pcpp_preprocessor(passthru_includes=re.compile(".+"))
190+
preprocessor=preprocessor.make_pcpp_preprocessor(
191+
passthru_includes=re.compile(".+")
192+
)
159193
)
160194
data = parse_file(tmp_path / "t1.h", options=options)
161195

0 commit comments

Comments
 (0)