kittenipc/kitcom/internal/tsgo/project/ata/discovertypings.go
2025-10-15 10:12:44 +03:00

334 lines
12 KiB
Go

package ata
import (
"fmt"
"maps"
"slices"
"unicode/utf8"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/collections"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/core"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/packagejson"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/project/logging"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/semver"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/tspath"
"efprojects.com/kitten-ipc/kitcom/internal/tsgo/vfs"
"github.com/go-json-experiment/json"
)
func isTypingUpToDate(cachedTyping *CachedTyping, availableTypingVersions map[string]string) bool {
useVersion, ok := availableTypingVersions["ts"+core.VersionMajorMinor()]
if !ok {
useVersion = availableTypingVersions["latest"]
}
availableVersion := semver.MustParse(useVersion)
return availableVersion.Compare(cachedTyping.Version) <= 0
}
func DiscoverTypings(
fs vfs.FS,
logger logging.Logger,
typingsInfo *TypingsInfo,
fileNames []string,
projectRootPath string,
packageNameToTypingLocation *collections.SyncMap[string, *CachedTyping],
typesRegistry map[string]map[string]string,
) (cachedTypingPaths []string, newTypingNames []string, filesToWatch []string) {
// A typing name to typing file path mapping
inferredTypings := map[string]string{}
// Only infer typings for .js and .jsx files
fileNames = core.Filter(fileNames, func(fileName string) bool {
return tspath.HasJSFileExtension(fileName)
})
if typingsInfo.TypeAcquisition.Include != nil {
addInferredTypings(fs, logger, inferredTypings, typingsInfo.TypeAcquisition.Include, "Explicitly included types")
}
exclude := typingsInfo.TypeAcquisition.Exclude
// Directories to search for package.json, bower.json and other typing information
if typingsInfo.CompilerOptions.Types == nil {
possibleSearchDirs := map[string]bool{}
for _, fileName := range fileNames {
possibleSearchDirs[tspath.GetDirectoryPath(fileName)] = true
}
possibleSearchDirs[projectRootPath] = true
for searchDir := range possibleSearchDirs {
filesToWatch = addTypingNamesAndGetFilesToWatch(fs, logger, inferredTypings, filesToWatch, searchDir, "bower.json", "bower_components")
filesToWatch = addTypingNamesAndGetFilesToWatch(fs, logger, inferredTypings, filesToWatch, searchDir, "package.json", "node_modules")
}
}
if !typingsInfo.TypeAcquisition.DisableFilenameBasedTypeAcquisition.IsTrue() {
getTypingNamesFromSourceFileNames(fs, logger, inferredTypings, fileNames)
}
// add typings for unresolved imports
var modules []string
if typingsInfo.UnresolvedImports != nil {
modules = make([]string, 0, typingsInfo.UnresolvedImports.Len())
for module := range typingsInfo.UnresolvedImports.Keys() {
modules = append(modules, core.NonRelativeModuleNameForTypingCache(module))
}
slices.Sort(modules)
modules = slices.Compact(modules)
}
addInferredTypings(fs, logger, inferredTypings, modules, "Inferred typings from unresolved imports")
// Remove typings that the user has added to the exclude list
for _, excludeTypingName := range exclude {
delete(inferredTypings, excludeTypingName)
logger.Log(fmt.Sprintf("ATA:: Typing for %s is in exclude list, will be ignored.", excludeTypingName))
}
// Add the cached typing locations for inferred typings that are already installed
packageNameToTypingLocation.Range(func(name string, typing *CachedTyping) bool {
registryEntry := typesRegistry[name]
if inferredTypings[name] == "" && registryEntry != nil && isTypingUpToDate(typing, registryEntry) {
inferredTypings[name] = typing.TypingsLocation
}
return true
})
for typing, inferred := range inferredTypings {
if inferred != "" {
cachedTypingPaths = append(cachedTypingPaths, inferred)
} else {
newTypingNames = append(newTypingNames, typing)
}
}
logger.Log(fmt.Sprintf("ATA:: Finished typings discovery: cachedTypingsPaths: %v newTypingNames: %v, filesToWatch %v", cachedTypingPaths, newTypingNames, filesToWatch))
return cachedTypingPaths, newTypingNames, filesToWatch
}
func addInferredTyping(inferredTypings map[string]string, typingName string) {
if _, ok := inferredTypings[typingName]; !ok {
inferredTypings[typingName] = ""
}
}
func addInferredTypings(
fs vfs.FS,
logger logging.Logger,
inferredTypings map[string]string,
typingNames []string, message string,
) {
logger.Log(fmt.Sprintf("ATA:: %s: %v", message, typingNames))
for _, typingName := range typingNames {
addInferredTyping(inferredTypings, typingName)
}
}
/**
* Infer typing names from given file names. For example, the file name "jquery-min.2.3.4.js"
* should be inferred to the 'jquery' typing name; and "angular-route.1.2.3.js" should be inferred
* to the 'angular-route' typing name.
* @param fileNames are the names for source files in the project
*/
func getTypingNamesFromSourceFileNames(
fs vfs.FS,
logger logging.Logger,
inferredTypings map[string]string,
fileNames []string,
) {
hasJsxFile := false
var fromFileNames []string
for _, fileName := range fileNames {
hasJsxFile = hasJsxFile || tspath.FileExtensionIs(fileName, tspath.ExtensionJsx)
inferredTypingName := tspath.RemoveFileExtension(tspath.ToFileNameLowerCase(tspath.GetBaseFileName(fileName)))
cleanedTypingName := removeMinAndVersionNumbers(inferredTypingName)
if typeName, ok := safeFileNameToTypeName[cleanedTypingName]; ok {
fromFileNames = append(fromFileNames, typeName)
}
}
if len(fromFileNames) > 0 {
addInferredTypings(fs, logger, inferredTypings, fromFileNames, "Inferred typings from file names")
}
if hasJsxFile {
logger.Log("ATA:: Inferred 'react' typings due to presence of '.jsx' extension")
addInferredTyping(inferredTypings, "react")
}
}
/**
* Adds inferred typings from manifest/module pairs (think package.json + node_modules)
*
* @param projectRootPath is the path to the directory where to look for package.json, bower.json and other typing information
* @param manifestName is the name of the manifest (package.json or bower.json)
* @param modulesDirName is the directory name for modules (node_modules or bower_components). Should be lowercase!
* @param filesToWatch are the files to watch for changes. We will push things into this array.
*/
func addTypingNamesAndGetFilesToWatch(
fs vfs.FS,
logger logging.Logger,
inferredTypings map[string]string,
filesToWatch []string,
projectRootPath string,
manifestName string,
modulesDirName string,
) []string {
// First, we check the manifests themselves. They're not
// _required_, but they allow us to do some filtering when dealing
// with big flat dep directories.
manifestPath := tspath.CombinePaths(projectRootPath, manifestName)
var manifestTypingNames []string
manifestContents, ok := fs.ReadFile(manifestPath)
if ok {
var manifest packagejson.DependencyFields
filesToWatch = append(filesToWatch, manifestPath)
// var manifest map[string]any
err := json.Unmarshal([]byte(manifestContents), &manifest)
if err == nil {
manifestTypingNames = slices.AppendSeq(manifestTypingNames, maps.Keys(manifest.Dependencies.Value))
manifestTypingNames = slices.AppendSeq(manifestTypingNames, maps.Keys(manifest.DevDependencies.Value))
manifestTypingNames = slices.AppendSeq(manifestTypingNames, maps.Keys(manifest.OptionalDependencies.Value))
manifestTypingNames = slices.AppendSeq(manifestTypingNames, maps.Keys(manifest.PeerDependencies.Value))
addInferredTypings(fs, logger, inferredTypings, manifestTypingNames, "Typing names in '"+manifestPath+"' dependencies")
}
}
// Now we scan the directories for typing information in
// already-installed dependencies (if present). Note that this
// step happens regardless of whether a manifest was present,
// which is certainly a valid configuration, if an unusual one.
packagesFolderPath := tspath.CombinePaths(projectRootPath, modulesDirName)
filesToWatch = append(filesToWatch, packagesFolderPath)
if !fs.DirectoryExists(packagesFolderPath) {
return filesToWatch
}
// There's two cases we have to take into account here:
// 1. If manifest is undefined, then we're not using a manifest.
// That means that we should scan _all_ dependencies at the top
// level of the modulesDir.
// 2. If manifest is defined, then we can do some special
// filtering to reduce the amount of scanning we need to do.
//
// Previous versions of this algorithm checked for a `_requiredBy`
// field in the package.json, but that field is only present in
// `npm@>=3 <7`.
// Package names that do **not** provide their own typings, so
// we'll look them up.
var packageNames []string
var dependencyManifestNames []string
if len(manifestTypingNames) > 0 {
// This is #1 described above.
for _, typingName := range manifestTypingNames {
dependencyManifestNames = append(dependencyManifestNames, tspath.CombinePaths(packagesFolderPath, typingName, manifestName))
}
} else {
// And #2. Depth = 3 because scoped packages look like `node_modules/@foo/bar/package.json`
depth := 3
for _, manifestPath := range vfs.ReadDirectory(fs, projectRootPath, packagesFolderPath, []string{tspath.ExtensionJson}, nil, nil, &depth) {
if tspath.GetBaseFileName(manifestPath) != manifestName {
continue
}
// It's ok to treat
// `node_modules/@foo/bar/package.json` as a manifest,
// but not `node_modules/jquery/nested/package.json`.
// We only assume depth 3 is ok for formally scoped
// packages. So that needs this dance here.
pathComponents := tspath.GetPathComponents(manifestPath, "")
lenPathComponents := len(pathComponents)
ch, _ := utf8.DecodeRuneInString(pathComponents[lenPathComponents-3])
isScoped := ch == '@'
if isScoped && tspath.ToFileNameLowerCase(pathComponents[lenPathComponents-4]) == modulesDirName || // `node_modules/@foo/bar`
!isScoped && tspath.ToFileNameLowerCase(pathComponents[lenPathComponents-3]) == modulesDirName { // `node_modules/foo`
dependencyManifestNames = append(dependencyManifestNames, manifestPath)
}
}
}
logger.Log(fmt.Sprintf("ATA:: Searching for typing names in %s; all files: %v", packagesFolderPath, dependencyManifestNames))
// Once we have the names of things to look up, we iterate over
// and either collect their included typings, or add them to the
// list of typings we need to look up separately.
for _, manifestPath := range dependencyManifestNames {
manifestContents, ok := fs.ReadFile(manifestPath)
if !ok {
continue
}
manifest, err := packagejson.Parse([]byte(manifestContents))
// If the package has its own d.ts typings, those will take precedence. Otherwise the package name will be used
// to download d.ts files from DefinitelyTyped
if err != nil || len(manifest.Name.Value) == 0 {
continue
}
ownTypes := manifest.Types.Value
if len(ownTypes) == 0 {
ownTypes = manifest.Typings.Value
}
if len(ownTypes) != 0 {
absolutePath := tspath.GetNormalizedAbsolutePath(ownTypes, tspath.GetDirectoryPath(manifestPath))
if fs.FileExists(absolutePath) {
logger.Log(fmt.Sprintf("ATA:: Package '%s' provides its own types.", manifest.Name.Value))
inferredTypings[manifest.Name.Value] = absolutePath
} else {
logger.Log(fmt.Sprintf("ATA:: Package '%s' provides its own types but they are missing.", manifest.Name.Value))
}
} else {
packageNames = append(packageNames, manifest.Name.Value)
}
}
addInferredTypings(fs, logger, inferredTypings, packageNames, " Found package names")
return filesToWatch
}
/**
* Takes a string like "jquery-min.4.2.3" and returns "jquery"
*
* @internal
*/
func removeMinAndVersionNumbers(fileName string) string {
// We used to use the regex /[.-]((min)|(\d+(\.\d+)*))$/ and would just .replace it twice.
// Unfortunately, that regex has O(n^2) performance because v8 doesn't match from the end of the string.
// Instead, we now essentially scan the filename (backwards) ourselves.
end := len(fileName)
for pos := end; pos > 0; {
ch, size := utf8.DecodeLastRuneInString(fileName[:pos])
if ch >= '0' && ch <= '9' {
// Match a \d+ segment
for {
pos -= size
ch, size = utf8.DecodeLastRuneInString(fileName[:pos])
if pos <= 0 || ch < '0' || ch > '9' {
break
}
}
} else if pos > 4 && (ch == 'n' || ch == 'N') {
// Looking for "min" or "min"
// Already matched the 'n'
pos -= size
ch, size = utf8.DecodeLastRuneInString(fileName[:pos])
if ch != 'i' && ch != 'I' {
break
}
pos -= size
ch, size = utf8.DecodeLastRuneInString(fileName[:pos])
if ch != 'm' && ch != 'M' {
break
}
pos -= size
ch, size = utf8.DecodeLastRuneInString(fileName[:pos])
} else {
// This character is not part of either suffix pattern
break
}
if ch != '-' && ch != '.' {
break
}
pos -= size
end = pos
}
return fileName[0:end]
}