cspell-lib
Version:
A library of useful functions used across various cspell tools.
553 lines • 24 kB
JavaScript
import assert from 'node:assert';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { CSpellConfigFile } from 'cspell-config-lib';
import { createReaderWriter } from 'cspell-config-lib';
import { isUrlLike, toFileURL } from 'cspell-io';
import { URI, Utils as UriUtils } from 'vscode-uri';
import { onClearCache } from '../../../events/index.js';
import { getVirtualFS } from '../../../fileSystem.js';
import { createCSpellSettingsInternal as csi } from '../../../Models/CSpellSettingsInternalDef.js';
import { srcDirectory } from '../../../pkg-info.mjs';
import { autoResolve, AutoResolveCache, autoResolveWeak } from '../../../util/AutoResolve.js';
import { logError, logWarning } from '../../../util/logger.js';
import { FileResolver } from '../../../util/resolveFile.js';
import { envToTemplateVars } from '../../../util/templates.js';
import { addTrailingSlash, cwdURL, resolveFileWithURL, toFileDirURL, toFilePathOrHref, toFileUrl, windowsDriveLetterToUpper, } from '../../../util/url.js';
import { configSettingsFileVersion0_1, configSettingsFileVersion0_2, currentSettingsFileVersion, defaultConfigFileModuleRef, ENV_CSPELL_GLOB_ROOT, } from '../../constants.js';
import { getMergeStats, mergeSettings } from '../../CSpellSettingsServer.js';
import { getGlobalConfig } from '../../GlobalSettings.js';
import { ImportError } from '../ImportError.js';
import { pnpLoader } from '../pnpLoader.js';
import { searchPlaces } from './configLocations.js';
import { ConfigSearch } from './configSearch.js';
import { configToRawSettings } from './configToRawSettings.js';
import { defaultSettings } from './defaultSettings.js';
import { normalizeCacheSettings, normalizeDictionaryDefs, normalizeGitignoreRoot, normalizeImport, normalizeLanguageSettings, normalizeOverrides, normalizeReporters, normalizeSettingsGlobs, } from './normalizeRawSettings.js';
import { defaultPnPSettings, normalizePnPSettings } from './PnPSettings.js';
const supportedCSpellConfigVersions = [configSettingsFileVersion0_2];
const setOfSupportedConfigVersions = Object.freeze(new Set(supportedCSpellConfigVersions));
export const sectionCSpell = 'cSpell';
export const defaultFileName = 'cspell.json';
let defaultConfigLoader = undefined;
const defaultExtensions = ['.json', '.yaml', '.yml', '.jsonc'];
const defaultJsExtensions = ['.js', '.cjs', '.mjs'];
const trustedSearch = new Map([
['*', defaultExtensions],
['file:', [...defaultExtensions, ...defaultJsExtensions]],
]);
const unTrustedSearch = new Map([['*', defaultExtensions]]);
export class ConfigLoader {
fs;
templateVariables;
onReady;
fileResolver;
_isTrusted = true;
/**
* Use `createConfigLoader`
* @param virtualFs - virtual file system to use.
*/
constructor(fs, templateVariables = envToTemplateVars(process.env)) {
this.fs = fs;
this.templateVariables = templateVariables;
this.configSearch = new ConfigSearch(searchPlaces, trustedSearch, fs);
this.cspellConfigFileReaderWriter = createReaderWriter(undefined, undefined, createIO(fs));
this.fileResolver = new FileResolver(fs, this.templateVariables);
this.onReady = this.init();
this.subscribeToEvents();
}
subscribeToEvents() {
this.toDispose.push(onClearCache(() => this.clearCachedSettingsFiles()));
}
cachedConfig = new Map();
cachedConfigFiles = new Map();
cachedPendingConfigFile = new AutoResolveCache();
cachedMergedConfig = new WeakMap();
cachedCSpellConfigFileInMemory = new WeakMap();
globalSettings;
cspellConfigFileReaderWriter;
configSearch;
stopSearchAtCache = new WeakMap();
toDispose = [];
async readSettingsAsync(filename, relativeTo, pnpSettings) {
await this.onReady;
const ref = await this.resolveFilename(filename, relativeTo || toFileDirURL('./'));
const entry = this.importSettings(ref, pnpSettings || defaultPnPSettings, []);
return entry.onReady;
}
async readConfigFile(filenameOrURL, relativeTo) {
const ref = await this.resolveFilename(filenameOrURL.toString(), relativeTo || toFileDirURL('./'));
const url = toFileURL(ref.filename);
const href = url.href;
if (ref.error)
return new ImportError(`Failed to read config file: "${ref.filename}"`, ref.error);
const cached = this.cachedConfigFiles.get(href);
if (cached)
return cached;
return this.cachedPendingConfigFile.get(href, async () => {
try {
const file = await this.cspellConfigFileReaderWriter.readConfig(href);
this.cachedConfigFiles.set(href, file);
// validateRawConfigVersion(file);
return file;
}
catch (error) {
// console.warn('Debug: %o', { href, error });
return new ImportError(`Failed to read config file: "${ref.filename}"`, error);
}
finally {
setTimeout(() => this.cachedPendingConfigFile.delete(href), 1);
}
});
}
async searchForConfigFileLocation(searchFrom, stopSearchAt) {
const searchFromURL = (await this.#normalizeDirURL(searchFrom)) || cwdURL();
return this.configSearch.searchForConfig(searchFromURL, stopSearchAt);
}
async searchForConfigFile(searchFrom, stopSearchAt) {
const location = await this.searchForConfigFileLocation(searchFrom, stopSearchAt);
if (!location)
return undefined;
const file = await this.readConfigFile(location);
return file instanceof Error ? undefined : file;
}
/**
*
* @param searchFrom the directory / file URL to start searching from.
* @param options - Optional settings including stop location and Yarn PnP configuration.
* @returns the resulting settings
*/
async searchForConfig(searchFrom, options) {
const stopAt = await this.#extractStopSearchAtURLs(options);
const configFile = await this.searchForConfigFile(searchFrom, stopAt);
if (!configFile)
return undefined;
return this.mergeConfigFileWithImports(configFile, options);
}
getGlobalSettings() {
assert(this.globalSettings, 'Global settings not loaded');
return this.globalSettings;
}
async getGlobalSettingsAsync() {
if (!this.globalSettings) {
const globalConfFile = await getGlobalConfig();
const normalized = await this.mergeConfigFileWithImports(globalConfFile, undefined);
normalized.id ??= 'global_config';
this.globalSettings = normalized;
}
return this.globalSettings;
}
clearCachedSettingsFiles() {
this.globalSettings = undefined;
this.cachedConfig.clear();
this.cachedConfigFiles.clear();
this.configSearch.clearCache();
this.cachedPendingConfigFile.clear();
this.cspellConfigFileReaderWriter.clearCachedFiles();
this.cachedMergedConfig = new WeakMap();
this.cachedCSpellConfigFileInMemory = new WeakMap();
this.prefetchGlobalSettingsAsync();
}
/**
* Resolve and merge the settings from the imports.
* @param settings - settings to resolve imports for
* @param filename - the path / URL to the settings file. Used to resolve imports.
*/
resolveSettingsImports(settings, filename) {
const settingsFile = this.createCSpellConfigFile(filename, settings);
return this.mergeConfigFileWithImports(settingsFile, settings);
}
init() {
this.onReady = Promise.all([this.prefetchGlobalSettingsAsync(), this.resolveDefaultConfig()]).then(() => undefined);
return this.onReady;
}
async prefetchGlobalSettingsAsync() {
await this.getGlobalSettingsAsync().catch((e) => logError(e));
}
async resolveDefaultConfig() {
const r = await this.fileResolver.resolveFile(defaultConfigFileModuleRef, srcDirectory);
const url = toFileURL(r.filename);
this.cspellConfigFileReaderWriter.setTrustedUrls([new URL('../..', url)]);
return url;
}
importSettings(fileRef, pnpSettings, backReferences) {
const url = toFileURL(fileRef.filename);
const cacheKey = url.href;
const cachedImport = this.cachedConfig.get(cacheKey);
if (cachedImport) {
backReferences.forEach((ref) => cachedImport.referencedSet.add(ref));
return cachedImport;
}
if (fileRef.error) {
const settings = csi({
__importRef: fileRef,
source: { name: fileRef.filename, filename: fileRef.filename },
});
const importedConfig = {
href: cacheKey,
fileRef,
configFile: undefined,
settings,
isReady: true,
onReady: Promise.resolve(settings),
onConfigFileReady: Promise.resolve(fileRef.error),
referencedSet: new Set(backReferences),
};
this.cachedConfig.set(cacheKey, importedConfig);
return importedConfig;
}
const source = {
name: fileRef.filename,
filename: fileRef.filename,
};
const mergeImports = (cfgFile) => {
if (cfgFile instanceof Error) {
fileRef.error = cfgFile;
return csi({ __importRef: fileRef, source });
}
return this.mergeConfigFileWithImports(cfgFile, pnpSettings, backReferences);
};
const referencedSet = new Set(backReferences);
const onConfigFileReady = onConfigFileReadyFixUp(this.readConfigFile(fileRef.filename));
const importedConfig = {
href: cacheKey,
fileRef,
configFile: undefined,
settings: undefined,
isReady: false,
onReady: onReadyFixUp(onConfigFileReady.then(mergeImports)),
onConfigFileReady,
referencedSet,
};
this.cachedConfig.set(cacheKey, importedConfig);
return importedConfig;
async function onReadyFixUp(pSettings) {
const settings = await pSettings;
settings.source ??= source;
settings.__importRef ??= fileRef;
importedConfig.isReady = true;
importedConfig.settings = settings;
return settings;
}
async function onConfigFileReadyFixUp(pCfgFile) {
const cfgFile = await pCfgFile;
if (cfgFile instanceof Error) {
importedConfig.fileRef.error = cfgFile;
return cfgFile;
}
source.name = cfgFile.settings.name || source.name;
importedConfig.configFile = cfgFile;
return cfgFile;
}
}
async setupPnp(cfgFile, pnpSettings) {
if (!pnpSettings?.usePnP || pnpSettings === defaultPnPSettings)
return;
if (cfgFile.url.protocol !== 'file:')
return;
// Try to load any .pnp files before reading dictionaries or other config files.
const { usePnP = pnpSettings.usePnP, pnpFiles = pnpSettings.pnpFiles } = cfgFile.settings;
const pnpSettingsToUse = normalizePnPSettings({ usePnP, pnpFiles });
const pathToSettingsDir = new URL('.', cfgFile.url);
await loadPnP(pnpSettingsToUse, pathToSettingsDir);
}
mergeConfigFileWithImports(cfg, pnpSettings, referencedBy) {
const cfgFile = this.toCSpellConfigFile(cfg);
const cached = this.cachedMergedConfig.get(cfgFile);
if (cached && cached.pnpSettings === pnpSettings && cached.referencedBy === referencedBy) {
return cached.result;
}
// console.warn('missing cache %o', cfgFile.url.href);
const pnp = {
usePnP: cfg.settings.usePnP ?? pnpSettings?.usePnP ?? !!process.versions.pnp,
pnpFiles: cfg.settings.pnpFiles ?? pnpSettings?.pnpFiles,
};
const result = this._mergeConfigFileWithImports(cfgFile, pnp, referencedBy);
this.cachedMergedConfig.set(cfgFile, { pnpSettings, referencedBy, result });
return result;
}
async _mergeConfigFileWithImports(cfgFile, pnpSettings, referencedBy = []) {
await this.setupPnp(cfgFile, pnpSettings);
const href = cfgFile.url.href;
const referencedSet = new Set(referencedBy);
const imports = normalizeImport(cfgFile.settings.import);
const __imports = await Promise.all(imports.map((name) => this.resolveFilename(name, cfgFile.url)));
const toImport = __imports.map((ref) => this.importSettings(ref, pnpSettings, [...referencedBy, href]));
// Add ourselves to the import sources.
toImport.forEach((entry) => {
entry.referencedSet.add(href);
});
const pendingImports = toImport.map((entry) => {
// Detect circular references, return raw settings if circular.
return referencedSet.has(entry.href)
? entry.settings || configToRawSettings(entry.configFile)
: entry.onReady;
});
const importSettings = await Promise.all(pendingImports);
const cfg = await this.mergeImports(cfgFile, importSettings);
return cfg;
}
/**
* normalizeSettings handles correcting all relative paths, anchoring globs, and importing other config files.
* @param rawSettings - raw configuration settings
* @param pathToSettingsFile - path to the source file of the configuration settings.
*/
async mergeImports(cfgFile, importedSettings) {
const rawSettings = configToRawSettings(cfgFile);
const url = cfgFile.url;
const fileRef = rawSettings.__importRef;
const source = rawSettings.source;
assert(source);
// Fix up dictionaryDefinitions
const settings = {
version: defaultSettings.version,
...rawSettings,
globRoot: resolveGlobRoot(rawSettings, cfgFile.url),
languageSettings: normalizeLanguageSettings(rawSettings.languageSettings),
};
const normalizedDictionaryDefs = normalizeDictionaryDefs(settings, url);
const normalizedSettingsGlobs = normalizeSettingsGlobs(settings, url);
const normalizedOverrides = normalizeOverrides(settings, url);
const normalizedReporters = await normalizeReporters(settings, url);
const normalizedGitignoreRoot = normalizeGitignoreRoot(settings, url);
const normalizedCacheSettings = normalizeCacheSettings(settings, url);
const fileSettings = csi({
...settings,
source,
...normalizedDictionaryDefs,
...normalizedSettingsGlobs,
...normalizedOverrides,
...normalizedReporters,
...normalizedGitignoreRoot,
...normalizedCacheSettings,
});
if (!importedSettings.length) {
return fileSettings;
}
const mergedImportedSettings = importedSettings.reduce((a, b) => mergeSettings(a, b));
const finalizeSettings = mergeSettings(mergedImportedSettings, fileSettings);
finalizeSettings.name = settings.name || finalizeSettings.name || '';
finalizeSettings.id = settings.id || finalizeSettings.id || '';
if (fileRef) {
finalizeSettings.__importRef = fileRef;
}
return finalizeSettings;
}
createCSpellConfigFile(filename, settings) {
const map = autoResolveWeak(this.cachedCSpellConfigFileInMemory, settings, () => new Map());
return autoResolve(map, filename, () => this.cspellConfigFileReaderWriter.toCSpellConfigFile({ url: toFileURL(filename), settings }));
}
toCSpellConfigFile(cfg) {
if (cfg instanceof CSpellConfigFile)
return cfg;
return this.createCSpellConfigFile(cfg.url, cfg.settings);
}
dispose() {
while (this.toDispose.length) {
try {
this.toDispose.pop()?.dispose();
}
catch (e) {
logError(e);
}
}
}
getStats() {
return { ...getMergeStats() };
}
async resolveConfigFileLocation(filenameOrURL, relativeTo) {
const r = await this.fileResolver.resolveFile(filenameOrURL, relativeTo);
return r.found ? toFileURL(r.filename) : undefined;
}
async resolveFilename(filename, relativeTo) {
if (filename instanceof URL)
return { filename: toFilePathOrHref(filename) };
if (isUrlLike(filename))
return { filename: toFilePathOrHref(filename) };
const r = await this.fileResolver.resolveFile(filename, relativeTo);
if (r.warning) {
logWarning(r.warning);
}
return {
filename: r.filename.startsWith('file:/') ? fileURLToPath(r.filename) : r.filename,
error: r.found ? undefined : new ConfigurationLoaderFailedToResolveError(filename, relativeTo),
};
}
get isTrusted() {
return this._isTrusted;
}
setIsTrusted(isTrusted) {
this._isTrusted = isTrusted;
this.clearCachedSettingsFiles();
this.configSearch = new ConfigSearch(searchPlaces, isTrusted ? trustedSearch : unTrustedSearch, this.fs);
this.cspellConfigFileReaderWriter.setUntrustedExtensions(isTrusted ? [] : defaultJsExtensions);
}
async #extractStopSearchAtURLs(options) {
if (!options?.stopSearchAt)
return undefined;
if (this.stopSearchAtCache.has(options)) {
return this.stopSearchAtCache.get(options);
}
const rawStops = Array.isArray(options.stopSearchAt) ? options.stopSearchAt : [options.stopSearchAt];
const stopURLs = await Promise.all(rawStops.map((s) => this.#normalizeDirURL(s)));
this.stopSearchAtCache.set(options, stopURLs);
return stopURLs;
}
async #normalizeDirURL(input) {
if (!input)
return undefined;
const url = toFileURL(input, cwdURL());
if (url.pathname.endsWith('/'))
return url;
if (input instanceof URL)
return new URL('.', url);
if (typeof input === 'string' &&
!isUrlLike(input) &&
url.protocol === 'file:' &&
(await isDirectory(this.fs, url))) {
return addTrailingSlash(url);
}
return new URL('.', url);
}
}
class ConfigLoaderInternal extends ConfigLoader {
constructor(vfs) {
super(vfs);
}
get _cachedFiles() {
return this.cachedConfig;
}
}
export function loadPnP(pnpSettings, searchFrom) {
if (!pnpSettings.usePnP) {
return Promise.resolve(undefined);
}
const loader = pnpLoader(pnpSettings.pnpFiles);
return loader.load(searchFrom);
}
const nestedConfigDirectories = {
'.vscode': true,
'.config': true, // this should be removed in the future, but it is a breaking change.
};
function resolveGlobRoot(settings, urlSettingsFile) {
const urlSettingsFileDir = new URL('.', urlSettingsFile);
const uriSettingsFileDir = URI.parse(urlSettingsFileDir.href);
const settingsFileDirName = UriUtils.basename(uriSettingsFileDir);
const isNestedConfig = settingsFileDirName in nestedConfigDirectories;
const isVSCode = settingsFileDirName === '.vscode';
const settingsFileDir = (isNestedConfig ? UriUtils.dirname(uriSettingsFileDir) : uriSettingsFileDir).toString();
const envGlobRoot = process.env[ENV_CSPELL_GLOB_ROOT];
const defaultGlobRoot = envGlobRoot ?? '${cwd}';
const rawRoot = settings.globRoot ??
(settings.version === configSettingsFileVersion0_1 ||
(envGlobRoot && !settings.version) ||
(isVSCode && !settings.version)
? defaultGlobRoot
: settingsFileDir);
const globRoot = rawRoot.startsWith('${cwd}') ? rawRoot : resolveFileWithURL(rawRoot, new URL(settingsFileDir));
return typeof globRoot === 'string'
? globRoot
: globRoot.protocol === 'file:'
? windowsDriveLetterToUpper(path.resolve(fileURLToPath(globRoot)))
: addTrailingSlash(globRoot).href;
}
function validationMessage(msg, url) {
return msg + `\n File: "${toFilePathOrHref(url)}"`;
}
function validateRawConfigVersion(config) {
const { version } = config.settings;
if (version === undefined)
return;
if (typeof version !== 'string') {
logError(validationMessage(`Unsupported config file version: "${version}", string expected`, config.url));
return;
}
if (setOfSupportedConfigVersions.has(version))
return;
if (!/^\d+(\.\d+)*$/.test(version)) {
logError(validationMessage(`Unsupported config file version: "${version}"`, config.url));
return;
}
const msg = version > currentSettingsFileVersion
? `Newer config file version found: "${version}". Supported version is "${currentSettingsFileVersion}"`
: `Legacy config file version found: "${version}", upgrade to "${currentSettingsFileVersion}"`;
logWarning(validationMessage(msg, config.url));
}
function createConfigLoaderInternal(fs) {
return new ConfigLoaderInternal(fs ?? getVirtualFS().fs);
}
export function createConfigLoader(fs) {
return createConfigLoaderInternal(fs);
}
export function getDefaultConfigLoaderInternal() {
if (defaultConfigLoader)
return defaultConfigLoader;
return (defaultConfigLoader = createConfigLoaderInternal());
}
function createIO(fs) {
const readFile = (url) => fs.readFile(url).then((file) => ({ url: file.url, content: file.getText() }));
const writeFile = (file) => fs.writeFile(file);
return {
readFile,
writeFile,
};
}
async function isDirectory(fs, path) {
try {
return (await fs.stat(path)).isDirectory();
}
catch {
return false;
}
}
export class ConfigurationLoaderError extends Error {
configurationFile;
relativeTo;
constructor(message, configurationFile, relativeTo, cause) {
super(message);
this.configurationFile = configurationFile;
this.relativeTo = relativeTo;
this.name = 'Configuration Loader Error';
if (cause) {
this.cause = cause;
}
}
}
export class ConfigurationLoaderFailedToResolveError extends ConfigurationLoaderError {
configurationFile;
relativeTo;
constructor(configurationFile, relativeTo, cause) {
const filename = configurationFile.startsWith('file:/') ? fileURLToPath(configurationFile) : configurationFile;
const relSource = relativeToCwd(relativeTo);
const message = `Failed to resolve configuration file: "${filename}" referenced from "${relSource}"`;
super(message, configurationFile, relativeTo, cause);
this.configurationFile = configurationFile;
this.relativeTo = relativeTo;
// this.name = 'Configuration Loader Error';
}
}
function relativeToCwd(file) {
const url = toFileUrl(file);
const cwdPath = cwdURL().pathname.split('/').slice(0, -1);
const urlPath = url.pathname.split('/');
if (urlPath[0] !== cwdPath[0])
return toFilePathOrHref(file);
let i = 0;
for (; i < cwdPath.length; ++i) {
if (cwdPath[i] !== urlPath[i])
break;
}
const segments = cwdPath.length - i;
if (segments > 3)
return toFilePathOrHref(file);
const prefix = [...'.'.repeat(segments)].map(() => '..').join('/');
return [prefix || '.', ...urlPath.slice(i)].join('/');
}
export const __testing__ = {
getDefaultConfigLoaderInternal,
normalizeCacheSettings,
validateRawConfigVersion,
resolveGlobRoot,
relativeToCwd,
};
//# sourceMappingURL=configLoader.js.map