2025-10-15 10:12:44 +03:00

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]}
}