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