6
6
import pathlib
7
7
import sys
8
8
import traceback
9
+ import typing as t
9
10
10
- from docutils import nodes
11
- from docutils .core import Publisher
12
- from docutils .io import StringInput
13
- from docutils .parsers .rst import Directive
14
- from docutils .parsers .rst .directives import register_directive
15
- from docutils .parsers .rst .directives import unchanged as directive_param_unchanged
16
- from docutils .utils import Reporter , SystemMessage
11
+ from antsibull_docutils .rst_code_finder import find_code_blocks
17
12
from yamllint import linter
18
13
from yamllint .config import YamlLintConfig
19
14
from yamllint .linter import PROBLEM_LEVELS
44
39
}
45
40
46
41
47
- class IgnoreDirective (Directive ):
48
- has_content = True
49
-
50
- def run (self ) -> list :
51
- return []
52
-
53
-
54
- class CodeBlockDirective (Directive ):
55
- has_content = True
56
- optional_arguments = 1
57
-
58
- # These are all options Sphinx allows for code blocks.
59
- # We need to have them here so that docutils successfully parses this extension.
60
- option_spec = {
61
- "caption" : directive_param_unchanged ,
62
- "class" : directive_param_unchanged ,
63
- "dedent" : directive_param_unchanged ,
64
- "emphasize-lines" : directive_param_unchanged ,
65
- "name" : directive_param_unchanged ,
66
- "force" : directive_param_unchanged ,
67
- "linenos" : directive_param_unchanged ,
68
- "lineno-start" : directive_param_unchanged ,
69
- }
70
-
71
- def run (self ) -> list [nodes .literal_block ]:
72
- code = "\n " .join (self .content )
73
- literal = nodes .literal_block (code , code )
74
- literal ["classes" ].append ("code-block" )
75
- literal ["ansible-code-language" ] = self .arguments [0 ] if self .arguments else None
76
- literal ["ansible-code-block" ] = True
77
- literal ["ansible-code-lineno" ] = self .lineno
78
- return [literal ]
79
-
80
-
81
- class YamlLintVisitor (nodes .SparseNodeVisitor ):
82
- def __init__ (
83
- self ,
84
- document : nodes .document ,
85
- path : str ,
86
- results : list [dict ],
87
- content : str ,
88
- yamllint_config : YamlLintConfig ,
89
- ):
90
- super ().__init__ (document )
91
- self .__path = path
92
- self .__results = results
93
- self .__content_lines = content .splitlines ()
94
- self .__yamllint_config = yamllint_config
95
-
96
- def visit_system_message (self , node : nodes .system_message ) -> None :
97
- raise nodes .SkipNode
98
-
99
- def visit_error (self , node : nodes .error ) -> None :
100
- raise nodes .SkipNode
101
-
102
- def visit_literal_block (self , node : nodes .literal_block ) -> None :
103
- if "ansible-code-block" not in node .attributes :
104
- if node .attributes ["classes" ]:
105
- self .__results .append (
106
- {
107
- "path" : self .__path ,
108
- "line" : node .line or "unknown" ,
109
- "col" : 0 ,
110
- "message" : (
111
- "Warning: found unknown literal block! Check for double colons '::'."
112
- " If that is not the cause, please report this warning."
113
- " It might indicate a bug in the checker or an unsupported Sphinx directive."
114
- f" Node: { node !r} ; attributes: { node .attributes } ; content: { node .rawsource !r} "
115
- ),
116
- }
117
- )
118
- else :
119
- allowed_languages = ", " .join (sorted (ALLOWED_LANGUAGES ))
120
- self .__results .append (
121
- {
122
- "path" : self .__path ,
123
- "line" : node .line or "unknown" ,
124
- "col" : 0 ,
125
- "message" : (
126
- "Warning: literal block (check for double colons '::')."
127
- " Please convert this to a regular code block with an appropriate language."
128
- f" Allowed languages: { allowed_languages } "
129
- ),
130
- }
131
- )
132
- raise nodes .SkipNode
133
-
134
- language = node .attributes ["ansible-code-language" ]
135
- lineno = node .attributes ["ansible-code-lineno" ]
136
-
137
- # Ok, we have to find both the row and the column offset for the actual code content
138
- row_offset = lineno
139
- found_empty_line = False
140
- found_content_lines = False
141
- content_lines = node .rawsource .count ("\n " ) + 1
142
- min_indent = None
143
- for offset , line in enumerate (self .__content_lines [lineno :]):
144
- stripped_line = line .strip ()
145
- if not stripped_line :
146
- if not found_empty_line :
147
- row_offset = lineno + offset + 1
148
- found_empty_line = True
149
- elif not found_content_lines :
150
- found_content_lines = True
151
- row_offset = lineno + offset
152
-
153
- if found_content_lines and content_lines > 0 :
154
- if stripped_line :
155
- indent = len (line ) - len (line .lstrip ())
156
- if min_indent is None or min_indent > indent :
157
- min_indent = indent
158
- content_lines -= 1
159
- elif not content_lines :
160
- break
161
-
162
- min_source_indent = None
163
- for line in node .rawsource .split ("\n " ):
164
- stripped_line = line .lstrip ()
165
- if stripped_line :
166
- indent = len (line ) - len (line .lstrip ())
167
- if min_source_indent is None or min_source_indent > indent :
168
- min_source_indent = indent
169
-
170
- col_offset = max (0 , (min_indent or 0 ) - (min_source_indent or 0 ))
171
-
172
- # Now that we have the offsets, we can actually do some processing...
173
- if language not in {"YAML" , "yaml" , "yaml+jinja" , "YAML+Jinja" }:
174
- if language is None :
175
- allowed_languages = ", " .join (sorted (ALLOWED_LANGUAGES ))
176
- self .__results .append (
177
- {
178
- "path" : self .__path ,
179
- "line" : row_offset + 1 ,
180
- "col" : col_offset + 1 ,
181
- "message" : (
182
- "Literal block without language!"
183
- f" Allowed languages are: { allowed_languages } ."
184
- ),
185
- }
186
- )
187
- return
188
- if language not in ALLOWED_LANGUAGES :
189
- allowed_languages = ", " .join (sorted (ALLOWED_LANGUAGES ))
190
- self .__results .append (
191
- {
192
- "path" : self .__path ,
193
- "line" : row_offset + 1 ,
194
- "col" : col_offset + 1 ,
195
- "message" : (
196
- f"Warning: literal block with disallowed language: { language } ."
197
- " If the language should be allowed, the checker needs to be updated."
198
- f" Currently allowed languages are: { allowed_languages } ."
199
- ),
200
- }
201
- )
202
- raise nodes .SkipNode
203
-
204
- # So we have YAML. Let's lint it!
205
- try :
206
- problems = linter .run (
207
- io .StringIO (node .rawsource .rstrip () + "\n " ),
208
- self .__yamllint_config ,
209
- self .__path ,
42
+ def create_warn_unknown_block (
43
+ results : list [dict [str , t .Any ]], path : str
44
+ ) -> t .Callable [[int | str , int , str , bool ], None ]:
45
+ def warn_unknown_block (
46
+ line : int | str , col : int , content : str , unknown_directive : bool
47
+ ) -> None :
48
+ if unknown_directive :
49
+ results .append (
50
+ {
51
+ "path" : path ,
52
+ "line" : line ,
53
+ "col" : col ,
54
+ "message" : (
55
+ "Warning: found unknown literal block! Check for double colons '::'."
56
+ " If that is not the cause, please report this warning."
57
+ " It might indicate a bug in the checker or an unsupported Sphinx directive."
58
+ f" Content: { content !r} "
59
+ ),
60
+ }
210
61
)
211
- for problem in problems :
212
- if problem .level not in REPORT_LEVELS :
213
- continue
214
- msg = f"{ problem .level } : { problem .desc } "
215
- if problem .rule :
216
- msg += f" ({ problem .rule } )"
217
- self .__results .append (
218
- {
219
- "path" : self .__path ,
220
- "line" : row_offset + problem .line ,
221
- "col" : col_offset + problem .column ,
222
- "message" : msg ,
223
- }
224
- )
225
- except Exception as exc :
226
- error = str (exc ).replace ("\n " , " / " )
227
- self .__results .append (
62
+ else :
63
+ allowed_languages = ", " .join (sorted (ALLOWED_LANGUAGES ))
64
+ results .append (
228
65
{
229
- "path" : self . __path ,
230
- "line" : row_offset + 1 ,
231
- "col" : col_offset + 1 ,
66
+ "path" : path ,
67
+ "line" : line ,
68
+ "col" : 0 ,
232
69
"message" : (
233
- f"Internal error while linting YAML: exception { type (exc )} :"
234
- f" { error } ; traceback: { traceback .format_exc ()!r} "
70
+ "Warning: literal block (check for double colons '::')."
71
+ " Please convert this to a regular code block with an appropriate language."
72
+ f" Allowed languages: { allowed_languages } "
235
73
),
236
74
}
237
75
)
238
76
239
- raise nodes . SkipNode
77
+ return warn_unknown_block
240
78
241
79
242
- def main ():
80
+ def main () -> None :
243
81
paths = sys .argv [1 :] or sys .stdin .read ().splitlines ()
244
- results = []
245
-
246
- for directive in (
247
- "code" ,
248
- "code-block" ,
249
- "sourcecode" ,
250
- ):
251
- register_directive (directive , CodeBlockDirective )
252
-
253
- # The following docutils directives should better be ignored:
254
- for directive in ("parsed-literal" ,):
255
- register_directive (directive , IgnoreDirective )
82
+ results : list [dict [str , t .Any ]] = []
256
83
257
84
# TODO: should we handle the 'literalinclude' directive? maybe check file directly if right extension?
258
85
# (https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-literalinclude)
@@ -267,56 +94,86 @@ def main():
267
94
with open (path , "rt" , encoding = "utf-8" ) as f :
268
95
content = f .read ()
269
96
270
- # We create a Publisher only to have a mechanism which gives us the settings object.
271
- # Doing this more explicit is a bad idea since the classes used are deprecated and will
272
- # eventually get replaced. Publisher.get_settings() looks like a stable enough API that
273
- # we can 'just use'.
274
- publisher = Publisher (source_class = StringInput )
275
- publisher .set_components ("standalone" , "restructuredtext" , "pseudoxml" )
276
- override = {
277
- "root_prefix" : docs_root ,
278
- "input_encoding" : "utf-8" ,
279
- "file_insertion_enabled" : False ,
280
- "raw_enabled" : False ,
281
- "_disable_config" : True ,
282
- "report_level" : Reporter .ERROR_LEVEL ,
283
- "warning_stream" : io .StringIO (),
284
- }
285
- publisher .process_programmatic_settings (None , override , None )
286
- publisher .set_source (content , path )
287
-
288
- # Parse the document
289
97
try :
290
- doc = publisher .reader .read (
291
- publisher .source , publisher .parser , publisher .settings
292
- )
293
- except SystemMessage as exc :
294
- error = str (exc ).replace ("\n " , " / " )
295
- results .append (
296
- {
297
- "path" : path ,
298
- "line" : 0 ,
299
- "col" : 0 ,
300
- "message" : f"Cannot parse document: { error } " ,
301
- }
302
- )
303
- continue
304
- except Exception as exc :
305
- error = str (exc ).replace ("\n " , " / " )
306
- results .append (
307
- {
308
- "path" : path ,
309
- "line" : 0 ,
310
- "col" : 0 ,
311
- "message" : f"Cannot parse document, unexpected error { type (exc )} : { error } ; traceback: { traceback .format_exc ()!r} " ,
312
- }
313
- )
314
- continue
98
+ for code_block in find_code_blocks (
99
+ content ,
100
+ path = path ,
101
+ root_prefix = docs_root ,
102
+ warn_unknown_block_w_unknown_info = create_warn_unknown_block (
103
+ results , path
104
+ ),
105
+ ):
106
+ # Now that we have the offsets, we can actually do some processing...
107
+ if code_block .language not in {
108
+ "YAML" ,
109
+ "yaml" ,
110
+ "yaml+jinja" ,
111
+ "YAML+Jinja" ,
112
+ }:
113
+ if code_block .language is None :
114
+ allowed_languages = ", " .join (sorted (ALLOWED_LANGUAGES ))
115
+ results .append (
116
+ {
117
+ "path" : path ,
118
+ "line" : code_block .row_offset + 1 ,
119
+ "col" : code_block .col_offset + 1 ,
120
+ "message" : (
121
+ "Literal block without language!"
122
+ f" Allowed languages are: { allowed_languages } ."
123
+ ),
124
+ }
125
+ )
126
+ return
127
+ if code_block .language not in ALLOWED_LANGUAGES :
128
+ allowed_languages = ", " .join (sorted (ALLOWED_LANGUAGES ))
129
+ results .append (
130
+ {
131
+ "path" : path ,
132
+ "line" : code_block .row_offset + 1 ,
133
+ "col" : code_block .col_offset + 1 ,
134
+ "message" : (
135
+ f"Warning: literal block with disallowed language: { code_block .language } ."
136
+ " If the language should be allowed, the checker needs to be updated."
137
+ f" Currently allowed languages are: { allowed_languages } ."
138
+ ),
139
+ }
140
+ )
141
+ continue
315
142
316
- # Process the document
317
- try :
318
- visitor = YamlLintVisitor (doc , path , results , content , yamllint_config )
319
- doc .walk (visitor )
143
+ # So we have YAML. Let's lint it!
144
+ try :
145
+ problems = linter .run (
146
+ io .StringIO (code_block .content ),
147
+ yamllint_config ,
148
+ path ,
149
+ )
150
+ for problem in problems :
151
+ if problem .level not in REPORT_LEVELS :
152
+ continue
153
+ msg = f"{ problem .level } : { problem .desc } "
154
+ if problem .rule :
155
+ msg += f" ({ problem .rule } )"
156
+ results .append (
157
+ {
158
+ "path" : path ,
159
+ "line" : code_block .row_offset + problem .line ,
160
+ "col" : code_block .col_offset + problem .column ,
161
+ "message" : msg ,
162
+ }
163
+ )
164
+ except Exception as exc :
165
+ error = str (exc ).replace ("\n " , " / " )
166
+ results .append (
167
+ {
168
+ "path" : path ,
169
+ "line" : code_block .row_offset + 1 ,
170
+ "col" : code_block .col_offset + 1 ,
171
+ "message" : (
172
+ f"Internal error while linting YAML: exception { type (exc )} :"
173
+ f" { error } ; traceback: { traceback .format_exc ()!r} "
174
+ ),
175
+ }
176
+ )
320
177
except Exception as exc :
321
178
error = str (exc ).replace ("\n " , " / " )
322
179
results .append (
0 commit comments