chiark / gitweb /
Rearrange the file tree.
[catacomb] / symm / multigen
1 #! @PYTHON@
2 ###
3 ### Generate files by filling in simple templates
4 ###
5 ### (c) 2013 Straylight/Edgeware
6 ###
7
8 ###----- Licensing notice ---------------------------------------------------
9 ###
10 ### This file is part of Catacomb.
11 ###
12 ### Catacomb is free software; you can redistribute it and/or modify
13 ### it under the terms of the GNU Library General Public License as
14 ### published by the Free Software Foundation; either version 2 of the
15 ### License, or (at your option) any later version.
16 ###
17 ### Catacomb is distributed in the hope that it will be useful,
18 ### but WITHOUT ANY WARRANTY; without even the implied warranty of
19 ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
20 ### GNU Library General Public License for more details.
21 ###
22 ### You should have received a copy of the GNU Library General Public
23 ### License along with Catacomb; if not, write to the Free
24 ### Software Foundation, Inc., 59 Temple Place - Suite 330, Boston,
25 ### MA 02111-1307, USA.
26
27 from __future__ import with_statement
28
29 import itertools as IT
30 import optparse as OP
31 import os as OS
32 import re as RX
33 from cStringIO import StringIO
34 from sys import argv, exit, stderr
35
36 ###--------------------------------------------------------------------------
37 ### Utilities.
38
39 QUIS = OS.path.basename(argv[0])
40
41 def die(msg):
42   stderr.write('%s: %s\n' % (QUIS, msg))
43   exit(1)
44
45 def indexed(seq):
46   return IT.izip(IT.count(), seq)
47
48 ###--------------------------------------------------------------------------
49 ### Reading the input values.
50
51 COLMAP = {}
52
53 class Cursor (object):
54   def __init__(me, rel):
55     me._rel = rel
56     me._i = 0
57     me._row = rel[0]
58   def step(me):
59     me._i += 1
60     if me._i >= len(me._rel):
61       me._i = me._row = None
62       return False
63     me._row = me._rel[me._i]
64     return True
65   def reset(me):
66     me._i = 0
67     me._row = me._rel[0]
68   def __getitem__(me, i):
69     return me._row[i]
70   def __repr__(me):
71     return '#<Cursor %r[%d] = %r>' % (me._rel, me._i, me._row)
72
73 class CursorSet (object):
74   def __init__(me):
75     me._map = {}
76     me._stack = []
77     me._act = None
78   def push(me, rels):
79     cc = []
80     rr = []
81     for r in rels:
82       if r in me._map: continue
83       c = me._map[r] = Cursor(r)
84       rr.append(r)
85       cc.append(c)
86     me._stack.append((me._act, rr))
87     me._act = cc
88   def step(me):
89     i = 0
90     while i < len(me._act):
91       if me._act[i].step(): return True
92       if i >= len(me._act): return False
93       me._act[i].reset()
94       i += 1
95     return False
96   def pop(me):
97     me._act, rels = me._stack.pop()
98     for r in rels: del me._map[r]
99   def get(me, rel, i):
100     return me._map[rel][i]
101
102 class Relation (object):
103   def __init__(me, head):
104     me._head = head
105     me._rows = []
106     for i, c in indexed(head): COLMAP[c] = me, i
107   def addrow(me, row):
108     if len(row) != len(me._head):
109       die("mismatch: row `%s' doesn't match heading `%s'" %
110           (', '.join(row), ', '.join(head)))
111     me._rows.append(row)
112   def __len__(me):
113     return len(me._rows)
114   def __getitem__(me, i):
115     return me._rows[i]
116   def __repr__(me):
117     return '#<Relation %r>' % me._head
118
119 def read_immediate(word):
120   head, rels = word.split('=', 1)
121   rel = Relation([c.strip() for c in head.split(',')])
122   for row in rels.split(): rel.addrow([c.strip() for c in row.split(',')])
123
124 def read_file(spec):
125   file, head = spec.split(':', 1)
126   rel = Relation([c.strip() for c in head.split(',')])
127   cols = [c.strip() for c in head.split(',')]
128   with open(file) as f:
129     for line in f:
130       line = line.strip()
131       if line.startswith('#') or line == '': continue
132       rel.addrow(line.split())
133
134 def read_thing(spec):
135   if spec.startswith('@'): read_file(spec[1:])
136   else: read_immediate(spec)
137
138 ###--------------------------------------------------------------------------
139 ### Template structure.
140
141 class BasicTemplate (object):
142   pass
143
144 class LiteralTemplate (BasicTemplate):
145   def __init__(me, text, **kw):
146     super(LiteralTemplate, me).__init__(**kw)
147     me._text = text
148   def relations(me):
149     return set()
150   def subst(me, out, cs):
151     out.write(me._text)
152   def __repr__(me):
153     return '#<LiteralTemplate %r>' % me._text
154
155 class TagTemplate (BasicTemplate):
156   def __init__(me, rel, i, op, **kw):
157     super(TagTemplate, me).__init__(**kw)
158     me._rel = rel
159     me._i = i
160     me._op = op
161   def relations(me):
162     return set([me._rel])
163   def subst(me, out, cs):
164     val = cs.get(me._rel, me._i)
165     if me._op is not None: val = me._op(val)
166     out.write(val)
167   def __repr__(me):
168     return '#<TagTemplate %s>' % me._rel._head[me._i]
169
170 class SequenceTemplate (BasicTemplate):
171   def __new__(cls, seq, **kw):
172     if len(seq) == 1:
173       return seq[0]
174     else:
175       me = super(SequenceTemplate, cls).__new__(cls, seq = seq, **kw)
176       tt = []
177       cls = type(me)
178       for t in seq:
179         if isinstance(t, cls): tt += t._seq
180         else: tt.append(t)
181       me._seq = tt
182       return me
183   def __init__(me, seq, **kw):
184     super(SequenceTemplate, me).__init__(**kw)
185   def relations(me):
186     rr = set()
187     for t in me._seq: rr.update(t.relations())
188     return rr
189   def subst(me, out, cs):
190     for t in me._seq: t.subst(out, cs)
191   def __repr__(me):
192     return '#<SequenceTemplate %r>' % me._seq
193
194 class RepeatTemplate (BasicTemplate):
195   def __init__(me, sub):
196     me._sub = sub
197   def relations(me):
198     return set()
199   def subst(me, out, cs):
200     rr = me._sub.relations()
201     for r in rr:
202       if len(r) == 0: return
203     cs.push(rr)
204     while True:
205       me._sub.subst(out, cs)
206       if not cs.step(): break
207     cs.pop()
208   def __repr__(me):
209     return '#<RepeatTemplate %r>' % me._sub
210
211 ###--------------------------------------------------------------------------
212 ### Some slightly cheesy parsing machinery.
213
214 class ParseState (object):
215   def __init__(me, file, text):
216     me._file = file
217     me._i = 0
218     me._it = iter(text.splitlines(True))
219     me.step()
220   def step(me):
221     me.curr = next(me._it, None)
222     if me.curr is not None: me._i += 1
223   def error(me, msg):
224     die('%s:%d: %s' % (me._file, me._i, msg))
225
226 class token (object):
227   def __init__(me, name):
228     me._name = name
229   def __repr__(me):
230     return '#<%s>' % me._name
231
232 EOF = token('eof')
233 END = token('end')
234
235 R_SIMPLETAG = RX.compile(r'@ (\w+)', RX.VERBOSE)
236 R_COMPLEXTAG = RX.compile(r'@ { (\w+) ((?: : \w+)*) }', RX.VERBOSE)
237
238 OPMAP = {}
239
240 def defop(func):
241   name = func.func_name
242   if name.startswith('op_'): name = name[3:]
243   OPMAP[name] = func
244   return func
245
246 @defop
247 def op_u(val): return val.upper()
248
249 @defop
250 def op_l(val): return val.lower()
251
252 R_NOTIDENT = RX.compile(r'[^a-zA-Z0-9_]+')
253 @defop
254 def op_c(val): return R_NOTIDENT.sub('_', val)
255
256 def _pairify(val):
257   c = val.find('=')
258   if c >= 0: return val[:c], val[c + 1:]
259   else: return val, val
260
261 @defop
262 def op_left(val): return _pairify(val)[0]
263 @defop
264 def op_right(val): return _pairify(val)[1]
265
266 def parse_text(ps):
267   tt = []
268   lit = StringIO()
269   def spill():
270     l = lit.getvalue()
271     if l: tt.append(LiteralTemplate(l))
272     lit.reset()
273     lit.truncate()
274   while True:
275     line = ps.curr
276     if line is None: break
277     elif line.startswith('%'):
278       if line.startswith('%#'): ps.step(); continue
279       elif line.startswith('%%'): line = line[1:]
280       else: break
281     i = 0
282     while True:
283       j = line.find('@', i)
284       if j < 0: break
285       lit.write(line[i:j])
286       m = R_SIMPLETAG.match(line, j)
287       if not m: m = R_COMPLEXTAG.match(line, j)
288       if not m: ps.error('invalid tag')
289       col = m.group(1)
290       try: rel, i = COLMAP[col]
291       except KeyError: ps.error("unknown column `%s'" % col)
292       wholeop = None
293       ops = m.lastindex >= 2 and m.group(2)
294       if ops:
295         for opname in ops[1:].split(':'):
296           try: op = OPMAP[opname]
297           except KeyError: ps.error("unknown operation `%s'" % opname)
298           if wholeop is None: wholeop = op
299           else: wholeop = (lambda f, g: lambda x: f(g(x)))(op, wholeop)
300       spill()
301       tt.append(TagTemplate(rel, i, wholeop))
302       i = m.end()
303     lit.write(line[i:])
304     ps.step()
305   spill()
306   return SequenceTemplate(tt)
307
308 DIRECT = []
309
310 def direct(rx):
311   def _(func):
312     DIRECT.append((RX.compile(rx, RX.VERBOSE), func))
313     return func
314   return _
315
316 def parse_template(ps):
317   while ps.curr is not None and ps.curr.startswith('%#'): ps.step()
318   if ps.curr is None: return EOF
319   elif ps.curr.startswith('%'):
320     if ps.curr.startswith('%%'): return parse_text(ps)
321     for rx, func in DIRECT:
322       line = ps.curr[1:].strip()
323       m = rx.match(line)
324       if m:
325         ps.step()
326         return func(ps, m)
327     ps.error("unrecognized directive")
328   else:
329     return parse_text(ps)
330
331 def parse_templseq(ps, nestp):
332   tt = []
333   while True:
334     t = parse_template(ps)
335     if t is END:
336       if nestp: break
337       else: ps.error("unexpected `end' directive")
338     elif t is EOF:
339       if nestp: ps.error("unexpected end of file")
340       else: break
341     tt.append(t)
342   return SequenceTemplate(tt)
343
344 @direct(r'repeat')
345 def dir_repeat(ps, m):
346   return RepeatTemplate(parse_templseq(ps, True))
347
348 @direct(r'end')
349 def dir_end(ps, m):
350   return END
351
352 def compile_template(file, text):
353   ps = ParseState(file, text)
354   t = parse_templseq(ps, False)
355   return t
356
357 ###--------------------------------------------------------------------------
358 ### Main code.
359
360 op = OP.OptionParser(
361   description = 'Generates files by filling in simple templates',
362   usage = 'usage: %prog [-gl] FILE [COL,...=VAL,... ... | @FILE:COL,...] ...',
363   version = 'Catacomb version @VERSION@')
364 for short, long, kw in [
365   ('-l', '--list', dict(
366       action = 'store_const', const = 'list', dest = 'mode',
367       help = 'list filenames generated')),
368   ('-g', '--generate', dict(
369       action = 'store', metavar = 'PATH', dest = 'input',
370       help = 'generate output (default)'))]:
371   op.add_option(short, long, **kw)
372 op.set_defaults(mode = 'gen')
373 opts, args = op.parse_args()
374
375 if len(args) < 1: op.error('missing FILE')
376 filepat = args[0]
377 for rel in args[1:]: read_thing(rel)
378 filetempl = compile_template('<output>', filepat)
379
380 def filenames(filetempl):
381   cs = CursorSet()
382   rr = filetempl.relations()
383   for r in rr:
384     if not len(r): return
385   cs.push(rr)
386   while True:
387     out = StringIO()
388     filetempl.subst(out, cs)
389     yield out.getvalue(), cs
390     if not cs.step(): break
391   cs.pop()
392
393 if opts.mode == 'list':
394   for file, cs in filenames(filetempl): print file
395 elif opts.mode == 'gen':
396   with open(opts.input) as f:
397     templ = RepeatTemplate(compile_template(opts.input, f.read()))
398   for file, cs in filenames(filetempl):
399     new = file + '.new'
400     with open(new, 'w') as out:
401       templ.subst(out, cs)
402     OS.rename(new, file)
403 else:
404   raise Exception, 'What am I doing here?'
405
406 ###----- That's all, folks --------------------------------------------------