1cf5d019b7fb90f034a2d7931db602987ad2671a
[nit.git] / src / testing / testing_doc.nit
1 # This file is part of NIT ( http://www.nitlanguage.org ).
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 # Testing from code comments.
16 module testing_doc
17
18 private import parser_util
19 import testing_base
20 import markdown
21 import html
22
23 # Extractor, Executor and Reporter for the tests in a module
24 class NitUnitExecutor
25 super HTMLDecorator
26
27 # Toolcontext used to parse Nit code blocks.
28 var toolcontext: ToolContext
29
30 # The prefix of the generated Nit source-file
31 var prefix: String
32
33 # The module to import, if any
34 var mmodule: nullable MModule
35
36 # The XML node associated to the module
37 var testsuite: HTMLTag
38
39 # The current test-case xml element
40 var tc: HTMLTag is noautoinit
41
42 # All failures from a same `ADoc`
43 var failures = new Array[String]
44
45 # Markdown processor used to parse markdown comments and extract code.
46 var mdproc = new MarkdownProcessor
47
48 init do
49 mdproc.emitter.decorator = new NitunitDecorator(self)
50 end
51
52 # The associated documentation object
53 var mdoc: nullable MDoc = null
54
55 # used to generate distinct names
56 var cpt = 0
57
58 # The last docunit extracted from a mdoc.
59 #
60 # Is used because a new code-block might just be added to it.
61 var last_docunit: nullable DocUnit = null
62
63 # The entry point for a new `ndoc` node
64 # Fill `docunits` with new discovered unit of tests.
65 #
66 # `tc` (testcase) is the pre-filled XML node
67 fun extract(mdoc: MDoc, tc: HTMLTag)
68 do
69 last_docunit = null
70 failures.clear
71 self.tc = tc
72
73 self.mdoc = mdoc
74
75 # Populate `blocks` from the markdown decorator
76 mdproc.process(mdoc.content.join("\n"))
77
78 toolcontext.check_errors
79
80 if not failures.is_empty then
81 for msg in failures do
82 var ne = new HTMLTag("failure")
83 ne.attr("message", msg)
84 tc.add ne
85 toolcontext.modelbuilder.unit_entities += 1
86 toolcontext.modelbuilder.failed_entities += 1
87 end
88 if last_docunit == null then testsuite.add(tc)
89 end
90 end
91
92 # All extracted docunits
93 var docunits = new Array[DocUnit]
94
95 # Execute all the docunits
96 fun run_tests
97 do
98 var simple_du = new Array[DocUnit]
99 for du in docunits do
100 var ast = toolcontext.parse_something(du.block)
101 if ast isa AExpr then
102 simple_du.add du
103 else
104 test_single_docunit(du)
105 end
106 end
107
108 test_simple_docunits(simple_du)
109 end
110
111 # Executes multiples doc-units in a shared program.
112 # Used for docunits simple block of code (without modules, classes, functions etc.)
113 #
114 # In case of compilation error, the method fallbacks to `test_single_docunit` to
115 # * locate exactly the compilation problem in the problematic docunit.
116 # * permit the execution of the other docunits that may be correct.
117 fun test_simple_docunits(dus: Array[DocUnit])
118 do
119 if dus.is_empty then return
120
121 var file = "{prefix}-0.nit"
122
123 var dir = file.dirname
124 if dir != "" then dir.mkdir
125 var f
126 f = create_unitfile(file)
127 var i = 0
128 for du in dus do
129
130 i += 1
131 f.write("fun run_{i} do\n")
132 f.write("# {du.testcase.attrs["name"]}\n")
133 f.write(du.block)
134 f.write("end\n")
135 end
136 f.write("var a = args.first.to_i\n")
137 for j in [1..i] do
138 f.write("if a == {j} then run_{j}\n")
139 end
140 f.close
141
142 if toolcontext.opt_noact.value then return
143
144 var res = compile_unitfile(file)
145
146 if res != 0 then
147 # Compilation error.
148 # Fall-back to individual modes:
149 for du in dus do
150 test_single_docunit(du)
151 end
152 return
153 end
154
155 i = 0
156 for du in dus do
157 var tc = du.testcase
158 toolcontext.modelbuilder.unit_entities += 1
159 i += 1
160 toolcontext.info("Execute doc-unit {du.testcase.attrs["name"]} in {file} {i}", 1)
161 var res2 = toolcontext.safe_exec("{file.to_program_name}.bin {i} >'{file}.out1' 2>&1 </dev/null")
162
163 f = new FileReader.open("{file}.out1")
164 var n2
165 n2 = new HTMLTag("system-err")
166 tc.add n2
167 var content = f.read_all
168 var msg = content.trunc(8192).filter_nonprintable
169 n2.append(msg)
170 f.close
171
172 n2 = new HTMLTag("system-out")
173 tc.add n2
174 n2.append(du.block)
175
176 if res2 != 0 then
177 var ne = new HTMLTag("error")
178 ne.attr("message", "Runtime error")
179 tc.add ne
180 toolcontext.warning(du.location, "error", "ERROR: {tc.attrs["classname"]}.{tc.attrs["name"]} (in {file}): Runtime error\n{msg}")
181 toolcontext.modelbuilder.failed_entities += 1
182 end
183 toolcontext.check_errors
184
185 testsuite.add(tc)
186 end
187 end
188
189 # Executes a single doc-unit in its own program.
190 # Used for docunits larger than a single block of code (with modules, classes, functions etc.)
191 fun test_single_docunit(du: DocUnit)
192 do
193 var tc = du.testcase
194 toolcontext.modelbuilder.unit_entities += 1
195
196 cpt += 1
197 var file = "{prefix}-{cpt}.nit"
198
199 toolcontext.info("Execute doc-unit {tc.attrs["name"]} in {file}", 1)
200
201 var f
202 f = create_unitfile(file)
203 f.write(du.block)
204 f.close
205
206 if toolcontext.opt_noact.value then return
207
208 var res = compile_unitfile(file)
209 var res2 = 0
210 if res == 0 then
211 res2 = toolcontext.safe_exec("{file.to_program_name}.bin >'{file}.out1' 2>&1 </dev/null")
212 end
213
214 f = new FileReader.open("{file}.out1")
215 var n2
216 n2 = new HTMLTag("system-err")
217 tc.add n2
218 var content = f.read_all
219 var msg = content.trunc(8192).filter_nonprintable
220 n2.append(msg)
221 f.close
222
223 n2 = new HTMLTag("system-out")
224 tc.add n2
225 n2.append(du.block)
226
227
228 if res != 0 then
229 var ne = new HTMLTag("failure")
230 ne.attr("message", "Compilation Error")
231 tc.add ne
232 toolcontext.warning(du.location, "failure", "FAILURE: {tc.attrs["classname"]}.{tc.attrs["name"]} (in {file}):\n{msg}")
233 toolcontext.modelbuilder.failed_entities += 1
234 else if res2 != 0 then
235 var ne = new HTMLTag("error")
236 ne.attr("message", "Runtime Error")
237 tc.add ne
238 toolcontext.warning(du.location, "error", "ERROR: {tc.attrs["classname"]}.{tc.attrs["name"]} (in {file}):\n{msg}")
239 toolcontext.modelbuilder.failed_entities += 1
240 end
241 toolcontext.check_errors
242
243 testsuite.add(tc)
244 end
245
246 # Create and fill the header of a unit file `file`.
247 #
248 # A unit file is a Nit source file generated from one
249 # or more docunits that will be compiled and executed.
250 #
251 # The handled on the file is returned and must be completed and closed.
252 #
253 # `file` should be a valid filepath for a Nit source file.
254 private fun create_unitfile(file: String): Writer
255 do
256 var dir = file.dirname
257 if dir != "" then dir.mkdir
258 var f
259 f = new FileWriter.open(file)
260 f.write("# GENERATED FILE\n")
261 f.write("# Docunits extracted from comments\n")
262 if mmodule != null then
263 f.write("import {mmodule.name}\n")
264 end
265 f.write("\n")
266 return f
267 end
268
269 # Compile an unit file and return the compiler return code
270 #
271 # Can terminate the program if the compiler is not found
272 private fun compile_unitfile(file: String): Int
273 do
274 var nitc = toolcontext.find_nitc
275 var opts = new Array[String]
276 if mmodule != null then
277 opts.add "-I {mmodule.filepath.dirname}"
278 end
279 var cmd = "{nitc} --ignore-visibility --no-color '{file}' {opts.join(" ")} >'{file}.out1' 2>&1 </dev/null -o '{file}.bin'"
280 var res = toolcontext.safe_exec(cmd)
281 return res
282 end
283 end
284
285 private class NitunitDecorator
286 super HTMLDecorator
287
288 var executor: NitUnitExecutor
289
290 redef fun add_code(v, block) do
291 var code = block.raw_content
292 var meta = block.meta or else "nit"
293 # Do not try to test non-nit code.
294 if meta != "nit" then return
295 # Try to parse code blocks
296 var ast = executor.toolcontext.parse_something(code)
297
298 var mdoc = executor.mdoc
299 assert mdoc != null
300
301 # Skip pure comments
302 if ast isa TComment then return
303
304 # The location is computed according to the starts of the mdoc and the block
305 # Note, the following assumes that all the comments of the mdoc are correctly aligned.
306 var loc = block.block.location
307 var line_offset = loc.line_start + mdoc.location.line_start - 2
308 var column_offset = loc.column_start + mdoc.location.column_start
309 # Hack to handle precise location in blocks
310 # TODO remove when markdown is more reliable
311 if block isa BlockFence then
312 # Skip the starting fence
313 line_offset += 1
314 else
315 # Account a standard 4 space indentation
316 column_offset += 4
317 end
318
319 # We want executable code
320 if not (ast isa AModule or ast isa ABlockExpr or ast isa AExpr) then
321 var message
322 var l = ast.location
323 # Get real location of the node (or error)
324 var location = new Location(mdoc.location.file,
325 l.line_start + line_offset,
326 l.line_end + line_offset,
327 l.column_start + column_offset,
328 l.column_end + column_offset)
329 if ast isa AError then
330 message = ast.message
331 else
332 message = "Error: Invalid Nit code."
333 end
334
335 executor.toolcontext.warning(location, "invalid-block", "{message} To suppress this message, enclose the block with a fence tagged `nitish` or `raw` (see `man nitdoc`).")
336 executor.failures.add("{location}: {message}")
337 return
338 end
339
340 # Create a first block
341 # Or create a new block for modules that are more than a main part
342 var last_docunit = executor.last_docunit
343 if last_docunit == null or ast isa AModule then
344 last_docunit = new DocUnit(executor.mdoc.as(not null), executor.tc, "")
345 executor.docunits.add last_docunit
346 end
347
348 # Add it to the file
349 last_docunit.block += code
350
351 # In order to retrieve precise positions,
352 # the real position of each line of the raw_content is stored.
353 # See `DocUnit::real_location`
354 line_offset -= loc.line_start - 1
355 for i in [loc.line_start..loc.line_end] do
356 last_docunit.lines.add i + line_offset
357 last_docunit.columns.add column_offset
358 end
359 end
360 end
361
362 # A unit-test extracted from some documentation.
363 #
364 # A docunit is extracted from the code-blocks of mdocs.
365 # Each mdoc can contains more than one docunit, and a single docunit can be made of more that a single code-block.
366 class DocUnit
367 # The doc that contains self
368 var mdoc: MDoc
369
370 # The XML node that contains the information about the execution
371 var testcase: HTMLTag
372
373 # The text of the code to execute.
374 #
375 # This is the verbatim content on one, or more, code-blocks from `mdoc`
376 var block: String
377
378 # For each line in `block`, the associated line in the mdoc
379 #
380 # Is used to give precise locations
381 var lines = new Array[Int]
382
383 # For each line in `block`, the associated column in the mdoc
384 #
385 # Is used to give precise locations
386 var columns = new Array[Int]
387
388 # The location of the whole docunit.
389 #
390 # If `self` is made of multiple code-blocks, then the location
391 # starts at the first code-books and finish at the last one, thus includes anything between.
392 var location: Location is lazy do
393 return new Location(mdoc.location.file, lines.first, lines.last+1, columns.first+1, 0)
394 end
395
396 # Compute the real location of a node on the `ast` based on `mdoc.location`
397 #
398 # The result is basically: ast_location + markdown location of the piece + mdoc.location
399 #
400 # The fun is that a single docunit can be made of various pieces of code blocks.
401 fun real_location(ast_location: Location): Location
402 do
403 var mdoc = self.mdoc
404 var res = new Location(mdoc.location.file, lines[ast_location.line_start-1],
405 lines[ast_location.line_end-1],
406 columns[ast_location.line_start-1] + ast_location.column_start,
407 columns[ast_location.line_end-1] + ast_location.column_end)
408 return res
409 end
410 end
411
412 redef class ModelBuilder
413 # Total number analyzed `MEntity`
414 var total_entities = 0
415
416 # The number of `MEntity` that have some documentation
417 var doc_entities = 0
418
419 # The total number of executed docunits
420 var unit_entities = 0
421
422 # The number failed docunits
423 var failed_entities = 0
424
425 # Extracts and executes all the docunits in the `mmodule`
426 # Returns a JUnit-compatible `<testsuite>` XML element that contains the results of the executions.
427 fun test_markdown(mmodule: MModule): HTMLTag
428 do
429 var ts = new HTMLTag("testsuite")
430 toolcontext.info("nitunit: doc-unit {mmodule}", 2)
431
432 var nmodule = mmodule2node(mmodule)
433 if nmodule == null then return ts
434
435 # usualy, only the original module must be imported in the unit test.
436 var o = mmodule
437 var g = o.mgroup
438 if g != null and g.mpackage.name == "core" then
439 # except for a unit test in a module of `core`
440 # in this case, the whole `core` must be imported
441 o = get_mmodule_by_name(nmodule, g, g.mpackage.name).as(not null)
442 end
443
444 ts.attr("package", mmodule.full_name)
445
446 var prefix = toolcontext.test_dir
447 prefix = prefix.join_path(mmodule.to_s)
448 var d2m = new NitUnitExecutor(toolcontext, prefix, o, ts)
449
450 var tc
451
452 do
453 total_entities += 1
454 var nmoduledecl = nmodule.n_moduledecl
455 if nmoduledecl == null then break label x
456 var ndoc = nmoduledecl.n_doc
457 if ndoc == null then break label x
458 doc_entities += 1
459 tc = new HTMLTag("testcase")
460 # NOTE: jenkins expects a '.' in the classname attr
461 tc.attr("classname", "nitunit." + mmodule.full_name + ".<module>")
462 tc.attr("name", "<module>")
463 d2m.extract(ndoc.to_mdoc, tc)
464 end label x
465 for nclassdef in nmodule.n_classdefs do
466 var mclassdef = nclassdef.mclassdef
467 if mclassdef == null then continue
468 if nclassdef isa AStdClassdef then
469 total_entities += 1
470 var ndoc = nclassdef.n_doc
471 if ndoc != null then
472 doc_entities += 1
473 tc = new HTMLTag("testcase")
474 tc.attr("classname", "nitunit." + mmodule.full_name + "." + mclassdef.mclass.full_name)
475 tc.attr("name", "<class>")
476 d2m.extract(ndoc.to_mdoc, tc)
477 end
478 end
479 for npropdef in nclassdef.n_propdefs do
480 var mpropdef = npropdef.mpropdef
481 if mpropdef == null then continue
482 total_entities += 1
483 var ndoc = npropdef.n_doc
484 if ndoc != null then
485 doc_entities += 1
486 tc = new HTMLTag("testcase")
487 tc.attr("classname", "nitunit." + mmodule.full_name + "." + mclassdef.mclass.full_name)
488 tc.attr("name", mpropdef.mproperty.full_name)
489 d2m.extract(ndoc.to_mdoc, tc)
490 end
491 end
492 end
493
494 d2m.run_tests
495
496 return ts
497 end
498
499 # Extracts and executes all the docunits in the readme of the `mgroup`
500 # Returns a JUnit-compatible `<testsuite>` XML element that contains the results of the executions.
501 fun test_group(mgroup: MGroup): HTMLTag
502 do
503 var ts = new HTMLTag("testsuite")
504 toolcontext.info("nitunit: doc-unit group {mgroup}", 2)
505
506 # usually, only the default module must be imported in the unit test.
507 var o = mgroup.default_mmodule
508
509 ts.attr("package", mgroup.full_name)
510
511 var prefix = toolcontext.test_dir
512 prefix = prefix.join_path(mgroup.to_s)
513 var d2m = new NitUnitExecutor(toolcontext, prefix, o, ts)
514
515 var tc
516
517 total_entities += 1
518 var mdoc = mgroup.mdoc
519 if mdoc == null then return ts
520
521 doc_entities += 1
522 tc = new HTMLTag("testcase")
523 # NOTE: jenkins expects a '.' in the classname attr
524 tc.attr("classname", "nitunit." + mgroup.full_name)
525 tc.attr("name", "<group>")
526 d2m.extract(mdoc, tc)
527
528 d2m.run_tests
529
530 return ts
531 end
532
533 # Test a document object unrelated to a Nit entity
534 fun test_mdoc(mdoc: MDoc): HTMLTag
535 do
536 var ts = new HTMLTag("testsuite")
537 var file = mdoc.location.to_s
538
539 toolcontext.info("nitunit: doc-unit file {file}", 2)
540
541 ts.attr("package", file)
542
543 var prefix = toolcontext.test_dir / "file"
544 var d2m = new NitUnitExecutor(toolcontext, prefix, null, ts)
545
546 var tc
547
548 total_entities += 1
549 doc_entities += 1
550
551 tc = new HTMLTag("testcase")
552 # NOTE: jenkins expects a '.' in the classname attr
553 tc.attr("classname", "nitunit.<file>")
554 tc.attr("name", file)
555
556 d2m.extract(mdoc, tc)
557 d2m.run_tests
558
559 return ts
560 end
561 end