diff --git a/api/filesys/util.go b/api/filesys/util.go index e7d844081..0ccb29132 100644 --- a/api/filesys/util.go +++ b/api/filesys/util.go @@ -3,7 +3,11 @@ package filesys -import "path/filepath" +import ( + "os" + "path/filepath" + "strings" +) // RootedPath returns a rooted path, e.g. "/foo/bar" as // opposed to "foo/bar". @@ -28,3 +32,94 @@ func StripLeadingSeps(s string) string { } return s[k:] } + +// PathSplit converts a file path to a slice of string. +// If the path is absolute (if the path has a leading slash), +// then the first entry in the result is an empty string. +// Desired: path == PathJoin(PathSplit(path)) +func PathSplit(incoming string) []string { + if incoming == "" { + return []string{} + } + dir, path := filepath.Split(incoming) + if dir == string(os.PathSeparator) { + if path == "" { + return []string{""} + } + return []string{"", path} + } + dir = strings.TrimSuffix(dir, string(os.PathSeparator)) + if dir == "" { + return []string{path} + } + return append(PathSplit(dir), path) +} + +// PathJoin converts a slice of string to a file path. +// If the first entry is an empty string, then the returned +// path is absolute (it has a leading slash). +// Desired: path == PathJoin(PathSplit(path)) +func PathJoin(incoming []string) string { + if len(incoming) == 0 { + return "" + } + if incoming[0] == "" { + return string(os.PathSeparator) + filepath.Join(incoming[1:]...) + } + return filepath.Join(incoming...) +} + +// InsertPathPart inserts 'part' at position 'pos' in the given filepath. +// The first position is 0. +// +// E.g. if part == 'PEACH' +// +// OLD : NEW : POS +// -------------------------------------------------------- +// {empty} : PEACH : irrelevant +// / : /PEACH : irrelevant +// pie : PEACH/pie : 0 (or negative) +// /pie : /PEACH/pie : 0 (or negative) +// raw : raw/PEACH : 1 (or larger) +// /raw : /raw/PEACH : 1 (or larger) +// a/nice/warm/pie : a/nice/warm/PEACH/pie : 3 +// /a/nice/warm/pie : /a/nice/warm/PEACH/pie : 3 +// +// * An empty part results in no change. +// +// * Absolute paths get their leading '/' stripped, treated like +// relative paths, and the leading '/' is re-added on output. +// The meaning of pos is intentionally the same in either absolute or +// relative paths; if it weren't, this function could convert absolute +// paths to relative paths, which is not desirable. +// +// * For robustness (liberal input, conservative output) Pos values that +// that are too small (large) to index the split filepath result in a +// prefix (postfix) rather than an error. Use extreme position values +// to assure a prefix or postfix (e.g. 0 will always prefix, and +// 9999 will presumably always postfix). +func InsertPathPart(path string, pos int, part string) string { + if part == "" { + return path + } + parts := PathSplit(path) + if pos < 0 { + pos = 0 + } else if pos > len(parts) { + pos = len(parts) + } + if len(parts) > 0 && parts[0] == "" && pos < len(parts) { + // An empty string at 0 indicates an absolute path, and means + // we must increment pos. This change means that a position + // specification has the same meaning in relative and absolute paths. + // E.g. in either the path 'a/b/c' or the path '/a/b/c', + // 'a' is at 0, 'b' is at 1 and 'c' is at 2, and inserting at + // zero means a new first field _without_ changing an absolute + // path to a relative path. + pos++ + } + result := make([]string, len(parts)+1) + copy(result, parts[0:pos]) + result[pos] = part + return PathJoin(append(result, parts[pos:]...)) +} diff --git a/api/filesys/util_test.go b/api/filesys/util_test.go index 488189e92..44d741a14 100644 --- a/api/filesys/util_test.go +++ b/api/filesys/util_test.go @@ -1,6 +1,7 @@ package filesys_test import ( + "os" "path/filepath" "testing" @@ -93,6 +94,11 @@ func TestFilePathSplit(t *testing.T) { dir: "", file: "rabbit.jpg", }, + { + full: "/", + dir: "/", + file: "", + }, { full: "/beans", dir: "/", @@ -124,6 +130,169 @@ func TestFilePathSplit(t *testing.T) { } } +func TestPathSplitAndJoin(t *testing.T) { + cases := map[string]struct { + original string + expected []string + }{ + "Empty": { + original: "", + expected: []string{}, + }, + "One": { + original: "hello", + expected: []string{"hello"}, + }, + "Two": { + original: "hello/there", + expected: []string{"hello", "there"}, + }, + "Three": { + original: "hello/my/friend", + expected: []string{"hello", "my", "friend"}, + }, + } + for n, c := range cases { + f := func(t *testing.T, original string, expected []string) { + actual := PathSplit(original) + if len(actual) != len(expected) { + t.Fatalf( + "expected len %d, got len %d", + len(expected), len(actual)) + } + for i := range expected { + if expected[i] != actual[i] { + t.Fatalf( + "at i=%d, expected '%s', got '%s'", + i, expected[i], actual[i]) + } + } + joined := PathJoin(actual) + if joined != original { + t.Fatalf( + "when rejoining, expected '%s', got '%s'", + original, joined) + } + } + t.Run("relative"+n, func(t *testing.T) { + f(t, c.original, c.expected) + }) + t.Run("absolute"+n, func(t *testing.T) { + f(t, + string(os.PathSeparator)+c.original, + append([]string{""}, c.expected...)) + }) + } +} + +func TestInsertPathPart(t *testing.T) { + cases := map[string]struct { + original string + pos int + part string + expected string + }{ + "rootOne": { + original: "/", + pos: 0, + part: "___", + expected: "/___", + }, + "rootTwo": { + original: "/", + pos: 444, + part: "___", + expected: "/___", + }, + "rootedFirst": { + original: "/apple", + pos: 0, + part: "___", + expected: "/___/apple", + }, + "rootedSecond": { + original: "/apple", + pos: 444, + part: "___", + expected: "/apple/___", + }, + "rootedThird": { + original: "/apple/banana", + pos: 444, + part: "___", + expected: "/apple/banana/___", + }, + "emptyLow": { + original: "", + pos: -3, + part: "___", + expected: "___", + }, + "emptyHigh": { + original: "", + pos: 444, + part: "___", + expected: "___", + }, + "peachPie": { + original: "a/nice/warm/pie", + pos: 3, + part: "PEACH", + expected: "a/nice/warm/PEACH/pie", + }, + "rootedPeachPie": { + original: "/a/nice/warm/pie", + pos: 3, + part: "PEACH", + expected: "/a/nice/warm/PEACH/pie", + }, + "longStart": { + original: "a/b/c/d/e/f", + pos: 0, + part: "___", + expected: "___/a/b/c/d/e/f", + }, + "rootedLongStart": { + original: "/a/b/c/d/e/f", + pos: 0, + part: "___", + expected: "/___/a/b/c/d/e/f", + }, + "longMiddle": { + original: "a/b/c/d/e/f", + pos: 3, + part: "___", + expected: "a/b/c/___/d/e/f", + }, + "rootedLongMiddle": { + original: "/a/b/c/d/e/f", + pos: 3, + part: "___", + expected: "/a/b/c/___/d/e/f", + }, + "longEnd": { + original: "a/b/c/d/e/f", + pos: 444, + part: "___", + expected: "a/b/c/d/e/f/___", + }, + "rootedLongEnd": { + original: "/a/b/c/d/e/f", + pos: 444, + part: "___", + expected: "/a/b/c/d/e/f/___", + }, + } + for n, c := range cases { + t.Run(n, func(t *testing.T) { + actual := InsertPathPart(c.original, c.pos, c.part) + if actual != c.expected { + t.Fatalf("expected '%s', got '%s'", c.expected, actual) + } + }) + } +} + func TestStripTrailingSeps(t *testing.T) { cases := []struct { full string