# Common functions and variables for testing the Python pretty printers.
#
# Copyright (C) 2016-2022 Free Software Foundation, Inc.
# This file is part of the GNU C Library.
#
# The GNU C Library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# The GNU C Library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with the GNU C Library; if not, see
# <https://www.gnu.org/licenses/>.

"""These tests require PExpect 4.0 or newer.

Exported constants:
    PASS, FAIL, UNSUPPORTED (int): Test exit codes, as per evaluate-test.sh.
"""

import os
import re
from test_printers_exceptions import *

PASS = 0
FAIL = 1
UNSUPPORTED = 77

gdb_bin = 'gdb'
gdb_options = '-q -nx'
gdb_invocation = '{0} {1}'.format(gdb_bin, gdb_options)
pexpect_min_version = 4
gdb_min_version = (7, 8)
encoding = 'utf-8'

try:
    import pexpect
except ImportError:
    print('PExpect 4.0 or newer must be installed to test the pretty printers.')
    exit(UNSUPPORTED)

pexpect_version = pexpect.__version__.split('.')[0]

if int(pexpect_version) < pexpect_min_version:
    print('PExpect 4.0 or newer must be installed to test the pretty printers.')
    exit(UNSUPPORTED)

if not pexpect.which(gdb_bin):
    print('gdb 7.8 or newer must be installed to test the pretty printers.')
    exit(UNSUPPORTED)

timeout = 5
TIMEOUTFACTOR = os.environ.get('TIMEOUTFACTOR')

if TIMEOUTFACTOR:
    timeout = int(TIMEOUTFACTOR)

# Otherwise GDB is run in interactive mode and readline may send escape
# sequences confusing output for pexpect.
os.environ["TERM"]="dumb"

try:
    # Check the gdb version.
    version_cmd = '{0} --version'.format(gdb_invocation, timeout=timeout)
    gdb_version_out = pexpect.run(version_cmd, encoding=encoding)

    # The gdb version string is "GNU gdb <PKGVERSION><version>", where
    # PKGVERSION can be any text.  We assume that there'll always be a space
    # between PKGVERSION and the version number for the sake of the regexp.
    version_match = re.search(r'GNU gdb .* ([1-9][0-9]*)\.([0-9]+)',
                              gdb_version_out)

    if not version_match:
        print('The gdb version string (gdb -v) is incorrectly formatted.')
        exit(UNSUPPORTED)

    gdb_version = (int(version_match.group(1)), int(version_match.group(2)))

    if gdb_version < gdb_min_version:
        print('gdb 7.8 or newer must be installed to test the pretty printers.')
        exit(UNSUPPORTED)

    # Check if gdb supports Python.
    gdb_python_cmd = '{0} -ex "python import os" -batch'.format(gdb_invocation,
                                                                timeout=timeout)
    gdb_python_error = pexpect.run(gdb_python_cmd, encoding=encoding)

    if gdb_python_error:
        print('gdb must have python support to test the pretty printers.')
        print('gdb output: {!r}'.format(gdb_python_error))
        exit(UNSUPPORTED)

    # If everything's ok, spawn the gdb process we'll use for testing.
    gdb = pexpect.spawn(gdb_invocation, echo=False, timeout=timeout,
                        encoding=encoding)
    gdb_prompt = u'\(gdb\)'
    gdb.expect(gdb_prompt)

except pexpect.ExceptionPexpect as exception:
    print('Error: {0}'.format(exception))
    exit(FAIL)

def test(command, pattern=None):
    """Sends 'command' to gdb and expects the given 'pattern'.

    If 'pattern' is None, simply consumes everything up to and including
    the gdb prompt.

    Args:
        command (string): The command we'll send to gdb.
        pattern (raw string): A pattern the gdb output should match.

    Returns:
        string: The string that matched 'pattern', or an empty string if
            'pattern' was None.
    """

    match = ''

    gdb.sendline(command)

    if pattern:
        # PExpect does a non-greedy match for '+' and '*'.  Since it can't look
        # ahead on the gdb output stream, if 'pattern' ends with a '+' or a '*'
        # we may end up matching only part of the required output.
        # To avoid this, we'll consume 'pattern' and anything that follows it
        # up to and including the gdb prompt, then extract 'pattern' later.
        index = gdb.expect([u'{0}.+{1}'.format(pattern, gdb_prompt),
                            pexpect.TIMEOUT])

        if index == 0:
            # gdb.after now contains the whole match.  Extract the text that
            # matches 'pattern'.
            match = re.match(pattern, gdb.after, re.DOTALL).group()
        elif index == 1:
            # We got a timeout exception.  Print information on what caused it
            # and bail out.
            error = ('Response does not match the expected pattern.\n'
                     'Command: {0}\n'
                     'Expected pattern: {1}\n'
                     'Response: {2}'.format(command, pattern, gdb.before))

            raise pexpect.TIMEOUT(error)
    else:
        # Consume just the the gdb prompt.
        gdb.expect(gdb_prompt)

    return match

def init_test(test_bin, printer_files, printer_names):
    """Loads the test binary file and the required pretty printers to gdb.

    Args:
        test_bin (string): The name of the test binary file.
        pretty_printers (list of strings): A list with the names of the pretty
            printer files.
    """

    # Disable debuginfod to avoid GDB messages like:
    #
    # This GDB supports auto-downloading debuginfo from the following URLs:
    # https://debuginfod.fedoraproject.org/
    # Enable debuginfod for this session? (y or [n])
    #
    try:
        test('set debuginfod enabled off')
    except Exception:
        pass

    # Load all the pretty printer files.  We're assuming these are safe.
    for printer_file in printer_files:
        test('source {0}'.format(printer_file))

    # Disable all the pretty printers.
    test('disable pretty-printer', r'0 of [0-9]+ printers enabled')

    # Enable only the required printers.
    for printer in printer_names:
        test('enable pretty-printer {0}'.format(printer),
             r'[1-9][0-9]* of [1-9]+ printers enabled')

    # Finally, load the test binary.
    test('file {0}'.format(test_bin))

    # Disable lock elision.
    test('set environment GLIBC_TUNABLES glibc.elision.enable=0')

def go_to_main():
    """Executes a gdb 'start' command, which takes us to main."""

    test('start', r'main')

def get_line_number(file_name, string):
    """Returns the number of the line in which 'string' appears within a file.

    Args:
        file_name (string): The name of the file we'll search through.
        string (string): The string we'll look for.

    Returns:
        int: The number of the line in which 'string' appears, starting from 1.
    """
    number = -1

    with open(file_name) as src_file:
        for i, line in enumerate(src_file):
            if string in line:
                number = i + 1
                break

    if number == -1:
        raise NoLineError(file_name, string)

    return number

def break_at(file_name, string, temporary=True, thread=None):
    """Places a breakpoint on the first line in 'file_name' containing 'string'.

    'string' is usually a comment like "Stop here".  Notice this may fail unless
    the comment is placed inline next to actual code, e.g.:

        ...
        /* Stop here */
        ...

    may fail, while:

        ...
        some_func(); /* Stop here */
        ...

    will succeed.

    If 'thread' isn't None, the breakpoint will be set for all the threads.
    Otherwise, it'll be set only for 'thread'.

    Args:
        file_name (string): The name of the file we'll place the breakpoint in.
        string (string): A string we'll look for inside the file.
            We'll place a breakpoint on the line which contains it.
        temporary (bool): Whether the breakpoint should be automatically deleted
            after we reach it.
        thread (int): The number of the thread we'll place the breakpoint for,
            as seen by gdb.  If specified, it should be greater than zero.
    """

    if not thread:
        thread_str = ''
    else:
        thread_str = 'thread {0}'.format(thread)

    if temporary:
        command = 'tbreak'
        break_type = 'Temporary breakpoint'
    else:
        command = 'break'
        break_type = 'Breakpoint'

    line_number = str(get_line_number(file_name, string))

    test('{0} {1}:{2} {3}'.format(command, file_name, line_number, thread_str),
         r'{0} [0-9]+ at 0x[a-f0-9]+: file {1}, line {2}\.'.format(break_type,
                                                                   file_name,
                                                                   line_number))

def continue_cmd(thread=None):
    """Executes a gdb 'continue' command.

    If 'thread' isn't None, the command will be applied to all the threads.
    Otherwise, it'll be applied only to 'thread'.

    Args:
        thread (int): The number of the thread we'll apply the command to,
            as seen by gdb.  If specified, it should be greater than zero.
    """

    if not thread:
        command = 'continue'
    else:
        command = 'thread apply {0} continue'.format(thread)

    test(command)

def next_cmd(count=1, thread=None):
    """Executes a gdb 'next' command.

    If 'thread' isn't None, the command will be applied to all the threads.
    Otherwise, it'll be applied only to 'thread'.

    Args:
        count (int): The 'count' argument of the 'next' command.
        thread (int): The number of the thread we'll apply the command to,
            as seen by gdb.  If specified, it should be greater than zero.
    """

    if not thread:
        command = 'next'
    else:
        command = 'thread apply {0} next'

    test('{0} {1}'.format(command, count))

def select_thread(thread):
    """Selects the thread indicated by 'thread'.

    Args:
        thread (int): The number of the thread we'll switch to, as seen by gdb.
            This should be greater than zero.
    """

    if thread > 0:
        test('thread {0}'.format(thread))

def get_current_thread_lwpid():
    """Gets the current thread's Lightweight Process ID.

    Returns:
        string: The current thread's LWP ID.
    """

    # It's easier to get the LWP ID through the Python API than the gdb CLI.
    command = 'python print(gdb.selected_thread().ptid[1])'

    return test(command, r'[0-9]+')

def set_scheduler_locking(mode):
    """Executes the gdb 'set scheduler-locking' command.

    Args:
        mode (bool): Whether the scheduler locking mode should be 'on'.
    """
    modes = {
        True: 'on',
        False: 'off'
    }

    test('set scheduler-locking {0}'.format(modes[mode]))

def test_printer(var, to_string, children=None, is_ptr=True):
    """ Tests the output of a pretty printer.

    For a variable called 'var', this tests whether its associated printer
    outputs the expected 'to_string' and children (if any).

    Args:
        var (string): The name of the variable we'll print.
        to_string (raw string): The expected output of the printer's 'to_string'
            method.
        children (map {raw string->raw string}): A map with the expected output
            of the printer's children' method.
        is_ptr (bool): Whether 'var' is a pointer, and thus should be
            dereferenced.
    """

    if is_ptr:
        var = '*{0}'.format(var)

    test('print {0}'.format(var), to_string)

    if children:
        for name, value in children.items():
            # Children are shown as 'name = value'.
            test('print {0}'.format(var), r'{0} = {1}'.format(name, value))

def check_debug_symbol(symbol):
    """ Tests whether a given debugging symbol exists.

    If the symbol doesn't exist, raises a DebugError.

    Args:
        symbol (string): The symbol we're going to check for.
    """

    try:
        test('ptype {0}'.format(symbol), r'type = {0}'.format(symbol))

    except pexpect.TIMEOUT:
        # The symbol doesn't exist.
        raise DebugError(symbol)
