From 127516d0b90c504ece537e1f4eaa4f1e00a3763d Mon Sep 17 00:00:00 2001 From: Filippo De Luca Date: Wed, 15 Oct 2025 22:03:23 +0200 Subject: [PATCH 01/12] Add tag expression parsing and filtering functionality --- build.sbt | 2 + .../src/main/scala/weaver/Filters.scala | 29 +++++- .../main/scala/weaver/internals/TagExpr.scala | 26 ++++++ .../weaver/internals/TagExprParser.scala | 91 +++++++++++++++++++ .../src/test/scala/TagExprParserTests.scala | 78 ++++++++++++++++ 5 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala create mode 100644 modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala create mode 100644 modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala diff --git a/build.sbt b/build.sbt index 538a8697..111a2fa9 100644 --- a/build.sbt +++ b/build.sbt @@ -42,6 +42,7 @@ ThisBuild / scalaVersion := scala213 // the default Scala val Version = new { val catsEffect = "3.6.3" val catsLaws = "2.11.0" + val catsParse = "0.3.10" val discipline = "1.5.1" val fs2 = "3.12.2" val junit = "4.13.2" @@ -71,6 +72,7 @@ lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform) libraryDependencies ++= Seq( "co.fs2" %%% "fs2-core" % Version.fs2, "org.typelevel" %%% "cats-effect" % Version.catsEffect, + "org.typelevel" %%% "cats-parse" % Version.catsParse, // https://github.com/portable-scala/portable-scala-reflect/issues/23 "org.portable-scala" %%% "portable-scala-reflect" % Version.portableReflect cross CrossVersion.for3Use2_13, "org.typelevel" %% "scalac-compat-annotation" % Version.scalacCompatAnnotation, diff --git a/modules/core/shared/src/main/scala/weaver/Filters.scala b/modules/core/shared/src/main/scala/weaver/Filters.scala index d525a48f..67248516 100644 --- a/modules/core/shared/src/main/scala/weaver/Filters.scala +++ b/modules/core/shared/src/main/scala/weaver/Filters.scala @@ -1,6 +1,7 @@ package weaver import java.util.regex.Pattern +import weaver.internals.TagExprParser private[weaver] object Filters { @@ -29,6 +30,17 @@ private[weaver] object Filters { } } + private def createTagFilter(expr: String): TestName => Boolean = { + TagExprParser.parse(expr) match { + case Right(tagExpr) => + testName => tagExpr.eval(testName.tags) + case Left(error) => + throw new IllegalArgumentException( + s"Invalid tag expression '$expr': $error" + ) + } + } + private[weaver] def filterTests(suiteName: String)( args: List[String]): TestName => Boolean = { @@ -49,11 +61,22 @@ private[weaver] object Filters { import scala.util.Try def indexOfOption(opt: String): Option[Int] = Option(args.indexOf(opt)).filter(_ >= 0) - val maybePattern = for { + + // Tag-based filtering + val maybeTagFilter = for { + index <- indexOfOption("-t").orElse(indexOfOption("--tags")) + expr <- Try(args(index + 1)).toOption + } yield createTagFilter(expr) + + // Keep existing pattern-based filtering for backwards compatibility + val maybePatternFilter = for { index <- indexOfOption("-o").orElse(indexOfOption("--only")) filter <- Try(args(index + 1)).toOption } yield toPredicate(filter) - testId => maybePattern.forall(_.apply(testId)) - } + testName => { + maybeTagFilter.forall(_.apply(testName)) && + maybePatternFilter.forall(_.apply(testName)) + } + } } diff --git a/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala b/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala new file mode 100644 index 00000000..937e5190 --- /dev/null +++ b/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala @@ -0,0 +1,26 @@ +package weaver.internals + +sealed trait TagExpr { + def eval(tags: Set[String]): Boolean +} + +object TagExpr { + case class Atom(name: String) extends TagExpr { + def eval(tags: Set[String]): Boolean = tags.contains(name) + } + + case class Not(expr: TagExpr) extends TagExpr { + def eval(tags: Set[String]): Boolean = !expr.eval(tags) + } + + case class And(left: TagExpr, right: TagExpr) extends TagExpr { + def eval(tags: Set[String]): Boolean = + left.eval(tags) && right.eval(tags) + } + + case class Or(left: TagExpr, right: TagExpr) extends TagExpr { + def eval(tags: Set[String]): Boolean = + left.eval(tags) || right.eval(tags) + } + +} diff --git a/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala b/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala new file mode 100644 index 00000000..a1378e40 --- /dev/null +++ b/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala @@ -0,0 +1,91 @@ +package weaver.internals + +import cats.parse.{ Parser => P, Parser0 => P0 } +import cats.syntax.show.* + +object TagExprParser { + import TagExpr._ + + private val whitespace: P[Unit] = P.charIn(" \t\r\n").void + private val whitespaces0: P0[Unit] = whitespace.rep0.void + private val whitespaces1: P[Unit] = whitespace.rep.void + + private val tagName: P[String] = { + val tagChar = P.charIn('a' to 'z') | + P.charIn('A' to 'Z') | + P.charIn('0' to '9') | + P.charIn("_-") + tagChar.rep.string + } + + // Keywords + private val andKeyword: P[Unit] = + P.string("and").surroundedBy(whitespaces0) + + private val orKeyword: P[Unit] = + P.string("or").surroundedBy(whitespaces0) + + private val notKeyword: P[Unit] = + P.string("not") <* whitespaces1 + + // Parentheses + private val leftParen: P[Unit] = + P.char('(').surroundedBy(whitespaces0) + + private val rightParen: P[Unit] = + P.char(')').surroundedBy(whitespaces0) + + // Forward declaration for recursive grammar + private def expression: P[TagExpr] = P.recursive[TagExpr] { recurse => + // Atom: either a tag name or parenthesized expression + def atom: P[TagExpr] = { + val tag = tagName.map(Atom.apply) + val parens = recurse.between(leftParen, rightParen) + + (parens | tag).surroundedBy(whitespaces0) + }.withContext("atom") + + // Not expression (highest precedence) + val notExpr: P[TagExpr] = P.recursive[TagExpr] { recurseNot => + val not = (notKeyword *> recurseNot).map(Not.apply) + not.backtrack | atom // Need backtrack here! + }.withContext("notExpr") + + // And expression (medium precedence) + val andExpr: P[TagExpr] = { + // Use rep.sep for left-associative 'and' chains + P.repSep(notExpr, min = 1, sep = andKeyword).map { exprs => + exprs.reduceLeft(And.apply) + } + }.withContext("andExpr") + + // Or expression (lowest precedence) + val orExpr: P[TagExpr] = { + // Use rep.sep for left-associative 'or' chains + P.repSep(andExpr, min = 1, sep = orKeyword).map { exprs => + exprs.reduceLeft(Or.apply) + } + }.withContext("orExpr") + + orExpr.surroundedBy(whitespaces0) + } + + // Main parse function + def parse(input: String): Either[String, TagExpr] = { + + expression.parseAll(input) match { + case Right(result) => Right(result) + case Left(error) => + // Extract line/column info for better error messages + val pos = error.failedAtOffset + val expectation = error.expected + val snippet = input.take(pos + 10).drop(Math.max(0, pos - 10)) + Left(show"Parse error at position $pos near '...$snippet...', expecting one of: ${expectation}") + } + } + + // Helper function to validate tag names (optional) + def isValidTagName(tag: String): Boolean = { + tagName.parseAll(tag).isRight + } +} diff --git a/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala b/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala new file mode 100644 index 00000000..f904a70b --- /dev/null +++ b/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala @@ -0,0 +1,78 @@ +package weaver +package framework +package test + +import scala.concurrent.duration._ +import weaver.internals.TagExprParser +import weaver.internals.TagExpr +import weaver.internals.TagExpr.* + +object TagExprParserTests extends SimpleIOSuite { + + List( + "foo" -> Right(Atom("foo")), + "(foo)" -> Right(Atom("foo")), + "not foo" -> Right(Not(Atom("foo"))), + "not (foo)" -> Right(Not(Atom("foo"))), + // "not(foo)" -> Right(Not(Atom("foo"))), TODO Add ( as stopword + "not not foo" -> Right(Not(Not(Atom("foo")))), + "foo or bar" -> Right(Or(Atom("foo"), Atom("bar"))), + "foo and bar" -> Right(And(Atom("foo"), Atom("bar"))), + "foo or not bar" -> Right(Or(Atom("foo"), Not(Atom("bar")))), + "not foo or bar" -> Right(Or(Not(Atom("foo")), Atom("bar"))), + "not foo or not bar" -> Right(Or(Not(Atom("foo")), Not(Atom("bar")))), + "foo or bar and foo or bar" -> Right( + Or( + Or(Atom("foo"), + And(Atom("bar"), Atom("foo"))), + Atom("bar") + ) + ), + "(foo or bar) and (foo or bar)" -> Right( + And( + Or(Atom("foo"), Atom("bar")), + Or(Atom("foo"), Atom("bar")) + ) + ) + ).map { case (expr, expected) => + pureTest(s"'$expr' should be parsed to $expected") { + val result = TagExprParser.parse(expr) + expect.same(expected, result) + } + } + + // pureTest("'foo' should be parsed to Atom('foo')") { + // val result = TagExprParser.parse("foo") + // expect.same(Right(TagExpr.Atom("foo")), result) + // } + + // pureTest("'(foo)' should be parsed to Atom('foo')") { + // val result = TagExprParser.parse("(foo)") + // expect.same(Right(TagExpr.Atom("foo")), result) + // } + + // pureTest("'not foo' should be parsed to Not(Atom('foo'))") { + // val result = TagExprParser.parse("not foo") + // expect.same(Right(TagExpr.Not(TagExpr.Atom("foo"))), result) + // } + + // pureTest("'not (foo)' should be parsed to Not(Atom('foo'))") { + // val result = TagExprParser.parse("not (foo)") + // expect.same(Right(TagExpr.Not(TagExpr.Atom("foo"))), result) + // } + + // pureTest("'(not foo)' should be parsed to Not(Atom('foo'))") { + // val result = TagExprParser.parse("not foo") + // expect.same(Right(TagExpr.Not(TagExpr.Atom("foo"))), result) + // } + + // pureTest("'(not (foo))' should be parsed to Not(Atom('foo'))") { + // val result = TagExprParser.parse("not foo") + // expect.same(Right(TagExpr.Not(TagExpr.Atom("foo"))), result) + // } + + // pureTest("'not not foo' should be parsed to Not(Not(Atom('foo')))") { + // val result = TagExprParser.parse("not not foo") + // expect.same(Right(TagExpr.Not(TagExpr.Not(TagExpr.Atom("foo")))), result) + // } +} From 1a88d4c7767fb233813ee228fec1200914e67fdf Mon Sep 17 00:00:00 2001 From: Filippo De Luca Date: Wed, 15 Oct 2025 22:05:03 +0200 Subject: [PATCH 02/12] Refactor TagExprParser and clean up commented test cases in TagExprParserTests --- .../weaver/internals/TagExprParser.scala | 3 -- .../src/test/scala/TagExprParserTests.scala | 35 ------------------- 2 files changed, 38 deletions(-) diff --git a/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala b/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala index a1378e40..f4541c4d 100644 --- a/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala +++ b/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala @@ -18,7 +18,6 @@ object TagExprParser { tagChar.rep.string } - // Keywords private val andKeyword: P[Unit] = P.string("and").surroundedBy(whitespaces0) @@ -28,7 +27,6 @@ object TagExprParser { private val notKeyword: P[Unit] = P.string("not") <* whitespaces1 - // Parentheses private val leftParen: P[Unit] = P.char('(').surroundedBy(whitespaces0) @@ -70,7 +68,6 @@ object TagExprParser { orExpr.surroundedBy(whitespaces0) } - // Main parse function def parse(input: String): Either[String, TagExpr] = { expression.parseAll(input) match { diff --git a/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala b/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala index f904a70b..a21fb7d9 100644 --- a/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala +++ b/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala @@ -40,39 +40,4 @@ object TagExprParserTests extends SimpleIOSuite { expect.same(expected, result) } } - - // pureTest("'foo' should be parsed to Atom('foo')") { - // val result = TagExprParser.parse("foo") - // expect.same(Right(TagExpr.Atom("foo")), result) - // } - - // pureTest("'(foo)' should be parsed to Atom('foo')") { - // val result = TagExprParser.parse("(foo)") - // expect.same(Right(TagExpr.Atom("foo")), result) - // } - - // pureTest("'not foo' should be parsed to Not(Atom('foo'))") { - // val result = TagExprParser.parse("not foo") - // expect.same(Right(TagExpr.Not(TagExpr.Atom("foo"))), result) - // } - - // pureTest("'not (foo)' should be parsed to Not(Atom('foo'))") { - // val result = TagExprParser.parse("not (foo)") - // expect.same(Right(TagExpr.Not(TagExpr.Atom("foo"))), result) - // } - - // pureTest("'(not foo)' should be parsed to Not(Atom('foo'))") { - // val result = TagExprParser.parse("not foo") - // expect.same(Right(TagExpr.Not(TagExpr.Atom("foo"))), result) - // } - - // pureTest("'(not (foo))' should be parsed to Not(Atom('foo'))") { - // val result = TagExprParser.parse("not foo") - // expect.same(Right(TagExpr.Not(TagExpr.Atom("foo"))), result) - // } - - // pureTest("'not not foo' should be parsed to Not(Not(Atom('foo')))") { - // val result = TagExprParser.parse("not not foo") - // expect.same(Right(TagExpr.Not(TagExpr.Not(TagExpr.Atom("foo")))), result) - // } } From 8ba9c8dbc11beae1b4d1af48e1c2121637a0263c Mon Sep 17 00:00:00 2001 From: Filippo De Luca Date: Wed, 15 Oct 2025 23:06:27 +0200 Subject: [PATCH 03/12] Refactor TagExprParser and update tests for improved expression parsing --- .../scala/weaver/internals/TagExprParser.scala | 15 +++++++-------- .../src/test/scala/TagExprParserTests.scala | 7 ++++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala b/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala index f4541c4d..856d99cb 100644 --- a/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala +++ b/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala @@ -18,6 +18,12 @@ object TagExprParser { tagChar.rep.string } + private val leftParen: P[Unit] = + P.char('(').surroundedBy(whitespaces0) + + private val rightParen: P[Unit] = + P.char(')').surroundedBy(whitespaces0) + private val andKeyword: P[Unit] = P.string("and").surroundedBy(whitespaces0) @@ -27,12 +33,6 @@ object TagExprParser { private val notKeyword: P[Unit] = P.string("not") <* whitespaces1 - private val leftParen: P[Unit] = - P.char('(').surroundedBy(whitespaces0) - - private val rightParen: P[Unit] = - P.char(')').surroundedBy(whitespaces0) - // Forward declaration for recursive grammar private def expression: P[TagExpr] = P.recursive[TagExpr] { recurse => // Atom: either a tag name or parenthesized expression @@ -45,8 +45,7 @@ object TagExprParser { // Not expression (highest precedence) val notExpr: P[TagExpr] = P.recursive[TagExpr] { recurseNot => - val not = (notKeyword *> recurseNot).map(Not.apply) - not.backtrack | atom // Need backtrack here! + (notKeyword *> recurseNot).map(Not.apply).backtrack | atom }.withContext("notExpr") // And expression (medium precedence) diff --git a/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala b/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala index a21fb7d9..e942611e 100644 --- a/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala +++ b/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala @@ -2,9 +2,7 @@ package weaver package framework package test -import scala.concurrent.duration._ import weaver.internals.TagExprParser -import weaver.internals.TagExpr import weaver.internals.TagExpr.* object TagExprParserTests extends SimpleIOSuite { @@ -13,8 +11,11 @@ object TagExprParserTests extends SimpleIOSuite { "foo" -> Right(Atom("foo")), "(foo)" -> Right(Atom("foo")), "not foo" -> Right(Not(Atom("foo"))), + "notfoo" -> Right(Atom("notfoo")), "not (foo)" -> Right(Not(Atom("foo"))), - // "not(foo)" -> Right(Not(Atom("foo"))), TODO Add ( as stopword + "not(foo)" -> Right( + Not(Atom("foo")) + ), // FIXME This require a bit of fiddling "not not foo" -> Right(Not(Not(Atom("foo")))), "foo or bar" -> Right(Or(Atom("foo"), Atom("bar"))), "foo and bar" -> Right(And(Atom("foo"), Atom("bar"))), From ceac5d6736540700a11d56460858cac2c5e8b6fb Mon Sep 17 00:00:00 2001 From: Filippo De Luca Date: Fri, 17 Oct 2025 01:07:19 +0200 Subject: [PATCH 04/12] Almost a working solution --- .../src/main/scala/weaver/Filters.scala | 2 - .../main/scala/weaver/internals/TagExpr.scala | 57 +++ .../weaver/internals/TagExprParser.scala | 72 ++-- .../src/test/scala/TagExprParserTests.scala | 91 ++++- .../shared/src/test/scala/TagExprTests.scala | 371 ++++++++++++++++++ 5 files changed, 541 insertions(+), 52 deletions(-) create mode 100644 modules/framework-cats/shared/src/test/scala/TagExprTests.scala diff --git a/modules/core/shared/src/main/scala/weaver/Filters.scala b/modules/core/shared/src/main/scala/weaver/Filters.scala index 67248516..b7571207 100644 --- a/modules/core/shared/src/main/scala/weaver/Filters.scala +++ b/modules/core/shared/src/main/scala/weaver/Filters.scala @@ -62,13 +62,11 @@ private[weaver] object Filters { def indexOfOption(opt: String): Option[Int] = Option(args.indexOf(opt)).filter(_ >= 0) - // Tag-based filtering val maybeTagFilter = for { index <- indexOfOption("-t").orElse(indexOfOption("--tags")) expr <- Try(args(index + 1)).toOption } yield createTagFilter(expr) - // Keep existing pattern-based filtering for backwards compatibility val maybePatternFilter = for { index <- indexOfOption("-o").orElse(indexOfOption("--only")) filter <- Try(args(index + 1)).toOption diff --git a/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala b/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala index 937e5190..dc3adfdc 100644 --- a/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala +++ b/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala @@ -1,14 +1,71 @@ package weaver.internals +import cats.parse.{ Parser0 => P0, Parser => P } +import cats.syntax.all.* + sealed trait TagExpr { def eval(tags: Set[String]): Boolean } object TagExpr { + case class Atom(name: String) extends TagExpr { def eval(tags: Set[String]): Boolean = tags.contains(name) } + object Wildcard { + val parser = { + val validCharP = P.charIn('a' to 'z') | + P.charIn('A' to 'Z') | + P.charIn('0' to '9') | + P.charIn("_-:") + val literalP: P[P[String]] = validCharP.rep.map { cs => + val str = cs.mkString_("") + P.string(str).as(str) + } + val questionMarkP: P[P[String]] = P.char('?').map { _ => + validCharP.map(_.toString) + } + val starP: P[P0[String]] = P.char('*').map { _ => + validCharP.rep0.string + } + + (starP | questionMarkP | literalP).rep.map { parts => + parts.reduceLeft { (acc, next) => + (acc, next).mapN(_ + _) + } + } + }.withString.map { case (parser, pattern) => + Wildcard(pattern, parser) + } + + def fromPattern(pattern: String): Either[P.Error, Wildcard] = + parser.parseAll(pattern) + + def unsafeFromPattern(pattern: String): Wildcard = + fromPattern(pattern).leftMap { e => + new RuntimeException( + s"The pattern: ${pattern} is not a valid tag wildcard. ${e}") + }.fold(throw _, identity) + } + + case class Wildcard private (patternStr: String, parser: P0[String]) + extends TagExpr { + def eval(tags: Set[String]): Boolean = { + tags.exists(tag => parser.parseAll(tag).isRight) + } + + // Override equals and hashCode to only consider patternStr, not parser + override def equals(obj: Any): Boolean = obj match { + case that: Wildcard => this.patternStr == that.patternStr + case _ => false + } + + override def hashCode(): Int = patternStr.hashCode() + + override def toString: String = s"Wildcard($patternStr)" + } + case class Not(expr: TagExpr) extends TagExpr { def eval(tags: Set[String]): Boolean = !expr.eval(tags) } diff --git a/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala b/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala index 856d99cb..53b56cfc 100644 --- a/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala +++ b/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala @@ -1,70 +1,86 @@ package weaver.internals import cats.parse.{ Parser => P, Parser0 => P0 } -import cats.syntax.show.* +import cats.syntax.all.* object TagExprParser { import TagExpr._ - private val whitespace: P[Unit] = P.charIn(" \t\r\n").void - private val whitespaces0: P0[Unit] = whitespace.rep0.void - private val whitespaces1: P[Unit] = whitespace.rep.void - - private val tagName: P[String] = { - val tagChar = P.charIn('a' to 'z') | - P.charIn('A' to 'Z') | - P.charIn('0' to '9') | - P.charIn("_-") - tagChar.rep.string + // In GitHub syntax, space is significant (it's the AND operator) + // So we only skip tabs, \r, \n for optional whitespace, NOT spaces + private val nonSpaceWhitespace: P[Unit] = P.charIn("\t\r\n").void + private val nonSpaceWhitespaces0: P0[Unit] = nonSpaceWhitespace.rep0.void + + // Valid characters in a tag name (including colon, but not wildcards) + private val tagChar: P[Char] = P.charIn('a' to 'z') | + P.charIn('A' to 'Z') | + P.charIn('0' to '9') | + P.charIn("_-:") + + // A tag pattern can contain wildcards (* and ?) or regular characters + private val tagPattern: P[String] = { + val wildcardOrChar = P.charIn("*?") | tagChar + wildcardOrChar.rep.string } private val leftParen: P[Unit] = - P.char('(').surroundedBy(whitespaces0) + P.char('(').surroundedBy(nonSpaceWhitespaces0) private val rightParen: P[Unit] = - P.char(')').surroundedBy(whitespaces0) + P.char(')').surroundedBy(nonSpaceWhitespaces0) - private val andKeyword: P[Unit] = - P.string("and").surroundedBy(whitespaces0) + // GitHub syntax operators + private val andOperator: P[Unit] = + P.char(' ').surroundedBy(nonSpaceWhitespaces0) - private val orKeyword: P[Unit] = - P.string("or").surroundedBy(whitespaces0) + private val orOperator: P[Unit] = + P.char(',').surroundedBy(nonSpaceWhitespaces0) - private val notKeyword: P[Unit] = - P.string("not") <* whitespaces1 + private val notOperator: P[Unit] = + P.char('!').surroundedBy(nonSpaceWhitespaces0) // Forward declaration for recursive grammar private def expression: P[TagExpr] = P.recursive[TagExpr] { recurse => - // Atom: either a tag name or parenthesized expression + // Atom: either a tag name (with optional wildcards) or parenthesized expression def atom: P[TagExpr] = { - val tag = tagName.map(Atom.apply) + val tag = tagPattern.flatMap { pattern => + // If pattern contains wildcards, create a Wildcard node + if (pattern.contains('*') || pattern.contains('?')) { + Wildcard.parser + } else { + P.pure(Atom(pattern)) + } + } val parens = recurse.between(leftParen, rightParen) - (parens | tag).surroundedBy(whitespaces0) + (parens | tag).surroundedBy(nonSpaceWhitespaces0) }.withContext("atom") // Not expression (highest precedence) + // In GitHub syntax: !foo or !(expr) val notExpr: P[TagExpr] = P.recursive[TagExpr] { recurseNot => - (notKeyword *> recurseNot).map(Not.apply).backtrack | atom + (notOperator *> recurseNot).map(Not.apply).backtrack | atom }.withContext("notExpr") // And expression (medium precedence) + // In GitHub syntax: space is AND operator val andExpr: P[TagExpr] = { // Use rep.sep for left-associative 'and' chains - P.repSep(notExpr, min = 1, sep = andKeyword).map { exprs => + P.repSep(notExpr, min = 1, sep = andOperator).map { exprs => exprs.reduceLeft(And.apply) } }.withContext("andExpr") // Or expression (lowest precedence) + // In GitHub syntax: comma is OR operator val orExpr: P[TagExpr] = { // Use rep.sep for left-associative 'or' chains - P.repSep(andExpr, min = 1, sep = orKeyword).map { exprs => + P.repSep(andExpr, min = 1, sep = orOperator).map { exprs => exprs.reduceLeft(Or.apply) } }.withContext("orExpr") - orExpr.surroundedBy(whitespaces0) + orExpr.surroundedBy(nonSpaceWhitespaces0) } def parse(input: String): Either[String, TagExpr] = { @@ -80,8 +96,4 @@ object TagExprParser { } } - // Helper function to validate tag names (optional) - def isValidTagName(tag: String): Boolean = { - tagName.parseAll(tag).isRight - } } diff --git a/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala b/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala index e942611e..f256d7ec 100644 --- a/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala +++ b/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala @@ -8,33 +8,84 @@ import weaver.internals.TagExpr.* object TagExprParserTests extends SimpleIOSuite { List( - "foo" -> Right(Atom("foo")), - "(foo)" -> Right(Atom("foo")), - "not foo" -> Right(Not(Atom("foo"))), - "notfoo" -> Right(Atom("notfoo")), - "not (foo)" -> Right(Not(Atom("foo"))), - "not(foo)" -> Right( - Not(Atom("foo")) - ), // FIXME This require a bit of fiddling - "not not foo" -> Right(Not(Not(Atom("foo")))), - "foo or bar" -> Right(Or(Atom("foo"), Atom("bar"))), - "foo and bar" -> Right(And(Atom("foo"), Atom("bar"))), - "foo or not bar" -> Right(Or(Atom("foo"), Not(Atom("bar")))), - "not foo or bar" -> Right(Or(Not(Atom("foo")), Atom("bar"))), - "not foo or not bar" -> Right(Or(Not(Atom("foo")), Not(Atom("bar")))), - "foo or bar and foo or bar" -> Right( + // Basic atom + "foo" -> Right(Atom("foo")), + "(foo)" -> Right(Atom("foo")), + + // NOT expressions + "!foo" -> Right(Not(Atom("foo"))), + "!(foo)" -> Right(Not(Atom("foo"))), + "!!foo" -> Right(Not(Not(Atom("foo")))), + + // OR expressions + "foo,bar" -> Right(Or(Atom("foo"), Atom("bar"))), + + // AND expressions + "foo bar" -> Right(And(Atom("foo"), Atom("bar"))), + + // Combined expressions + "foo,!bar" -> Right(Or(Atom("foo"), Not(Atom("bar")))), + "!foo,bar" -> Right(Or(Not(Atom("foo")), Atom("bar"))), + "!foo,!bar" -> Right(Or(Not(Atom("foo")), Not(Atom("bar")))), + + // Complex precedence: OR has lower precedence than AND + "foo,bar baz" -> Right( + Or(Atom("foo"), And(Atom("bar"), Atom("baz"))) + ), + "foo,bar baz,qux" -> Right( Or( Or(Atom("foo"), - And(Atom("bar"), Atom("foo"))), - Atom("bar") + And(Atom("bar"), Atom("baz"))), + Atom("qux") ) ), - "(foo or bar) and (foo or bar)" -> Right( + + // Parentheses change precedence + "(foo,bar) (baz,qux)" -> Right( And( Or(Atom("foo"), Atom("bar")), - Or(Atom("foo"), Atom("bar")) + Or(Atom("baz"), Atom("qux")) ) - ) + ), + + // Example: (x y) + "(x y)" -> Right(And(Atom("x"), Atom("y"))), + + // Example: !(z,t) + "!(z,t)" -> Right(Not(Or(Atom("z"), Atom("t")))), + + // Full example: foo,bar !baz,(x y),!(z,t) + "foo,bar !baz,(x y),!(z,t)" -> Right( + Or( + Or( + Or( + Atom("foo"), + And(Atom("bar"), Not(Atom("baz"))) + ), + And(Atom("x"), Atom("y")) + ), + Not(Or(Atom("z"), Atom("t"))) + ) + ), + + // Wildcard patterns + "bug*" -> Right(Wildcard.unsafeFromPattern("bug*")), + "*bug" -> Right(Wildcard.unsafeFromPattern("*bug")), + "bug?" -> Right(Wildcard.unsafeFromPattern("bug?")), + "?bug" -> Right(Wildcard.unsafeFromPattern("?bug")), + "first*bug" -> Right(Wildcard.unsafeFromPattern("first*bug")), + "a?c" -> Right(Wildcard.unsafeFromPattern("a?c")), + + // Wildcards with colon + "test:*" -> Right(Wildcard.unsafeFromPattern("test:*")), + "*:prod" -> Right(Wildcard.unsafeFromPattern("*:prod")), + + // Wildcards in combinations + "bug*,feature*" -> Right(Or(Wildcard.unsafeFromPattern("bug*"), + Wildcard.unsafeFromPattern("feature*"))), + "test* prod" -> Right(And(Wildcard.unsafeFromPattern("test*"), + Atom("prod"))), + "!bug*" -> Right(Not(Wildcard.unsafeFromPattern("bug*"))) ).map { case (expr, expected) => pureTest(s"'$expr' should be parsed to $expected") { val result = TagExprParser.parse(expr) diff --git a/modules/framework-cats/shared/src/test/scala/TagExprTests.scala b/modules/framework-cats/shared/src/test/scala/TagExprTests.scala new file mode 100644 index 00000000..cebe56db --- /dev/null +++ b/modules/framework-cats/shared/src/test/scala/TagExprTests.scala @@ -0,0 +1,371 @@ +package weaver +package framework +package test + +import weaver.internals.TagExpr.* +import cats.parse.{ Parser => P, Parser0 => P0 } + +object TagExprTests extends SimpleIOSuite { + + pureTest("Atom matches exact tag") { + val expr = Atom("foo") + val tags = Set("foo", "bar", "baz") + expect(expr.eval(tags)) + } + + pureTest("Atom does not match different tag") { + val expr = Atom("foo") + val tags = Set("bar", "baz") + expect(!expr.eval(tags)) + } + + // Wildcard fiddling tests + pureTest("Wildcard fiddling 1".only) { + val validCharP: P[Char] = + P.charIn('a' to 'z') | + P.charIn('A' to 'Z') | + P.charIn('0' to '9') | + P.charIn("_-:") + + val bugP: P0[String] = P.string("bug").as("bug") + val starP: P0[String] = validCharP.rep0.string + + val parser = starP.backtrack ~ bugP + expect(clue(parser.parseAll("foobar")).isRight) + } + + // Wildcard pattern parsing tests + pureTest("Wildcard.fromPattern should parse literal pattern") { + val result = Wildcard.fromPattern("foo") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse pattern with star") { + val result = Wildcard.fromPattern("bug*") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse pattern with leading star") { + val result = Wildcard.fromPattern("*bug") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse pattern with question mark") { + val result = Wildcard.fromPattern("bug?") + expect(clue(result).isRight) + } + + pureTest( + "Wildcard.fromPattern should parse pattern with leading question mark") { + val result = Wildcard.fromPattern("?bug") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse pattern with star in middle") { + val result = Wildcard.fromPattern("first*bug") + expect(clue(result).isRight) + } + + pureTest( + "Wildcard.fromPattern should parse pattern with question mark in middle") { + val result = Wildcard.fromPattern("a?c") + expect(clue(result).isRight) + } + + pureTest( + "Wildcard.fromPattern should parse pattern with multiple wildcards") { + val result = Wildcard.fromPattern("a*b*c") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse pattern with colon") { + val result = Wildcard.fromPattern("test:unit") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse pattern with colon and star") { + val result = Wildcard.fromPattern("test:*") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse pattern with hyphen") { + val result = Wildcard.fromPattern("bug-123") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse pattern with underscore") { + val result = Wildcard.fromPattern("test_case") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse pattern with only star") { + val result = Wildcard.fromPattern("*") + expect(clue(result).isRight) + } + + pureTest( + "Wildcard.fromPattern should parse pattern with only question mark") { + val result = Wildcard.fromPattern("?") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse complex pattern") { + val result = Wildcard.fromPattern("test-*:prod-?") + expect(clue(result).isRight) + } + + // Negative tests: invalid patterns + pureTest("Wildcard.fromPattern should reject empty pattern") { + val result = Wildcard.fromPattern("") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with spaces") { + val result = Wildcard.fromPattern("foo bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with comma") { + val result = Wildcard.fromPattern("foo,bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with exclamation mark") { + val result = Wildcard.fromPattern("!foo") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with parentheses") { + val result = Wildcard.fromPattern("(foo)") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with at sign") { + val result = Wildcard.fromPattern("foo@bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with hash") { + val result = Wildcard.fromPattern("foo#bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with dollar sign") { + val result = Wildcard.fromPattern("foo$bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with percent") { + val result = Wildcard.fromPattern("foo%bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with ampersand") { + val result = Wildcard.fromPattern("foo&bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with slash") { + val result = Wildcard.fromPattern("foo/bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with backslash") { + val result = Wildcard.fromPattern("foo\\bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with brackets") { + val result = Wildcard.fromPattern("foo[bar]") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with pipe") { + val result = Wildcard.fromPattern("foo|bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with semicolon") { + val result = Wildcard.fromPattern("foo;bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with dot") { + val result = Wildcard.fromPattern("foo.bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with tab") { + val result = Wildcard.fromPattern("foo\tbar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with newline") { + val result = Wildcard.fromPattern("foo\nbar") + expect(clue(result).isLeft) + } + + // Wildcard matching tests + pureTest("Wildcard: bug* matches bug") { + whenSuccess(Wildcard.fromPattern("bug*")) { wildcard => + val tags = Set("bug") + expect(clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: bug* matches bug-123") { + whenSuccess(Wildcard.fromPattern("bug*")) { wildcard => + val tags = Set("bug-123") + expect(clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: bug* matches bugfix") { + whenSuccess(Wildcard.fromPattern("bug*")) { wildcard => + val tags = Set("bugfix") + expect(clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: bug* does not match foo") { + whenSuccess(Wildcard.fromPattern("bug*")) { wildcard => + val tags = Set("foo") + expect(!clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: *bug matches bug") { + whenSuccess(Wildcard.fromPattern("*bug")) { wildcard => + val tags = Set("bug") + expect(clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: *bug matches critical-bug") { + whenSuccess(Wildcard.fromPattern("*bug")) { wildcard => + val tags = Set("critical-bug") + expect(clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: *bug does not match bugfix") { + whenSuccess(Wildcard.fromPattern("*bug")) { wildcard => + val tags = Set("bugfix") + expect(!clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: bug? matches bug1") { + whenSuccess(Wildcard.fromPattern("bug?")) { wildcard => + val tags = Set("bug1") + expect(clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: bug? matches buga") { + whenSuccess(Wildcard.fromPattern("bug?")) { wildcard => + val tags = Set("buga") + expect(clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: bug? does not match bug") { + whenSuccess(Wildcard.fromPattern("bug?")) { wildcard => + val tags = Set("bug") + expect(!clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: bug? does not match bug12") { + whenSuccess(Wildcard.fromPattern("bug?")) { wildcard => + val tags = Set("bug12") + expect(!clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: ?bug matches abug") { + whenSuccess(Wildcard.fromPattern("?bug")) { wildcard => + val tags = Set("abug") + expect(clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: ?bug does not match bug") { + whenSuccess(Wildcard.fromPattern("?bug")) { wildcard => + val tags = Set("bug") + expect(!clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: first*bug matches firstbug") { + whenSuccess(Wildcard.fromPattern("first*bug")) { wildcard => + val tags = Set("firstbug") + expect(clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: first*bug matches first-critical-bug") { + whenSuccess(Wildcard.fromPattern("first*bug")) { wildcard => + val tags = Set("first-critical-bug") + expect(clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: first*bug does not match firstbugfix") { + whenSuccess(Wildcard.fromPattern("first*bug")) { wildcard => + val tags = Set("firstbugfix") + expect(!clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: a?c matches abc") { + whenSuccess(Wildcard.fromPattern("a?c")) { wildcard => + val tags = Set("abc") + expect(clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: a?c matches a1c") { + whenSuccess(Wildcard.fromPattern("a?c")) { wildcard => + val tags = Set("a1c") + expect(clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: a?c does not match ac") { + whenSuccess(Wildcard.fromPattern("a?c")) { wildcard => + val tags = Set("ac") + expect(!clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: a?c does not match abcd") { + whenSuccess(Wildcard.fromPattern("a?c")) { wildcard => + val tags = Set("abcd") + expect(!clue(wildcard).eval(tags)) + } + } + + // Wildcards with colon + pureTest("Wildcard: test:* matches test:unit") { + whenSuccess(Wildcard.fromPattern("test:*")) { wildcard => + val tags = Set("test:unit") + expect(clue(wildcard).eval(tags)) + } + } + + pureTest("Wildcard: *:prod matches env:prod") { + whenSuccess(Wildcard.fromPattern("*:prod")) { wildcard => + val tags = Set("env:prod") + expect(clue(wildcard).eval(tags)) + } + } + + // No wildcards (should work like exact match) + pureTest("Wildcard: foo matches foo exactly") { + whenSuccess(Wildcard.fromPattern("foo")) { wildcard => + val tags = Set("foo", "foobar") + expect(clue(wildcard).eval(tags)) + } + } +} From 8e1f409c0a8d748b450e343227dfa070ba8b1374 Mon Sep 17 00:00:00 2001 From: Filippo De Luca Date: Fri, 17 Oct 2025 01:25:30 +0200 Subject: [PATCH 05/12] Use void for wildcard --- .../main/scala/weaver/internals/TagExpr.scala | 51 +++++++++++++------ .../shared/src/test/scala/TagExprTests.scala | 16 ------ 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala b/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala index dc3adfdc..3599b51e 100644 --- a/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala +++ b/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala @@ -14,27 +14,48 @@ object TagExpr { } object Wildcard { + /* The complexity here comes from the fact that * at the beginning or in the middle needs to + * know the next expected parser to know when to stop consuming */ val parser = { val validCharP = P.charIn('a' to 'z') | P.charIn('A' to 'Z') | P.charIn('0' to '9') | P.charIn("_-:") - val literalP: P[P[String]] = validCharP.rep.map { cs => - val str = cs.mkString_("") - P.string(str).as(str) - } - val questionMarkP: P[P[String]] = P.char('?').map { _ => - validCharP.map(_.toString) - } - val starP: P[P0[String]] = P.char('*').map { _ => - validCharP.rep0.string - } - (starP | questionMarkP | literalP).rep.map { parts => - parts.reduceLeft { (acc, next) => - (acc, next).mapN(_ + _) - } + sealed trait Token + case class Literal(str: String) extends Token + case object Star extends Token + case object Question extends Token + + val literalP: P[Token] = + validCharP.rep.map(cs => Literal(cs.toList.mkString)) + val starP: P[Token] = P.char('*').as(Star) + val questionP: P[Token] = P.char('?').as(Question) + + val tokenP: P[Token] = starP | questionP | literalP + val tokensP: P[List[Token]] = tokenP.rep.map(_.toList) + + def loop(tokens: List[Token]): P0[Unit] = tokens match { + case Nil => + P.unit + + case Literal(str) :: rest => + (P.string(str) ~ loop(rest)).void + + case Question :: rest => + (validCharP ~ loop(rest)).void + + case Star :: Nil => + // Star at the end - consume everything + validCharP.rep0.void + + case Star :: rest => + // Star followed by something we need to use repUntil0 to stop before the next parser + val afterStar = loop(rest) + (validCharP.repUntil0(afterStar) ~ afterStar).void } + + tokensP.map(loop) }.withString.map { case (parser, pattern) => Wildcard(pattern, parser) } @@ -49,7 +70,7 @@ object TagExpr { }.fold(throw _, identity) } - case class Wildcard private (patternStr: String, parser: P0[String]) + case class Wildcard private (patternStr: String, parser: P0[Unit]) extends TagExpr { def eval(tags: Set[String]): Boolean = { tags.exists(tag => parser.parseAll(tag).isRight) diff --git a/modules/framework-cats/shared/src/test/scala/TagExprTests.scala b/modules/framework-cats/shared/src/test/scala/TagExprTests.scala index cebe56db..e4e21750 100644 --- a/modules/framework-cats/shared/src/test/scala/TagExprTests.scala +++ b/modules/framework-cats/shared/src/test/scala/TagExprTests.scala @@ -3,7 +3,6 @@ package framework package test import weaver.internals.TagExpr.* -import cats.parse.{ Parser => P, Parser0 => P0 } object TagExprTests extends SimpleIOSuite { @@ -19,21 +18,6 @@ object TagExprTests extends SimpleIOSuite { expect(!expr.eval(tags)) } - // Wildcard fiddling tests - pureTest("Wildcard fiddling 1".only) { - val validCharP: P[Char] = - P.charIn('a' to 'z') | - P.charIn('A' to 'Z') | - P.charIn('0' to '9') | - P.charIn("_-:") - - val bugP: P0[String] = P.string("bug").as("bug") - val starP: P0[String] = validCharP.rep0.string - - val parser = starP.backtrack ~ bugP - expect(clue(parser.parseAll("foobar")).isRight) - } - // Wildcard pattern parsing tests pureTest("Wildcard.fromPattern should parse literal pattern") { val result = Wildcard.fromPattern("foo") From 7914dfd0fae02bc1fd1634be468ac10b4b621835 Mon Sep 17 00:00:00 2001 From: Filippo De Luca Date: Fri, 17 Oct 2025 02:08:54 +0200 Subject: [PATCH 06/12] Get rid of Atom --- .../main/scala/weaver/internals/TagExpr.scala | 4 -- .../weaver/internals/TagExprParser.scala | 46 ++++----------- .../src/test/scala/TagExprParserTests.scala | 59 +++++++++++-------- .../shared/src/test/scala/TagExprTests.scala | 4 +- 4 files changed, 50 insertions(+), 63 deletions(-) diff --git a/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala b/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala index 3599b51e..ef3b733a 100644 --- a/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala +++ b/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala @@ -9,10 +9,6 @@ sealed trait TagExpr { object TagExpr { - case class Atom(name: String) extends TagExpr { - def eval(tags: Set[String]): Boolean = tags.contains(name) - } - object Wildcard { /* The complexity here comes from the fact that * at the beginning or in the middle needs to * know the next expected parser to know when to stop consuming */ diff --git a/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala b/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala index 53b56cfc..6a5e56f5 100644 --- a/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala +++ b/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala @@ -11,25 +11,12 @@ object TagExprParser { private val nonSpaceWhitespace: P[Unit] = P.charIn("\t\r\n").void private val nonSpaceWhitespaces0: P0[Unit] = nonSpaceWhitespace.rep0.void - // Valid characters in a tag name (including colon, but not wildcards) - private val tagChar: P[Char] = P.charIn('a' to 'z') | - P.charIn('A' to 'Z') | - P.charIn('0' to '9') | - P.charIn("_-:") - - // A tag pattern can contain wildcards (* and ?) or regular characters - private val tagPattern: P[String] = { - val wildcardOrChar = P.charIn("*?") | tagChar - wildcardOrChar.rep.string - } - private val leftParen: P[Unit] = P.char('(').surroundedBy(nonSpaceWhitespaces0) private val rightParen: P[Unit] = P.char(')').surroundedBy(nonSpaceWhitespaces0) - // GitHub syntax operators private val andOperator: P[Unit] = P.char(' ').surroundedBy(nonSpaceWhitespaces0) @@ -41,54 +28,45 @@ object TagExprParser { // Forward declaration for recursive grammar private def expression: P[TagExpr] = P.recursive[TagExpr] { recurse => - // Atom: either a tag name (with optional wildcards) or parenthesized expression - def atom: P[TagExpr] = { - val tag = tagPattern.flatMap { pattern => - // If pattern contains wildcards, create a Wildcard node - if (pattern.contains('*') || pattern.contains('?')) { - Wildcard.parser - } else { - P.pure(Atom(pattern)) - } - } + // either a tag name (with optional wildcards) or parenthesized expression + val wildcardP: P[TagExpr] = { val parens = recurse.between(leftParen, rightParen) - (parens | tag).surroundedBy(nonSpaceWhitespaces0) - }.withContext("atom") + (parens | Wildcard.parser).surroundedBy(nonSpaceWhitespaces0) + }.withContext("wildcard") // Not expression (highest precedence) // In GitHub syntax: !foo or !(expr) - val notExpr: P[TagExpr] = P.recursive[TagExpr] { recurseNot => - (notOperator *> recurseNot).map(Not.apply).backtrack | atom + val notExprP: P[TagExpr] = P.recursive[TagExpr] { recurseNot => + (notOperator *> recurseNot).map(Not.apply).backtrack | wildcardP }.withContext("notExpr") // And expression (medium precedence) // In GitHub syntax: space is AND operator - val andExpr: P[TagExpr] = { + val andExprP: P[TagExpr] = { // Use rep.sep for left-associative 'and' chains - P.repSep(notExpr, min = 1, sep = andOperator).map { exprs => + P.repSep(notExprP, min = 1, sep = andOperator).map { exprs => exprs.reduceLeft(And.apply) } }.withContext("andExpr") // Or expression (lowest precedence) // In GitHub syntax: comma is OR operator - val orExpr: P[TagExpr] = { + val orExprP: P[TagExpr] = { // Use rep.sep for left-associative 'or' chains - P.repSep(andExpr, min = 1, sep = orOperator).map { exprs => + P.repSep(andExprP, min = 1, sep = orOperator).map { exprs => exprs.reduceLeft(Or.apply) } }.withContext("orExpr") - orExpr.surroundedBy(nonSpaceWhitespaces0) + orExprP.surroundedBy(nonSpaceWhitespaces0) } def parse(input: String): Either[String, TagExpr] = { expression.parseAll(input) match { case Right(result) => Right(result) - case Left(error) => - // Extract line/column info for better error messages + case Left(error) => val pos = error.failedAtOffset val expectation = error.expected val snippet = input.take(pos + 10).drop(Math.max(0, pos - 10)) diff --git a/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala b/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala index f256d7ec..9731951f 100644 --- a/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala +++ b/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala @@ -9,62 +9,75 @@ object TagExprParserTests extends SimpleIOSuite { List( // Basic atom - "foo" -> Right(Atom("foo")), - "(foo)" -> Right(Atom("foo")), + "foo" -> Right(Wildcard.unsafeFromPattern("foo")), + "(foo)" -> Right(Wildcard.unsafeFromPattern("foo")), // NOT expressions - "!foo" -> Right(Not(Atom("foo"))), - "!(foo)" -> Right(Not(Atom("foo"))), - "!!foo" -> Right(Not(Not(Atom("foo")))), + "!foo" -> Right(Not(Wildcard.unsafeFromPattern("foo"))), + "!(foo)" -> Right(Not(Wildcard.unsafeFromPattern("foo"))), + "!!foo" -> Right(Not(Not(Wildcard.unsafeFromPattern("foo")))), // OR expressions - "foo,bar" -> Right(Or(Atom("foo"), Atom("bar"))), + "foo,bar" -> Right(Or(Wildcard.unsafeFromPattern("foo"), + Wildcard.unsafeFromPattern("bar"))), // AND expressions - "foo bar" -> Right(And(Atom("foo"), Atom("bar"))), + "foo bar" -> Right(And(Wildcard.unsafeFromPattern("foo"), + Wildcard.unsafeFromPattern("bar"))), // Combined expressions - "foo,!bar" -> Right(Or(Atom("foo"), Not(Atom("bar")))), - "!foo,bar" -> Right(Or(Not(Atom("foo")), Atom("bar"))), - "!foo,!bar" -> Right(Or(Not(Atom("foo")), Not(Atom("bar")))), + "foo,!bar" -> Right(Or(Wildcard.unsafeFromPattern("foo"), + Not(Wildcard.unsafeFromPattern("bar")))), + "!foo,bar" -> Right(Or(Not(Wildcard.unsafeFromPattern("foo")), + Wildcard.unsafeFromPattern("bar"))), + "!foo,!bar" -> Right(Or(Not(Wildcard.unsafeFromPattern("foo")), + Not(Wildcard.unsafeFromPattern("bar")))), // Complex precedence: OR has lower precedence than AND "foo,bar baz" -> Right( - Or(Atom("foo"), And(Atom("bar"), Atom("baz"))) + Or(Wildcard.unsafeFromPattern("foo"), + And(Wildcard.unsafeFromPattern("bar"), + Wildcard.unsafeFromPattern("baz"))) ), "foo,bar baz,qux" -> Right( Or( - Or(Atom("foo"), - And(Atom("bar"), Atom("baz"))), - Atom("qux") + Or(Wildcard.unsafeFromPattern("foo"), + And(Wildcard.unsafeFromPattern("bar"), + Wildcard.unsafeFromPattern("baz"))), + Wildcard.unsafeFromPattern("qux") ) ), // Parentheses change precedence "(foo,bar) (baz,qux)" -> Right( And( - Or(Atom("foo"), Atom("bar")), - Or(Atom("baz"), Atom("qux")) + Or(Wildcard.unsafeFromPattern("foo"), + Wildcard.unsafeFromPattern("bar")), + Or(Wildcard.unsafeFromPattern("baz"), Wildcard.unsafeFromPattern("qux")) ) ), // Example: (x y) - "(x y)" -> Right(And(Atom("x"), Atom("y"))), + "(x y)" -> Right(And(Wildcard.unsafeFromPattern("x"), + Wildcard.unsafeFromPattern("y"))), // Example: !(z,t) - "!(z,t)" -> Right(Not(Or(Atom("z"), Atom("t")))), + "!(z,t)" -> Right(Not(Or(Wildcard.unsafeFromPattern("z"), + Wildcard.unsafeFromPattern("t")))), // Full example: foo,bar !baz,(x y),!(z,t) "foo,bar !baz,(x y),!(z,t)" -> Right( Or( Or( Or( - Atom("foo"), - And(Atom("bar"), Not(Atom("baz"))) + Wildcard.unsafeFromPattern("foo"), + And(Wildcard.unsafeFromPattern("bar"), + Not(Wildcard.unsafeFromPattern("baz"))) ), - And(Atom("x"), Atom("y")) + And(Wildcard.unsafeFromPattern("x"), Wildcard.unsafeFromPattern("y")) ), - Not(Or(Atom("z"), Atom("t"))) + Not(Or(Wildcard.unsafeFromPattern("z"), + Wildcard.unsafeFromPattern("t"))) ) ), @@ -84,7 +97,7 @@ object TagExprParserTests extends SimpleIOSuite { "bug*,feature*" -> Right(Or(Wildcard.unsafeFromPattern("bug*"), Wildcard.unsafeFromPattern("feature*"))), "test* prod" -> Right(And(Wildcard.unsafeFromPattern("test*"), - Atom("prod"))), + Wildcard.unsafeFromPattern("prod"))), "!bug*" -> Right(Not(Wildcard.unsafeFromPattern("bug*"))) ).map { case (expr, expected) => pureTest(s"'$expr' should be parsed to $expected") { diff --git a/modules/framework-cats/shared/src/test/scala/TagExprTests.scala b/modules/framework-cats/shared/src/test/scala/TagExprTests.scala index e4e21750..0dfc03d8 100644 --- a/modules/framework-cats/shared/src/test/scala/TagExprTests.scala +++ b/modules/framework-cats/shared/src/test/scala/TagExprTests.scala @@ -7,13 +7,13 @@ import weaver.internals.TagExpr.* object TagExprTests extends SimpleIOSuite { pureTest("Atom matches exact tag") { - val expr = Atom("foo") + val expr = Wildcard.unsafeFromPattern("foo") val tags = Set("foo", "bar", "baz") expect(expr.eval(tags)) } pureTest("Atom does not match different tag") { - val expr = Atom("foo") + val expr = Wildcard.unsafeFromPattern("foo") val tags = Set("bar", "baz") expect(!expr.eval(tags)) } From a30d6ce72e770d8f40a06899179b0e93f64b717b Mon Sep 17 00:00:00 2001 From: Filippo De Luca Date: Fri, 17 Oct 2025 08:54:51 +0200 Subject: [PATCH 07/12] Updater documentation --- README.md | 40 +++++++-- docs/features/filtering.md | 152 +++++++++++++++++++++++++++++++++- docs/features/tagging.md | 162 ++++++++++++++++++++++++++++++++++++- 3 files changed, 340 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 29f7bf6f..cbeee9b7 100644 --- a/README.md +++ b/README.md @@ -113,10 +113,10 @@ object MySuite extends IOSuite { Weaver also includes support for -| Alias | Suite name | Provided by | Use case | -| ----------------- | ------------------------ | ------------------ | --------------------------------------------- | -| `SimpleIOSuite` | `SimpleMutableIOSuite` | `weaver-cats` | Each test is a standalone `IO` action | -| `IOSuite` | `MutableIOSuite` | `weaver-cats` | Each test needs access to a shared `Resource` | +| Alias | Suite name | Provided by | Use case | +| --------------- | ---------------------- | ------------- | --------------------------------------------- | +| `SimpleIOSuite` | `SimpleMutableIOSuite` | `weaver-cats` | Each test is a standalone `IO` action | +| `IOSuite` | `MutableIOSuite` | `weaver-cats` | Each test needs access to a shared `Resource` | ### Expectations (assertions) @@ -140,18 +140,44 @@ Something worth noting is that expectations are not throwing, and that if the us ### Filtering tests -When using the IOSuite variants, the user can call `sbt`'s test command as such: +Weaver supports powerful test filtering with pattern-based and tag-based filters. + +#### Pattern filtering + +Filter tests by their qualified name using the `-o` or `--only` flag: ```  > testOnly -- -o *foo* ``` -This filter will prevent the execution of any test that doesn't contain the string "foo" in is qualified name. For a test labeled "foo" in a "FooSuite" object, in the package "fooPackage", the qualified name of a test is: +This will run only tests containing "foo" in their qualified name (`fooPackage.FooSuite.foo`). + +#### Tag filtering +Filter tests using GitHub-style tag expressions with the `-t` or `--tags` flag: + +``` +> testOnly -- -t "bug-*,feature-*" ``` -fooPackage.FooSuite.foo + +Tag expressions support: + +- **`,` (comma)** - OR: `bug,feature` matches either tag +- **` ` (space)** - AND: `bug critical` matches both tags +- **`!` (exclamation)** - NOT: `!slow` excludes slow tests +- **`()` (parentheses)** - grouping: `(bug,feature) !wontfix` +- **Wildcards** - `*` (any chars), `?` (one char): `bug-*`, `test-?` + +Add tags to tests: + +```scala +test("my test".tagged("bug").tagged("critical")) { + expect(1 + 1 == 2) +} ``` +See the [filtering documentation](https://typelevel.org/weaver-test/features/filtering.html) for complete details. + ### Running suites in standalone It is possible to run suites outside of your build tool, via a good old `main` function. To do so, you can instantiate the `weaver.Runner`, create a `fs2.Stream` of the suites you want to run, and call `runner.run(stream)`. diff --git a/docs/features/filtering.md b/docs/features/filtering.md index 7bc3e36e..b88987ef 100644 --- a/docs/features/filtering.md +++ b/docs/features/filtering.md @@ -1,14 +1,160 @@ Filtering tests =============== -When using the IOSuite variants, the user can call `sbt`'s test command as such: +Weaver supports two types of test filtering: pattern-based filtering and tag-based filtering. Both can be used independently or combined together. -```  +## Pattern-based filtering + +When using the IOSuite variants, you can filter tests by their qualified name using the `-o` or `--only` flag: + +``` > testOnly -- -o *foo* +> testOnly -- --only *foo* ``` -This filter will prevent the execution of any test that doesn't contain the string "foo" in is qualified name. For a test labeled "foo" in a "FooSuite" object, in the package "fooPackage", the qualified name of a test is: +This filter will prevent the execution of any test that doesn't contain the string "foo" in its qualified name. For a test labeled "foo" in a "FooSuite" object, in the package "fooPackage", the qualified name of a test is: ``` fooPackage.FooSuite.foo ``` + +You can also filter by line number: + +``` +> testOnly -- -o FooSuite.line://42 +``` + +This will only run the test at line 42 in FooSuite. + +## Tag-based filtering + +Weaver supports powerful tag-based filtering using GitHub-style tag expressions with the `-t` or `--tags` flag: + +``` +> testOnly -- -t bug-123 +> testOnly -- --tags "bug-* !bug-wontfix" +``` + +### Tag expression syntax + +Tag expressions use GitHub's label filtering syntax: + +- **`,` (comma)** - OR operator: matches tests with any of the specified tags +- **` ` (space)** - AND operator: matches tests with all of the specified tags +- **`!` (exclamation)** - NOT operator: excludes tests with the specified tag +- **`()` (parentheses)** - grouping to control precedence + +### Examples + +**Match tests with either tag:** +``` +> testOnly -- -t "bug,feature" +``` +Matches tests tagged with `bug` OR `feature`. + +**Match tests with both tags:** +``` +> testOnly -- -t "bug critical" +``` +Matches tests tagged with both `bug` AND `critical`. + +**Exclude specific tags:** +``` +> testOnly -- -t "!slow" +``` +Matches all tests EXCEPT those tagged `slow`. + +**Complex expressions:** +``` +> testOnly -- -t "bug,feature !wontfix" +``` +Matches tests tagged with (`bug` OR `feature`) AND NOT `wontfix`. + +**Grouping with parentheses:** +``` +> testOnly -- -t "(bug,feature) critical" +``` +Matches tests tagged with (`bug` OR `feature`) AND `critical`. + +### Wildcard patterns + +Tag expressions support wildcards for flexible matching: + +- **`*` (asterisk)** - matches zero or more characters +- **`?` (question mark)** - matches exactly one character + +**Examples:** + +**Prefix matching:** +``` +> testOnly -- -t "bug-*" +``` +Matches tags like `bug-123`, `bug-critical`, `bug-feature-x`, etc. + +**Suffix matching:** +``` +> testOnly -- -t "*-prod" +``` +Matches tags like `env-prod`, `db-prod`, etc. + +**Single character:** +``` +> testOnly -- -t "test-?" +``` +Matches tags like `test-1`, `test-a`, but not `test-12`. + +**Infix matching:** +``` +> testOnly -- -t "first*bug" +``` +Matches tags like `firstbug`, `first-critical-bug`, etc. + +**Complex wildcards:** +``` +> testOnly -- -t "bug-*,feature-* !bug-wontfix" +``` +Matches all bug or feature tags except `bug-wontfix`. + +### Precedence rules + +Operators have the following precedence (highest to lowest): + +1. `!` (NOT) +2. ` ` (AND - space) +3. `,` (OR - comma) + +Use parentheses to override the default precedence: + +``` +> testOnly -- -t "tag1,tag2 tag3" # (tag1) OR (tag2 AND tag3) +> testOnly -- -t "(tag1,tag2) tag3" # (tag1 OR tag2) AND (tag3) +``` + +## Combining filters + +You can combine pattern-based (`-o`/`--only`) and tag-based (`-t`/`--tags`) filtering in the same command. When both are specified, **BOTH filters must match** for a test to run (AND logic): + +``` +> testOnly -- -t "bug-*" -o "*integration*" +``` + +This will only run tests that: +- Are tagged with a bug tag (like `bug-123`, `bug-critical`), **AND** +- Have "integration" in their qualified name + +**More examples:** + +``` +> testOnly -- -t "slow" -o "MySuite.*" +``` +Runs only slow tests from MySuite. + +``` +> testOnly -- -t "!flaky" -o "*database*" +``` +Runs all database-related tests that are NOT tagged as flaky. + +``` +> testOnly -- -t "(bug,feature) !wontfix" -o "FooSuite.line://42" +``` +Runs the test at line 42 only if it's tagged as bug or feature, but not wontfix. diff --git a/docs/features/tagging.md b/docs/features/tagging.md index adb1fce1..954e2926 100644 --- a/docs/features/tagging.md +++ b/docs/features/tagging.md @@ -1,14 +1,166 @@ Tagging ======= -Weaver provides some constructs to dynamically tag tests as `ignored` : +Weaver provides constructs to tag tests for organization and selective execution. Tags can be used to categorize tests (e.g., by type, priority, environment) and filter which tests run. + +## Adding tags to tests + +Tests can be tagged using the `.tagged()` method on the test name: ```scala mdoc import weaver._ + +object TaggedSuite extends SimpleIOSuite { + + // Test with a single tag + test("bug fix".tagged("bug")) { + expect(1 + 1 == 2) + } + + // Test with multiple tags (chain .tagged() calls) + test("integration test".tagged("integration").tagged("slow").tagged("database")) { + expect(true) + } + + // Test without tags + test("simple unit test") { + expect(42 == 42) + } +} +``` + +## Special tags: ignore and only + +Weaver provides convenient shortcuts for two special tags: + +### ignore + +The `.ignore` method is equivalent to `.tagged("ignore")`: + +```scala mdoc:reset +import weaver._ + +object IgnoreSuite extends SimpleIOSuite { + + // These two are equivalent + test("skipped test".ignore) { + expect(1 + 1 == 2) + } + + test("also skipped".tagged("ignore")) { + expect(1 + 1 == 2) + } +} +``` + +### only + +The `.only` method is equivalent to `.tagged("only")`: + +```scala mdoc:reset +import weaver._ + +object OnlySuite extends SimpleIOSuite { + + // These two are equivalent + test("run only this".only) { + expect(1 + 1 == 2) + } + + test("run only this too".tagged("only")) { + expect(1 + 1 == 2) + } + + test("this will be skipped") { + expect(42 == 42) + } +} +``` + +When any test in a suite is tagged with `only`, only those tests will run. + +## Tag naming conventions + +Tags should consist of alphanumeric characters, hyphens, underscores, and colons: + +- Valid characters: `a-z`, `A-Z`, `0-9`, `_`, `-`, `:` +- Examples: `bug`, `bug-123`, `test_case`, `env:prod` + +Common tagging strategies: + +```scala +// By category +test("test name".tagged("unit")) +test("test name".tagged("integration")) +test("test name".tagged("e2e")) + +// By priority +test("test name".tagged("critical")) +test("test name".tagged("high")) +test("test name".tagged("low")) + +// By feature or issue +test("test name".tagged("bug-123")) +test("test name".tagged("feature-auth")) +test("test name".tagged("epic-billing")) + +// By environment +test("test name".tagged("env:dev")) +test("test name".tagged("env:staging")) +test("test name".tagged("env:prod")) + +// By speed +test("test name".tagged("fast")) +test("test name".tagged("slow")) + +// By stability +test("test name".tagged("flaky")) +test("test name".tagged("stable")) + +// Multiple tags +test("test name" + .tagged("integration") + .tagged("slow") + .tagged("database") + .tagged("env:staging")) +``` + +## Filtering tests by tags + +See [Filtering](filtering.md) for complete documentation on how to filter tests using tag expressions. + +Quick examples: + +``` +// Run only tests tagged as "bug" +> testOnly -- -t bug + +// Run tests tagged as "bug" OR "feature" +> testOnly -- -t "bug,feature" + +// Run tests tagged as both "integration" AND "database" +> testOnly -- -t "integration database" + +// Run all tests except "slow" ones +> testOnly -- -t "!slow" + +// Run bug tests with wildcards +> testOnly -- -t "bug-*" + +// Exclude ignored tests explicitly +> testOnly -- -t "!ignore" +``` + +## Dynamic tagging (conditional ignore) + +Weaver also provides constructs to dynamically tag tests as ignored at runtime: + +```scala mdoc:reset +import weaver._ import cats.effect.IO import cats.syntax.all._ -object TaggingSuite extends SimpleIOSuite { +object DynamicTaggingSuite extends SimpleIOSuite { test("Only on CI") { for { @@ -21,6 +173,8 @@ object TaggingSuite extends SimpleIOSuite { } ``` -```scala mdoc:passthrough -println(weaver.docs.Output.runSuites(TaggingSuite)) +```scala mdoc:passthrough +println(weaver.docs.Output.runSuites(DynamicTaggingSuite)) ``` + +This allows you to conditionally skip tests based on runtime conditions like environment variables, system properties, or other dynamic checks. From d15f50139008c06543f26bfd8fba3ee67386efd8 Mon Sep 17 00:00:00 2001 From: Filippo De Luca Date: Fri, 17 Oct 2025 09:53:24 +0200 Subject: [PATCH 08/12] Bump up cats-parse to 1.1.0 --- build.sbt | 2 +- docs/features/tagging.md | 31 +++++++++++++++++++++---------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/build.sbt b/build.sbt index 111a2fa9..2ca1491f 100644 --- a/build.sbt +++ b/build.sbt @@ -42,7 +42,7 @@ ThisBuild / scalaVersion := scala213 // the default Scala val Version = new { val catsEffect = "3.6.3" val catsLaws = "2.11.0" - val catsParse = "0.3.10" + val catsParse = "1.1.0" val discipline = "1.5.1" val fs2 = "3.12.2" val junit = "4.13.2" diff --git a/docs/features/tagging.md b/docs/features/tagging.md index 954e2926..cb98c8e6 100644 --- a/docs/features/tagging.md +++ b/docs/features/tagging.md @@ -13,17 +13,17 @@ import weaver._ object TaggedSuite extends SimpleIOSuite { // Test with a single tag - test("bug fix".tagged("bug")) { + pureTest("bug fix".tagged("bug")) { expect(1 + 1 == 2) } // Test with multiple tags (chain .tagged() calls) - test("integration test".tagged("integration").tagged("slow").tagged("database")) { + pureTest("integration test".tagged("integration").tagged("slow").tagged("database")) { expect(true) } // Test without tags - test("simple unit test") { + pureTest("simple unit test") { expect(42 == 42) } } @@ -43,11 +43,11 @@ import weaver._ object IgnoreSuite extends SimpleIOSuite { // These two are equivalent - test("skipped test".ignore) { + pureTest("skipped test".ignore) { expect(1 + 1 == 2) } - test("also skipped".tagged("ignore")) { + pureTest("also skipped".tagged("ignore")) { expect(1 + 1 == 2) } } @@ -63,15 +63,15 @@ import weaver._ object OnlySuite extends SimpleIOSuite { // These two are equivalent - test("run only this".only) { + pureTest("run only this".only) { expect(1 + 1 == 2) } - test("run only this too".tagged("only")) { + pureTest("run only this too".tagged("only")) { expect(1 + 1 == 2) } - test("this will be skipped") { + pureTest("this will be skipped") { expect(42 == 42) } } @@ -83,8 +83,19 @@ When any test in a suite is tagged with `only`, only those tests will run. Tags should consist of alphanumeric characters, hyphens, underscores, and colons: -- Valid characters: `a-z`, `A-Z`, `0-9`, `_`, `-`, `:` -- Examples: `bug`, `bug-123`, `test_case`, `env:prod` +- **Valid characters**: `a-z`, `A-Z`, `0-9`, `_`, `-`, `:` +- **Examples**: `bug`, `bug-123`, `test_case`, `env:prod` + +**Important**: While you *can* create tags with spaces (e.g., `.tagged("foo bar")`), these tags **cannot be used with tag filtering expressions** because spaces are used as the AND operator in the filter syntax. Tags with spaces can only be matched by exact string matching, not through the `-t`/`--tags` filter. It's strongly recommended to use hyphens or underscores instead of spaces. + +```scala +// ✅ Recommended: use hyphens or underscores +test("test".tagged("foo-bar")) +test("test".tagged("foo_bar")) + +// ❌ Not recommended: spaces prevent filtering +test("test".tagged("foo bar")) // Cannot filter with: -t "foo bar" +``` Common tagging strategies: From 726e400e5f68a41edf3f268073d16ad703bb23f3 Mon Sep 17 00:00:00 2001 From: Filippo De Luca Date: Fri, 17 Oct 2025 10:02:04 +0200 Subject: [PATCH 09/12] Bump cats-parse down to 1.0.0 --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 2ca1491f..b99bd672 100644 --- a/build.sbt +++ b/build.sbt @@ -42,7 +42,7 @@ ThisBuild / scalaVersion := scala213 // the default Scala val Version = new { val catsEffect = "3.6.3" val catsLaws = "2.11.0" - val catsParse = "1.1.0" + val catsParse = "1.0.0" // 1.1.0 doesn't exist for native 0.5 val discipline = "1.5.1" val fs2 = "3.12.2" val junit = "4.13.2" From 72201b902980f7e736f28e86e867b2368faa64b1 Mon Sep 17 00:00:00 2001 From: Filippo De Luca Date: Fri, 17 Oct 2025 15:08:39 +0200 Subject: [PATCH 10/12] Make expression parser a val --- .../shared/src/main/scala/weaver/internals/TagExprParser.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala b/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala index 6a5e56f5..705ea9ea 100644 --- a/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala +++ b/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala @@ -27,7 +27,7 @@ object TagExprParser { P.char('!').surroundedBy(nonSpaceWhitespaces0) // Forward declaration for recursive grammar - private def expression: P[TagExpr] = P.recursive[TagExpr] { recurse => + private val expression: P[TagExpr] = P.recursive[TagExpr] { recurse => // either a tag name (with optional wildcards) or parenthesized expression val wildcardP: P[TagExpr] = { val parens = recurse.between(leftParen, rightParen) From e5a0b377c0b4fb7ce1e0cbecda76da164d283317 Mon Sep 17 00:00:00 2001 From: Filippo De Luca Date: Fri, 17 Oct 2025 15:41:19 +0200 Subject: [PATCH 11/12] Address some PR comments --- .../main/scala/weaver/internals/TagExpr.scala | 45 +++++---- .../weaver/internals/TagExprParser.scala | 95 ++++++++++--------- 2 files changed, 72 insertions(+), 68 deletions(-) diff --git a/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala b/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala index ef3b733a..2bc05827 100644 --- a/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala +++ b/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala @@ -1,42 +1,42 @@ package weaver.internals -import cats.parse.{ Parser0 => P0, Parser => P } +import cats.parse.{ Parser0, Parser } import cats.syntax.all.* -sealed trait TagExpr { +private[weaver] sealed trait TagExpr { def eval(tags: Set[String]): Boolean } -object TagExpr { +private[weaver] object TagExpr { - object Wildcard { + private[weaver] object Wildcard { /* The complexity here comes from the fact that * at the beginning or in the middle needs to * know the next expected parser to know when to stop consuming */ val parser = { - val validCharP = P.charIn('a' to 'z') | - P.charIn('A' to 'Z') | - P.charIn('0' to '9') | - P.charIn("_-:") + val validCharP = Parser.charIn('a' to 'z') | + Parser.charIn('A' to 'Z') | + Parser.charIn('0' to '9') | + Parser.charIn("_-:") sealed trait Token case class Literal(str: String) extends Token case object Star extends Token case object Question extends Token - val literalP: P[Token] = + val literalP: Parser[Token] = validCharP.rep.map(cs => Literal(cs.toList.mkString)) - val starP: P[Token] = P.char('*').as(Star) - val questionP: P[Token] = P.char('?').as(Question) + val starP: Parser[Token] = Parser.char('*').as(Star) + val questionP: Parser[Token] = Parser.char('?').as(Question) - val tokenP: P[Token] = starP | questionP | literalP - val tokensP: P[List[Token]] = tokenP.rep.map(_.toList) + val tokenP: Parser[Token] = starP | questionP | literalP + val tokensP: Parser[List[Token]] = tokenP.rep.map(_.toList) - def loop(tokens: List[Token]): P0[Unit] = tokens match { + def loop(tokens: List[Token]): Parser0[Unit] = tokens match { case Nil => - P.unit + Parser.unit case Literal(str) :: rest => - (P.string(str) ~ loop(rest)).void + (Parser.string(str) ~ loop(rest)).void case Question :: rest => (validCharP ~ loop(rest)).void @@ -56,7 +56,7 @@ object TagExpr { Wildcard(pattern, parser) } - def fromPattern(pattern: String): Either[P.Error, Wildcard] = + def fromPattern(pattern: String): Either[Parser.Error, Wildcard] = parser.parseAll(pattern) def unsafeFromPattern(pattern: String): Wildcard = @@ -66,7 +66,9 @@ object TagExpr { }.fold(throw _, identity) } - case class Wildcard private (patternStr: String, parser: P0[Unit]) + private[weaver] case class Wildcard private ( + patternStr: String, + parser: Parser0[Unit]) extends TagExpr { def eval(tags: Set[String]): Boolean = { tags.exists(tag => parser.parseAll(tag).isRight) @@ -83,16 +85,17 @@ object TagExpr { override def toString: String = s"Wildcard($patternStr)" } - case class Not(expr: TagExpr) extends TagExpr { + private[weaver] case class Not(expr: TagExpr) extends TagExpr { def eval(tags: Set[String]): Boolean = !expr.eval(tags) } - case class And(left: TagExpr, right: TagExpr) extends TagExpr { + private[weaver] case class And(left: TagExpr, right: TagExpr) + extends TagExpr { def eval(tags: Set[String]): Boolean = left.eval(tags) && right.eval(tags) } - case class Or(left: TagExpr, right: TagExpr) extends TagExpr { + private[weaver] case class Or(left: TagExpr, right: TagExpr) extends TagExpr { def eval(tags: Set[String]): Boolean = left.eval(tags) || right.eval(tags) } diff --git a/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala b/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala index 705ea9ea..cb6e896d 100644 --- a/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala +++ b/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala @@ -1,65 +1,66 @@ package weaver.internals -import cats.parse.{ Parser => P, Parser0 => P0 } +import cats.parse.{ Parser, Parser0 } import cats.syntax.all.* -object TagExprParser { +private[weaver] object TagExprParser { import TagExpr._ // In GitHub syntax, space is significant (it's the AND operator) // So we only skip tabs, \r, \n for optional whitespace, NOT spaces - private val nonSpaceWhitespace: P[Unit] = P.charIn("\t\r\n").void - private val nonSpaceWhitespaces0: P0[Unit] = nonSpaceWhitespace.rep0.void + private val nonSpaceWhitespace: Parser[Unit] = Parser.charIn("\t\r\n").void + private val nonSpaceWhitespaces0: Parser0[Unit] = nonSpaceWhitespace.rep0.void - private val leftParen: P[Unit] = - P.char('(').surroundedBy(nonSpaceWhitespaces0) + private val leftParen: Parser[Unit] = + Parser.char('(').surroundedBy(nonSpaceWhitespaces0) - private val rightParen: P[Unit] = - P.char(')').surroundedBy(nonSpaceWhitespaces0) + private val rightParen: Parser[Unit] = + Parser.char(')').surroundedBy(nonSpaceWhitespaces0) - private val andOperator: P[Unit] = - P.char(' ').surroundedBy(nonSpaceWhitespaces0) + private val andOperator: Parser[Unit] = + Parser.char(' ').surroundedBy(nonSpaceWhitespaces0) - private val orOperator: P[Unit] = - P.char(',').surroundedBy(nonSpaceWhitespaces0) + private val orOperator: Parser[Unit] = + Parser.char(',').surroundedBy(nonSpaceWhitespaces0) - private val notOperator: P[Unit] = - P.char('!').surroundedBy(nonSpaceWhitespaces0) + private val notOperator: Parser[Unit] = + Parser.char('!').surroundedBy(nonSpaceWhitespaces0) // Forward declaration for recursive grammar - private val expression: P[TagExpr] = P.recursive[TagExpr] { recurse => - // either a tag name (with optional wildcards) or parenthesized expression - val wildcardP: P[TagExpr] = { - val parens = recurse.between(leftParen, rightParen) - - (parens | Wildcard.parser).surroundedBy(nonSpaceWhitespaces0) - }.withContext("wildcard") - - // Not expression (highest precedence) - // In GitHub syntax: !foo or !(expr) - val notExprP: P[TagExpr] = P.recursive[TagExpr] { recurseNot => - (notOperator *> recurseNot).map(Not.apply).backtrack | wildcardP - }.withContext("notExpr") - - // And expression (medium precedence) - // In GitHub syntax: space is AND operator - val andExprP: P[TagExpr] = { - // Use rep.sep for left-associative 'and' chains - P.repSep(notExprP, min = 1, sep = andOperator).map { exprs => - exprs.reduceLeft(And.apply) - } - }.withContext("andExpr") - - // Or expression (lowest precedence) - // In GitHub syntax: comma is OR operator - val orExprP: P[TagExpr] = { - // Use rep.sep for left-associative 'or' chains - P.repSep(andExprP, min = 1, sep = orOperator).map { exprs => - exprs.reduceLeft(Or.apply) - } - }.withContext("orExpr") - - orExprP.surroundedBy(nonSpaceWhitespaces0) + private val expression: Parser[TagExpr] = Parser.recursive[TagExpr] { + recurse => + // either a tag name (with optional wildcards) or parenthesized expression + val wildcardP: Parser[TagExpr] = { + val parens = recurse.between(leftParen, rightParen) + + (parens | Wildcard.parser).surroundedBy(nonSpaceWhitespaces0) + }.withContext("wildcard") + + // Not expression (highest precedence) + // In GitHub syntax: !foo or !(expr) + val notExprP: Parser[TagExpr] = Parser.recursive[TagExpr] { recurseNot => + (notOperator *> recurseNot).map(Not.apply).backtrack | wildcardP + }.withContext("notExpr") + + // And expression (medium precedence) + // In GitHub syntax: space is AND operator + val andExprP: Parser[TagExpr] = { + // Use rep.sep for left-associative 'and' chains + Parser.repSep(notExprP, min = 1, sep = andOperator).map { exprs => + exprs.reduceLeft(And.apply) + } + }.withContext("andExpr") + + // Or expression (lowest precedence) + // In GitHub syntax: comma is OR operator + val orExprP: Parser[TagExpr] = { + // Use rep.sep for left-associative 'or' chains + Parser.repSep(andExprP, min = 1, sep = orOperator).map { exprs => + exprs.reduceLeft(Or.apply) + } + }.withContext("orExpr") + + orExprP.surroundedBy(nonSpaceWhitespaces0) } def parse(input: String): Either[String, TagExpr] = { From 886860ae615e60caf6793053ec808fbaf5b09d28 Mon Sep 17 00:00:00 2001 From: Filippo De Luca Date: Fri, 17 Oct 2025 15:48:14 +0200 Subject: [PATCH 12/12] Use matches rather than eval --- .../src/main/scala/weaver/Filters.scala | 2 +- .../main/scala/weaver/internals/TagExpr.scala | 14 +++--- .../shared/src/test/scala/TagExprTests.scala | 50 +++++++++---------- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/modules/core/shared/src/main/scala/weaver/Filters.scala b/modules/core/shared/src/main/scala/weaver/Filters.scala index b7571207..575b4bf1 100644 --- a/modules/core/shared/src/main/scala/weaver/Filters.scala +++ b/modules/core/shared/src/main/scala/weaver/Filters.scala @@ -33,7 +33,7 @@ private[weaver] object Filters { private def createTagFilter(expr: String): TestName => Boolean = { TagExprParser.parse(expr) match { case Right(tagExpr) => - testName => tagExpr.eval(testName.tags) + testName => tagExpr.matches(testName.tags) case Left(error) => throw new IllegalArgumentException( s"Invalid tag expression '$expr': $error" diff --git a/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala b/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala index 2bc05827..a2f1c572 100644 --- a/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala +++ b/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala @@ -4,7 +4,7 @@ import cats.parse.{ Parser0, Parser } import cats.syntax.all.* private[weaver] sealed trait TagExpr { - def eval(tags: Set[String]): Boolean + def matches(tags: Set[String]): Boolean } private[weaver] object TagExpr { @@ -70,7 +70,7 @@ private[weaver] object TagExpr { patternStr: String, parser: Parser0[Unit]) extends TagExpr { - def eval(tags: Set[String]): Boolean = { + def matches(tags: Set[String]): Boolean = { tags.exists(tag => parser.parseAll(tag).isRight) } @@ -86,18 +86,18 @@ private[weaver] object TagExpr { } private[weaver] case class Not(expr: TagExpr) extends TagExpr { - def eval(tags: Set[String]): Boolean = !expr.eval(tags) + def matches(tags: Set[String]): Boolean = !expr.matches(tags) } private[weaver] case class And(left: TagExpr, right: TagExpr) extends TagExpr { - def eval(tags: Set[String]): Boolean = - left.eval(tags) && right.eval(tags) + def matches(tags: Set[String]): Boolean = + left.matches(tags) && right.matches(tags) } private[weaver] case class Or(left: TagExpr, right: TagExpr) extends TagExpr { - def eval(tags: Set[String]): Boolean = - left.eval(tags) || right.eval(tags) + def matches(tags: Set[String]): Boolean = + left.matches(tags) || right.matches(tags) } } diff --git a/modules/framework-cats/shared/src/test/scala/TagExprTests.scala b/modules/framework-cats/shared/src/test/scala/TagExprTests.scala index 0dfc03d8..5611879f 100644 --- a/modules/framework-cats/shared/src/test/scala/TagExprTests.scala +++ b/modules/framework-cats/shared/src/test/scala/TagExprTests.scala @@ -9,13 +9,13 @@ object TagExprTests extends SimpleIOSuite { pureTest("Atom matches exact tag") { val expr = Wildcard.unsafeFromPattern("foo") val tags = Set("foo", "bar", "baz") - expect(expr.eval(tags)) + expect(expr.matches(tags)) } pureTest("Atom does not match different tag") { val expr = Wildcard.unsafeFromPattern("foo") val tags = Set("bar", "baz") - expect(!expr.eval(tags)) + expect(!expr.matches(tags)) } // Wildcard pattern parsing tests @@ -193,140 +193,140 @@ object TagExprTests extends SimpleIOSuite { pureTest("Wildcard: bug* matches bug") { whenSuccess(Wildcard.fromPattern("bug*")) { wildcard => val tags = Set("bug") - expect(clue(wildcard).eval(tags)) + expect(clue(wildcard).matches(tags)) } } pureTest("Wildcard: bug* matches bug-123") { whenSuccess(Wildcard.fromPattern("bug*")) { wildcard => val tags = Set("bug-123") - expect(clue(wildcard).eval(tags)) + expect(clue(wildcard).matches(tags)) } } pureTest("Wildcard: bug* matches bugfix") { whenSuccess(Wildcard.fromPattern("bug*")) { wildcard => val tags = Set("bugfix") - expect(clue(wildcard).eval(tags)) + expect(clue(wildcard).matches(tags)) } } pureTest("Wildcard: bug* does not match foo") { whenSuccess(Wildcard.fromPattern("bug*")) { wildcard => val tags = Set("foo") - expect(!clue(wildcard).eval(tags)) + expect(!clue(wildcard).matches(tags)) } } pureTest("Wildcard: *bug matches bug") { whenSuccess(Wildcard.fromPattern("*bug")) { wildcard => val tags = Set("bug") - expect(clue(wildcard).eval(tags)) + expect(clue(wildcard).matches(tags)) } } pureTest("Wildcard: *bug matches critical-bug") { whenSuccess(Wildcard.fromPattern("*bug")) { wildcard => val tags = Set("critical-bug") - expect(clue(wildcard).eval(tags)) + expect(clue(wildcard).matches(tags)) } } pureTest("Wildcard: *bug does not match bugfix") { whenSuccess(Wildcard.fromPattern("*bug")) { wildcard => val tags = Set("bugfix") - expect(!clue(wildcard).eval(tags)) + expect(!clue(wildcard).matches(tags)) } } pureTest("Wildcard: bug? matches bug1") { whenSuccess(Wildcard.fromPattern("bug?")) { wildcard => val tags = Set("bug1") - expect(clue(wildcard).eval(tags)) + expect(clue(wildcard).matches(tags)) } } pureTest("Wildcard: bug? matches buga") { whenSuccess(Wildcard.fromPattern("bug?")) { wildcard => val tags = Set("buga") - expect(clue(wildcard).eval(tags)) + expect(clue(wildcard).matches(tags)) } } pureTest("Wildcard: bug? does not match bug") { whenSuccess(Wildcard.fromPattern("bug?")) { wildcard => val tags = Set("bug") - expect(!clue(wildcard).eval(tags)) + expect(!clue(wildcard).matches(tags)) } } pureTest("Wildcard: bug? does not match bug12") { whenSuccess(Wildcard.fromPattern("bug?")) { wildcard => val tags = Set("bug12") - expect(!clue(wildcard).eval(tags)) + expect(!clue(wildcard).matches(tags)) } } pureTest("Wildcard: ?bug matches abug") { whenSuccess(Wildcard.fromPattern("?bug")) { wildcard => val tags = Set("abug") - expect(clue(wildcard).eval(tags)) + expect(clue(wildcard).matches(tags)) } } pureTest("Wildcard: ?bug does not match bug") { whenSuccess(Wildcard.fromPattern("?bug")) { wildcard => val tags = Set("bug") - expect(!clue(wildcard).eval(tags)) + expect(!clue(wildcard).matches(tags)) } } pureTest("Wildcard: first*bug matches firstbug") { whenSuccess(Wildcard.fromPattern("first*bug")) { wildcard => val tags = Set("firstbug") - expect(clue(wildcard).eval(tags)) + expect(clue(wildcard).matches(tags)) } } pureTest("Wildcard: first*bug matches first-critical-bug") { whenSuccess(Wildcard.fromPattern("first*bug")) { wildcard => val tags = Set("first-critical-bug") - expect(clue(wildcard).eval(tags)) + expect(clue(wildcard).matches(tags)) } } pureTest("Wildcard: first*bug does not match firstbugfix") { whenSuccess(Wildcard.fromPattern("first*bug")) { wildcard => val tags = Set("firstbugfix") - expect(!clue(wildcard).eval(tags)) + expect(!clue(wildcard).matches(tags)) } } pureTest("Wildcard: a?c matches abc") { whenSuccess(Wildcard.fromPattern("a?c")) { wildcard => val tags = Set("abc") - expect(clue(wildcard).eval(tags)) + expect(clue(wildcard).matches(tags)) } } pureTest("Wildcard: a?c matches a1c") { whenSuccess(Wildcard.fromPattern("a?c")) { wildcard => val tags = Set("a1c") - expect(clue(wildcard).eval(tags)) + expect(clue(wildcard).matches(tags)) } } pureTest("Wildcard: a?c does not match ac") { whenSuccess(Wildcard.fromPattern("a?c")) { wildcard => val tags = Set("ac") - expect(!clue(wildcard).eval(tags)) + expect(!clue(wildcard).matches(tags)) } } pureTest("Wildcard: a?c does not match abcd") { whenSuccess(Wildcard.fromPattern("a?c")) { wildcard => val tags = Set("abcd") - expect(!clue(wildcard).eval(tags)) + expect(!clue(wildcard).matches(tags)) } } @@ -334,14 +334,14 @@ object TagExprTests extends SimpleIOSuite { pureTest("Wildcard: test:* matches test:unit") { whenSuccess(Wildcard.fromPattern("test:*")) { wildcard => val tags = Set("test:unit") - expect(clue(wildcard).eval(tags)) + expect(clue(wildcard).matches(tags)) } } pureTest("Wildcard: *:prod matches env:prod") { whenSuccess(Wildcard.fromPattern("*:prod")) { wildcard => val tags = Set("env:prod") - expect(clue(wildcard).eval(tags)) + expect(clue(wildcard).matches(tags)) } } @@ -349,7 +349,7 @@ object TagExprTests extends SimpleIOSuite { pureTest("Wildcard: foo matches foo exactly") { whenSuccess(Wildcard.fromPattern("foo")) { wildcard => val tags = Set("foo", "foobar") - expect(clue(wildcard).eval(tags)) + expect(clue(wildcard).matches(tags)) } } }