aboutsummaryrefslogtreecommitdiffstats
path: root/src/main/scala/org/perl8/test/tap/Consumer.scala
blob: 30541a661de5d49affccf8c07e355ed169d2f3e3 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
package org.perl8.test.tap

import java.io.{ByteArrayInputStream,InputStream,OutputStream}
import scala.annotation.tailrec
import scala.io.Source
import scala.util.parsing.combinator._
import scala.util.parsing.input.{Position,Reader}

import org.perl8.test.{Plan,NumericPlan,SkipAll}

object Consumer {
  def parse (input: InputStream, cb: TAPEvent => Unit): TAPResult =
    consumer(cb).parse(input)

  def parse (input: InputStream): TAPResult =
    consumer().parse(input)

  def parse (input: String, cb: TAPEvent => Unit): TAPResult =
    consumer(cb).parse(new ByteArrayInputStream(input.getBytes))

  def parse (input: String): TAPResult =
    consumer().parse(new ByteArrayInputStream(input.getBytes))

  // XXX should be able to make a streaming input stream out of this
  def parse (input: OutputStream, cb: TAPEvent => Unit): TAPResult =
    consumer(cb).parse(new ByteArrayInputStream(input.toString.getBytes))

  def parse (input: OutputStream): TAPResult =
    consumer().parse(new ByteArrayInputStream(input.toString.getBytes))

  private def consumer (cb: TAPEvent => Unit = e => ()) =
    new Consumer(cb)
}

class Consumer (cb: TAPEvent => Unit) {
  def parse (input: InputStream): TAPResult = {
    import TAPParser.{tap,Success,NoSuccess}

    tap(new LineReader(input)) match {
      case Success(result, _) => result
      case failure: NoSuccess => throw new ParseException(failure.msg)
    }
  }

  private object TAPParser extends Parsers {
    type Elem = Line

    def tap: Parser[TAPResult] =
      planFirst | planLast

    private def planFirst: Parser[TAPResult] =
      plan ~ rep(result) ^^ { case plan ~ results =>
        new TAPResult(plan, results)
      }

    private def planLast: Parser[TAPResult] =
      rep(result) ~ plan ^^ { case results ~ plan =>
        new TAPResult(plan, results)
      }

    private def plan: Parser[Plan] =
      planLine ^^ { line =>
        line.plan
      }

    private def result: Parser[TestResult] =
      simpleResult | subtestResult

    private def simpleResult: Parser[TestResult] =
      resultLine ^^ { line =>
        line.result
      }

    private def subtestResult: Parser[TestResult] = new Parser[TestResult] {
      def apply (in: Input) = {
        if (in.atEnd) {
          Failure("Subtest expected, but end of input found", in)
        }
        else {
          val firstLine = in.first
          if (firstLine.indent.length <= expectedIndent.length) {
            Failure(
              "Subtest expected, but '" + firstLine + "' is not indented",
              in
            )
          }
          else {
            parseSubtest(in)
          }
        }
      }

      private def parseSubtest (in: Input) = {
        val oldIndent = expectedIndent
        val newIndent = in.first.indent

        val subtestParseResult = try {
          expectedIndent = newIndent
          tap(in)
        }
        finally {
          expectedIndent = oldIndent
        }

        subtestParseResult match {
          case Success(subtestResult, rest) => {
            simpleResult(rest) match {
              case Success(summaryResult, rest) => {
                val testResult = new TestResult(
                  summaryResult.passed,
                  summaryResult.number,
                  summaryResult.description,
                  summaryResult.directive,
                  Some(subtestParseResult.get)
                )
                Success(testResult, rest)
              }
              case Failure(_, rest) => {
                Failure(
                  "Subtest summary test result expected, but " +
                    "'" + subtestParseResult.next.first + "' found",
                  rest
                )
              }
              case e: Error => e
            }
          }
          case Failure(_, rest) => {
            Failure("Subtest expected, but '" + in.first + "' found", in)
          }
          case e: Error => e
        }
      }
    }

    private def planLine: Parser[PlanLine] = new Parser[PlanLine] {
      def apply (in: Input) = {
        if (in.atEnd) {
          Failure("Plan line expected, but end of input found", in)
        }
        else {
          val line = in.first
          if (line.indent == expectedIndent) {
            line match {
              case p: PlanLine =>
                Success(p, in.rest)
              case _ =>
                Failure("Plan line expected, but '" + line + "' found", in)
            }
          }
          else {
            Failure(
              "Plan line expected, but " +
                "'" + line + "' has incorrect indentation",
              in
            )
          }
        }
      }
    }

    private def resultLine: Parser[ResultLine] = new Parser[ResultLine] {
      def apply (in: Input) = {
        if (in.atEnd) {
          Failure("Result line expected, but end of input found", in)
        }
        else {
          val line = in.first
          if (line.indent == expectedIndent) {
            line match {
              case p: ResultLine =>
                Success(p, in.rest)
              case _ =>
                Failure("Result line expected, but '" + line + "' found", in)
            }
          }
          else {
            Failure(
              "Result line expected, but " +
                "'" + line + "' has incorrect indentation",
              in
            )
          }
        }
      }
    }

    private var expectedIndent = ""
  }

  private sealed trait Line {
    def contents: String
    def indent: String
    override def toString: String =
      indent + contents
  }

  private object Line {
    def apply (line: String): Line = {
      commentRx.findFirstMatchIn(line).map { m =>
        m.subgroups match {
          case Seq(indent, text) => new CommentLine(text, indent)
        }
      }.getOrElse {
        planRx.findFirstMatchIn(line).map { m =>
          m.subgroups match {
            case Seq(indent, p, null) =>
              new PlanLine(NumericPlan(p.toInt), indent)
            case Seq(indent, _, skip) =>
              new PlanLine(SkipAll(skip), indent)
          }
        }.getOrElse {
          resultRx.findFirstMatchIn(line).map { m =>
            val indent = m.group(1)
            val passed = m.group(2) == null
            val number = m.group(3).toInt
            val description = m.group(4) match {
              case null => ""
              case s    => s.trim
            }
            val directive = (m.group(5), m.group(6)) match {
              case (null, null) => None
              case (d, r)       => {
                val reason = if (r == null) "" else r
                """(?i:skip)""".r.findFirstIn(d) match {
                  case Some(_) => Some(new SkipDirective(Some(reason)))
                  case None    => Some(new TodoDirective(Some(reason)))
                }
              }
            }
            val result = new TestResult(
              passed,
              number,
              description,
              directive,
              None
            )
            new ResultLine(result, indent)
          }.getOrElse {
            throw ParseException("Couldn't parse line: " + line)
          }
        }
      }
    }

    private val commentRx = """^(\s*)#\s*(.*)""".r
    private val planRx    = """^(\s*)1..(\d+)\s*(?:# SKIP (.*))?""".r
    private val resultRx  =
      """^(\s*)(not )?ok (\d+)\s*([^#]+)?(?:#\s*(?i:(skip|todo))\s+(.*))?""".r
  }

  private case class CommentLine (
    val text:            String,
    override val indent: String
  ) extends Line {
    def contents = "# " + text
  }

  private case class PlanLine (
    val plan:            Plan,
    override val indent: String
  ) extends Line {
    def contents = {
      val count = plan.plan
      val comment = plan match {
        case SkipAll(m) => " # SKIP " + m
        case _          => ""
      }
      indent + "1.." + count + comment
    }
  }

  private case class ResultLine(
    val result:          TestResult,
    override val indent: String
  ) extends Line {
    def contents =  {
      val success = (if (result.passed) "ok" else "not ok") + " "
      val number = result.number + " "
      val description = result.description match {
        case "" => ""
        case s  => s + " "
      }
      val directive = result.directive match {
        case Some(TodoDirective(m)) => "# TODO " + m
        case Some(SkipDirective(m)) => "# skip " + m
        case None                   => ""
      }
      indent + success + number + description + directive
    }
  }

  private class LineReader (
    in:      Stream[Char],
    lineNum: Int
  ) extends Reader[Line] {
    def this (in: InputStream) =
      this(Source.fromInputStream(in).toStream, 1)

    def atEnd: Boolean =
      nextLine.isEmpty

    def first: Line =
      nextLine.getOrElse(throw new RuntimeException("read from empty input"))

    lazy val pos =
      new LinePosition(lineNum, nextLine.map(_.toString).getOrElse(""))

    def rest: Reader[Line] =
      new LineReader(remainingStream, lineNum + 1)

    private def nextLine: Option[Line] =
      state._1

    private def remainingStream: Stream[Char] =
      state._2

    private lazy val state: (Option[Line], Stream[Char]) =
      readNextLine(in)

    @tailrec
    private def readNextLine (
      stream: Stream[Char]
    ): (Option[Line], Stream[Char]) = {
      stream match {
        case Stream.Empty => (None, in)
        case s            => {
          val (line, rest) = s.span(_ != '\n') match {
            case (l, r) => (Line(l.mkString), r.tail)
          }
          line match {
            case _: CommentLine => readNextLine(rest)
            case other          => (Some(other), rest)
          }
        }
      }
    }
  }

  private case class LinePosition (
    override val line: Int,
    override val lineContents: String
  ) extends Position {
    def column: Int = 1
  }
}

sealed trait Directive {
  val message: Option[String]
}
case class SkipDirective (
  override val message: Option[String]
) extends Directive
case class TodoDirective (
  override val message: Option[String]
) extends Directive

case class TestResult (
  val passed:      Boolean,
  val number:      Int,
  val description: String,
  val directive:   Option[Directive],
  val subtest:     Option[TAPResult]
)

class TAPResult (val plan: Plan, val results: Seq[TestResult]) {
  val correctPlan = plan match {
    case NumericPlan(n) => results.length == n
    case SkipAll(_)     => results.length == 0
  }

  val fails = results.count { r =>
    !r.passed && !r.directive.isDefined
  }

  val testsPassed = fails == 0

  val success =
    correctPlan && testsPassed

  val exitCode =
    if (success) {
      0
    }
    else if (!correctPlan) {
      255
    }
    else {
      fails
    }
}

trait TAPEvent
case class ResultEvent (result: TestResult) extends TAPEvent
case class PlanEvent (plan: Plan) extends TAPEvent
case object SubtestStartEvent extends TAPEvent
case class SubtestEndEvent (result: TestResult) extends TAPEvent
case class CommentEvent (text: String) extends TAPEvent

case class ParseException (
  val message: String
) extends RuntimeException(message)