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