]> git.sesse.net Git - casparcg/blob - source/tools/rstlint.py
(no commit message)
[casparcg] / source / tools / rstlint.py
1 #!/usr/bin/env python3
2 # -*- coding: utf-8 -*-
3
4 # Check for stylistic and formal issues in .rst and .py
5 # files included in the documentation.
6 #
7 # 01/2009, Georg Brandl
8
9 # TODO: - wrong versions in versionadded/changed
10 #       - wrong markup after versionchanged directive
11
12 from __future__ import with_statement
13
14 import os
15 import re
16 import sys
17 import getopt
18 import subprocess
19 from os.path import join, splitext, abspath, exists
20 from collections import defaultdict
21
22 directives = [
23     # standard docutils ones
24     'admonition', 'attention', 'caution', 'class', 'compound', 'container',
25     'contents', 'csv-table', 'danger', 'date', 'default-role', 'epigraph',
26     'error', 'figure', 'footer', 'header', 'highlights', 'hint', 'image',
27     'important', 'include', 'line-block', 'list-table', 'meta', 'note',
28     'parsed-literal', 'pull-quote', 'raw', 'replace',
29     'restructuredtext-test-directive', 'role', 'rubric', 'sectnum', 'sidebar',
30     'table', 'target-notes', 'tip', 'title', 'topic', 'unicode', 'warning',
31     # Sphinx custom ones
32     'acks', 'attribute', 'autoattribute', 'autoclass', 'autodata',
33     'autoexception', 'autofunction', 'automethod', 'automodule', 'centered',
34     'cfunction', 'class', 'classmethod', 'cmacro', 'cmdoption', 'cmember',
35     'code-block', 'confval', 'cssclass', 'ctype', 'currentmodule', 'cvar',
36     'data', 'deprecated', 'describe', 'directive', 'doctest', 'envvar', 'event',
37     'exception', 'function', 'glossary', 'highlight', 'highlightlang', 'index',
38     'literalinclude', 'method', 'module', 'moduleauthor', 'productionlist',
39     'program', 'role', 'sectionauthor', 'seealso', 'sourcecode', 'staticmethod',
40     'tabularcolumns', 'testcode', 'testoutput', 'testsetup', 'toctree', 'todo',
41     'todolist', 'versionadded', 'versionchanged'
42 ]
43
44 all_directives = '(' + '|'.join(directives) + ')'
45 seems_directive_re = re.compile(r'\.\. %s([^a-z:]|:(?!:))' % all_directives)
46 default_role_re = re.compile(r'(^| )`\w([^`]*?\w)?`($| )')
47 leaked_markup_re = re.compile(r'[a-z]::[^=]|:[a-z]+:|`|\.\.\s*\w+:')
48
49
50 checkers = {}
51
52 checker_props = {'severity': 1, 'falsepositives': False}
53
54 def checker(*suffixes, **kwds):
55     """Decorator to register a function as a checker."""
56     def deco(func):
57         for suffix in suffixes:
58             checkers.setdefault(suffix, []).append(func)
59         for prop in checker_props:
60             setattr(func, prop, kwds.get(prop, checker_props[prop]))
61         return func
62     return deco
63
64
65 @checker('.py', severity=4)
66 def check_syntax(fn, lines):
67     """Check Python examples for valid syntax."""
68     code = ''.join(lines)
69     if '\r' in code:
70         if os.name != 'nt':
71             yield 0, '\\r in code file'
72         code = code.replace('\r', '')
73     try:
74         compile(code, fn, 'exec')
75     except SyntaxError as err:
76         yield err.lineno, 'not compilable: %s' % err
77
78
79 @checker('.rst', severity=2)
80 def check_suspicious_constructs(fn, lines):
81     """Check for suspicious reST constructs."""
82     inprod = False
83     for lno, line in enumerate(lines):
84         if seems_directive_re.match(line):
85             yield lno+1, 'comment seems to be intended as a directive'
86         if '.. productionlist::' in line:
87             inprod = True
88         elif not inprod and default_role_re.search(line):
89             yield lno+1, 'default role used'
90         elif inprod and not line.strip():
91             inprod = False
92
93
94 @checker('.py', '.rst')
95 def check_whitespace(fn, lines):
96     """Check for whitespace and line length issues."""
97     for lno, line in enumerate(lines):
98         if '\r' in line:
99             yield lno+1, '\\r in line'
100         if '\t' in line:
101             yield lno+1, 'OMG TABS!!!1'
102         if line[:-1].rstrip(' \t') != line[:-1]:
103             yield lno+1, 'trailing whitespace'
104
105
106 @checker('.rst', severity=0)
107 def check_line_length(fn, lines):
108     """Check for line length; this checker is not run by default."""
109     for lno, line in enumerate(lines):
110         if len(line) > 81:
111             # don't complain about tables, links and function signatures
112             if line.lstrip()[0] not in '+|' and \
113                'http://' not in line and \
114                not line.lstrip().startswith(('.. function',
115                                              '.. method',
116                                              '.. cfunction')):
117                 yield lno+1, "line too long"
118
119
120 @checker('.html', severity=2, falsepositives=True)
121 def check_leaked_markup(fn, lines):
122     """Check HTML files for leaked reST markup; this only works if
123     the HTML files have been built.
124     """
125     for lno, line in enumerate(lines):
126         if leaked_markup_re.search(line):
127             yield lno+1, 'possibly leaked markup: %r' % line
128
129
130 def main(argv):
131     usage = '''\
132 Usage: %s [-v] [-f] [-s sev] [-i path]* [path]
133
134 Options:  -v       verbose (print all checked file names)
135           -f       enable checkers that yield many false positives
136           -s sev   only show problems with severity >= sev
137           -i path  ignore subdir or file path
138 ''' % argv[0]
139     try:
140         gopts, args = getopt.getopt(argv[1:], 'vfs:i:')
141     except getopt.GetoptError:
142         print(usage)
143         return 2
144
145     verbose = False
146     severity = 1
147     ignore = []
148     falsepos = False
149     for opt, val in gopts:
150         if opt == '-v':
151             verbose = True
152         elif opt == '-f':
153             falsepos = True
154         elif opt == '-s':
155             severity = int(val)
156         elif opt == '-i':
157             ignore.append(abspath(val))
158
159     if len(args) == 0:
160         path = '.'
161     elif len(args) == 1:
162         path = args[0]
163     else:
164         print(usage)
165         return 2
166
167     if not exists(path):
168         print('Error: path %s does not exist' % path)
169         return 2
170
171     count = defaultdict(int)
172
173     for root, dirs, files in os.walk(path):
174         # ignore subdirs controlled by svn
175         if '.svn' in dirs:
176             dirs.remove('.svn')
177
178         # ignore subdirs in ignore list
179         if abspath(root) in ignore:
180             del dirs[:]
181             continue
182
183         for fn in files:
184             fn = join(root, fn)
185             if fn[:2] == './':
186                 fn = fn[2:]
187
188             # ignore files in ignore list
189             if abspath(fn) in ignore:
190                 continue
191
192             ext = splitext(fn)[1]
193             checkerlist = checkers.get(ext, None)
194             if not checkerlist:
195                 continue
196
197             if verbose:
198                 print('Checking %s...' % fn)
199
200             try:
201                 with open(fn, 'r') as f:
202                     lines = list(f)
203             except (IOError, OSError) as err:
204                 print('%s: cannot open: %s' % (fn, err))
205                 count[4] += 1
206                 continue
207
208             for checker in checkerlist:
209                 if checker.falsepositives and not falsepos:
210                     continue
211                 csev = checker.severity
212                 if csev >= severity:
213                     for lno, msg in checker(fn, lines):
214                         print('[%d] %s:%d: %s' % (csev, fn, lno, msg))
215                         count[csev] += 1
216     if verbose:
217         print()
218     if not count:
219         if severity > 1:
220             print('No problems with severity >= %d found.' % severity)
221         else:
222             print('No problems found.')
223     else:
224         for severity in sorted(count):
225             number = count[severity]
226             print('%d problem%s with severity %d found.' %
227                   (number, number > 1 and 's' or '', severity))
228     return int(bool(count))
229
230
231 if __name__ == '__main__':
232     sys.exit(main(sys.argv))