1130 lines
34 KiB
Go
1130 lines
34 KiB
Go
package tspath
|
|
|
|
import (
|
|
"cmp"
|
|
"slices"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/stringutil"
|
|
)
|
|
|
|
type Path string
|
|
|
|
// Internally, we represent paths as strings with '/' as the directory separator.
|
|
// When we make system calls (eg: LanguageServiceHost.getDirectory()),
|
|
// we expect the host to correctly handle paths in our specified format.
|
|
const (
|
|
DirectorySeparator = '/'
|
|
urlSchemeSeparator = "://"
|
|
)
|
|
|
|
//// Path Tests
|
|
|
|
// Determines whether a byte corresponds to `/` or `\`.
|
|
func isAnyDirectorySeparator(char byte) bool {
|
|
return char == '/' || char == '\\'
|
|
}
|
|
|
|
// Determines whether a path starts with a URL scheme (e.g. starts with `http://`, `ftp://`, `file://`, etc.).
|
|
func IsUrl(path string) bool {
|
|
return GetEncodedRootLength(path) < 0
|
|
}
|
|
|
|
// Determines whether a path is an absolute disk path (e.g. starts with `/`, or a dos path
|
|
// like `c:`, `c:\` or `c:/`).
|
|
func IsRootedDiskPath(path string) bool {
|
|
return GetEncodedRootLength(path) > 0
|
|
}
|
|
|
|
// Determines whether a path consists only of a path root.
|
|
func IsDiskPathRoot(path string) bool {
|
|
rootLength := GetEncodedRootLength(path)
|
|
return rootLength > 0 && rootLength == len(path)
|
|
}
|
|
|
|
// Determines whether a path starts with an absolute path component (i.e. `/`, `c:/`, `file://`, etc.).
|
|
//
|
|
// ```
|
|
// // POSIX
|
|
// PathIsAbsolute("/path/to/file.ext") === true
|
|
// // DOS
|
|
// PathIsAbsolute("c:/path/to/file.ext") === true
|
|
// // URL
|
|
// PathIsAbsolute("file:///path/to/file.ext") === true
|
|
// // Non-absolute
|
|
// PathIsAbsolute("path/to/file.ext") === false
|
|
// PathIsAbsolute("./path/to/file.ext") === false
|
|
// ```
|
|
func PathIsAbsolute(path string) bool {
|
|
return GetEncodedRootLength(path) != 0
|
|
}
|
|
|
|
func HasTrailingDirectorySeparator(path string) bool {
|
|
return len(path) > 0 && isAnyDirectorySeparator(path[len(path)-1])
|
|
}
|
|
|
|
// Combines paths. If a path is absolute, it replaces any previous path. Relative paths are not simplified.
|
|
//
|
|
// ```
|
|
// // Non-rooted
|
|
// CombinePaths("path", "to", "file.ext") === "path/to/file.ext"
|
|
// CombinePaths("path", "dir", "..", "to", "file.ext") === "path/dir/../to/file.ext"
|
|
// // POSIX
|
|
// CombinePaths("/path", "to", "file.ext") === "/path/to/file.ext"
|
|
// CombinePaths("/path", "/to", "file.ext") === "/to/file.ext"
|
|
// // DOS
|
|
// CombinePaths("c:/path", "to", "file.ext") === "c:/path/to/file.ext"
|
|
// CombinePaths("c:/path", "c:/to", "file.ext") === "c:/to/file.ext"
|
|
// // URL
|
|
// CombinePaths("file:///path", "to", "file.ext") === "file:///path/to/file.ext"
|
|
// CombinePaths("file:///path", "file:///to", "file.ext") === "file:///to/file.ext"
|
|
// ```
|
|
func CombinePaths(firstPath string, paths ...string) string {
|
|
// TODO (drosen): There is potential for a fast path here.
|
|
// In the case where we find the last absolute path and just path.Join from there.
|
|
firstPath = NormalizeSlashes(firstPath)
|
|
|
|
var b strings.Builder
|
|
size := len(firstPath) + len(paths)
|
|
for _, p := range paths {
|
|
size += len(p)
|
|
}
|
|
b.Grow(size)
|
|
|
|
b.WriteString(firstPath)
|
|
|
|
// To provide a way to "set" the path, keep track of the start and then slice.
|
|
// This will waste some memory each time we do it, but saving memory is more common.
|
|
start := 0
|
|
result := func() string {
|
|
return b.String()[start:]
|
|
}
|
|
setResult := func(value string) {
|
|
start = b.Len()
|
|
b.WriteString(value)
|
|
}
|
|
|
|
for _, trailingPath := range paths {
|
|
if trailingPath == "" {
|
|
continue
|
|
}
|
|
trailingPath = NormalizeSlashes(trailingPath)
|
|
if result() == "" || GetRootLength(trailingPath) != 0 {
|
|
// `trailingPath` is absolute.
|
|
setResult(trailingPath)
|
|
} else {
|
|
if !HasTrailingDirectorySeparator(result()) {
|
|
b.WriteByte(DirectorySeparator)
|
|
}
|
|
b.WriteString(trailingPath)
|
|
}
|
|
}
|
|
return result()
|
|
}
|
|
|
|
func GetPathComponents(path string, currentDirectory string) []string {
|
|
path = CombinePaths(currentDirectory, path)
|
|
return pathComponents(path, GetRootLength(path))
|
|
}
|
|
|
|
func pathComponents(path string, rootLength int) []string {
|
|
root := path[:rootLength]
|
|
rest := strings.Split(path[rootLength:], "/")
|
|
if len(rest) > 0 && rest[len(rest)-1] == "" {
|
|
rest = rest[:len(rest)-1]
|
|
}
|
|
return append([]string{root}, rest...)
|
|
}
|
|
|
|
func IsVolumeCharacter(char byte) bool {
|
|
return char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z'
|
|
}
|
|
|
|
func getFileUrlVolumeSeparatorEnd(url string, start int) int {
|
|
if len(url) <= start {
|
|
return -1
|
|
}
|
|
ch0 := url[start]
|
|
if ch0 == ':' {
|
|
return start + 1
|
|
}
|
|
if ch0 == '%' && len(url) > start+2 && url[start+1] == '3' {
|
|
ch2 := url[start+2]
|
|
if ch2 == 'a' || ch2 == 'A' {
|
|
return start + 3
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func GetEncodedRootLength(path string) int {
|
|
ln := len(path)
|
|
if ln == 0 {
|
|
return 0
|
|
}
|
|
ch0 := path[0]
|
|
|
|
// POSIX or UNC
|
|
if ch0 == '/' || ch0 == '\\' {
|
|
if ln == 1 || path[1] != ch0 {
|
|
return 1 // POSIX: "/" (or non-normalized "\")
|
|
}
|
|
|
|
offset := 2
|
|
p1 := strings.IndexByte(path[offset:], ch0)
|
|
if p1 < 0 {
|
|
return ln // UNC: "//server" or "\\server"
|
|
}
|
|
|
|
return p1 + offset + 1 // UNC: "//server/" or "\\server\"
|
|
}
|
|
|
|
// DOS
|
|
if IsVolumeCharacter(ch0) && ln > 1 && path[1] == ':' {
|
|
if ln == 2 {
|
|
return 2 // DOS: "c:" (but not "c:d")
|
|
}
|
|
ch2 := path[2]
|
|
if ch2 == '/' || ch2 == '\\' {
|
|
return 3 // DOS: "c:/" or "c:\"
|
|
}
|
|
}
|
|
|
|
// Untitled paths (e.g., "^/untitled/ts-nul-authority/Untitled-1")
|
|
if ch0 == '^' && ln > 1 && path[1] == '/' {
|
|
return 2 // Untitled: "^/"
|
|
}
|
|
|
|
// URL
|
|
schemeEnd := strings.Index(path, urlSchemeSeparator)
|
|
if schemeEnd != -1 {
|
|
authorityStart := schemeEnd + len(urlSchemeSeparator)
|
|
authorityLength := strings.Index(path[authorityStart:], "/")
|
|
if authorityLength != -1 { // URL: "file:///", "file://server/", "file://server/path"
|
|
authorityEnd := authorityStart + authorityLength
|
|
|
|
// For local "file" URLs, include the leading DOS volume (if present).
|
|
// Per https://www.ietf.org/rfc/rfc1738.txt, a host of "" or "localhost" is a
|
|
// special case interpreted as "the machine from which the URL is being interpreted".
|
|
scheme := path[:schemeEnd]
|
|
authority := path[authorityStart:authorityEnd]
|
|
if scheme == "file" && (authority == "" || authority == "localhost") && (len(path) > authorityEnd+2) && IsVolumeCharacter(path[authorityEnd+1]) {
|
|
volumeSeparatorEnd := getFileUrlVolumeSeparatorEnd(path, authorityEnd+2)
|
|
if volumeSeparatorEnd != -1 {
|
|
if volumeSeparatorEnd == len(path) {
|
|
// URL: "file:///c:", "file://localhost/c:", "file:///c$3a", "file://localhost/c%3a"
|
|
// but not "file:///c:d" or "file:///c%3ad"
|
|
return ^volumeSeparatorEnd
|
|
}
|
|
if path[volumeSeparatorEnd] == '/' {
|
|
// URL: "file:///c:/", "file://localhost/c:/", "file:///c%3a/", "file://localhost/c%3a/"
|
|
return ^(volumeSeparatorEnd + 1)
|
|
}
|
|
}
|
|
}
|
|
return ^(authorityEnd + 1) // URL: "file://server/", "http://server/"
|
|
}
|
|
return ^ln // URL: "file://server", "http://server"
|
|
}
|
|
|
|
// relative
|
|
return 0
|
|
}
|
|
|
|
func GetRootLength(path string) int {
|
|
rootLength := GetEncodedRootLength(path)
|
|
if rootLength < 0 {
|
|
return ^rootLength
|
|
}
|
|
return rootLength
|
|
}
|
|
|
|
func GetDirectoryPath(path string) string {
|
|
path = NormalizeSlashes(path)
|
|
|
|
// If the path provided is itself a root, then return it.
|
|
rootLength := GetRootLength(path)
|
|
if rootLength == len(path) {
|
|
return path
|
|
}
|
|
|
|
// return the leading portion of the path up to the last (non-terminal) directory separator
|
|
// but not including any trailing directory separator.
|
|
path = RemoveTrailingDirectorySeparator(path)
|
|
return path[:max(rootLength, strings.LastIndex(path, "/"))]
|
|
}
|
|
|
|
func (p Path) GetDirectoryPath() Path {
|
|
return Path(GetDirectoryPath(string(p)))
|
|
}
|
|
|
|
func GetPathFromPathComponents(pathComponents []string) string {
|
|
if len(pathComponents) == 0 {
|
|
return ""
|
|
}
|
|
|
|
root := pathComponents[0]
|
|
if root != "" {
|
|
root = EnsureTrailingDirectorySeparator(root)
|
|
}
|
|
|
|
return root + strings.Join(pathComponents[1:], "/")
|
|
}
|
|
|
|
func NormalizeSlashes(path string) string {
|
|
return strings.ReplaceAll(path, "\\", "/")
|
|
}
|
|
|
|
func reducePathComponents(components []string) []string {
|
|
if len(components) == 0 {
|
|
return []string{}
|
|
}
|
|
reduced := []string{components[0]}
|
|
for i := 1; i < len(components); i++ {
|
|
component := components[i]
|
|
if component == "" {
|
|
continue
|
|
}
|
|
if component == "." {
|
|
continue
|
|
}
|
|
if component == ".." {
|
|
if len(reduced) > 1 {
|
|
if reduced[len(reduced)-1] != ".." {
|
|
reduced = reduced[:len(reduced)-1]
|
|
continue
|
|
}
|
|
} else if reduced[0] != "" {
|
|
continue
|
|
}
|
|
}
|
|
reduced = append(reduced, component)
|
|
}
|
|
return reduced
|
|
}
|
|
|
|
// Combines and resolves paths. If a path is absolute, it replaces any previous path. Any
|
|
// `.` and `..` path components are resolved. Trailing directory separators are preserved.
|
|
//
|
|
// ```go
|
|
// resolvePath("/path", "to", "file.ext") == "path/to/file.ext"
|
|
// resolvePath("/path", "to", "file.ext/") == "path/to/file.ext/"
|
|
// resolvePath("/path", "dir", "..", "to", "file.ext") == "path/to/file.ext"
|
|
// ```
|
|
func ResolvePath(path string, paths ...string) string {
|
|
var combinedPath string
|
|
if len(paths) > 0 {
|
|
combinedPath = CombinePaths(path, paths...)
|
|
} else {
|
|
combinedPath = NormalizeSlashes(path)
|
|
}
|
|
return NormalizePath(combinedPath)
|
|
}
|
|
|
|
func ResolveTripleslashReference(moduleName string, containingFile string) string {
|
|
basePath := GetDirectoryPath(containingFile)
|
|
if IsRootedDiskPath(moduleName) {
|
|
return NormalizePath(moduleName)
|
|
}
|
|
return NormalizePath(CombinePaths(basePath, moduleName))
|
|
}
|
|
|
|
func GetNormalizedPathComponents(path string, currentDirectory string) []string {
|
|
return reducePathComponents(GetPathComponents(path, currentDirectory))
|
|
}
|
|
|
|
func GetNormalizedAbsolutePath(fileName string, currentDirectory string) string {
|
|
rootLength := GetRootLength(fileName)
|
|
if rootLength == 0 && currentDirectory != "" {
|
|
fileName = CombinePaths(currentDirectory, fileName)
|
|
} else {
|
|
// CombinePaths normalizes slashes, so not necessary in other branch
|
|
fileName = NormalizeSlashes(fileName)
|
|
}
|
|
rootLength = GetRootLength(fileName)
|
|
|
|
if simpleNormalized, ok := simpleNormalizePath(fileName); ok {
|
|
length := len(simpleNormalized)
|
|
if length > rootLength {
|
|
return RemoveTrailingDirectorySeparator(simpleNormalized)
|
|
}
|
|
if length == rootLength && rootLength != 0 {
|
|
return EnsureTrailingDirectorySeparator(simpleNormalized)
|
|
}
|
|
return simpleNormalized
|
|
}
|
|
|
|
length := len(fileName)
|
|
root := fileName[:rootLength]
|
|
// `normalized` is only initialized once `fileName` is determined to be non-normalized.
|
|
// `changed` is set at the same time.
|
|
var changed bool
|
|
var normalized string
|
|
var segmentStart int
|
|
index := rootLength
|
|
normalizedUpTo := index
|
|
seenNonDotDotSegment := rootLength != 0
|
|
for index < length {
|
|
// At beginning of segment
|
|
segmentStart = index
|
|
ch := fileName[index]
|
|
for ch == '/' {
|
|
index++
|
|
if index < length {
|
|
ch = fileName[index]
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
if index > segmentStart {
|
|
// Seen superfluous separator
|
|
if !changed {
|
|
normalized = fileName[:max(rootLength, segmentStart-1)]
|
|
changed = true
|
|
}
|
|
if index == length {
|
|
break
|
|
}
|
|
segmentStart = index
|
|
}
|
|
// Past any superfluous separators
|
|
segmentEnd := strings.IndexByte(fileName[index+1:], '/')
|
|
if segmentEnd == -1 {
|
|
segmentEnd = length
|
|
} else {
|
|
segmentEnd += index + 1
|
|
}
|
|
segmentLength := segmentEnd - segmentStart
|
|
if segmentLength == 1 && fileName[index] == '.' {
|
|
// "." segment (skip)
|
|
if !changed {
|
|
normalized = fileName[:normalizedUpTo]
|
|
changed = true
|
|
}
|
|
} else if segmentLength == 2 && fileName[index] == '.' && fileName[index+1] == '.' {
|
|
// ".." segment
|
|
if !seenNonDotDotSegment {
|
|
if changed {
|
|
if len(normalized) == rootLength {
|
|
normalized += ".."
|
|
} else {
|
|
normalized += "/.."
|
|
}
|
|
} else {
|
|
normalizedUpTo = index + 2
|
|
}
|
|
} else if !changed {
|
|
if normalizedUpTo-1 >= 0 {
|
|
normalized = fileName[:max(rootLength, strings.LastIndexByte(fileName[:normalizedUpTo-1], '/'))]
|
|
} else {
|
|
normalized = fileName[:normalizedUpTo]
|
|
}
|
|
changed = true
|
|
seenNonDotDotSegment = (len(normalized) != rootLength || rootLength != 0) && normalized != ".." && !strings.HasSuffix(normalized, "/..")
|
|
} else {
|
|
lastSlash := strings.LastIndexByte(normalized, '/')
|
|
if lastSlash != -1 {
|
|
normalized = normalized[:max(rootLength, lastSlash)]
|
|
} else {
|
|
normalized = root
|
|
}
|
|
seenNonDotDotSegment = (len(normalized) != rootLength || rootLength != 0) && normalized != ".." && !strings.HasSuffix(normalized, "/..")
|
|
}
|
|
} else if changed {
|
|
if len(normalized) != rootLength {
|
|
normalized += "/"
|
|
}
|
|
seenNonDotDotSegment = true
|
|
normalized += fileName[segmentStart:segmentEnd]
|
|
} else {
|
|
seenNonDotDotSegment = true
|
|
normalizedUpTo = segmentEnd
|
|
}
|
|
index = segmentEnd + 1
|
|
}
|
|
if changed {
|
|
return normalized
|
|
}
|
|
if length > rootLength {
|
|
return RemoveTrailingDirectorySeparators(fileName)
|
|
}
|
|
if length == rootLength {
|
|
return EnsureTrailingDirectorySeparator(fileName)
|
|
}
|
|
return fileName
|
|
}
|
|
|
|
func simpleNormalizePath(path string) (string, bool) {
|
|
// Most paths don't require normalization
|
|
if !hasRelativePathSegment(path) {
|
|
return path, true
|
|
}
|
|
// Some paths only require cleanup of `/./` or leading `./`
|
|
simplified := strings.ReplaceAll(path, "/./", "/")
|
|
trimmed := strings.TrimPrefix(simplified, "./")
|
|
if trimmed != path && !hasRelativePathSegment(trimmed) && !(trimmed != simplified && strings.HasPrefix(trimmed, "/")) {
|
|
// If we trimmed a leading "./" and the path now starts with "/", we changed the meaning
|
|
path = trimmed
|
|
return path, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// hasRelativePathSegment reports whether p contains ".", "..", "./", "../", "/.", "/..", "//", "/./", or "/../".
|
|
func hasRelativePathSegment(p string) bool {
|
|
n := len(p)
|
|
if n == 0 {
|
|
return false
|
|
}
|
|
|
|
if p == "." || p == ".." {
|
|
return true
|
|
}
|
|
|
|
// Leading "./" OR "../"
|
|
if p[0] == '.' {
|
|
if n >= 2 && p[1] == '/' {
|
|
return true
|
|
}
|
|
// Leading "../"
|
|
if n >= 3 && p[1] == '.' && p[2] == '/' {
|
|
return true
|
|
}
|
|
}
|
|
// Trailing "/." OR "/.."
|
|
if p[n-1] == '.' {
|
|
if n >= 2 && p[n-2] == '/' {
|
|
return true
|
|
}
|
|
if n >= 3 && p[n-2] == '.' && p[n-3] == '/' {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Now look for any `//` or `/./` or `/../`
|
|
|
|
prevSlash := false
|
|
segLen := 0 // length of current segment since last slash
|
|
dotCount := 0 // consecutive dots at start of the current segment; -1 => not only dots
|
|
|
|
for i := range n {
|
|
c := p[i]
|
|
if c == '/' {
|
|
// "//"
|
|
if prevSlash {
|
|
return true
|
|
}
|
|
// "/./" or "/../"
|
|
if (segLen == 1 && dotCount == 1) || (segLen == 2 && dotCount == 2) {
|
|
return true
|
|
}
|
|
prevSlash = true
|
|
segLen = 0
|
|
dotCount = 0
|
|
continue
|
|
}
|
|
|
|
if c == '.' {
|
|
if dotCount >= 0 {
|
|
dotCount++
|
|
}
|
|
} else {
|
|
dotCount = -1
|
|
}
|
|
segLen++
|
|
prevSlash = false
|
|
}
|
|
|
|
// Trailing "/." or "/.."
|
|
return (segLen == 1 && dotCount == 1) || (segLen == 2 && dotCount == 2)
|
|
}
|
|
|
|
func NormalizePath(path string) string {
|
|
path = NormalizeSlashes(path)
|
|
if normalized, ok := simpleNormalizePath(path); ok {
|
|
return normalized
|
|
}
|
|
normalized := GetNormalizedAbsolutePath(path, "")
|
|
if normalized != "" && HasTrailingDirectorySeparator(path) {
|
|
normalized = EnsureTrailingDirectorySeparator(normalized)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func GetCanonicalFileName(fileName string, useCaseSensitiveFileNames bool) string {
|
|
if useCaseSensitiveFileNames {
|
|
return fileName
|
|
}
|
|
return ToFileNameLowerCase(fileName)
|
|
}
|
|
|
|
// We convert the file names to lower case as key for file name on case insensitive file system
|
|
// While doing so we need to handle special characters (eg \u0130) to ensure that we dont convert
|
|
// it to lower case, fileName with its lowercase form can exist along side it.
|
|
// Handle special characters and make those case sensitive instead
|
|
//
|
|
// |-#--|-Unicode--|-Char code-|-Desc-------------------------------------------------------------------|
|
|
// | 1. | i | 105 | Ascii i |
|
|
// | 2. | I | 73 | Ascii I |
|
|
// |-------- Special characters ------------------------------------------------------------------------|
|
|
// | 3. | \u0130 | 304 | Upper case I with dot above |
|
|
// | 4. | i,\u0307 | 105,775 | i, followed by 775: Lower case of (3rd item) |
|
|
// | 5. | I,\u0307 | 73,775 | I, followed by 775: Upper case of (4th item), lower case is (4th item) |
|
|
// | 6. | \u0131 | 305 | Lower case i without dot, upper case is I (2nd item) |
|
|
// | 7. | \u00DF | 223 | Lower case sharp s |
|
|
//
|
|
// Because item 3 is special where in its lowercase character has its own
|
|
// upper case form we cant convert its case.
|
|
// Rest special characters are either already in lower case format or
|
|
// they have corresponding upper case character so they dont need special handling
|
|
|
|
func ToFileNameLowerCase(fileName string) string {
|
|
const IWithDot = '\u0130'
|
|
|
|
ascii := true
|
|
needsLower := false
|
|
fileNameLen := len(fileName)
|
|
for i := range fileNameLen {
|
|
c := fileName[i]
|
|
if c >= 0x80 {
|
|
ascii = false
|
|
break
|
|
}
|
|
if 'A' <= c && c <= 'Z' {
|
|
needsLower = true
|
|
}
|
|
}
|
|
if ascii {
|
|
if !needsLower {
|
|
return fileName
|
|
}
|
|
b := make([]byte, fileNameLen)
|
|
for i := range fileNameLen {
|
|
c := fileName[i]
|
|
if 'A' <= c && c <= 'Z' {
|
|
c += 'a' - 'A' // +32
|
|
}
|
|
b[i] = c
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
return strings.Map(func(r rune) rune {
|
|
if r == IWithDot {
|
|
return r
|
|
}
|
|
return unicode.ToLower(r)
|
|
}, fileName)
|
|
}
|
|
|
|
func ToPath(fileName string, basePath string, useCaseSensitiveFileNames bool) Path {
|
|
var nonCanonicalizedPath string
|
|
if IsRootedDiskPath(fileName) {
|
|
nonCanonicalizedPath = NormalizePath(fileName)
|
|
} else {
|
|
nonCanonicalizedPath = GetNormalizedAbsolutePath(fileName, basePath)
|
|
}
|
|
return Path(GetCanonicalFileName(nonCanonicalizedPath, useCaseSensitiveFileNames))
|
|
}
|
|
|
|
func RemoveTrailingDirectorySeparator(path string) string {
|
|
if HasTrailingDirectorySeparator(path) {
|
|
return path[:len(path)-1]
|
|
}
|
|
return path
|
|
}
|
|
|
|
func (p Path) RemoveTrailingDirectorySeparator() Path {
|
|
return Path(RemoveTrailingDirectorySeparator(string(p)))
|
|
}
|
|
|
|
func RemoveTrailingDirectorySeparators(path string) string {
|
|
for HasTrailingDirectorySeparator(path) {
|
|
path = RemoveTrailingDirectorySeparator(path)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func EnsureTrailingDirectorySeparator(path string) string {
|
|
if !HasTrailingDirectorySeparator(path) {
|
|
return path + "/"
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
func (p Path) EnsureTrailingDirectorySeparator() Path {
|
|
return Path(EnsureTrailingDirectorySeparator(string(p)))
|
|
}
|
|
|
|
//// Relative Paths
|
|
|
|
func GetPathComponentsRelativeTo(from string, to string, options ComparePathsOptions) []string {
|
|
fromComponents := reducePathComponents(GetPathComponents(from, options.CurrentDirectory))
|
|
toComponents := reducePathComponents(GetPathComponents(to, options.CurrentDirectory))
|
|
|
|
start := 0
|
|
maxCommonComponents := min(len(fromComponents), len(toComponents))
|
|
stringEqualer := options.getEqualityComparer()
|
|
for ; start < maxCommonComponents; start++ {
|
|
fromComponent := fromComponents[start]
|
|
toComponent := toComponents[start]
|
|
if start == 0 {
|
|
if !stringutil.EquateStringCaseInsensitive(fromComponent, toComponent) {
|
|
break
|
|
}
|
|
} else {
|
|
if !stringEqualer(fromComponent, toComponent) {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if start == 0 {
|
|
return toComponents
|
|
}
|
|
|
|
numDotDotSlashes := len(fromComponents) - start
|
|
result := make([]string, 1+numDotDotSlashes+len(toComponents)-start)
|
|
|
|
result[0] = ""
|
|
i := 1
|
|
// Add all the relative components until we hit a common directory.
|
|
for range numDotDotSlashes {
|
|
result[i] = ".."
|
|
i++
|
|
}
|
|
// Now add all the remaining components of the "to" path.
|
|
for _, component := range toComponents[start:] {
|
|
result[i] = component
|
|
i++
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func GetRelativePathFromDirectory(fromDirectory string, to string, options ComparePathsOptions) string {
|
|
if (GetRootLength(fromDirectory) > 0) != (GetRootLength(to) > 0) {
|
|
panic("paths must either both be absolute or both be relative")
|
|
}
|
|
pathComponents := GetPathComponentsRelativeTo(fromDirectory, to, options)
|
|
return GetPathFromPathComponents(pathComponents)
|
|
}
|
|
|
|
func GetRelativePathFromFile(from string, to string, options ComparePathsOptions) string {
|
|
return EnsurePathIsNonModuleName(GetRelativePathFromDirectory(GetDirectoryPath(from), to, options))
|
|
}
|
|
|
|
func ConvertToRelativePath(absoluteOrRelativePath string, options ComparePathsOptions) string {
|
|
if !IsRootedDiskPath(absoluteOrRelativePath) {
|
|
return absoluteOrRelativePath
|
|
}
|
|
|
|
return GetRelativePathToDirectoryOrUrl(options.CurrentDirectory, absoluteOrRelativePath, false /*isAbsolutePathAnUrl*/, options)
|
|
}
|
|
|
|
func GetRelativePathToDirectoryOrUrl(directoryPathOrUrl string, relativeOrAbsolutePath string, isAbsolutePathAnUrl bool, options ComparePathsOptions) string {
|
|
pathComponents := GetPathComponentsRelativeTo(
|
|
directoryPathOrUrl,
|
|
relativeOrAbsolutePath,
|
|
options,
|
|
)
|
|
|
|
firstComponent := pathComponents[0]
|
|
if isAbsolutePathAnUrl && IsRootedDiskPath(firstComponent) {
|
|
var prefix string
|
|
if firstComponent[0] == DirectorySeparator {
|
|
prefix = "file://"
|
|
} else {
|
|
prefix = "file:///"
|
|
}
|
|
pathComponents[0] = prefix + firstComponent
|
|
}
|
|
|
|
return GetPathFromPathComponents(pathComponents)
|
|
}
|
|
|
|
// Gets the portion of a path following the last (non-terminal) separator (`/`).
|
|
// Semantics align with NodeJS's `path.basename` except that we support URL's as well.
|
|
// If the base name has any one of the provided extensions, it is removed.
|
|
//
|
|
// // POSIX
|
|
// GetBaseFileName("/path/to/file.ext") == "file.ext"
|
|
// GetBaseFileName("/path/to/") == "to"
|
|
// GetBaseFileName("/") == ""
|
|
// // DOS
|
|
// GetBaseFileName("c:/path/to/file.ext") == "file.ext"
|
|
// GetBaseFileName("c:/path/to/") == "to"
|
|
// GetBaseFileName("c:/") == ""
|
|
// GetBaseFileName("c:") == ""
|
|
// // URL
|
|
// GetBaseFileName("http://typescriptlang.org/path/to/file.ext") == "file.ext"
|
|
// GetBaseFileName("http://typescriptlang.org/path/to/") == "to"
|
|
// GetBaseFileName("http://typescriptlang.org/") == ""
|
|
// GetBaseFileName("http://typescriptlang.org") == ""
|
|
// GetBaseFileName("file://server/path/to/file.ext") == "file.ext"
|
|
// GetBaseFileName("file://server/path/to/") == "to"
|
|
// GetBaseFileName("file://server/") == ""
|
|
// GetBaseFileName("file://server") == ""
|
|
// GetBaseFileName("file:///path/to/file.ext") == "file.ext"
|
|
// GetBaseFileName("file:///path/to/") == "to"
|
|
// GetBaseFileName("file:///") == ""
|
|
// GetBaseFileName("file://") == ""
|
|
func GetBaseFileName(path string) string {
|
|
path = NormalizeSlashes(path)
|
|
|
|
// if the path provided is itself the root, then it has no file name.
|
|
rootLength := GetRootLength(path)
|
|
if rootLength == len(path) {
|
|
return ""
|
|
}
|
|
|
|
// return the trailing portion of the path starting after the last (non-terminal) directory
|
|
// separator but not including any trailing directory separator.
|
|
path = RemoveTrailingDirectorySeparator(path)
|
|
return path[max(GetRootLength(path), strings.LastIndex(path, string(DirectorySeparator))+1):]
|
|
}
|
|
|
|
// Gets the file extension for a path.
|
|
// If extensions are provided, gets the file extension for a path, provided it is one of the provided extensions.
|
|
//
|
|
// GetAnyExtensionFromPath("/path/to/file.ext", nil, false) == ".ext"
|
|
// GetAnyExtensionFromPath("/path/to/file.ext/", nil, false) == ".ext"
|
|
// GetAnyExtensionFromPath("/path/to/file", nil, false) == ""
|
|
// GetAnyExtensionFromPath("/path/to.ext/file", nil, false) == ""
|
|
// GetAnyExtensionFromPath("/path/to/file.ext", ".ext", true) === ".ext"
|
|
// GetAnyExtensionFromPath("/path/to/file.js", ".ext", true) === ""
|
|
// GetAnyExtensionFromPath("/path/to/file.js", [".ext", ".js"], true) === ".js"
|
|
// GetAnyExtensionFromPath("/path/to/file.ext", ".EXT", false) === ""
|
|
func GetAnyExtensionFromPath(path string, extensions []string, ignoreCase bool) string {
|
|
// Retrieves any string from the final "." onwards from a base file name.
|
|
// Unlike extensionFromPath, which throws an exception on unrecognized extensions.
|
|
if len(extensions) > 0 {
|
|
return getAnyExtensionFromPathWorker(RemoveTrailingDirectorySeparator(path), extensions, stringutil.GetStringEqualityComparer(ignoreCase))
|
|
}
|
|
|
|
baseFileName := GetBaseFileName(path)
|
|
extensionIndex := strings.LastIndex(baseFileName, ".")
|
|
if extensionIndex >= 0 {
|
|
return baseFileName[extensionIndex:]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func getAnyExtensionFromPathWorker(path string, extensions []string, stringEqualityComparer func(a, b string) bool) string {
|
|
for _, extension := range extensions {
|
|
result := tryGetExtensionFromPath(path, extension, stringEqualityComparer)
|
|
if result != "" {
|
|
return result
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func tryGetExtensionFromPath(path string, extension string, stringEqualityComparer func(a, b string) bool) string {
|
|
if !strings.HasPrefix(extension, ".") {
|
|
extension = "." + extension
|
|
}
|
|
if len(path) >= len(extension) && path[len(path)-len(extension)] == '.' {
|
|
pathExtension := path[len(path)-len(extension):]
|
|
if stringEqualityComparer(pathExtension, extension) {
|
|
return pathExtension
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func PathIsRelative(path string) bool {
|
|
// True if path is ".", "..", or starts with "./", "../", ".\\", or "..\\".
|
|
|
|
if path == "." || path == ".." {
|
|
return true
|
|
}
|
|
|
|
if len(path) >= 2 && path[0] == '.' && (path[1] == '/' || path[1] == '\\') {
|
|
return true
|
|
}
|
|
|
|
if len(path) >= 3 && path[0] == '.' && path[1] == '.' && (path[2] == '/' || path[2] == '\\') {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// EnsurePathIsNonModuleName ensures a path is either absolute (prefixed with `/` or `c:`) or dot-relative (prefixed
|
|
// with `./` or `../`) so as not to be confused with an unprefixed module name.
|
|
func EnsurePathIsNonModuleName(path string) string {
|
|
if !PathIsAbsolute(path) && !PathIsRelative(path) {
|
|
return "./" + path
|
|
}
|
|
return path
|
|
}
|
|
|
|
func IsExternalModuleNameRelative(moduleName string) bool {
|
|
// TypeScript 1.0 spec (April 2014): 11.2.1
|
|
// An external module name is "relative" if the first term is "." or "..".
|
|
// Update: We also consider a path like `C:\foo.ts` "relative" because we do not search for it in `node_modules` or treat it as an ambient module.
|
|
return PathIsRelative(moduleName) || IsRootedDiskPath(moduleName)
|
|
}
|
|
|
|
type ComparePathsOptions struct {
|
|
UseCaseSensitiveFileNames bool
|
|
CurrentDirectory string
|
|
}
|
|
|
|
func (o ComparePathsOptions) GetComparer() func(a, b string) int {
|
|
return stringutil.GetStringComparer(!o.UseCaseSensitiveFileNames)
|
|
}
|
|
|
|
func (o ComparePathsOptions) getEqualityComparer() func(a, b string) bool {
|
|
return stringutil.GetStringEqualityComparer(!o.UseCaseSensitiveFileNames)
|
|
}
|
|
|
|
func ComparePaths(a string, b string, options ComparePathsOptions) int {
|
|
a = CombinePaths(options.CurrentDirectory, a)
|
|
b = CombinePaths(options.CurrentDirectory, b)
|
|
|
|
if a == b {
|
|
return 0
|
|
}
|
|
if a == "" {
|
|
return -1
|
|
}
|
|
if b == "" {
|
|
return 1
|
|
}
|
|
|
|
// NOTE: Performance optimization - shortcut if the root segments differ as there would be no
|
|
// need to perform path reduction.
|
|
aRoot := a[:GetRootLength(a)]
|
|
bRoot := b[:GetRootLength(b)]
|
|
result := stringutil.CompareStringsCaseInsensitive(aRoot, bRoot)
|
|
if result != 0 {
|
|
return result
|
|
}
|
|
|
|
// NOTE: Performance optimization - shortcut if there are no relative path segments in
|
|
// the non-root portion of the path
|
|
aRest := a[len(aRoot):]
|
|
bRest := b[len(bRoot):]
|
|
if !hasRelativePathSegment(aRest) && !hasRelativePathSegment(bRest) {
|
|
return options.GetComparer()(aRest, bRest)
|
|
}
|
|
|
|
// The path contains a relative path segment. Normalize the paths and perform a slower component
|
|
// by component comparison.
|
|
aComponents := reducePathComponents(GetPathComponents(a, ""))
|
|
bComponents := reducePathComponents(GetPathComponents(b, ""))
|
|
sharedLength := min(len(aComponents), len(bComponents))
|
|
for i := 1; i < sharedLength; i++ {
|
|
result := options.GetComparer()(aComponents[i], bComponents[i])
|
|
if result != 0 {
|
|
return result
|
|
}
|
|
}
|
|
return cmp.Compare(len(aComponents), len(bComponents))
|
|
}
|
|
|
|
func ComparePathsCaseSensitive(a string, b string, currentDirectory string) int {
|
|
return ComparePaths(a, b, ComparePathsOptions{UseCaseSensitiveFileNames: true, CurrentDirectory: currentDirectory})
|
|
}
|
|
|
|
func ComparePathsCaseInsensitive(a string, b string, currentDirectory string) int {
|
|
return ComparePaths(a, b, ComparePathsOptions{UseCaseSensitiveFileNames: false, CurrentDirectory: currentDirectory})
|
|
}
|
|
|
|
func ContainsPath(parent string, child string, options ComparePathsOptions) bool {
|
|
parent = CombinePaths(options.CurrentDirectory, parent)
|
|
child = CombinePaths(options.CurrentDirectory, child)
|
|
if parent == "" || child == "" {
|
|
return false
|
|
}
|
|
if parent == child {
|
|
return true
|
|
}
|
|
parentComponents := reducePathComponents(GetPathComponents(parent, ""))
|
|
childComponents := reducePathComponents(GetPathComponents(child, ""))
|
|
if len(childComponents) < len(parentComponents) {
|
|
return false
|
|
}
|
|
|
|
componentComparer := options.getEqualityComparer()
|
|
for i, parentComponent := range parentComponents {
|
|
var comparer func(a, b string) bool
|
|
if i == 0 {
|
|
comparer = stringutil.EquateStringCaseInsensitive
|
|
} else {
|
|
comparer = componentComparer
|
|
}
|
|
if !comparer(parentComponent, childComponents[i]) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (p Path) ContainsPath(child Path) bool {
|
|
return ContainsPath(string(p), string(child), ComparePathsOptions{UseCaseSensitiveFileNames: true})
|
|
}
|
|
|
|
func FileExtensionIs(path string, extension string) bool {
|
|
return len(path) > len(extension) && strings.HasSuffix(path, extension)
|
|
}
|
|
|
|
// Calls `callback` on `directory` and every ancestor directory it has, returning the first defined result.
|
|
// Stops at global cache location
|
|
func ForEachAncestorDirectoryStoppingAtGlobalCache[T any](
|
|
globalCacheLocation string,
|
|
directory string,
|
|
callback func(directory string) (result T, stop bool),
|
|
) T {
|
|
result, _ := ForEachAncestorDirectory(directory, func(ancestorDirectory string) (T, bool) {
|
|
result, stop := callback(ancestorDirectory)
|
|
if stop || ancestorDirectory == globalCacheLocation {
|
|
return result, true
|
|
}
|
|
return result, false
|
|
})
|
|
return result
|
|
}
|
|
|
|
func ForEachAncestorDirectory[T any](directory string, callback func(directory string) (result T, stop bool)) (result T, ok bool) {
|
|
for {
|
|
result, stop := callback(directory)
|
|
if stop {
|
|
return result, true
|
|
}
|
|
|
|
parentPath := GetDirectoryPath(directory)
|
|
if parentPath == directory {
|
|
var zero T
|
|
return zero, false
|
|
}
|
|
|
|
directory = parentPath
|
|
}
|
|
}
|
|
|
|
func ForEachAncestorDirectoryPath[T any](directory Path, callback func(directory Path) (result T, stop bool)) (result T, ok bool) {
|
|
return ForEachAncestorDirectory(string(directory), func(directory string) (T, bool) {
|
|
return callback(Path(directory))
|
|
})
|
|
}
|
|
|
|
func HasExtension(fileName string) bool {
|
|
return strings.Contains(GetBaseFileName(fileName), ".")
|
|
}
|
|
|
|
func SplitVolumePath(path string) (volume string, rest string, ok bool) {
|
|
if len(path) >= 2 && IsVolumeCharacter(path[0]) && path[1] == ':' {
|
|
return strings.ToLower(path[0:2]), path[2:], true
|
|
}
|
|
return "", path, false
|
|
}
|
|
|
|
// GetCommonParents returns the smallest set of directories that are parents of all paths with
|
|
// at least `minComponents` directory components. Any path that has fewer than `minComponents` directory components
|
|
// will be returned in the second return value. Examples:
|
|
//
|
|
// /a/b/c/d, /a/b/c/e, /a/b/f/g => /a/b
|
|
// /a/b/c/d, /a/b/c/e, /a/b/f/g, /x/y => /
|
|
// /a/b/c/d, /a/b/c/e, /a/b/f/g, /x/y (minComponents: 2) => /a/b, /x/y
|
|
// c:/a/b/c/d, d:/a/b/c/d => c:/a/b/c/d, d:/a/b/c/d
|
|
func GetCommonParents(
|
|
paths []string,
|
|
minComponents int,
|
|
getPathComponents func(path string, currentDirectory string) []string,
|
|
options ComparePathsOptions,
|
|
) (parents []string, ignored map[string]struct{}) {
|
|
if minComponents < 1 {
|
|
panic("minComponents must be at least 1")
|
|
}
|
|
if len(paths) == 0 {
|
|
return nil, nil
|
|
}
|
|
if len(paths) == 1 {
|
|
if len(reducePathComponents(getPathComponents(paths[0], options.CurrentDirectory))) < minComponents {
|
|
return nil, map[string]struct{}{paths[0]: {}}
|
|
}
|
|
return paths, nil
|
|
}
|
|
|
|
ignored = make(map[string]struct{})
|
|
pathComponents := make([][]string, 0, len(paths))
|
|
for _, path := range paths {
|
|
components := reducePathComponents(getPathComponents(path, options.CurrentDirectory))
|
|
if len(components) < minComponents {
|
|
ignored[path] = struct{}{}
|
|
} else {
|
|
pathComponents = append(pathComponents, components)
|
|
}
|
|
}
|
|
|
|
results := getCommonParentsWorker(pathComponents, minComponents, options)
|
|
resultPaths := make([]string, len(results))
|
|
for i, comps := range results {
|
|
resultPaths[i] = GetPathFromPathComponents(comps)
|
|
}
|
|
|
|
return resultPaths, ignored
|
|
}
|
|
|
|
func getCommonParentsWorker(componentGroups [][]string, minComponents int, options ComparePathsOptions) [][]string {
|
|
if len(componentGroups) == 0 {
|
|
return nil
|
|
}
|
|
// Determine the maximum depth we can consider
|
|
maxDepth := len(componentGroups[0])
|
|
for _, comps := range componentGroups[1:] {
|
|
if l := len(comps); l < maxDepth {
|
|
maxDepth = l
|
|
}
|
|
}
|
|
|
|
equality := options.getEqualityComparer()
|
|
for lastCommonIndex := range maxDepth {
|
|
candidate := componentGroups[0][lastCommonIndex]
|
|
for j, comps := range componentGroups[1:] {
|
|
if !equality(candidate, comps[lastCommonIndex]) { // divergence
|
|
if lastCommonIndex < minComponents {
|
|
// Not enough components, we need to fan out
|
|
orderedGroups := make([]Path, 0, len(componentGroups)-j)
|
|
newGroups := make(map[Path]struct {
|
|
head []string
|
|
tails [][]string
|
|
})
|
|
for _, g := range componentGroups {
|
|
key := ToPath(g[lastCommonIndex], options.CurrentDirectory, options.UseCaseSensitiveFileNames)
|
|
if _, ok := newGroups[key]; !ok {
|
|
orderedGroups = append(orderedGroups, key)
|
|
}
|
|
newGroups[key] = struct {
|
|
head []string
|
|
tails [][]string
|
|
}{
|
|
head: g[:lastCommonIndex+1],
|
|
tails: append(newGroups[key].tails, g[lastCommonIndex+1:]),
|
|
}
|
|
}
|
|
slices.Sort(orderedGroups)
|
|
result := make([][]string, 0, len(newGroups))
|
|
for _, key := range orderedGroups {
|
|
group := newGroups[key]
|
|
subResults := getCommonParentsWorker(group.tails, minComponents-(lastCommonIndex+1), options)
|
|
for _, sr := range subResults {
|
|
result = append(result, append(group.head, sr...))
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
return [][]string{componentGroups[0][:lastCommonIndex]}
|
|
}
|
|
}
|
|
}
|
|
|
|
return [][]string{componentGroups[0][:maxDepth]}
|
|
}
|