nitunit: DocUnit is also a TestUnit
[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
110 for du in docunits do
111 testsuite.add du.to_xml
112 end
113 end
114
115 # Executes multiples doc-units in a shared program.
116 # Used for docunits simple block of code (without modules, classes, functions etc.)
117 #
118 # In case of compilation error, the method fallbacks to `test_single_docunit` to
119 # * locate exactly the compilation problem in the problematic docunit.
120 # * permit the execution of the other docunits that may be correct.
121 fun test_simple_docunits(dus: Array[DocUnit])
122 do
123 if dus.is_empty then return
124
125 var file = "{prefix}-0.nit"
126
127 var dir = file.dirname
128 if dir != "" then dir.mkdir
129 var f
130 f = create_unitfile(file)
131 var i = 0
132 for du in dus do
133
134 i += 1
135 f.write("fun run_{i} do\n")
136 f.write("# {du.testcase.attrs["name"]}\n")
137 f.write(du.block)
138 f.write("end\n")
139 end
140 f.write("var a = args.first.to_i\n")
141 for j in [1..i] do
142 f.write("if a == {j} then run_{j}\n")
143 end
144 f.close
145
146 if toolcontext.opt_noact.value then return
147
148 var res = compile_unitfile(file)
149
150 if res != 0 then
151 # Compilation error.
152 # Fall-back to individual modes:
153 for du in dus do
154 test_single_docunit(du)
155 end
156 return
157 end
158
159 i = 0
160 for du in dus do
161 var tc = du.testcase
162 toolcontext.modelbuilder.unit_entities += 1
163 i += 1
164 toolcontext.info("Execute doc-unit {du.testcase.attrs["name"]} in {file} {i}", 1)
165 var res2 = toolcontext.safe_exec("{file.to_program_name}.bin {i} >'{file}.out1' 2>&1 </dev/null")
166
167 var content = "{file}.out1".to_path.read_all
168 var msg = content.trunc(8192).filter_nonprintable
169
170 if res2 != 0 then
171 du.error = content
172 toolcontext.warning(du.location, "error", "ERROR: {tc.attrs["classname"]}.{tc.attrs["name"]} (in {file}): Runtime error\n{msg}")
173 toolcontext.modelbuilder.failed_entities += 1
174 end
175 toolcontext.check_errors
176 end
177 end
178
179 # Executes a single doc-unit in its own program.
180 # Used for docunits larger than a single block of code (with modules, classes, functions etc.)
181 fun test_single_docunit(du: DocUnit)
182 do
183 var tc = du.testcase
184 toolcontext.modelbuilder.unit_entities += 1
185
186 cpt += 1
187 var file = "{prefix}-{cpt}.nit"
188
189 toolcontext.info("Execute doc-unit {tc.attrs["name"]} in {file}", 1)
190
191 var f
192 f = create_unitfile(file)
193 f.write(du.block)
194 f.close
195
196 if toolcontext.opt_noact.value then return
197
198 var res = compile_unitfile(file)
199 var res2 = 0
200 if res == 0 then
201 res2 = toolcontext.safe_exec("{file.to_program_name}.bin >'{file}.out1' 2>&1 </dev/null")
202 end
203
204 var content = "{file}.out1".to_path.read_all
205 var msg = content.trunc(8192).filter_nonprintable
206
207
208 if res != 0 then
209 du.error = content
210 toolcontext.warning(du.location, "failure", "FAILURE: {tc.attrs["classname"]}.{tc.attrs["name"]} (in {file}):\n{msg}")
211 toolcontext.modelbuilder.failed_entities += 1
212 else if res2 != 0 then
213 toolcontext.warning(du.location, "error", "ERROR: {tc.attrs["classname"]}.{tc.attrs["name"]} (in {file}):\n{msg}")
214 toolcontext.modelbuilder.failed_entities += 1
215 end
216 toolcontext.check_errors
217 end
218
219 # Create and fill the header of a unit file `file`.
220 #
221 # A unit file is a Nit source file generated from one
222 # or more docunits that will be compiled and executed.
223 #
224 # The handled on the file is returned and must be completed and closed.
225 #
226 # `file` should be a valid filepath for a Nit source file.
227 private fun create_unitfile(file: String): Writer
228 do
229 var dir = file.dirname
230 if dir != "" then dir.mkdir
231 var f
232 f = new FileWriter.open(file)
233 f.write("# GENERATED FILE\n")
234 f.write("# Docunits extracted from comments\n")
235 if mmodule != null then
236 f.write("import {mmodule.name}\n")
237 end
238 f.write("\n")
239 return f
240 end
241
242 # Compile an unit file and return the compiler return code
243 #
244 # Can terminate the program if the compiler is not found
245 private fun compile_unitfile(file: String): Int
246 do
247 var nitc = toolcontext.find_nitc
248 var opts = new Array[String]
249 if mmodule != null then
250 opts.add "-I {mmodule.filepath.dirname}"
251 end
252 var cmd = "{nitc} --ignore-visibility --no-color '{file}' {opts.join(" ")} >'{file}.out1' 2>&1 </dev/null -o '{file}.bin'"
253 var res = toolcontext.safe_exec(cmd)
254 return res
255 end
256 end
257
258 private class NitunitDecorator
259 super HTMLDecorator
260
261 var executor: NitUnitExecutor
262
263 redef fun add_code(v, block) do
264 var code = block.raw_content
265 var meta = block.meta or else "nit"
266 # Do not try to test non-nit code.
267 if meta != "nit" then return
268 # Try to parse code blocks
269 var ast = executor.toolcontext.parse_something(code)
270
271 var mdoc = executor.mdoc
272 assert mdoc != null
273
274 # Skip pure comments
275 if ast isa TComment then return
276
277 # The location is computed according to the starts of the mdoc and the block
278 # Note, the following assumes that all the comments of the mdoc are correctly aligned.
279 var loc = block.block.location
280 var line_offset = loc.line_start + mdoc.location.line_start - 2
281 var column_offset = loc.column_start + mdoc.location.column_start
282 # Hack to handle precise location in blocks
283 # TODO remove when markdown is more reliable
284 if block isa BlockFence then
285 # Skip the starting fence
286 line_offset += 1
287 else
288 # Account a standard 4 space indentation
289 column_offset += 4
290 end
291
292 # We want executable code
293 if not (ast isa AModule or ast isa ABlockExpr or ast isa AExpr) then
294 var message
295 var l = ast.location
296 # Get real location of the node (or error)
297 var location = new Location(mdoc.location.file,
298 l.line_start + line_offset,
299 l.line_end + line_offset,
300 l.column_start + column_offset,
301 l.column_end + column_offset)
302 if ast isa AError then
303 message = ast.message
304 else
305 message = "Error: Invalid Nit code."
306 end
307
308 executor.toolcontext.warning(location, "invalid-block", "{message} To suppress this message, enclose the block with a fence tagged `nitish` or `raw` (see `man nitdoc`).")
309 executor.failures.add("{location}: {message}")
310 return
311 end
312
313 # Create a first block
314 # Or create a new block for modules that are more than a main part
315 var last_docunit = executor.last_docunit
316 if last_docunit == null or ast isa AModule then
317 last_docunit = new DocUnit(executor.mdoc.as(not null), executor.tc, "")
318 executor.docunits.add last_docunit
319 end
320
321 # Add it to the file
322 last_docunit.block += code
323
324 # In order to retrieve precise positions,
325 # the real position of each line of the raw_content is stored.
326 # See `DocUnit::real_location`
327 line_offset -= loc.line_start - 1
328 for i in [loc.line_start..loc.line_end] do
329 last_docunit.lines.add i + line_offset
330 last_docunit.columns.add column_offset
331 end
332 end
333 end
334
335 # A unit-test extracted from some documentation.
336 #
337 # A docunit is extracted from the code-blocks of mdocs.
338 # Each mdoc can contains more than one docunit, and a single docunit can be made of more that a single code-block.
339 class DocUnit
340 super UnitTest
341
342 # The doc that contains self
343 var mdoc: MDoc
344
345 # The XML node that contains the information about the execution
346 var testcase: HTMLTag
347
348 # The text of the code to execute.
349 #
350 # This is the verbatim content on one, or more, code-blocks from `mdoc`
351 var block: String
352
353 # For each line in `block`, the associated line in the mdoc
354 #
355 # Is used to give precise locations
356 var lines = new Array[Int]
357
358 # For each line in `block`, the associated column in the mdoc
359 #
360 # Is used to give precise locations
361 var columns = new Array[Int]
362
363 # The location of the whole docunit.
364 #
365 # If `self` is made of multiple code-blocks, then the location
366 # starts at the first code-books and finish at the last one, thus includes anything between.
367 var location: Location is lazy do
368 return new Location(mdoc.location.file, lines.first, lines.last+1, columns.first+1, 0)
369 end
370
371 # Compute the real location of a node on the `ast` based on `mdoc.location`
372 #
373 # The result is basically: ast_location + markdown location of the piece + mdoc.location
374 #
375 # The fun is that a single docunit can be made of various pieces of code blocks.
376 fun real_location(ast_location: Location): Location
377 do
378 var mdoc = self.mdoc
379 var res = new Location(mdoc.location.file, lines[ast_location.line_start-1],
380 lines[ast_location.line_end-1],
381 columns[ast_location.line_start-1] + ast_location.column_start,
382 columns[ast_location.line_end-1] + ast_location.column_end)
383 return res
384 end
385
386 redef fun to_xml
387 do
388 var res = super
389 res.open("system-out").append(block)
390 return res
391 end
392
393 end
394
395 redef class ModelBuilder
396 # Total number analyzed `MEntity`
397 var total_entities = 0
398
399 # The number of `MEntity` that have some documentation
400 var doc_entities = 0
401
402 # The total number of executed docunits
403 var unit_entities = 0
404
405 # The number failed docunits
406 var failed_entities = 0
407
408 # Extracts and executes all the docunits in the `mmodule`
409 # Returns a JUnit-compatible `<testsuite>` XML element that contains the results of the executions.
410 fun test_markdown(mmodule: MModule): HTMLTag
411 do
412 var ts = new HTMLTag("testsuite")
413 toolcontext.info("nitunit: doc-unit {mmodule}", 2)
414
415 var nmodule = mmodule2node(mmodule)
416 if nmodule == null then return ts
417
418 # usualy, only the original module must be imported in the unit test.
419 var o = mmodule
420 var g = o.mgroup
421 if g != null and g.mpackage.name == "core" then
422 # except for a unit test in a module of `core`
423 # in this case, the whole `core` must be imported
424 o = get_mmodule_by_name(nmodule, g, g.mpackage.name).as(not null)
425 end
426
427 ts.attr("package", mmodule.full_name)
428
429 var prefix = toolcontext.test_dir
430 prefix = prefix.join_path(mmodule.to_s)
431 var d2m = new NitUnitExecutor(toolcontext, prefix, o, ts)
432
433 var tc
434
435 do
436 total_entities += 1
437 var nmoduledecl = nmodule.n_moduledecl
438 if nmoduledecl == null then break label x
439 var ndoc = nmoduledecl.n_doc
440 if ndoc == null then break label x
441 doc_entities += 1
442 tc = new HTMLTag("testcase")
443 # NOTE: jenkins expects a '.' in the classname attr
444 tc.attr("classname", "nitunit." + mmodule.full_name + ".<module>")
445 tc.attr("name", "<module>")
446 d2m.extract(ndoc.to_mdoc, tc)
447 end label x
448 for nclassdef in nmodule.n_classdefs do
449 var mclassdef = nclassdef.mclassdef
450 if mclassdef == null then continue
451 if nclassdef isa AStdClassdef then
452 total_entities += 1
453 var ndoc = nclassdef.n_doc
454 if ndoc != null then
455 doc_entities += 1
456 tc = new HTMLTag("testcase")
457 tc.attr("classname", "nitunit." + mmodule.full_name + "." + mclassdef.mclass.full_name)
458 tc.attr("name", "<class>")
459 d2m.extract(ndoc.to_mdoc, tc)
460 end
461 end
462 for npropdef in nclassdef.n_propdefs do
463 var mpropdef = npropdef.mpropdef
464 if mpropdef == null then continue
465 total_entities += 1
466 var ndoc = npropdef.n_doc
467 if ndoc != null then
468 doc_entities += 1
469 tc = new HTMLTag("testcase")
470 tc.attr("classname", "nitunit." + mmodule.full_name + "." + mclassdef.mclass.full_name)
471 tc.attr("name", mpropdef.mproperty.full_name)
472 d2m.extract(ndoc.to_mdoc, tc)
473 end
474 end
475 end
476
477 d2m.run_tests
478
479 return ts
480 end
481
482 # Extracts and executes all the docunits in the readme of the `mgroup`
483 # Returns a JUnit-compatible `<testsuite>` XML element that contains the results of the executions.
484 fun test_group(mgroup: MGroup): HTMLTag
485 do
486 var ts = new HTMLTag("testsuite")
487 toolcontext.info("nitunit: doc-unit group {mgroup}", 2)
488
489 # usually, only the default module must be imported in the unit test.
490 var o = mgroup.default_mmodule
491
492 ts.attr("package", mgroup.full_name)
493
494 var prefix = toolcontext.test_dir
495 prefix = prefix.join_path(mgroup.to_s)
496 var d2m = new NitUnitExecutor(toolcontext, prefix, o, ts)
497
498 var tc
499
500 total_entities += 1
501 var mdoc = mgroup.mdoc
502 if mdoc == null then return ts
503
504 doc_entities += 1
505 tc = new HTMLTag("testcase")
506 # NOTE: jenkins expects a '.' in the classname attr
507 tc.attr("classname", "nitunit." + mgroup.full_name)
508 tc.attr("name", "<group>")
509 d2m.extract(mdoc, tc)
510
511 d2m.run_tests
512
513 return ts
514 end
515
516 # Test a document object unrelated to a Nit entity
517 fun test_mdoc(mdoc: MDoc): HTMLTag
518 do
519 var ts = new HTMLTag("testsuite")
520 var file = mdoc.location.to_s
521
522 toolcontext.info("nitunit: doc-unit file {file}", 2)
523
524 ts.attr("package", file)
525
526 var prefix = toolcontext.test_dir / "file"
527 var d2m = new NitUnitExecutor(toolcontext, prefix, null, ts)
528
529 var tc
530
531 total_entities += 1
532 doc_entities += 1
533
534 tc = new HTMLTag("testcase")
535 # NOTE: jenkins expects a '.' in the classname attr
536 tc.attr("classname", "nitunit.<file>")
537 tc.attr("name", file)
538
539 d2m.extract(mdoc, tc)
540 d2m.run_tests
541
542 return ts
543 end
544 end