xref: /llvm-project/clang/utils/check_cfc/check_cfc.py (revision daaaf4e9009edf38dfc3d01d3c30de0827ffd1b5)
1#!/usr/bin/env python
2
3"""Check CFC - Check Compile Flow Consistency
4
5This is a compiler wrapper for testing that code generation is consistent with
6different compilation processes. It checks that code is not unduly affected by
7compiler options or other changes which should not have side effects.
8
9To use:
10-Ensure that the compiler under test (i.e. clang, clang++) is on the PATH
11-On Linux copy this script to the name of the compiler
12   e.g. cp check_cfc.py clang && cp check_cfc.py clang++
13-On Windows use setup.py to generate check_cfc.exe and copy that to clang.exe
14 and clang++.exe
15-Enable the desired checks in check_cfc.cfg (in the same directory as the
16 wrapper)
17   e.g.
18[Checks]
19dash_g_no_change = true
20dash_s_no_change = false
21
22-The wrapper can be run using its absolute path or added to PATH before the
23 compiler under test
24   e.g. export PATH=<path to check_cfc>:$PATH
25-Compile as normal. The wrapper intercepts normal -c compiles and will return
26 non-zero if the check fails.
27   e.g.
28$ clang -c test.cpp
29Code difference detected with -g
30--- /tmp/tmp5nv893.o
31+++ /tmp/tmp6Vwjnc.o
32@@ -1 +1 @@
33-   0:       48 8b 05 51 0b 20 00    mov    0x200b51(%rip),%rax
34+   0:       48 39 3d 51 0b 20 00    cmp    %rdi,0x200b51(%rip)
35
36-To run LNT with Check CFC specify the absolute path to the wrapper to the --cc
37 and --cxx options
38   e.g.
39   lnt runtest nt --cc <path to check_cfc>/clang \\
40           --cxx <path to check_cfc>/clang++ ...
41
42To add a new check:
43-Create a new subclass of WrapperCheck
44-Implement the perform_check() method. This should perform the alternate compile
45 and do the comparison.
46-Add the new check to check_cfc.cfg. The check has the same name as the
47 subclass.
48"""
49
50from __future__ import absolute_import, division, print_function
51
52import imp
53import os
54import platform
55import shutil
56import subprocess
57import sys
58import tempfile
59
60try:
61    import configparser
62except ImportError:
63    import ConfigParser as configparser
64import io
65
66import obj_diff
67
68
69def is_windows():
70    """Returns True if running on Windows."""
71    return platform.system() == "Windows"
72
73
74class WrapperStepException(Exception):
75    """Exception type to be used when a step other than the original compile
76    fails."""
77
78    def __init__(self, msg, stdout, stderr):
79        self.msg = msg
80        self.stdout = stdout
81        self.stderr = stderr
82
83
84class WrapperCheckException(Exception):
85    """Exception type to be used when a comparison check fails."""
86
87    def __init__(self, msg):
88        self.msg = msg
89
90
91def main_is_frozen():
92    """Returns True when running as a py2exe executable."""
93    return (
94        hasattr(sys, "frozen")
95        or hasattr(sys, "importers")  # new py2exe
96        or imp.is_frozen("__main__")  # old py2exe
97    )  # tools/freeze
98
99
100def get_main_dir():
101    """Get the directory that the script or executable is located in."""
102    if main_is_frozen():
103        return os.path.dirname(sys.executable)
104    return os.path.dirname(sys.argv[0])
105
106
107def remove_dir_from_path(path_var, directory):
108    """Remove the specified directory from path_var, a string representing
109    PATH"""
110    pathlist = path_var.split(os.pathsep)
111    norm_directory = os.path.normpath(os.path.normcase(directory))
112    pathlist = [
113        x for x in pathlist if os.path.normpath(os.path.normcase(x)) != norm_directory
114    ]
115    return os.pathsep.join(pathlist)
116
117
118def path_without_wrapper():
119    """Returns the PATH variable modified to remove the path to this program."""
120    scriptdir = get_main_dir()
121    path = os.environ["PATH"]
122    return remove_dir_from_path(path, scriptdir)
123
124
125def flip_dash_g(args):
126    """Search for -g in args. If it exists then return args without. If not then
127    add it."""
128    if "-g" in args:
129        # Return args without any -g
130        return [x for x in args if x != "-g"]
131    else:
132        # No -g, add one
133        return args + ["-g"]
134
135
136def derive_output_file(args):
137    """Derive output file from the input file (if just one) or None
138    otherwise."""
139    infile = get_input_file(args)
140    if infile is None:
141        return None
142    else:
143        return "{}.o".format(os.path.splitext(infile)[0])
144
145
146def get_output_file(args):
147    """Return the output file specified by this command or None if not
148    specified."""
149    grabnext = False
150    for arg in args:
151        if grabnext:
152            return arg
153        if arg == "-o":
154            # Specified as a separate arg
155            grabnext = True
156        elif arg.startswith("-o"):
157            # Specified conjoined with -o
158            return arg[2:]
159    assert not grabnext
160
161    return None
162
163
164def is_output_specified(args):
165    """Return true is output file is specified in args."""
166    return get_output_file(args) is not None
167
168
169def replace_output_file(args, new_name):
170    """Replaces the specified name of an output file with the specified name.
171    Assumes that the output file name is specified in the command line args."""
172    replaceidx = None
173    attached = False
174    for idx, val in enumerate(args):
175        if val == "-o":
176            replaceidx = idx + 1
177            attached = False
178        elif val.startswith("-o"):
179            replaceidx = idx
180            attached = True
181
182    if replaceidx is None:
183        raise Exception
184    replacement = new_name
185    if attached:
186        replacement = "-o" + new_name
187    args[replaceidx] = replacement
188    return args
189
190
191def add_output_file(args, output_file):
192    """Append an output file to args, presuming not already specified."""
193    return args + ["-o", output_file]
194
195
196def set_output_file(args, output_file):
197    """Set the output file within the arguments. Appends or replaces as
198    appropriate."""
199    if is_output_specified(args):
200        args = replace_output_file(args, output_file)
201    else:
202        args = add_output_file(args, output_file)
203    return args
204
205
206gSrcFileSuffixes = (".c", ".cpp", ".cxx", ".c++", ".cp", ".cc")
207
208
209def get_input_file(args):
210    """Return the input file string if it can be found (and there is only
211    one)."""
212    inputFiles = list()
213    for arg in args:
214        testarg = arg
215        quotes = ('"', "'")
216        while testarg.endswith(quotes):
217            testarg = testarg[:-1]
218        testarg = os.path.normcase(testarg)
219
220        # Test if it is a source file
221        if testarg.endswith(gSrcFileSuffixes):
222            inputFiles.append(arg)
223    if len(inputFiles) == 1:
224        return inputFiles[0]
225    else:
226        return None
227
228
229def set_input_file(args, input_file):
230    """Replaces the input file with that specified."""
231    infile = get_input_file(args)
232    if infile:
233        infile_idx = args.index(infile)
234        args[infile_idx] = input_file
235        return args
236    else:
237        # Could not find input file
238        assert False
239
240
241def is_normal_compile(args):
242    """Check if this is a normal compile which will output an object file rather
243    than a preprocess or link. args is a list of command line arguments."""
244    compile_step = "-c" in args
245    # Bitcode cannot be disassembled in the same way
246    bitcode = "-flto" in args or "-emit-llvm" in args
247    # Version and help are queries of the compiler and override -c if specified
248    query = "--version" in args or "--help" in args
249    # Options to output dependency files for make
250    dependency = "-M" in args or "-MM" in args
251    # Check if the input is recognised as a source file (this may be too
252    # strong a restriction)
253    input_is_valid = bool(get_input_file(args))
254    return (
255        compile_step and not bitcode and not query and not dependency and input_is_valid
256    )
257
258
259def run_step(command, my_env, error_on_failure):
260    """Runs a step of the compilation. Reports failure as exception."""
261    # Need to use shell=True on Windows as Popen won't use PATH otherwise.
262    p = subprocess.Popen(
263        command,
264        stdout=subprocess.PIPE,
265        stderr=subprocess.PIPE,
266        env=my_env,
267        shell=is_windows(),
268    )
269    (stdout, stderr) = p.communicate()
270    if p.returncode != 0:
271        raise WrapperStepException(error_on_failure, stdout, stderr)
272
273
274def get_temp_file_name(suffix):
275    """Get a temporary file name with a particular suffix. Let the caller be
276    responsible for deleting it."""
277    tf = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
278    tf.close()
279    return tf.name
280
281
282class WrapperCheck(object):
283    """Base class for a check. Subclass this to add a check."""
284
285    def __init__(self, output_file_a):
286        """Record the base output file that will be compared against."""
287        self._output_file_a = output_file_a
288
289    def perform_check(self, arguments, my_env):
290        """Override this to perform the modified compilation and required
291        checks."""
292        raise NotImplementedError("Please Implement this method")
293
294
295class dash_g_no_change(WrapperCheck):
296    def perform_check(self, arguments, my_env):
297        """Check if different code is generated with/without the -g flag."""
298        output_file_b = get_temp_file_name(".o")
299
300        alternate_command = list(arguments)
301        alternate_command = flip_dash_g(alternate_command)
302        alternate_command = set_output_file(alternate_command, output_file_b)
303        run_step(alternate_command, my_env, "Error compiling with -g")
304
305        # Compare disassembly (returns first diff if differs)
306        difference = obj_diff.compare_object_files(self._output_file_a, output_file_b)
307        if difference:
308            raise WrapperCheckException(
309                "Code difference detected with -g\n{}".format(difference)
310            )
311
312        # Clean up temp file if comparison okay
313        os.remove(output_file_b)
314
315
316class dash_s_no_change(WrapperCheck):
317    def perform_check(self, arguments, my_env):
318        """Check if compiling to asm then assembling in separate steps results
319        in different code than compiling to object directly."""
320        output_file_b = get_temp_file_name(".o")
321
322        alternate_command = arguments + ["-via-file-asm"]
323        alternate_command = set_output_file(alternate_command, output_file_b)
324        run_step(alternate_command, my_env, "Error compiling with -via-file-asm")
325
326        # Compare if object files are exactly the same
327        exactly_equal = obj_diff.compare_exact(self._output_file_a, output_file_b)
328        if not exactly_equal:
329            # Compare disassembly (returns first diff if differs)
330            difference = obj_diff.compare_object_files(
331                self._output_file_a, output_file_b
332            )
333            if difference:
334                raise WrapperCheckException(
335                    "Code difference detected with -S\n{}".format(difference)
336                )
337
338            # Code is identical, compare debug info
339            dbgdifference = obj_diff.compare_debug_info(
340                self._output_file_a, output_file_b
341            )
342            if dbgdifference:
343                raise WrapperCheckException(
344                    "Debug info difference detected with -S\n{}".format(dbgdifference)
345                )
346
347            raise WrapperCheckException("Object files not identical with -S\n")
348
349        # Clean up temp file if comparison okay
350        os.remove(output_file_b)
351
352
353if __name__ == "__main__":
354    # Create configuration defaults from list of checks
355    default_config = """
356[Checks]
357"""
358
359    # Find all subclasses of WrapperCheck
360    checks = [cls.__name__ for cls in vars()["WrapperCheck"].__subclasses__()]
361
362    for c in checks:
363        default_config += "{} = false\n".format(c)
364
365    config = configparser.RawConfigParser()
366    config.readfp(io.BytesIO(default_config))
367    scriptdir = get_main_dir()
368    config_path = os.path.join(scriptdir, "check_cfc.cfg")
369    try:
370        config.read(os.path.join(config_path))
371    except:
372        print("Could not read config from {}, " "using defaults.".format(config_path))
373
374    my_env = os.environ.copy()
375    my_env["PATH"] = path_without_wrapper()
376
377    arguments_a = list(sys.argv)
378
379    # Prevent infinite loop if called with absolute path.
380    arguments_a[0] = os.path.basename(arguments_a[0])
381
382    # Basic correctness check
383    enabled_checks = [
384        check_name for check_name in checks if config.getboolean("Checks", check_name)
385    ]
386    checks_comma_separated = ", ".join(enabled_checks)
387    print("Check CFC, checking: {}".format(checks_comma_separated))
388
389    # A - original compilation
390    output_file_orig = get_output_file(arguments_a)
391    if output_file_orig is None:
392        output_file_orig = derive_output_file(arguments_a)
393
394    p = subprocess.Popen(arguments_a, env=my_env, shell=is_windows())
395    p.communicate()
396    if p.returncode != 0:
397        sys.exit(p.returncode)
398
399    if not is_normal_compile(arguments_a) or output_file_orig is None:
400        # Bail out here if we can't apply checks in this case.
401        # Does not indicate an error.
402        # Maybe not straight compilation (e.g. -S or --version or -flto)
403        # or maybe > 1 input files.
404        sys.exit(0)
405
406    # Sometimes we generate files which have very long names which can't be
407    # read/disassembled. This will exit early if we can't find the file we
408    # expected to be output.
409    if not os.path.isfile(output_file_orig):
410        sys.exit(0)
411
412    # Copy output file to a temp file
413    temp_output_file_orig = get_temp_file_name(".o")
414    shutil.copyfile(output_file_orig, temp_output_file_orig)
415
416    # Run checks, if they are enabled in config and if they are appropriate for
417    # this command line.
418    current_module = sys.modules[__name__]
419    for check_name in checks:
420        if config.getboolean("Checks", check_name):
421            class_ = getattr(current_module, check_name)
422            checker = class_(temp_output_file_orig)
423            try:
424                checker.perform_check(arguments_a, my_env)
425            except WrapperCheckException as e:
426                # Check failure
427                print(
428                    "{} {}".format(get_input_file(arguments_a), e.msg), file=sys.stderr
429                )
430
431                # Remove file to comply with build system expectations (no
432                # output file if failed)
433                os.remove(output_file_orig)
434                sys.exit(1)
435
436            except WrapperStepException as e:
437                # Compile step failure
438                print(e.msg, file=sys.stderr)
439                print("*** stdout ***", file=sys.stderr)
440                print(e.stdout, file=sys.stderr)
441                print("*** stderr ***", file=sys.stderr)
442                print(e.stderr, file=sys.stderr)
443
444                # Remove file to comply with build system expectations (no
445                # output file if failed)
446                os.remove(output_file_orig)
447                sys.exit(1)
448