senfscons: Reimplemented Doxyfile parser
[senf.git] / senfscons / Doxygen.py
1 # The Doxygen builder is based on the Doxygen builder from:
2 #
3 # Astxx, the Asterisk C++ API and Utility Library.
4 # Copyright (C) 2005, 2006  Matthew A. Nicholson
5 # Copyright (C) 2006  Tim Blechmann
6 #
7 # This library is free software; you can redistribute it and/or
8 # modify it under the terms of the GNU Lesser General Public
9 # License version 2.1 as published by the Free Software Foundation.
10 #
11 # This library is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14 # Lesser General Public License for more details.
15 #
16 # You should have received a copy of the GNU Lesser General Public
17 # License along with this library; if not, write to the Free Software
18 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
19
20 # The Modifications are Copyright (C) 2006,2007
21 # Fraunhofer Institut fuer offene Kommunikationssysteme (FOKUS)
22 # Kompetenzzentrum fuer Satelitenkommunikation (SatCom)
23 #     Stefan Bund <g0dil@berlios.de>
24
25 ## \file
26 # \brief Doxygen builder
27
28 ## \package senfscons.Doxygen
29 # \brief Doxygen Documentation Builder
30 #
31 # This builder will invoke \c doxygen to build software
32 # documentation. The doxygen builder only takes the name of the
33 # doxyfile as it's source file. The builder parses that doxygen
34 # configuration file.
35 #
36 # The builder will automatically find all sources on which the
37 # documentation depends. This includes
38 # \li the source code files (as selected by the \c RECURSIVE, \c
39 #     FILE_PATTERNS, \c INPUT and \c EXCLUDE_PATTERNS doxygen
40 #     directives
41 # \li the \c HTML_HEADER and \c HTML_FOOTER
42 # \li all referenced \c TAGFILES
43 # \li the \c INPUT_FILTER
44 # \li all included doxyfiles (via \c @INCLUDE)
45 #
46 # The builder will emit a list of targets built by doxygen. This
47 # depends on the types of documentation built.
48 #
49 # The builder will also generate additional commands to resolve
50 # cross-references to other module documentations. This is based on
51 # the \c TAGFILES used. Tagfiles built in the same project in other
52 # modules are automatically found and the links will be resolved
53 # correctly. To resolve links from external tagfiles, you may specify
54 # <i>tagfilename</i><tt>_DOXY_URL</tt> as a construction environment
55 # variable to specify the path to resolve references from the given
56 # tagfile to. <i>tagfilename</i> is the uppercased basename of the
57 # tagfile used.
58 #
59 # \par Construction Envrionment Variables:
60 # <table class="senf">
61 # <tr><td>\c DOXYGEN</td><td>doxygen command, defaults to \c doxygen</td></tr>
62 # <tr><td><i>tag</i><tt>_DOXY_URL</tt></td><td>external tagfile resolve URL</td></tr>
63 # </table>
64 #
65 # \ingroup builder
66
67 # I (g0dil@berlios.de) have been fighting 4 problems in this
68 # implementation:
69 # - A Directory target will *not* call any source scanners
70 # - A Directory target will interpret the directory contents as
71 #   sources not targets. This means, that if a command creates that
72 #   directory plus contents, the target will never be up-to-date
73 #   (since the directory contents will change with every call of
74 #   scons)
75 # - Theres a bug in SCons which will produce an error message for
76 #   directory targets if dir.sources is not set explicitly
77 # - the first argument to env.Clean() must be the command line target,
78 #   with which the scons was invoked. This does not help to add
79 #   aditional files or directories to be cleaned if you don't know
80 #   that target (it's not possible to say 'if you clean this file,
81 #   also clean that one' hich is, what I had expected env.Clean to
82 #   do).
83 #
84 # Together, these problems have produced several difficulties. I have
85 # solved them by
86 # - Adding an (empty) stamp file as a (file) target. This target will
87 #   cause source scanners to be invoked
88 # - Adding the documentation directory as a target (so it will be
89 #   cleaned up which env.Clean doesn't help me to do), but *only* if
90 #   scons is called to with the -c option
91 # - Setting dir.sources to the known source-list to silence the error
92 #   message whenever a directory is added as a target
93 #
94 # You will find all this in the DoxyEmitter
95
96 import os, sys, traceback, string
97 import os.path
98 import glob, re
99 import SCons.Action
100 from fnmatch import fnmatch
101
102 class DoxyfileLexer:
103
104    def __init__(self,stream):
105       self._stream = stream
106       self._buffer = ""
107       self.lineno = 0
108       self._eof = False
109       self._fillbuffer()
110       
111    VARIABLE_RE = re.compile("[@A-Z_]+")
112    OPERATOR_RE = re.compile("\\+?=")
113    VALUE_RE = re.compile("\\S+")
114
115    def _readline(self):
116       if self._eof:
117          self._buffer = ""
118          return
119       self._buffer = self._stream.readline()
120       if not self._buffer:
121          self._eof = True
122          return
123       self._buffer = self._buffer.strip()
124       self.lineno += 1
125
126    def _skip(self, nchars=0):
127       self._buffer = self._buffer[nchars:].strip()
128       while self._buffer[:1] == '\\' and not self.eof():
129          self._readline()
130       if self._buffer[:1] == '#':
131          self._buffer=""
132       
133    def _fillbuffer(self):
134       while not self._buffer and not self.eof():
135          self._readline()
136          self._skip()
137
138    def _token(self, re, read=False):
139       if not self._buffer and read:
140          self._fillbuffer()
141       if not self._buffer:
142          return ""
143       m = re.match(self._buffer)
144       if m:
145          v = self._buffer[:m.end()]
146          self._skip(m.end())
147          return v
148       else:
149          raise ValueError,"Invalid input"
150
151    def var(self): return self._token(self.VARIABLE_RE, True)
152    def op(self): return self._token(self.OPERATOR_RE)
153
154    def next(self):
155       if not self._buffer:
156          raise StopIteration
157       if self._buffer[0] == '"':
158          return self._qstr()
159       m = self.VALUE_RE.match(self._buffer)
160       if m:
161          v = self._buffer[:m.end()]
162          self._skip(m.end())
163          return v
164       else:
165          raise ValueError
166
167    def __iter__(self):
168       return self
169
170    QSKIP_RE = re.compile("[^\\\"]+")
171    
172    def _qstr(self):
173       self._buffer = self._buffer[1:]
174       v = ""
175       while self._buffer:
176           m = self.QSKIP_RE.match(self._buffer)
177           if m:
178              v += self._buffer[:m.end()]
179              self._buffer = self._buffer[m.end():]
180           if self._buffer[:1] == '"':
181              self._skip(1)
182              return v
183           if self._buffer[:1] == '\\' and len(self._buffer)>1:
184              v += self._buffer[1]
185              self._buffer = self._buffer[2:]
186           else:
187              raise ValueError,"Unexpected charachter in string"
188       raise ValueError,"Unterminated string"
189
190    def eof(self):
191       return self._eof
192
193 class DoxyfileParser:
194
195    ENVVAR_RE = re.compile(r"\$\(([0-9A-Za-z_-]+)\)")
196
197    def __init__(self, path, env, include_path=None, items = None):
198       self._env = env
199       self._include_path = include_path or []
200       self._lexer = DoxyfileLexer(file(path))
201       self._dir = os.path.split(path)[0]
202       self._items = items or {}
203
204    def parse(self):
205       while True:
206          var = self._lexer.var()
207          if not var: break;
208          op = self._lexer.op()
209          value = [ self._envsub(v) for v in self._lexer ]
210          if not value:
211             raise ValueError,"Missing value in assignment"
212          if var[0] == '@':
213             self._meta(var,op,value)
214          elif op == '=':
215             self._items[var] = value
216          else:
217             self._items.setdefault(var,[]).extend(value)
218
219    def _envsub(self,value):
220       return self.ENVVAR_RE.sub(lambda m, env=self._env : str(env.get(m.group(1),"")), value)
221
222    def _meta(self, cmd, op, value):
223       m = '_'+cmd[1:]
224       try:
225          m = getattr(self,m)
226       except AttributeError:
227          raise ValueError,'Unknown meta command ' + cmd
228       m(op,value)
229
230    def _INCLUDE(self, op, value):
231       if len(value) != 1:
232          raise ValueError,"Invalid argument to @INCLUDE"
233       
234       for d in [ self._dir ] + self._include_path:
235          p = os.path.join(d,value[0])
236          if os.path.exists(p):
237             self._items.setdefault('@INCLDUE',[]).append(p)
238             parser = DoxyfileParser(p, self._env, self._include_path, self._items)
239             parser.parse()
240             return
241
242       raise ValueError,"@INCLUDE file not found"
243
244    def _INCLUDE_PATH(self, op, value):
245       self._include_path.extend(value)
246
247    def items(self):
248       return self._items
249
250 def DoxyfileParse(env,file):
251    ENV = {}
252    ENV.update(env.get("ENV",{}))
253    ENV['TOPDIR'] = env.Dir('#').abspath
254    parser = DoxyfileParser(file,ENV)
255    try:
256       parser.parse()
257    except ValueError, v:
258       print "WARNING: Error while parsing doxygen configuration '%s': %s" % (str(file),str(v))
259       return {}
260    data = parser.items()
261    for k,v in data.items():
262       if not v : del data[k]
263       elif k in ("INPUT", "FILE_PATTERNS", "EXCLUDE_PATTERNS", "@INCLUDE", "TAGFILES") : continue
264       elif len(v)==1 : data[k] = v[0]
265    return data
266
267 def DoxySourceScan(node, env, path):
268    """
269    Doxygen Doxyfile source scanner.  This should scan the Doxygen file and add
270    any files used to generate docs to the list of source files.
271    """
272    dep_add_keys = (
273       '@INCLUDE', 'HTML_HEADER', 'HTML_FOOTER', 'TAGFILES', 'INPUT_FILTER'
274    )
275
276    default_file_patterns = (
277       '*.c', '*.cc', '*.cxx', '*.cpp', '*.c++', '*.java', '*.ii', '*.ixx',
278       '*.ipp', '*.i++', '*.inl', '*.h', '*.hh ', '*.hxx', '*.hpp', '*.h++',
279       '*.idl', '*.odl', '*.cs', '*.php', '*.php3', '*.inc', '*.m', '*.mm',
280       '*.py',
281    )
282
283    default_exclude_patterns = (
284       '*~',
285    )
286
287    sources          = []
288    basedir          = node.dir.abspath
289    data             = DoxyfileParse(env, node.abspath)
290    recursive        = data.get("RECURSIVE", "NO").upper()=="YES"
291    file_patterns    = data.get("FILE_PATTERNS", default_file_patterns)
292    exclude_patterns = data.get("EXCLUDE_PATTERNS", default_exclude_patterns)
293
294    for i in data.get("INPUT", [ "." ]):
295       input = os.path.normpath(os.path.join(basedir,i))
296       if os.path.isfile(input):
297          sources.append(input)
298       elif os.path.isdir(input):
299          if recursive : entries = os.walk(input)
300          else         : entries = [ (input, [], os.listdir(input)) ]
301          for root, dirs, files in entries:
302             for f in files:
303                filename = os.path.normpath(os.path.join(root, f))
304                if ( reduce(lambda x, y: x or fnmatch(f, y),
305                            file_patterns, False)
306                     and not reduce(lambda x, y: x or fnmatch(f, y),
307                                    exclude_patterns, False) ):
308                   sources.append(filename)
309
310    for key in dep_add_keys:
311       if data.has_key(key):
312          elt = data[key]
313          if type(elt) is type ("") : elt = [ elt ]
314          sources.extend([ os.path.normpath(os.path.join(basedir,f))
315                           for f in elt ])
316
317    sources = map( lambda path: env.File(path), sources )
318    return sources
319
320 def DoxySourceScanCheck(node, env):
321    """Check if we should scan this file"""
322    return os.path.isfile(node.path)
323
324 def DoxyEmitter(source, target, env):
325    """Doxygen Doxyfile emitter"""
326    # possible output formats and their default values and output locations
327    output_formats = {
328       "HTML"  : ("YES", "html"),
329       "LATEX" : ("YES", "latex"),
330       "RTF"   : ("NO",  "rtf"),
331       "MAN"   : ("YES", "man"),
332       "XML"   : ("NO",  "xml"),
333    }
334
335    data = DoxyfileParse(env, source[0].abspath)
336
337    targets = []
338    if data.has_key("OUTPUT_DIRECTORY"):
339       out_dir = data["OUTPUT_DIRECTORY"]
340       dir = env.Dir( os.path.join(source[0].dir.abspath, out_dir) )
341       dir.sources = source
342       if env.GetOption('clean'): targets.append(dir)
343    else:
344       out_dir = '.'
345
346    # add our output locations
347    html_dir = None
348    for (k, v) in output_formats.iteritems():
349       if data.get("GENERATE_" + k, v[0]).upper() == "YES":
350          dir = env.Dir( os.path.join(source[0].dir.abspath, out_dir, data.get(k + "_OUTPUT", v[1])) )
351          if k == "HTML" : html_dir = dir
352          dir.sources = source
353          node = env.File( os.path.join(dir.abspath, k.lower()+".stamp" ) )
354          targets.append(node)
355          if env.GetOption('clean'): targets.append(dir)
356
357    if data.has_key("GENERATE_TAGFILE") and html_dir:
358       targets.append(env.File( os.path.join(source[0].dir.abspath, data["GENERATE_TAGFILE"]) ))
359
360    if data.get("SEARCHENGINE","NO").upper() == "YES":
361       targets.append(env.File( os.path.join(html_dir.abspath, "search.idx") ))
362
363    # don't clobber targets
364    for node in targets:
365       env.Precious(node)
366
367    return (targets, source)
368
369 def doxyNodeHtmlDir(env,node):
370    if not node.sources : return None
371    data = DoxyfileParse(env, node.sources[0].abspath)
372    if data.get("GENERATE_HTML",'YES').upper() != 'YES' : return None
373    return os.path.normpath(os.path.join( node.sources[0].dir.abspath,
374                                          data.get("OUTPUT_DIRECTORY","."),
375                                          data.get("HTML_OUTPUT","html") ))
376
377 def relpath(source, target):
378    source = os.path.normpath(source)
379    target = os.path.normpath(target)
380    prefix = os.path.dirname(os.path.commonprefix((source,target)))
381    prefix_len = prefix and len(prefix.split(os.sep)) or 0
382    source_elts = source.split(os.sep)
383    target_elts = target.split(os.sep)
384    if source_elts[0] == '..' or target_elts[0] == '..':
385       raise ValueError, "invalid relapth args"
386    return os.path.join(*([".."] * (len(source_elts) - prefix_len) +
387                          target_elts[prefix_len:]))
388
389 def DoxyGenerator(source, target, env, for_signature):
390
391    data = DoxyfileParse(env, source[0].abspath)
392
393    actions = [ SCons.Action.Action("cd ${SOURCE.dir}  && TOPDIR=%s ${DOXYGEN} ${SOURCE.file}"
394                                    % (relpath(source[0].dir.abspath, env.Dir('#').abspath),)) ]
395
396    # This will add automatic 'installdox' calls.
397    #
398    # For every referenced tagfile, the generator first checks for the
399    # existence of a construction variable '<name>_DOXY_URL' where
400    # '<name>' is the uppercased name of the tagfile sans extension
401    # (e.g. 'Utils.tag' -> 'UTILS_DOXY_URL'). If this variable exists,
402    # it must contain the url or path to the installed documentation
403    # corresponding to the tag file.
404    #
405    # Is the variable is not found and if a referenced tag file is a
406    # target within this same build, the generator will parse the
407    # 'Doxyfile' from which the tag file is built. It will
408    # automatically create the html directory from the information in
409    # that 'Doxyfile'.
410    #
411    # If for any referenced tagfile no url can be found, 'installdox'
412    # will *not* be called and a warning about the missing url is
413    # generated.
414
415    if data.get('GENERATE_HTML','YES').upper() == "YES":
416       output_dir = os.path.normpath(os.path.join( source[0].dir.abspath,
417                                                   data.get("OUTPUT_DIRECTORY","."),
418                                                   data.get("HTML_OUTPUT","html") ))
419       args = []
420       for tagfile in data.get('TAGFILES',[]):
421          url = env.get(os.path.splitext(os.path.basename(tagfile))[0].upper()+"_DOXY_URL", None)
422          if not url:
423             url = doxyNodeHtmlDir(
424                env,
425                env.File(os.path.normpath(os.path.join(str(source[0].dir), tagfile))))
426             if url : url = relpath(output_dir, url)
427          if not url:
428             print "WARNING:",source[0].abspath, ": missing tagfile url for", tagfile
429             args = None
430          if args is not None and url:
431             args.append("-l %s@%s" % ( os.path.basename(tagfile), url ))
432       if args:
433          actions.append(SCons.Action.Action('cd %s && ./installdox %s' % (output_dir, " ".join(args))))
434
435    actions.append(SCons.Action.Action([ "touch $TARGETS" ]))
436
437    return actions
438
439 def generate(env):
440    """
441    Add builders and construction variables for the
442    Doxygen tool. This is currently for Doxygen 1.4.6.
443    """
444    doxyfile_scanner = env.Scanner(
445       DoxySourceScan,
446       "DoxySourceScan",
447       scan_check = DoxySourceScanCheck,
448    )
449
450    doxyfile_builder = env.Builder(
451       # scons 0.96.93 hang on the next line but I don't know hot to FIX the problem
452       generator = DoxyGenerator,
453       emitter = DoxyEmitter,
454       target_factory = env.fs.Entry,
455       single_source = True,
456       source_scanner =  doxyfile_scanner
457    )
458
459    env.Append(BUILDERS = {
460       'Doxygen': doxyfile_builder,
461    })
462
463    env.AppendUnique(
464       DOXYGEN = 'doxygen',
465    )
466
467 def exists(env):
468    """
469    Make sure doxygen exists.
470    """
471    return env.Detect("doxygen")