Rearrange the file tree.
[u/mdw/catacomb] / symm / multigen
diff --git a/symm/multigen b/symm/multigen
new file mode 100755 (executable)
index 0000000..2d626c2
--- /dev/null
@@ -0,0 +1,406 @@
+#! @PYTHON@
+###
+### Generate files by filling in simple templates
+###
+### (c) 2013 Straylight/Edgeware
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Catacomb.
+###
+### Catacomb is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Library General Public License as
+### published by the Free Software Foundation; either version 2 of the
+### License, or (at your option) any later version.
+###
+### Catacomb 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 Library General Public License for more details.
+###
+### You should have received a copy of the GNU Library General Public
+### License along with Catacomb; if not, write to the Free
+### Software Foundation, Inc., 59 Temple Place - Suite 330, Boston,
+### MA 02111-1307, USA.
+
+from __future__ import with_statement
+
+import itertools as IT
+import optparse as OP
+import os as OS
+import re as RX
+from cStringIO import StringIO
+from sys import argv, exit, stderr
+
+###--------------------------------------------------------------------------
+### Utilities.
+
+QUIS = OS.path.basename(argv[0])
+
+def die(msg):
+  stderr.write('%s: %s\n' % (QUIS, msg))
+  exit(1)
+
+def indexed(seq):
+  return IT.izip(IT.count(), seq)
+
+###--------------------------------------------------------------------------
+### Reading the input values.
+
+COLMAP = {}
+
+class Cursor (object):
+  def __init__(me, rel):
+    me._rel = rel
+    me._i = 0
+    me._row = rel[0]
+  def step(me):
+    me._i += 1
+    if me._i >= len(me._rel):
+      me._i = me._row = None
+      return False
+    me._row = me._rel[me._i]
+    return True
+  def reset(me):
+    me._i = 0
+    me._row = me._rel[0]
+  def __getitem__(me, i):
+    return me._row[i]
+  def __repr__(me):
+    return '#<Cursor %r[%d] = %r>' % (me._rel, me._i, me._row)
+
+class CursorSet (object):
+  def __init__(me):
+    me._map = {}
+    me._stack = []
+    me._act = None
+  def push(me, rels):
+    cc = []
+    rr = []
+    for r in rels:
+      if r in me._map: continue
+      c = me._map[r] = Cursor(r)
+      rr.append(r)
+      cc.append(c)
+    me._stack.append((me._act, rr))
+    me._act = cc
+  def step(me):
+    i = 0
+    while i < len(me._act):
+      if me._act[i].step(): return True
+      if i >= len(me._act): return False
+      me._act[i].reset()
+      i += 1
+    return False
+  def pop(me):
+    me._act, rels = me._stack.pop()
+    for r in rels: del me._map[r]
+  def get(me, rel, i):
+    return me._map[rel][i]
+
+class Relation (object):
+  def __init__(me, head):
+    me._head = head
+    me._rows = []
+    for i, c in indexed(head): COLMAP[c] = me, i
+  def addrow(me, row):
+    if len(row) != len(me._head):
+      die("mismatch: row `%s' doesn't match heading `%s'" %
+          (', '.join(row), ', '.join(head)))
+    me._rows.append(row)
+  def __len__(me):
+    return len(me._rows)
+  def __getitem__(me, i):
+    return me._rows[i]
+  def __repr__(me):
+    return '#<Relation %r>' % me._head
+
+def read_immediate(word):
+  head, rels = word.split('=', 1)
+  rel = Relation([c.strip() for c in head.split(',')])
+  for row in rels.split(): rel.addrow([c.strip() for c in row.split(',')])
+
+def read_file(spec):
+  file, head = spec.split(':', 1)
+  rel = Relation([c.strip() for c in head.split(',')])
+  cols = [c.strip() for c in head.split(',')]
+  with open(file) as f:
+    for line in f:
+      line = line.strip()
+      if line.startswith('#') or line == '': continue
+      rel.addrow(line.split())
+
+def read_thing(spec):
+  if spec.startswith('@'): read_file(spec[1:])
+  else: read_immediate(spec)
+
+###--------------------------------------------------------------------------
+### Template structure.
+
+class BasicTemplate (object):
+  pass
+
+class LiteralTemplate (BasicTemplate):
+  def __init__(me, text, **kw):
+    super(LiteralTemplate, me).__init__(**kw)
+    me._text = text
+  def relations(me):
+    return set()
+  def subst(me, out, cs):
+    out.write(me._text)
+  def __repr__(me):
+    return '#<LiteralTemplate %r>' % me._text
+
+class TagTemplate (BasicTemplate):
+  def __init__(me, rel, i, op, **kw):
+    super(TagTemplate, me).__init__(**kw)
+    me._rel = rel
+    me._i = i
+    me._op = op
+  def relations(me):
+    return set([me._rel])
+  def subst(me, out, cs):
+    val = cs.get(me._rel, me._i)
+    if me._op is not None: val = me._op(val)
+    out.write(val)
+  def __repr__(me):
+    return '#<TagTemplate %s>' % me._rel._head[me._i]
+
+class SequenceTemplate (BasicTemplate):
+  def __new__(cls, seq, **kw):
+    if len(seq) == 1:
+      return seq[0]
+    else:
+      me = super(SequenceTemplate, cls).__new__(cls, seq = seq, **kw)
+      tt = []
+      cls = type(me)
+      for t in seq:
+        if isinstance(t, cls): tt += t._seq
+        else: tt.append(t)
+      me._seq = tt
+      return me
+  def __init__(me, seq, **kw):
+    super(SequenceTemplate, me).__init__(**kw)
+  def relations(me):
+    rr = set()
+    for t in me._seq: rr.update(t.relations())
+    return rr
+  def subst(me, out, cs):
+    for t in me._seq: t.subst(out, cs)
+  def __repr__(me):
+    return '#<SequenceTemplate %r>' % me._seq
+
+class RepeatTemplate (BasicTemplate):
+  def __init__(me, sub):
+    me._sub = sub
+  def relations(me):
+    return set()
+  def subst(me, out, cs):
+    rr = me._sub.relations()
+    for r in rr:
+      if len(r) == 0: return
+    cs.push(rr)
+    while True:
+      me._sub.subst(out, cs)
+      if not cs.step(): break
+    cs.pop()
+  def __repr__(me):
+    return '#<RepeatTemplate %r>' % me._sub
+
+###--------------------------------------------------------------------------
+### Some slightly cheesy parsing machinery.
+
+class ParseState (object):
+  def __init__(me, file, text):
+    me._file = file
+    me._i = 0
+    me._it = iter(text.splitlines(True))
+    me.step()
+  def step(me):
+    me.curr = next(me._it, None)
+    if me.curr is not None: me._i += 1
+  def error(me, msg):
+    die('%s:%d: %s' % (me._file, me._i, msg))
+
+class token (object):
+  def __init__(me, name):
+    me._name = name
+  def __repr__(me):
+    return '#<%s>' % me._name
+
+EOF = token('eof')
+END = token('end')
+
+R_SIMPLETAG = RX.compile(r'@ (\w+)', RX.VERBOSE)
+R_COMPLEXTAG = RX.compile(r'@ { (\w+) ((?: : \w+)*) }', RX.VERBOSE)
+
+OPMAP = {}
+
+def defop(func):
+  name = func.func_name
+  if name.startswith('op_'): name = name[3:]
+  OPMAP[name] = func
+  return func
+
+@defop
+def op_u(val): return val.upper()
+
+@defop
+def op_l(val): return val.lower()
+
+R_NOTIDENT = RX.compile(r'[^a-zA-Z0-9_]+')
+@defop
+def op_c(val): return R_NOTIDENT.sub('_', val)
+
+def _pairify(val):
+  c = val.find('=')
+  if c >= 0: return val[:c], val[c + 1:]
+  else: return val, val
+
+@defop
+def op_left(val): return _pairify(val)[0]
+@defop
+def op_right(val): return _pairify(val)[1]
+
+def parse_text(ps):
+  tt = []
+  lit = StringIO()
+  def spill():
+    l = lit.getvalue()
+    if l: tt.append(LiteralTemplate(l))
+    lit.reset()
+    lit.truncate()
+  while True:
+    line = ps.curr
+    if line is None: break
+    elif line.startswith('%'):
+      if line.startswith('%#'): ps.step(); continue
+      elif line.startswith('%%'): line = line[1:]
+      else: break
+    i = 0
+    while True:
+      j = line.find('@', i)
+      if j < 0: break
+      lit.write(line[i:j])
+      m = R_SIMPLETAG.match(line, j)
+      if not m: m = R_COMPLEXTAG.match(line, j)
+      if not m: ps.error('invalid tag')
+      col = m.group(1)
+      try: rel, i = COLMAP[col]
+      except KeyError: ps.error("unknown column `%s'" % col)
+      wholeop = None
+      ops = m.lastindex >= 2 and m.group(2)
+      if ops:
+        for opname in ops[1:].split(':'):
+          try: op = OPMAP[opname]
+          except KeyError: ps.error("unknown operation `%s'" % opname)
+          if wholeop is None: wholeop = op
+          else: wholeop = (lambda f, g: lambda x: f(g(x)))(op, wholeop)
+      spill()
+      tt.append(TagTemplate(rel, i, wholeop))
+      i = m.end()
+    lit.write(line[i:])
+    ps.step()
+  spill()
+  return SequenceTemplate(tt)
+
+DIRECT = []
+
+def direct(rx):
+  def _(func):
+    DIRECT.append((RX.compile(rx, RX.VERBOSE), func))
+    return func
+  return _
+
+def parse_template(ps):
+  while ps.curr is not None and ps.curr.startswith('%#'): ps.step()
+  if ps.curr is None: return EOF
+  elif ps.curr.startswith('%'):
+    if ps.curr.startswith('%%'): return parse_text(ps)
+    for rx, func in DIRECT:
+      line = ps.curr[1:].strip()
+      m = rx.match(line)
+      if m:
+        ps.step()
+        return func(ps, m)
+    ps.error("unrecognized directive")
+  else:
+    return parse_text(ps)
+
+def parse_templseq(ps, nestp):
+  tt = []
+  while True:
+    t = parse_template(ps)
+    if t is END:
+      if nestp: break
+      else: ps.error("unexpected `end' directive")
+    elif t is EOF:
+      if nestp: ps.error("unexpected end of file")
+      else: break
+    tt.append(t)
+  return SequenceTemplate(tt)
+
+@direct(r'repeat')
+def dir_repeat(ps, m):
+  return RepeatTemplate(parse_templseq(ps, True))
+
+@direct(r'end')
+def dir_end(ps, m):
+  return END
+
+def compile_template(file, text):
+  ps = ParseState(file, text)
+  t = parse_templseq(ps, False)
+  return t
+
+###--------------------------------------------------------------------------
+### Main code.
+
+op = OP.OptionParser(
+  description = 'Generates files by filling in simple templates',
+  usage = 'usage: %prog [-gl] FILE [COL,...=VAL,... ... | @FILE:COL,...] ...',
+  version = 'Catacomb version @VERSION@')
+for short, long, kw in [
+  ('-l', '--list', dict(
+      action = 'store_const', const = 'list', dest = 'mode',
+      help = 'list filenames generated')),
+  ('-g', '--generate', dict(
+      action = 'store', metavar = 'PATH', dest = 'input',
+      help = 'generate output (default)'))]:
+  op.add_option(short, long, **kw)
+op.set_defaults(mode = 'gen')
+opts, args = op.parse_args()
+
+if len(args) < 1: op.error('missing FILE')
+filepat = args[0]
+for rel in args[1:]: read_thing(rel)
+filetempl = compile_template('<output>', filepat)
+
+def filenames(filetempl):
+  cs = CursorSet()
+  rr = filetempl.relations()
+  for r in rr:
+    if not len(r): return
+  cs.push(rr)
+  while True:
+    out = StringIO()
+    filetempl.subst(out, cs)
+    yield out.getvalue(), cs
+    if not cs.step(): break
+  cs.pop()
+
+if opts.mode == 'list':
+  for file, cs in filenames(filetempl): print file
+elif opts.mode == 'gen':
+  with open(opts.input) as f:
+    templ = RepeatTemplate(compile_template(opts.input, f.read()))
+  for file, cs in filenames(filetempl):
+    new = file + '.new'
+    with open(new, 'w') as out:
+      templ.subst(out, cs)
+    OS.rename(new, file)
+else:
+  raise Exception, 'What am I doing here?'
+
+###----- That's all, folks --------------------------------------------------