66import pathlib
77import sys
88import traceback
9+ import typing as t
910
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
1712from yamllint import linter
1813from yamllint .config import YamlLintConfig
1914from yamllint .linter import PROBLEM_LEVELS
4439}
4540
4641
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- raise nodes .SkipNode
119-
120- language = node .attributes ["ansible-code-language" ]
121- lineno = node .attributes ["ansible-code-lineno" ]
122-
123- # Ok, we have to find both the row and the column offset for the actual code content
124- row_offset = lineno
125- found_empty_line = False
126- found_content_lines = False
127- content_lines = node .rawsource .count ("\n " ) + 1
128- min_indent = None
129- for offset , line in enumerate (self .__content_lines [lineno :]):
130- stripped_line = line .strip ()
131- if not stripped_line :
132- if not found_empty_line :
133- row_offset = lineno + offset + 1
134- found_empty_line = True
135- elif not found_content_lines :
136- found_content_lines = True
137- row_offset = lineno + offset
138-
139- if found_content_lines and content_lines > 0 :
140- if stripped_line :
141- indent = len (line ) - len (line .lstrip ())
142- if min_indent is None or min_indent > indent :
143- min_indent = indent
144- content_lines -= 1
145- elif not content_lines :
146- break
147-
148- min_source_indent = None
149- for line in node .rawsource .split ("\n " ):
150- stripped_line = line .lstrip ()
151- if stripped_line :
152- indent = len (line ) - len (line .lstrip ())
153- if min_source_indent is None or min_source_indent > indent :
154- min_source_indent = indent
155-
156- col_offset = max (0 , (min_indent or 0 ) - (min_source_indent or 0 ))
157-
158- # Now that we have the offsets, we can actually do some processing...
159- if language not in {"YAML" , "yaml" , "yaml+jinja" , "YAML+Jinja" }:
160- if language is None :
161- allowed_languages = ", " .join (sorted (ALLOWED_LANGUAGES ))
162- self .__results .append (
163- {
164- "path" : self .__path ,
165- "line" : row_offset + 1 ,
166- "col" : col_offset + 1 ,
167- "message" : (
168- "Literal block without language!"
169- f" Allowed languages are: { allowed_languages } ."
170- ),
171- }
172- )
173- return
174- if language not in ALLOWED_LANGUAGES :
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- f"Warning: literal block with disallowed language: { language } ."
183- " If the language should be allowed, the checker needs to be updated."
184- f" Currently allowed languages are: { allowed_languages } ."
185- ),
186- }
187- )
188- raise nodes .SkipNode
189-
190- # So we have YAML. Let's lint it!
191- try :
192- problems = linter .run (
193- io .StringIO (node .rawsource .rstrip () + "\n " ),
194- self .__yamllint_config ,
195- self .__path ,
196- )
197- for problem in problems :
198- if problem .level not in REPORT_LEVELS :
199- continue
200- msg = f"{ problem .level } : { problem .desc } "
201- if problem .rule :
202- msg += f" ({ problem .rule } )"
203- self .__results .append (
204- {
205- "path" : self .__path ,
206- "line" : row_offset + problem .line ,
207- "col" : col_offset + problem .column ,
208- "message" : msg ,
209- }
210- )
211- except Exception as exc :
212- error = str (exc ).replace ("\n " , " / " )
213- self .__results .append (
214- {
215- "path" : self .__path ,
216- "line" : row_offset + 1 ,
217- "col" : col_offset + 1 ,
218- "message" : (
219- f"Internal error while linting YAML: exception { type (exc )} :"
220- f" { error } ; traceback: { traceback .format_exc ()!r} "
221- ),
222- }
223- )
224-
225- raise nodes .SkipNode
226-
227-
228- def main ():
42+ def create_warn_unknown_block (
43+ results : list [dict [str , t .Any ]], path : str
44+ ) -> t .Callable [[int | str , int , str ], None ]:
45+ def warn_unknown_block (line : int | str , col : int , content : str ) -> None :
46+ results .append (
47+ {
48+ "path" : path ,
49+ "line" : line ,
50+ "col" : col ,
51+ "message" : (
52+ "Warning: found unknown literal block! Check for double colons '::'."
53+ " If that is not the cause, please report this warning."
54+ " It might indicate a bug in the checker or an unsupported Sphinx directive."
55+ f" Content: { content !r} "
56+ ),
57+ }
58+ )
59+
60+ return warn_unknown_block
61+
62+
63+ def main () -> None :
22964 paths = sys .argv [1 :] or sys .stdin .read ().splitlines ()
230- results = []
231-
232- for directive in (
233- "code" ,
234- "code-block" ,
235- "sourcecode" ,
236- ):
237- register_directive (directive , CodeBlockDirective )
238-
239- # The following docutils directives should better be ignored:
240- for directive in ("parsed-literal" ,):
241- register_directive (directive , IgnoreDirective )
65+ results : list [dict [str , t .Any ]] = []
24266
24367 # TODO: should we handle the 'literalinclude' directive? maybe check file directly if right extension?
24468 # (https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-literalinclude)
@@ -253,56 +77,84 @@ def main():
25377 with open (path , "rt" , encoding = "utf-8" ) as f :
25478 content = f .read ()
25579
256- # We create a Publisher only to have a mechanism which gives us the settings object.
257- # Doing this more explicit is a bad idea since the classes used are deprecated and will
258- # eventually get replaced. Publisher.get_settings() looks like a stable enough API that
259- # we can 'just use'.
260- publisher = Publisher (source_class = StringInput )
261- publisher .set_components ("standalone" , "restructuredtext" , "pseudoxml" )
262- override = {
263- "root_prefix" : docs_root ,
264- "input_encoding" : "utf-8" ,
265- "file_insertion_enabled" : False ,
266- "raw_enabled" : False ,
267- "_disable_config" : True ,
268- "report_level" : Reporter .ERROR_LEVEL ,
269- "warning_stream" : io .StringIO (),
270- }
271- publisher .process_programmatic_settings (None , override , None )
272- publisher .set_source (content , path )
273-
274- # Parse the document
27580 try :
276- doc = publisher .reader .read (
277- publisher .source , publisher .parser , publisher .settings
278- )
279- except SystemMessage as exc :
280- error = str (exc ).replace ("\n " , " / " )
281- results .append (
282- {
283- "path" : path ,
284- "line" : 0 ,
285- "col" : 0 ,
286- "message" : f"Cannot parse document: { error } " ,
287- }
288- )
289- continue
290- except Exception as exc :
291- error = str (exc ).replace ("\n " , " / " )
292- results .append (
293- {
294- "path" : path ,
295- "line" : 0 ,
296- "col" : 0 ,
297- "message" : f"Cannot parse document, unexpected error { type (exc )} : { error } ; traceback: { traceback .format_exc ()!r} " ,
298- }
299- )
300- continue
81+ for code_block in find_code_blocks (
82+ content ,
83+ path = path ,
84+ root_prefix = docs_root ,
85+ warn_unknown_block = create_warn_unknown_block (results , path ),
86+ ):
87+ # Now that we have the offsets, we can actually do some processing...
88+ if code_block .language not in {
89+ "YAML" ,
90+ "yaml" ,
91+ "yaml+jinja" ,
92+ "YAML+Jinja" ,
93+ }:
94+ if code_block .language is None :
95+ allowed_languages = ", " .join (sorted (ALLOWED_LANGUAGES ))
96+ results .append (
97+ {
98+ "path" : path ,
99+ "line" : code_block .row_offset + 1 ,
100+ "col" : code_block .col_offset + 1 ,
101+ "message" : (
102+ "Literal block without language!"
103+ f" Allowed languages are: { allowed_languages } ."
104+ ),
105+ }
106+ )
107+ return
108+ if code_block .language not in ALLOWED_LANGUAGES :
109+ allowed_languages = ", " .join (sorted (ALLOWED_LANGUAGES ))
110+ results .append (
111+ {
112+ "path" : path ,
113+ "line" : code_block .row_offset + 1 ,
114+ "col" : code_block .col_offset + 1 ,
115+ "message" : (
116+ f"Warning: literal block with disallowed language: { code_block .language } ."
117+ " If the language should be allowed, the checker needs to be updated."
118+ f" Currently allowed languages are: { allowed_languages } ."
119+ ),
120+ }
121+ )
122+ continue
301123
302- # Process the document
303- try :
304- visitor = YamlLintVisitor (doc , path , results , content , yamllint_config )
305- doc .walk (visitor )
124+ # So we have YAML. Let's lint it!
125+ try :
126+ problems = linter .run (
127+ io .StringIO (code_block .content ),
128+ yamllint_config ,
129+ path ,
130+ )
131+ for problem in problems :
132+ if problem .level not in REPORT_LEVELS :
133+ continue
134+ msg = f"{ problem .level } : { problem .desc } "
135+ if problem .rule :
136+ msg += f" ({ problem .rule } )"
137+ results .append (
138+ {
139+ "path" : path ,
140+ "line" : code_block .row_offset + problem .line ,
141+ "col" : code_block .col_offset + problem .column ,
142+ "message" : msg ,
143+ }
144+ )
145+ except Exception as exc :
146+ error = str (exc ).replace ("\n " , " / " )
147+ results .append (
148+ {
149+ "path" : path ,
150+ "line" : code_block .row_offset + 1 ,
151+ "col" : code_block .col_offset + 1 ,
152+ "message" : (
153+ f"Internal error while linting YAML: exception { type (exc )} :"
154+ f" { error } ; traceback: { traceback .format_exc ()!r} "
155+ ),
156+ }
157+ )
306158 except Exception as exc :
307159 error = str (exc ).replace ("\n " , " / " )
308160 results .append (
0 commit comments