xref: /illumos-gate/usr/src/test/header-tests/tests/common/symbol_test.py (revision ac2250cb76bb32944fd2c8a3ba2cd3f79747748d)
1#!@PYTHON@
2#
3# This file and its contents are supplied under the terms of the
4# Common Development and Distribution License ("CDDL"), version 1.0.
5# You may only use this file in accordance with the terms of version
6# 1.0 of the CDDL.
7#
8# A full copy of the text of the CDDL should have accompanied this
9# source.  A copy of the CDDL is also available via the Internet at
10# http://www.illumos.org/license/CDDL.
11#
12
13#
14# Copyright 2026 Gordon W. Ross
15#
16
17"""
18symbol_test.py - C and C++ symbol visibility test driver.
19
20Reads an environment config file and one or more symbols config files,
21generates probe programs for each (symbol, environment) pair, compiles
22them, and reports pass/fail.  Supports parallel compilation jobs.
23
24This program is run separately for 64-bit compiles and (where supported)
2532-bit compiles by the test driver scripts.  See the setup.ksh scripts in
26the ../c-symbols/ and ../cxx-symbols/ directories for examples.
27
28See also tests/common/README.md.
29"""
30
31import argparse
32import io
33import os
34import signal
35import subprocess
36import sys
37import tempfile
38import threading
39from concurrent.futures import ThreadPoolExecutor
40
41
42# ---------------------------------------------------------------------------
43# parse_cfg - shared cfg file parser
44# ---------------------------------------------------------------------------
45
46def parse_cfg(fileobj, filename, handlers):
47    """
48    Parse a cfg file-like object, dispatching each directive line to handlers.
49
50    Handles continuation lines (trailing backslash), blank lines, and comments
51    (lines starting with '#').  Each non-blank, non-comment line is split on
52    '|' with each field stripped, then dispatched as:
53
54        handlers[keyword](fields, filename, lineno)
55
56    Unknown keywords cause an error via sys.exit().
57    """
58    lineno = 0
59    accum = ''
60    for raw in fileobj:
61        lineno += 1
62        text = raw.rstrip('\n').rstrip('\r')
63        if text.endswith('\\'):
64            accum += text[:-1]
65            continue
66        accum += text
67        line = accum.strip()
68        accum = ''
69        if not line or line.startswith('#'):
70            continue
71        parts = [p.strip() for p in line.split('|')]
72        keyword, fields = parts[0], parts[1:]
73        if keyword not in handlers:
74            sys.exit(
75                f'error: {filename}:{lineno}: unknown keyword {keyword!r}')
76        handlers[keyword](fields, filename, lineno)
77
78
79# ---------------------------------------------------------------------------
80# CompileEnv - one compilation environment (name, lang standard, defines)
81# ---------------------------------------------------------------------------
82
83class CompileEnv:
84    """One compilation environment as defined by an 'env' line in the cfg."""
85
86    def __init__(self, name, lang, defs):
87        self.name = name
88        self.lang = lang    # e.g. 'c++98', 'c99'
89        self.defs = defs    # preprocessor defines string, may be empty
90
91
92# ---------------------------------------------------------------------------
93# EnvConfig - loads and represents the env cfg file
94# See test_parse_env.py for examples of method calls and results.
95# ---------------------------------------------------------------------------
96
97class EnvConfig:
98    """
99    Loads a compilation environment config file (c-symbols-env.cfg or
100    cxx-symbols-env.cfg) and provides environment and group lookup.
101
102    Public attributes:
103      envs:   dict  name -> CompileEnv
104      groups: dict  name -> frozenset of env names
105    """
106
107    def __init__(self, lang):
108        self.lang = lang
109        self.envs = {}
110        self.groups = {}
111
112    def load(self, path):
113        """Load env cfg from a file path."""
114        # Locations searched for the file:
115        #   1. path as given
116        #   2. $STF_SUITE/cfg/<path>
117        #   3. cfg/<path>
118        candidates = [path]
119        if not os.path.isabs(path):
120            stf = os.environ.get('STF_SUITE', '../..')
121            candidates.append(os.path.join(stf, 'cfg', path))
122            candidates.append(os.path.join('cfg', path))
123
124        for candidate in candidates:
125            if os.path.exists(candidate):
126                with open(candidate) as f:
127                    self._parse(f, filename=candidate)
128                return
129
130        sys.exit(f'error: env cfg file not found: {path}')
131
132    def _parse(self, fileobj, filename='<input>'):
133        """
134        Parse a file-like object as an environment config file.
135
136        self.lang must be 'c' or 'c++'.  Each env line's lang field is
137        validated against this value; a mismatch is an error.
138        """
139        handlers = {
140            'env':       lambda f, fn, ln: self._do_env(f, fn, ln),
141            'env_group': self._do_env_group,
142        }
143        parse_cfg(fileobj, filename, handlers)
144
145    def _do_env(self, fields, filename, lineno):
146        if len(fields) != 3:
147            sys.exit(
148                f'error: {filename}:{lineno}: env: expected 3 fields, '
149                f'got {len(fields)}')
150
151        name, env_lang, defs = fields
152
153        # Validate lang consistency
154        is_cxx = env_lang.startswith('c++')
155        if self.lang == 'c++' and not is_cxx:
156            sys.exit(
157                f'error: --lang=c++ but {filename} line {lineno} '
158                f'has lang {env_lang!r}')
159        if self.lang == 'c' and is_cxx:
160            sys.exit(
161                f'error: --lang=c but {filename} line {lineno} '
162                f'has lang {env_lang!r}')
163
164        self.envs[name] = CompileEnv(name=name, lang=env_lang, defs=defs)
165
166    def _do_env_group(self, fields, filename, lineno):
167        if len(fields) != 2:
168            sys.exit(
169                f'error: {filename}:{lineno}: env_group: expected 2 fields, '
170                f'got {len(fields)}')
171
172        name, members_str = fields
173        members = set()
174        for member in members_str.split():
175            if member in self.envs:
176                members.add(member)
177            elif member in self.groups:
178                members |= self.groups[member]
179            else:
180                sys.exit(
181                    f'error: {filename}:{lineno}: '
182                    f'reference to undefined env {member!r}')
183
184        self.groups[name] = frozenset(members)
185
186    def resolve(self, spec):
187        """
188        Parse an env spec string like 'CXX98+ -CXX11 +CXX98'.
189
190        Returns (test_set, need_set) where both are sets of env names:
191          test_set  - all envs to compile
192          need_set  - envs where compilation must succeed (pass)
193
194        Bare name or +name -> added to both test_set and need_set.
195        -name -> added to test_set only (must fail).
196        """
197        test_set = set()
198        need_set = set()
199
200        for token in spec.split():
201            if token.startswith('+'):
202                act = True
203                token = token[1:]
204            elif token.startswith('-'):
205                act = False
206                token = token[1:]
207            else:
208                act = True
209
210            # Expand token - may be a single env or a group
211            expanded = self._expand(token)
212            test_set |= expanded
213            if act:
214                need_set |= expanded
215            else:
216                need_set -= expanded
217
218        return (test_set, need_set)
219
220    def _expand(self, name):
221        """Return frozenset of env names for a single env name or group name."""
222        if name in self.envs:
223            return frozenset({name})
224        if name in self.groups:
225            return self.groups[name]
226        sys.exit(f'error: reference to undefined env or group {name!r}')
227
228
229# ---------------------------------------------------------------------------
230# SymEntry - one symbol entry from a symbols config file
231# ---------------------------------------------------------------------------
232
233class SymEntry:
234    """One test entry as parsed from a symbols config file."""
235
236    def __init__(self, directive, symbol, env_spec, headers,
237                 rtype=None, atypes=None, defval=None):
238        self.directive = directive  # 'func' | 'type' | 'value' | 'define'
239        self.symbol    = symbol     # name to test
240        self.env_spec  = env_spec   # raw env spec string e.g. 'CXX98+ -CXX11'
241        self.headers   = headers    # list of header filenames
242        self.rtype     = rtype      # return/declaration type (not for define)
243        self.atypes    = atypes if atypes is not None else []
244        self.defval    = defval     # expected value for 'define', else None
245
246
247# ---------------------------------------------------------------------------
248# SymConfig - loads symbols config files
249# See test_parse_sym.py for examples of method calls and results.
250# ---------------------------------------------------------------------------
251
252class SymConfig:
253    """
254    Loads one or more symbols config files.
255
256    Public attributes:
257      entries: list of SymEntry, in file order
258    """
259
260    def __init__(self):
261        self.entries = []
262
263    def load(self, path):
264        """Load symbols config from a file path, with STF_SUITE search."""
265        candidates = [path]
266        if not os.path.isabs(path):
267            stf = os.environ.get('STF_SUITE', '../..')
268            candidates.append(os.path.join(stf, 'cfg', path))
269            candidates.append(os.path.join('cfg', path))
270
271        for candidate in candidates:
272            if os.path.exists(candidate):
273                with open(candidate) as f:
274                    self._parse(f, filename=candidate)
275                return
276
277        sys.exit(f'error: symbols config file not found: {path}')
278
279    def _parse(self, fileobj, filename='<input>'):
280        """Parse a file-like object as a symbols config file."""
281        handlers = {
282            'type':   self._do_type,
283            'value':  self._do_value,
284            'define': self._do_define,
285            'func':   self._do_func,
286        }
287        parse_cfg(fileobj, filename, handlers)
288
289    @staticmethod
290    def _split_list(s):
291        """Split a semicolon-separated field, stripping each item."""
292        return [item.strip() for item in s.split(';') if item.strip()]
293
294    def _do_type(self, fields, filename, lineno):
295        # type | decl | headers | envs
296        if len(fields) != 3:
297            sys.exit(
298                f'error: {filename}:{lineno}: type: expected 3 fields, '
299                f'got {len(fields)}')
300        decl, hdrs, envs = fields
301        self.entries.append(SymEntry(
302            directive='type',
303            symbol=decl,
304            rtype=decl,
305            headers=self._split_list(hdrs),
306            env_spec=envs,
307        ))
308
309    def _do_value(self, fields, filename, lineno):
310        # value | name | type | headers | envs
311        if len(fields) != 4:
312            sys.exit(
313                f'error: {filename}:{lineno}: value: expected 4 fields, '
314                f'got {len(fields)}')
315        name, rtype, hdrs, envs = fields
316        self.entries.append(SymEntry(
317            directive='value',
318            symbol=name,
319            rtype=rtype,
320            headers=self._split_list(hdrs),
321            env_spec=envs,
322        ))
323
324    def _do_define(self, fields, filename, lineno):
325        # define | name | value | headers | envs  (value may be empty)
326        if len(fields) != 4:
327            sys.exit(
328                f'error: {filename}:{lineno}: define: expected 4 fields, '
329                f'got {len(fields)}')
330        name, defval, hdrs, envs = fields
331        self.entries.append(SymEntry(
332            directive='define',
333            symbol=name,
334            defval=defval if defval else None,
335            headers=self._split_list(hdrs),
336            env_spec=envs,
337        ))
338
339    def _do_func(self, fields, filename, lineno):
340        # func | name | rtype | atypes | headers | envs
341        if len(fields) != 5:
342            sys.exit(
343                f'error: {filename}:{lineno}: func: expected 5 fields, '
344                f'got {len(fields)}')
345        name, rtype, atypes, hdrs, envs = fields
346        self.entries.append(SymEntry(
347            directive='func',
348            symbol=name,
349            rtype=rtype,
350            atypes=self._split_list(atypes),
351            headers=self._split_list(hdrs),
352            env_spec=envs,
353        ))
354
355
356# ---------------------------------------------------------------------------
357# ProbeGen - generates probe program source for a (SymEntry, lang) pair
358# See test_gen_probe.py for examples of method calls and results.
359# ---------------------------------------------------------------------------
360
361class ProbeGen:
362
363    RESULT_MACRO = (
364        '#if __cplusplus >= 201103L\n'
365        '#define RESULT(v) result{v}\n'
366        '#else\n'
367        '#define RESULT(v) result = (v)\n'
368        '#endif\n'
369    )
370
371    @staticmethod
372    def gen_probe(entry, lang):
373        """
374        Generate probe program source text for a SymEntry and language.
375
376        """
377        out = []
378
379        for h in entry.headers:
380            out.append(f'#include <{h}>\n')
381
382        rtype = entry.rtype or ''
383        prefix, suffix = ProbeGen._split_rtype(rtype)
384        is_fnptr = suffix != ''
385
386        # Emit the RESULT() macro for C++ func probes with a non-void,
387        # non-fnptr return value (brace-init catches narrowing errors in
388        # C++11+; plain assignment used for C++98).
389        has_rtype = (entry.directive == 'func' and
390                     rtype != '' and rtype != 'void')
391        use_result = has_rtype and not is_fnptr and lang == 'c++'
392
393        if use_result:
394            out.append(ProbeGen.RESULT_MACRO)
395
396        # Emit return type prefix + trailing space (matches C programs'
397        # unconditional addprogch(' ') after the rtype loop).
398        if rtype:
399            out.append(prefix + ' ')
400
401        if entry.directive == 'type':
402            out.append('test_type;\n')
403
404        elif entry.directive == 'value':
405            out.append(f'test_value{suffix};\n')
406            out.append('void\ntest_func(void)\n{\n')
407            out.append(f'\ttest_value = {entry.symbol};\n}}\n')
408
409        elif entry.directive == 'define':
410            out.append(f'#if !defined({entry.symbol})')
411            if entry.defval:
412                out.append(f' || {entry.symbol} != {entry.defval}')
413            out.append(f'\n#error {entry.symbol} is not defined or has the wrong value')
414            out.append('\n#endif\n')
415            out.append('\n')
416
417        elif entry.directive == 'func':
418            arglist = ProbeGen._build_arglist(entry.atypes)
419            out.append(f'\ntest_func({arglist}){suffix}\n{{\n\t')
420
421            call = f'{entry.symbol}({ProbeGen._build_callargs(entry.atypes)})'
422
423            if use_result:
424                out.append(f'{rtype} RESULT({call});\n\treturn result;\n}}')
425            elif has_rtype or is_fnptr:
426                out.append(f'return {call};\n}}')
427            else:
428                out.append(f'{call};\n}}')
429            out.append('\n')
430
431        return ''.join(out)
432
433    @staticmethod
434    def _split_rtype(rtype):
435        """
436        Split rtype for function pointer support.
437
438        For 'void (*)(int)': returns ('void (*', ')(int)')
439        For 'double':        returns ('double', '')
440        """
441        idx = rtype.find('(*')
442        if idx >= 0:
443            return rtype[:idx + 2], rtype[idx + 2:]
444        return rtype, ''
445
446    @staticmethod
447    def _build_arglist(atypes):
448        """Build the formal parameter list string, matching C arg-name insertion."""
449        if not atypes:
450            return 'void'
451        parts = []
452        for i, atype in enumerate(atypes):
453            if atype == '':
454                parts.append('void')
455            elif atype == 'void':
456                parts.append('void')
457            elif '(*' in atype:
458                idx = atype.index('(*')
459                parts.append(f'{atype[:idx + 2]}a{i}{atype[idx + 2:]}')
460            elif '[' in atype:
461                idx = atype.index('[')
462                parts.append(f'{atype[:idx]}a{i}{atype[idx:]}')
463            else:
464                parts.append(f'{atype} a{i}')
465        return ', '.join(parts)
466
467    @staticmethod
468    def _build_callargs(atypes):
469        """Build actual argument names for the function call, skipping void/empty."""
470        return ', '.join(
471            f'a{i}' for i, atype in enumerate(atypes)
472            if atype not in ('', 'void')
473        )
474
475
476# ---------------------------------------------------------------------------
477# Job - inputs to a single (symbol, env) compilation
478# ---------------------------------------------------------------------------
479
480class Job:
481    """All inputs needed to run one compilation job in a worker thread."""
482
483    __slots__ = ('index', 'entry', 'env', 'expect_pass', 'lang',
484                 'compiler', 'mflag', 'arch', 'std_flag', 'base_flags',
485                 'tmpdir', 'debug', 'extra_debug', 'force')
486
487    def __init__(self, index, entry, env, expect_pass, lang,
488                 compiler, mflag, arch, std_flag, base_flags,
489                 tmpdir, debug, extra_debug, force):
490        self.index       = index
491        self.entry       = entry
492        self.env         = env
493        self.expect_pass = expect_pass
494        self.lang        = lang
495        self.compiler    = compiler
496        self.mflag       = mflag
497        self.arch        = arch          # '64-bit' or '32-bit'
498        self.std_flag    = std_flag      # e.g. '-std=c99'
499        self.base_flags  = base_flags    # list of flags common to all jobs
500        self.tmpdir      = tmpdir
501        self.debug       = debug         # -d: show probe + compiler output on failure
502        self.extra_debug = extra_debug   # -D: also show compiler command on pass
503        self.force       = force         # -f: continue after failures
504
505
506# ---------------------------------------------------------------------------
507# TestDriver - builds job list, drives thread pool, reports results
508# ---------------------------------------------------------------------------
509
510class TestDriver:
511    """
512    Expands symbol entries × environments into jobs, runs them in a thread
513    pool, and reports results.
514    """
515
516    def run(self, entries, env_config, compiler, mflag, arch,
517                base_flags, tmpdir, opts):
518        """
519        Run all (symbol, env) compilations.
520
521        Returns True if all tests passed (and not interrupted), else False.
522        """
523        lock = threading.Lock()
524        stop = threading.Event()
525        counters = {'pass': 0, 'fail': 0}
526
527        orig_sigint  = signal.getsignal(signal.SIGINT)
528        orig_sigterm = signal.getsignal(signal.SIGTERM)
529
530        def handle_signal(signum, frame):
531            stop.set()
532
533        signal.signal(signal.SIGINT,  handle_signal)
534        signal.signal(signal.SIGTERM, handle_signal)
535
536        # run_job() is the worker function called by each thread in the pool.
537        # It captures all output for one job in a local buffer, then acquires
538        # the lock to write (flush) it contiguously to the output stream.
539        def run_job(job):
540            if stop.is_set():
541                return
542
543            # Generate probe source and write to temp file.
544            src  = ProbeGen.gen_probe(job.entry, job.lang)
545            ext  = 'cc' if job.lang == 'c++' else 'c'
546            base = os.path.join(job.tmpdir, f'job-{job.index}')
547            srcfile = f'{base}.{ext}'
548            objfile = f'{base}.o'
549            logfile = f'{base}.log'
550
551            with open(srcfile, 'w') as f:
552                f.write(src)
553
554            # Build compiler command.
555            env_defs = job.env.defs.split() if job.env.defs else []
556            cmd = ([job.compiler, job.mflag, job.std_flag] +
557                   job.base_flags + env_defs +
558                   ['-c', srcfile, '-o', objfile])
559
560            with open(logfile, 'w') as lf:
561                proc = subprocess.run(cmd, stdout=lf, stderr=lf)
562
563            compile_ok = (proc.returncode == 0)
564            passed     = (compile_ok == job.expect_pass)
565
566            # Output buffer (flushed at end of job)
567            sign  = '+' if job.expect_pass else '-'
568            label = f'{job.entry.symbol} : {sign}{job.env.name} ({job.arch})'
569            out   = [f'TEST STARTING {label}:']
570
571            if job.extra_debug:
572                out.append(f'TEST DEBUG {label}: command: {" ".join(cmd)}')
573
574            if job.debug and not passed:
575                out.append(f'TEST DEBUG {label}: probe program:')
576                for line in src.splitlines():
577                    out.append(f'TEST DEBUG {label}:   {line}')
578                with open(logfile) as lf:
579                    cc_out = lf.read().strip()
580                if cc_out:
581                    out.append(f'TEST DEBUG {label}: compiler output:')
582                    for line in cc_out.splitlines():
583                        out.append(f'TEST DEBUG {label}:   {line}')
584
585            if passed:
586                out.append(f'TEST PASS: {label}')
587            else:
588                verb   = 'FAILING' if job.force else 'FAILED'
589                reason = (f'error compiling in {job.env.name}'
590                          if job.expect_pass
591                          else f'symbol visible in {job.env.name}')
592                out.append(f'TEST {verb} {label}: {reason}')
593
594            with lock:
595                print('\n'.join(out), flush=True)
596                if passed:
597                    counters['pass'] += 1
598                else:
599                    counters['fail'] += 1
600                    if not job.force:
601                        stop.set()
602
603        # Build the list of jobs to run.
604        jobs = []
605        for entry in entries:
606            if opts.sym and entry.symbol != opts.sym:
607                continue
608            test_set, need_set = env_config.resolve(entry.env_spec)
609            if opts.env:
610                narrow, _ = env_config.resolve(opts.env)
611                test_set  &= narrow
612                need_set  &= narrow
613            for env_name in sorted(test_set):
614                env = env_config.envs[env_name]
615                jobs.append(Job(
616                    index=len(jobs),
617                    entry=entry,
618                    env=env,
619                    expect_pass=(env_name in need_set),
620                    lang=opts.lang,
621                    compiler=compiler,
622                    mflag=mflag,
623                    arch=arch,
624                    std_flag=f'-std={env.lang}',
625                    base_flags=base_flags,
626                    tmpdir=tmpdir,
627                    debug=opts.debug,
628                    extra_debug=opts.extra_debug,
629                    force=opts.force,
630                ))
631
632        # The ThreadPoolExecutor runs up to opts.j worker threads concurrently.
633        # executor.submit() queues each job; the pool calls run_job(job) in a
634        # worker thread.  Exiting the "with" block waits for all submitted jobs
635        # to finish before proceeding.
636        with ThreadPoolExecutor(max_workers=opts.j) as executor:
637            for job in jobs:
638                executor.submit(run_job, job)
639
640        signal.signal(signal.SIGINT,  orig_sigint)
641        signal.signal(signal.SIGTERM, orig_sigterm)
642
643        passes = counters['pass']
644        total  = passes + counters['fail']
645        if passes == total:
646            print(f'TEST SUMMARY: {passes} / {total} (ok)')
647        else:
648            print(f'TEST SUMMARY: {passes} / {total} ({total - passes} failing)')
649
650        return counters['fail'] == 0 and not stop.is_set()
651
652
653# ---------------------------------------------------------------------------
654# Compiler detection
655# ---------------------------------------------------------------------------
656
657# Exit codes emitted by the compiler-detection probe.
658_COMP_STUDIO  = 51
659_COMP_CLANG   = 52
660_COMP_GCC     = 53
661_COMP_UNKNOWN = 99
662
663# clang defines both __GNUC__ and __clang__, therefore test for
664# __clang__ ahead of __GNUC__.
665
666_C_PROBE_SRC = """\
667#include <stdlib.h>
668int main(int argc, char **argv) {
669#if defined(__SUNPRO_C)
670exit(51);
671#elif defined(__clang__)
672exit(52);
673#elif defined(__GNUC__)
674exit(53);
675#else
676exit(99);
677#endif
678}
679"""
680
681_CXX_PROBE_SRC = """\
682#include <cstdlib>
683int main(int argc, char **argv) {
684#if defined(__SUNPRO_CC)
685exit(51);
686#elif defined(__clang__)
687exit(52);
688#elif defined(__GNUC__)
689exit(53);
690#else
691exit(99);
692#endif
693}
694"""
695
696# Base flags used for all C compilations.
697# We turn off -Wformat-security because the auto-generated tests don't pass
698# string literals to printf family functions, which will trigger warnings in
699# some compilers (e.g. clang-16).
700_C_BASE_FLAGS = [
701    '-Wall', '-Werror', '-nostdinc',
702    '-isystem', '/usr/include',
703    '-Wno-format-security',
704]
705
706
707def _run_compiler_probe(compiler, src, ext, mflag, tmpdir):
708    """
709    Write src to detect.ext, compile with compiler+mflag, run the result.
710    Returns the probe exit code, or None on compile/exec failure.
711    """
712    srcfile   = os.path.join(tmpdir, f'detect.{ext}')
713    exec_name = os.path.join(tmpdir, 'detect')
714    with open(srcfile, 'w') as f:
715        f.write(src)
716    try:
717        r = subprocess.run(
718            [compiler, mflag, srcfile, '-o', exec_name],
719            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
720        if r.returncode != 0:
721            return None
722        r = subprocess.run(
723            [exec_name],
724            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
725        return r.returncode
726    except OSError:
727        return None
728
729
730def _validate_c_compiler(cc, mflag, tmpdir):
731    """Return True if cc is a usable C compiler (gcc or clang); else False."""
732    code = _run_compiler_probe(cc, _C_PROBE_SRC, 'c', mflag, tmpdir)
733    return code in (_COMP_GCC, _COMP_CLANG)
734
735
736def _validate_cxx_compiler(cc, mflag, tmpdir):
737    """Return True if cc is a usable C++ compiler (g++ or clang++); else False."""
738    code = _run_compiler_probe(cc, _CXX_PROBE_SRC, 'cc', mflag, tmpdir)
739    return code in (_COMP_GCC, _COMP_CLANG)
740
741
742def _compiler_kind(compiler):
743    """
744    Return 'clang' or 'gcc' by inspecting the compiler's --version output.
745    Used when an explicit compiler path is given and the kind cannot be
746    inferred from the name.
747    """
748    result = subprocess.run(
749        [compiler, '--version'],
750        capture_output=True, text=True)
751    if 'clang' in result.stdout.lower():
752        return 'clang'
753    return 'gcc'
754
755
756def find_c_compiler(mflag, tmpdir, explicit=None):
757    """
758    Find a usable C compiler.  Returns the compiler path.
759    Raises SystemExit if none is found.
760    """
761    candidates = [explicit] if explicit else ['gcc', 'clang']
762    for cc in candidates:
763        if _validate_c_compiler(cc, mflag, tmpdir):
764            return cc
765    if explicit:
766        sys.exit(f'error: C compiler {explicit!r} is not usable')
767    sys.exit('error: no usable C compiler found (tried gcc, clang)')
768
769
770def find_cxx_compiler(mflag, tmpdir, explicit=None):
771    """
772    Find a usable C++ compiler.  Returns (compiler, kind) where kind is
773    'gcc' or 'clang'.  Raises SystemExit if none is found.
774    """
775    if explicit:
776        if not _validate_cxx_compiler(explicit, mflag, tmpdir):
777            sys.exit(f'error: C++ compiler {explicit!r} is not usable')
778        return explicit, _compiler_kind(explicit)
779    for cc, kind in [('g++', 'gcc'), ('clang++', 'clang')]:
780        if _validate_cxx_compiler(cc, mflag, tmpdir):
781            return cc, kind
782    sys.exit('error: no usable C++ compiler found (tried g++, clang++)')
783
784
785def find_gcc_cxx_includes(compiler):
786    """
787    Query a GCC C++ compiler for its internal include directory and return a
788    base_flags list with all necessary -isystem paths.
789
790    The compiler reports its internal include directory as, e.g.:
791      /opt/gcc-14/lib/gcc/x86_64-pc-solaris2.11/14.2.0/include
792
793    We parse out prefix, target triple, and version, then build:
794      -isystem prefix/include/c++/version
795      -isystem prefix/include/c++/version/target
796      -isystem prefix/lib/gcc/target/version/include
797      -isystem /usr/include
798    """
799    result = subprocess.run(
800        [compiler, '-print-file-name=include'],
801        capture_output=True, text=True)
802    buf = result.stdout.strip()
803
804    sep = '/lib/gcc/'
805    idx = buf.find(sep)
806    if idx < 0:
807        sys.exit(f'error: unexpected -print-file-name=include output: {buf!r}')
808
809    prefix = buf[:idx]
810    rest   = buf[idx + len(sep):]   # "target/version/include"
811    parts  = rest.split('/')
812    if len(parts) < 3:
813        sys.exit(f'error: cannot parse target/version from: {buf!r}')
814    target  = parts[0]
815    version = parts[1]
816
817    return [
818        '-Wall', '-Werror', '-nostdinc',
819        '-isystem', f'{prefix}/include/c++/{version}',
820        '-isystem', f'{prefix}/include/c++/{version}/{target}',
821        '-isystem', f'{prefix}/lib/gcc/{target}/{version}/include',
822        '-isystem', '/usr/include',
823        '-Wno-format-security',
824    ]
825
826
827def find_clang_cxx_includes(compiler):
828    """
829    Query a clang++ compiler for its C++ include search paths by running
830    it in preprocessing mode with -v, then parse the include list from
831    stderr.  Returns a base_flags list with -isystem for each path found,
832    plus -isystem /usr/include.
833    """
834    result = subprocess.run(
835        [compiler, '-xc++', '-E', '-v', '-'],
836        input='', capture_output=True, text=True)
837
838    paths = []
839    in_list = False
840    for line in result.stderr.splitlines():
841        if line == '#include <...> search starts here:':
842            in_list = True
843        elif line == 'End of search list.':
844            break
845        elif in_list:
846            paths.append(line.strip())
847
848    if not paths:
849        sys.exit(f'error: could not determine C++ include paths from {compiler}')
850
851    flags = ['-Wall', '-Werror', '-nostdinc']
852    for p in paths:
853        flags += ['-isystem', p]
854    if '/usr/include' not in paths:
855        flags += ['-isystem', '/usr/include']
856    flags.append('-Wno-format-security')
857    return flags
858
859
860# ---------------------------------------------------------------------------
861# Argument parsing and main
862# ---------------------------------------------------------------------------
863
864def _parse_args():
865    p = argparse.ArgumentParser(
866        description='Test C/C++ symbol visibility in system headers.')
867
868    p.add_argument('--lang', required=True, choices=('c', 'c++'),
869                   help='Language to test')
870
871    bits = p.add_mutually_exclusive_group(required=True)
872    bits.add_argument('-m64', dest='mflag', action='store_const', const='-m64',
873                      help='Compile for 64-bit')
874    bits.add_argument('-m32', dest='mflag', action='store_const', const='-m32',
875                      help='Compile for 32-bit')
876
877    p.add_argument('-c', dest='compiler', metavar='COMPILER', default=None,
878                   help='Explicit compiler path')
879    p.add_argument('-d', dest='debug', action='store_true',
880                   help='Show probe and compiler output on failure')
881    p.add_argument('-D', dest='extra_debug', action='store_true',
882                   help='Also show compiler command (implies -d)')
883    p.add_argument('-e', dest='env', metavar='ENV', default=None,
884                   help='Narrow to one environment name')
885    p.add_argument('-f', dest='force', action='store_true',
886                   help='Continue after failures')
887    p.add_argument('-j', dest='j', metavar='N', type=int, default=None,
888                   help='Number of parallel jobs (default: SYMBOL_TEST_JOBS or 4)')
889    p.add_argument('-s', dest='sym', metavar='SYM', default=None,
890                   help='Narrow to one symbol name')
891
892    p.add_argument('-C', dest='compiler_check', action='store_true',
893                   help='Check compiler only, do not run tests')
894
895    p.add_argument('env_cfg', nargs='?', help='Environment config file')
896    p.add_argument('sym_cfgs', nargs='*', metavar='sym_cfg',
897                   help='One or more symbols config files')
898
899    args = p.parse_args()
900    if args.extra_debug:
901        args.debug = True
902    jobs = 4
903    env_jobs = os.environ.get('SYMBOL_TEST_JOBS')
904    if env_jobs is not None:
905        jobs = int(env_jobs)
906    if args.j is not None:
907        jobs = args.j
908    args.j = jobs
909    if not args.compiler_check and not args.env_cfg:
910        p.error('env_cfg is required unless -C is specified')
911    if not args.compiler_check and not args.sym_cfgs:
912        p.error('at least one sym_cfg is required unless -C is specified')
913    return args
914
915
916def main():
917    args = _parse_args()
918    mflag = args.mflag
919    arch  = '64-bit' if mflag == '-m64' else '32-bit'
920
921    with tempfile.TemporaryDirectory() as tmpdir:
922        if args.lang == 'c':
923            compiler   = find_c_compiler(mflag, tmpdir, args.compiler)
924            base_flags = _C_BASE_FLAGS
925        else:
926            # kind is 'gcc' or 'clang', used to select the right
927            # include path discovery method.
928            compiler, kind = find_cxx_compiler(mflag, tmpdir, args.compiler)
929            if kind == 'gcc':
930                base_flags = find_gcc_cxx_includes(compiler)
931            else:
932                base_flags = find_clang_cxx_includes(compiler)
933
934        if args.compiler_check:
935            sys.exit(0)
936
937        env_cfg = EnvConfig(args.lang)
938        env_cfg.load(args.env_cfg)
939
940        sym_cfg = SymConfig()
941        for path in args.sym_cfgs:
942            sym_cfg.load(path)
943
944        ok = TestDriver().run(
945            sym_cfg.entries, env_cfg, compiler,
946            mflag, arch, base_flags, tmpdir, args)
947
948    sys.exit(0 if ok else 1)
949
950
951if __name__ == '__main__':
952    main()
953