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