Skip to content

Commit 944e33f

Browse files
authored
Add literal syntax for os.Path, os.SubPath, os.RelPath (#353)
This PR allows ```scala val p: os.Path = "/hello/world" val s: os.SubPath = "hello/world" val r: os.RelPath = "../hello/world" ``` This only allows string-literals that are valid absolute/sub/relative-path respectively; passing in invalid paths (e.g. `val p: os.Path = "hello/world"`) or non-literals (e.g. `val str = "/hello/world"; val s: os.SubPath = str `) is a compile error This builds upon @pawelsadlo's work in #297, mostly using `segmentsFromStringLiteralValidation` unchanged with some light pre/post processing to trim the leading `/` off of absolute `os.Path`s and check for leading `..`s on `os.SubPath`s I'm going to declare bankruptcy on the Expecty issues, as we cannot forever be working around bugs in unrelated libraries. If someone has problems and wants to fix expecty, they can do so, and we don't need to care. If nobody cares enough to fix expecty, we shouldn't care either.
1 parent e659bd9 commit 944e33f

File tree

7 files changed

+155
-43
lines changed

7 files changed

+155
-43
lines changed

Readme.adoc

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2073,6 +2073,9 @@ wd / "folder" / "file"
20732073
// The RHS of `/` can have multiple segments if-and-only-if it is a literal string
20742074
wd / "folder/file"
20752075
2076+
// Literal syntax for absolute `os.Path`
2077+
val p: os.Path = "/folder/file"
2078+
20762079
// A path starting from the root
20772080
os.root / "folder/file"
20782081
@@ -2086,7 +2089,7 @@ wd / os.up
20862089
wd / os.up / os.up
20872090
----
20882091

2089-
When constructing `os.Path`s, the right-hand-side of the `/` operator must be either a non-literal
2092+
When constructing ``os.Path``s, the right-hand-side of the `/` operator must be either a non-literal
20902093
a string expression containing a single path segment or a literal string containing one-or-more
20912094
path segments. If a non-literal string expression on the RHS contains multiple segments, you need
20922095
to wrap the RHS in an explicit `os.RelPath(...)` or `os.SubPath(...)` constructor to tell OS-Lib
@@ -2135,9 +2138,11 @@ before the relative path is applied. They can be created in the following ways:
21352138
val rel1 = os.rel / "folder" / "file"
21362139
// RHS of `/` can have multiple segments if-and-only-if it is a literal string
21372140
val rel2 = os.rel / "folder/file"
2141+
// Literal syntax for `os.RelPath`
2142+
val rel3: os.RelPath = "folder/file"
21382143
21392144
// The path "file"
2140-
val rel3 = os.rel / "file"
2145+
val rel4 = os.rel / "file"
21412146
21422147
// The relative difference between two paths
21432148
val target = os.pwd / "target/file"
@@ -2201,14 +2206,16 @@ They can be created in the following ways:
22012206
val sub1 = os.sub / "folder" / "file"
22022207
// RHS of `/` can have multiple segments if-and-only-if it is a literal string
22032208
val sub2 = os.sub / "folder/file"
2209+
// Literal syntax for `os.SubPath`
2210+
val sub2: os.Subpath = "folder/file"
22042211
22052212
// The relative difference between two paths
22062213
val target = os.pwd / "out/scratch/file"
22072214
assert((target subRelativeTo os.pwd) == os.sub / "out/scratch/file")
22082215
22092216
// Converting os.RelPath to os.SubPath
22102217
val rel3 = os.rel / "folder/file"
2211-
val sub3 = rel3.asSubPath
2218+
val sub4 = rel3.asSubPath
22122219
----
22132220

22142221
``os.SubPath``s are useful for representing paths within a particular
@@ -2521,6 +2528,7 @@ string, int or set representations of the `os.PermSet` via:
25212528

25222529
* Add ability to instrument path based operations using hooks https://github.com/com-lihaoyi/os-lib/pull/325[#325]
25232530
* Add compile-time validation of literal paths containing ".." https://github.com/com-lihaoyi/os-lib/pull/329[#329]
2531+
* Add literal string syntax for `os.Path`s, `os.SubPath`s, and `os.RelPath`s https://github.com/com-lihaoyi/os-lib/pull/353[#353]
25242532

25252533
[#0-11-3]
25262534
=== 0.11.3

os/src-2/Macros.scala

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@ trait PathChunkMacros extends StringPathChunkConversion {
1111
implicit def stringPathChunkValidated(s: String): PathChunk =
1212
macro Macros.stringPathChunkValidatedImpl
1313
}
14+
trait SubPathMacros extends StringPathChunkConversion {
15+
implicit def stringSubPathValidated(s: String): SubPath =
16+
macro Macros.stringSubPathValidatedImpl
17+
}
18+
trait RelPathMacros extends StringPathChunkConversion {
19+
implicit def stringRelPathValidated(s: String): RelPath =
20+
macro Macros.stringRelPathValidatedImpl
21+
}
22+
trait PathMacros extends StringPathChunkConversion {
23+
implicit def stringPathValidated(s: String): Path =
24+
macro Macros.stringPathValidatedImpl
25+
}
1426

1527
object Macros {
1628

@@ -30,4 +42,42 @@ object Macros {
3042
)
3143
}
3244
}
45+
def stringSubPathValidatedImpl(c: blackbox.Context)(s: c.Expr[String]): c.Expr[SubPath] = {
46+
import c.universe.{Try => _, _}
47+
48+
s match {
49+
case Expr(Literal(Constant(literal: String))) if !literal.startsWith("/") =>
50+
val stringSegments = segmentsFromStringLiteralValidation(literal)
51+
52+
if (stringSegments.startsWith(Seq(".."))) {
53+
c.abort(s.tree.pos, "Invalid subpath literal: " + s.tree)
54+
}
55+
c.Expr(q"""os.sub / _root_.os.RelPath.fromStringSegments($stringSegments)""")
56+
57+
case _ => c.abort(s.tree.pos, "Invalid subpath literal: " + s.tree)
58+
}
59+
}
60+
def stringRelPathValidatedImpl(c: blackbox.Context)(s: c.Expr[String]): c.Expr[RelPath] = {
61+
import c.universe.{Try => _, _}
62+
63+
s match {
64+
case Expr(Literal(Constant(literal: String))) if !literal.startsWith("/") =>
65+
val stringSegments = segmentsFromStringLiteralValidation(literal)
66+
c.Expr(q"""os.rel / _root_.os.RelPath.fromStringSegments($stringSegments)""")
67+
68+
case _ => c.abort(s.tree.pos, "Invalid relative path literal: " + s.tree)
69+
}
70+
}
71+
def stringPathValidatedImpl(c: blackbox.Context)(s: c.Expr[String]): c.Expr[Path] = {
72+
import c.universe.{Try => _, _}
73+
74+
s match {
75+
case Expr(Literal(Constant(literal: String))) if literal.startsWith("/") =>
76+
val stringSegments = segmentsFromStringLiteralValidation(literal.stripPrefix("/"))
77+
78+
c.Expr(q"""os.root / _root_.os.RelPath.fromStringSegments($stringSegments)""")
79+
80+
case _ => c.abort(s.tree.pos, "Invalid absolute path literal: " + s.tree)
81+
}
82+
}
3383
}

os/src-3/Macros.scala

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,34 @@ trait PathChunkMacros extends StringPathChunkConversion {
1313
Macros.stringPathChunkValidatedImpl('s)
1414
}
1515
}
16+
trait SubPathMacros extends StringPathChunkConversion {
17+
inline implicit def stringSubPathValidated(s: String): SubPath =
18+
${
19+
Macros.stringSubPathValidatedImpl('s)
20+
}
21+
}
22+
trait RelPathMacros extends StringPathChunkConversion {
23+
inline implicit def stringRelPathValidated(s: String): RelPath =
24+
${
25+
Macros.stringRelPathValidatedImpl('s)
26+
}
27+
}
28+
trait PathMacros extends StringPathChunkConversion {
29+
inline implicit def stringPathValidated(s: String): Path =
30+
${
31+
Macros.stringPathValidatedImpl('s)
32+
}
33+
}
1634

1735
object Macros {
1836
def stringPathChunkValidatedImpl(s: Expr[String])(using quotes: Quotes): Expr[PathChunk] = {
1937
import quotes.reflect.*
2038

2139
s.asTerm match {
2240
case Inlined(_, _, Literal(StringConstant(literal))) =>
23-
segmentsFromStringLiteralValidation(literal)
41+
val segments = segmentsFromStringLiteralValidation(literal)
2442
'{
25-
new RelPathChunk(fromStringSegments(segmentsFromString($s)))
43+
new RelPathChunk(fromStringSegments(${Expr(segments)}))
2644
}
2745
case _ =>
2846
'{
@@ -32,4 +50,38 @@ object Macros {
3250
}
3351
}
3452
}
53+
def stringSubPathValidatedImpl(s: Expr[String])(using quotes: Quotes): Expr[SubPath] = {
54+
import quotes.reflect.*
55+
56+
s.asTerm match {
57+
case Inlined(_, _, Literal(StringConstant(literal))) if !literal.startsWith("/") =>
58+
val stringSegments = segmentsFromStringLiteralValidation(literal)
59+
if (stringSegments.startsWith(Seq(".."))) {
60+
report.errorAndAbort("Invalid subpath literal: " + s.show)
61+
}
62+
'{ os.sub / fromStringSegments(${Expr(stringSegments)}) }
63+
case _ => report.errorAndAbort("Invalid subpath literal: " + s.show)
64+
65+
}
66+
}
67+
def stringRelPathValidatedImpl(s: Expr[String])(using quotes: Quotes): Expr[RelPath] = {
68+
import quotes.reflect.*
69+
70+
s.asTerm match {
71+
case Inlined(_, _, Literal(StringConstant(literal))) if !literal.startsWith("/") =>
72+
val segments = segmentsFromStringLiteralValidation(literal)
73+
'{ fromStringSegments(${Expr(segments)}) }
74+
case _ => report.errorAndAbort("Invalid relative path literal: " + s.show)
75+
}
76+
}
77+
def stringPathValidatedImpl(s: Expr[String])(using quotes: Quotes): Expr[Path] = {
78+
import quotes.reflect.*
79+
80+
s.asTerm match {
81+
case Inlined(_, _, Literal(StringConstant(literal))) if literal.startsWith("/") =>
82+
val segments = segmentsFromStringLiteralValidation(literal.stripPrefix("/"))
83+
'{ os.root / fromStringSegments(${Expr(segments)}) }
84+
case _ => report.errorAndAbort("Invalid absolute path literal: " + s.show)
85+
}
86+
}
3587
}

os/src/Path.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ class RelPath private[os] (segments0: Array[String], val ups: Int)
349349
def resolveFrom(base: os.Path) = base / this
350350
}
351351

352-
object RelPath {
352+
object RelPath extends RelPathMacros {
353353

354354
def apply[T: PathConvertible](f0: T): RelPath = {
355355
val f = implicitly[PathConvertible[T]].apply(f0)
@@ -410,7 +410,7 @@ class SubPath private[os] (val segments0: Array[String])
410410
def resolveFrom(base: os.Path) = base / this
411411
}
412412

413-
object SubPath {
413+
object SubPath extends SubPathMacros {
414414
private[os] def relativeTo0(segments0: Array[String], segments: IndexedSeq[String]): RelPath = {
415415

416416
val commonPrefix = {
@@ -437,7 +437,7 @@ object SubPath {
437437
val sub: SubPath = new SubPath(Internals.emptyStringArray)
438438
}
439439

440-
object Path {
440+
object Path extends PathMacros {
441441
def apply(p: FilePath, base: Path) = p match {
442442
case p: RelPath => base / p
443443
case p: SubPath => base / p

os/test/src-jvm/ExampleTests.scala

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,14 @@ object ExampleTests extends TestSuite {
172172
test("newPath") {
173173

174174
val target = os.pwd / "out/scratch"
175+
val target2: os.Path = "/out/scratch" // literal syntax
175176
}
176177
test("relPaths") {
177178

178179
// The path "folder/file"
179180
val rel1 = os.rel / "folder/file"
180181
val rel2 = os.rel / "folder/file"
182+
val rel3: os.RelPath = "folder/file" // literal syntax
181183

182184
// The relative difference between two paths
183185
val target = os.pwd / "out/scratch/file"
@@ -197,14 +199,15 @@ object ExampleTests extends TestSuite {
197199
// The path "folder/file"
198200
val sub1 = os.sub / "folder/file"
199201
val sub2 = os.sub / "folder/file"
202+
val sub3 = "folder/file" // literal syntax
200203

201204
// The relative difference between two paths
202205
val target = os.pwd / "out/scratch/file"
203206
assert((target subRelativeTo os.pwd) == os.sub / "out/scratch/file")
204207

205208
// Converting os.RelPath to os.SubPath
206209
val rel3 = os.rel / "folder/file"
207-
val sub3 = rel3.asSubPath
210+
val sub4 = rel3.asSubPath
208211

209212
// `up`s are not allowed in sub paths
210213
intercept[Exception](os.pwd subRelativeTo target)

os/test/src-jvm/ExpectyIntegration.scala

Lines changed: 0 additions & 34 deletions
This file was deleted.

os/test/src/PathTests.scala

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,39 @@ object PathTests extends TestSuite {
1515

1616
val tests = Tests {
1717
test("Literals") {
18+
test("implicitConstructors") {
19+
test("valid") {
20+
val p: os.Path = "/hello/world"
21+
val s: os.SubPath = "hello/world"
22+
val r: os.RelPath = "../hello/world"
23+
assert(p == os.Path("/hello/world"))
24+
assert(s == os.SubPath("hello/world"))
25+
assert(r == os.RelPath("../hello/world"))
26+
}
27+
test("invalidLiteral") {
28+
val err1 = compileError("""val p: os.Path = "hello/world" """)
29+
assert(err1.msg.contains("Invalid absolute path literal: \"hello/world\""))
30+
31+
val err2 = compileError("""val s: os.SubPath = "../hello/world" """)
32+
assert(err2.msg.contains("Invalid subpath literal: \"../hello/world\""))
33+
34+
val err3 = compileError("""val s: os.SubPath = "/hello/world" """)
35+
assert(err3.msg.contains("Invalid subpath literal: \"/hello/world\""))
36+
37+
val err4 = compileError("""val r: os.RelPath = "/hello/world" """)
38+
assert(err4.msg.contains("Invalid relative path literal: \"/hello/world\""))
39+
}
40+
test("nonLiteral") {
41+
val err1 = compileError("""val str = "hello/world"; val p: os.Path = str """)
42+
assert(err1.msg.contains("Invalid absolute path literal: str"))
43+
44+
val err2 = compileError("""val str = "/hello/world"; val s: os.SubPath = str """)
45+
assert(err2.msg.contains("Invalid subpath literal: str"))
46+
47+
val err3 = compileError("""val str = "/hello/world"; val r: os.RelPath = str""")
48+
assert(err3.msg.contains("Invalid relative path literal: str"))
49+
}
50+
}
1851
test("Basic") {
1952
assert(rel / "src" / "Main/.scala" == rel / "src" / "Main" / ".scala")
2053
assert(root / "core/src/test" == root / "core" / "src" / "test")

0 commit comments

Comments
 (0)