Skip to content

Commit 4262d5c

Browse files
authored
Allow multiple segments in Stringliterals (#297)
This PR adds support for multi-segment literal strings. for example ```scala val path = root / "foo/bar" val qux = "qux" val path = root / "foo/bar" / "baz" / qux val path = root / "foo/bar" / "baz/qux" ``` it also parses `..` segments from literal as `os.up` enabling syntax like: ```scala val path = root / "foo" / ".." / "bar" // equivalent to `root / "foo" / os.up / "bar"` val path = root / "foo" / "../bar" // equivalent to `root / "foo" / os.up / "bar"` ``` non-canonical paths used in literals result in compile-time errors, suggesting usage of canonical paths or removing path segment, eg. ```scala val path = root / "foo/./bar" //suggests "foo/bar" val path = root / "foo//bar" //suggests "foo/bar" val path = root / "//foo//bar/./baz" //suggests "foo/bar/baz" val path = root / "" //suggests removing the segment val path = root / "." //suggests removing the segment val path = root / "/" //suggests removing the segment val path = root / "//" //suggests removing the segment val path = root / "/./" //suggests removing the segment ``` Note: Its not usable in os-Lib itself, due to the fact that it would lead to macro expansion in the same compilation unit as its definition. @lihaoyi there is a little bit of hacking involved: 1. There is a default implicit conversion not being a macro to be used within os library, without this we would have to add a submodule and split the whole project, I'm not even sure if its doable. 4. Needed to turn off acyclic in `Path` and particular `Macro` files (also needed to mock `acyclic.skipped` in case of `scala 3`). 5. Needed to provide another implicit conversion in `ViewBoundImplicit` trait because macros turn out to be not avaliable as implicit views, this is needed for `ArrayPathChunk` and `SeqPathChunk` to work.
1 parent 5561ad6 commit 4262d5c

File tree

7 files changed

+246
-15
lines changed

7 files changed

+246
-15
lines changed

build.sc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ object Deps {
2424
val geny = ivy"com.lihaoyi::geny::1.1.1"
2525
val sourcecode = ivy"com.lihaoyi::sourcecode::0.4.2"
2626
val utest = ivy"com.lihaoyi::utest::0.8.4"
27+
def scalaReflect(scalaVersion: String) = ivy"org.scala-lang:scala-reflect:$scalaVersion"
2728
def scalaLibrary(version: String) = ivy"org.scala-lang:scala-library:${version}"
2829
}
2930

@@ -94,6 +95,12 @@ trait OsLibModule
9495

9596
trait OsModule extends OsLibModule { outer =>
9697
def ivyDeps = Agg(Deps.geny)
98+
override def compileIvyDeps = T{
99+
val scalaReflectOpt = Option.when(!ZincWorkerUtil.isDottyOrScala3(scalaVersion())) (
100+
Deps.scalaReflect(scalaVersion())
101+
)
102+
super.compileIvyDeps() ++ scalaReflectOpt
103+
}
97104

98105
def artifactName = "os-lib"
99106

os/src-2/Macros.scala

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package os
2+
3+
import os.PathChunk.segmentsFromStringLiteralValidation
4+
5+
import scala.language.experimental.macros
6+
import scala.reflect.macros.blackbox
7+
import acyclic.skipped
8+
9+
// StringPathChunkConversion is a fallback to non-macro String => PathChunk implicit conversion in case eta expansion is needed, this is required for ArrayPathChunk and SeqPathChunk
10+
trait PathChunkMacros extends StringPathChunkConversion {
11+
implicit def stringPathChunkValidated(s: String): PathChunk =
12+
macro Macros.stringPathChunkValidatedImpl
13+
}
14+
15+
object Macros {
16+
17+
def stringPathChunkValidatedImpl(c: blackbox.Context)(s: c.Expr[String]): c.Expr[PathChunk] = {
18+
import c.universe.{Try => _, _}
19+
20+
s match {
21+
case Expr(Literal(Constant(literal: String))) =>
22+
val stringSegments = segmentsFromStringLiteralValidation(literal)
23+
24+
c.Expr(
25+
q"""new _root_.os.PathChunk.RelPathChunk(_root_.os.RelPath.fromStringSegments($stringSegments))"""
26+
)
27+
case nonLiteral =>
28+
c.Expr(
29+
q"new _root_.os.PathChunk.StringPathChunk($nonLiteral)"
30+
)
31+
}
32+
}
33+
}

os/src-3/Macros.scala

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package os
2+
3+
import os.PathChunk.{RelPathChunk, StringPathChunk, segmentsFromStringLiteralValidation}
4+
import os.RelPath.fromStringSegments
5+
6+
import scala.quoted.{Expr, Quotes}
7+
import acyclic.skipped
8+
9+
// StringPathChunkConversion is a fallback to non-macro String => PathChunk implicit conversion in case eta expansion is needed, this is required for ArrayPathChunk and SeqPathChunk
10+
trait PathChunkMacros extends StringPathChunkConversion {
11+
inline implicit def stringPathChunkValidated(s: String): PathChunk =
12+
${
13+
Macros.stringPathChunkValidatedImpl('s)
14+
}
15+
}
16+
17+
object Macros {
18+
def stringPathChunkValidatedImpl(s: Expr[String])(using quotes: Quotes): Expr[PathChunk] = {
19+
import quotes.reflect.*
20+
21+
s.asTerm match {
22+
case Inlined(_, _, Literal(StringConstant(literal))) =>
23+
val stringSegments = segmentsFromStringLiteralValidation(literal)
24+
'{
25+
new RelPathChunk(fromStringSegments(${
26+
Expr(stringSegments)
27+
}))
28+
}
29+
case _ =>
30+
'{
31+
{
32+
new StringPathChunk($s)
33+
}
34+
}
35+
}
36+
}
37+
}

os/src-3/acyclic.scala

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package os
2+
private[os] object acyclic {
3+
4+
/** Mocks [[\\import acyclic.skipped]] for scala 3 */
5+
private[os] type skipped
6+
}

os/src/Path.scala

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,76 @@ package os
22

33
import java.net.URI
44
import java.nio.file.Paths
5-
65
import collection.JavaConverters._
76
import scala.language.implicitConversions
8-
import java.nio.file
7+
import acyclic.skipped
8+
import os.PathError.{InvalidSegment, NonCanonicalLiteral}
9+
10+
import scala.util.Try //needed for cross-version defined macros
911

1012
trait PathChunk {
1113
def segments: Seq[String]
1214
def ups: Int
1315
}
14-
object PathChunk {
16+
trait StringPathChunkConversion {
17+
18+
implicit def stringToPathChunk(s: String): PathChunk =
19+
new PathChunk.StringPathChunkInternal(s)
20+
}
21+
22+
object PathChunk extends PathChunkMacros {
23+
private[os] def segmentsFromString(s: String): Array[String] = {
24+
val trailingSeparatorsCount = s.reverseIterator.takeWhile(_ == '/').length
25+
val strNoTrailingSeps = s.dropRight(trailingSeparatorsCount)
26+
val splitted = strNoTrailingSeps.split('/')
27+
splitted ++ Array.fill(trailingSeparatorsCount)("")
28+
}
29+
30+
private[os] def segmentsFromStringLiteralValidation(literal: String) = {
31+
val stringSegments = segmentsFromString(literal)
32+
val validSegmnts = validLiteralSegments(stringSegments)
33+
val sanitizedLiteral = validSegmnts.mkString("/")
34+
if (validSegmnts.isEmpty) throw InvalidSegment(
35+
literal,
36+
s"Literal path sequence [$literal] doesn't affect path being formed, please remove it"
37+
)
38+
if (literal != sanitizedLiteral) throw NonCanonicalLiteral(literal, sanitizedLiteral)
39+
stringSegments
40+
}
41+
private def validLiteralSegments(segments: Array[String]): Array[String] = {
42+
val AllowedLiteralSegment = ".."
43+
segments.collect {
44+
case AllowedLiteralSegment => AllowedLiteralSegment
45+
case segment if Try(BasePath.checkSegment(segment)).isSuccess => segment
46+
}
47+
}
48+
1549
implicit class RelPathChunk(r: RelPath) extends PathChunk {
1650
def segments = r.segments
1751
def ups = r.ups
1852
override def toString() = r.toString
1953
}
54+
2055
implicit class SubPathChunk(r: SubPath) extends PathChunk {
2156
def segments = r.segments
2257
def ups = 0
2358
override def toString() = r.toString
2459
}
25-
implicit class StringPathChunk(s: String) extends PathChunk {
60+
61+
// Implicit String => PathChunk conversion used inside os-lib, prevents macro expansion in same compilation unit
62+
private[os] implicit class StringPathChunkInternal(s: String) extends PathChunk {
2663
BasePath.checkSegment(s)
2764
def segments = Seq(s)
2865
def ups = 0
2966
override def toString() = s
3067
}
68+
69+
// binary compatibility shim
70+
class StringPathChunk(s: String) extends StringPathChunkInternal(s)
71+
72+
// binary compatibility shim
73+
def StringPathChunk(s: String): StringPathChunk = new StringPathChunk(s)
74+
3175
implicit class SymbolPathChunk(s: Symbol) extends PathChunk {
3276
BasePath.checkSegment(s.name)
3377
def segments = Seq(s.name)
@@ -227,6 +271,11 @@ object PathError {
227271

228272
case class LastOnEmptyPath()
229273
extends IAE("empty path has no last segment")
274+
275+
case class NonCanonicalLiteral(providedLiteral: String, sanitizedLiteral: String)
276+
extends IAE(
277+
s"Literal path sequence [$providedLiteral] used in OS-Lib must be in a canonical form, please use [$sanitizedLiteral] instead"
278+
)
230279
}
231280

232281
/**
@@ -297,6 +346,7 @@ class RelPath private[os] (segments0: Array[String], val ups: Int)
297346
}
298347

299348
object RelPath {
349+
300350
def apply[T: PathConvertible](f0: T): RelPath = {
301351
val f = implicitly[PathConvertible[T]].apply(f0)
302352

@@ -319,6 +369,10 @@ object RelPath {
319369
val up: RelPath = new RelPath(Internals.emptyStringArray, 1)
320370
val rel: RelPath = new RelPath(Internals.emptyStringArray, 0)
321371
implicit def SubRelPath(p: SubPath): RelPath = new RelPath(p.segments0, 0)
372+
def fromStringSegments(segments: Array[String]): RelPath = segments.foldLeft(RelPath.rel) {
373+
case (agg, "..") => agg / up
374+
case (agg, seg) => agg / seg
375+
}
322376
}
323377

324378
/**
@@ -473,6 +527,7 @@ object Path {
473527

474528
trait ReadablePath {
475529
def toSource: os.Source
530+
476531
def getInputStream: java.io.InputStream
477532
}
478533

os/test/src/PathTests.scala

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,68 @@ package test.os
22

33
import java.nio.file.Paths
44
import java.io.File
5-
65
import os._
7-
import os.Path.{driveRoot}
6+
import os.Path.driveRoot
87
import utest.{assert => _, _}
8+
99
import java.net.URI
1010
object PathTests extends TestSuite {
11+
private def nonCanonicalLiteral(providedLiteral: String, sanitizedLiteral: String) =
12+
s"Literal path sequence [$providedLiteral] used in OS-Lib must be in a canonical form, please use [$sanitizedLiteral] instead"
13+
private def removeLiteralErr(literal: String) =
14+
s"Literal path sequence [$literal] doesn't affect path being formed, please remove it"
15+
1116
val tests = Tests {
17+
test("Literals") {
18+
test("Basic") {
19+
assert(rel / "src" / "Main/.scala" == rel / "src" / "Main" / ".scala")
20+
assert(root / "core/src/test" == root / "core" / "src" / "test")
21+
assert(root / "core/src/test" == root / "core" / "src/test")
22+
}
23+
test("literals with [..]") {
24+
assert(rel / "src" / ".." == rel / "src" / os.up)
25+
assert(root / "src/.." == root / "src" / os.up)
26+
assert(root / "src" / ".." == root / "src" / os.up)
27+
assert(root / "hello" / ".." / "world" == root / "hello" / os.up / "world")
28+
assert(root / "hello" / "../world" == root / "hello" / os.up / "world")
29+
assert(root / "hello/../world" == root / "hello" / os.up / "world")
30+
}
31+
32+
test("Compile errors") {
33+
compileError("""root / "/" """).check("", removeLiteralErr("/"))
34+
compileError("""root / "/ " """).check("", nonCanonicalLiteral("/ ", " "))
35+
compileError("""root / " /" """).check("", nonCanonicalLiteral(" /", " "))
36+
compileError("""root / "//" """).check("", removeLiteralErr("//"))
37+
38+
compileError("""root / "foo/" """).check("", nonCanonicalLiteral("foo/", "foo"))
39+
compileError("""root / "foo//" """).check("", nonCanonicalLiteral("foo//", "foo"))
40+
41+
compileError("""root / "foo/bar/" """).check("", nonCanonicalLiteral("foo/bar/", "foo/bar"))
42+
compileError("""root / "foo/bar//" """).check(
43+
"",
44+
nonCanonicalLiteral("foo/bar//", "foo/bar")
45+
)
46+
47+
compileError("""root / "/foo" """).check("", nonCanonicalLiteral("/foo", "foo"))
48+
compileError("""root / "//foo" """).check("", nonCanonicalLiteral("//foo", "foo"))
49+
50+
compileError("""root / "//foo/" """).check("", nonCanonicalLiteral("//foo/", "foo"))
51+
52+
compileError(""" rel / "src" / "" """).check("", removeLiteralErr(""))
53+
compileError(""" rel / "src" / "." """).check("", removeLiteralErr("."))
54+
55+
compileError(""" root / "src/" """).check("", nonCanonicalLiteral("src/", "src"))
56+
compileError(""" root / "src/." """).check("", nonCanonicalLiteral("src/.", "src"))
57+
58+
compileError(""" root / "" """).check("", removeLiteralErr(""))
59+
compileError(""" root / "." """).check("", removeLiteralErr("."))
60+
61+
}
62+
}
1263
test("Basic") {
1364
val base = rel / "src" / "main" / "scala"
1465
val subBase = sub / "src" / "main" / "scala"
66+
1567
test("Transform posix paths") {
1668
// verify posix string format of driveRelative path
1769
assert(posix(root / "omg") == posix(Paths.get("/omg").toAbsolutePath))
@@ -279,29 +331,31 @@ object PathTests extends TestSuite {
279331
}
280332
}
281333
test("Errors") {
334+
def nonLiteral(s: String) = s
335+
282336
test("InvalidChars") {
283-
val ex = intercept[PathError.InvalidSegment](rel / "src" / "Main/.scala")
337+
val ex = intercept[PathError.InvalidSegment](rel / "src" / nonLiteral("Main/.scala"))
284338

285339
val PathError.InvalidSegment("Main/.scala", msg1) = ex
286340

287341
assert(msg1.contains("[/] is not a valid character to appear in a path segment"))
288342

289-
val ex2 = intercept[PathError.InvalidSegment](root / "hello" / ".." / "world")
343+
val ex2 = intercept[PathError.InvalidSegment](root / "hello" / nonLiteral("..") / "world")
290344

291345
val PathError.InvalidSegment("..", msg2) = ex2
292346

293347
assert(msg2.contains("use the `up` segment from `os.up`"))
294348
}
295349
test("InvalidSegments") {
296-
intercept[PathError.InvalidSegment] { root / "core/src/test" }
297-
intercept[PathError.InvalidSegment] { root / "" }
298-
intercept[PathError.InvalidSegment] { root / "." }
299-
intercept[PathError.InvalidSegment] { root / ".." }
350+
intercept[PathError.InvalidSegment] { root / nonLiteral("core/src/test") }
351+
intercept[PathError.InvalidSegment] { root / nonLiteral("") }
352+
intercept[PathError.InvalidSegment] { root / nonLiteral(".") }
353+
intercept[PathError.InvalidSegment] { root / nonLiteral("..") }
300354
}
301355
test("EmptySegment") {
302-
intercept[PathError.InvalidSegment](rel / "src" / "")
303-
intercept[PathError.InvalidSegment](rel / "src" / ".")
304-
intercept[PathError.InvalidSegment](rel / "src" / "..")
356+
intercept[PathError.InvalidSegment](rel / "src" / nonLiteral(""))
357+
intercept[PathError.InvalidSegment](rel / "src" / nonLiteral("."))
358+
intercept[PathError.InvalidSegment](rel / "src" / nonLiteral(".."))
305359
}
306360
test("CannotRelativizeAbsAndRel") {
307361
val abs = pwd
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package os
2+
3+
import os.PathChunk.segmentsFromString
4+
import utest.{assert => _, _}
5+
6+
object SegmentsFromStringTests extends TestSuite {
7+
8+
val tests = Tests {
9+
test("segmentsFromString") {
10+
def testSegmentsFromString(s: String, expected: List[String]) = {
11+
assert(segmentsFromString(s).sameElements(expected))
12+
}
13+
14+
testSegmentsFromString(" ", List(" "))
15+
16+
testSegmentsFromString("", List(""))
17+
18+
testSegmentsFromString("""foo/bar/baz""", List("foo", "bar", "baz"))
19+
20+
testSegmentsFromString("""/""", List("", ""))
21+
testSegmentsFromString("""//""", List("", "", ""))
22+
testSegmentsFromString("""///""", List("", "", "", ""))
23+
24+
testSegmentsFromString("""a/""", List("a", ""))
25+
testSegmentsFromString("""a//""", List("a", "", ""))
26+
testSegmentsFromString("""a///""", List("a", "", "", ""))
27+
28+
testSegmentsFromString("""ahs/""", List("ahs", ""))
29+
testSegmentsFromString("""ahs//""", List("ahs", "", ""))
30+
31+
testSegmentsFromString("""ahs/aa/""", List("ahs", "aa", ""))
32+
testSegmentsFromString("""ahs/aa//""", List("ahs", "aa", "", ""))
33+
34+
testSegmentsFromString("""/a""", List("", "a"))
35+
testSegmentsFromString("""//a""", List("", "", "a"))
36+
testSegmentsFromString("""//a/""", List("", "", "a", ""))
37+
}
38+
}
39+
}

0 commit comments

Comments
 (0)