#!/usr/bin/env python3
#
# Copyright (c) 2018 The Bitcoin Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
#
# Lint format strings: This program checks that the number of arguments passed
# to a variadic format string function matches the number of format specifiers
# in the format string.

import argparse
import re
import sys

FALSE_POSITIVES = [
    ("src/dbwrapper.cpp", "vsnprintf(p, limit - p, format, backup_ap)"),
    ("src/index/base.cpp", "FatalError(const char* fmt, const Args&... args)"),
    ("src/netbase.cpp", "LogConnectFailure(bool manual_connection, const char* fmt, const Args&... args)"),
    ("src/util.cpp", "strprintf(_(COPYRIGHT_HOLDERS), _(COPYRIGHT_HOLDERS_SUBSTITUTION))"),
    ("src/util.cpp", "strprintf(COPYRIGHT_HOLDERS, COPYRIGHT_HOLDERS_SUBSTITUTION)"),
    ("src/seeder/main.cpp", "fprintf(stderr, help, argv[0])"),
    ("src/tinyformat.h", "printf(const char *fmt, const Args &... args)"),
    ("src/tinyformat.h", "printf(const char *fmt, TINYFORMAT_VARARGS(n))"),
]


def parse_function_calls(function_name, source_code):
    """Return an array with all calls to function function_name in string source_code.
    Preprocessor directives and C++ style comments ("//") in source_code are removed.

    >>> len(parse_function_calls("foo", "foo();bar();foo();bar();"))
    2
    >>> parse_function_calls("foo", "foo(1);bar(1);foo(2);bar(2);")[0].startswith("foo(1);")
    True
    >>> parse_function_calls("foo", "foo(1);bar(1);foo(2);bar(2);")[1].startswith("foo(2);")
    True
    >>> len(parse_function_calls("foo", "foo();bar();// foo();bar();"))
    1
    >>> len(parse_function_calls("foo", "#define FOO foo();"))
    0
    """
    assert(type(function_name) is str and type(
        source_code) is str and function_name)
    lines = [re.sub("// .*", " ", line).strip()
             for line in source_code.split("\n")
             if not line.strip().startswith("#")]
    return re.findall(r"[^a-zA-Z_](?=({}\(.*).*)".format(function_name), " " + " ".join(lines))


def normalize(s):
    """Return a normalized version of string s with newlines, tabs and C style comments ("/* ... */")
    replaced with spaces. Multiple spaces are replaced with a single space.

    >>> normalize("  /* nothing */   foo\tfoo  /* bar */  foo     ")
    'foo foo foo'
    """
    assert(type(s) is str)
    s = s.replace("\n", " ")
    s = s.replace("\t", " ")
    s = re.sub("/\*.*?\*/", " ", s)
    s = re.sub(" {2,}", " ", s)
    return s.strip()


ESCAPE_MAP = {
    r"\n": "[escaped-newline]",
    r"\t": "[escaped-tab]",
    r'\"': "[escaped-quote]",
}


def escape(s):
    """Return the escaped version of string s with "\\\"", "\\n" and "\\t" escaped as
    "[escaped-backslash]", "[escaped-newline]" and "[escaped-tab]".

    >>> unescape(escape("foo")) == "foo"
    True
    >>> escape(r'foo \\t foo \\n foo \\\\ foo \\ foo \\"bar\\"')
    'foo [escaped-tab] foo [escaped-newline] foo \\\\\\\\ foo \\\\ foo [escaped-quote]bar[escaped-quote]'
    """
    assert(type(s) is str)
    for raw_value, escaped_value in ESCAPE_MAP.items():
        s = s.replace(raw_value, escaped_value)
    return s


def unescape(s):
    """Return the unescaped version of escaped string s.
    Reverses the replacements made in function escape(s).

    >>> unescape(escape("bar"))
    'bar'
    >>> unescape("foo [escaped-tab] foo [escaped-newline] foo \\\\\\\\ foo \\\\ foo [escaped-quote]bar[escaped-quote]")
    'foo \\\\t foo \\\\n foo \\\\\\\\ foo \\\\ foo \\\\"bar\\\\"'
    """
    assert(type(s) is str)
    for raw_value, escaped_value in ESCAPE_MAP.items():
        s = s.replace(escaped_value, raw_value)
    return s


def parse_function_call_and_arguments(function_name, function_call):
    """Split string function_call into an array of strings consisting of:
    * the string function_call followed by "("
    * the function call argument #1
    * ...
    * the function call argument #n
    * a trailing ");"

    The strings returned are in escaped form. See escape(...).

    >>> parse_function_call_and_arguments("foo", 'foo("%s", "foo");')
    ['foo(', '"%s",', ' "foo"', ')']
    >>> parse_function_call_and_arguments("foo", 'foo("%s", "foo");')
    ['foo(', '"%s",', ' "foo"', ')']
    >>> parse_function_call_and_arguments("foo", 'foo("%s %s", "foo", "bar");')
    ['foo(', '"%s %s",', ' "foo",', ' "bar"', ')']
    >>> parse_function_call_and_arguments("fooprintf", 'fooprintf("%050d", i);')
    ['fooprintf(', '"%050d",', ' i', ')']
    >>> parse_function_call_and_arguments("foo", 'foo(bar(foobar(barfoo("foo"))), foobar); barfoo')
    ['foo(', 'bar(foobar(barfoo("foo"))),', ' foobar', ')']
    >>> parse_function_call_and_arguments("foo", "foo()")
    ['foo(', '', ')']
    >>> parse_function_call_and_arguments("foo", "foo(123)")
    ['foo(', '123', ')']
    >>> parse_function_call_and_arguments("foo", 'foo("foo")')
    ['foo(', '"foo"', ')']
    """
    assert(type(function_name) is str and type(
        function_call) is str and function_name)
    remaining = normalize(escape(function_call))
    expected_function_call = "{}(".format(function_name)
    assert(remaining.startswith(expected_function_call))
    parts = [expected_function_call]
    remaining = remaining[len(expected_function_call):]
    open_parentheses = 1
    in_string = False
    parts.append("")
    for char in remaining:
        parts.append(parts.pop() + char)
        if char == "\"":
            in_string = not in_string
            continue
        if in_string:
            continue
        if char == "(":
            open_parentheses += 1
            continue
        if char == ")":
            open_parentheses -= 1
        if open_parentheses > 1:
            continue
        if open_parentheses == 0:
            parts.append(parts.pop()[:-1])
            parts.append(char)
            break
        if char == ",":
            parts.append("")
    return parts


def parse_string_content(argument):
    """Return the text within quotes in string argument.

    >>> parse_string_content('1 "foo %d bar" 2')
    'foo %d bar'
    >>> parse_string_content('1 foobar 2')
    ''
    >>> parse_string_content('1 "bar" 2')
    'bar'
    >>> parse_string_content('1 "foo" 2 "bar" 3')
    'foobar'
    >>> parse_string_content('1 "foo" 2 " " "bar" 3')
    'foo bar'
    >>> parse_string_content('""')
    ''
    >>> parse_string_content('')
    ''
    >>> parse_string_content('1 2 3')
    ''
    """
    assert(type(argument) is str)
    string_content = ""
    in_string = False
    for char in normalize(escape(argument)):
        if char == "\"":
            in_string = not in_string
        elif in_string:
            string_content += char
    return string_content


def count_format_specifiers(format_string):
    """Return the number of format specifiers in string format_string.

    >>> count_format_specifiers("foo bar foo")
    0
    >>> count_format_specifiers("foo %d bar foo")
    1
    >>> count_format_specifiers("foo %d bar %i foo")
    2
    >>> count_format_specifiers("foo %d bar %i foo %% foo")
    2
    >>> count_format_specifiers("foo %d bar %i foo %% foo %d foo")
    3
    >>> count_format_specifiers("foo %d bar %i foo %% foo %*d foo")
    4
    """
    assert(type(format_string) is str)
    n = 0
    in_specifier = False
    for i, char in enumerate(format_string):
        if format_string[i - 1:i + 1] == "%%" or format_string[i:i + 2] == "%%":
            pass
        elif char == "%":
            in_specifier = True
            n += 1
        elif char in "aAcdeEfFgGinopsuxX":
            in_specifier = False
        elif in_specifier and char == "*":
            n += 1
    return n


def main(args_in):
    """ Return a string output with information on string format errors

    >>> main(["printit", "test/lint/lint-format-strings-tests.txt"])
    0

    >>> main(["printme", "test/lint/lint-format-strings-tests.txt"])
    test/lint/lint-format-strings-tests.txt: Expected 1 argument(s) after format string but found 2 argument(s): printme("%d", 1, 2)
    test/lint/lint-format-strings-tests.txt: Expected 2 argument(s) after format string but found 3 argument(s): printme("%a %b", 1, 2, "anything")
    test/lint/lint-format-strings-tests.txt: Expected 1 argument(s) after format string but found 0 argument(s): printme("%d")
    test/lint/lint-format-strings-tests.txt: Expected 3 argument(s) after format string but found 2 argument(s): printme("%a%b%z", 1, "anything")
    1

    >>> main(["printme", "test/lint/lint-format-strings-tests-skip-arguments.txt"])
    test/lint/lint-format-strings-tests-skip-arguments.txt: Expected 0 argument(s) after format string but found 2 argument(s): printme(skipped, "%d", 1)
    test/lint/lint-format-strings-tests-skip-arguments.txt: Expected 0 argument(s) after format string but found 4 argument(s): printme(skipped, "%a%b%z", 1, "anything", 3)
    test/lint/lint-format-strings-tests-skip-arguments.txt: Expected 0 argument(s) after format string but found 3 argument(s): printme(skipped, "%d", 1, 2)
    test/lint/lint-format-strings-tests-skip-arguments.txt: Expected 0 argument(s) after format string but found 1 argument(s): printme(skipped, "%d")
    test/lint/lint-format-strings-tests-skip-arguments.txt: Expected 0 argument(s) after format string but found 3 argument(s): printme(skip1, skip2, "%d", 1)
    test/lint/lint-format-strings-tests-skip-arguments.txt: Expected 0 argument(s) after format string but found 5 argument(s): printme(skip1, skip2, "%a%b%z", 1, "anything", 3)
    test/lint/lint-format-strings-tests-skip-arguments.txt: Expected 0 argument(s) after format string but found 4 argument(s): printme(skip1, skip2, "%d", 1, 2)
    test/lint/lint-format-strings-tests-skip-arguments.txt: Expected 0 argument(s) after format string but found 2 argument(s): printme(skip1, skip2, "%d")
    1

    >>> main(["--skip-arguments=1", "printme", "test/lint/lint-format-strings-tests-skip-arguments.txt"])
    test/lint/lint-format-strings-tests-skip-arguments.txt: Expected 1 argument(s) after format string but found 2 argument(s): printme(skipped, "%d", 1, 2)
    test/lint/lint-format-strings-tests-skip-arguments.txt: Expected 1 argument(s) after format string but found 0 argument(s): printme(skipped, "%d")
    test/lint/lint-format-strings-tests-skip-arguments.txt: Expected 0 argument(s) after format string but found 2 argument(s): printme(skip1, skip2, "%d", 1)
    test/lint/lint-format-strings-tests-skip-arguments.txt: Expected 0 argument(s) after format string but found 4 argument(s): printme(skip1, skip2, "%a%b%z", 1, "anything", 3)
    test/lint/lint-format-strings-tests-skip-arguments.txt: Expected 0 argument(s) after format string but found 3 argument(s): printme(skip1, skip2, "%d", 1, 2)
    test/lint/lint-format-strings-tests-skip-arguments.txt: Expected 0 argument(s) after format string but found 1 argument(s): printme(skip1, skip2, "%d")
    1

    >>> main(["--skip-arguments=2", "printme", "test/lint/lint-format-strings-tests-skip-arguments.txt"])
    test/lint/lint-format-strings-tests-skip-arguments.txt: Expected 0 argument(s) after format string but found 2 argument(s): printme(skipped, "%a%b%z", 1, "anything", 3)
    test/lint/lint-format-strings-tests-skip-arguments.txt: Expected 0 argument(s) after format string but found 1 argument(s): printme(skipped, "%d", 1, 2)
    test/lint/lint-format-strings-tests-skip-arguments.txt: Could not parse function call string "printme(...)": printme(skipped, "%d")
    test/lint/lint-format-strings-tests-skip-arguments.txt: Expected 1 argument(s) after format string but found 2 argument(s): printme(skip1, skip2, "%d", 1, 2)
    test/lint/lint-format-strings-tests-skip-arguments.txt: Expected 1 argument(s) after format string but found 0 argument(s): printme(skip1, skip2, "%d")
    1
    """
    parser = argparse.ArgumentParser(description="This program checks that the number of arguments passed "
                                     "to a variadic format string function matches the number of format "
                                     "specifiers in the format string.")
    parser.add_argument("--skip-arguments", type=int, help="number of arguments before the format string "
                        "argument (e.g. 1 in the case of fprintf)", default=0)
    parser.add_argument(
        "function_name", help="function name (e.g. fprintf)", default=None)
    parser.add_argument("file", type=argparse.FileType(
        "r", encoding="utf-8"), nargs="*", help="C++ source code file (e.g. foo.cpp)")
    args = parser.parse_args(args_in)

    exit_code = 0
    for f in args.file:
        for function_call_str in parse_function_calls(args.function_name, f.read()):
            parts = parse_function_call_and_arguments(
                args.function_name, function_call_str)
            relevant_function_call_str = unescape("".join(parts))[:512]
            if (f.name, relevant_function_call_str) in FALSE_POSITIVES:
                continue
            if len(parts) < 3 + args.skip_arguments:
                exit_code = 1
                print("{}: Could not parse function call string \"{}(...)\": {}".format(
                    f.name, args.function_name, relevant_function_call_str))
                continue
            argument_count = len(parts) - 3 - args.skip_arguments
            format_str = parse_string_content(parts[1 + args.skip_arguments])
            format_specifier_count = count_format_specifiers(format_str)
            if format_specifier_count != argument_count:
                exit_code = 1
                print("{}: Expected {} argument(s) after format string but found {} argument(s): {}".format(
                    f.name, format_specifier_count, argument_count, relevant_function_call_str))
                continue
    return exit_code


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
