#!/usr/bin/env python3
# Copyright 2017 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""usage: rc.py [options] input.res
A resource compiler for .rc files.

options:
-h, --help     Print this message.
-Werror        Treat warnings as errors.
-I<dir>        Add include path, used for both headers and resources.
-imsvc<dir>    Add system include path, used for preprocessing only.
/winsysroot<d> Set winsysroot, used for preprocessing only.
-D<sym>        Define a macro for the preprocessor.
/fo<out>       Set path of output .res file.
/nologo        Ignored (rc.py doesn't print a logo by default).
/showIncludes  Print referenced header and resource files."""

from collections import namedtuple
import codecs
import os
import re
import subprocess
import sys
import tempfile


THIS_DIR = os.path.abspath(os.path.dirname(__file__))
SRC_DIR = os.path.dirname(
    os.path.dirname(os.path.dirname(os.path.dirname(THIS_DIR)))
)


def ParseFlags():
    """Parses flags off sys.argv and returns the parsed flags."""
    # Can't use optparse / argparse because of /fo flag :-/
    includes = []
    imsvcs = []
    winsysroot = []
    defines = []
    output = None
    input = None
    show_includes = False
    werror = False
    # Parse.
    for flag in sys.argv[1:]:
        if flag == '-h' or flag == '--help':
            print(__doc__)
            sys.exit(0)
        if flag.startswith('-I'):
            includes.append(flag)
        elif flag.startswith('-imsvc'):
            imsvcs.append(flag)
        elif flag.startswith('/winsysroot'):
            winsysroot = [flag]
        elif flag.startswith('-D'):
            defines.append(flag)
        elif flag.startswith('/fo'):
            if output:
                print(
                    'rc.py: error: multiple /fo flags',
                    '/fo' + output,
                    flag,
                    file=sys.stderr,
                )
                sys.exit(1)
            output = flag[3:]
        elif flag == '/nologo':
            pass
        elif flag == '/showIncludes':
            show_includes = True
        elif flag == '-Werror':
            werror = True
        elif flag.startswith('-') or (
            flag.startswith('/') and not os.path.exists(flag)
        ):
            print('rc.py: error: unknown flag', flag, file=sys.stderr)
            print(__doc__, file=sys.stderr)
            sys.exit(1)
        else:
            if input:
                print(
                    'rc.py: error: multiple inputs:',
                    input,
                    flag,
                    file=sys.stderr,
                )
                sys.exit(1)
            input = flag
    # Validate and set default values.
    if not input:
        print('rc.py: error: no input file', file=sys.stderr)
        sys.exit(1)
    if not output:
        output = os.path.splitext(input)[0] + '.res'
    Flags = namedtuple(
        'Flags',
        [
            'includes',
            'defines',
            'output',
            'imsvcs',
            'winsysroot',
            'input',
            'show_includes',
            'werror',
        ],
    )
    return Flags(
        includes=includes,
        defines=defines,
        output=output,
        imsvcs=imsvcs,
        winsysroot=winsysroot,
        input=input,
        show_includes=show_includes,
        werror=werror,
    )


def ReadInput(input):
    """ "Reads input and returns it. For UTF-16LEBOM input, converts to UTF-8."""
    # Microsoft's rc.exe only supports unicode in the form of UTF-16LE with a BOM.
    # Our rc binary sniffs for UTF-16LE.  If that's not found, if /utf-8 is
    # passed, the input is treated as UTF-8.  If /utf-8 is not passed and the
    # input is not UTF-16LE, then our rc errors out on characters outside of
    # 7-bit ASCII.  Since the driver always converts UTF-16LE to UTF-8 here (for
    # the preprocessor, which doesn't support UTF-16LE), our rc will either see
    # UTF-8 with the /utf-8 flag (for UTF-16LE input), or ASCII input.
    # This is compatible with Microsoft rc.exe.  If we wanted, we could expose
    # a /utf-8 flag for the driver for UTF-8 .rc inputs too.
    # TODO(thakis): Microsoft's rc.exe supports BOM-less UTF-16LE. We currently
    # don't, but for chrome it currently doesn't matter.
    is_utf8 = False
    try:
        with open(input, 'rb') as rc_file:
            rc_file_data = rc_file.read()
            if rc_file_data.startswith(codecs.BOM_UTF16_LE):
                rc_file_data = (
                    rc_file_data[2:].decode('utf-16le').encode('utf-8')
                )
                is_utf8 = True
    except IOError:
        print('rc.py: failed to open', input, file=sys.stderr)
        sys.exit(1)
    except UnicodeDecodeError:
        print(
            'rc.py: failed to decode UTF-16 despite BOM', input, file=sys.stderr
        )
        sys.exit(1)
    return rc_file_data, is_utf8


def Preprocess(rc_file_data, flags):
    """Runs the input file through the preprocessor."""
    clang = os.path.join(
        SRC_DIR,
        'third_party',
        'llvm-build',
        'Release+Asserts',
        'bin',
        'clang-cl',
    )
    # Let preprocessor write to a temp file so that it doesn't interfere
    # with /showIncludes output on stdout.
    if sys.platform == 'win32':
        clang += '.exe'
    temp_handle, temp_file = tempfile.mkstemp(suffix='.i')
    # Closing temp_handle immediately defeats the purpose of mkstemp(), but I
    # can't figure out how to let write to the temp file on Windows otherwise.
    os.close(temp_handle)
    clang_cmd = [clang, '/P', '/DRC_INVOKED', '/TC', '-', '/Fi' + temp_file]
    if flags.imsvcs:
        clang_cmd += ['/X']
    if os.path.dirname(flags.input):
        # This must precede flags.includes.
        clang_cmd.append('-I' + os.path.dirname(flags.input))
    if flags.show_includes:
        clang_cmd.append('/showIncludes')
    if flags.werror:
        clang_cmd.append('/WX')
    clang_cmd += (
        flags.imsvcs + flags.winsysroot + flags.includes + flags.defines
    )
    p = subprocess.Popen(clang_cmd, stdin=subprocess.PIPE)
    p.communicate(input=rc_file_data)
    if p.returncode != 0:
        sys.exit(p.returncode)
    preprocessed_output = open(temp_file, 'rb').read()
    os.remove(temp_file)

    # rc.exe has a wacko preprocessor:
    # https://msdn.microsoft.com/en-us/library/windows/desktop/aa381033(v=vs.85).aspx
    # """RC treats files with the .c and .h extensions in a special manner. It
    # assumes that a file with one of these extensions does not contain
    # resources. If a file has the .c or .h file name extension, RC ignores all
    # lines in the file except the preprocessor directives."""
    # Thankfully, the Microsoft headers are mostly good about putting everything
    # in the system headers behind `if !defined(RC_INVOKED)`, so regular
    # preprocessing with RC_INVOKED defined works.
    return preprocessed_output


def RunRc(preprocessed_output, is_utf8, flags):
    if sys.platform.startswith('linux'):
        rc = os.path.join(THIS_DIR, 'linux64', 'rc')
    elif sys.platform == 'darwin':
        rc = os.path.join(THIS_DIR, 'mac', 'rc')
    elif sys.platform == 'win32':
        rc = os.path.join(THIS_DIR, 'win', 'rc.exe')
    else:
        print(
            'rc.py: error: unsupported platform', sys.platform, file=sys.stderr
        )
        sys.exit(1)
    rc_cmd = [rc]
    # Make sure rc-relative resources can be found:
    if os.path.dirname(flags.input):
        rc_cmd.append('/cd' + os.path.dirname(flags.input))
    rc_cmd.append('/fo' + flags.output)
    if is_utf8:
        rc_cmd.append('/utf-8')
    # TODO(thakis): cl currently always prints full paths for /showIncludes,
    # but clang-cl /P doesn't.  Which one is right?
    if flags.show_includes:
        rc_cmd.append('/showIncludes')
    # Microsoft rc.exe searches for referenced files relative to -I flags in
    # addition to the pwd, so -I flags need to be passed both to both
    # the preprocessor and rc.
    rc_cmd += flags.includes
    p = subprocess.Popen(rc_cmd, stdin=subprocess.PIPE)
    p.communicate(input=preprocessed_output)

    if flags.show_includes and p.returncode == 0:
        TOOL_DIR = os.path.dirname(os.path.relpath(THIS_DIR)).replace("\\", "/")
        # Since tool("rc") can't have deps, add deps on this script and on rc.py
        # and its deps here, so that rc edges become dirty if rc.py changes.
        print('Note: including file: {}/tool_wrapper.py'.format(TOOL_DIR))
        print('Note: including file: {}/rc/rc.py'.format(TOOL_DIR))
        print('Note: including file: {}/rc/linux64/rc.sha1'.format(TOOL_DIR))
        print('Note: including file: {}/rc/mac/rc.sha1'.format(TOOL_DIR))
        print('Note: including file: {}/rc/win/rc.exe.sha1'.format(TOOL_DIR))

    return p.returncode


def CompareToMsRcOutput(preprocessed_output, is_utf8, flags):
    msrc_in = flags.output + '.preprocessed.rc'

    # Strip preprocessor line markers.
    preprocessed_output = re.sub(
        rb'^#.*$', b'', preprocessed_output, flags=re.M
    )
    if is_utf8:
        preprocessed_output = preprocessed_output.decode('utf-8').encode(
            'utf-16le'
        )
    with open(msrc_in, 'wb') as f:
        f.write(preprocessed_output)

    msrc_out = flags.output + '_ms_rc'
    msrc_cmd = ['rc', '/nologo', '/x', '/fo' + msrc_out]

    # Make sure rc-relative resources can be found. rc.exe looks for external
    # resource files next to the file, but the preprocessed file isn't where the
    # input was.
    # Note that rc searches external resource files in the order of
    # 1. next to the input file
    # 2. relative to cwd
    # 3. next to -I directories
    # Changing the cwd means we'd have to rewrite all -I flags, so just add
    # the input file dir as -I flag. That technically gets the order of 1 and 2
    # wrong, but in Chromium's build the cwd is the gn out dir, and generated
    # files there are in obj/ and gen/, so this difference doesn't matter in
    # practice.
    if os.path.dirname(flags.input):
        msrc_cmd += ['-I' + os.path.dirname(flags.input)]

    # Microsoft rc.exe searches for referenced files relative to -I flags in
    # addition to the pwd, so -I flags need to be passed both to both
    # the preprocessor and rc.
    msrc_cmd += flags.includes

    # Input must come last.
    msrc_cmd += [msrc_in]

    rc_exe_exit_code = subprocess.call(msrc_cmd)
    # Assert Microsoft rc.exe and rc.py produced identical .res files.
    if rc_exe_exit_code == 0:
        import filecmp

        assert filecmp.cmp(msrc_out, flags.output)
    return rc_exe_exit_code


def main():
    # This driver has to do these things:
    # 1. Parse flags.
    # 2. Convert the input from UTF-16LE to UTF-8 if needed.
    # 3. Pass the input through a preprocessor (and clean up the preprocessor's
    #    output in minor ways).
    # 4. Call rc for the heavy lifting.
    flags = ParseFlags()
    rc_file_data, is_utf8 = ReadInput(flags.input)
    preprocessed_output = Preprocess(rc_file_data, flags)
    rc_exe_exit_code = RunRc(preprocessed_output, is_utf8, flags)

    # 5. On Windows, we also call Microsoft's rc.exe and check that we produced
    #   the same output.
    # Since Microsoft's rc has a preprocessor that only accepts 32 characters
    # for macro names, feed the clang-preprocessed source into it instead
    # of using ms rc's preprocessor.
    if sys.platform == 'win32' and rc_exe_exit_code == 0:
        rc_exe_exit_code = CompareToMsRcOutput(
            preprocessed_output, is_utf8, flags
        )

    return rc_exe_exit_code


if __name__ == '__main__':
    sys.exit(main())
