1   /*
2    * The MIT License
3    *
4    * Copyright (c) <2010> <tap4j>
5    * 
6    * Permission is hereby granted, free of charge, to any person obtaining a copy
7    * of this software and associated documentation files (the "Software"), to deal
8    * in the Software without restriction, including without limitation the rights
9    * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10   * copies of the Software, and to permit persons to whom the Software is
11   * furnished to do so, subject to the following conditions:
12   * 
13   * The above copyright notice and this permission notice shall be included in
14   * all copies or substantial portions of the Software.
15   * 
16   * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17   * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18   * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19   * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20   * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21   * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22   * THE SOFTWARE.
23   */
24  package org.tap4j.parser;
25  
26  import java.io.File;
27  import java.util.Map;
28  import java.util.Scanner;
29  import java.util.Stack;
30  import java.util.regex.Matcher;
31  import java.util.regex.Pattern;
32  
33  import org.apache.commons.lang.StringUtils;
34  import org.tap4j.model.BailOut;
35  import org.tap4j.model.Comment;
36  import org.tap4j.model.Directive;
37  import org.tap4j.model.Footer;
38  import org.tap4j.model.Header;
39  import org.tap4j.model.Plan;
40  import org.tap4j.model.SkipPlan;
41  import org.tap4j.model.TapElement;
42  import org.tap4j.model.TestResult;
43  import org.tap4j.model.TestSet;
44  import org.tap4j.model.Text;
45  import org.tap4j.util.DirectiveValues;
46  import org.tap4j.util.StatusValues;
47  import org.yaml.snakeyaml.Yaml;
48  
49  /**
50   * TAP 13 parser with support to YAML.
51   * 
52   * @since 1.0
53   */
54  public class Tap13YamlParser implements Parser {
55  
56      protected static final Pattern INDENTANTION_PATTERN = Pattern
57          .compile("((\\s|\\t)*)?.*");
58  
59      private TestSet testSet;
60  
61      private Stack<Memento> mementos = new Stack<Memento>();
62  
63      private boolean firstLine;
64  
65      private boolean planBeforeTestResult;
66  
67      private boolean currentlyInYAML;
68  
69      // Helper String to check the Footer
70      private String lastLine = null;
71  
72      private TapElement lastParsedElement;
73  
74      /**
75       * Indicator of the base indentation level. Usually defined by the TAP
76       * Header.
77       */
78      private int baseIndentationLevel;
79  
80      /**
81       * Helper indicator of in what indentantion level we are working at moment.
82       * It is helpful specially when you have many nested elements, like a META
83       * element with some multiline text.
84       */
85      private int currentIndentationLevel;
86  
87      /**
88       * YAML parser and emitter.
89       */
90      private Yaml yaml;
91  
92      private StringBuilder diagnosticBuffer;
93  
94      public Tap13YamlParser() {
95          super();
96          this.init();
97      }
98  
99      /**
100      * Called from the constructor and everytime a new TAP Stream (file or
101      * string) is processed.
102      */
103     public final void init() {
104         this.baseIndentationLevel = -1;
105         this.currentIndentationLevel = -1;
106         this.currentlyInYAML = Boolean.FALSE;
107         this.diagnosticBuffer = new StringBuilder();
108         this.lastParsedElement = null;
109         this.firstLine = Boolean.TRUE;
110         this.planBeforeTestResult = Boolean.FALSE;
111         this.testSet = new TestSet();
112         yaml = new Yaml();
113     }
114 
115     /**
116      * Save the parser memento.
117      */
118     private void saveMemento() {
119         Memento memento = new Memento();
120         memento.setBaseIndentationLevel(this.baseIndentationLevel);
121         memento.setCurrentIndentationLevel(this.currentIndentationLevel);
122         memento.setCurrentlyInYaml(this.currentlyInYAML);
123         memento.setDiagnosticBuffer(this.diagnosticBuffer);
124         memento.setLastParsedElement(this.lastParsedElement);
125         memento.setFirstLine(this.firstLine);
126         memento.setPlanBeforeTestResult(this.planBeforeTestResult);
127         memento.setTestSet(this.testSet);
128         this.mementos.push(memento);
129     }
130 
131     /**
132      * Load the parser memento.
133      */
134     private void loadMemento() {
135         Memento memento = this.mementos.pop();
136         this.baseIndentationLevel = memento.getBaseIndentationLevel();
137         this.currentIndentationLevel = memento.getCurrentIndentationLevel();
138         this.currentlyInYAML = memento.isCurrentlyInYaml();
139         this.diagnosticBuffer = memento.getDiagnosticBuffer();
140         this.lastParsedElement = memento.getLastParsedElement();
141         this.firstLine = memento.isFirstLine();
142         this.planBeforeTestResult = memento.isPlanBeforeTestResult();
143         this.testSet = memento.getTestSet();
144     }
145 
146     /*
147      * (non-Javadoc)
148      * @see org.tap4j.TapConsumer#getTestSet()
149      */
150     public TestSet getTestSet() {
151         return this.testSet;
152     }
153 
154     /*
155      * (non-Javadoc)
156      * @see org.tap4j.consumer.DefaultTapConsumer#parseLine(java.lang.String)
157      */
158     public void parseLine(String tapLine) {
159         Matcher matcher = null;
160 
161         // Comment
162         matcher = COMMENT_PATTERN.matcher(tapLine);
163         if (matcher.matches()) {
164             this.extractComment(matcher);
165             return; // NOPMD by Bruno on 12/01/11 07:47
166         }
167 
168         // Last line that is not a comment.
169         lastLine = tapLine;
170 
171         // Check if we already know the indentation level... if so, try to find
172         // out the indentation level of the current line in the TAP Stream.
173         // If the line indentation level is greater than the pre-defined
174         // one, than we know it is a) a META, b)
175         if (this.isBaseIndentationAlreadyDefined()) {
176             matcher = INDENTANTION_PATTERN.matcher(tapLine);
177             if (matcher.matches()) {
178                 String spaces = matcher.group(1);
179                 int indentation = spaces.length();
180                 this.currentIndentationLevel = indentation;
181                 if (indentation > this.baseIndentationLevel) {
182                     // we are at the start of the meta tags, but we should
183                     // ignore
184                     // the --- or ...
185                     // TBD: check how snakeyaml can handle these tokens.
186                     if (tapLine.trim().equals("---")) {
187                         this.currentlyInYAML = true;
188                         return;
189                     } else if (tapLine.trim().equals("...")) {
190                         this.currentlyInYAML = false;
191                         return;
192                     } else if (this.currentlyInYAML) {
193                         this.appendTapLineToDiagnosticBuffer(tapLine);
194                         return; // NOPMD by Bruno on 12/01/11 07:47
195                     } else {
196                         // If we are in a different level, but it is not YAML,
197                         // Then it must be a subtest! Yay!
198                         if (this.lastParsedElement instanceof TestResult) {
199                             indentation = baseIndentationLevel;
200                             TestResult lastTestResult = (TestResult) this.lastParsedElement;
201                             TestSet newTestSet = new TestSet();
202                             lastTestResult.setSubtest(newTestSet);
203                             this.saveMemento();
204                             this.init();
205                             this.testSet = newTestSet;
206                         }
207                     }
208                 }
209 
210                 // indentation cannot be less then the base indentation level
211                 this.checkIndentationLevel(indentation, tapLine);
212             }
213         }
214 
215         // Check if we have some diagnostic set in the buffer
216         this.checkAndParseTapDiagnostic();
217 
218         // Header
219         matcher = HEADER_PATTERN.matcher(tapLine);
220         if (matcher.matches()) {
221             this.setIndentationLevelIfNotDefined(tapLine);
222 
223             this.currentIndentationLevel = this.baseIndentationLevel;
224 
225             this.checkTAPHeaderParsingLocationAndDuplicity();
226 
227             this.extractHeader(matcher);
228             this.firstLine = false;
229 
230             this.lastParsedElement = this.testSet.getHeader();
231 
232             return; // NOPMD by Bruno on 12/01/11 07:47
233         }
234 
235         // Check if the header was set
236         // this.checkHeader();
237 
238         // Plan
239         matcher = PLAN_PATTERN.matcher(tapLine);
240         if (matcher.matches()) {
241             this.checkTAPPlanDuplicity();
242 
243             this.checkIfTAPPlanIsSetBeforeTestResultsOrBailOut();
244 
245             this.setIndentationLevelIfNotDefined(tapLine);
246 
247             this.extractPlan(matcher);
248             this.firstLine = false;
249 
250             this.lastParsedElement = this.testSet.getPlan();
251 
252             return; // NOPMD by Bruno on 12/01/11 07:47
253         }
254 
255         // Test Result
256         matcher = TEST_RESULT_PATTERN.matcher(tapLine);
257         if (matcher.matches()) {
258             this.setIndentationLevelIfNotDefined(tapLine);
259 
260             this.extractTestResult(matcher);
261 
262             this.lastParsedElement = this.testSet.getTapLines()
263                 .get((this.testSet.getTapLines().size() - 1));
264 
265             return; // NOPMD by Bruno on 12/01/11 07:47
266         }
267 
268         // Bail Out
269         matcher = BAIL_OUT_PATTERN.matcher(tapLine);
270         if (matcher.matches()) {
271 
272             this.setIndentationLevelIfNotDefined(tapLine);
273 
274             this.extractBailOut(matcher);
275 
276             this.lastParsedElement = this.testSet.getTapLines()
277                 .get((this.testSet.getTapLines().size() - 1));
278 
279             return; // NOPMD by Bruno on 12/01/11 07:47
280         }
281 
282         // Footer
283         matcher = FOOTER_PATTERN.matcher(tapLine);
284         if (matcher.matches()) {
285             this.extractFooter(matcher);
286 
287             this.lastParsedElement = this.testSet.getFooter();
288 
289             return; // NOPMD by Bruno on 12/01/11 07:47
290         }
291 
292         // Any text. It should not be parsed by the consumer.
293         final Text text = new Text(tapLine);
294         this.lastParsedElement = text;
295         this.testSet.addTapLine(text);
296 
297     }
298 
299     /**
300      * Checks if the TAP Plan is set before any Test Result or Bail Out.
301      */
302     protected void checkIfTAPPlanIsSetBeforeTestResultsOrBailOut() {
303         if (this.testSet.getTestResults().size() <= 0 &&
304             this.testSet.getBailOuts().size() <= 0) {
305             this.planBeforeTestResult = true;
306         }
307     }
308 
309     /**
310      * Checks the Header location and duplicity. The Header must be the first
311      * element and cannot occurs more than on time. However the Header is
312      * optional.
313      */
314     protected void checkTAPHeaderParsingLocationAndDuplicity() {
315         if (this.testSet.getHeader() != null) {
316             throw new ParserException("Duplicated TAP Header found.");
317         }
318         if (!firstLine) {
319             throw new ParserException(
320                                       "Invalid position of TAP Header. It must be the first element (apart of Comments) in the TAP Stream.");
321         }
322     }
323 
324     /**
325      * Checks if there are more than one TAP Plan in the TAP Stream.
326      */
327     protected void checkTAPPlanDuplicity() {
328         if (this.testSet.getPlan() != null) {
329             throw new ParserException("Duplicated TAP Plan found.");
330         }
331     }
332 
333     /**
334      * This method is called after the TAP Stream has already been parsed. So we
335      * just check if the plan was found before test result or bail outs. If so,
336      * skip this check. Otherwise, we shall check if the last line is the TAP
337      * Plan.
338      * 
339      * @deprecated
340      */
341     protected void checkTAPPlanPosition() {
342         if (!this.planBeforeTestResult) {
343             Matcher matcher = PLAN_PATTERN.matcher(lastLine);
344 
345             if (matcher.matches()) {
346                 return; // OK
347             }
348 
349             throw new ParserException("Invalid position of TAP Plan.");
350         }
351     }
352 
353     /**
354      * Checks if TAP Plan has been set.
355      * 
356      * @throws ParserException if TAP Plan has not been set.
357      */
358     protected void checkTAPPlanIsSet() {
359         if (this.testSet.getPlan() == null) {
360             throw new ParserException("Missing TAP Plan.");
361         }
362     }
363 
364     /**
365      * Extracts the Header from a TAP Line.
366      * 
367      * @param matcher REGEX Matcher.
368      */
369     protected void extractHeader(Matcher matcher) {
370         final Integer version = Integer.parseInt(matcher.group(1));
371 
372         final Header header = new Header(version);
373 
374         final String commentToken = matcher.group(2);
375 
376         if (commentToken != null) {
377             String text = matcher.group(3);
378             final Comment comment = new Comment(text);
379             header.setComment(comment);
380         }
381 
382         this.testSet.setHeader(header);
383     }
384 
385     /**
386      * @param matcher REGEX Matcher.
387      */
388     protected void extractPlan(Matcher matcher) {
389         Integer initialTest = Integer.parseInt(matcher.group(1));
390         Integer lastTest = Integer.parseInt(matcher.group(3));
391 
392         Plan plan = null;
393         plan = new Plan(initialTest, lastTest);
394 
395         String skipToken = matcher.group(4);
396         if (skipToken != null) {
397             String reason = matcher.group(5);
398             final SkipPlan skip = new SkipPlan(reason);
399             plan.setSkip(skip);
400         }
401 
402         String commentToken = matcher.group(6);
403         if (commentToken != null) {
404             String text = matcher.group(7);
405             final Comment comment = new Comment(text);
406             plan.setComment(comment);
407         }
408 
409         this.testSet.setPlan(plan);
410     }
411 
412     /**
413      * @param matcher REGEX Matcher.
414      */
415     protected void extractTestResult(Matcher matcher) {
416         TestResult testResult = null;
417 
418         final String okOrNotOk = matcher.group(1);
419         StatusValues status = null;
420         if (okOrNotOk.trim().equals("ok")) {
421             status = StatusValues.OK;
422         } else // regex mate...
423         {
424             status = StatusValues.NOT_OK;
425         }
426 
427         Integer testNumber = this.getTestNumber(matcher.group(2));
428 
429         testResult = new TestResult(status, testNumber);
430 
431         testResult.setDescription(matcher.group(3));
432 
433         String directiveToken = matcher.group(4);
434         if (directiveToken != null) {
435             String directiveText = matcher.group(5);
436             DirectiveValues directiveValue = null;
437             if (directiveText.trim().equalsIgnoreCase("todo")) {
438                 directiveValue = DirectiveValues.TODO;
439             } else {
440                 directiveValue = DirectiveValues.SKIP;
441             }
442             String reason = matcher.group(6);
443             Directive directive = new Directive(directiveValue, reason);
444             testResult.setDirective(directive);
445         }
446 
447         String commentToken = matcher.group(7);
448         if (commentToken != null) {
449             String text = matcher.group(8);
450             final Comment comment = new Comment(text);
451             comment.setInline(Boolean.TRUE);
452             testResult.addComment(comment);
453         }
454 
455         this.testSet.addTestResult(testResult);
456     }
457 
458     /**
459      * Returns the test number out from an input String. If the string is null
460      * or equals "" this method returns the next test result number. Otherwise
461      * it will return the input String value parsed to an Integer.
462      * 
463      * @param testNumber
464      * @return
465      */
466     private Integer getTestNumber(String testNumber) {
467         Integer integerTestNumber = null;
468         if (StringUtils.isEmpty(testNumber)) {
469             integerTestNumber = (this.testSet.getTestResults().size() + 1);
470         } else {
471             integerTestNumber = Integer.parseInt(testNumber);
472         }
473         return integerTestNumber;
474     }
475 
476     /**
477      * @param matcher REGEX Matcher.
478      */
479     protected void extractBailOut(Matcher matcher) {
480         String reason = matcher.group(1);
481 
482         BailOut bailOut = new BailOut(reason);
483 
484         String commentToken = matcher.group(2);
485 
486         if (commentToken != null) {
487             String text = matcher.group(3);
488             Comment comment = new Comment(text);
489             bailOut.setComment(comment);
490         }
491 
492         this.testSet.addBailOut(bailOut);
493     }
494 
495     /**
496      * @param matcher REGEX Matcher.
497      */
498     protected void extractComment(Matcher matcher) {
499         String text = matcher.group(1);
500         Comment comment = new Comment(text);
501 
502         this.testSet.addComment(comment);
503 
504         if (lastParsedElement instanceof TestResult) {
505             TestResult lastTestResult = (TestResult) lastParsedElement;
506             lastTestResult.addComment(comment);
507         }
508     }
509 
510     /**
511      * Simply extracts the footer from the TAP line.
512      * 
513      * @param matcher REGEX Matcher.
514      */
515     protected void extractFooter(Matcher matcher) {
516         String text = matcher.group(1);
517         Footer footer = new Footer(text);
518 
519         final String commentToken = matcher.group(2);
520 
521         if (commentToken != null) {
522             String commentText = matcher.group(3);
523             final Comment comment = new Comment(commentText);
524             footer.setComment(comment);
525         }
526 
527         this.testSet.setFooter(footer);
528     }
529 
530     /*
531      * (non-Javadoc)
532      * @see org.tap4j.TapConsumer#parseTapStream(java.lang.String)
533      */
534     public TestSet parseTapStream(String tapStream) {
535 
536         this.init();
537 
538         Scanner scanner = null;
539 
540         try {
541             scanner = new Scanner(tapStream);
542             String line = null;
543 
544             while (scanner.hasNextLine()) {
545                 line = scanner.nextLine();
546                 if (StringUtils.isNotEmpty(line)) {
547                     this.parseLine(line);
548                 }
549             }
550             this.postProcess();
551         } catch (Exception e) {
552             throw new ParserException("Error parsing TAP Stream: " +
553                                       e.getMessage(), e);
554         } finally {
555             if (scanner != null) {
556                 scanner.close();
557             }
558         }
559 
560         return this.getTestSet();
561 
562     }
563 
564     /*
565      * (non-Javadoc)
566      * @see org.tap4j.TapConsumer#parseFile(java.io.File)
567      */
568     public TestSet parseFile(File tapFile) {
569 
570         this.init();
571 
572         Scanner scanner = null;
573 
574         try {
575             scanner = new Scanner(tapFile);
576             String line = null;
577 
578             while (scanner.hasNextLine()) {
579                 line = scanner.nextLine();
580                 if (StringUtils.isNotBlank(line)) {
581                     this.parseLine(line);
582                 }
583             }
584             this.postProcess();
585         } catch (Exception e) {
586             throw new ParserException("Error parsing TAP Stream: " +
587                                       e.getMessage(), e);
588         } finally {
589             if (scanner != null) {
590                 scanner.close();
591             }
592         }
593 
594         return this.getTestSet();
595     }
596 
597     /**
598 	 * 
599 	 */
600     private void setIndentationLevelIfNotDefined(String tapLine) {
601         if (this.isBaseIndentationAlreadyDefined() == Boolean.FALSE) {
602             this.baseIndentationLevel = this.getIndentationLevel(tapLine);
603         }
604     }
605 
606     /**
607      * Checks if the indentation is greater than the
608      * {@link #baseIndentationLevel}
609      * 
610      * @param indentation indentation level
611      * @throws org.tap4j.consumer.TapConsumerException if indentation is less
612      *         then the {@link #baseIndentationLevel} .
613      */
614     private void checkIndentationLevel(int indentation, String tapLine) {
615         if (indentation < this.baseIndentationLevel) {
616             if (!this.currentlyInYAML &&
617                 this.mementos.isEmpty() == Boolean.FALSE) {
618                 while (!this.mementos.isEmpty() &&
619                        indentation < this.baseIndentationLevel) {
620                     this.loadMemento();
621                 }
622             } else {
623                 throw new ParserException("Invalid indentantion. " +
624                                           "Check your TAP Stream. Line: " +
625                                           tapLine);
626             }
627         }
628     }
629 
630     /**
631      * Gets the indentation level of a line.
632      * 
633      * @param tapLine line.
634      * @return indentation level of a line.
635      */
636     private int getIndentationLevel(String tapLine) {
637         int indentationLevel = 0;
638 
639         final Matcher indentMatcher = INDENTANTION_PATTERN.matcher(tapLine);
640 
641         if (indentMatcher.matches()) {
642             String spaces = indentMatcher.group(1);
643             indentationLevel = spaces.length();
644         }
645         return indentationLevel;
646     }
647 
648     /**
649      * <p>
650      * Checks if there is any diagnostic information on the diagnostic buffer.
651      * </p>
652      * <p>
653      * If so, tries to parse it using snakeyaml.
654      * </p>
655      * 
656      * @throws org.tap4j.consumer.TapConsumerException
657      */
658     private void checkAndParseTapDiagnostic() {
659         // If we found any meta, then process it with SnakeYAML
660         if (diagnosticBuffer.length() > 0) {
661 
662             if (this.lastParsedElement == null) {
663                 throw new ParserException(
664                                           "Found diagnostic information without a previous TAP element.");
665             }
666 
667             try {
668                 // Iterable<?> metaIterable = (Iterable<?>)yaml.loadAll(
669                 // diagnosticBuffer.toString() );
670                 @SuppressWarnings("unchecked")
671                 Map<String, Object> metaIterable = (Map<String, Object>) yaml
672                     .load(diagnosticBuffer.toString());
673                 this.lastParsedElement.setDiagnostic(metaIterable);
674             } catch (Exception ex) {
675                 throw new ParserException("Error parsing YAML [" +
676                                           diagnosticBuffer.toString() + "]: " +
677                                           ex.getMessage(), ex);
678             }
679 
680             diagnosticBuffer = new StringBuilder();
681         }
682     }
683 
684     /*
685      * Checks if the Header was set.
686      * @throws org.tap4j.consumer.TapConsumerException
687      * @deprecated
688      */
689     // void checkHeader()
690     // throws TapConsumerException
691     // {
692     // if ( this.header == null )
693     // {
694     // throw new TapConsumerException("Missing required TAP Header element.");
695     // }
696     // }
697 
698     /**
699      * Appends a diagnostic line to diagnostic buffer. If the diagnostic line
700      * contains --- or ... then it ignores this line. In the end of each line it
701      * appends a break line.
702      * 
703      * @param diagnosticLine diagnostic line
704      */
705     private void appendTapLineToDiagnosticBuffer(String diagnosticLine) {
706         if (diagnosticLine.trim().equals("---") ||
707             diagnosticLine.trim().equals("...")) {
708             return;
709         }
710 
711         if (this.currentlyInYAML) {
712             diagnosticBuffer.append(diagnosticLine);
713             diagnosticBuffer.append('\n');
714         }
715     }
716 
717     /**
718      * @return true if the base indentation is already defined, false otherwise.
719      */
720     protected boolean isBaseIndentationAlreadyDefined() {
721         return this.baseIndentationLevel >= 0;
722     }
723 
724     /*
725      * (non-Javadoc)
726      * @see org.tap4j.consumer.DefaultTapConsumer#postProcess()
727      */
728     protected void postProcess() {
729         this.checkTAPPlanIsSet();
730         this.checkAndParseTapDiagnostic();
731     }
732 
733 }