# 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: