Added more cross-reference targets and tidied up list of useful handlers.
[python.git] / Doc / tools / rstlint.py
blob0c19003b0fb5ef3c6df5fc5c740e4331be98716d
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
4 # Check for stylistic and formal issues in .rst and .py
5 # files included in the documentation.
7 # 01/2009, Georg Brandl
9 # TODO: - wrong versions in versionadded/changed
10 # - wrong markup after versionchanged directive
12 from __future__ import with_statement
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
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'
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+:')
50 checkers = {}
52 checker_props = {'severity': 1, 'falsepositives': False}
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
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, err:
76 yield err.lineno, 'not compilable: %s' % err
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
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'
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"
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.
125 for lno, line in enumerate(lines):
126 if leaked_markup_re.search(line):
127 yield lno+1, 'possibly leaked markup: %r' % line
130 def main(argv):
131 usage = '''\
132 Usage: %s [-v] [-f] [-s sev] [-i path]* [path]
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
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))
159 if len(args) == 0:
160 path = '.'
161 elif len(args) == 1:
162 path = args[0]
163 else:
164 print usage
165 return 2
167 if not exists(path):
168 print 'Error: path %s does not exist' % path
169 return 2
171 count = defaultdict(int)
172 out = sys.stdout
174 for root, dirs, files in os.walk(path):
175 # ignore subdirs controlled by svn
176 if '.svn' in dirs:
177 dirs.remove('.svn')
179 # ignore subdirs in ignore list
180 if abspath(root) in ignore:
181 del dirs[:]
182 continue
184 for fn in files:
185 fn = join(root, fn)
186 if fn[:2] == './':
187 fn = fn[2:]
189 # ignore files in ignore list
190 if abspath(fn) in ignore:
191 continue
193 ext = splitext(fn)[1]
194 checkerlist = checkers.get(ext, None)
195 if not checkerlist:
196 continue
198 if verbose:
199 print 'Checking %s...' % fn
201 try:
202 with open(fn, 'r') as f:
203 lines = list(f)
204 except (IOError, OSError), err:
205 print '%s: cannot open: %s' % (fn, err)
206 count[4] += 1
207 continue
209 for checker in checkerlist:
210 if checker.falsepositives and not falsepos:
211 continue
212 csev = checker.severity
213 if csev >= severity:
214 for lno, msg in checker(fn, lines):
215 print >>out, '[%d] %s:%d: %s' % (csev, fn, lno, msg)
216 count[csev] += 1
217 if verbose:
218 print
219 if not count:
220 if severity > 1:
221 print 'No problems with severity >= %d found.' % severity
222 else:
223 print 'No problems found.'
224 else:
225 for severity in sorted(count):
226 number = count[severity]
227 print '%d problem%s with severity %d found.' % \
228 (number, number > 1 and 's' or '', severity)
229 return int(bool(count))
232 if __name__ == '__main__':
233 sys.exit(main(sys.argv))