Module dependency cycle checker for 'doc-check'
[alexxy/gromacs.git] / doxygen / doxygen-check.py
1 #!/usr/bin/python
2 #
3 # This file is part of the GROMACS molecular simulation package.
4 #
5 # Copyright (c) 2014, by the GROMACS development team, led by
6 # Mark Abraham, David van der Spoel, Berk Hess, and Erik Lindahl,
7 # and including many others, as listed in the AUTHORS file in the
8 # top-level source 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 """Check Doxygen documentation for issues that Doxygen does not warn about.
37
38 This script for some issues in the Doxygen documentation, using Doxygen XML
39 output.  Part of the checks are generic, like checking that all documented
40 entities have brief descriptions.  Other are specific to GROMACS, like checking
41 that only installed headers contribute to the public API documentation.
42
43 The checks should be self-evident from the source code of the script.
44 All the logic of parsing the Doxygen XML output and creating a GROMACS-specific
45 representation of the source tree is separated into separate Python modules
46 (doxygenxml.py and gmxtree.py, respectively).  Similarly, logic for handling
47 the output messages is in reporter.py.   This leaves only the actual checks and
48 the script command-line interface in this file.
49
50 The script can be run using the 'doc-check' target generated by CMake.
51 This target takes care of generating all the necessary input files and passing
52 them to the script.
53 """
54
55 import sys
56 from optparse import OptionParser
57
58 from gmxtree import GromacsTree, DocType
59 from reporter import Reporter
60
61 def check_file(fileobj, reporter):
62     """Check file-level documentation."""
63     if not fileobj.is_documented():
64         # TODO: Add rules for required documentation
65         return
66
67     if fileobj.is_source_file():
68         # TODO: Add rule to exclude examples from this check
69         if fileobj.is_installed():
70             reporter.file_error(fileobj, "source file is installed")
71         if fileobj.get_doc_type() != DocType.internal:
72             reporter.file_error(fileobj,
73                     "source file documentation appears outside full documentation")
74         elif fileobj.get_api_type() != DocType.internal:
75             reporter.file_error(fileobj, "source file marked as non-internal")
76     elif fileobj.is_test_file() and fileobj.is_installed():
77         reporter.file_error(fileobj, "test file is installed")
78     elif fileobj.is_installed():
79         if fileobj.get_doc_type() != DocType.public:
80             reporter.file_error(fileobj,
81                     "public header has non-public documentation")
82     elif fileobj.get_doc_type() == DocType.public:
83         reporter.file_error(fileobj,
84                 "non-installed header has public documentation")
85     elif fileobj.get_api_type() == DocType.public:
86         reporter.file_error(fileobj,
87                 "non-installed header specified as part of public API")
88     elif fileobj.get_doc_type() < fileobj.get_api_type():
89         reporter.file_error(fileobj,
90                 "API type ({0}) conflicts with documentation visibility ({1})"
91                 .format(fileobj.get_api_type(), fileobj.get_doc_type()))
92
93     if not fileobj.has_brief_description():
94         reporter.file_error(fileobj,
95                 "is documented, but does not have brief description")
96
97     expectedmod = fileobj.get_expected_module()
98     if expectedmod:
99         docmodules = fileobj.get_doc_modules()
100         if docmodules:
101             for module in docmodules:
102                 if module != expectedmod:
103                     reporter.file_error(fileobj,
104                             "is documented in incorrect module: {0}"
105                             .format(module.get_name()))
106         elif expectedmod.is_documented():
107             reporter.file_error(fileobj,
108                     "is not documented in any module, but {0} exists"
109                     .format(expectedmod.get_name()))
110
111 def check_include(fileobj, includedfile, reporter):
112     """Check an #include directive."""
113     if includedfile.is_system():
114         if includedfile.get_file():
115             reporter.code_issue(includedfile,
116                     "includes local file as {0}".format(includedfile))
117     else:
118         otherfile = includedfile.get_file()
119         if not otherfile:
120             reporter.code_issue(includedfile,
121                     "includes non-local file as {0}".format(includedfile))
122         elif fileobj.is_installed() and not includedfile.is_relative():
123             reporter.code_issue(includedfile,
124                     "installed header includes {0} using non-relative path"
125                     .format(includedfile))
126         if not otherfile:
127             return
128         if fileobj.is_installed() and not otherfile.is_installed():
129             reporter.code_issue(includedfile,
130                     "installed header includes non-installed {0}"
131                     .format(includedfile))
132         filemodule = fileobj.get_module()
133         othermodule = otherfile.get_module()
134         if fileobj.is_documented() and otherfile.is_documented():
135             filetype = fileobj.get_doc_type()
136             othertype = otherfile.get_doc_type()
137             if filetype > othertype:
138                 reporter.code_issue(includedfile,
139                         "{0} file includes {1} file {2}"
140                         .format(filetype, othertype, includedfile))
141         check_api = (otherfile.api_type_is_reliable() and filemodule != othermodule)
142         if check_api and otherfile.get_api_type() < DocType.library:
143             reporter.code_issue(includedfile,
144                     "included file {0} is not documented as exposed outside its module"
145                     .format(includedfile))
146
147 def check_entity(entity, reporter):
148     """Check documentation for a code construct."""
149     if entity.is_documented():
150         if not entity.has_brief_description():
151             reporter.doc_error(entity,
152                     "is documented, but does not have brief description")
153
154 def check_class(classobj, reporter):
155     """Check documentation for a class/struct/union."""
156     check_entity(classobj, reporter)
157     if classobj.is_documented():
158         classtype = classobj.get_doc_type()
159         filetype = classobj.get_file_doc_type()
160         if classtype == DocType.public and not classobj.is_in_installed_file():
161             reporter.doc_error(classobj,
162                     "has public documentation, but is not in installed header")
163         elif filetype is not DocType.none and classtype > filetype:
164             reporter.doc_error(classobj,
165                     "is in {0} file(s), but appears in {1} documentation"
166                     .format(filetype, classtype))
167
168 def check_member(member, reporter, check_ignored):
169     """Check documentation for a generic member."""
170     check_entity(member, reporter)
171     if member.is_documented():
172         if check_ignored and not member.is_visible():
173             reporter.doc_note(member,
174                     "is documented, but is ignored by Doxygen, because its scope is not documented")
175         if member.has_inbody_description():
176             reporter.doc_note(member, "has in-body comments, which are ignored")
177
178 def check_cycles(graph, reporter):
179     """Check cyclic dependencies in a dependency graph.
180
181     The graph parameter provides the graph to check.  It should be an object
182     that has three methods:
183       iternodes():
184         Return the list of nodes in the graph.
185       iteredges(node):
186         Return the list of edges from a given node.
187         The list should contain (node, edge) pairs, where node is an object
188         returned by iternodes() and edge is any object.
189       report_cycle(cycle, reporter):
190         Process a found cycle. cycle contains a list of (node, edge) pairs
191         that describe the cycle.  edge is the edge object that leads _to_
192         the node in the cycle.
193
194     This is implemented using an extended DFS-based strongly connected
195     component (SCC) search, written using a stack instead of recursion.
196     The base algorithm is Tarjan's SCC search:
197       http://en.wikipedia.org/wiki/Tarjan's_strongly_connected_components_algorithm
198
199     Each back edge that is encountered during the search is reported as a
200     cycle.  Additionally, if a cross edge is encountered that is within the
201     current SCC, the target node and all its children in the current SCC will
202     be visited again to find all cycles.  All steps except cycle detection are
203     omitted for such re-traversal.
204
205     To avoid duplicates from cycles that do not include all nodes in an SCC,
206     a cycle is only reported if the target of the back edge is still active
207     in the search, i.e., all edges from it have not yet been traversed.
208     """
209     # The DFS stack; next node is always popped from the end.
210     # Stores (node, edge) pairs.
211     # edge is None for start nodes and for post-order processing.
212     dfsstack = []
213     for node in graph.iternodes():
214         dfsstack.append((node, None))
215     # Stack of visited nodes that have not yet been assigned to a strongly
216     # connected component.
217     visitstack = []
218     # List of nodes in the DFS recursion stack.
219     currlist = []
220     # Set of nodes in currlist for more efficient searching.
221     currset = set()
222     # Counter for initializing preorder.
223     visit_count = 0
224     # DFS pre-order for nodes: initialized when a node is first encountered
225     # in the search.
226     preorder = dict()
227     # Lowest pre-order index reachable from this node.
228     # Initialized to pre-order, and updated during post-order processing.
229     linkorder = dict()
230     # Set to True for a node when first encountered, and set to False when
231     # a strongly connected component has been processed.
232     in_progress = dict()
233     # The DFS search
234     while dfsstack:
235         currnode, curredge = dfsstack.pop()
236         # curredge is None if this is a start node or post-order traversal.
237         # currlist is empty if this is a start node.
238         if curredge is None and currlist:
239             # All children visited: post-order processing.
240             done = currlist.pop()[0]
241             assert done == currnode
242             currset.remove(currnode)
243             # If this is the first time this node is encountered, fill
244             # linkorder and check for strongly connected components.
245             if linkorder[currnode] == preorder[currnode]:
246                 children = [x for x, dummy in graph.iteredges(currnode) if in_progress[x]]
247                 if children:
248                     linkorder[currnode] = min([linkorder[x] for x in children])
249                 if preorder[currnode] <= linkorder[currnode]:
250                     # This is a root of a strongly connected component.
251                     while visitstack:
252                         node = visitstack.pop()
253                         in_progress[node] = False
254                         if node == currnode:
255                             break
256                     else:
257                         assert False
258             continue
259         if currnode not in preorder:
260             # First encounter of this node: pre-order processing.
261             preorder[currnode] = visit_count
262             linkorder[currnode] = visit_count
263             visitstack.append(currnode)
264             visit_count += 1
265             in_progress[currnode] = True
266         elif not in_progress[currnode]:
267             # Do not enter processed components again.
268             continue
269         currlist.append((currnode, curredge))
270         currset.add(currnode)
271         # add entry for post-order traversal
272         dfsstack.append((currnode, None))
273         for nextnode, edge in graph.iteredges(currnode):
274             if nextnode not in preorder:
275                 # Not seen previously: push
276                 dfsstack.append((nextnode, edge))
277             else:
278                 # If an already visited node is in the same component, it is
279                 # either part of a cycle, or we need to traverse it again to
280                 # find all cycles.
281                 if in_progress[nextnode]:
282                     if nextnode not in currset:
283                         dfsstack.append((nextnode, edge))
284                     # Only report cycles to nodes that haven't been processed
285                     # yet to avoid duplicates.
286                     elif linkorder[nextnode] == preorder[nextnode]:
287                         for index in xrange(len(currlist)):
288                             if currlist[index][0] == nextnode:
289                                 cycle = [(nextnode, edge)]
290                                 cycle.extend(currlist[index+1:])
291                                 graph.report_cycle(cycle, reporter)
292                                 break
293                         else:
294                             assert False
295
296 class ModuleDependencyGraph(object):
297
298     """Module dependency graph representation for check_cycles().
299
300     In the reported graph, the nodes are gmxtree.Module objects and the edges
301     are gmxtree.ModuleDependency objects.
302     """
303
304     def __init__(self, tree):
305         self._tree = tree
306
307     def iternodes(self):
308         for module in self._tree.get_modules():
309             if module.get_name() != 'module_testutils':
310                 yield module
311
312     def iteredges(self, module):
313         for dependency in module.get_dependencies():
314             if dependency.get_other_module().get_name() != 'module_testutils':
315                 yield (dependency.get_other_module(), dependency)
316
317     def report_cycle(self, cycle, reporter):
318         if any([x[1].is_cycle_suppressed() for x in cycle]):
319             # TODO: Report unused suppressions.
320             return
321         modulelist = ' -> '.join([x[0].get_name()[7:] for x in cycle])
322         summary = 'module-level cyclic dependency: ' + modulelist
323         reporter.cyclic_issue(summary)
324
325 def main():
326     """Run the checking script."""
327     parser = OptionParser()
328     parser.add_option('-S', '--source-root',
329                       help='Source tree root directory')
330     parser.add_option('-B', '--build-root',
331                       help='Build tree root directory')
332     parser.add_option('--installed',
333                       help='Read list of installed files from given file')
334     parser.add_option('-l', '--log',
335                       help='Write issues into a given log file in addition to stderr')
336     parser.add_option('--ignore',
337                       help='Set file with patterns for messages to ignore')
338     parser.add_option('--ignore-cycles',
339                       help='Set file with module dependencies to ignore in cycles')
340     parser.add_option('--check-ignored', action='store_true',
341                       help='Issue notes for comments ignored by Doxygen')
342     parser.add_option('-q', '--quiet', action='store_true',
343                       help='Do not write status messages')
344     options, args = parser.parse_args()
345
346     installedlist = []
347     if options.installed:
348         with open(options.installed, 'r') as outfile:
349             for line in outfile:
350                 installedlist.append(line.strip())
351
352     reporter = Reporter(options.log)
353     if options.ignore:
354         reporter.load_filters(options.ignore)
355
356     if not options.quiet:
357         sys.stderr.write('Scanning source tree...\n')
358     tree = GromacsTree(options.source_root, options.build_root, reporter)
359     tree.set_installed_file_list(installedlist)
360     if not options.quiet:
361         sys.stderr.write('Reading source files...\n')
362     tree.scan_files()
363     if options.ignore_cycles:
364         tree.load_cycle_suppression_list(options.ignore_cycles)
365     if not options.quiet:
366         sys.stderr.write('Reading Doxygen XML files...\n')
367     tree.load_xml()
368
369     reporter.write_pending()
370
371     if not options.quiet:
372         sys.stderr.write('Checking...\n')
373
374     for fileobj in tree.get_files():
375         check_file(fileobj, reporter)
376         for includedfile in fileobj.get_includes():
377             check_include(fileobj, includedfile, reporter)
378
379     for classobj in tree.get_classes():
380         check_class(classobj, reporter)
381
382     for memberobj in tree.get_members():
383         check_member(memberobj, reporter, options.check_ignored)
384
385     check_cycles(ModuleDependencyGraph(tree), reporter)
386
387     reporter.write_pending()
388     reporter.report_unused_filters()
389     reporter.close_log()
390
391 main()