diff --git a/spec/std/file_spec.cr b/spec/std/file_spec.cr index 33f46a492843..440b4a2cc19e 100644 --- a/spec/std/file_spec.cr +++ b/spec/std/file_spec.cr @@ -564,17 +564,17 @@ describe "File" do File.expand_path("../bin", "x/../tmp").should eq(File.join([base, "bin"])) end - pending_win32 "expand_path for commoms unix path give a full path" do - File.expand_path("/tmp/").should eq("/tmp") + it "expand_path for commoms unix path give a full path" do + File.expand_path("/tmp/").should eq("/tmp/") File.expand_path("/tmp/../../../tmp").should eq("/tmp") File.expand_path("").should eq(base) - File.expand_path("./////").should eq(base) + File.expand_path("./////").should eq(File.join(base, "")) File.expand_path(".").should eq(base) File.expand_path(base).should eq(base) end - pending_win32 "converts a pathname to an absolute pathname, using ~ (home) as base" do - File.expand_path("~/").should eq(home) + it "converts a pathname to an absolute pathname, using ~ (home) as base" do + File.expand_path("~/").should eq(File.join(home, "")) File.expand_path("~/..badfilename").should eq(File.join(home, "..badfilename")) File.expand_path("..").should eq("/#{base.split('/')[0...-1].join('/')}".gsub(%r{\A//}, "/")) File.expand_path("~/a", "~/b").should eq(File.join(home, "a")) @@ -583,11 +583,11 @@ describe "File" do File.expand_path("~/a", "/tmp/gumby/ddd").should eq(File.join([home, "a"])) end - pending_win32 "converts a pathname to an absolute pathname, using ~ (home) as base (trailing /)" do + it "converts a pathname to an absolute pathname, using ~ (home) as base (trailing /)" do prev_home = home begin ENV["HOME"] = File.expand_path(datapath) - File.expand_path("~/").should eq(home) + File.expand_path("~/").should eq(File.join(home, "")) File.expand_path("~/..badfilename").should eq(File.join(home, "..badfilename")) File.expand_path("..").should eq("/#{base.split('/')[0...-1].join('/')}".gsub(%r{\A//}, "/")) File.expand_path("~/a", "~/b").should eq(File.join(home, "a")) @@ -599,13 +599,13 @@ describe "File" do end end - pending_win32 "converts a pathname to an absolute pathname, using ~ (home) as base (HOME=/)" do + it "converts a pathname to an absolute pathname, using ~ (home) as base (HOME=/)" do prev_home = home begin ENV["HOME"] = "/" File.expand_path("~/").should eq(home) File.expand_path("~/..badfilename").should eq(File.join(home, "..badfilename")) - File.expand_path("..").should eq("/#{base.split('/')[0...-1].join('/')}".gsub(%r{\A//}, "/")) + File.expand_path("..").should eq("/#{base.split('/')[0...-1].join('/')}".gsub(/\A\/\//, "/")) File.expand_path("~/a", "~/b").should eq(File.join(home, "a")) File.expand_path("~").should eq(home) File.expand_path("~", "/tmp/gumby/ddd").should eq(home) diff --git a/spec/std/path_spec.cr b/spec/std/path_spec.cr new file mode 100644 index 000000000000..2f38cfedca11 --- /dev/null +++ b/spec/std/path_spec.cr @@ -0,0 +1,599 @@ +require "spec" +require "./spec_helper" + +private BASE_POSIX = "/default/base" +private BASE_WINDOWS = "\\default\\base" +private HOME_WINDOWS = "C:\\Users\\Crystal" +private HOME_POSIX = "/home/crystal" + +private def it_normalizes_path(path, posix = path, windows = path, file = __FILE__, line = __LINE__) + assert_paths(path, posix, windows, "normalizes", file, line, &.normalize) +end + +private def it_expands_path(path, posix, windows = posix, *, base = nil, env_home = nil, expand_base = false, file = __FILE__, line = __LINE__) + assert_paths(path, posix, windows, %((base: "#{base}")), file, line) do |path| + prev_home = ENV["HOME"] + + begin + ENV["HOME"] = env_home || (path.windows? ? HOME_WINDOWS : HOME_POSIX) + + base_arg = base || (path.windows? ? BASE_WINDOWS : BASE_POSIX) + path.expand(base_arg.not_nil!, expand_base: !!expand_base) + ensure + ENV["HOME"] = prev_home + end + end +end + +private def it_joins_path(path, parts, posix, windows = posix, file = __FILE__, line = __LINE__) + assert_paths(path, posix, windows, %(resolving "#{parts}"), file, line, &.join(parts)) +end + +private def assert_paths(path, posix, windows = posix, label = nil, file = __FILE__, line = __LINE__, &block : Path -> _) + case posix + when Nil + when Tuple then posix = Path.posix(*posix) + when String then posix = Path.posix(posix) + when Array then posix = posix.map { |path| Path.posix(path) } + end + case windows + when Nil + when Tuple then windows = Path.windows(*windows) + when String then windows = Path.windows(windows) + when Array then windows = windows.map { |path| Path.windows(path) } + end + assert_paths_raw(path, posix, windows, label, file, line, &block) +end + +private def assert_paths_raw(path, posix, windows = posix, label = nil, file = __FILE__, line = __LINE__, &block : Path -> _) + it %(#{label} "#{path}" (posix)), file, line do + block.call(Path.posix(path)).should eq(posix) + end + it %(#{label} "#{path}" (windows)), file, line do + block.call(Path.windows(path)).should eq(windows) + end +end + +describe Path do + describe ".new" do + it { Path.new("foo").native?.should be_true } + it { Path.new("foo").to_s.should eq "foo" } + + it "fails with null byte" do + expect_raises ArgumentError, "String contains null byte" do + Path.new("foo\0") + end + end + + it { Path.new.to_s.should eq "" } + end + + describe ".posix" do + it { Path.posix("foo").posix?.should be_true } + it { Path.posix("foo").windows?.should be_false } + it { Path.posix("foo").to_s.should eq "foo" } + + it "fails with null byte" do + expect_raises ArgumentError, "String contains null byte" do + Path.posix("foo\0") + end + end + + it { Path.posix.to_s.should eq "" } + end + + describe ".windows" do + it { Path.windows("foo").posix?.should be_false } + it { Path.windows("foo").windows?.should be_true } + it { Path.windows("foo").to_s.should eq "foo" } + + it "fails with null byte" do + expect_raises ArgumentError, "String contains null byte" do + Path.windows("foo\0") + end + end + + it { Path.windows.to_s.should eq "" } + end + + it ".[]" do + Path["foo"].should eq Path.new("foo") + Path["foo"].native?.should be_true + Path["foo", "bar", "baz"].should eq Path.new("foo", "bar", "baz") + Path["/foo", "bar", "baz"].should eq Path.new("/foo", "bar", "baz") + end + + describe "#parent" do + assert_paths("/Users/foo/bar.cr", "/Users/foo", &.parent) + assert_paths("Users/foo/bar.cr", "Users/foo", &.parent) + assert_paths("foo/bar/", "foo", &.parent) + assert_paths("foo/bar/.", "foo/bar", &.parent) + assert_paths("foo/bar/..", "foo/bar", &.parent) + assert_paths("foo", ".", &.parent) + assert_paths("foo/", ".", &.parent) + assert_paths("/", "/", &.parent) + assert_paths("////", "/", &.parent) + assert_paths("foo//.//", "foo", &.parent) + assert_paths("/.", "/", &.parent) + assert_paths("/foo", "/", &.parent) + assert_paths("", ".", &.parent) + assert_paths("./foo", ".", &.parent) + assert_paths(".", ".", &.parent) + assert_paths("\\Users\\foo\\bar.cr", ".", "\\Users\\foo", &.parent) + assert_paths("\\Users/foo\\bar.cr", "\\Users", "\\Users/foo", &.parent) + assert_paths("foo\\bar\\", ".", "foo", &.parent) + assert_paths("foo\\bar\\.", ".", "foo\\bar", &.parent) + assert_paths("foo\\bar\\..", ".", "foo\\bar", &.parent) + assert_paths("foo\\", ".", &.parent) + assert_paths("\\", ".", "\\", &.parent) + assert_paths(".\\foo", ".", &.parent) + assert_paths("C:", ".", "C:", &.parent) + assert_paths("C:/", ".", "C:/", &.parent) + assert_paths("C:\\", ".", "C:\\", &.parent) + assert_paths("C:/foo", "C:", "C:/", &.parent) + assert_paths("C:\\foo", ".", "C:\\", &.parent) + assert_paths("/foo/C:/bar", "/foo/C:", "/foo/C:", &.parent) + end + + describe "#parents" do + assert_paths("/Users/foo/bar.cr", ["/", "/Users", "/Users/foo"], &.parents) + assert_paths("Users/foo/bar.cr", [".", "Users", "Users/foo"], &.parents) + assert_paths("foo/bar/", [".", "foo"], &.parents) + assert_paths("foo/bar/.", [".", "foo", "foo/bar"], &.parents) + assert_paths("foo", ["."], &.parents) + assert_paths("foo/", ["."], &.parents) + assert_paths("/", [] of String, &.parents) + assert_paths("////", [] of String, &.parents) + assert_paths("/.", [] of String, &.parents) + assert_paths("/foo", ["/"], &.parents) + assert_paths("", [] of String, &.parents) + assert_paths("./foo", ["."], &.parents) + assert_paths(".", [] of String, &.parents) + assert_paths("\\Users\\foo\\bar.cr", ["."], ["\\", "\\Users", "\\Users\\foo"], &.parents) + assert_paths("\\Users/foo\\bar.cr", [".", "\\Users"], ["\\", "\\Users", "\\Users/foo"], &.parents) + assert_paths("C:\\Users\\foo\\bar.cr", ["."], ["C:\\", "C:\\Users", "C:\\Users\\foo"], &.parents) + assert_paths("foo\\bar\\", ["."], [".", "foo"], &.parents) + assert_paths("foo\\", ["."], &.parents) + assert_paths("\\", ["."], [] of String, &.parents) + assert_paths(".\\foo", ["."], &.parents) + assert_paths("foo/../bar/", [".", "foo", "foo/.."], &.parents) + assert_paths("foo/../bar/.", [".", "foo", "foo/..", "foo/../bar"], &.parents) + assert_paths("foo/bar/..", [".", "foo", "foo/bar"], &.parents) + assert_paths("foo/bar/../.", [".", "foo", "foo/bar", "foo/bar/.."], &.parents) + assert_paths("foo/./bar/", [".", "foo", "foo/."], &.parents) + assert_paths("foo/./bar/.", [".", "foo", "foo/.", "foo/./bar"], &.parents) + assert_paths("foo/bar/.", [".", "foo", "foo/bar"], &.parents) + assert_paths("foo/bar/./.", [".", "foo", "foo/bar", "foo/bar/."], &.parents) + end + + describe "#dirname" do + assert_paths_raw("/Users/foo/bar.cr", "/Users/foo", &.dirname) + end + + describe "#basename" do + assert_paths_raw("/foo/bar/baz.cr", "baz.cr", &.basename) + assert_paths_raw("/foo/", "foo", &.basename) + assert_paths_raw("foo", "foo", &.basename) + assert_paths_raw("", "", &.basename) + assert_paths_raw(".", ".", &.basename) + assert_paths_raw("/.", ".", &.basename) + assert_paths_raw("/", "/", &.basename) + assert_paths_raw("////", "/", &.basename) + assert_paths_raw("a/.x", ".x", &.basename) + assert_paths_raw("a/x.", "x.", &.basename) + + assert_paths_raw("\\foo\\bar\\baz.cr", "\\foo\\bar\\baz.cr", "baz.cr", &.basename) + assert_paths_raw("\\foo\\", "\\foo\\", "foo", &.basename) + assert_paths_raw("\\", "\\", "\\", &.basename) + assert_paths_raw("\\.", "\\.", ".", &.basename) + + describe "removes suffix" do + assert_paths_raw("/foo/bar/baz.cr", "baz", &.basename(".cr")) + assert_paths_raw("\\foo\\bar\\baz.cr", "\\foo\\bar\\baz", "baz", &.basename(".cr")) + assert_paths_raw("\\foo/bar\\baz.cr", "bar\\baz", "baz", &.basename(".cr")) + assert_paths_raw("/foo/bar/baz.cr.tmp", "baz.cr.tmp", "baz.cr.tmp", &.basename(".cr")) + assert_paths_raw("\\foo\\bar\\baz.cr.tmp", "\\foo\\bar\\baz.cr.tmp", "baz.cr.tmp", &.basename(".cr")) + assert_paths_raw("/foo/bar/baz.cr.tmp", "baz", &.basename(".cr.tmp")) + assert_paths_raw("\\foo\\bar\\baz.cr.tmp", "\\foo\\bar\\baz", "baz", &.basename(".cr.tmp")) + assert_paths_raw("/foo/bar/baz.cr.tmp", "baz.cr", &.basename(".tmp")) + assert_paths_raw("\\foo\\bar\\baz.cr.tmp", "\\foo\\bar\\baz.cr", "baz.cr", &.basename(".tmp")) + end + end + + describe "#extension" do + assert_paths_raw("/foo/bar/baz.cr", ".cr", &.extension) + assert_paths_raw("/foo/bar/baz.cr.cz", ".cz", &.extension) + assert_paths_raw("/foo/bar/.profile", "", &.extension) + assert_paths_raw("/foo/bar/.profile.sh", ".sh", &.extension) + assert_paths_raw("/foo/bar/foo.", "", &.extension) + assert_paths_raw("test", "", &.extension) + assert_paths_raw("test.ext/foo", "", &.extension) + end + + describe "#absolute?" do + assert_paths_raw("/foo", true, false, &.absolute?) + assert_paths_raw("/./foo", true, false, &.absolute?) + + assert_paths_raw("foo", false, &.absolute?) + assert_paths_raw("./foo", false, &.absolute?) + assert_paths_raw("~/foo", false, &.absolute?) + + assert_paths_raw("\\foo", false, &.absolute?) + assert_paths_raw("\\.\\foo", false, &.absolute?) + assert_paths_raw("foo", false, &.absolute?) + assert_paths_raw(".\\foo", false, &.absolute?) + assert_paths_raw("~\\foo", false, &.absolute?) + assert_paths_raw("C:", false, &.absolute?) + + assert_paths_raw("C:\\foo", false, true, &.absolute?) + assert_paths_raw("C:/foo/bar", false, true, &.absolute?) + assert_paths_raw("C:\\", false, true, &.absolute?) + assert_paths_raw("C:/foo", false, true, &.absolute?) + assert_paths_raw("C:/", false, true, &.absolute?) + assert_paths_raw("c:\\\\", false, true, &.absolute?) + + assert_paths_raw("//some/share", true, false, &.absolute?) + assert_paths_raw("\\\\some\\share", false, false, &.absolute?) + assert_paths_raw("//some/share/", true, true, &.absolute?) + assert_paths_raw("\\\\some\\share\\", false, true, &.absolute?) + end + + describe "#drive" do + assert_paths("C:\\foo", nil, "C:", &.drive) + assert_paths("C:/foo", nil, "C:", &.drive) + assert_paths("C:foo", nil, "C:", &.drive) + assert_paths("/foo", nil, nil, &.drive) + assert_paths("//foo", nil, nil, &.drive) + assert_paths("//some/share", nil, "//some/share", &.drive) + assert_paths("//some/share/", nil, "//some/share", &.drive) + assert_paths("///not-a/share/", nil, nil, &.drive) + assert_paths("/not-a//share/", nil, nil, &.drive) + assert_paths("\\\\some\\share", nil, "\\\\some\\share", &.drive) + assert_paths("\\\\some\\share\\", nil, "\\\\some\\share", &.drive) + assert_paths("\\\\\\not-a\\share", nil, nil, &.drive) + assert_paths("\\\\not-a\\\\share", nil, nil, &.drive) + end + + describe "#root" do + assert_paths("C:\\foo", nil, "\\", &.root) + assert_paths("C:/foo", nil, "/", &.root) + assert_paths("C:foo", nil, nil, &.root) + assert_paths("/foo", "/", &.root) + assert_paths("//foo", "/", &.root) + assert_paths("\\foo", nil, "\\", &.root) + assert_paths("\\\\foo", nil, "\\", &.root) + assert_paths("//some/share", "/", nil, &.root) + assert_paths("\\\\some\\share", nil, &.root) + assert_paths("//some/share/", "/", "/", &.root) + assert_paths("\\\\some\\share\\", nil, "\\", &.root) + end + + describe "#anchor" do + assert_paths("C:\\foo", nil, "C:\\", &.anchor) + assert_paths("C:/foo", nil, "C:/", &.anchor) + assert_paths("C:foo", nil, "C:", &.anchor) + assert_paths("/foo", "/", &.anchor) + assert_paths("\\foo", nil, "\\", &.anchor) + assert_paths("//some/share", "/", "//some/share", &.anchor) + assert_paths("//some/share/", "/", "//some/share/", &.anchor) + assert_paths("\\\\some\\share", nil, "\\\\some\\share", &.anchor) + assert_paths("\\\\some\\share\\", nil, "\\\\some\\share\\", &.anchor) + end + + describe "#normalize" do + describe "path with forward slash" do + describe "already clean" do + it_normalizes_path("", ".", ".") + it_normalizes_path("abc") + it_normalizes_path("abc/def", windows: "abc\\def") + it_normalizes_path("a/b/c", windows: "a\\b\\c") + it_normalizes_path(".") + it_normalizes_path("..") + it_normalizes_path("../..", windows: "..\\..") + it_normalizes_path("../../abc", windows: "..\\..\\abc") + it_normalizes_path("/abc", windows: "\\abc") + it_normalizes_path("/", windows: "\\") + end + + describe "removes trailing slash" do + it_normalizes_path("abc/", "abc", "abc") + it_normalizes_path("abc/def/", "abc/def", "abc\\def") + it_normalizes_path("a/b/c/", "a/b/c", "a\\b\\c") + it_normalizes_path("./", ".", ".") + it_normalizes_path("../", "..", "..") + it_normalizes_path("../../", "../..", "..\\..") + it_normalizes_path("/abc/", "/abc", "\\abc") + end + + describe "removes double slash" do + it_normalizes_path("abc//def//ghi", "abc/def/ghi", "abc\\def\\ghi") + it_normalizes_path("//abc", "/abc", "\\abc") + it_normalizes_path("///abc", "/abc", "\\abc") + it_normalizes_path("//abc//", "/abc", "\\abc") + it_normalizes_path("abc//", "abc", "abc") + end + + describe "removes ." do + it_normalizes_path("abc/./def", "abc/def", "abc\\def") + it_normalizes_path("/./abc/def", "/abc/def", "\\abc\\def") + it_normalizes_path("abc/.", "abc", "abc") + end + + describe "removes .." do + it_normalizes_path("abc/def/ghi/../jkl", "abc/def/jkl", "abc\\def\\jkl") + it_normalizes_path("abc/def/../ghi/../jkl", "abc/jkl", "abc\\jkl") + it_normalizes_path("abc/def/..", "abc", "abc") + it_normalizes_path("abc/def/../..", ".", ".") + it_normalizes_path("/abc/def/../..", "/", "\\") + it_normalizes_path("abc/def/../../..", "..", "..") + it_normalizes_path("/abc/def/../../..", "/", "\\") + it_normalizes_path("abc/def/../../../ghi/jkl/../../../mno", "../../mno", "..\\..\\mno") + end + + describe "combinations" do + it_normalizes_path("abc/./../def", "def", "def") + it_normalizes_path("abc//./../def", "def", "def") + it_normalizes_path("abc/../../././../def", "../../def", "..\\..\\def") + end + end + + describe "paths with backslash" do + describe "already clean" do + it_normalizes_path("abc\\def") + it_normalizes_path("a\\b\\c") + it_normalizes_path("..\\..") + it_normalizes_path("..\\..\\abc") + it_normalizes_path("\\abc") + it_normalizes_path("\\") + end + + describe "removes trailing slash" do + it_normalizes_path("abc\\", windows: "abc") + it_normalizes_path("abc\\def\\", windows: "abc\\def") + it_normalizes_path("a\\b\\c\\", windows: "a\\b\\c") + it_normalizes_path(".\\", windows: ".") + it_normalizes_path("..\\", windows: "..") + it_normalizes_path("..\\..\\", windows: "..\\..") + it_normalizes_path("\\abc\\", windows: "\\abc") + end + + describe "removes double slash" do + it_normalizes_path("abc\\\\def\\\\ghi", windows: "abc\\def\\ghi") + it_normalizes_path("\\\\abc", windows: "\\abc") + it_normalizes_path("\\\\\\abc", windows: "\\abc") + it_normalizes_path("\\\\abc\\\\", windows: "\\abc") + it_normalizes_path("abc\\\\", windows: "abc") + end + + describe "removes ." do + it_normalizes_path("abc\\.\\def", windows: "abc\\def") + it_normalizes_path("\\.\\abc\\def", windows: "\\abc\\def") + it_normalizes_path("abc\\.", windows: "abc") + end + + describe "removes .." do + it_normalizes_path("abc\\def\\ghi\\..\\jkl", windows: "abc\\def\\jkl") + it_normalizes_path("abc\\def\\..\\ghi\\..\\jkl", windows: "abc\\jkl") + it_normalizes_path("abc\\def\\..", windows: "abc") + it_normalizes_path("abc\\def\\..\\..", windows: ".") + it_normalizes_path("\\abc\\def\\..\\..", windows: "\\") + it_normalizes_path("abc\\def\\..\\..\\..", windows: "..") + it_normalizes_path("\\abc\\def\\..\\..\\..", windows: "\\") + it_normalizes_path("abc\\def\\..\\..\\..\\ghi\\jkl\\..\\..\\..\\mno", windows: "..\\..\\mno") + end + + describe "combinations" do + it_normalizes_path("abc\\.\\..\\def", windows: "def") + it_normalizes_path("abc\\\\.\\..\\def", windows: "def") + it_normalizes_path("abc\\..\\..\\.\\.\\..\\def", windows: "..\\..\\def") + end + end + + describe "with drive" do + it_normalizes_path("C:", "C:") + it_normalizes_path("C:\\", "C:\\") + it_normalizes_path("C:/", "C:", "C:\\") + it_normalizes_path("C:foo", "C:foo") + it_normalizes_path("C:\\foo", "C:\\foo") + it_normalizes_path("C:/foo", "C:/foo", "C:\\foo") + end + end + + describe "#join" do + it_joins_path("", "", "/", "\\") + it_joins_path("/", "", "/") + it_joins_path("", "/", "/") + it_joins_path("foo", {"bar", ""}, "foo/bar/", "foo\\bar\\") + it_joins_path("foo", {"bar", ""}, "foo/bar/", "foo\\bar\\") + it_joins_path("///foo", "bar", "///foo/bar", "///foo\\bar") + it_joins_path("///foo", "//bar", "///foo//bar") + it_joins_path("/foo/", "/bar", "/foo/bar") + it_joins_path("foo", "/", "foo/") + it_joins_path("foo", {"bar", "baz"}, "foo/bar/baz", "foo\\bar\\baz") + it_joins_path("foo", {"//bar//", "baz///"}, "foo//bar//baz///") + it_joins_path("/foo/", {"/bar/", "/baz/"}, "/foo/bar/baz/") + it_joins_path("", "a", "a") + it_joins_path("/", "a", "/a") + it_joins_path("", "/a", "/a") + it_joins_path("foo", {"/", "bar"}, "foo/bar") + it_joins_path("foo", {"/", "/", "bar"}, "foo/bar") + it_joins_path("/", {"/foo", "/", "bar/", "/"}, "/foo/bar/") + it_joins_path("c:/", "Program Files", "c:/Program Files") + it_joins_path("c:", "Program Files", "c:/Program Files", "c:\\Program Files") + + it_joins_path("\\\\\\\\foo", "bar", "\\\\\\\\foo/bar", "\\\\\\\\foo\\bar") + it_joins_path("\\\\\\foo", "\\\\bar", "\\\\\\foo/\\\\bar", "\\\\\\foo\\\\bar") + it_joins_path("\\foo\\", "\\bar", "\\foo\\/\\bar", "\\foo\\bar") + it_joins_path("foo", "\\", "foo/\\", "foo\\") + it_joins_path("foo", {"\\\\bar\\\\", "baz\\\\\\"}, "foo/\\\\bar\\\\/baz\\\\\\", "foo\\\\bar\\\\baz\\\\\\") + it_joins_path("\\foo\\", {"\\bar\\", "\\baz\\"}, "\\foo\\/\\bar\\/\\baz\\", "\\foo\\bar\\baz\\") + it_joins_path("\\", "a", "\\/a", "\\a") + it_joins_path("", "\\a", "\\a") + it_joins_path("foo", {"\\", "bar"}, "foo/\\/bar", "foo\\bar") + it_joins_path("foo", {"\\", "\\", "bar"}, "foo/\\/\\/bar", "foo\\bar") + it_joins_path("\\", {"\\foo", "\\", "bar\\", "\\"}, "\\/\\foo/\\/bar\\/\\", "\\foo\\bar\\") + it_joins_path("c:\\", "Program Files", "c:\\/Program Files", "c:\\Program Files") + + it_joins_path("foo", Path.windows("bar\\baz"), "foo/bar/baz", "foo\\bar\\baz") + it_joins_path("foo", Path.posix("bar/baz"), "foo/bar/baz", "foo\\bar/baz") + end + + describe "#expand" do + describe "converts a pathname to an absolute pathname" do + it_expands_path("", BASE_POSIX, BASE_WINDOWS) + it_expands_path("a", {BASE_POSIX, "a"}, {BASE_WINDOWS, "a"}) + it_expands_path("a", {BASE_POSIX, "a"}, {BASE_WINDOWS, "a"}) + end + + describe "converts a pathname to an absolute pathname, Ruby-Talk:18512" do + it_expands_path(".a", {BASE_POSIX, ".a"}, {BASE_WINDOWS, ".a"}) + it_expands_path("..a", {BASE_POSIX, "..a"}, {BASE_WINDOWS, "..a"}) + it_expands_path("a../b", {BASE_POSIX, "a../b"}, {BASE_WINDOWS, "a..\\b"}) + end + + describe "keeps trailing dots on absolute pathname" do + it_expands_path("a.", {BASE_POSIX, "a."}, {BASE_WINDOWS, "a."}) + it_expands_path("a..", {BASE_POSIX, "a.."}, {BASE_WINDOWS, "a.."}) + end + + describe "converts a pathname to an absolute pathname, using a complete path" do + it_expands_path("", "/tmp", "\\tmp", base: Path.posix("/tmp")) + it_expands_path("", "C:/tmp", "C:\\tmp", base: Path.windows("C:\\tmp")) + it_expands_path("a", "/tmp/a", "\\tmp\\a", base: Path.posix("/tmp")) + it_expands_path("a", "C:/tmp/a", "C:\\tmp\\a", base: Path.windows("C:\\tmp")) + it_expands_path("../a", "/tmp/a", "\\tmp\\a", base: Path.posix("/tmp/xxx")) + it_expands_path("../a", "C:/tmp/a", "C:\\tmp\\a", base: Path.windows("C:\\tmp\\xxx")) + it_expands_path("../a", "/tmp/a", "\\tmp\\a", base: Path.posix("/tmp/xxx")) + it_expands_path("../a", "C:/tmp/a", "C:\\tmp\\a", base: Path.windows("C:\\tmp\\xxx")) + it_expands_path(".", "/", "\\", base: Path.posix("/")) + pending { it_expands_path(".", "C:/", "C:\\", base: Path.windows("C:\\")) } + end + + describe "expands a path with multi-byte characters" do + it_expands_path("Ångström", "#{BASE_POSIX}/Ångström", "#{BASE_WINDOWS}\\Ångström") + end + + describe "expands /./dir to /dir" do + it_expands_path("/./dir", "/dir", "\\dir", base: "/") + end + + describe "replaces multiple / with a single /" do + it_expands_path("//some/path", "/some/path", "\\\\some\\path#{BASE_WINDOWS}\\") # Windows path is UNC share + it_expands_path("////some/path", "/some/path", "\\some\\path") + it_expands_path("/some////path", "/some/path", "\\some\\path") + end + + describe "expand path with" do + it_expands_path("../../bin", "/bin", "\\bin", base: "/tmp/x") + it_expands_path("../../bin", "/bin", "\\bin", base: "/tmp") + it_expands_path("../../bin", "/bin", "\\bin", base: "/") + it_expands_path("../bin", {Dir.current.gsub('\\', '/'), "tmp", "bin"}, {Path.windows(Dir.current).normalize.to_s, "tmp", "bin"}, base: "tmp/x", expand_base: true) + it_expands_path("../bin", {Dir.current.gsub('\\', '/'), "bin"}, {Path.windows(Dir.current).normalize.to_s, "bin"}, base: "x/../tmp", expand_base: true) + end + + describe "expand_path for commoms unix path give a full path" do + it_expands_path("/tmp/", "/tmp/", "\\tmp\\") + it_expands_path("/tmp/../../../tmp", "/tmp", "\\tmp") + it_expands_path("", BASE_POSIX, BASE_WINDOWS) + it_expands_path("./////", "#{BASE_POSIX}/", "#{BASE_WINDOWS}\\") + it_expands_path(".", BASE_POSIX, BASE_WINDOWS) + it_expands_path(BASE_POSIX, BASE_POSIX, BASE_POSIX.gsub('/', '\\')) + end + + describe "with drive" do + it_expands_path("foo", "D:/foo", "D:foo", base: "D:") + it_expands_path("/foo", "/foo", "D:\\foo", base: "D:") + it_expands_path("\\foo", "D:/\\foo", "D:\\foo", base: "D:") + it_expands_path("foo", "D:\\/foo", "D:\\foo", base: Path.posix("D:\\")) + it_expands_path("foo", "D:/foo", "D:\\foo", base: Path.windows("D:\\")) + it_expands_path("foo", "D:/foo", "D:\\foo", base: "D:/") + it_expands_path("/foo", "/foo", "D:\\foo", base: "D:\\") + it_expands_path("\\foo", "D:\\/\\foo", "D:\\foo", base: Path.posix("D:\\")) + it_expands_path("\\foo", "D:/\\foo", "D:\\foo", base: Path.windows("D:\\")) + it_expands_path("/foo", "/foo", "D:\\foo", base: "D:/") + it_expands_path("\\foo", "D:/\\foo", "D:\\foo", base: "D:/") + + it_expands_path("C:", "D:/C:", "C:", base: "D:") + it_expands_path("C:", "D:/C:", "C:\\", base: "D:/") + it_expands_path("C:", "D:\\/C:", "C:\\", base: Path.posix("D:\\")) + it_expands_path("C:", "D:/C:", "C:\\", base: Path.windows("D:\\")) + it_expands_path("C:/", "D:/C:/", "C:\\", base: "D:") + it_expands_path("C:/", "D:/C:/", "C:\\", base: "D:/") + it_expands_path("C:/", "D:\\/C:/", "C:\\", base: Path.posix("D:\\")) + it_expands_path("C:/", "D:/C:/", "C:\\", base: Path.windows("D:\\")) + it_expands_path("C:\\", "D:/C:\\", "C:\\", base: "D:") + it_expands_path("C:\\", "D:/C:\\", "C:\\", base: "D:/") + it_expands_path("C:\\", "D:\\/C:\\", "C:\\", base: Path.posix("D:\\")) + it_expands_path("C:\\", "D:/C:\\", "C:\\", base: Path.windows("D:\\")) + + it_expands_path("C:foo", "D:/C:foo", "C:foo", base: "D:") + it_expands_path("C:/foo", "D:/C:/foo", "C:\\foo", base: "D:") + it_expands_path("C:\\foo", "D:/C:\\foo", "C:\\foo", base: "D:") + it_expands_path("C:foo", "D:\\/C:foo", "C:\\foo", base: Path.posix("D:\\")) + it_expands_path("C:foo", "D:/C:foo", "C:\\foo", base: Path.windows("D:\\")) + it_expands_path("C:foo", "D:/C:foo", "C:\\foo", base: "D:/") + it_expands_path("C:/foo", "D:\\/C:/foo", "C:\\foo", base: Path.posix("D:\\")) + it_expands_path("C:/foo", "D:/C:/foo", "C:\\foo", base: Path.windows("D:\\")) + it_expands_path("C:\\foo", "D:\\/C:\\foo", "C:\\foo", base: Path.posix("D:\\")) + it_expands_path("C:\\foo", "D:/C:\\foo", "C:\\foo", base: Path.windows("D:\\")) + it_expands_path("C:/foo", "D:/C:/foo", "C:\\foo", base: "D:/") + it_expands_path("C:\\foo", "D:/C:\\foo", "C:\\foo", base: "D:/") + end + + describe "converts a pathname to an absolute pathname, using ~ (home) as base" do + it_expands_path("~/", {HOME_POSIX, ""}, {HOME_WINDOWS, ""}) + it_expands_path("~/..badfilename", {HOME_POSIX, "..badfilename"}, {HOME_WINDOWS, "..badfilename"}) + it_expands_path("..", "/default", "\\default") + it_expands_path("~/a", {HOME_POSIX, "a"}, {HOME_WINDOWS, "a"}, base: "~/b") + it_expands_path("~", HOME_POSIX, HOME_WINDOWS) + it_expands_path("~", HOME_POSIX, HOME_WINDOWS, base: "/tmp/gumby/ddd") + it_expands_path("~/a", {HOME_POSIX, "a"}, {HOME_WINDOWS, "a"}, base: "/tmp/gumby/ddd") + end + + describe "converts a pathname to an absolute pathname, using ~ (home) as base (trailing /)" do + it_expands_path("~/", {HOME_POSIX, ""}, {HOME_WINDOWS, ""}) + it_expands_path("~/..badfilename", {"#{HOME_POSIX}/", "..badfilename"}, {"#{HOME_WINDOWS}\\", "..badfilename"}, base: "") + it_expands_path("~/..", "/home", "C:\\Users") + it_expands_path("~/a", {HOME_POSIX, "a"}, {HOME_WINDOWS, "a"}, base: "~/b") + it_expands_path("~", HOME_POSIX, HOME_WINDOWS) + it_expands_path("~", HOME_POSIX, HOME_WINDOWS, base: "/tmp/gumby/ddd") + it_expands_path("~/a", {HOME_POSIX, "a"}, {HOME_WINDOWS, "a"}, base: "/tmp/gumby/ddd") + end + + describe "converts a pathname to an absolute pathname, using ~ (home) as base (HOME=/)" do + it_expands_path("~/", "/", "\\", env_home: "/") + it_expands_path("~/..badfilename", "/..badfilename", "\\..badfilename", env_home: "/") + it_expands_path("..", "/default", "\\default", env_home: "/") + it_expands_path("~/a", "/a", "\\a", base: "~/b", env_home: "/") + it_expands_path("~", "/", "\\", env_home: "/") + it_expands_path("~", "/", "\\", base: "/tmp/gumby/ddd", env_home: "/") + it_expands_path("~/a", "/a", "\\a", base: "/tmp/gumby/ddd", env_home: "/") + end + end + + describe "#<=>" do + it "case sensitivity" do + Path.posix("foo").should_not eq Path.posix("FOO") + Path.windows("foo").should eq Path.windows("FOO") + end + end + + describe "#ends_with_separator?" do + assert_paths_raw("foo", false, &.ends_with_separator?) + assert_paths_raw("foo/", true, &.ends_with_separator?) + assert_paths_raw("foo\\", false, true, &.ends_with_separator?) + assert_paths_raw("C:/", true, &.ends_with_separator?) + assert_paths_raw("foo/bar", false, &.ends_with_separator?) + assert_paths_raw("foo/.", false, &.ends_with_separator?) + end + + describe "#to_windows" do + assert_paths_raw("foo/bar", Path.windows("foo/bar"), &.to_windows) + assert_paths_raw("C:\\foo\\bar", Path.windows("C:\\foo\\bar"), &.to_windows) + end + + describe "to_posix" do + assert_paths_raw("foo/bar", Path.posix("foo/bar"), &.to_posix) + assert_paths_raw("C:\\foo\\bar", Path.posix("C:\\foo\\bar"), Path.posix("C:/foo/bar"), &.to_posix) + end +end diff --git a/src/docs_main.cr b/src/docs_main.cr index d6940c7b4984..b77af2123e87 100644 --- a/src/docs_main.cr +++ b/src/docs_main.cr @@ -47,6 +47,8 @@ require "./gzip" require "./ini" require "./levenshtein" require "./option_parser" +require "./partial_comparable" +require "./path" require "./random/**" require "./readline" require "./semantic_version" diff --git a/src/file.cr b/src/file.cr index cfc7cc6e42b0..06ef9ab86e17 100644 --- a/src/file.cr +++ b/src/file.cr @@ -100,7 +100,8 @@ class File < IO::FileDescriptor # ab | Same as the 'a' mode but in binary file mode. # ``` # In binary file mode, line endings are not converted to CRLF on Windows. - def self.new(filename : String, mode = "r", perm = DEFAULT_CREATE_PERMISSIONS, encoding = nil, invalid = nil) + def self.new(filename : Path | String, mode = "r", perm = DEFAULT_CREATE_PERMISSIONS, encoding = nil, invalid = nil) + filename = filename.to_s fd = Crystal::System::File.open(filename, mode, perm) new(filename, fd, blocking: true, encoding: encoding, invalid: invalid) end @@ -121,8 +122,8 @@ class File < IO::FileDescriptor # File.symlink("foo", "bar") # File.info?("bar", follow_symlinks: false).try(&.type.symlink?) # => true # ``` - def self.info?(path : String, follow_symlinks = true) : Info? - Crystal::System::File.info?(path, follow_symlinks) + def self.info?(path : Path | String, follow_symlinks = true) : Info? + Crystal::System::File.info?(path.to_s, follow_symlinks) end # Returns a `File::Info` object for the file given by *path* or raises @@ -139,8 +140,8 @@ class File < IO::FileDescriptor # File.symlink("foo", "bar") # File.info("bar", follow_symlinks: false).type.symlink? # => true # ``` - def self.info(path, follow_symlinks = true) : Info - info?(path, follow_symlinks) || raise Errno.new("Unable to get info for '#{path.inspect_unquoted}'") + def self.info(path : Path | String, follow_symlinks = true) : Info + info?(path, follow_symlinks) || raise Errno.new("Unable to get info for '#{path.inspect_unquoted}''") end # Returns `true` if *path* exists else returns `false` @@ -151,8 +152,8 @@ class File < IO::FileDescriptor # File.write("foo", "foo") # File.exists?("foo") # => true # ``` - def self.exists?(path) : Bool - Crystal::System::File.exists?(path) + def self.exists?(path : Path | String) : Bool + Crystal::System::File.exists?(path.to_s) end # Returns `true` if *path1* and *path2* represents the same file. @@ -169,7 +170,7 @@ class File < IO::FileDescriptor # File.write("foo", "foo") # File.size("foo") # => 3 # ``` - def self.size(filename) : UInt64 + def self.size(filename : Path | String) : UInt64 info(filename).size rescue ex : Errno raise Errno.new("Error determining size of '#{filename.inspect_unquoted}'", ex.errno) @@ -184,7 +185,7 @@ class File < IO::FileDescriptor # File.write("foo", "foo") # File.empty?("foo") # => false # ``` - def self.empty?(path) : Bool + def self.empty?(path : Path | String) : Bool size(path) == 0 end @@ -194,8 +195,8 @@ class File < IO::FileDescriptor # File.write("foo", "foo") # File.readable?("foo") # => true # ``` - def self.readable?(path) : Bool - Crystal::System::File.readable?(path) + def self.readable?(path : Path | String) : Bool + Crystal::System::File.readable?(path.to_s) end # Returns `true` if *path* is writable by the real user id of this process else returns `false`. @@ -204,8 +205,8 @@ class File < IO::FileDescriptor # File.write("foo", "foo") # File.writable?("foo") # => true # ``` - def self.writable?(path) : Bool - Crystal::System::File.writable?(path) + def self.writable?(path : Path | String) : Bool + Crystal::System::File.writable?(path.to_s) end # Returns `true` if *path* is executable by the real user id of this process else returns `false`. @@ -214,8 +215,8 @@ class File < IO::FileDescriptor # File.write("foo", "foo") # File.executable?("foo") # => false # ``` - def self.executable?(path) : Bool - Crystal::System::File.executable?(path) + def self.executable?(path : Path | String) : Bool + Crystal::System::File.executable?(path.to_s) end # Returns `true` if given *path* exists and is a file. @@ -227,7 +228,7 @@ class File < IO::FileDescriptor # File.file?("dir1") # => false # File.file?("foobar") # => false # ``` - def self.file?(path) : Bool + def self.file?(path : Path | String) : Bool if info = info?(path) info.type.file? else @@ -244,7 +245,7 @@ class File < IO::FileDescriptor # File.directory?("dir2") # => true # File.directory?("foobar") # => false # ``` - def self.directory?(path) : Bool + def self.directory?(path : Path | String) : Bool Dir.exists?(path) end @@ -254,17 +255,7 @@ class File < IO::FileDescriptor # File.dirname("/foo/bar/file.cr") # => "/foo/bar" # ``` def self.dirname(path) : String - path.check_no_null_byte - index = path.rindex SEPARATOR - if index - if index == 0 - SEPARATOR_STRING - else - path[0, index] - end - else - "." - end + Path.new(path).dirname end # Returns the last component of the given *path*. @@ -273,20 +264,7 @@ class File < IO::FileDescriptor # File.basename("/foo/bar/file.cr") # => "file.cr" # ``` def self.basename(path) : String - return "" if path.bytesize == 0 - return SEPARATOR_STRING if path == SEPARATOR_STRING - - path.check_no_null_byte - - last = path.size - 1 - last -= 1 if path[last] == SEPARATOR - - index = path.rindex SEPARATOR, last - if index - path[index + 1, last - index] - else - path - end + Path.new(path).basename end # Returns the last component of the given *path*. @@ -297,8 +275,7 @@ class File < IO::FileDescriptor # File.basename("/foo/bar/file.cr", ".cr") # => "file" # ``` def self.basename(path, suffix) : String - suffix.check_no_null_byte - basename(path).chomp(suffix) + Path.new(path).basename(suffix.check_no_null_byte) end # Changes the owner of the specified file. @@ -316,8 +293,8 @@ class File < IO::FileDescriptor # File.chown("foo", gid: 100) # changes foo's gid # File.chown("foo", gid: 100, follow_symlinks: true) # changes baz's gid # ``` - def self.chown(path, uid : Int = -1, gid : Int = -1, follow_symlinks = false) - Crystal::System::File.chown(path, uid, gid, follow_symlinks) + def self.chown(path : Path | String, uid : Int = -1, gid : Int = -1, follow_symlinks = false) + Crystal::System::File.chown(path.to_s, uid, gid, follow_symlinks) end # Changes the permissions of the specified file. @@ -332,8 +309,8 @@ class File < IO::FileDescriptor # File.chmod("foo", 0o700) # File.info("foo").permissions.value # => 0o700 # ``` - def self.chmod(path, permissions : Int | Permissions) - Crystal::System::File.chmod(path, permissions) + def self.chmod(path : Path | String, permissions : Int | Permissions) + Crystal::System::File.chmod(path.to_s, permissions) end # Deletes the file at *path*. Deleting non-existent file will raise an exception. @@ -343,8 +320,8 @@ class File < IO::FileDescriptor # File.delete("./foo") # File.delete("./bar") # raises Errno (No such file or directory) # ``` - def self.delete(path) - Crystal::System::File.delete(path) + def self.delete(path : Path | String) + Crystal::System::File.delete(path.to_s) end # Returns *filename*'s extension, or an empty string if it has no extension. @@ -353,42 +330,7 @@ class File < IO::FileDescriptor # File.extname("foo.cr") # => ".cr" # ``` def self.extname(filename) : String - filename.check_no_null_byte - - bytes = filename.to_slice - - return "" if bytes.empty? - - current = bytes.size - 1 - - # if the pattern is foo, it has no extension - return "" if bytes[current] == '.'.ord - - # position the reader at the last . or SEPARATOR - # that is not the first char - while bytes[current] != SEPARATOR.ord && - bytes[current] != '.'.ord && - current > 0 - current -= 1 - end - - # if we are at the beginning of the string, there is no extension. - # /foo or .foo have no extension - return "" unless current > 0 - - # otherwise we are not at the beginning, and there is a previous char. - # if current is '/', then the pattern is prefix/foo and has no extension - return "" if bytes[current] == SEPARATOR.ord - - # otherwise the current_char is '.' - # if previous is '/', then the pattern is prefix/.foo and has no extension - return "" if bytes[current - 1] == SEPARATOR.ord - - # So the current char is '.', - # we are not at the beginning, - # the previous char is not a '/', - # and we have an extension - String.new(bytes[current, bytes.size - current]) + Path.new(filename).extension end # Converts *path* to an absolute path. Relative paths are @@ -401,41 +343,7 @@ class File < IO::FileDescriptor # File.expand_path("baz", "/foo/bar") # => "/foo/bar/baz" # ``` def self.expand_path(path, dir = nil) : String - path.check_no_null_byte - - if path.starts_with?('~') - home = ENV["HOME"] - home = home.chomp('/') unless home == "/" - - if path.size >= 2 && path[1] == SEPARATOR - path = home + path[1..-1] - elsif path.size < 2 - return home - end - end - - unless path.starts_with?(SEPARATOR) - dir = dir ? expand_path(dir) : Dir.current - path = "#{dir}#{SEPARATOR}#{path}" - end - - parts = path.split(SEPARATOR) - items = [] of String - parts.each do |part| - case part - when "", "." - # Nothing - when ".." - items.pop? - else - items << part - end - end - - String.build do |str| - str << SEPARATOR_STRING - items.join SEPARATOR_STRING, str - end + Path.new(path).expand(dir || Dir.current).to_s end class BadPatternError < Exception @@ -460,7 +368,7 @@ class File < IO::FileDescriptor # * `\\` escapes the next character. # # NOTE: Only `/` is recognized as path separator in both *pattern* and *path*. - def self.match?(pattern : String, path : String) + def self.match?(pattern : String, path : Path | String) expanded_patterns = [] of String File.expand_brace_pattern(pattern, expanded_patterns) @@ -670,7 +578,7 @@ class File < IO::FileDescriptor # permissions may be set using the *perm* parameter. # # See `self.new` for what *mode* can be. - def self.open(filename, mode = "r", perm = DEFAULT_CREATE_PERMISSIONS, encoding = nil, invalid = nil) : self + def self.open(filename : Path | String, mode = "r", perm = DEFAULT_CREATE_PERMISSIONS, encoding = nil, invalid = nil) : self new filename, mode, perm, encoding, invalid end @@ -679,7 +587,7 @@ class File < IO::FileDescriptor # file as an argument, the file will be automatically closed when the block returns. # # See `self.new` for what *mode* can be. - def self.open(filename, mode = "r", perm = DEFAULT_CREATE_PERMISSIONS, encoding = nil, invalid = nil) + def self.open(filename : Path | String, mode = "r", perm = DEFAULT_CREATE_PERMISSIONS, encoding = nil, invalid = nil) file = new filename, mode, perm, encoding, invalid begin yield file @@ -782,8 +690,8 @@ class File < IO::FileDescriptor # File.join("foo/", "/bar/", "/baz") # => "foo/bar/baz" # File.join("/foo/", "/bar/", "/baz/") # => "/foo/bar/baz/" # ``` - def self.join(*parts) : String - join parts + def self.join(first : String | Path, *parts : String | Path) : String + Path.new(first, *parts).to_s end # Returns a new string formed by joining the strings using `File::SEPARATOR`. @@ -794,33 +702,7 @@ class File < IO::FileDescriptor # File.join(["/foo/", "/bar/", "/baz/"]) # => "/foo/bar/baz/" # ``` def self.join(parts : Array | Tuple) : String - String.build do |str| - first = true - parts_last_index = parts.size - 1 - parts.each_with_index do |part, index| - part.check_no_null_byte - next if part.empty? && index != parts_last_index - next if !first && index != parts_last_index && part == SEPARATOR_STRING - - str << SEPARATOR unless first - - byte_start = 0 - byte_count = part.bytesize - - if !first && part.starts_with?(SEPARATOR) - byte_start += 1 - byte_count -= 1 - end - - if index != parts_last_index && part.ends_with?(SEPARATOR) - byte_count -= 1 - end - - str.write part.unsafe_byte_slice(byte_start, byte_count) - - first = false - end - end + Path.new(parts).to_s end # Moves *old_filename* to *new_filename*. diff --git a/src/path.cr b/src/path.cr new file mode 100644 index 000000000000..8ac8907a3310 --- /dev/null +++ b/src/path.cr @@ -0,0 +1,942 @@ +# A `Path` represents a filesystem path and allows path-handling operations +# such as querying its components as well as semantic manipulations. +# +# A path is hierarchical and composed of a sequence of directory and file name +# elements separated by a special separator or delimiter. A root component, +# that identifies a file system hierarchy, may also be present. +# The name element that is farthest from the root of the directory hierarchy is +# the name of a file or directory. The other name elements are directory names. +# A `Path` can represent a root, a root and a sequence of names, or simply one or +# more name elements. +# A `Path` is considered to be an empty path if it consists solely of one name +# element that is empty. Accessing a file using an empty path is equivalent +# to accessing the default directory of the process. +# +# # Examples +# +# ``` +# Path["foo/bar/baz.cr"].parent # => Path["foo/bar"] +# Path["foo/bar/baz.cr"].basename # => "baz.cr" +# Path["./foo/../bar"].normalize # => Path["bar"] +# Path["~/bin"].expand # => Path["/home/crystal/bin"] +# ``` +# +# For now, its methods are purely lexical, there is no direct filesystem access. +# +# Path handling comes in different kinds depending on operating system: +# +# * `Path.posix()` creates a new POSIX path +# * `Path.windows()` creates a new Windows path +# * `Path.new()` means `Path.posix` on POSIX platforms and `Path.windows()` +# on Windows platforms. +# +# ``` +# # On POSIX system: +# Path.new("foo", "bar", "baz.cr") == Path.posix("foo/bar/baz.cr") +# # On Windows system: +# Path.new("foo", "bar", "baz.cr") == Path.windows("foo\\bar\\baz.cr") +# ``` +# +# The main differences between Windows and POSIX paths: +# * POSIX paths use forward slash (`/`) as only path separator, Windows paths use +# backslash (`\`) as default separator but also recognize forward slashes. +# * POSIX paths are generally case-sensitive, Windows paths case-insensitive +# (see `#<=>`). +# * A POSIX path is absolute if it begins with a forward slash (`/`). A Windows path +# is absolute if it starts with a drive letter and root (`C:\`). +# +# ``` +# Path.posix("/foo/./bar").normalize # => Path.posix("/foo/bar") +# Path.windows("/foo/./bar").normalize # => Path.windows("\\foo\\bar") + +# Path.posix("/foo").absolute? # => true +# Path.windows("/foo").absolute? # => false +# +# Path.posix("foo") == Path.posix("FOO") # => false +# Path.windows("foo") == Path.windows("FOO") # => true +# ``` +struct Path + include Comparable(Path) + + class Error < Exception + end + + enum Kind : UInt8 + # TODO: Consider adding NATIVE member, see https://github.com/crystal-lang/crystal/pull/5635#issuecomment-441237811 + + POSIX + WINDOWS + + def self.native : Kind + {% if flag?(:win32) %} + WINDOWS + {% else %} + POSIX + {% end %} + end + end + + # The file/directory separator characters of the current platform. + # `{'/'}` on POSIX, `{'\\', '/'}` on Windows. + SEPARATORS = separators(Kind.native) + + # :nodoc: + def self.separators(kind) + if kind.windows? + {'\\', '/'} + else + {'/'} + end + end + + # Creates a new `Path` of native kind. + # + # When compiling for a windows target, this is equal to `Path.windows()`, + # otherwise `Path.posix` is used. + def self.new(name : String = "") : Path + new(name.check_no_null_byte, Kind.native) + end + + # ditto + def self.new(name : String, *parts) : Path + new(name).join(*parts) + end + + # ditto + def self.[](name : String, *parts) : Path + new(name, *parts) + end + + # ditto + def self.new(parts : Enumerable) : Path + new("").join(parts) + end + + # ditto + def self.[](parts : Enumerable) : Path + new(parts) + end + + # Creates a new `Path` of POSIX kind. + def self.posix(name : String = "") : Path + new(name.check_no_null_byte, Kind::POSIX) + end + + # ditto + def self.posix(name : String, *parts) : Path + posix(name).join(parts) + end + + # ditto + def self.posix(parts : Enumerable) : Path + posix("").join(parts) + end + + # Creates a new `Path` of Windows kind. + def self.windows(name : String = "") : Path + new(name.check_no_null_byte, Kind::WINDOWS) + end + + # ditto + def self.windows(name : String, *parts) : Path + windows(name).join(parts) + end + + # ditto + def self.windows(parts : Enumerable) : Path + windows("").join(parts) + end + + # :nodoc: + protected def initialize(@name : String, @kind : Kind) + end + + # Internal helper method to create a new `Path` of the same kind as `self`. + private def new_instance(string : String, kind = @kind) : Path + Path.new(string, kind) + end + + # Returns `true` if this is a Windows path. + def windows? : Bool + @kind.windows? + end + + # Returns `true` if this is a POSIX path. + def posix? : Bool + @kind.posix? + end + + # Returns `true` if this is a native path for the target platform. + def native? : Bool + @kind == Kind.native + end + + # Returns all components of this path except the last one. + # + # ``` + # Path["/foo/bar/file.cr"].dirname # => "/foo/bar" + # ``` + def dirname : String + reader = Char::Reader.new(at_end: @name) + separators = self.separators + + # skip trailing separators + while separators.includes?(reader.current_char) && reader.pos > 0 + reader.previous_char + end + + # skip last component + while !separators.includes?(reader.current_char) && reader.pos > 0 + reader.previous_char + end + + # strip trailing separators + while separators.includes?(reader.current_char) && reader.pos > 0 + reader.previous_char + end + + if reader.pos == 0 + current = reader.current_char + + if separators.includes?(current) + return current.to_s + else + # skip windows here for next condition regarding anchor + if windows? && reader.has_next? && reader.peek_next_char == ':' + reader.next_char + else + return "." + end + end + end + + if windows? && reader.current_char == ':' && reader.pos == 1 && (anchor = self.anchor) + return anchor.to_s + end + + @name.byte_slice(0, reader.pos + 1) + end + + # Returns the parent path of this path. + # + # If the path is empty or `"."`, it returns `"."`. If the path is rooted + # and in the top-most hierarchy, the root path is returned. + # + # ``` + # Path["foo/bar/file.cr"].parent # => Path["foo/bar"] + # Path["foo"].parent # => Path["."] + # Path["/foo"].parent # => Path["/"] + # Path["/"].parent # => Path["/"] + # Path[""].parent # => Path["."] + # Path["foo/bar/."].parent # => Path["foo/bar"] + # ``` + def parent : Path + new_instance dirname + end + + # Returns all parent paths of this path beginning with the topmost path. + # + # ``` + # Path["foo/bar/file.cr"].parents # => [Path["."], Path["foo"], Path["foo/bar"]] + # ``` + def parents : Array(Path) + parents = [] of Path + each_parent do |parent| + parents << parent + end + parents + end + + # Yields each parent of this path beginning with the topmost parent. + # + # ``` + # Path["foo/bar/file.cr"].each_parent { |parent| puts parent } + # # Path["."] + # # Path["foo"] + # # Path["foo/bar"] + # ``` + def each_parent(&block : Path ->) + return if @name.empty? || @name == "." + + first = true + each_part_separator_index do |pos| + if pos == 0 || (pos == 2 && @name[1] == ':') + first = false + break if pos == @name.bytesize - 1 || @name.byte_slice(pos + 1).each_char.all? { |char| separators.includes?(char) || char == '.' } + path = anchor || new_instance(separators[0].to_s) + else + if first && @name[0] != '.' + yield new_instance "." + end + first = false + + break if pos == @name.bytesize - 1 + path = new_instance @name.byte_slice(0, pos) + end + + yield path + end + + if first + # this path didn't contain any separators + yield new_instance "." + end + end + + # Returns the last component of this path. + # + # If *suffix* is given, it is stripped from the end. + # + # ``` + # Path["/foo/bar/file.cr"].basename # => "file.cr" + # Path["/foo/bar/"].basename # => "bar" + # Path["/"].basename # => "/" + # Path[""].basename # => "" + # ``` + def basename(suffix : String? = nil) : String + suffix.try &.check_no_null_byte + + return "" if @name.empty? + return @name if @name.size == 1 && separators.includes?(@name[0]) + + bytes = @name.to_slice + + current = bytes.size - 1 + + separators = self.separators.map &.ord + + # skip trailing separators + while separators.includes?(bytes[current]) && current > 0 + current -= 1 + end + + # read suffix + if suffix && suffix.bytesize < current && suffix == @name.byte_slice(current - suffix.bytesize + 1, suffix.bytesize) + current -= suffix.bytesize + end + + end_pos = {current, 1}.max + + # read basename + while !separators.includes?(bytes[current]) && current > 0 + current -= 1 + end + + start_pos = current + 1 + + if start_pos == 1 && !separators.includes?(bytes[current]) + start_pos = 0 + end + + @name.byte_slice(start_pos, end_pos - start_pos + 1) + end + + # Returns the extension of this path, or an empty string if it has no extension. + # + # ``` + # Path["foo.cr"].extension # => ".cr" + # Path["foo"].extension # => "" + # ``` + def extension : String + bytes = @name.to_slice + + return "" if bytes.empty? + + current = bytes.size - 1 + + # if the pattern is `foo.`, it has no extension + return "" if bytes[current] == '.'.ord + + separators = self.separators.map &.ord + + # position the reader at the last `.` or SEPARATOR + # that is not the first char + while !separators.includes?(bytes[current]) && + bytes[current] != '.'.ord && + current > 0 + current -= 1 + end + + # if we are the beginning of the string there is no extension + # `/foo` and `.foo` have no extension + return "" unless current > 0 + + # otherwise we are not at the beginning, and there is a previous char. + # if current is '/', then the pattern is prefix/foo and has no extension + return "" if separators.includes?(bytes[current]) + + # otherwise the current_char is '.' + # if previous is '/', then the pattern is `prefix/.foo` and has no extension + return "" if separators.includes?(bytes[current - 1]) + + # So the current char is '.', + # we are not at the beginning, + # the previous char is not a '/', + # and we have an extension + String.new(bytes[current, bytes.size - current]) + end + + # Removes redundant elements from this path and returns the shortest equivalent path by purely lexical processing. + # It applies the following rules iteratively until no further processing can be done: + # + # 1. Replace multiple slashes with a single slash. + # 2. Eliminate each `.` path name element (the current directory). + # 3. Eliminate each `..` path name element (the parent directory) preceded + # by a non-`..` element along with the latter. + # 4. Eliminate `..` elements that begin a rooted path: + # that is, replace `"/.."` by `"/"` at the beginning of a path. + # + # If the path turns to be empty, the current directory (`"."`) is returned. + # + # The returned path ends in a slash only if it is the root (`"/"`, `\`, or `C:\`). + # + # See also Rob Pike: *[Lexical File Names in Plan 9 or Getting Dot-Dot Right](https://9p.io/sys/doc/lexnames.html)* + def normalize(*, remove_final_separator : Bool = true) : Path + return new_instance "." if @name.empty? + + drive, root = drive_and_root + reader = Char::Reader.new(@name) + dotdot = 0 + separators = self.separators + add_separator_at_end = !remove_final_separator && ends_with_separator? + + new_name = String.build do |str| + if drive + str << drive.gsub('/', '\\') + reader.pos += drive.bytesize + end + if root + str << separators[0] + reader.next_char + dotdot = str.bytesize + end + anchor_pos = str.bytesize + + while (char = reader.current_char) != Char::ZERO + curr_pos = reader.pos + if separators.includes?(char) + # empty path element + reader.next_char + elsif char == '.' && (reader.pos + 1 == @name.bytesize || separators.includes?(reader.peek_next_char)) + # . element + reader.next_char + elsif char == '.' && reader.next_char == '.' && (reader.pos + 1 == @name.bytesize || separators.includes?(reader.peek_next_char)) + # .. element: remove to last / + reader.next_char + if str.bytesize > dotdot + str.back 1 + while str.bytesize > dotdot && !separators.includes?((str.buffer + str.bytesize).value.unsafe_chr) + str.back 1 + end + elsif !root + if str.bytesize > 0 + str << separators[0] + end + str << ".." + dotdot = str.bytesize + end + else + reader.pos = curr_pos # make sure to reset lookahead used in previous condition + + # real path element + # add slash if needed + if str.bytesize > anchor_pos && !separators.includes?((str.buffer + str.bytesize - 1).value.unsafe_chr) + str << separators[0] + end + + loop do + str << char + char = reader.next_char + break if separators.includes?(char) || char == Char::ZERO + end + end + end + + if str.empty? + str << '.' + end + + last_char = (str.buffer + str.bytesize - 1).value.unsafe_chr + + if add_separator_at_end && !separators.includes?(last_char) + str << separators[0] + end + end + + new_instance new_name + end + + # Yields each component of this path as a `String`. + def each_part + last_pos = 0 + each_part_separator_index do |pos| + yield @name.byte_slice(last_pos, pos - last_pos) + last_pos = pos + end + end + + # Returns the components of this path as an `Array(String)`. + def parts : Array(String) + parts = [] of String + each_part do |part| + parts << part + end + parts + end + + private def each_part_separator_index + reader = Char::Reader.new(@name) + last_was_separator = false + reader.each do |char| + if separators.includes?(char) + yield reader.pos unless last_was_separator + last_was_separator = true + else + last_was_separator = false + end + end + end + + # Converts this path to a Windows path. + # + # ``` + # Path.posix("foo/bar").to_windows # => Path.windows("foo/bar") + # Path.windows("foo/bar").to_windows # => Path.windows("foo/bar") + # ``` + # + # This creates a new instance with the same string representation but with + # `Kind::WINDOWS`. + def to_windows : Path + new_instance(@name, Kind::WINDOWS) + end + + # Converts this path to a POSIX path. + # + # ``` + # Path.windows("foo/bar\\baz").to_posix # => Path.posix("foo/bar/baz") + # Path.posix("foo/bar").to_posix # => Path.posix("foo/bar") + # Path.posix("foo/bar\\baz").to_posix # => Path.posix("foo/bar\\baz") + # ``` + # + # It returns a copy of this instance if it already has POSIX kind. Otherwise + # a new instance is created with `Kind::POSIX` and all occurences of + # backslash file separators (`\\`) replaced by forward slash (`/`). + def to_posix : Path + if posix? + new_instance(@name, Kind::POSIX) + else + new_instance(@name.gsub(Path.separators(Kind::WINDOWS)[0], Path.separators(Kind::POSIX)[0]), Kind::POSIX) + end + end + + # Converts this path to the given *kind*. + # + # See `#to_windows` and `#to_posix` for details. + def to_kind(kind) + if kind.posix? + to_posix + else + to_windows + end + end + + # Converts this path to an absolute path. Relative paths are + # referenced from the current working directory of the process (`Dir.current`) + # unless *base* is given, in which case it will be used as the reference path. + # + # ``` + # Path["foo"].expand # => Path["/current/path/foo"] + # Path["~/crystal/foo"].expand # => Path["/home/crystal/foo"] + # Path["baz"].expand("/foo/bar") # => Path["/foo/bar/baz"] + # ``` + # + # *home* specifies the home directory which `~` will expand to. + # If *expand_base* is `true`, *base* itself will be exanded in `Dir.current` + # if it is not an absolute path. This guarantees the method returns an absolute + # path (assuming that `Dir.current` is absolute). + def expand(base : Path | String = Dir.current, *, home = Path.home, expand_base = true) : Path + base = Path.new(base) unless base.is_a?(Path) + base = base.to_kind(@kind) + if base == self + # expanding base, avoid recursion + return new_instance(@name).normalize(remove_final_separator: false) + end + + name = @name + + if name.starts_with?('~') + home = home.to_kind(@kind).normalize + + if name.size == 1 + name = home.to_s + else + name = home.join(name.byte_slice(2, name.bytesize - 2)).to_s + end + end + + unless new_instance(name).absolute? + unless base.absolute? || !expand_base + base = base.expand + end + + if name.empty? + expanded = base + elsif windows? + base_drive, base_root = base.drive_and_root + drive, root = new_instance(name).drive_and_root + + if drive && base_root + base_relative = base_drive ? base.@name.lchop(base_drive) : base.@name + expanded = "#{drive}#{base_relative}#{separators[0]}#{name.lchop(drive)}" + elsif root + if base_drive + expanded = "#{base_drive}#{name}" + else + expanded = name + end + else + if base_root + expanded = base.join(name) + else + expanded = String.build do |io| + if drive + io << drive + elsif base_drive + io << base_drive + end + base_relative = base.@name + base_relative = base_relative.lchop(base_drive) if base_drive + name_relative = drive ? name.lchop(drive) : name + + io << base_relative + io << separators[0] unless base_relative.empty? + io << name_relative + end + end + end + else + expanded = base.join(name) + end + else + expanded = name + end + + expanded = new_instance(expanded) unless expanded.is_a?(Path) + expanded.normalize(remove_final_separator: false) + end + + # Appends the given *parts* to this path and returns the joined path. + # + # ``` + # Path["foo"].join("bar", "baz") # => Path["foo/bar/baz"] + # Path["foo/"].join("/bar/", "/baz") # => Path["foo/bar/baz"] + # Path["/foo/"].join("/bar/", "/baz/") # => Path["/foo/bar/baz/"] + # ``` + def join(*parts) : Path + join parts + end + + # Appends the given *parts* to this path and returns the joined path. + # + # ``` + # Path["foo"].join("bar", "baz") # => Path["foo/bar/baz"] + # Path["foo/"].join(Path["/bar/", "/baz"]) # => Path["foo/bar/baz"] + # Path["/foo/"].join("/bar/", "/baz/") # => Path["/foo/bar/baz/"] + # ``` + # + # Non-matching paths are implicitly converted to this path's kind. + # + # ``` + # Path.posix("foo/bar").join(Path.windows("baz\baq")) # => Path.posix("foo/bar/baz/baq") + # Path.windows("foo\\bar").join(Path.posix("baz/baq")) # => Path.posix("foo\\bar\\baz/baq") + # ``` + def join(parts : Enumerable) : Path + new_name = String.build do |str| + str << @name + last_ended_with_separator = ends_with_separator? + + parts.each_with_index do |part, index| + case part + when Path + # Every POSIX path is also a valid Windows path, so we only need to + # convert the other way around (see `#to_windows`, `#to_posix`). + part = part.to_posix if posix? && part.windows? + + part = part.@name + else + part = part.to_s + part.check_no_null_byte + end + + if part.empty? + if index == parts.size - 1 + str << separators[0] unless last_ended_with_separator + last_ended_with_separator = true + else + last_ended_with_separator = false + end + + next + end + + byte_start = 0 + byte_count = part.bytesize + + case {starts_with_separator?(part), last_ended_with_separator} + when {true, true} + byte_start += 1 + byte_count -= 1 + when {false, false} + str << separators[0] unless str.bytesize == 0 + end + + last_ended_with_separator = ends_with_separator?(part) + + str.write part.unsafe_byte_slice(byte_start, byte_count) + end + end + + new_instance new_name + end + + # Appends the given *part* to this path and returns the joined path. + # + # ``` + # Path["foo"] / "bar" / "baz" # => Path["foo/bar/baz"] + # Path["foo/"] / Path["/bar/baz"] # => Path["foo/bar/baz"] + # ``` + def /(part : Path | String) : Path + join(part) + end + + # Resolves path *name* in this path's parent directory. + # + # Raises `Path::Error` if `#parent` is `nil`. + def sibling(name : Path | String) : Path? + if parent = self.parent + parent.join(name) + else + raise Error.new("Can't resolve sibling for a path without parent directory") + end + end + + # Compares this path to *other*. + # + # The comparison is performed strictly lexically: `foo` and `./foo` are *not* + # treated as equal. To compare paths semantically, they need to be normalized + # and converted to the same kind. + # + # ``` + # Path["foo"] <=> Path["foo"] # => 0 + # Path["foo"] <=> Path["./foo"] # => 1 + # Path.posix("foo") <=> Path.windows("foo") # => -1 + # ``` + # + # Comparison is case-sensitive for POSIX paths and case-insensitive for + # Windows paths. + # + # ``` + # Path.posix("foo") <=> Path.posix("FOO") # => 1 + # Path.windows("foo") <=> Path.windows("FOO") # => 0 + # ``` + def <=>(other : Path) + ord = @name.compare(other.@name, case_insensitive: windows?) + return ord if ord != 0 + + @kind <=> other.@kind + end + + # Returns a path representing the drive component or `nil` if this path does not contain a drive. + # + # See `#anchor` for the combination of drive and `#root`. + # + # ``` + # Path.windows("C:\Program Files").drive # => Path.windows("C:") + # Path.windows("\\host\share\folder").drive # => Path.windows("\\host\share") + # ``` + # + # NOTE: Drives are only available for Windows paths. It can either be a drive letter (`C:`) or a UNC share (`\\host\share`). + def drive : Path? + if drive = drive_and_root[0] + new_instance drive + end + end + + # Returns the root path component of this path or `nil` if it is not rooted. + # + # See `#anchor` for the combination of `#drive` and root. + # + # ``` + # Path["/etc/"].root # => Path["/"] + # Path.windows("C:Program Files").root # => nil + # Path.windows("C:\Program Files").root # => Path.windows("\") + # Path.windows("\\host\share\folder").root # => Path.windows("\") + # ``` + def root : Path? + if root = drive_and_root[1] + new_instance root + end + end + + # Returns the concatenation of `#drive` and `#root`. + # + # ``` + # Path["/etc/"].anchor # => Path["/"] + # Path.windows("C:Program Files").anchor # => Path.windows("C:") + # Path.windows("C:\Program Files").anchor # => Path.windows("C:\") + # Path.windows("\\host\share\folder").anchor # => Path.windows("\\host\share\") + # ``` + def anchor : Path? + drive, root = drive_and_root + + if root + if drive + new_instance({drive, root}.join) + else + new_instance(root) + end + elsif drive + new_instance drive + end + end + + # Returns a tuple of `#drive` and `#root` as strings. + def drive_and_root : {String?, String?} + if windows? + if @name.byte_at?(1) == ':'.ord && @name.byte_at?(0).try(&.chr.ascii_letter?) + drive = @name.byte_slice(0, 2) + if separators.includes?(@name.byte_at?(2).try(&.chr)) + return drive, @name.byte_slice(2, 1) + else + return drive, nil + end + elsif (@name.starts_with?("\\\\") || @name.starts_with?("//")) && !separators.includes?(@name.byte_at?(2).try &.unsafe_chr) + # UNC share + index = 0 + last_pos = 0 + each_part_separator_index do |pos| + if index == 2 + return @name.byte_slice(0, pos), @name.byte_slice(pos, 1) + end + index += 1 + last_pos = pos + end + + if index == 2 && last_pos < @name.bytesize && !separators.includes?(@name.byte_at(last_pos + 1).unsafe_chr) + # the entire name is a UNC share without a root + return @name, nil + else + # Not a UNC share, but path starts with two separators + return nil, @name.byte_slice(0, 1) + end + elsif starts_with_separator? + return nil, @name.byte_slice(0, 1) + else + return nil, nil + end + elsif absolute? # posix + return nil, "/" + else + return nil, nil + end + end + + # Returns `true` if this path is absolute. + # + # A POSIX path is absolute if it begins with a forward slash (`/`). + # A Windows path is absolute if it begins with a drive letter and root (`C:\`) + # or with a UNC share (`\\server\share\`). + def absolute? : Bool + separators = self.separators + if windows? + first_is_separator = false + starts_with_double_separator = false + found_share_name = false + @name.each_char_with_index do |char, index| + case index + when 0 + if separators.includes?(char) + first_is_separator = true + else + return false unless char.ascii_letter? + end + when 1 + if first_is_separator && separators.includes?(char) + starts_with_double_separator = true + else + return false unless char == ':' + end + else + if separators.includes?(char) + if index == 2 + return !starts_with_double_separator && !found_share_name + elsif found_share_name + return true + else + found_share_name = true + end + end + end + end + + false + else + separators.includes?(@name[0]?) + end + end + + private def separators + Path.separators(@kind) + end + + def ends_with_separator? + ends_with_separator?(@name) + end + + private def ends_with_separator?(name) + separators.any? { |separator| name.ends_with?(separator) } + end + + private def starts_with_separator?(name = @name) + separators.any? { |separator| name.starts_with?(separator) } + end + + # Returns the string representation of this path. + def to_s : String + @name + end + + # Appends the string representation of this path to *io*. + def to_s(io : IO) + io << to_s + end + + # Inspects this path to *io*. + def inspect(io : IO) + if native? + io << "Path[" + @name.inspect(io) + io << ']' + else + io << "Path." + io << (windows? ? "windows" : "posix") + io << '(' + @name.inspect(io) + io << ')' + end + end + + # Returns a new `URI` with `file` scheme from this path. + # + # A URI can only be created with an absolute path. Raises `Path::Error` if + # this path is not absolute. + def to_uri : URI + raise Error.new("Cannot create a URI from relative path") unless absolute? + URI.new(scheme: "file", path: @name) + end + + # Returns the path of the home directory of the current user. + def self.home : Path + new ENV["HOME"] + end +end diff --git a/src/prelude.cr b/src/prelude.cr index 0b24a1ad238e..e69aa53e3536 100644 --- a/src/prelude.cr +++ b/src/prelude.cr @@ -59,6 +59,7 @@ require "named_tuple" require "nil" require "number" require "humanize" +require "path" require "pointer" require "pretty_print" require "primitives"