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- 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+ }
21061 )
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 (
22865 {
229- "path" : self . __path ,
230- "line" : row_offset + 1 ,
231- "col" : col_offset + 1 ,
66+ "path" : path ,
67+ "line" : line ,
68+ "col" : 0 ,
23269 "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 } "
23573 ),
23674 }
23775 )
23876
239- raise nodes . SkipNode
77+ return warn_unknown_block
24078
24179
242- def main ():
80+ def main () -> None :
24381 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 ]] = []
25683
25784 # TODO: should we handle the 'literalinclude' directive? maybe check file directly if right extension?
25885 # (https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-literalinclude)
@@ -267,56 +94,86 @@ def main():
26794 with open (path , "rt" , encoding = "utf-8" ) as f :
26895 content = f .read ()
26996
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
28997 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
315142
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+ )
320177 except Exception as exc :
321178 error = str (exc ).replace ("\n " , " / " )
322179 results .append (
0 commit comments