""" Execute Rust-Clippy linter and expose results to CodeSonar. """

import argparse
import os
from shutil import which
from shlex import split as cmdline2list
from subprocess import Popen, list2cmdline, DEVNULL, PIPE
import sys
from tempfile import mkstemp

import import_sarif


# We might want to ignore this exit code by default,
#  but in some cases it gets used for real build failures, not just lint violations.
#CLIPPY_WARNINGS_RETURNCODE = 101


class RespFileArgumentParser(argparse.ArgumentParser):
    """ Extend basic ArgumentParser to parse arguments from response files. """
    def convert_arg_line_to_args(self, arg_line):
        return cmdline2list(arg_line)


def main(argv, stdio):
    """ High-level script to invoke Rust linter subprocesses and CodeSonar analysis import commands. """
    # flags for import_sarif.py:
    INCLUDE_FLAG = '-include-sources'
    EXCLUDE_FLAG = '-exclude-sources'
    #EXCLUDE_SIGIL = '!'
    ANALYZER_ARG_FLAG = '-X'
    linesep = '\n'
    stderr = stdio[2]
    cwd = os.getcwd()
    returncode = 0
    parser = RespFileArgumentParser(
        fromfile_prefix_chars='@',
        description='Scan Rust source files for CodeSonar analysis.',
        )
    parser.add_argument(
        'cargo_project',
        metavar='SOURCE',
        help=("Rust Cargo project to build and analyze."
              "  Use '.' for project in current directory."
              "  See also -C option."))
    parser.add_argument(
        '-C', '-directory',
        metavar='DIR',
        dest='base_dir',
        help='Change to directory DIR before analyzing.')
    parser.add_argument(
        '-sarif-output',
        dest='sarif_output',
        help='Name of file to save analysis results in SARIF format.  Can be used for diagnostics.')
    parser.add_argument(
        '-include-sources',
        metavar='PATTERN',
        dest='source_args',
        action='append',
        type=(lambda s: (INCLUDE_FLAG, s)),
        help="File pattern for source files to include in analysis.")
    parser.add_argument(
        '-exclude-sources',
        metavar='PATTERN',
        dest='source_args',
        action='append',
        type=(lambda s: (EXCLUDE_FLAG, s)),
        help="File pattern for source files to exclude from analysis.")
    parser.add_argument(
        '-source-max-bytes',
        dest='source_max_size',
        type=int,
        help="The maximum size in bytes for any included source file.")
    parser.add_argument(
        '-cargo',
        metavar='PATH',
        dest='cargo_path',
        help="Path to cargo executable.")
    parser.add_argument(
        '-cargo-clippy',
        metavar='PATH',
        dest='clippy_path',
        help=argparse.SUPPRESS)
    parser.add_argument(
        '-clippy-sarif',
        metavar='PATH',
        dest='clippy_sarif_path',
        help="Path to clippy-sarif executable.")
    parser.add_argument(
        '-clippy-exit-ok',
        metavar='EXITCODE',
        dest='clippy_ignore_returncodes',
        action='append',
        help="Return code from clippy that should be ignored."
            + "  Clippy typically returns 101 for rule violations.")
    # This -keep-going option is useful to ensure that analysis completes,
    #  it became stable in rust 1.74,
    #  prior to 1.74, you will see this error:
    #  > error: the `--keep-going` flag is unstable, and only available on the nightly channel of Cargo, but this is the `stable` channel
    parser.add_argument(
        '-keep-going',
        dest='keep_going',
        action='store_true',
        help=argparse.SUPPRESS)
    parser.add_argument(
        ANALYZER_ARG_FLAG,
        dest='analyzer_args',
        action='append',
        help='Arguments to pass to clippy.  Arguments should be prefixed and separated by ",".  E.g "-X,--,-A,needless_borrow".')
    arg_obj, unknown_args = parser.parse_known_args(argv[1:])

    for arg in unknown_args:
        if arg.startswith(ANALYZER_ARG_FLAG):
            arg_obj.analyzer_args.append(arg[len(ANALYZER_ARG_FLAG):])
        else:
            stderr.write(f'Error: unrecognized argument: {arg}\n')
            return 2
    analyzer_args = []
    for arg in arg_obj.analyzer_args or ():
        if not arg:
            continue
        sep = arg[0]
        arg = arg[len(sep):]
        args = arg.split(sep)
        analyzer_args.extend(args)

    cargo_project = arg_obj.cargo_project
    source_args = arg_obj.source_args or ()
    source_max_size = arg_obj.source_max_size
    sarif_file = arg_obj.sarif_output
    base_dir = arg_obj.base_dir
    cargo_path = arg_obj.cargo_path
    clippy_path = arg_obj.clippy_path
    clippy_sarif_path = arg_obj.clippy_sarif_path
    keep_going = arg_obj.keep_going
    clippy_ignore_returncodes = arg_obj.clippy_ignore_returncodes or ()
    clippy_ignore_returncodes = frozenset((int(x) for x in clippy_ignore_returncodes))

    cargo_cmd = 'cargo'
    clippy_subcmd = 'clippy'
    # We aren't going to call cargo-clippy directly,
    #  but if it doesn't exist, then clippy isn't installed:
    clippy_cmd = 'cargo-clippy'
    clippy_sarif_cmd = 'clippy-sarif'

    # Both base_dir and cargo_project will determine the cargo project directory.
    # The base_dir option is provided to try and add consistency with other scan.py subcommands.
    if base_dir:
        base_dir = os.path.normpath(base_dir)
    else:
        base_dir = None
    cargo_project_dir = cargo_project
    if base_dir and cargo_project_dir:
        cargo_project_dir = os.path.normpath(os.path.join(base_dir, cargo_project_dir))
    if cargo_path:
        # Use full path to cargo (and clippy-sarif, later),
        #  since Popen() will possibly change the current directory,
        #  and the user could have given us a relative path.
        cargo_path = os.path.join(base_dir or cwd, cargo_path)
        cargo_cmd = cargo_path
    else:
        cargo_path = which(cargo_cmd)

    if clippy_path:
        clippy_path = os.path.join(base_dir or cwd, clippy_path)
        clippy_cmd = clippy_path
    else:
        clippy_path = which(clippy_cmd)

    if clippy_sarif_path:
        clippy_sarif_path = os.path.join(base_dir or cwd, clippy_sarif_path)
        clippy_sarif_cmd = clippy_sarif_path
    else:
        clippy_sarif_path = which(clippy_sarif_cmd)
    if not cargo_path:
        stderr.write(
            'Error: Could not find Cargo executable.'
            '  Please ensure Rust Cargo tools are in the PATH.\n')
        return 1
    if not clippy_path:
        stderr.write(
            'Error: Could not find Clippy executable.'
            '  Please ensure Rust Clippy analyzer is installed for Cargo and that Cargo is in the PATH.\n'
            '  You can install Clippy using `rustup component add clippy`.\n')
        return 1
    if not clippy_sarif_path:
        stderr.write(
            'Error: Could not find clippy-sarif executable.'
            '  Please ensure clippy-sarif tool is installed.\n'
            'You can install clippy-sarif using `cargo install clippy-sarif`.\n')
        return 1
    if cargo_project_dir and not os.path.isdir(cargo_project_dir):
        stderr.write(
            f'The scan source must be a directory: "{cargo_project_dir}"\n')
        return 1

    source_args2 = []
    if cargo_project_dir:
        default_source_pattern = os.path.join(cargo_project_dir, '**', '*.rs')
        # Use normpath to remove initial './' if it exists.
        #  This can help to ensure compatibility with exclusion patterns.
        default_source_pattern = os.path.normpath(default_source_pattern)
        # Put the default pattern at the front of the include/exclude list:
        source_args2.append((INCLUDE_FLAG, default_source_pattern))
    for flag, source_pattern in source_args:
        if base_dir and base_dir != os.path.curdir:
            source_pattern = os.path.join(base_dir, source_pattern)
        source_args2.append((flag, source_pattern))
    source_args = source_args2

    temp_sarif_file = None
    if not sarif_file:
        fd, temp_sarif_file = mkstemp(prefix='codesonar.rust_scan.', suffix='.sarif', dir='.')
        os.close(fd)
        sarif_file = temp_sarif_file
    clippy = None
    clippy_sarif = None
    try:
        clippy_argv = [
            cargo_path,
            clippy_subcmd,
            '--message-format=json',
            ]
        if keep_going:
            clippy_argv.append('--keep-going')
        if analyzer_args:
            clippy_argv.extend(analyzer_args)
        clippy_sarif_argv = [
            clippy_sarif_path,
            '-o', sarif_file,
        ]
        sarif_argv = ['import_sarif.py', sarif_file]
        if source_max_size:
            sarif_argv.extend(('-source-max-bytes', str(source_max_size)))
        if cargo_project_dir:
            sarif_argv.extend(('-path-base', cargo_project_dir))
        for flag, pattern in source_args:
            sarif_argv.append(flag)
            sarif_argv.append(pattern)
        if cargo_project_dir:
            stderr.write('cd "%s" && ' % cargo_project_dir)
        stderr.write('%s | %s' % (
            list2cmdline(clippy_argv),
            list2cmdline(clippy_sarif_argv)))
        stderr.write(linesep)
        stderr.flush()
        # We could use a with-block here,
        #  but we use try-finally to try and ensure both processes are stopped.
        clippy = Popen(clippy_argv, stdin=DEVNULL, stdout=PIPE, cwd=cargo_project_dir)
        clippy_sarif = Popen(clippy_sarif_argv, stdin=clippy.stdout, cwd=cargo_project_dir)
        clippy_returncode = clippy.wait()
        clippy_sarif_returncode = clippy_sarif.wait()
        # Close clippy.stdout pipe to avoid warning:
        #  ResourceWarning: unclosed file <_io.BufferedReader name=4>
        clippy.stdout.close()
        clippy = None
        clippy_sarif = None
        if clippy_returncode:
            stderr.write('clippy exited with failure code %d.\n' % clippy_returncode)
            if clippy_returncode in clippy_ignore_returncodes:
                stderr.write('Ignoring clippy failure code %d.\n' % clippy_returncode)
            elif keep_going:
                stderr.write('Ignoring clippy failure since -keep-going was specified.\n')
            else:
                returncode = clippy_returncode
        if clippy_sarif_returncode:
            stderr.write('clippy-sarif exited with failure code %d.\n' % clippy_sarif_returncode)
        if not returncode:
            returncode = clippy_sarif_returncode
        if not returncode:
            stderr.write(list2cmdline(sarif_argv))
            stderr.write(linesep)
            stderr.flush()
            returncode = import_sarif.main(sarif_argv)
    finally:
        if clippy_sarif:
            clippy_sarif.kill()
            clippy_sarif.wait()
            returncode = 1
        if clippy:
            clippy.kill()
            clippy.wait()
            clippy.stdout.close()
            returncode = 1
        if temp_sarif_file:
            os.remove(temp_sarif_file)
    return returncode


if __name__ == '__main__':
    sys.exit(main(sys.argv, (sys.stdin, sys.stdout, sys.stderr)))
