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