Merge "Merge branch release-4-6"
[alexxy/gromacs.git] / admin / includedeps.py
1 #!/usr/bin/python
2 #
3 # This file is part of the GROMACS molecular simulation package.
4 #
5 # Copyright (c) 2012, by the GROMACS development team, led by
6 # David van der Spoel, Berk Hess, Erik Lindahl, and including many
7 # others, as listed in the AUTHORS file in the top-level source
8 # directory and at http://www.gromacs.org.
9 #
10 # GROMACS is free software; you can redistribute it and/or
11 # modify it under the terms of the GNU Lesser General Public License
12 # as published by the Free Software Foundation; either version 2.1
13 # of the License, or (at your option) any later version.
14 #
15 # GROMACS is distributed in the hope that it will be useful,
16 # but WITHOUT ANY WARRANTY; without even the implied warranty of
17 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
18 # Lesser General Public License for more details.
19 #
20 # You should have received a copy of the GNU Lesser General Public
21 # License along with GROMACS; if not, see
22 # http://www.gnu.org/licenses, or write to the Free Software Foundation,
23 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA.
24 #
25 # If you want to redistribute modifications to GROMACS, please
26 # consider that scientific software is very special. Version
27 # control is crucial - bugs must be traceable. We will be happy to
28 # consider code for inclusion in the official distribution, but
29 # derived work must not be called official GROMACS. Details are found
30 # in the README & COPYING files - if they are missing, get the
31 # official version at http://www.gromacs.org.
32 #
33 # To help us fund GROMACS development, we humbly ask that you cite
34 # the research papers on the package. Check out http://www.gromacs.org.
35
36 """Generate include dependency graphs for Gromacs.
37
38 This script can generate two types of include dependency graphs: per-file or
39 per-module (where module is equivalent to a subdirectory).
40 It is intended to be run on a subset of files under the src/ directory.
41 Output format is suitable for processing with 'dot'.
42
43 FILE GRAPHS
44
45 The legend for per-file graph nodex:
46     gray:          source files
47     light blue:    public headers
48     dark blue:     library headers
49     no background: other files
50
51 MODULE GRAPHS
52
53 Module graph will contain one node for each top-level subdirectory under src/,
54 except that the src/gromacs/ directory will be expanded one level further.
55
56 The legend for per-module graph links (a link with a certain color indicates
57 that types above it in the list are not present):
58     red:          invalid dependency (e.g., undocumented file)
59     dark blue:    library header depends on the other module
60     light blue:   public header depends on the other module
61     dashed black: source file depends on a library header in the other module
62     solid black:  source file depends on a public header in the other module
63     dotted grey:  test files depend on the other module
64 """
65
66 import os.path
67 import re
68 import sys
69
70 from optparse import OptionParser
71
72 class ErrorReporter(object):
73     def input_warning(self, file_path, msg):
74         sys.stderr.write('warning: {0}: {1}\n'.format(file_path, msg))
75
76     def error(self, file_path, msg):
77         sys.stderr.write('error: {0}: {1}\n'.format(file_path, msg))
78
79 class Link(object):
80
81     """Link between two node objects.
82     
83     Signifies an include dependency between the two nodes, and manages types
84     associated with the dependencies.
85     """
86
87     priorities = {
88             'unknown': 0,
89             'undocumented': 1,
90             'intramodule': 2,
91             'library': 3,
92             'public': 4,
93             'libimpl': 5,
94             'pubimpl': 6,
95             'test': 7}
96
97     def __init__(self, fromnode, tonode, link_type=None):
98         self.fromnode = fromnode
99         self.tonode = tonode
100         self.link_type = link_type
101
102     def refresh_type(self, reporter):
103         """Initialize type of a link between two file nodes.
104
105         Both endpoints of the link must be file objects when this method is
106         called.
107         """
108         fromfile = self.fromnode.obj
109         tofile = self.tonode.obj
110         intramodule = \
111                 (fromfile.module.get_top_level_module() == \
112                  tofile.module.get_top_level_module())
113         if tofile.type != 'publicheader' and tofile.type != 'libheader':
114             if intramodule:
115                 link_type = 'intramodule'
116             else:
117                 reporter.error(fromfile.path,
118                         'included file "{0}" is missing API definition'
119                             .format(tofile.path))
120                 link_type = 'undocumented'
121         elif fromfile.type == 'test':
122             link_type = 'test'
123         elif fromfile.type in ('source', 'header', 'implheader'):
124             if tofile.type == 'publicheader':
125                 link_type = 'pubimpl'
126             elif tofile.type == 'libheader':
127                 link_type = 'libimpl'
128             else:
129                 reporter.error(fromfile.path,
130                         'unknown link type to "{0}"'.format(tofile.path))
131                 link_type = 'unknown'
132         elif fromfile.type == 'libheader':
133             link_type = 'library'
134         elif fromfile.type == 'publicheader':
135             if tofile.type == 'publicheader' or tofile.doctype == 'public':
136                 link_type = 'public'
137             else:
138                 reporter.error(fromfile.path,
139                         'public API file includes non-public header "{0}"'
140                             .format(tofile.path))
141                 link_type = 'undocumented'
142         else:
143             reporter.error(fromfile.path,
144                     'unknown link type to "{0}"'.format(tofile.path))
145             link_type = 'unknown'
146         self.link_type = link_type
147
148     def merge_link(self, other):
149         """Merge another link into this one and choose an appropriate type.
150
151         Updates the type of this link based on the types of the merged links.
152         """
153         if Link.priorities[other.link_type] < Link.priorities[self.link_type]:
154             self.link_type = other.link_type
155
156     def format(self):
157         """Format this link for 'dot'."""
158         if isinstance(self.fromnode.obj, File) and \
159                 isinstance(self.tonode.obj, File):
160             properties = ''
161         elif self.link_type == 'intramodule':
162             properties = ''
163         elif self.link_type == 'test':
164             properties = 'color=grey75, style=dotted'
165         elif self.link_type == 'libimpl':
166             properties = 'color=".66 .5 1"'
167         elif self.link_type == 'pubimpl':
168             properties = 'color=".66 .2 1"'
169         elif self.link_type == 'library':
170             properties = 'color=black, style=dashed'
171         elif self.link_type == 'public':
172             properties = 'color=black'
173         else: #unknown or undocumented
174             properties = 'color=red'
175         return '{0} -> {1} [{2}]'.format(self.fromnode.obj.nodename,
176                                          self.tonode.obj.nodename,
177                                          properties)
178
179 class Node(object):
180     def __init__(self, obj):
181         self.obj = obj
182         self.children = []
183         self.root = False
184
185     def set_root(self):
186         self.root = True
187
188     def add_child(self, child):
189         self.children.append(child)
190
191     def clear_children(self):
192         self.children = []
193
194     def get_children(self, recursive=False):
195         if recursive:
196             result = list(self.children)
197             for child in self.children:
198                 result.extend(child.get_children(recursive=True))
199             return result
200         else:
201             return self.children
202
203     def format(self):
204         """Format this node for 'dot'."""
205         result = ''
206         if self.children:
207             if not self.root:
208                 result += '    subgraph cluster_{0} {{\n' \
209                               .format(self.obj.nodename)
210                 result += '        label = "{0}"\n'.format(self.obj.name)
211             for child in self.children:
212                 result += child.format()
213             if not self.root:
214                 result += '    }\n'
215         else:
216             result += '    {0} [{1}]\n'.format(
217                     self.obj.nodename, self.obj.node_properties())
218         return result
219
220
221 class Graph(object):
222     def __init__(self, nodes, links):
223         self.nodes = set(nodes)
224         self.links = links
225         self.left_to_right = False
226         self.concentrate = True
227
228     def set_options(self, left_to_right=None, concentrate=None):
229         if left_to_right != None:
230             self.left_to_right = left_to_right
231         if concentrate != None:
232             self.concentrate = concentrate
233
234     def prune_links(self):
235         nodes = set()
236         for node in self.nodes:
237             nodes.update(node.get_children(recursive=True))
238         newlinks = []
239         for link in self.links:
240             if link.fromnode in nodes and link.tonode in nodes:
241                 newlinks.append(link)
242         self.links = newlinks
243
244     def merge_nodes(self, nodes, target):
245         nodes = set(nodes)
246         nodes.add(target)
247         newlinks = []
248         linksto = dict()
249         linksfrom = dict()
250         for link in self.links:
251             isfrom = (link.fromnode in nodes)
252             isto = (link.tonode in nodes)
253             if isfrom and isto:
254                 pass
255             elif isfrom:
256                 if not link.tonode in linksfrom:
257                     linksfrom[link.tonode] = \
258                             Link(target, link.tonode, link.link_type)
259                 else:
260                     linksfrom[link.tonode].merge_link(link)
261             elif isto:
262                 if not link.fromnode in linksto:
263                     linksto[link.fromnode] = \
264                             Link(link.fromnode, target, link.link_type)
265                 else:
266                     linksto[link.fromnode].merge_link(link)
267             else:
268                 newlinks.append(link)
269         newlinks.extend(linksfrom.values())
270         newlinks.extend(linksto.values())
271         self.links = newlinks
272
273     def collapse_node(self, node):
274         nodes = node.get_children(recursive=True)
275         self.merge_nodes(nodes, node)
276         node.clear_children()
277
278     def write(self, outfile):
279         outfile.write('digraph includedeps {\n')
280         if self.left_to_right:
281             outfile.write('    rankdir = LR\n')
282         if self.concentrate:
283             outfile.write('    concentrate = true\n')
284         outfile.write('    node [fontname="FreeSans",fontsize=10,height=.2,'
285                                  'shape=box]\n')
286         for link in self.links:
287             outfile.write('    ' + link.format() + '\n')
288         for node in self.nodes:
289             outfile.write(node.format())
290         outfile.write('}\n')
291
292
293 def find_include_file(filename, includedirs):
294     """Find full path to filename, looking in a set of directories."""
295     for includedir in includedirs:
296         fullpath = os.path.abspath(os.path.join(includedir, filename))
297         if os.path.exists(fullpath):
298             return fullpath
299     return None
300
301
302 class File(object):
303     def __init__(self, path, module):
304         self.path = path
305         self.name = os.path.basename(path)
306         self.nodename = re.subn(r'[-./]', '_', path)[0]
307         self.module = module
308         if module.name == 'tests':
309             self.type = 'test'
310         elif re.search(r'\.c(pp)?$', self.name) != None:
311             self.type = 'source'
312         else:
313             self.type = 'header'
314         self.doctype = 'none'
315         #headername = re.sub(r'\.cpp$', '.h', self.name)
316         #implheadername = re.sub(r'\.cpp$', '-impl.h', self.name)
317         self.links = []
318         self.node = Node(self)
319         self.installed = False
320
321     def set_installed(self, reporter):
322         if self.type != 'header':
323             reporter.input_warning(self.path,
324                     'installing {0} file'.format(self.type))
325             return
326         self.installed = True
327
328     def add_dependency(self, dep):
329         self.links.append(Link(self.node, dep.node))
330
331     def get_node(self):
332         return self.node
333
334     def get_links(self):
335         return self.links
336
337     def node_properties(self):
338         properties = []
339         style = []
340         properties.append('label="{0}"'.format(self.name))
341         properties.append('URL="\\ref {0}"'.format(self.name))
342         if not self.module:
343             style.append('bold')
344             properties.append('color=red')
345         if self.type == 'source':
346             style.append('filled')
347             properties.append('fillcolor=grey75')
348         elif self.type == 'publicheader':
349             style.append('filled')
350             properties.append('fillcolor=".66 .2 1"')
351         elif self.type == 'libheader':
352             style.append('filled')
353             properties.append('fillcolor=".66 .5 1"')
354         if style:
355             properties.append('style="{0}"'.format(','.join(style)))
356         return ', '.join(properties)
357
358     def scan_include_file(self, line, allfiles, selfdir, includedirs,
359             ignorelist, reporter):
360         """Process #include directive during scan().
361
362         Searches for the included file in given directories, does some checks,
363         and adds the dependency link to the other file if applicable.
364         """
365         fullpath = None
366         match = re.match(r'#include <([^>]*)>', line)
367         if match:
368             includedfile = match.group(1)
369             fullpath = find_include_file(includedfile, includedirs)
370             if fullpath:
371                 reporter.error(self.path,
372                         'local file included as <{0}>'
373                             .format(includedfile))
374         else:
375             match = re.match(r'#include "([^"]*)"', line)
376             if match:
377                 includedfile = match.group(1)
378                 fullpath = os.path.join(selfdir, includedfile)
379                 #if os.path.abspath(fullpath) in ignorelist:
380                 #    return
381                 if not os.path.exists(fullpath):
382                     fullpath = find_include_file(includedfile, includedirs)
383                     if fullpath:
384                         if self.installed:
385                             reporter.error(self.path,
386                                     'installed header includes "{0}", '
387                                     'which is not found using relative path'
388                                         .format(includedfile))
389                     else:
390                         reporter.input_warning(self.path,
391                                 'included file "{0}" not found'
392                                     .format(includedfile))
393         if fullpath:
394             if fullpath in allfiles:
395                 other = allfiles[fullpath]
396                 if self.installed and not other.installed:
397                     reporter.error(self.path,
398                             'installed header includes '
399                             'non-installed header "{0}"'
400                                 .format(other.path))
401                 self.add_dependency(other)
402             #elif not dep in ignorelist:
403             #    depfile = File(dep, None)
404             #    files[dep] = depfile
405             #    file.add_dependency(depfile)
406             #    extrafiles.append(dep)
407
408     def scan(self, filename, allfiles, includedirs, ignorelist, reporter):
409         selfdir = os.path.dirname(filename)
410         infileblock = False
411         foundfileblock = False
412         docmodule = None
413         with open(filename, 'r') as scanfile:
414             for line in scanfile:
415                 if line.startswith('#include'):
416                     self.scan_include_file(line, allfiles, selfdir,
417                             includedirs, ignorelist, reporter)
418                     continue
419                 if not foundfileblock:
420                     if infileblock:
421                         if line.startswith(r' */'):
422                             infileblock = False
423                             foundfileblock = True
424                             continue
425                         if self.type == 'implheader':
426                             if line.startswith(r' * \inpublicapi'):
427                                 self.type = 'publicheader'
428                             elif line.startswith(r' * \inlibraryapi'):
429                                 self.type = 'libheader'
430                         match = re.match(r' \* \\ingroup module_([a-z_]*)', line)
431                         if match:
432                             if docmodule:
433                                 reporter.error(self.path,
434                                         'file documented in multiple modules')
435                             docmodule = match.group(1)
436                     else:
437                         match = re.match(r'/\*! *(\\[a-z]*internal)? *\\file', line)
438                         if match:
439                             docspec = match.group(1)
440                             if not docspec:
441                                 self.doctype = 'public'
442                             elif docspec == r'\libinternal':
443                                 self.doctype = 'library'
444                             elif docspec == r'\internal':
445                                 self.doctype = 'implementation'
446                             else:
447                                 reporter.input_warning(self.path,
448                                         'unknown specifier "{0}"'.format(docspec))
449                                 self.doctype = 'unknown'
450                             infileblock = True
451                             if self.type == 'header':
452                                 # Default type if no other found
453                                 self.type = 'implheader'
454         if self.doctype == 'none':
455             reporter.error(self.path, 'file not documented')
456         elif self.doctype == 'implementation' and \
457                 self.type in ('publicheader', 'libheader'):
458             reporter.error(self.path,
459                     'file documentation visibility incorrect')
460         elif self.doctype == 'library' and self.type == 'publicheader':
461             reporter.error(self.path,
462                     'file documentation visibility incorrect')
463         if self.installed and self.doctype not in ('public', 'unknown'):
464             reporter.error(self.path,
465                     'installed header has no public documentation')
466         elif not self.installed and self.doctype == 'public':
467             reporter.error(self.path,
468                     'non-installed file has public documentation')
469         selfmodnodename = self.module.nodename
470         if docmodule and \
471                 not selfmodnodename.startswith('module_' + docmodule) and \
472                 not selfmodnodename.startswith('module_gromacs_' + docmodule):
473             reporter.error(self.path,
474                     'file documented in incorrect module "{0}"'
475                         .format(docmodule))
476
477
478 class Module(object):
479     def __init__(self, name, parent = None):
480         self.parent = parent
481         self.name = name
482         if parent:
483             self.nodename = parent.nodename + '_' + name
484         else:
485             self.nodename = 'module'
486         self.files = []
487         self.children = dict()
488         self.is_top_level = (not parent or parent.name in ('', 'gromacs'))
489
490     def is_child(self, module):
491         parent = module.parent
492         while parent:
493             if parent == self:
494                 return True
495             parent = parent.parent
496         return False
497
498     def get_top_level_module(self):
499         if self.is_top_level or not self.parent:
500             return self
501         return self.parent.get_top_level_module()
502
503     def add_nested_file(self, modules, path):
504         if len(modules) == 1:
505             newfile = File(path, self)
506             self.files.append(newfile)
507         else:
508             if not modules[0] in self.children:
509                 module = Module(modules[0], self)
510                 self.children[modules[0]] = module
511             else:
512                 module = self.children[modules[0]]
513             newfile = module.add_nested_file(modules[1:], path)
514         return newfile
515
516     def create_node(self):
517         node = Node(self)
518         for childfile in self.files:
519             node.add_child(childfile.get_node())
520         for childmodule in self.children.itervalues():
521             node.add_child(childmodule.create_node())
522         return node
523
524     def node_properties(self):
525         properties = 'label="{0}", shape=ellipse'.format(self.name)
526         properties += ', URL="\\ref module_{0}"'.format(self.name)
527         return properties
528
529
530 class Dependencies(object):
531     def __init__(self, rootdir, includedirs, installedfiles):
532         self.files = dict()
533         self.root = Module("")
534         self.rootpath = []
535         for root in rootdir:
536             self.rootpath.append(os.path.abspath(root))
537         if includedirs:
538             self.includedirs = self.rootpath + includedirs
539         else:
540             self.includedirs = self.rootpath
541         self.installedfiles = installedfiles
542
543     def add_file(self, filename, reporter):
544         fullpath = os.path.abspath(filename)
545         for root in self.rootpath:
546             if fullpath.startswith(root):
547                 relpath = fullpath[len(root)+1:]
548                 break
549         else:
550             reporter.input_warning(filename,
551                     'input file not under root path, skipped')
552             return
553         modules = relpath.split(os.sep)
554         newfile = self.root.add_nested_file(modules, relpath)
555         if fullpath in self.installedfiles:
556             newfile.set_installed(reporter)
557         self.files[os.path.abspath(filename)] = newfile
558
559     def scan_files(self, ignorelist, reporter):
560         for (filename, scanfile) in self.files.iteritems():
561             scanfile.scan(filename, self.files, self.includedirs, ignorelist,
562                     reporter)
563         for scanfile in self.files.itervalues():
564             for link in scanfile.get_links():
565                 link.refresh_type(reporter)
566
567     def create_file_graph(self):
568         rootnode = self.root.create_node()
569         rootnode.set_root()
570         links = []
571         for scanfile in self.files.itervalues():
572             links.extend(scanfile.get_links())
573         graph = Graph([rootnode], links)
574         return graph
575
576     def create_modules_graph(self):
577         rootnode = self.root.create_node()
578         rootnode.set_root()
579         links = []
580         for scanfile in self.files.itervalues():
581             links.extend(scanfile.get_links())
582         graph = Graph([rootnode], links)
583         for node in rootnode.get_children():
584             if node.obj.name == 'gromacs':
585                 for child in node.get_children():
586                     graph.collapse_node(child)
587             else:
588                 graph.collapse_node(node)
589         graph.set_options(concentrate=False)
590         return graph
591
592     def create_module_file_graph(self, module):
593         rootnode = module.create_node()
594         rootnode.set_root()
595         links = []
596         for scanfile in self.files.itervalues():
597             links.extend(scanfile.get_links())
598         graph = Graph([rootnode], links)
599         graph.prune_links()
600         return graph
601
602     def get_toplevel_modules(self):
603         result = []
604         for module in self.root.children.itervalues():
605             if module.name == 'gromacs':
606                 result.extend(module.children.itervalues())
607             else:
608                 result.append(module)
609         return result
610
611
612 def print_module_graph(outfile, deps, options):
613     graph = deps.create_modules_graph()
614     graph.write(outfile)
615
616 def print_file_graph(outfile, deps, options):
617     graph = deps.create_file_graph()
618     graph.set_options(left_to_right=options.left_to_right)
619     graph.write(outfile)
620     #if options.source_at_top:
621     #    sourcenodes = []
622     #    for file in deps.files.itervalues():
623     #        if file.sourcefile:
624     #            sourcenodes.append(file.nodename)
625     #    if sourcenodes:
626     #        outfile.write('    { rank = min; ' + '; '.join(sourcenodes) + '}\n')
627     #if options.with_external and options.external_at_bottom:
628     #    extnodes = []
629     #    for file in deps.files.itervalues():
630     #        if not file.module:
631     #            extnodes.append(file.nodename)
632     #    if extnodes:
633     #        outfile.write('    { rank = max; ' + '; '.join(extnodes) + '}\n')
634
635 def print_module_file_graph(outfile, deps, module, options):
636     graph = deps.create_module_file_graph(module)
637     graph.set_options(left_to_right=options.left_to_right)
638     graph.write(outfile)
639
640 def main():
641     parser = OptionParser()
642     parser.add_option('-f', '--files',
643                       help='Read list of input files from given file')
644     parser.add_option('--installed',
645                       help='Read list of installed files from given file')
646     parser.add_option('-R', '--rootdir', action='append',
647                       help='Remove this prefix from all files')
648     parser.add_option('-I', '--includedir', action='append',
649                       help='Specify additional directories to search for '
650                            'include files')
651     parser.add_option('-o', '--outdir', default='.',
652                       help='Specify output directory for graphs')
653     #parser.add_option('--source-at-top', action='store_true',
654     #                  help='Force source files at the top of the graph')
655     #parser.add_option('--with-external', action='store_true',
656     #                  help='Include external dependencies in the graph')
657     #parser.add_option('--external-at-bottom', action='store_true',
658     #                  help='Force external dependencies files at the bottom '
659     #                       'of the graph')
660     parser.add_option('--left-to-right', action='store_true',
661                       help='Lay out from left to right')
662     parser.add_option('--file-graph',
663                       help='Write graph for individual files')
664     parser.add_option('--module-graph',
665                       help='Write graph for modules')
666     parser.add_option('--module-file-graphs', action='store_true',
667                       help='Write file graphs for each module')
668     options, args = parser.parse_args()
669
670     # Constructs lists of files
671     filelist = []
672     ignorelist = []
673     installedlist = []
674     if options.files:
675         with open(options.files, 'r') as outfile:
676             for line in outfile:
677                 if line.startswith('!'):
678                     ignorelist.append(os.path.abspath(line[1:].strip()))
679                 else:
680                     filelist.append(line.strip())
681     filelist.extend(args)
682     if options.installed:
683         with open(options.installed, 'r') as outfile:
684             for line in outfile:
685                 installedlist.append(line.strip())
686
687     # Creates objects for all files and modules
688     reporter = ErrorReporter()
689     deps = Dependencies(options.rootdir, options.includedir, installedlist)
690     for filename in filelist:
691         deps.add_file(filename, reporter)
692
693     deps.scan_files(ignorelist, reporter)
694
695     #if options.with_external:
696     #    for filename in extrafiles:
697     #        file = files[filename]
698     #        if os.path.exists(filename):
699     #            with open(filename, 'r') as outfile:
700     #                for line in outfile:
701     #                    if not file.api:
702     #                        if line.startswith(' * \inpublicapi'):
703     #                            file.api = "public"
704     #                        elif line.startswith(' * \inlibraryapi'):
705     #                            file.api = "library"
706
707     # Prints out the graph
708     if options.module_graph:
709         graphpath = os.path.join(options.outdir, options.module_graph)
710         with open(graphpath, 'w') as outfile:
711             print_module_graph(outfile, deps, options)
712     if options.file_graph:
713         graphpath = os.path.join(options.outdir, options.file_graph)
714         with open(graphpath, 'w') as outfile:
715             print_file_graph(outfile, deps, options)
716     if options.module_file_graphs:
717         options.left_to_right = True
718         for module in deps.get_toplevel_modules():
719             filename = 'module_{0}-deps.dot'.format(module.name)
720             with open(os.path.join(options.outdir, filename), 'w') as outfile:
721                 print_module_file_graph(outfile, deps, module, options)
722
723 main()