Skip to content

Commit

Permalink
Add literal syntax for os.Path, os.SubPath, os.RelPath (#353)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
lihaoyi authored Feb 5, 2025
1 parent e659bd9 commit 944e33f
Show file tree
Hide file tree
Showing 7 changed files with 155 additions and 43 deletions.
14 changes: 11 additions & 3 deletions Readme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -2073,6 +2073,9 @@ wd / "folder" / "file"
// The RHS of `/` can have multiple segments if-and-only-if it is a literal string
wd / "folder/file"
// Literal syntax for absolute `os.Path`
val p: os.Path = "/folder/file"
// A path starting from the root
os.root / "folder/file"
Expand All @@ -2086,7 +2089,7 @@ wd / os.up
wd / os.up / os.up
----

When constructing `os.Path`s, the right-hand-side of the `/` operator must be either a non-literal
When constructing ``os.Path``s, the right-hand-side of the `/` operator must be either a non-literal
a string expression containing a single path segment or a literal string containing one-or-more
path segments. If a non-literal string expression on the RHS contains multiple segments, you need
to wrap the RHS in an explicit `os.RelPath(...)` or `os.SubPath(...)` constructor to tell OS-Lib
Expand Down Expand Up @@ -2135,9 +2138,11 @@ before the relative path is applied. They can be created in the following ways:
val rel1 = os.rel / "folder" / "file"
// RHS of `/` can have multiple segments if-and-only-if it is a literal string
val rel2 = os.rel / "folder/file"
// Literal syntax for `os.RelPath`
val rel3: os.RelPath = "folder/file"
// The path "file"
val rel3 = os.rel / "file"
val rel4 = os.rel / "file"
// The relative difference between two paths
val target = os.pwd / "target/file"
Expand Down Expand Up @@ -2201,14 +2206,16 @@ They can be created in the following ways:
val sub1 = os.sub / "folder" / "file"
// RHS of `/` can have multiple segments if-and-only-if it is a literal string
val sub2 = os.sub / "folder/file"
// Literal syntax for `os.SubPath`
val sub2: os.Subpath = "folder/file"
// The relative difference between two paths
val target = os.pwd / "out/scratch/file"
assert((target subRelativeTo os.pwd) == os.sub / "out/scratch/file")
// Converting os.RelPath to os.SubPath
val rel3 = os.rel / "folder/file"
val sub3 = rel3.asSubPath
val sub4 = rel3.asSubPath
----

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

* Add ability to instrument path based operations using hooks https://github.com/com-lihaoyi/os-lib/pull/325[#325]
* Add compile-time validation of literal paths containing ".." https://github.com/com-lihaoyi/os-lib/pull/329[#329]
* 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]

[#0-11-3]
=== 0.11.3
Expand Down
50 changes: 50 additions & 0 deletions os/src-2/Macros.scala
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ trait PathChunkMacros extends StringPathChunkConversion {
implicit def stringPathChunkValidated(s: String): PathChunk =
macro Macros.stringPathChunkValidatedImpl
}
trait SubPathMacros extends StringPathChunkConversion {
implicit def stringSubPathValidated(s: String): SubPath =
macro Macros.stringSubPathValidatedImpl
}
trait RelPathMacros extends StringPathChunkConversion {
implicit def stringRelPathValidated(s: String): RelPath =
macro Macros.stringRelPathValidatedImpl
}
trait PathMacros extends StringPathChunkConversion {
implicit def stringPathValidated(s: String): Path =
macro Macros.stringPathValidatedImpl
}

object Macros {

Expand All @@ -30,4 +42,42 @@ object Macros {
)
}
}
def stringSubPathValidatedImpl(c: blackbox.Context)(s: c.Expr[String]): c.Expr[SubPath] = {
import c.universe.{Try => _, _}

s match {
case Expr(Literal(Constant(literal: String))) if !literal.startsWith("/") =>
val stringSegments = segmentsFromStringLiteralValidation(literal)

if (stringSegments.startsWith(Seq(".."))) {
c.abort(s.tree.pos, "Invalid subpath literal: " + s.tree)
}
c.Expr(q"""os.sub / _root_.os.RelPath.fromStringSegments($stringSegments)""")

case _ => c.abort(s.tree.pos, "Invalid subpath literal: " + s.tree)
}
}
def stringRelPathValidatedImpl(c: blackbox.Context)(s: c.Expr[String]): c.Expr[RelPath] = {
import c.universe.{Try => _, _}

s match {
case Expr(Literal(Constant(literal: String))) if !literal.startsWith("/") =>
val stringSegments = segmentsFromStringLiteralValidation(literal)
c.Expr(q"""os.rel / _root_.os.RelPath.fromStringSegments($stringSegments)""")

case _ => c.abort(s.tree.pos, "Invalid relative path literal: " + s.tree)
}
}
def stringPathValidatedImpl(c: blackbox.Context)(s: c.Expr[String]): c.Expr[Path] = {
import c.universe.{Try => _, _}

s match {
case Expr(Literal(Constant(literal: String))) if literal.startsWith("/") =>
val stringSegments = segmentsFromStringLiteralValidation(literal.stripPrefix("/"))

c.Expr(q"""os.root / _root_.os.RelPath.fromStringSegments($stringSegments)""")

case _ => c.abort(s.tree.pos, "Invalid absolute path literal: " + s.tree)
}
}
}
56 changes: 54 additions & 2 deletions os/src-3/Macros.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,34 @@ trait PathChunkMacros extends StringPathChunkConversion {
Macros.stringPathChunkValidatedImpl('s)
}
}
trait SubPathMacros extends StringPathChunkConversion {
inline implicit def stringSubPathValidated(s: String): SubPath =
${
Macros.stringSubPathValidatedImpl('s)
}
}
trait RelPathMacros extends StringPathChunkConversion {
inline implicit def stringRelPathValidated(s: String): RelPath =
${
Macros.stringRelPathValidatedImpl('s)
}
}
trait PathMacros extends StringPathChunkConversion {
inline implicit def stringPathValidated(s: String): Path =
${
Macros.stringPathValidatedImpl('s)
}
}

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

s.asTerm match {
case Inlined(_, _, Literal(StringConstant(literal))) =>
segmentsFromStringLiteralValidation(literal)
val segments = segmentsFromStringLiteralValidation(literal)
'{
new RelPathChunk(fromStringSegments(segmentsFromString($s)))
new RelPathChunk(fromStringSegments(${Expr(segments)}))
}
case _ =>
'{
Expand All @@ -32,4 +50,38 @@ object Macros {
}
}
}
def stringSubPathValidatedImpl(s: Expr[String])(using quotes: Quotes): Expr[SubPath] = {
import quotes.reflect.*

s.asTerm match {
case Inlined(_, _, Literal(StringConstant(literal))) if !literal.startsWith("/") =>
val stringSegments = segmentsFromStringLiteralValidation(literal)
if (stringSegments.startsWith(Seq(".."))) {
report.errorAndAbort("Invalid subpath literal: " + s.show)
}
'{ os.sub / fromStringSegments(${Expr(stringSegments)}) }
case _ => report.errorAndAbort("Invalid subpath literal: " + s.show)

}
}
def stringRelPathValidatedImpl(s: Expr[String])(using quotes: Quotes): Expr[RelPath] = {
import quotes.reflect.*

s.asTerm match {
case Inlined(_, _, Literal(StringConstant(literal))) if !literal.startsWith("/") =>
val segments = segmentsFromStringLiteralValidation(literal)
'{ fromStringSegments(${Expr(segments)}) }
case _ => report.errorAndAbort("Invalid relative path literal: " + s.show)
}
}
def stringPathValidatedImpl(s: Expr[String])(using quotes: Quotes): Expr[Path] = {
import quotes.reflect.*

s.asTerm match {
case Inlined(_, _, Literal(StringConstant(literal))) if literal.startsWith("/") =>
val segments = segmentsFromStringLiteralValidation(literal.stripPrefix("/"))
'{ os.root / fromStringSegments(${Expr(segments)}) }
case _ => report.errorAndAbort("Invalid absolute path literal: " + s.show)
}
}
}
6 changes: 3 additions & 3 deletions os/src/Path.scala
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ class RelPath private[os] (segments0: Array[String], val ups: Int)
def resolveFrom(base: os.Path) = base / this
}

object RelPath {
object RelPath extends RelPathMacros {

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

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

val commonPrefix = {
Expand All @@ -437,7 +437,7 @@ object SubPath {
val sub: SubPath = new SubPath(Internals.emptyStringArray)
}

object Path {
object Path extends PathMacros {
def apply(p: FilePath, base: Path) = p match {
case p: RelPath => base / p
case p: SubPath => base / p
Expand Down
5 changes: 4 additions & 1 deletion os/test/src-jvm/ExampleTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,14 @@ object ExampleTests extends TestSuite {
test("newPath") {

val target = os.pwd / "out/scratch"
val target2: os.Path = "/out/scratch" // literal syntax
}
test("relPaths") {

// The path "folder/file"
val rel1 = os.rel / "folder/file"
val rel2 = os.rel / "folder/file"
val rel3: os.RelPath = "folder/file" // literal syntax

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

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

// Converting os.RelPath to os.SubPath
val rel3 = os.rel / "folder/file"
val sub3 = rel3.asSubPath
val sub4 = rel3.asSubPath

// `up`s are not allowed in sub paths
intercept[Exception](os.pwd subRelativeTo target)
Expand Down
34 changes: 0 additions & 34 deletions os/test/src-jvm/ExpectyIntegration.scala

This file was deleted.

33 changes: 33 additions & 0 deletions os/test/src/PathTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,39 @@ object PathTests extends TestSuite {

val tests = Tests {
test("Literals") {
test("implicitConstructors") {
test("valid") {
val p: os.Path = "/hello/world"
val s: os.SubPath = "hello/world"
val r: os.RelPath = "../hello/world"
assert(p == os.Path("/hello/world"))
assert(s == os.SubPath("hello/world"))
assert(r == os.RelPath("../hello/world"))
}
test("invalidLiteral") {
val err1 = compileError("""val p: os.Path = "hello/world" """)
assert(err1.msg.contains("Invalid absolute path literal: \"hello/world\""))

val err2 = compileError("""val s: os.SubPath = "../hello/world" """)
assert(err2.msg.contains("Invalid subpath literal: \"../hello/world\""))

val err3 = compileError("""val s: os.SubPath = "/hello/world" """)
assert(err3.msg.contains("Invalid subpath literal: \"/hello/world\""))

val err4 = compileError("""val r: os.RelPath = "/hello/world" """)
assert(err4.msg.contains("Invalid relative path literal: \"/hello/world\""))
}
test("nonLiteral") {
val err1 = compileError("""val str = "hello/world"; val p: os.Path = str """)
assert(err1.msg.contains("Invalid absolute path literal: str"))

val err2 = compileError("""val str = "/hello/world"; val s: os.SubPath = str """)
assert(err2.msg.contains("Invalid subpath literal: str"))

val err3 = compileError("""val str = "/hello/world"; val r: os.RelPath = str""")
assert(err3.msg.contains("Invalid relative path literal: str"))
}
}
test("Basic") {
assert(rel / "src" / "Main/.scala" == rel / "src" / "Main" / ".scala")
assert(root / "core/src/test" == root / "core" / "src" / "test")
Expand Down

0 comments on commit 944e33f

Please sign in to comment.