Skip to content

Commit cba48f3

Browse files
committed
Adding C++ parser.
1 parent 1c89bdb commit cba48f3

5 files changed

Lines changed: 3193 additions & 1 deletion

File tree

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
#!/usr/bin/env python3
2+
3+
# Copyright 2026 The Logica Authors
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
"""Compare C++ parser output vs python logica.py parse on integration tests.
18+
19+
Runs on all integration_tests/*.l files and compares JSON ASTs.
20+
Output format is intentionally similar to common/logica_test.py.
21+
22+
Usage:
23+
python3 parser_cpp/compare_integration_parse.py
24+
python3 parser_cpp/compare_integration_parse.py test_only=duckdb_json_test,closure_test
25+
python3 parser_cpp/compare_integration_parse.py strict_errors
26+
python3 parser_cpp/compare_integration_parse.py show_timings
27+
28+
Notes:
29+
- Assumes repo root as working directory.
30+
- Honors LOGICAPATH environment variable for both parsers.
31+
- Uses in-process parsing for both modes by temporarily setting LOGICA_PARSER.
32+
- By default, tests PASS if both parsers fail (same success/failure). Use strict_errors
33+
to require identical stderr on failing parses.
34+
"""
35+
36+
from __future__ import annotations
37+
38+
import contextlib
39+
import difflib
40+
import glob
41+
import json
42+
import os
43+
import sys
44+
import time
45+
import traceback
46+
from typing import Any, List, Tuple
47+
48+
49+
# Ensure repo root is importable even when running this script from a subdir.
50+
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
51+
if REPO_ROOT not in sys.path:
52+
sys.path.insert(0, REPO_ROOT)
53+
54+
# Allow running as a script from repo root.
55+
if __name__ == '__main__' and not __package__:
56+
from common import color
57+
from parser_py import parse
58+
else:
59+
from ..common import color
60+
from ..parser_py import parse
61+
62+
63+
RUN_ONLY: List[str] = []
64+
STRICT_ERRORS = False
65+
SHOW_TIMINGS = False
66+
67+
68+
def _read_run_only(argv: List[str]) -> None:
69+
global RUN_ONLY
70+
global STRICT_ERRORS
71+
global SHOW_TIMINGS
72+
for a in argv:
73+
if a.startswith('test_only='):
74+
RUN_ONLY = [x for x in a.split('=', 1)[1].split(',') if x]
75+
if a == 'strict_errors':
76+
STRICT_ERRORS = True
77+
if a == 'show_timings':
78+
SHOW_TIMINGS = True
79+
80+
81+
def _print_header() -> None:
82+
print(color.Format('% 64s %s' % ('{warning}TEST{end}', '{warning}RESULT{end}')))
83+
print(color.Format('% 64s %s' % ('{warning}----{end}', '{warning}------{end}')))
84+
85+
@contextlib.contextmanager
86+
def _temp_env(key: str, value: str) -> Any:
87+
old = os.environ.get(key)
88+
os.environ[key] = value
89+
try:
90+
yield
91+
finally:
92+
if old is None:
93+
os.environ.pop(key, None)
94+
else:
95+
os.environ[key] = old
96+
97+
98+
def _import_root_from_env() -> Any:
99+
import_root_env = os.environ.get('LOGICAPATH')
100+
if not import_root_env:
101+
return None
102+
roots = import_root_env.split(':')
103+
if len(roots) > 1:
104+
return roots
105+
return import_root_env
106+
107+
108+
def _format_parse_exception(e: BaseException) -> str:
109+
buf = []
110+
if hasattr(e, 'ShowMessage') and callable(getattr(e, 'ShowMessage')):
111+
try:
112+
import io
113+
s = io.StringIO()
114+
e.ShowMessage(stream=s) # type: ignore[attr-defined]
115+
return s.getvalue()
116+
except Exception: # pylint: disable=broad-exception-caught
117+
pass
118+
buf.append(f"{type(e).__name__}: {e}\n")
119+
return ''.join(buf)
120+
121+
122+
def _parse_rules_timed(program_text: str, parser_mode: str) -> Tuple[int, str, Any, float]:
123+
"""Returns (rc, stderr, rules_or_none, seconds)."""
124+
import_root = _import_root_from_env()
125+
t0 = time.perf_counter()
126+
with _temp_env('LOGICA_PARSER', parser_mode):
127+
try:
128+
rules = parse.ParseFile(program_text, import_root=import_root)['rule']
129+
t1 = time.perf_counter()
130+
return 0, '', rules, (t1 - t0)
131+
except parse.ParsingException as e:
132+
t1 = time.perf_counter()
133+
return 1, _format_parse_exception(e), None, (t1 - t0)
134+
except BaseException as e: # noqa: BLE001
135+
t1 = time.perf_counter()
136+
return 2, traceback.format_exc() or _format_parse_exception(e), None, (t1 - t0)
137+
138+
139+
def _canonical_dump(x: Any) -> str:
140+
# Match logica.py parse: sort_keys=True, indent=' ' (one space).
141+
return json.dumps(x, sort_keys=True, indent=' ') + "\n"
142+
143+
144+
def _diff(a: str, b: str, from_name: str, to_name: str, limit: int = 200) -> str:
145+
lines = list(difflib.unified_diff(
146+
a.splitlines(True),
147+
b.splitlines(True),
148+
fromfile=from_name,
149+
tofile=to_name,
150+
))
151+
if len(lines) <= limit:
152+
return ''.join(lines)
153+
return ''.join(lines[:limit] + [f"\n... (diff truncated to {limit} lines)\n"])
154+
155+
156+
def _percentile(sorted_values: List[float], p: float) -> float:
157+
if not sorted_values:
158+
return 0.0
159+
if p <= 0.0:
160+
return sorted_values[0]
161+
if p >= 100.0:
162+
return sorted_values[-1]
163+
idx = (p / 100.0) * (len(sorted_values) - 1)
164+
lo = int(idx)
165+
hi = min(lo + 1, len(sorted_values) - 1)
166+
frac = idx - lo
167+
return sorted_values[lo] * (1.0 - frac) + sorted_values[hi] * frac
168+
169+
170+
def _print_timing_summary(ran: List[str], ran_files: List[str], py_times: List[float], cpp_times: List[float]) -> None:
171+
if not ran:
172+
return
173+
174+
py_total = sum(py_times)
175+
cpp_total = sum(cpp_times)
176+
py_sorted = sorted(py_times)
177+
cpp_sorted = sorted(cpp_times)
178+
179+
def fmt_s(x: float) -> str:
180+
return f"{x:.3f}s"
181+
182+
def fmt_ms(x: float) -> str:
183+
return f"{x * 1000.0:.1f}ms"
184+
185+
print(color.Format('\n{warning}Timing summary{end} (one run per file)'))
186+
print(f" Tests: {len(ran)}")
187+
print(
188+
" Python: total %s | avg %s | p50 %s | p95 %s" % (
189+
fmt_s(py_total),
190+
fmt_ms(py_total / len(ran)),
191+
fmt_ms(_percentile(py_sorted, 50.0)),
192+
fmt_ms(_percentile(py_sorted, 95.0)),
193+
)
194+
)
195+
print(
196+
" C++: total %s | avg %s | p50 %s | p95 %s" % (
197+
fmt_s(cpp_total),
198+
fmt_ms(cpp_total / len(ran)),
199+
fmt_ms(_percentile(cpp_sorted, 50.0)),
200+
fmt_ms(_percentile(cpp_sorted, 95.0)),
201+
)
202+
)
203+
if cpp_total > 0.0:
204+
print(f" Speedup (total): {py_total / cpp_total:.2f}x")
205+
206+
by_py = sorted(zip(py_times, ran_files), reverse=True)[:5]
207+
by_cpp = sorted(zip(cpp_times, ran_files), reverse=True)[:5]
208+
slow_py = ', '.join([f"{fmt_ms(t)} {os.path.basename(f)}" for t, f in by_py])
209+
slow_cpp = ', '.join([f"{fmt_ms(t)} {os.path.basename(f)}" for t, f in by_cpp])
210+
print(f" Slowest Python: {slow_py}")
211+
print(f" Slowest C++: {slow_cpp}")
212+
213+
214+
def _compare_one(test_name: str, path: str) -> Tuple[bool, float, float]:
215+
if RUN_ONLY and test_name not in RUN_ONLY:
216+
return True, 0.0, 0.0
217+
218+
test_result = '{warning}RUNNING{end}'
219+
print(color.Format('% 50s %s' % (test_name, test_result)))
220+
221+
program_text = open(path, 'r', encoding='utf-8').read()
222+
223+
# Force both modes regardless of caller environment.
224+
py_rc, py_err, py_json, py_s = _parse_rules_timed(program_text, 'PY')
225+
cpp_rc, cpp_err, cpp_json, cpp_s = _parse_rules_timed(program_text, 'CPP')
226+
227+
ok = True
228+
details = ''
229+
230+
if py_rc == 0 and cpp_rc == 0:
231+
if py_json != cpp_json:
232+
ok = False
233+
a = _canonical_dump(py_json)
234+
b = _canonical_dump(cpp_json)
235+
details += _diff(a, b, f"python:{path}", f"cpp:{path}")
236+
elif py_rc != 0 and cpp_rc != 0:
237+
# Default: only require both to fail. Use strict_errors to compare outputs.
238+
if STRICT_ERRORS and py_err != cpp_err:
239+
ok = False
240+
details += "Both parsers failed, but error output differs.\n"
241+
details += _diff(py_err, cpp_err, f"python-stderr:{path}", f"cpp-stderr:{path}")
242+
else:
243+
ok = False
244+
if py_rc != 0:
245+
details += f"Python parser failed (rc={py_rc})\n{py_err}\n"
246+
if cpp_rc != 0:
247+
details += f"C++ parser failed (rc={cpp_rc})\n{cpp_err}\n"
248+
249+
if ok:
250+
test_result = '{ok}PASSED{end}'
251+
else:
252+
test_result = '{error}FAILED{end}'
253+
254+
line = '% 50s %s' % (test_name, test_result)
255+
if SHOW_TIMINGS:
256+
line += ' (py=%6.1fms, cpp=%6.1fms)' % (py_s * 1000.0, cpp_s * 1000.0)
257+
print('\033[F\033[K' + color.Format(line))
258+
259+
if not ok:
260+
# Keep failure output readable and similar to other test runners.
261+
print(details.rstrip() + "\n")
262+
263+
return ok, py_s, cpp_s
264+
265+
266+
def main(argv: List[str]) -> int:
267+
_read_run_only(argv)
268+
_print_header()
269+
270+
files = sorted(glob.glob('integration_tests/*.l'))
271+
if not files:
272+
print('No integration_tests/*.l files found.', file=sys.stderr)
273+
return 2
274+
275+
failed: List[str] = []
276+
ran: List[str] = []
277+
ran_files: List[str] = []
278+
py_times: List[float] = []
279+
cpp_times: List[float] = []
280+
281+
for path in files:
282+
test_name = os.path.splitext(os.path.basename(path))[0]
283+
if RUN_ONLY and test_name not in RUN_ONLY:
284+
continue
285+
ran.append(test_name)
286+
ran_files.append(path)
287+
ok, py_s, cpp_s = _compare_one(test_name, path)
288+
py_times.append(py_s)
289+
cpp_times.append(cpp_s)
290+
if not ok:
291+
failed.append(test_name)
292+
293+
if failed:
294+
_print_timing_summary(ran, ran_files, py_times, cpp_times)
295+
print(color.Format('{error}FAILED{end}: %d tests' % len(failed)))
296+
print('Failed:', ', '.join(failed))
297+
return 1
298+
299+
_print_timing_summary(ran, ran_files, py_times, cpp_times)
300+
print(color.Format('{ok}PASSED{end}: %d tests' % len(ran)))
301+
return 0
302+
303+
304+
if __name__ == '__main__':
305+
raise SystemExit(main(sys.argv[1:]))

0 commit comments

Comments
 (0)