836d1db426e6bc0ce380b35d2b34f4e9ebb1096a
[alexxy/gromacs.git] / doxygen / gmxtree.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 """GROMACS-specific representation for source tree and documentation.
37
38 This module provides classes that construct a GROMACS-specific representation
39 of the source tree and associate the Doxygen XML output with it.  It constructs
40 an initial representation by walking the source tree in the file system, and
41 then associates information from the Doxygen XML output into this.
42 It also adds some additional knowledge from how the GROMACS source tree is
43 organized to construct a representation that is easy to process and check as
44 the top-level scripts expect.
45
46 The object model is rooted at a GromacsTree object.  Currently, it constructs a
47 representation of the source tree from the file system, but is otherwise mostly
48 a thin wrapper around the Doxygen XML tree.  It already adds some relations and
49 rules that come from GROMACS-specific knowledge.  In the future, more such
50 customizations will be added.
51 """
52
53 import os
54 import os.path
55 import re
56
57 import doxygenxml as xml
58 import reporter
59 # We import DocType directly so that it is exposed from this module as well.
60 from doxygenxml import DocType
61
62 def _get_api_type_for_compound(grouplist):
63     """Helper function to deduce API type from Doxygen group membership."""
64     result = DocType.internal
65     for group in grouplist:
66         if isinstance(group, xml.Group):
67             if group.get_name() == 'group_publicapi':
68                 result = DocType.public
69             elif group.get_name() == 'group_libraryapi':
70                 result = DocType.library
71             # TODO: Check for multiple group membership
72     return result
73
74 class IncludedFile(object):
75
76     """Information about an #include directive in a file."""
77
78     def __init__(self, path, lineno, included_file, included_path, is_relative, is_system):
79         self._path = path
80         self._line_number = lineno
81         self._included_file = included_file
82         self._included_path = included_path
83         #self._used_include_path = used_include_path
84         self._is_relative = is_relative
85         self._is_system = is_system
86
87     def __str__(self):
88         if self._is_system:
89             return '<{0}>'.format(self._included_path)
90         else:
91             return '"{0}"'.format(self._included_path)
92
93     def is_system(self):
94         return self._is_system
95
96     def is_relative(self):
97         return self._is_relative
98
99     def get_file(self):
100         return self._included_file
101
102     def get_reporter_location(self):
103         return reporter.Location(self._path, self._line_number)
104
105 class File(object):
106
107     """Source/header file in the GROMACS tree."""
108
109     def __init__(self, path, directory):
110         """Initialize a file representation with basic information."""
111         self._path = path
112         self._dir = directory
113         self._rawdoc = None
114         self._installed = False
115         extension = os.path.splitext(path)[1]
116         self._sourcefile = (extension in ('.c', '.cc', '.cpp', '.cu'))
117         self._apitype = DocType.none
118         self._modules = set()
119         self._includes = []
120
121     def set_doc_xml(self, rawdoc, sourcetree):
122         """Assiociate Doxygen documentation entity with the file."""
123         assert self._rawdoc is None
124         assert rawdoc.is_source_file() == self._sourcefile
125         self._rawdoc = rawdoc
126         if self._rawdoc.is_documented():
127             grouplist = self._rawdoc.get_groups()
128             self._apitype = _get_api_type_for_compound(grouplist)
129             for group in grouplist:
130                 module = sourcetree.get_object(group)
131                 if module:
132                     self._modules.add(module)
133
134     def set_installed(self):
135         """Mark the file installed."""
136         self._installed = True
137
138     def _process_include(self, lineno, is_system, includedpath, sourcetree):
139         """Process #include directive during scan()."""
140         is_relative = False
141         if is_system:
142             fileobj = sourcetree.find_include_file(includedpath)
143         else:
144             fullpath = os.path.join(self._dir.get_path(), includedpath)
145             fullpath = os.path.abspath(fullpath)
146             if os.path.exists(fullpath):
147                 is_relative = True
148                 fileobj = sourcetree.get_file(fullpath)
149             else:
150                 fileobj = sourcetree.find_include_file(includedpath)
151         self._includes.append(IncludedFile(self.get_path(), lineno, fileobj, includedpath,
152                 is_relative, is_system))
153
154     def scan_contents(self, sourcetree):
155         """Scan the file contents and initialize information based on it."""
156         # TODO: Consider a more robust regex.
157         include_re = r'^#\s*include\s+(?P<quote>["<])(?P<path>[^">]*)[">]'
158         with open(self._path, 'r') as scanfile:
159             for lineno, line in enumerate(scanfile, 1):
160                 match = re.match(include_re, line)
161                 if match:
162                     is_system = (match.group('quote') == '<')
163                     includedpath = match.group('path')
164                     self._process_include(lineno, is_system, includedpath,
165                             sourcetree)
166
167     def get_reporter_location(self):
168         return reporter.Location(self._path, None)
169
170     def is_installed(self):
171         return self._installed
172
173     def is_external(self):
174         return self._dir.is_external()
175
176     def is_source_file(self):
177         return self._sourcefile
178
179     def is_test_file(self):
180         return self._dir.is_test_directory()
181
182     def is_documented(self):
183         return self._rawdoc and self._rawdoc.is_documented()
184
185     def has_brief_description(self):
186         return self._rawdoc and self._rawdoc.has_brief_description()
187
188     def get_path(self):
189         return self._path
190
191     def get_documentation_type(self):
192         if not self._rawdoc:
193             return DocType.none
194         return self._rawdoc.get_visibility()
195
196     def get_api_type(self):
197         return self._apitype
198
199     def get_expected_module(self):
200         return self._dir.get_module()
201
202     def get_doc_modules(self):
203         return self._modules
204
205     def get_module(self):
206         module = self.get_expected_module()
207         if not module and len(self._modules) == 1:
208             module = list(self._modules)[0]
209         return module
210
211     def get_includes(self):
212         return self._includes
213
214 class GeneratedFile(File):
215     pass
216
217 class Directory(object):
218
219     """(Sub)directory in the GROMACS tree."""
220
221     def __init__(self, path, parent):
222         """Initialize a file representation with basic information."""
223         self._path = path
224         self._name = os.path.basename(path)
225         self._parent = parent
226         self._rawdoc = None
227         self._module = None
228         self._is_test_dir = False
229         if parent and parent.is_test_directory() or \
230                 os.path.basename(path) in ('tests', 'legacytests'):
231             self._is_test_dir = True
232         self._is_external = False
233         if parent and parent.is_external() or \
234                 os.path.basename(path) == 'external':
235             self._is_external = True
236         self._subdirs = set()
237         if parent:
238             parent._subdirs.add(self)
239
240     def set_doc_xml(self, rawdoc, sourcetree):
241         """Assiociate Doxygen documentation entity with the directory."""
242         assert self._rawdoc is None
243         assert self._path == rawdoc.get_path().rstrip('/')
244         self._rawdoc = rawdoc
245
246     def set_module(self, module):
247         assert self._module is None
248         self._module = module
249
250     def get_name(self):
251         return self._name
252
253     def get_reporter_location(self):
254         return reporter.Location(self._path, None)
255
256     def get_path(self):
257         return self._path
258
259     def is_test_directory(self):
260         return self._is_test_dir
261
262     def is_external(self):
263         return self._is_external
264
265     def get_module(self):
266         if self._module:
267             return self._module
268         if self._parent:
269             return self._parent.get_module()
270         return None
271
272     def get_subdirectories(self):
273         return self._subdirs
274
275 class Module(object):
276
277     """Code module in the GROMACS source tree.
278
279     Modules are specific subdirectories that host a more or less coherent
280     set of routines.  Simplified, every subdirectory under src/gromacs/ is
281     a different module.  This object provides that abstraction and also links
282     the subdirectory to the module documentation (documented as a group in
283     Doxygen) if that exists.
284     """
285
286     def __init__(self, name, rootdir):
287         self._name = name
288         self._rawdoc = None
289         self._rootdir = rootdir
290
291     def set_doc_xml(self, rawdoc, sourcetree):
292         """Assiociate Doxygen documentation entity with the module."""
293         assert self._rawdoc is None
294         self._rawdoc = rawdoc
295
296     def is_documented(self):
297         return self._rawdoc is not None
298
299     def get_name(self):
300         return self._name
301
302 class Class(object):
303
304     """Class/struct/union in the GROMACS source code."""
305
306     def __init__(self, rawdoc, files):
307         self._rawdoc = rawdoc
308         self._files = set(files)
309
310     def get_name(self):
311         return self._rawdoc.get_name()
312
313     def get_reporter_location(self):
314         return self._rawdoc.get_reporter_location()
315
316     def get_files(self):
317         return self._files
318
319     def is_documented(self):
320         return self._rawdoc.is_documented()
321
322     def has_brief_description(self):
323         return self._rawdoc.has_brief_description()
324
325     def get_documentation_type(self):
326         if not self.is_documented():
327             return DocType.none
328         if self._rawdoc.is_local():
329             return DocType.internal
330         return self._rawdoc.get_visibility()
331
332     def get_file_documentation_type(self):
333         return max([fileobj.get_documentation_type() for fileobj in self._files])
334
335     def is_in_installed_file(self):
336         return any([fileobj.is_installed() for fileobj in self._files])
337
338 class GromacsTree(object):
339
340     """Root object for navigating the GROMACS source tree.
341
342     On initialization, the list of files and directories is initialized by
343     walking the source tree, and modules are created for top-level
344     subdirectories.  At this point, only information that is accessible from
345     file names and paths only is available.
346
347     set_installed_file_list() can be called to set the list of installed
348     files.
349
350     scan_files() can be called to read all the files and initialize #include
351     dependencies between the files based on the information.  This is done like
352     this instead of relying on Doxygen-extracted include files to make the
353     dependency graph independent from preprocessor macro definitions
354     (Doxygen only sees those #includes that the preprocessor sees, which
355     depends on what #defines it has seen).
356
357     load_xml() can be called to load information from Doxygen XML data in
358     the build tree (the Doxygen XML data must have been built separately).
359     """
360
361     def __init__(self, source_root, build_root, reporter):
362         """Initialize the tree object by walking the source tree."""
363         self._source_root = os.path.abspath(source_root)
364         self._build_root = os.path.abspath(build_root)
365         self._reporter = reporter
366         self._docset = None
367         self._docmap = dict()
368         self._dirs = dict()
369         self._files = dict()
370         self._modules = dict()
371         self._classes = set()
372         self._walk_dir(os.path.join(self._source_root, 'src'))
373         rootdir = self._get_dir(os.path.join('src', 'gromacs'))
374         for subdir in rootdir.get_subdirectories():
375             self._create_module(subdir)
376         rootdir = self._get_dir(os.path.join('src', 'testutils'))
377         self._create_module(rootdir)
378
379     def _get_rel_path(self, path):
380         assert os.path.isabs(path)
381         if path.startswith(self._build_root):
382             return path[len(self._build_root)+1:]
383         if path.startswith(self._source_root):
384             return path[len(self._source_root)+1:]
385         raise ValueError("path not under build nor source tree: {0}".format(path))
386
387     def _walk_dir(self, rootpath):
388         """Construct representation of the source tree by walking the file system."""
389         assert os.path.isabs(rootpath)
390         assert rootpath not in self._dirs
391         relpath = self._get_rel_path(rootpath)
392         self._dirs[relpath] = Directory(rootpath, None)
393         for dirpath, dirnames, filenames in os.walk(rootpath):
394             if 'contrib' in dirnames:
395                 dirnames.remove('contrib')
396             if 'refdata' in dirnames:
397                 dirnames.remove('refdata')
398             currentdir = self._dirs[self._get_rel_path(dirpath)]
399             # Loop through a copy so that we can modify dirnames.
400             for dirname in list(dirnames):
401                 fullpath = os.path.join(dirpath, dirname)
402                 if fullpath == self._build_root:
403                     dirnames.remove(dirname)
404                     continue
405                 relpath = self._get_rel_path(fullpath)
406                 self._dirs[relpath] = Directory(fullpath, currentdir)
407             extensions = ('.h', '.cuh', '.hpp', '.c', '.cc', '.cpp', '.cu', '.bm')
408             for filename in filenames:
409                 basename, extension = os.path.splitext(filename)
410                 if extension in extensions:
411                     fullpath = os.path.join(dirpath, filename)
412                     relpath = self._get_rel_path(fullpath)
413                     self._files[relpath] = File(fullpath, currentdir)
414                 elif extension == '.cmakein':
415                     extension = os.path.splitext(basename)[1]
416                     if extension in extensions:
417                         fullpath = os.path.join(dirpath, basename)
418                         relpath = self._get_rel_path(fullpath)
419                         fullpath = os.path.join(dirpath, filename)
420                         self._files[relpath] = GeneratedFile(fullpath, currentdir)
421
422     def _create_module(self, rootdir):
423         """Create module for a subdirectory."""
424         name = 'module_' + rootdir.get_name()
425         moduleobj = Module(name, rootdir)
426         rootdir.set_module(moduleobj)
427         self._modules[name] = moduleobj
428
429     def scan_files(self):
430         """Read source files to initialize #include dependencies."""
431         for fileobj in self._files.itervalues():
432             if not fileobj.is_external():
433                 fileobj.scan_contents(self)
434
435     def load_xml(self):
436         """Load Doxygen XML information."""
437         xmldir = os.path.join(self._build_root, 'doxygen', 'xml')
438         self._docset = xml.DocumentationSet(xmldir, self._reporter)
439         self._docset.load_details()
440         self._docset.merge_duplicates()
441         self._load_dirs()
442         self._load_modules()
443         self._load_files()
444         self._load_classes()
445
446     def _load_dirs(self):
447         """Load Doxygen XML directory information."""
448         rootdirs = self._docset.get_compounds(xml.Directory,
449                 lambda x: x.get_parent() is None)
450         for dirdoc in rootdirs:
451             self._load_dir(dirdoc, None)
452
453     def _load_dir(self, dirdoc, parent):
454         """Load Doxygen XML directory information for a single directory."""
455         path = dirdoc.get_path().rstrip('/')
456         if not os.path.isabs(path):
457             self._reporter.xml_assert(dirdoc.get_xml_path(),
458                     "expected absolute path in Doxygen-produced XML file")
459             return
460         relpath = self._get_rel_path(path)
461         dirobj = self._dirs.get(relpath)
462         if not dirobj:
463             dirobj = Directory(path, parent)
464             self._dirs[relpath] = dirobj
465         dirobj.set_doc_xml(dirdoc, self)
466         self._docmap[dirdoc] = dirobj
467         for subdirdoc in dirdoc.get_subdirectories():
468             self._load_dir(subdirdoc, dirobj)
469
470     def _load_modules(self):
471         """Load Doxygen XML module (group) information."""
472         moduledocs = self._docset.get_compounds(xml.Group,
473                 lambda x: x.get_name().startswith('module_'))
474         for moduledoc in moduledocs:
475             moduleobj = self._modules.get(moduledoc.get_name())
476             if not moduleobj:
477                 self._reporter.input_error(
478                         "no matching directory for module: {0}".format(moduledoc))
479                 continue
480             moduleobj.set_doc_xml(moduledoc, self)
481             self._docmap[moduledoc] = moduleobj
482
483     def _load_files(self):
484         """Load Doxygen XML file information."""
485         for filedoc in self._docset.get_files():
486             path = filedoc.get_path()
487             if not os.path.isabs(path):
488                 self._reporter.xml_assert(filedoc.get_xml_path(),
489                         "expected absolute path in Doxygen-produced XML file")
490                 continue
491             extension = os.path.splitext(filedoc.get_path())[1]
492             # We don't care about Markdown files that only produce pages
493             # (and fail the directory check below).
494             if extension == '.md':
495                 continue
496             dirdoc = filedoc.get_directory()
497             if not dirdoc:
498                 self._reporter.xml_assert(filedoc.get_xml_path(),
499                         "file is not in any directory in Doxygen")
500                 continue
501             relpath = self._get_rel_path(path)
502             fileobj = self._files.get(relpath)
503             if not fileobj:
504                 fileobj = File(path, self._docmap[dirdoc])
505                 self._files[relpath] = fileobj
506             fileobj.set_doc_xml(filedoc, self)
507             self._docmap[filedoc] = fileobj
508
509     def _load_classes(self):
510         """Load Doxygen XML class information."""
511         classdocs = self._docset.get_classes()
512         for classdoc in classdocs:
513             files = [self._docmap[filedoc] for filedoc in classdoc.get_files()]
514             classobj = Class(classdoc, files)
515             self._docmap[classdoc] = classobj
516             self._classes.add(classobj)
517
518     def _get_dir(self, relpath):
519         """Get directory object for a path relative to source tree root."""
520         return self._dirs.get(relpath)
521
522     def get_file(self, path):
523         """Get file object for a path relative to source tree root."""
524         return self._files.get(self._get_rel_path(path))
525
526     def find_include_file(self, includedpath):
527         """Find a file object corresponding to an include path."""
528         for testdir in ('src', 'src/gromacs/legacyheaders', 'src/external/thread_mpi/include'):
529             testpath = os.path.join(testdir, includedpath)
530             if testpath in self._files:
531                 return self._files[testpath]
532
533     def set_installed_file_list(self, installedfiles):
534         """Set list of installed files."""
535         for path in installedfiles:
536             if not os.path.isabs(path):
537                 self._reporter.input_error(
538                         "installed file not specified with absolute path: {0}"
539                         .format(path))
540                 continue
541             relpath = self._get_rel_path(path)
542             if relpath not in self._files:
543                 self._reporter.input_error(
544                         "installed file not in source tree: {0}".format(path))
545                 continue
546             self._files[relpath].set_installed()
547
548     def get_object(self, docobj):
549         """Get tree object for a Doxygen XML object."""
550         return self._docmap.get(docobj)
551
552     def get_files(self):
553         """Get iterable for all files in the source tree."""
554         return self._files.itervalues()
555
556     def get_classes(self):
557         """Get iterable for all classes in the source tree."""
558         return self._classes
559
560     def get_members(self):
561         """Get iterable for all members (in Doxygen terms) in the source tree."""
562         # TODO: Add wrappers to solve some issues.
563         return self._docset.get_members()