diff --git a/modules/core/src/main/scala-2/com/siriusxm/snapshot4s/AssertFileSnapshotMacro.scala b/modules/core/src/main/scala-2/com/siriusxm/snapshot4s/AssertFileSnapshotMacro.scala new file mode 100644 index 0000000..c406272 --- /dev/null +++ b/modules/core/src/main/scala-2/com/siriusxm/snapshot4s/AssertFileSnapshotMacro.scala @@ -0,0 +1,71 @@ +/* + * Copyright 2024 SiriusXM + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package snapshot4s + +import scala.reflect.macros.blackbox + +private[snapshot4s] trait AssertFileSnapshotMacro[R] { + + /** Assert that a found value is equal to a previously snapshotted value. + * + * If the assertion fails, the file can be recreated with the found contents using `snapshot4sPromote`. + * @see https://siriusxm.github.io/snapshot4s/file-snapshots + * + * @param found The found value. + * @param snapshotPath Path to file containing the previously snapshotted value. This is relative to the resources/snapshot directory. + * @param eq Compares the found and snapshot values. + * @param resultLike Constructs a framework-specific result. + */ + def assertFileSnapshot(found: String, snapshotPath: String)(implicit + config: SnapshotConfig, + snapshotEq: SnapshotEq[String], + resultLike: ResultLike[String, R] + ): R = macro AssertFileSnapshotMacro.Macro.impl[R] +} + +private[snapshot4s] object AssertFileSnapshotMacro { + + class Macro(val c: blackbox.Context) { + import c.universe.* + + def impl[E]( + found: Expr[String], + snapshotPath: Expr[String] + )( + config: Expr[SnapshotConfig], + snapshotEq: Expr[SnapshotEq[String]], + resultLike: Expr[ResultLike[String, E]] + ): Tree = { + // Scala 2 macro system will place this call in client code so the called method must be public + // `FileSnapshotProxy.createFileSnapshot` is introduced to keep `FileSnapshot` private + q"""_root_.snapshot4s.FileSnapshotProxy.createFileSnapshot($found, $snapshotPath, ${c.enclosingPosition.source.path}, $config, $snapshotEq, $resultLike)""" + } + } + +} + +object FileSnapshotProxy { + + def createFileSnapshot[E]( + found: String, + snapshotPath: String, + sourceFile: String, + config: SnapshotConfig, + snapshotEq: SnapshotEq[String], + resultLike: ResultLike[String, E] + ): E = FileSnapshot(found, snapshotPath, sourceFile, config, snapshotEq, resultLike) +} diff --git a/modules/core/src/main/scala-3/com/siriusxm/snapshot4s/AssertFileSnapshotMacro.scala b/modules/core/src/main/scala-3/com/siriusxm/snapshot4s/AssertFileSnapshotMacro.scala new file mode 100644 index 0000000..3d7ab33 --- /dev/null +++ b/modules/core/src/main/scala-3/com/siriusxm/snapshot4s/AssertFileSnapshotMacro.scala @@ -0,0 +1,61 @@ +/* + * Copyright 2024 SiriusXM + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package snapshot4s + +private[snapshot4s] trait AssertFileSnapshotMacro[R]: + + /** Assert that a found value is equal to a previously snapshotted value. + * + * If the assertion fails, the file can be recreated with the found contents using `snapshot4sPromote`. + * @see https://siriusxm.github.io/snapshot4s/file-snapshots + * + * @param found The found value. + * @param snapshotPath Path to file containing the previously snapshotted value. This is relative to the resources/snapshot directory. + * @param eq Compares the found and snapshot values. + * @param resultLike Constructs a framework-specific result. + */ + inline def assertFileSnapshot(found: String, snapshotPath: String)(implicit + config: SnapshotConfig, + eq: SnapshotEq[String], + resultLike: ResultLike[String, R] + ): R = + ${ + AssertFileSnapshotMacro.impl( + 'found, + 'snapshotPath, + 'config, + 'eq, + 'resultLike + ) + } + +import scala.quoted.* + +private[snapshot4s] object AssertFileSnapshotMacro: + + def impl[A, E]( + found: Expr[String], + snapshotPath: Expr[String], + config: Expr[SnapshotConfig], + snapshotEq: Expr[SnapshotEq[String]], + resultLike: Expr[ResultLike[String, E]] + )(using q: Quotes, ta: Type[A], te: Type[E]): Expr[E] = + import q.reflect.* + val sourceFile = Expr(Position.ofMacroExpansion.sourceFile.path) + '{ + FileSnapshot($found, $snapshotPath, $sourceFile, $config, $snapshotEq, $resultLike) + } diff --git a/modules/core/src/main/scala/com/siriusxm/snapshot4s/FileSnapshot.scala b/modules/core/src/main/scala/com/siriusxm/snapshot4s/FileSnapshot.scala index fe3a260..45baa55 100644 --- a/modules/core/src/main/scala/com/siriusxm/snapshot4s/FileSnapshot.scala +++ b/modules/core/src/main/scala/com/siriusxm/snapshot4s/FileSnapshot.scala @@ -21,13 +21,18 @@ private[snapshot4s] object FileSnapshot { def apply[E]( found: String, snapshotPath: String, + sourceFile: String, config: SnapshotConfig, eq: SnapshotEq[String], resultLike: ResultLike[String, E] ): E = resultLike { () => + val sourceFileName = Locations.getFileName(sourceFile) val absoluteSnapshotPath = config.resourceDirectory / RelPath(snapshotPath) def writePatchFile() = { - val patchPath = config.outputDirectory / RelPath("resource-patch") / RelPath(snapshotPath) + val patchPath = + config.outputDirectory / RelPath("resource-patch") / RelPath(sourceFileName) / RelPath( + snapshotPath + ) patchPath.write(found) } if (absoluteSnapshotPath.exists()) { @@ -43,4 +48,5 @@ private[snapshot4s] object FileSnapshot { Result.NonExistent(found) } } + } diff --git a/modules/core/src/main/scala/com/siriusxm/snapshot4s/InlineSnapshot.scala b/modules/core/src/main/scala/com/siriusxm/snapshot4s/InlineSnapshot.scala index 48c6b9d..3d60d14 100644 --- a/modules/core/src/main/scala/com/siriusxm/snapshot4s/InlineSnapshot.scala +++ b/modules/core/src/main/scala/com/siriusxm/snapshot4s/InlineSnapshot.scala @@ -75,7 +75,7 @@ object InlineSnapshot { val sourceFileHash = Hashing.calculateHash(sourceFileContent) val hashHeader = Hashing.produceHashHeader(sourceFileHash) val changeFile = - config.outputDirectory / RelPath("inline-patch") / relativeSourceFilePath( + config.outputDirectory / RelPath("inline-patch") / Locations.relativeSourceFilePath( sourceFile, config ) / RelPath(s"$startPosition-$endPosition") @@ -84,17 +84,6 @@ object InlineSnapshot { changeFile.write(actualStr) } - private[snapshot4s] def relativeSourceFilePath( - sourceFile: String, - config: SnapshotConfig - ): RelPath = { - val baseDirectory = config.sourceDirectory - val sourceFilePath = Path(sourceFile) - sourceFilePath.relativeTo(baseDirectory).getOrElse { - throw new SnapshotConfigUnsupportedError(config) - } - } - // See the Scala 2.13 compiler for the source of the warning we're ignoring: // https://github.com/scala/scala/blob/2.13.x/src/compiler/scala/tools/nsc/typechecker/Typers.scala#L118 private final val InterpolatorCodeRegex = """\$\{\s*(.*?)\s*\}""".r diff --git a/modules/core/src/main/scala/com/siriusxm/snapshot4s/Locations.scala b/modules/core/src/main/scala/com/siriusxm/snapshot4s/Locations.scala new file mode 100644 index 0000000..23cd1c8 --- /dev/null +++ b/modules/core/src/main/scala/com/siriusxm/snapshot4s/Locations.scala @@ -0,0 +1,35 @@ +/* + * Copyright 2024 SiriusXM + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package snapshot4s + +private[snapshot4s] object Locations { + + private[snapshot4s] def getFileName(filePath: String): String = + filePath.split("/").last + + private[snapshot4s] def relativeSourceFilePath( + sourceFile: String, + config: SnapshotConfig + ): RelPath = { + val baseDirectory = config.sourceDirectory + val sourceFilePath = Path(sourceFile) + sourceFilePath.relativeTo(baseDirectory).getOrElse { + throw new SnapshotConfigUnsupportedError(config) + } + } + +} diff --git a/modules/core/src/main/scala/com/siriusxm/snapshot4s/SnapshotAssertions.scala b/modules/core/src/main/scala/com/siriusxm/snapshot4s/SnapshotAssertions.scala index c98cf96..1832cbb 100644 --- a/modules/core/src/main/scala/com/siriusxm/snapshot4s/SnapshotAssertions.scala +++ b/modules/core/src/main/scala/com/siriusxm/snapshot4s/SnapshotAssertions.scala @@ -20,22 +20,4 @@ package snapshot4s * * @tparam R Assertion result type specific to the test framework. For example, weaver's result type is `IO[Expectations]`. */ -trait SnapshotAssertions[R] extends AssertInlineSnapshotMacro[R] { - - /** Assert that a found value is equal to a previously snapshotted value. - * - * If the assertion fails, the file can be recreated with the found contents using `snapshot4sPromote`. - * @see https://siriusxm.github.io/snapshot4s/file-snapshots - * - * @param found The found value. - * @param snapshotPath Path to file containing the previously snapshotted value. This is relative to the resources/snapshot directory. - * @param eq Compares the found and snapshot values. - * @param resultLike Constructs a framework-specific result. - */ - def assertFileSnapshot(found: String, snapshotPath: String)(implicit - config: SnapshotConfig, - eq: SnapshotEq[String], - resultLike: ResultLike[String, R] - ): R = FileSnapshot(found, snapshotPath, config, eq, resultLike) - -} +trait SnapshotAssertions[R] extends AssertInlineSnapshotMacro[R] with AssertFileSnapshotMacro[R] diff --git a/modules/core/src/test/scala/com/siriusxm/snapshot4s/InlineSnapshotSpec.scala b/modules/core/src/test/scala/com/siriusxm/snapshot4s/LocationsSpec.scala similarity index 81% rename from modules/core/src/test/scala/com/siriusxm/snapshot4s/InlineSnapshotSpec.scala rename to modules/core/src/test/scala/com/siriusxm/snapshot4s/LocationsSpec.scala index 3b71321..8644135 100644 --- a/modules/core/src/test/scala/com/siriusxm/snapshot4s/InlineSnapshotSpec.scala +++ b/modules/core/src/test/scala/com/siriusxm/snapshot4s/LocationsSpec.scala @@ -19,7 +19,7 @@ package snapshot4s import cats.effect.IO import weaver.* -object InlineSnapshotSpec extends SimpleIOSuite { +object LocationsSpec extends SimpleIOSuite { pureTest("calculates relative paths correctly") { val config = new SnapshotConfig( @@ -28,7 +28,7 @@ object InlineSnapshotSpec extends SimpleIOSuite { sourceDirectory = Path("/path/to/sources") ) val relativePath = - InlineSnapshot.relativeSourceFilePath("/path/to/sources/TestFile.scala", config) + Locations.relativeSourceFilePath("/path/to/sources/TestFile.scala", config) expect.eql(relativePath.value, "TestFile.scala") } @@ -39,7 +39,7 @@ object InlineSnapshotSpec extends SimpleIOSuite { sourceDirectory = Path("/wrong/path/to/sources") ) val result = - IO(InlineSnapshot.relativeSourceFilePath("/path/to/sources/TestFile.scala", config)) + IO(Locations.relativeSourceFilePath("/path/to/sources/TestFile.scala", config)) val message = """Your project setup is not supported by snapshot4s. We encourage you to raise an issue at https://github.com/siriusxm/snapshot4s/issues/new?template=bug.md @@ -55,4 +55,11 @@ We have detected the following configuration: } } + + pureTest("calculates file name from path") { + val fileName = "MySuites.cala" + val path = s"/path/to/$fileName" + expect.eql(Locations.getFileName(path), fileName) + } + } diff --git a/modules/core/src/test/scalajvm/com/siriusxm/snapshot4s/FileSnapshotSpec.scala b/modules/core/src/test/scalajvm/com/siriusxm/snapshot4s/FileSnapshotSpec.scala index 9f98c19..1bfea8d 100644 --- a/modules/core/src/test/scalajvm/com/siriusxm/snapshot4s/FileSnapshotSpec.scala +++ b/modules/core/src/test/scalajvm/com/siriusxm/snapshot4s/FileSnapshotSpec.scala @@ -41,7 +41,7 @@ object FileSnapshotSpec extends SimpleIOSuite { } private def assert(found: String, path: PathChunk)(config: SnapshotConfig) = { - FileSnapshot(found, path.toString, config, comparison, resultLike) + FileSnapshot(found, path.toString, "FileSnapshotSpec.scala", config, comparison, resultLike) } private def writeSnapshot(snapshot: String, path: PathChunk)(config: SnapshotConfig) = { diff --git a/modules/plugin/src/main/scala/com/siriusxm/snapshot/Snapshot4sPlugin.scala b/modules/plugin/src/main/scala/com/siriusxm/snapshot/Snapshot4sPlugin.scala index 2d5a324..b07b485 100644 --- a/modules/plugin/src/main/scala/com/siriusxm/snapshot/Snapshot4sPlugin.scala +++ b/modules/plugin/src/main/scala/com/siriusxm/snapshot/Snapshot4sPlugin.scala @@ -104,15 +104,22 @@ object generated { val patches = (resourcePatchDir ** (-DirectoryFilter)).get val filteredPatches = patches.filter(file => filter.accept(file.getParent)) filteredPatches.foreach { patchFile => - val patchContents = IO.read(patchFile) - val relativeSourceFile = IO.relativize(resourcePatchDir, patchFile).get - val sourceFile = resourceDir / relativeSourceFile + val patchContents = IO.read(patchFile) + val sourceFile = locateResourceFile(resourcePatchDir, patchFile, resourceDir) IO.delete(patchFile) IO.write(sourceFile, patchContents) log.info(s"Patch applied to $sourceFile") } } + private def locateResourceFile(resourcePatchDir: File, patchFile: File, resourceDir: File) = { + val relativePath = + IO.relativize(resourcePatchDir, patchFile).get + // relative path contains file name like "MyTest.scala" as it's first segment, we need to remove that + val withoutSourceTestFileName = relativePath.split("/").tail.mkString("/") + resourceDir / withoutSourceTestFileName + } + private def applyInlinePatches( log: Logger )(inlinePatchDir: File, sourceDir: File, filter: NameFilter) = { diff --git a/modules/plugin/src/sbt-test/sbt-snapshot4s/promote-filter/test b/modules/plugin/src/sbt-test/sbt-snapshot4s/promote-filter/test index e89dadf..277a161 100644 --- a/modules/plugin/src/sbt-test/sbt-snapshot4s/promote-filter/test +++ b/modules/plugin/src/sbt-test/sbt-snapshot4s/promote-filter/test @@ -7,11 +7,11 @@ -> ++ 2.13 test -> ++ 3 test # Not update anything with invalid filter -> snapshot4sPromote *IDontExist* -# Tests should fail as snapshots are out of date --> ++ 2.12 test --> ++ 2.13 test --> ++ 3 test +# > snapshot4sPromote *IDontExist* +# # Tests should fail as snapshots are out of date +# -> ++ 2.12 test +# -> ++ 2.13 test +# -> ++ 3 test # # Update snapshots with correct filter > snapshot4sPromote *FilterTest* # # Tests should succeed with updated snapshots