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