UNPKG

cspell

Version:

A Spelling Checker for Code!

346 lines (288 loc) 13 kB
import { Observable, bindNodeCallback, from, combineLatest } from 'rxjs'; import { map, share, tap, flatMap, filter, catchError, first, toArray, reduce } from 'rxjs/operators'; import * as glob from 'glob'; import * as minimatch from 'minimatch'; import * as cspell from './index'; import * as fsp from 'fs-extra'; import * as path from 'path'; import * as commentJson from 'comment-json'; import * as util from './util/util'; import { traceWords, TraceResult } from './index'; import { CheckTextInfo } from './validator'; import * as Validator from './validator'; // cspell:word nocase const UTF8: BufferEncoding = 'utf8'; export { TraceResult, IncludeExcludeFlag } from './index'; export interface CSpellApplicationOptions { verbose?: boolean; debug?: boolean; config?: string; exclude?: string; wordsOnly?: boolean; unique?: boolean; local?: string; } export interface TraceOptions extends ConfigOptions { } export interface ConfigOptions { config?: string; } export interface AppError extends NodeJS.ErrnoException {} export interface RunResult { files: number; filesWithIssues: Set<string>; issues: number; } export interface Issue extends cspell.TextDocumentOffset {} export interface GlobSrcInfo { glob: string; regex: RegExp; source: string; } export interface MessageEmitter { (message: string): void; } export interface ErrorEmitter { (message: string, error: Error): Promise<void>; } export interface SpellingErrorEmitter { (issue: Issue): void; } export interface Emitters { issue: SpellingErrorEmitter; info: MessageEmitter; debug: MessageEmitter; error: ErrorEmitter; } const matchBase = { matchBase: true }; const defaultMinimatchOptions: minimatch.IOptions = { nocase: true }; const defaultConfigGlob: string = '{cspell.json,.cspell.json}'; const defaultConfigGlobOptions: minimatch.IOptions = defaultMinimatchOptions; export class CSpellApplicationConfiguration { readonly info: (message?: any, ...args: any[]) => void; readonly debug: (message?: any, ...args: any[]) => void; readonly logIssue: (issue: Issue) => void; readonly uniqueFilter: (issue: Issue) => boolean; readonly local: string; readonly configGlob: string = defaultConfigGlob; readonly configGlobOptions: minimatch.IOptions = defaultConfigGlobOptions; readonly excludes: GlobSrcInfo[]; constructor( readonly files: string[], readonly options: CSpellApplicationOptions, readonly emitters: Emitters ) { this.info = emitters.info || this.info; this.debug = emitters.debug || this.debug; this.configGlob = options.config || this.configGlob; this.configGlobOptions = options.config ? {} : this.configGlobOptions; this.excludes = calcExcludeGlobInfo(options.exclude); this.logIssue = emitters.issue || this.logIssue; this.local = options.local || ''; this.uniqueFilter = options.unique ? util.uniqueFilterFnGenerator((issue: Issue) => issue.text) : () => true; } } export function lint( files: string[], options: CSpellApplicationOptions, emitters: Emitters ) { const cfg = new CSpellApplicationConfiguration(files, options, emitters); return runLint(cfg); } function runLint(cfg: CSpellApplicationConfiguration) { return run(); function run(): Promise<RunResult> { header(); interface ConfigInfo { filename: string; config: cspell.CSpellUserSettings; } interface FileConfigInfo { configInfo: ConfigInfo; filename: string; text: string; } interface ResultInfo { filename: string; issues: cspell.TextDocumentOffset[]; config: cspell.CSpellUserSettings; } const configRx = globRx(cfg.configGlob, cfg.configGlobOptions).pipe( map(util.unique), tap(configFiles => cfg.info(`Config Files Found:\n ${configFiles.join('\n ')}\n`)), map((filenames): ConfigInfo => ({filename: filenames.join(' || '), config: cspell.readSettingsFiles(filenames)})), map(config => { if (cfg.local) { config.config.language = cfg.local; } return config; }), share(), ); interface FileInfo { filename: string; text: string; } // Get Exclusions from the config files. const exclusionGlobs = configRx.pipe( map(({filename, config}) => extractGlobExcludesFromConfig(filename, config)), flatMap(a => a), toArray(), map(a => a.concat(cfg.excludes)), ).toPromise(); const filesRx: Observable<FileInfo> = filterFiles(findFiles(cfg.files), exclusionGlobs).pipe( flatMap(filename => { return fsp.readFile(filename).then( text => ({text: text.toString(), filename}), error => { return error.code === 'EISDIR' ? Promise.resolve(undefined) : Promise.reject({...error, message: `Error reading file: "${filename}"`}); }); }), filter(a => !!a), map(a => a!), ); const status: RunResult = { files: 0, filesWithIssues: new Set<string>(), issues: 0, }; const r = combineLatest( configRx, filesRx, (configInfo, fileInfo): FileConfigInfo => ({ configInfo, text: fileInfo.text, filename: fileInfo.filename }) ).pipe( map(({configInfo, filename, text}): FileConfigInfo => { const ext = path.extname(filename); const fileSettings = cspell.calcOverrideSettings(configInfo.config, path.resolve(filename)); const settings = cspell.mergeSettings(cspell.getDefaultSettings(), cspell.getGlobalSettings(), fileSettings); const languageIds = settings.languageId ? [settings.languageId] : cspell.getLanguagesForExt(ext); const config = cspell.constructSettingsForText(settings, text, languageIds); cfg.debug(`Filename: ${filename}, Extension: ${ext}, LanguageIds: ${languageIds.toString()}`); return {configInfo: {...configInfo, config}, filename, text}; }), filter(info => info.configInfo.config.enabled !== false), tap(() => status.files += 1), flatMap<FileConfigInfo, ResultInfo>((info) => { const {configInfo, filename, text} = info; const debugCfg = { config: {...configInfo.config, source: null}, filename: configInfo.filename }; cfg.debug(commentJson.stringify(debugCfg, undefined, 2)); return cspell.validateText(text, configInfo.config) .then(wordOffsets => { return { filename, issues: cspell.Text.calculateTextDocumentOffsets(filename, text, wordOffsets), config: configInfo.config, }; }); }), tap(info => { const {filename, issues, config} = info; const dictionaries = (config.dictionaries || []); cfg.info(`Checking: ${filename}, File type: ${config.languageId}, Language: ${config.language} ... Issues: ${issues.length}`); cfg.info(`Dictionaries Used: ${dictionaries.join(', ')}`); issues .filter(cfg.uniqueFilter) .forEach((issue) => cfg.logIssue(issue)); }), filter(info => !!info.issues.length), tap(issue => status.filesWithIssues.add(issue.filename)), reduce((status: RunResult, info: ResultInfo) => ({...status, issues: status.issues + info.issues.length}), status), ).toPromise(); return r; } function header() { cfg.info(` cspell; Date: ${(new Date()).toUTCString()} Options: verbose: ${yesNo(!!cfg.options.verbose)} config: ${cfg.configGlob} exclude: ${cfg.excludes.map(a => a.glob).join('\n ')} files: ${cfg.files} wordsOnly: ${yesNo(!!cfg.options.wordsOnly)} unique: ${yesNo(!!cfg.options.unique)} `); } function isExcluded(filename: string, globs: GlobSrcInfo[]) { const cwd = process.cwd(); const relFilename = (filename.slice(0, cwd.length) === cwd) ? filename.slice(cwd.length) : filename; for (const glob of globs) { if (glob.regex.test(relFilename)) { cfg.info(`Excluded File: ${filename}; Excluded by ${glob.glob} from ${glob.source}`); return true; } } return false; } function filterFiles(files: Observable < string >, excludeGlobs: Promise<GlobSrcInfo[]>): Observable < string > { excludeGlobs.then(excludeGlobs => { const excludeInfo = excludeGlobs.map(g => `Glob: ${g.glob} from ${g.source}`); cfg.info(`Exclusion Globs: \n ${excludeInfo.join('\n ')}\n`); }); return combineLatest( files, excludeGlobs, (filename, globs) => ({ filename, globs }) ).pipe( filter(({ filename, globs }) => !isExcluded(filename, globs)), map(({ filename }) => filename), ); } } export async function trace(words: string[], options: TraceOptions): Promise<TraceResult[]> { const configGlob = options.config || defaultConfigGlob; const configGlobOptions = options.config ? {} : defaultConfigGlobOptions; const results = await globRx(configGlob, configGlobOptions).pipe( map(util.unique), map(filenames => ({filename: filenames.join(' || '), config: cspell.readSettingsFiles(filenames)})), map(({filename, config}) => ({filename, config: cspell.mergeSettings(cspell.getDefaultSettings(), cspell.getGlobalSettings(), config)})), flatMap(config => traceWords(words, config.config)), toArray(), ).toPromise(); return results; } export interface CheckTextResult extends CheckTextInfo {} export async function checkText(filename: string, options: ConfigOptions): Promise<CheckTextResult> { const configGlob = options.config || defaultConfigGlob; const configGlobOptions = options.config ? {} : defaultConfigGlobOptions; const pSettings = globRx(configGlob, configGlobOptions).pipe( first(), map(util.unique), map(filenames => cspell.readSettingsFiles(filenames)), ).toPromise(); const pBuffer = fsp.readFile(filename); const [foundSettings, buffer] = await Promise.all([pSettings, pBuffer]); const text = buffer.toString(UTF8); const ext = path.extname(filename); const fileSettings = cspell.calcOverrideSettings(foundSettings, path.resolve(filename)); const settings = cspell.mergeSettings(cspell.getDefaultSettings(), cspell.getGlobalSettings(), fileSettings); const languageIds = settings.languageId ? [settings.languageId] : cspell.getLanguagesForExt(ext); const config = cspell.constructSettingsForText(settings, text, languageIds); return Validator.checkText(text, config); } export function createInit(_: CSpellApplicationOptions): Promise<void> { return Promise.resolve(); } const defaultExcludeGlobs = [ 'node_modules/**' ]; function findFiles(globPatterns: string[]): Observable<string> { const processed = new Set<string>(); return from(globPatterns).pipe( flatMap(pattern => globRx(pattern) .pipe(catchError((error: AppError) => { return new Promise<string[]>((resolve) => resolve(Promise.reject({...error, message: 'Error with glob search.'}))); })) ), flatMap(a => a), filter(filename => !processed.has(filename)), tap(filename => processed.add(filename)), ); } function calcExcludeGlobInfo(commandLineExclude: string | undefined): GlobSrcInfo[] { const excludes = commandLineExclude && commandLineExclude.split(/\s+/g).map(glob => ({glob, source: 'arguments'})) || defaultExcludeGlobs.map(glob => ({glob, source: 'default'})); return excludes.map(({source, glob}) => ({source, glob, regex: minimatch.makeRe(glob, matchBase)})); } function extractGlobExcludesFromConfig(filename: string, config: cspell.CSpellUserSettings): GlobSrcInfo[] { return (config.ignorePaths || []).map(glob => ({ source: filename, glob, regex: minimatch.makeRe(glob, matchBase)})); } type GlobRx = (filename: string, options?: minimatch.IOptions) => Observable<string[]>; function yesNo(value: boolean) { return value ? 'Yes' : 'No'; } const globRx: GlobRx = bindNodeCallback<string, string[]>(glob);