# Part of the A-A-P recipe executive: Parse and execute recipe commands

# Copyright (C) 2002 Stichting NLnet Labs
# Permission to copy and use this file is specified in the file COPYING.
# If this file is missing you can find it here: http://www.a-a-p.org/COPYING

import string
import sys
import traceback

from Util import *
from Work import setrpstack
from Error import *
from RecPos import rpcopy
import Global

# ":" commands that only work at the toplevel
aap_cmd_toplevel = [
		"autodepend",
		"recipe",
		"rule",
		"variant",
		]

# All possible names of ":" commands in a recipe, including toplevel-only ones.
aap_cmd_names = [
		"add",
		"attr",
		"attribute",
		"cat",
		"checkin",
		"checkout",
		"child",
		"commit",
		"commitall",
		"copy",
		"del",
		"delete",
		"error",
		"export",
		"filetype",
		"include",
		"mkdir",
		"move",
		"print",
		"publish",
		"publishall",
		"refresh",
		"remove",
		"removeall",
		"require",
		"sys",
		"system",
		"touch",
		"unlock",
		"update",
		"verscont",
		] + aap_cmd_toplevel

# marker for recipe line number in Python script
line_marker = '#@recipe='
line_marker_len = len(line_marker)


def assert_var_name(name, rpstack):
    """Check if "name" is a valid variable name.
       If it isn't, throw a user exception."""
    for c in name:
	if not varchar(c):
	    recipe_error(rpstack, _("Invalid character in variable name"))


def get_var_name(fp):
    """Get the name of a variable from the current position at "fp" and
    get the index of the next non-white after it.  Returns an empty string if
    there is no valid variable name."""
    idx = fp.idx
    while idx < fp.line_len and varchar(fp.line[idx]):
	idx = idx + 1
    return fp.line[fp.idx:idx], skip_white(fp.line, idx)


class ArgItem:
    """Object used as smallest part in the arglist."""
    def __init__(self, isexpr, str):
	self.isexpr = isexpr	    # 0 for string, 1 for Python expression
	self.str = str		    # the string itself


def getarg(fp, stop, globals):
    """Get an argument starting at fp.line[fp.idx] and ending at a character in
       stop[].
       Quotes are used to include stop characters in the argument.
       Backticks are handled here.  `` is reduced to a single `.  A Python
       expression `python` is translated to '" + expr2str(python) + "'.
       Returns the resulting string and fp.idx is updated to the character
       after the argument (the stop character or past the end of line).
       """
    res = ''				# argument collected so far
    inquote = ''			# quote we're inside of
    inbraces = 0			# inside {} count
    while 1:
	if fp.idx >= fp.line_len:	# end of line
	    break

	# Python `expression`?
	if fp.line[fp.idx] == '`':
	    # `` isn't the start of an expression, reduce it to a single `.
	    if fp.idx + 1 < fp.line_len and fp.line[fp.idx + 1] == '`':
		res = res + '`'
		fp.idx = fp.idx + 2
		continue
	    # Append the Python expression.
	    res = res + '" + expr2str(' + get_py_expr(fp) + ') + "'
	    continue

	# End of quoted string?
	if inquote:
	    if fp.line[fp.idx] == inquote:
		inquote = ''

	# Start of quoted string?
	elif fp.line[fp.idx] == '"' or fp.line[fp.idx] == "'":
	    inquote = fp.line[fp.idx]

	else:
	    # start/end of {}?
	    if fp.line[fp.idx] == '{':
		inbraces = inbraces + 1
	    elif fp.line[fp.idx] == '}':
		inbraces = inbraces - 1
		if inbraces < 0:
		    # TODO: recipe_error(fp.rpstack, _("Unmatched }"))
		    inbraces = 0

	    # Stop character found?
	    # A ':' must be followed by white space to be recongized.
	    # A '=' must not be inside {}.
	    if string.find(stop, fp.line[fp.idx]) != -1 \
		    and (fp.line[fp.idx] != ':'
			    or fp.idx + 1 == fp.line_len
			    or fp.line[fp.idx + 1] == ' '
			    or fp.line[fp.idx + 1] == '\t') \
		    and (fp.line[fp.idx] != '=' or inbraces == 0):
		break

	# Need to escape backslash and double quote.
	c = fp.line[fp.idx]
	if c == '"' or c == '\\':
	    res = res + '\\'
	res = res + c
	fp.idx = fp.idx + 1

	# Skip over $$ and $#.
	if (c == '$' and fp.idx < fp.line_len
		       and (fp.line[fp.idx] == '$' or fp.line[fp.idx] == '#')):
	    res = res + fp.line[fp.idx]
	    fp.idx = fp.idx + 1

    # Remove trailing white space.
    e = len(res)
    while e > 0 and is_white(res[e - 1]):
	e = e - 1

    return res[:e]


def get_func_args(fp, indent, globals):
    """Get the arguments for an aap_ function or assignment from the recipe
       line(s).
       Stop at a line with an indent of "indent" or less.

       Input lines:   cmdname arg ` <python-expr> `arg
			        arg
       Result:        "arg " + expr2str(<python-expr>) + "arg arg"

       Return the argument string, advance fp to the following line."""

    res = ''
    fp.idx = skip_white(fp.line, fp.idx)

    while 1:
	if fp.idx >= fp.line_len or fp.line[fp.idx] == '#':
	    # Read the next line
	    fp.nextline()
	    if fp.line is None:
		break	# end of file
	    fp.idx = skip_white(fp.line, 0)
	    if get_indent(fp.line) > indent:
		continue
	    # A line with less indent finishes the list of arguments
	    break

	# Get the argument, stop at a comment, handle python expression.
	# A line break is changed into a space.
	if res:
	    res = res + ' '
	res = res + getarg(fp, "#", globals)

    return '"' + res + '"'


def esc_quote(s):
    """Escape double quotes and backslash with a backslash."""
    return string.replace(string.replace(s, '\\', '\\\\'), '"', '\\"')


def get_commands(fp, indent):
    """Read command lines for a dependency or a rule.
       Stop when the indent is at or below "indent".
       Returns the string of commands, each line ending in '\n'."""
    s = ''
    while 1:
	if fp.line is None or get_indent(fp.line) <= indent:
	    break	    # end of commands reached
	s = s + fp.line + '\n'
	fp.nextline()

    return '"""' + esc_quote(s) + '"""'


def get_py_expr(fp):
    """Get a Python expression from ` to matching `.
       Reduce `` to `.
       fp.idx points to the ` and is advanced to after the matching `.
       Returns the expression excluding the ` before and after."""
    # Remember the RecPos where the first ` was found; need to make a copy,
    # because fp.nextline() will change it.
    rpstack = rpcopy(fp.rpstack, fp.rpstack[-1].line_nr)

    res = ''
    fp.idx = fp.idx + 1
    while 1:
	if fp.idx >= fp.line_len:
	    # Python expression continues in the next line.
	    fp.nextline()
	    if fp.line is None:
		recipe_error(rpstack, _("Missing `"))
	    res = res + '\n'
	if fp.line[fp.idx] == '`':
	    # Either the matching ` or `` that stands for a single `.
	    fp.idx = fp.idx + 1
	    if fp.idx >= fp.line_len or fp.line[fp.idx] != '`':
		break	    # found matching `
	    res = res + '`'
	else:
	    # Append a character to the Python expression.
	    res = res + fp.line[fp.idx]
	fp.idx = fp.idx + 1

    return res


def recipe_error(rpstack, msg):
    """Throw an exception for an error in a recipe:
	    Error: Unknown command
	    in recipe "main.aap" line 88: :foobar asdf
	    included from "main.aap" line 33
       When "rpstack" is empty it's not mentioned, useful for errors
       not related to a specific line."""
    # Note: These messages is not translated, so that a parser for the
    # messages isn't confused by various languages.
    if len(rpstack) == 0:
	e = 'Error in recipe: %s\n' % msg
    else:
	e = 'Error in recipe "%s" line %d: %s\n' \
				 % (rpstack[-1].name, rpstack[-1].line_nr, msg)
    if len(rpstack) > 1:
	for i in range(len(rpstack) - 2, 0, -1):
	    e = e + 'included from "%s" line %d\n' \
					% (rpstack[i].name, rpstack[i].line_nr)
    e = e[:-1]	    # remove trailing \n

    raise UserError, e


def script_error(rpstack, script, e):
    """Handle an error raised while executing the Python script produced for a
    recipe.  The error is probably in the recipe, try to give a useful error
    message."""
    etype, evalue, tb = sys.exc_info()

    # A SyntaxError is special: it's not the last frame in the traceback but
    # only in the "etype" and "evalue".  When there is a filename it must have
    # been an internal error, otherwise it's an error in the converted recipe.
    py_line_nr = -1
    if etype is SyntaxError:
	try:
	    msg, (filename, py_line_nr, offset, line) = evalue
	    if not filename is None:
		py_line_nr = -2
	except:
	    pass

    if py_line_nr < 0:
	# Find the line number in the last traceback frame.
	while tb.tb_next:
	    tb = tb.tb_next
	fname = tb.tb_frame.f_code.co_filename
	if py_line_nr == -2 or (fname and not fname == "<string>"):
	    # If there is a filename, it's not an error in the script.
	    from Main import error_msg
	    error_msg(_("Internal Error"))
	    traceback.print_exc()
	    sys.exit(1)

	py_line_nr = traceback.tb_lineno(tb)

    # Translate the line number in the Python script to the line number
    # in the recipe.
    i = 0
    script_len = len(script)
    rec_line_nr = 1
    while 1:
	if py_line_nr == 1:
	    break
	while i < script_len:
	    if script[i] == '\n':
		break
	    i = i + 1
	i = i + 1
	if i >= script_len:
	    break
	if script[i : i + line_marker_len] == line_marker:
	    i = i + line_marker_len
	    j = i
	    while script[j] in string.digits:
		j = j + 1
	    rec_line_nr = string.atoi(script[i:j])
	py_line_nr = py_line_nr - 1

    # Give the exception error with the line number in the recipe.
    recipe_py_error(rpcopy(rpstack, rec_line_nr), '')


def recipe_py_error(rpstack, msg):
    """Turn the list from format_exception_only() into a simple string and pass
    it to recipe_error()."""
    etype, evalue, tb = sys.exc_info()

    lines = traceback.format_exception_only(etype, evalue)

    # For a syntax error remove the "<string>" and line number that the 
    # Python script causes.
    if etype is SyntaxError:
	try:
	    emsg, (filename, lineno, offset, line) = evalue
	    if filename is None:
		lines[0] = '\n'
	except:
	    pass

    str = msg
    for line in lines[:-1]:
	str = str + line + ' '
    str = str + lines[-1]
    recipe_error(rpstack, str)


def Process(fp, globals):
    """Read all the lines in ParsePos "fp", convert it into a Python script and
       execute it.
       When "fp.string" is empty, the source is a recipe file, otherwise it is
       a string (commands from a dependency or rule)."""

    # Need to be able to find the RecPos stack in globals.
    setrpstack(globals, fp.rpstack)

    class Variant:
	"""Class used to remember nested ":variant" commands in
	   variant_stack."""
	def __init__(self, name, indent):
	    self.name = name
	    self.min_indent = indent
	    self.val_indent = 0
	    self.had_star = 0	    # encountered * item

    #
    # At the start of the loop "fp.line" contains the next line to be
    # processsed.  "fp.rpstack[-1].line_nr" is the number of this line in the
    # recipe.
    #
    script = ""
    shell_cmd = ""	    # shell command collected so far
    variant_stack = []	    # nested ":variant" commands
    had_recipe_cmd = 0	    # encountered ":recipe" command
    fp.nextline()	    # read the first line

    while 1:

	# Skip leading white space (unless at end of file).
	if not fp.line is None:
	    indent = get_indent(fp.line)
	    fp.idx = skip_white(fp.line, 0)

	# If it's not a shell command and the previous line was, generate the
	# collected shell commands now.
	if shell_cmd:
	    if (fp.line is None \
		    or indent < shell_cmd_indent \
		    or fp.line[fp.idx:fp.idx + 4] != ":sys"):
		script = script + (' ' * shell_cmd_indent) \
			      + ('aap_shell(%d, globals(), "%s")\n'
				   % (shell_cmd_line_nr, shell_cmd))
		shell_cmd = ''
	elif not fp.line is None:
	    # Append the recipe line number, used for error messages.
	    script = script + ("%s%d\n" % (line_marker, fp.rpstack[-1].line_nr))


	#
	# Handle the end of commands in a variant or the end of a variant
	#
	if len(variant_stack) > 0:
	    v = variant_stack[-1]
	    if fp.line is None or indent <= v.min_indent:
		# End of the :variant command.
		if v.val_indent == 0:
		    recipe_error(fp.rpstack,
				  _("Exepected list of values after :variant"))
		script = script + (' ' * v.min_indent) + (
					   "BDIR = BDIR + '-' + %s\n" % v.name)
		del variant_stack[-1]
		if len(variant_stack) > 0:
		    continue    # another may end here as well
	    else:
		if v.val_indent == 0:
		    v.val_indent = indent
		    first = 1
		else:
		    first = 0
		if indent <= v.val_indent:
		    # Start of a variant value: "debug [ condition ]"
		    # We simply ignore the condition here.
		    if v.had_star:
			recipe_error(fp.rpstack,
					  _("Variant item * must be last one"))
		    if fp.idx < fp.line_len and fp.line[fp.idx] == '*':
			if (fp.idx + 1 < fp.line_len
					and not is_white(fp.line[fp.idx + 1])):
			    recipe_error(fp.rpstack, _("* must be by itself"))
			if not first:
			    script = script + (' ' * v.min_indent) + "else:\n"
			v.had_star = 1
		    else:
			val, n = get_var_name(fp)
			if val == '':
			    recipe_error(fp.rpstack,
						  _("Exepected variant value"))
			if first:
			    # Specify the default value
			    script = script + (' ' * v.min_indent) + (
				  'if not globals().has_key("%s"):\n' % v.name)
			    script = script + (' ' * v.min_indent) + (
					       '  %s = "%s"\n' % (v.name, val))

			script = script + (' ' * v.min_indent) + (
					    "if %s == '%s':\n" % (v.name, val))
		    fp.nextline()
		    if fp.line is None or get_indent(fp.line) <= v.val_indent:
			script = script + (' ' * v.min_indent) + "pass\n"
		    continue

	#
	# Stop at the end of the file.
	#
	if fp.line is None:
	    break

	#
	# A Python block
	#
	#  recipe:    :python <<<
	#		    command
	#		    command
	#		  <<<
	#  Python:	  if 1:
	#		     command
	#		     command
	#
	if fp.line[fp.idx:fp.idx + 7] == ":python":
	    fp.idx = skip_white(fp.line, fp.idx + 7)
	    if fp.idx >= fp.line_len or fp.line[fp.idx] == '#':
		term = None
	    else:
		n = skip_to_white(fp.line, fp.idx)
		term = fp.line[fp.idx:n]
		term_len = len(term)
		n = skip_white(fp.line, n)
		if n < fp.line_len and fp.line[n] != '#':
		    recipe_error(fp.rpstack,
					   _("Too many arguments for :python"))
	    start_line_nr = fp.rpstack[-1].line_nr
	    first = 1
	    while 1:
		fp.nextline()
		if fp.line is None:
		    if not term:
			break
		    fp.rpstack[-1].line_nr = start_line_nr
		    recipe_error(fp.rpstack, _("Unterminated :python block"))

		if first:
		    first = 0
		    # If the indent of the Python block is more than the
		    # current indent, insert an ":if 1".
		    if get_indent(fp.line) > indent:
			script = script + (indent * ' ') + "if 1:" + '\n'

		if not term:
		    # No terminator defined: end when indent is smaller.
		    if get_indent(fp.line) <= indent:
			break
		else:
		    # Terminator defined: end when it's found.
		    n = skip_white(fp.line, 0)
		    if n < fp.line_len and fp.line[n:n + term_len] == term:
			n = skip_white(fp.line, n + term_len)
			if n >= fp.line_len or fp.line[n] == "#":
			    fp.nextline()
			    break

		# Append the recipe line number, used for error messages.
		script = script + ("%s%d\n%s\n"
			      % (line_marker, fp.rpstack[-1].line_nr, fp.line))
	    continue

	#
	# An A-A-P command
	#
	#  recipe:  :cmd arg arg
	#                  arg
	#  Python:  aap_cmd(123, globals(), "arg arg arg")
	#
	if fp.line[fp.idx] == ":":
	    s = fp.idx
	    fp.idx = fp.idx + 1
	    e = skip_to_white(fp.line, fp.idx)
	    cmd_name = fp.line[fp.idx:e]
	    fp.idx = skip_white(fp.line, e)

	    # Check if this is a valid command name.  The error is postponed
	    # until executing the line, so that "@if aapversion > nr" can be
	    # used before it.
	    if cmd_name not in aap_cmd_names:
		cmd_name = "unknown"
		fp.idx = s
	    if fp.string and cmd_name in aap_cmd_toplevel:
		cmd_name = "nothere"
		fp.idx = s

	    #
	    # To avoid starting a shell for every single command, collect
	    # system commands until encountering another command.
	    #
	    # recipe:       :system one-shell-command
	    #		    :sys  two-shell-command
	    # Python:	    aap_shell(123, globals(),
	    #			    "one-shell_command\ntwo_shell_command\n")
	    #
	    if cmd_name == "system" or cmd_name == "sys":
		if not shell_cmd:
		    shell_cmd_line_nr = fp.rpstack[-1].line_nr
		    shell_cmd_indent = indent
		shell_cmd = shell_cmd + getarg(fp, "#", globals) + '\\n'

		# get the next line
		fp.nextline()
		continue

	    # recipe:   :variant VAR
	    #               foo  [ condition ]
	    #                  cmds
	    #               *    [ condition ]
	    #                  cmds
	    # Python:   if VAR == "foo":
	    #                  cmds
	    #           else:
	    #                  cmds
	    #           BDIR = BDIR + '-' + VAR
	    # This is complicated, because "cmds" can be any command, and
	    # variants may nest.  Store the info about the variant in
	    # variant_stack and continue, the rest is handled above.
	    if cmd_name == "variant":
		var_name, n = get_var_name(fp)
		if var_name == '' or (n < fp.line_len and fp.line[n] != '#'):
		    recipe_error(fp.rpstack,
				   _("Expected variable name after :variant"))
		variant_stack.append(Variant(var_name, indent))

		# get the next line
		fp.nextline()
		continue

	    # Generate a call to the Python function for this command.
	    script = script + (indent * ' ') + ('aap_%s(%d, globals(), '
					  % (cmd_name, fp.rpstack[-1].line_nr))

	    if cmd_name == "rule" or cmd_name == "autodepend":
		# recipe:   :rule  target : {attr} source
		#                 commands
		# Python:   aap_rule(123, globals(), "target", "source",
		#                      124, """commands""")
		#
		# recipe:   :autodepend  {attr} source
		#                 commands
		# Python:   aap_autodepend(123, globals(), "source",
		#                      124, """commands""")
		if cmd_name == "rule":
		    target = getarg(fp, ":#", globals)
		    if fp.idx >= fp.line_len or fp.line[fp.idx] != ':':
			recipe_error(fp.rpstack, _("Missing ':' after :%s")
								    % cmd_name)
		    fp.idx = fp.idx + 1
		    script = script + ('"%s", ' % target)

		source = getarg(fp, "#", globals)

		cmd_line_nr = fp.rpstack[-1].line_nr
		fp.nextline()
		cmds = get_commands(fp, indent)

		script = script + ('"%s", %d, %s)\n'
						 % (source, cmd_line_nr, cmds))

	    elif cmd_name == "filetype":
		# recipe:   :filetype [filename]
		#		detection-lines
		# Python:   aap_filetype_python(123, globals(), "arg",
		#				    """detection-lines""")
		#
		arg = getarg(fp, "#", globals)
		cmd_line_nr = fp.rpstack[-1].line_nr
		fp.nextline()
		cmds = get_commands(fp, indent)
		script = script + '"%s", %d, %s)\n' % (arg, cmd_line_nr, cmds)

	    else:
		# get arguments that may continue on the next line
		script = script + get_func_args(fp, indent, globals) + ")\n"

		# When a ":recipe" command is encountered that will probably
		# be executed, make a copy of the globals at the start, so that
		# this can be restored when executing the updated recipe.
		# This is a "heavy" command, only do it when needed.
		from Commands import do_recipe_cmd
		if (cmd_name == "recipe" and not had_recipe_cmd
						and do_recipe_cmd(fp.rpstack)):
		    had_recipe_cmd = 1
		    script = ('globals()["_start_globals"] = globals().copy()\n'
			    + script)

	    continue

	#
	# A Python command
	#
	#  recipe:    @command args
	#  Python:	  command args
	#
	if fp.line[fp.idx] == "@":
	    if fp.idx + 1 < fp.line_len:
		if fp.line[fp.idx + 1] == ' ' \
					    or fp.line[fp.idx + 1] == '\t':
		    # followed by white space: replace @ with a space
		    script = script \
			      + string.replace(fp.line, '@', ' ', 1) + '\n'
		else:
		    # followed by text: remove the @
		    script = script \
			       + string.replace(fp.line, '@', '', 1) + '\n'

	    # get the next line
	    fp.nextline()
	    continue

	#
	# Assignment
	#
	#  recipe:   name = $VAR {attr=val} ` glob("*.c") `
	#                     two
	#  Python:	 name = aap_eval(123, globals(),
	#			"$VAR {attr=val} " + glob("*.c") + " two", 1)
	#
	#  var = value	    assign
	#  var += value	    append (assign if not set yet)
	#  var ?= value	    only assign when not set yet
	#  var $= value	    evaluate when used
	#  var $+= value    append, evaluate when used
	#  var $?= value    only when not set, evaluate when used
	var_name, n = get_var_name(fp)

	if n < fp.line_len:
	    nc = fp.line[n]
	    ec = nc
	    if ec == '$' and n + 1 < fp.line_len:
		ne = n + 1
		ec = fp.line[ne]
	    else:
		ne = n
	    if (ec == '+' or ec == '?') and ne + 1 < fp.line_len:
		lc = ec
		ne = ne + 1
		ec = fp.line[ne]
	    else:
		lc = ''
	    if var_name != '' and ec == '=':
		# When changing $CACHE need to flush the cache and reload it.
		if var_name == "CACHE":
		    script = script + (indent * ' ') + "flush_cache()\n"
		fp.idx = skip_white(fp.line, ne + 1)
		script = script + (indent * ' ') + (
				     "aap_assign(%d, globals(), '%s', "
					  % (fp.rpstack[-1].line_nr, var_name))
		args = get_func_args(fp, indent, globals)
		script = script + args + (", '%s', '%s')\n" % (nc, lc))
		continue

	#
	# If there is no ":" following we don't know what it is.
	#
	targets = getarg(fp, ":#", globals)
	if fp.idx >= fp.line_len or fp.line[fp.idx] != ':':
	    recipe_error(fp.rpstack, _("No recognized item"))

	if fp.string:
	    recipe_error(fp.rpstack, _("Dependency not allowed here"))

	#
	# Dependency
	#
	#  recipe:     target target : source source
	#                    commands
	#  Python:     aap_depend(123, globals(), list-of-targets,
	#			      list-of-sources, "commands")
	#
	else:
	    # Skip the ':' and get the list of sources.
	    fp.idx = skip_white(fp.line, fp.idx + 1)
	    sources = getarg(fp, '#', globals)
	    nr = fp.rpstack[-1].line_nr
	    fp.nextline()
	    script = script + ('aap_depend(%d, globals(), "%s", "%s", %d, '
			      % (nr, targets, sources, fp.rpstack[-1].line_nr))

	    # get the commands and the following line
	    cmds = get_commands(fp, indent)
	    script = script + cmds + ')\n'

	#
	# End of loop over all lines in recipe.
	#


    if fp.string:
	# When parsing a string need to take care of the indent.
	if is_white(fp.string[0]):
	    script = "if 1:\n" + script
    else:
	# Close the file before executing the script, so that ":recipe" can
	# overwrite the file.
	fp.file.close()

    # Prepend the default imports.
    script = "from Commands import *\n" \
		+ "from glob import glob\n" \
		+ script
    
    # DEBUG
    #print script

    #
    # Execute the resulting Python script.
    # Give a useful error message when something is wrong.
    #
    try:
	exec script in globals, globals
    except StandardError, e:
	script_error(fp.rpstack, script, e)


# vim: set sw=4 sts=4 tw=79 fo+=l:
