from __future__ import print_function

"""
Variables

This module provides a Vars class that supports
variable substitution with modifiers similar to bmake(1).
"""

"""
RCSid:
	$Id: vars.py,v 1.43 2026/04/02 20:24:04 sjg Exp $

	@(#) Copyright (c) 2014-2026 Simon J. Gerraty

	SPDX-License-Identifier: BSD-2-Clause
      
	Please send copies of changes and bug-fixes to:
	sjg@crufty.net

"""

import fnmatch
import os
import os.path
import re
import sys
import subprocess

def run_cmd(cmd):
    """run cmd and capture output"""
    # PITA to get this to work with python 2 and 3
    r = subprocess.Popen(cmd.split(), universal_newlines=True,
                         stdout=subprocess.PIPE)
    return ''.join(r.stdout.readlines()).strip()

def unescape(s,x=''):
    """handle common escape chars"""
    ra = []
    i = 0
    n = len(s)
    while i < n:
        c = s[i]
        i += 1
        if c == '\\':
            if s[i] == '\\' or s[i] in x:
                ra.append(s[i])
            elif s[i] == 'n':
                ra.append('\n')
            elif s[i] == 't':
                ra.append('\t')
            i += 1
            continue
        ra.append(c)
    return ''.join(ra)

def str_subst(s,p,r,gf=False):
    """substitute r for p in s

    if gf is True replace every instance, otherwise just the first
    """
    ra = []
    if p.startswith('^'):
        if s.startswith(p[1:]):
           ra.append(r)
           ra.append(s[len(p) - 1:])
        else:
            return s
    elif p.endswith('$'):
        if s.endswith(p[0:-1]):
            ra.append(s[0:-len(p)])
            ra.append(r)
        else:
            return s
    else:
        if gf:
            n = len(s)
        else:
            n = 1
        while n > 0:
            x = s.find(p)
            if x < 0:
                return s
            n -= 1
            if x > 0:
                ra.append(s[0:x])
            ra.append(r)
            ra.append(s[x+len(p):])
            s = ''.join(ra)
            ra = []
        return s
    return ''.join(ra)

def re_subst(s,p,r,gf=False):
    """substitute r for p in s

    if gf is True replace every instance, otherwise just the first
    """
    mp = re.compile(unescape(p,'()'))
    
    if gf:
        n = 0
    else:
        n = 1
    return mp.sub(r, s, n)

Braces = {
    '{': '}',
    '(': ')',
    '<': '>',
    '[': ']'
    }

def find_ec(s,t=None):
    """find the index of end char that matches the first or t if set"""
    n=0
    sc = s[0]
    if sc in Braces:
        ec = Braces[sc]
    else:
        ec = None
    for i in range(len(s)):
        c = s[i]
        if c == sc:
            n += 1
        elif c == ec:
            n -= 1
            if n == 0:
                return i
        elif c == t and n == 1:
            return i
    return -1

class varMod(object):
    """track state of var during a series of mods
    We track name, val and va - an array of words
    representing val, only one of val or va should be set at any time.
    """

    def __init__(self, name, val):
        """initialize name and va"""
        self.name = name
        if val:
            self.va = val.split()
        else:
            self.va = []
        self.val =  None
        self.sep = ' '
        
    def get_str(self):
        """return a string representation"""
        if not self.val is None:
            return self.val
        return self.sep.join(self.va)

    def get_words(self):
        """return va
        if necessary set it from val
        """
        if not self.val is None:
            self.va = self.val.split()
            self.val = None
        return self.va

    def set_words(self, va=None):
        """set va by splitting val if needed"""
        if not va is None:
            self.va = va
        elif self.val:
            self.va = self.val.split()
        self.val = None

    def set_str(self, str=None):
        """set val from va if no str provided"""
        if not str is None:
            self.val = str
        else:
            self.val = self.sep.join(self.va)

    def set_sep(self, sep):
        """set the separator to be used when joining va"""
        self.sep = sep


class Vars(object):
    """Class for variable storage and substiutions

    The syntax is similar to bmake(1).
    """
    
    GET_NO_ENV=1
    
    def __init__(self, vars={}):
        """initialize vars
        set progname, progdir and .newline
        """
        self.vars = vars;
        self.set('.newline', '\n');
        self.set('.', vars.get('progname', sys.argv[0]))
        self.set('progname', '${.:T}', ':=')
        self.set('progdir',  '${.:H:tA}', ':=')
        self.delete('.')

    def set(self, var, val, op='=', flags=0):
        """set var to val according to op

        ?= we ignore val if var already set
        += we append val to existing value (which might be a list)
        =  we override any current value
        := as for '=' but expand val first
        != run val and use output as value
        """
        if '$' in var:
            var = self.subst(var)
        if op == '=':
            self.vars[var] = val
        elif op == ':=':
            self.vars[var] = self.subst(val)
        elif op == '?=':
            if self.get(var, flags=flags):
                return
            self.vars[var] = val
        elif op == '+=':
            self.append(var,val)
        elif op == '!=':
            self.vars[var] = run_cmd(val)

    def append(self, var, val):
        """append val to var"""
        if not var in self.vars:
            self.vars[var] = val
        elif isinstance(self.vars[var], list):
            if isinstance(val, list):
                self.vars[var] += val
            else:
                self.vars[var].append(val)
        else:
            self.vars[var] += ' ' + val

    def delete(self, var):
        """remove var"""
        if '$' in var:
            var = self.subst(var)
        if var in self.vars:
            del self.vars[var]

    def get(self, var, d=None, flags=0):
        """return raw value of var"""
        if '$' in var:
            var = self.subst(var)
        if var in self.vars:
            return self.vars[var]
        if (flags & self.GET_NO_ENV) == 0:
            return os.getenv(var, d)
        return d

    def dump(self, file=sys.stdout):
        """dump all the vars and their values to file"""
        for k,v in self.vars.items():
            print('{}={}'.format(k,v), file=file)
            
    def modify(self, var, val, mods, debug=0):
        """Apply a series of modifiers

        The modifiers are patterned on bmake.
        The following is lifted from bmake(1) though re-ordered and
        slightly abridged:

             :C/pattern/replacement/[1gW]
                  The :C modifier works like the :S modifier except
                  that the old and new strings, instead of being
                  simple strings, are an extended regular expressions.

             :Dnewval
                  If the variable is defined, newval is the value.
        
             :E   Replaces each word with its suffix.
        
             :gmtime[=timestamp]
                  The value is interpreted as a format string for strftime(3),
                  using gmtime(3), producing the formatted timestamp.
                  If a timestamp value is not provided or is 0, the current
                  time is used. 
        
             :H   Replaces each word with its dirname.
        
             :hash[=hash_func]
                  Computes a hash using the specified hash function
                  of the value.
                  The default is sha1 and only 8 bytes will be reported,
                  whereas :hash=sha1 will give the full value.
        
             :L   The name of the variable is the value.
        
             :localtime[=timestamp]
                  As for :gmtime but using localtime(3).
        
             :Mpattern
                  Selects only those words that match pattern.
                  The standard shell wildcard characters (`*', `?', and `[]')
                  may be used.
        
             :Npattern
                  This is the opposite of `:M', selecting all words which
                  do not match pattern.
        
             :O   Orders the words lexicographically.
        
             :On  Orders the words numerically.
        
             :Or  Orders the words in reverse lexicographical order.
        
             :Orn
                  Orders the words in reverse numerical order.
        
             :R   Replaces each word with everything but its suffix.
        
             :range
                  The value is an integer sequence representing the words
                  of the original value.
        
             :S/old_string/new_string/[1gW]
                  Modifies the first occurrence of old_string in each word of
                  the value, replacing it with new_string. If a `g' is
                  appended to the last delimiter of the pattern, all
                  occurrences in each word are replaced.
                  If a `1' is appended to the last delimiter of the
                  pattern, only the first occurrence is affected.
                  If a `W' is appended to the last delimiter of the
                  pattern, the value is treated as a single word.
                  If old_string begins with a caret (`^'),
                  old_string is anchored at the beginning of each
                  word.
                  If old_string ends with a dollar sign (`$'), it is
                  anchored at the end of each word.
                  Inside new_string, an ampersand (`&') is replaced by
                  old_string (without the anchoring `^' or `$').  Any
                  character may be used as the delimiter for the parts
                  of the modifier string.  The anchoring, ampersand
                  and delimiter characters can be escaped with a 
                  backslash (`\').
        
             :sh  The value is run as a command, and the output
                  becomes the new value.
        
             :T   Replaces each word with its last path component (basename).
        
             :tA  Attempts to convert the value to an absolute path using
                  realpath(3).  If that fails, the value is unchanged.
        
             :tl  Converts the value to lower-case letters.
        
             :tsc
                  When joining the words after a modifier that treats the
                  value as words, the words are normally separated by a space.
                  This modifier changes the separator to the character c.
                  If c is omitted, no separator is used.
                  The common escapes (including octal numeric
                  codes) work as expected.
        
             :tt  Converts the value to title-case words.
                  That is, capitalize the first letter of each word.

             :tu  Converts the value to upper-case letters.
        
             :u   Removes adjacent duplicate words (like uniq(1)).
        
             :@varname@string@
                  This is the loop expansion mechanism from the OSF Development
                  Environment (ODE) make. 
                  For each word in the value, assign the word to
                  the variable named varname and evaluate string.
        
             :_[=var]
                  Saves the current variable value in `$_' or the
                  named var for later reference.
        
             :Unewval
                  If the variable is undefined, newval is the value.
                
             :[range]
                  Selects one or more words from the value, or performs other
                  operations related to the way in which the value is
                  split into words.
        
                  An empty value, or a value that consists entirely of
                  white-space, is treated as a single word.  For the
                  purposes of the `:[]' modifier, the words are
                  indexed both forwards using positive integers (where 
                  index 1 represents the first word), and backwards
                  using negative integers (where index -1 represents
                  the last word).
        
                  The range is subjected to variable expansion, and
                  the expanded result is then interpreted as follows:
        
                  index  Selects a single word from the value.
        
                  start..end
                         Selects all words from start to end,
                         inclusive. For example, `:[2..-1]' selects
                         all words from the second word to the last 
                         word.  If start is greater than end, the
                         words are output in reverse order.  For
                         example, `:[-1..1]' selects all the words 
                         from last to first.  If the list is already
                         ordered, this effectively reverses the list,
                         but it is more efficient to use `:Or' instead
                         of `:O:[-1..1]'.
        
                  *      Causes subsequent modifiers to treat the
                         value as a single word (possibly containing
                         embedded whitespace).  Analogous to 
                         the effect of $* in Bourne shell.
        
                  0      Means the same as `:[*]'.
        
                  @      Causes subsequent modifiers to treat the
                         value as a sequence of words delimited by
                         whitespace.  Analogous to the effect of 
                         $@ in Bourne shell.
        
                  #      Returns the number of words in the value.
                """

        if debug:
            print('apply: {} to: {}'.format(mods,val))
        vm = varMod(var,val)
        while True:
            if len(mods) == 0:
                break
            # corner cases first - might contain ':'
            if mods.startswith('$') and mods[1] in '{(':
                ei = find_ec(mods[1:])
                if ei < 0:
                    raise ValueError(mods)
                x = self.subst(mods[:ei+2])
                mods = x + mods[ei+2:]
            if mods.startswith('S') or mods.startswith('C'):
                S,old,new,flags = mods.split(mods[1],3)
                if ':' in flags:
                    flags,mods = flags.split(':', 1)
                else:
                    mods = ''
                wf = one = gf = False
                nv = []
                if 'W' in flags:
                    wf = True
                if '1' in flags:
                    one = True
                if 'g' in flags:
                    gf = True
                if wf:
                    wl = [vm.get_str()]
                else:
                    wl = vm.get_words()
                done = False
                for w in wl:
                    if not done:
                        if S == 'C':
                            nv.append(re_subst(w, old, new, gf))
                        else:
                            nv.append(str_subst(w, old, new, gf))
                        if one:
                            done = True
                    else:
                        nv.append(w)
                # corner case!
                if old == ':' and new == ' ':
                    nnv = []
                    for w in nv:
                        nnv += w.split()
                    nv = nnv
                vm.set_words(nv)
                continue
            if mods.startswith('ts:'):
                vm.set_sep(':')
                mods = mods[3:]
                continue
            if mods.startswith('@'):
                j,tv,ts,r = mods.split('@', 3)
                # if ts is sane we are good
                i = ni = 0
                while True:
                    ni = ts[i:].find('${')
                    if ni < 0:
                        ni = ts[i:].find('$(')
                    if ni < 0:
                        break
                    i = ni + 1
                    ei = find_ec(ts[i:])
                    if ei < 0:
                        break
                    i = ei + 1
                if i > 0 and ei < 0:
                    # parsing is too complicated! ;-)
                    raise ValueError(mods)
                if r.startswith(':'):
                    mods = r[1:]
                else:
                    mods = ''
                # temp vars context
                tvars = Vars(self.vars)
                nv = []
                for w in vm.get_words():
                    tvars.set(tv, w)
                    nv.append(tvars.subst(ts, debug))
                vm.set_words(nv)
                continue
            if ':' in mods:
                m,mods = mods.split(':', 1)
            else:
                m = mods
                mods = ''

            if m == '_':
                self.set('_', vm.get_str())
            elif m.startswith('['):
                # the only non-numeric we support is '#'
                x = m[1:-1].split('..')
                if x[0].startswith('$'):
                    x[0] = self.subst(x[0])
                w = vm.get_words()
                if len(x) == 1:
                    if x[0] == '#':
                        vm.set_str('{}'.format(len(w)))
                        continue
                    if x[0] == '*' or x[0] == '0':
                        vm.set_str()
                        continue
                    if x[0] == '@':
                        vm.set_words()
                        continue
                    i = int(x[0])
                    if i < 0:
                        vm.set_str(w[i])
                    elif i > 0:
                        vm.set_str(w[i-1])
                    continue
                # a range - a bit messy since we are matching make
                ix = map(int, x)
                if ix[0] > 0:
                    ix[0] -= 1
                if ix[1] == -1:
                    vm.set_words(w[ix[0]:])
                elif ix[0] == -1 and ix[1] == 1:
                    vm.set_words(w[-1::-1])
                else:
                    vm.set_words(w[ix[0]:ix[1]])
            elif m.startswith('D'):
                if self.get(var):
                    vm.set_str(m[1:])
            elif m == 'E':
                nv = []
                for w in vm.get_words():
                    ext = os.path.splitext(w)[1]
                    if ext:
                        nv.append(ext[1:])
                    else:
                        nv.append('')
                vm.set_words(nv)
            elif m == 'H':
                nv = map(lambda s: os.path.dirname(s), vm.get_words())
                vm.set_words(nv)
            elif m.startswith('hash'):
                import hashlib

                if m == 'hash':
                    h = hashlib.sha1()
                    hlen = 8
                else:
                    h = hashlib.new(m[5:])
                    hlen = 0
                if sys.version_info[0] == 2:
                    h.update(vm.get_str())
                else:
                    h.update(bytes(vm.get_str(), "utf-8"))
                nv = h.hexdigest()
                if hlen:
                    vm.set_str(nv[:hlen])
                else:
                    vm.set_str(nv)
            elif m == 'L':
                vm.set_str(var)
            elif m.startswith('M'):
                nv = []
                p = m[1:]
                for w in vm.get_words():
                    if fnmatch.fnmatch(w, p):
                        nv.append(w)
                vm.set_words(nv)
            elif m.startswith('N'):
                nv = []
                p = m[1:]
                for w in vm.get_words():
                    if not fnmatch.fnmatch(w, p):
                        nv.append(w)
                vm.set_words(nv)
            elif m.startswith('O'):
                nv = vm.get_words()
                if 'n' in m:
                    k=int
                else:
                    k=None
                if 'r' in m:
                    r=True
                else:
                    r=False
                nv = sorted(nv, key=k, reverse=r)
                vm.set_words(nv)
            elif m == 'R':
                nv = map(lambda s: os.path.splitext(s)[0], vm.get_words())
                vm.set_words(nv)
            elif m == 'range':
                nv = list(map(lambda i: '{}'.format(i), range(1,len(vm.va)+1)))
                vm.set_words(nv)
            elif m == 'sh':
                nv = run_cmd(vm.get_str())
                vm.set_str(nv)
            elif m == 'T':
                nv = map(lambda s: os.path.basename(s), vm.get_words())
                vm.set_words(nv)
            elif m == 'tA':
                nv = map(lambda s: os.path.realpath(s), vm.get_words())
                vm.set_words(nv)
            elif m == 'tl':
                vm.set_str(vm.get_str().lower())
            elif m.startswith('ts'):
                vm.set_sep(unescape(m[2:]))
            elif m == 'tt':
                nv = []
                for w in vm.get_words():
                    nv.append(w.capitalize())
                vm.set_words(nv)
            elif m == 'tu':
                vm.set_str(vm.get_str().upper())
            elif m.startswith('gmtime') or m.startswith('localtime'):
                import time

                if '$' in m:
                    m = self.subst(m)
                x = m.find('=')
                if x == 6 or x == 9:
                    utc = int(m[x+1:])
                else:
                    utc = time.time()
                if m[0] == 'g':
                    tm = time.gmtime(utc)
                else:
                    tm = time.localtime(utc)
                nv = time.strftime(vm.get_str(), tm)
                vm.set_str(nv)
            elif m.startswith('U'):
                if not self.get(var):
                    vm.set_str(m[1:])
            elif m == 'u':
                nv = []
                lw = ''
                for w in vm.get_words():
                    if w != lw:
                        nv.append(w)
                        lw = w
                vm.set_words(nv)
        return vm.get_str()

    def subst(self, s, debug=0):
        """substitute any var references

        If ``.vs.alt`` is set use that (in addition to ``$``!)
        to identify variable references.
        Eg. if ``.vs.alt=%`` then ``%(USER)`` will be replaced with
        the value of ``USER``.
        """
        
        if debug:
            print('subst: {}'.format(s))
        alt = self.vars.get('.vs.alt')
        ra = []
        i = 0
        n = len(s)
        ec = None

        while i < n:
            if i == 0:
                rs = s
            else:
                rs = s[i:]
            x = rs.find('$')
            if x < 0 and alt != None:
                x = rs.find(alt)
            if x < 0:
                if i == 0:
                    return s
                ra.append(rs)
                break
            if x > 0:
                ra.append(rs[:x])
                i += x
            c = s[i]
            i += 1
            if c == '$' or (alt != None and c == alt):
                if s[i] == c:
                    ra.append(c)
                    i += 1
                    continue
                if s[i] not in '{(':
                    # simple single char
                    v = s[i]
                    i += 1
                    ra.append(self.get(v,''))
                    continue
                rs = s[i:]
                m = find_ec(rs, ':')
                if rs[m] == ':':
                    ei = find_ec(rs)
                else:
                    ei = m
                si = rs.find('$')
                if (si < 0 and alt != None):
                    si = rs.find(alt)
                if si > 0 and si < m:
                    # variable within the variable name we need to recurse
                    v = self.subst(rs[1:m])
                else:
                    v = rs[1:m]
                val = self.get(v,'')
                if isinstance(val,list):
                    val = ' '.join(val)
                if '$' in val or (alt != None and alt in val):
                    val = self.subst(val)
                if m > 0 and m < ei:
                    val = self.modify(v, val, rs[m+1:ei], debug)
                ra.append(val)
                i += ei + 1
            else:
                ra.append(c)
        r = ''.join(ra)
        if debug:
            print('subst-> {}'.format(r))
        if '$' in r:
            r = self.subst(r, debug)
        return r

                
if __name__ == '__main__':
    import getopt
    
    vars = Vars()

    opts,args = getopt.getopt(sys.argv[1:], 'a:dr:s:')
    debug = 0
    vars.set('list',[])
    vars.append('list', ['one','two'])
    vars.append('list', 'three')
    for o,a in opts:
        if o == '-d':
            debug += 1
        elif o == '-a':
            k,v = a.split('=', 1)
            vars.append(k,v)
        elif o == '-r':
            for line in open(a).readlines():
                if line.startswith('#'):
                    continue
                k,v = line.strip().split('=', 1)
                vars.set(k.strip(), v.strip())
        elif o == '-s':
            k,v = a.split('=', 1)
            vars.set(k,v)

    if debug:
        vars.dump()

    for a in args:
        print('{}->{}'.format(a, vars.subst(a, debug)))
        


