69fa2c0402d50c449bea4598a738978b93513ffb
[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 Institute for Open Communication Systems (FOKUS) 
22 # Competence Center NETwork research (NET), St. Augustin, GERMANY 
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('@INCLUDE',[]).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    # We don't parse source files which do not contain the word 'doxyfile'. SCons will
252    # pass other dependencies to DoxyfileParse which are not doxyfiles ... grmpf ...
253    if not 'doxyfile' in file.lower():
254       return {}
255    ENV = {}
256    ENV.update(env.get("ENV",{}))
257    ENV['TOPDIR'] = env.Dir('#').abspath
258    parser = DoxyfileParser(file,ENV)
259    try:
260       parser.parse()
261    except ValueError, v:
262       print "WARNING: Error while parsing doxygen configuration '%s': %s" % (str(file),str(v))
263       return {}
264    data = parser.items()
265    for k,v in data.items():
266       if not v : del data[k]
267       elif k in ("INPUT", "FILE_PATTERNS", "EXCLUDE_PATTERNS", "@INCLUDE", "TAGFILES") : continue
268       elif len(v)==1 : data[k] = v[0]
269    return data
270
271 def DoxySourceScan(node, env, path):
272    """
273    Doxygen Doxyfile source scanner.  This should scan the Doxygen file and add
274    any files used to generate docs to the list of source files.
275    """
276    dep_add_keys = (
277       '@INCLUDE', 'HTML_HEADER', 'HTML_FOOTER', 'TAGFILES', 'INPUT_FILTER'
278    )
279
280    default_file_patterns = (
281       '*.c', '*.cc', '*.cxx', '*.cpp', '*.c++', '*.java', '*.ii', '*.ixx',
282       '*.ipp', '*.i++', '*.inl', '*.h', '*.hh ', '*.hxx', '*.hpp', '*.h++',
283       '*.idl', '*.odl', '*.cs', '*.php', '*.php3', '*.inc', '*.m', '*.mm',
284       '*.py',
285    )
286
287    default_exclude_patterns = (
288       '*~',
289    )
290
291    sources          = []
292    basedir          = node.dir.abspath
293    data             = DoxyfileParse(env, node.abspath)
294    recursive        = data.get("RECURSIVE", "NO").upper()=="YES"
295    file_patterns    = data.get("FILE_PATTERNS", default_file_patterns)
296    exclude_patterns = data.get("EXCLUDE_PATTERNS", default_exclude_patterns)
297
298    for i in data.get("INPUT", [ "." ]):
299       input = os.path.normpath(os.path.join(basedir,i))
300       if os.path.isfile(input):
301          sources.append(input)
302       elif os.path.isdir(input):
303          if recursive : entries = os.walk(input)
304          else         : entries = [ (input, [], os.listdir(input)) ]
305          for root, dirs, files in entries:
306             for f in files:
307                filename = os.path.normpath(os.path.join(root, f))
308                if (         reduce(lambda x, y: x or fnmatch(f, y), file_patterns,    False)
309                     and not reduce(lambda x, y: x or fnmatch(f, y), exclude_patterns, False) ):
310                   sources.append(filename)
311
312    for key in dep_add_keys:
313       if data.has_key(key):
314          elt = data[key]
315          if type(elt) is type ("") : elt = [ elt ]
316          sources.extend([ os.path.normpath(os.path.join(basedir,f))
317                           for f in elt ])
318
319    sources = map( lambda path: env.File(path), sources )
320    return sources
321
322 def DoxySourceScanCheck(node, env):
323    """Check if we should scan this file"""
324    return os.path.isfile(node.path)
325
326 def DoxyEmitter(source, target, env):
327    """Doxygen Doxyfile emitter"""
328    # possible output formats and their default values and output locations
329    output_formats = {
330       "HTML"  : ("YES", "html"),
331       "LATEX" : ("YES", "latex"),
332       "RTF"   : ("NO",  "rtf"),
333       "MAN"   : ("YES", "man"),
334       "XML"   : ("NO",  "xml"),
335    }
336
337    data = DoxyfileParse(env, source[0].abspath)
338
339    targets = []
340    if data.has_key("OUTPUT_DIRECTORY"):
341       out_dir = data["OUTPUT_DIRECTORY"]
342       dir = env.Dir( os.path.join(source[0].dir.abspath, out_dir) )
343       dir.sources = source
344       if env.GetOption('clean'):
345          targets.append(dir)
346          return (targets, source)
347    else:
348       out_dir = '.'
349
350    # add our output locations
351    html_dir = None
352    for (k, v) in output_formats.iteritems():
353       if data.get("GENERATE_" + k, v[0]).upper() == "YES":
354          dir = env.Dir( os.path.join(source[0].dir.abspath, out_dir, data.get(k + "_OUTPUT", v[1])) )
355          if k == "HTML" : html_dir = dir
356          dir.sources = source
357          node = env.File( os.path.join(dir.abspath, k.lower()+".stamp" ) )
358          targets.append(node)
359          if env.GetOption('clean'): targets.append(dir)
360
361    if data.has_key("GENERATE_TAGFILE") and html_dir:
362       targets.append(env.File( os.path.join(source[0].dir.abspath, data["GENERATE_TAGFILE"]) ))
363
364    if data.get("SEARCHENGINE","NO").upper() == "YES":
365       targets.append(env.File( os.path.join(html_dir.abspath, "search.idx") ))
366
367    # don't clobber targets
368    for node in targets:
369       env.Precious(node)
370
371    return (targets, source)
372
373 def doxyNodeHtmlDir(env,node):
374    if not node.sources : return None
375    data = DoxyfileParse(env, node.sources[0].abspath)
376    if data.get("GENERATE_HTML",'YES').upper() != 'YES' : return None
377    return os.path.normpath(os.path.join( node.sources[0].dir.abspath,
378                                          data.get("OUTPUT_DIRECTORY","."),
379                                          data.get("HTML_OUTPUT","html") ))
380
381 def relpath(source, target):
382    source = os.path.normpath(source)
383    target = os.path.normpath(target)
384    prefix = os.path.dirname(os.path.commonprefix((source,target)))
385    prefix_len = prefix and len(prefix.split(os.sep)) or 0
386    source_elts = source.split(os.sep)
387    target_elts = target.split(os.sep)
388    if source_elts[0] == '..' or target_elts[0] == '..':
389       raise ValueError, "invalid relapth args"
390    return os.path.join(*([".."] * (len(source_elts) - prefix_len) +
391                          target_elts[prefix_len:]))
392
393 def DoxyGenerator(source, target, env, for_signature):
394
395    data = DoxyfileParse(env, source[0].abspath)
396
397    actions = [ SCons.Action.Action("cd ${SOURCE.dir} && TOPDIR=%s ${DOXYGEN} ${SOURCE.file}"
398                                    % env.Dir('#').abspath) ]
399
400    # This will add automatic 'installdox' calls.
401    #
402    # For every referenced tagfile, the generator first checks for the
403    # existence of a construction variable '<name>_DOXY_URL' where
404    # '<name>' is the uppercased name of the tagfile sans extension
405    # (e.g. 'Utils.tag' -> 'UTILS_DOXY_URL'). If this variable exists,
406    # it must contain the url or path to the installed documentation
407    # corresponding to the tag file.
408    #
409    # Is the variable is not found and if a referenced tag file is a
410    # target within this same build, the generator will parse the
411    # 'Doxyfile' from which the tag file is built. It will
412    # automatically create the html directory from the information in
413    # that 'Doxyfile'.
414    #
415    # If for any referenced tagfile no url can be found, 'installdox'
416    # will *not* be called and a warning about the missing url is
417    # generated.
418
419    if data.get('GENERATE_HTML','YES').upper() == "YES":
420       output_dir = os.path.normpath(os.path.join( source[0].dir.abspath,
421                                                   data.get("OUTPUT_DIRECTORY","."),
422                                                   data.get("HTML_OUTPUT","html") ))
423       args = []
424       for tagfile in data.get('TAGFILES',[]):
425          url = env.get(os.path.splitext(os.path.basename(tagfile))[0].upper()+"_DOXY_URL", None)
426          if not url:
427             url = doxyNodeHtmlDir(
428                env,
429                env.File(os.path.normpath(os.path.join(str(source[0].dir), tagfile))))
430             if url : url = relpath(output_dir, url)
431          if not url:
432             print "WARNING:",source[0].abspath, ": missing tagfile url for", tagfile
433             args = None
434          if args is not None and url:
435             args.append("-l %s@%s" % ( os.path.basename(tagfile), url ))
436       if args:
437          actions.append(SCons.Action.Action('cd %s && ./installdox %s' % (output_dir, " ".join(args))))
438
439    actions.append(SCons.Action.Action([ "touch $TARGETS" ]))
440
441    return actions
442
443 def generate(env):
444    """
445    Add builders and construction variables for the
446    Doxygen tool. This is currently for Doxygen 1.4.6.
447    """
448    doxyfile_scanner = env.Scanner(
449       DoxySourceScan,
450       "DoxySourceScan",
451       scan_check = DoxySourceScanCheck,
452    )
453
454    doxyfile_builder = env.Builder(
455       # scons 0.96.93 hang on the next line but I don't know hot to FIX the problem
456       generator = DoxyGenerator,
457       emitter = DoxyEmitter,
458       target_factory = env.fs.Entry,
459       single_source = True,
460       source_scanner =  doxyfile_scanner
461    )
462
463    env.Append(BUILDERS = {
464       'Doxygen': doxyfile_builder,
465    })
466
467    env.SetDefault(
468       DOXYGEN = 'doxygen',
469    )
470
471 def exists(env):
472    """
473    Make sure doxygen exists.
474    """
475    return env.Detect("doxygen")