cspell-lib
Version:
A library of useful functions used across various cspell tools.
432 lines • 18.9 kB
JavaScript
import assert from 'node:assert';
import { opConcatMap, opMap, pipeSync } from '@cspell/cspell-pipe/sync';
import { IssueType } from '@cspell/cspell-types';
import { toFilePathOrHref, toFileURL } from '@cspell/url';
import { satisfiesCSpellConfigFile } from 'cspell-config-lib';
import { getGlobMatcherForExcluding } from '../globs/getGlobMatcher.js';
import { documentUriToURL, updateTextDocument } from '../Models/TextDocument.js';
import { createPerfTimer } from '../perf/index.js';
import { finalizeSettings, loadConfig, mergeSettings, resolveConfigFileImports, resolveSettingsImports, searchForConfig, } from '../Settings/index.js';
import { validateInDocumentSettings } from '../Settings/InDocSettings.js';
import { getDictionaryInternal } from '../SpellingDictionary/index.js';
import { calcSuggestionAdjustedToToMatchCase } from '../suggestions.js';
import { catchPromiseError, toError } from '../util/errors.js';
import { AutoCache } from '../util/simpleCache.js';
import { uriToFilePath } from '../util/Uri.js';
import { defaultMaxDuplicateProblems, defaultMaxNumberOfProblems } from './defaultConstants.js';
import { determineTextDocumentSettings } from './determineTextDocumentSettings.js';
import { textValidatorFactory } from './lineValidatorFactory.js';
import { createMappedTextSegmenter } from './parsedText.js';
import { settingsToValidateOptions } from './settingsToValidateOptions.js';
import { calcTextInclusionRanges } from './textValidator.js';
import { traceWord } from './traceWord.js';
const ERROR_NOT_PREPARED = 'Validator Must be prepared before calling this function.';
export class DocumentValidator {
settings;
_document;
_ready = false;
errors = [];
_prepared;
_preparations;
_preparationTime = -1;
_suggestions = new AutoCache((text) => this.genSuggestions(text), 1000);
options;
perfTiming = {};
skipValidation;
static async create(doc, options, settingsOrConfigFile) {
const settings = satisfiesCSpellConfigFile(settingsOrConfigFile)
? await resolveConfigFileImports(settingsOrConfigFile)
: settingsOrConfigFile;
const validator = new DocumentValidator(doc, options, settings);
await validator.prepare();
return validator;
}
/**
* @param doc - Document to validate
* @param config - configuration to use (not finalized).
*/
constructor(doc, options, settings) {
this.settings = settings;
this._document = doc;
this.options = { ...options };
const numSuggestions = this.options.numSuggestions ?? settings.numSuggestions;
if (numSuggestions !== undefined) {
this.options.numSuggestions = numSuggestions;
}
this.skipValidation = !!options.skipValidation;
// console.error(`DocumentValidator: ${doc.uri}`);
}
get ready() {
return this._ready;
}
prepare() {
if (this._ready)
return Promise.resolve();
if (this._prepared)
return this._prepared;
this._prepared = this._prepareAsync();
return this._prepared;
}
async _prepareAsync() {
assert(!this._ready);
const timer = createPerfTimer('_prepareAsync');
const { options, settings: rawSettings } = this;
const resolveImportsRelativeTo = toFileURL(options.resolveImportsRelativeTo || toFileURL('./virtual.settings.json'));
const settings = rawSettings.import?.length
? await resolveSettingsImports(rawSettings, resolveImportsRelativeTo)
: rawSettings;
const useSearchForConfig = (!options.noConfigSearch && !settings.noConfigSearch) || options.noConfigSearch === false;
const pLocalConfig = options.configFile
? loadConfig(options.configFile, settings)
: useSearchForConfig
? timePromise(this.perfTiming, '__searchForDocumentConfig', searchForDocumentConfig(this._document, settings, settings))
: undefined;
pLocalConfig && timePromise(this.perfTiming, '_loadConfig', pLocalConfig);
const localConfig = (await catchPromiseError(pLocalConfig, (e) => this.addPossibleError(e))) || {};
this.addPossibleError(localConfig?.__importRef?.error);
const config = mergeSettings(settings, localConfig);
const docSettings = await timePromise(this.perfTiming, '_determineTextDocumentSettings', determineTextDocumentSettings(this._document, config));
const dict = await timePromise(this.perfTiming, '_getDictionaryInternal', getDictionaryInternal(docSettings));
const recGlobMatcherTime = recordPerfTime(this.perfTiming, '_GlobMatcher');
const matcher = getGlobMatcherForExcluding(localConfig?.ignorePaths);
const uri = this._document.uri;
recGlobMatcherTime();
const recShouldCheckTime = recordPerfTime(this.perfTiming, '_shouldCheck');
// eslint-disable-next-line unicorn/prefer-regexp-test
const shouldCheck = !matcher.match(uriToFilePath(uri)) && (docSettings.enabled ?? true);
recShouldCheckTime();
const recFinalizeTime = recordPerfTime(this.perfTiming, '_finalizeSettings');
const finalSettings = finalizeSettings(docSettings);
const validateOptions = settingsToValidateOptions(finalSettings);
const includeRanges = calcTextInclusionRanges(this._document.text, validateOptions);
const segmenter = createMappedTextSegmenter(includeRanges);
const textValidator = textValidatorFactory(dict, validateOptions);
recFinalizeTime();
this._preparations = {
config,
dictionary: dict,
docSettings,
finalSettings,
shouldCheck,
validateOptions,
includeRanges,
segmenter,
textValidator,
localConfig,
localConfigFilepath: localConfig?.__importRef?.filename,
};
this._ready = true;
this._preparationTime = timer.elapsed;
this.perfTiming.prepTime = this._preparationTime;
}
async _updatePrep() {
assert(this._preparations, ERROR_NOT_PREPARED);
const timer = createPerfTimer('_updatePrep');
const prep = this._preparations;
const docSettings = await determineTextDocumentSettings(this._document, prep.config);
const dict = await getDictionaryInternal(docSettings);
const shouldCheck = docSettings.enabled ?? true;
const finalSettings = finalizeSettings(docSettings);
const validateOptions = settingsToValidateOptions(finalSettings);
const includeRanges = calcTextInclusionRanges(this._document.text, validateOptions);
const segmenter = createMappedTextSegmenter(includeRanges);
const textValidator = textValidatorFactory(dict, validateOptions);
this._preparations = {
...prep,
dictionary: dict,
docSettings,
shouldCheck,
validateOptions,
includeRanges,
segmenter,
textValidator,
};
this._preparationTime = timer.elapsed;
}
/**
* The amount of time in ms to prepare for validation.
*/
get prepTime() {
return this._preparationTime;
}
get validateDirectives() {
return this.options.validateDirectives ?? this._preparations?.config.validateDirectives ?? false;
}
/**
* Check a range of text for validation issues.
* @param range - the range of text to check.
* @param _text - the text to check. If not given, the text will be taken from the document.
* @param scope - the scope to use for validation. If not given, the default scope will be used.
* @returns the validation issues.
*/
checkText(range, _text, scope) {
const text = this._document.text.slice(range[0], range[1]);
scope = (Array.isArray(scope) ? scope.join(' ') : scope) || '';
return this.check({ text, range, scope });
}
check(parsedText) {
assert(this._ready);
assert(this._preparations, ERROR_NOT_PREPARED);
const { segmenter, textValidator } = this._preparations;
// Determine settings for text range
// Slice text based upon include ranges
// Check text against dictionaries.
const document = this._document;
let line = undefined;
function mapToIssue(issue) {
const { range, text, isFlagged, isFound, suggestionsEx, hasPreferredSuggestions, hasSimpleSuggestions } = issue;
const offset = range[0];
const length = range[1] - range[0];
assert(!line || line.offset <= offset);
if (!line || line.offset + line.text.length <= offset) {
line = document.lineAt(offset);
}
return {
text,
offset,
line,
length,
isFlagged,
isFound,
suggestionsEx,
hasPreferredSuggestions,
hasSimpleSuggestions,
};
}
const issues = [...pipeSync(segmenter(parsedText), opConcatMap(textValidator.validate), opMap(mapToIssue))];
if (!this.options.generateSuggestions) {
return issues.map((issue) => {
if (!issue.suggestionsEx)
return issue;
const suggestionsEx = this.adjustSuggestions(issue.text, issue.suggestionsEx);
const suggestions = suggestionsEx.map((s) => s.word);
return { ...issue, suggestionsEx, suggestions };
});
}
const withSugs = issues.map((t) => {
// lazy suggestion calculation.
const text = t.text;
const suggestionsEx = this.getSuggestions(text);
t.suggestionsEx = suggestionsEx;
t.suggestions = suggestionsEx.map((s) => s.word);
return t;
});
return withSugs;
}
/**
* Check a Document for Validation Issues.
* @param forceCheck - force a check even if the document would normally be excluded.
* @returns the validation issues.
*/
async checkDocumentAsync(forceCheck) {
await this.prepare();
return this.checkDocument(forceCheck);
}
/**
* Check a Document for Validation Issues.
*
* Note: The validator must be prepared before calling this method.
* @param forceCheck - force a check even if the document would normally be excluded.
* @returns the validation issues.
*/
checkDocument(forceCheck = false) {
const timerDone = recordPerfTime(this.perfTiming, 'checkDocument');
try {
if (this.skipValidation)
return [];
assert(this._ready);
assert(this._preparations, ERROR_NOT_PREPARED);
const spellingIssues = forceCheck || this.shouldCheckDocument() ? [...this._checkParsedText(this._parse())] : [];
const directiveIssues = this.checkDocumentDirectives();
// console.log('Stats: %o', this._preparations.textValidator.lineValidator.dict.stats());
const allIssues = [...spellingIssues, ...directiveIssues].sort((a, b) => a.offset - b.offset);
return allIssues;
}
finally {
timerDone();
}
}
checkDocumentDirectives(forceCheck = false) {
assert(this._ready);
assert(this._preparations, ERROR_NOT_PREPARED);
const validateDirectives = forceCheck || this.validateDirectives;
if (!validateDirectives)
return [];
const document = this.document;
const issueType = IssueType.directive;
function toValidationIssue(dirIssue) {
const { text, range, suggestions, suggestionsEx, message } = dirIssue;
const offset = range[0];
const pos = document.positionAt(offset);
const line = document.getLine(pos.line);
const issue = { text, offset, line, suggestions, suggestionsEx, message, issueType };
return issue;
}
return [...validateInDocumentSettings(this.document.text, this._preparations.config)].map(toValidationIssue);
}
get document() {
return this._document;
}
async updateDocumentText(text) {
updateTextDocument(this._document, [{ text }]);
await this._updatePrep();
}
/**
* Get the calculated ranges of text that should be included in the spell checking.
* @returns MatchRanges of text to include.
*/
getCheckedTextRanges() {
assert(this._preparations, ERROR_NOT_PREPARED);
return this._preparations.includeRanges;
}
traceWord(word) {
assert(this._preparations, ERROR_NOT_PREPARED);
return traceWord(word, this._preparations.dictionary, this._preparations.config);
}
defaultParser() {
return pipeSync(this.document.getLines(), opMap((line) => {
const { text, offset } = line;
const range = [offset, offset + text.length];
return { text, range };
}));
}
*_checkParsedText(parsedTexts) {
assert(this._preparations, ERROR_NOT_PREPARED);
const { maxNumberOfProblems = defaultMaxNumberOfProblems, maxDuplicateProblems = defaultMaxDuplicateProblems } = this._preparations.validateOptions;
let numProblems = 0;
const mapOfProblems = new Map();
for (const pText of parsedTexts) {
for (const issue of this.check(pText)) {
const { text } = issue;
const n = (mapOfProblems.get(text) || 0) + 1;
mapOfProblems.set(text, n);
if (n > maxDuplicateProblems)
continue;
yield issue;
if (++numProblems >= maxNumberOfProblems)
return;
}
}
}
addPossibleError(error) {
if (!error)
return;
error = this.errors.push(toError(error));
}
_parse() {
assert(this._preparations, ERROR_NOT_PREPARED);
const parser = this._preparations.finalSettings.parserFn;
if (typeof parser !== 'object')
return this.defaultParser();
return parser.parse(this.document.text, toFilePathOrHref(documentUriToURL(this.document.uri))).parsedTexts;
}
getSuggestions(text) {
return this._suggestions.get(text);
}
genSuggestions(text) {
assert(this._preparations, ERROR_NOT_PREPARED);
const settings = this._preparations.docSettings;
const dict = this._preparations.dictionary;
const sugOptions = {
compoundMethod: 0,
numSuggestions: this.options.numSuggestions,
includeTies: false,
ignoreCase: !(settings.caseSensitive ?? false),
timeout: settings.suggestionsTimeout,
numChanges: settings.suggestionNumChanges,
};
const rawSuggestions = dict.suggest(text, sugOptions);
return this.adjustSuggestions(text, rawSuggestions);
}
adjustSuggestions(text, rawSuggestions) {
assert(this._preparations, ERROR_NOT_PREPARED);
const settings = this._preparations.docSettings;
const ignoreCase = !(settings.caseSensitive ?? false);
const locale = this._preparations.config.language;
const dict = this._preparations.dictionary;
const sugsWithAlt = calcSuggestionAdjustedToToMatchCase(text, rawSuggestions.map(mapSug), locale, ignoreCase, dict);
return sugsWithAlt.map(sanitizeSuggestion);
}
getFinalizedDocSettings() {
assert(this._ready);
assert(this._preparations, ERROR_NOT_PREPARED);
return this._preparations.docSettings;
}
/**
* Returns true if the final result of the configuration calculation results
* in the document being enabled. Note: in some cases, checking the document
* might still make sense, for example, the `@cspell/eslint-plugin` relies on
* `eslint` configuration to make that determination.
* @returns true if the document settings have resolved to be `enabled`
*/
shouldCheckDocument() {
assert(this._preparations, ERROR_NOT_PREPARED);
return this._preparations.shouldCheck;
}
/**
* Internal `cspell-lib` use.
*/
_getPreparations() {
return this._preparations;
}
}
function sanitizeSuggestion(sug) {
const { word, isPreferred, wordAdjustedToMatchCase } = sug;
if (isPreferred && wordAdjustedToMatchCase)
return { word, wordAdjustedToMatchCase, isPreferred };
if (isPreferred)
return { word, isPreferred };
if (wordAdjustedToMatchCase)
return { word, wordAdjustedToMatchCase };
return { word };
}
async function searchForDocumentConfig(document, defaultConfig, pnpSettings) {
const url = documentUriToURL(document.uri);
try {
return await searchForConfig(url, pnpSettings).then((s) => s || defaultConfig);
}
catch (e) {
if (url.protocol !== 'file:')
return defaultConfig;
throw e;
}
}
function mapSug(sug) {
return { cost: 999, ...sug };
}
export async function shouldCheckDocument(doc, options, settings) {
const errors = [];
function addPossibleError(error) {
if (!error)
return undefined;
error = errors.push(toError(error));
return undefined;
}
async function shouldCheck() {
const useSearchForConfig = (!options.noConfigSearch && !settings.noConfigSearch) || options.noConfigSearch === false;
const pLocalConfig = options.configFile
? loadConfig(options.configFile, settings)
: useSearchForConfig
? searchForDocumentConfig(doc, settings, settings)
: undefined;
const localConfig = (await catchPromiseError(pLocalConfig, addPossibleError)) || {};
addPossibleError(localConfig?.__importRef?.error);
const config = mergeSettings(settings, localConfig);
const matcher = getGlobMatcherForExcluding(localConfig?.ignorePaths);
const docSettings = await determineTextDocumentSettings(doc, config);
// eslint-disable-next-line unicorn/prefer-regexp-test
return !matcher.match(uriToFilePath(doc.uri)) && (docSettings.enabled ?? true);
}
return { errors, shouldCheck: await shouldCheck() };
}
export const __testing__ = {
sanitizeSuggestion,
};
function recordPerfTime(timings, name) {
const timer = createPerfTimer(name, (elapsed) => (timings[name] = elapsed));
return () => timer.end();
}
function timePromise(timings, name, p) {
return p.finally(recordPerfTime(timings, name));
}
//# sourceMappingURL=docValidator.js.map