From 1fd90afe1dacce66e43ceb2a63a1cfc1ae7a6be0 Mon Sep 17 00:00:00 2001 From: Jason Dent Date: Fri, 8 Dec 2023 13:02:39 +0100 Subject: [PATCH 1/2] feat: Support inline cspell directives --- packages/__utils/src/AutoResolve.test.ts | 109 +++++ packages/__utils/src/AutoResolve.ts | 168 ++++++++ packages/__utils/src/index.ts | 1 + packages/__utils/tsconfig.json | 4 +- packages/client/src/autocomplete.ts | 404 ++++++++++++------ .../client/src/client/DocumentConfigCache.ts | 91 ++++ packages/client/src/client/index.ts | 1 + packages/client/src/diags.ts | 9 +- 8 files changed, 657 insertions(+), 130 deletions(-) create mode 100644 packages/__utils/src/AutoResolve.test.ts create mode 100644 packages/__utils/src/AutoResolve.ts create mode 100644 packages/client/src/client/DocumentConfigCache.ts diff --git a/packages/__utils/src/AutoResolve.test.ts b/packages/__utils/src/AutoResolve.test.ts new file mode 100644 index 000000000..0487da825 --- /dev/null +++ b/packages/__utils/src/AutoResolve.test.ts @@ -0,0 +1,109 @@ +// import { describe, expect, test, vi } from 'vitest'; + +import { createAutoResolveCache, createAutoResolveWeakCache } from './AutoResolve'; + +describe('AutoResolve', () => { + test('createAutoResolveCache', () => { + const cache = createAutoResolveCache(); + + const resolver = jest.fn((s: string) => s.toUpperCase()); + + expect(cache.get('hello')).toBe(undefined); + expect(cache.get('hello', resolver)).toBe('HELLO'); + expect(resolver).toHaveBeenCalledTimes(1); + expect(cache.get('hello', resolver)).toBe('HELLO'); + expect(resolver).toHaveBeenCalledTimes(1); + cache.set('hello', 'hello'); + expect(cache.get('hello', resolver)).toBe('hello'); + expect(resolver).toHaveBeenCalledTimes(1); + expect(cache.get('a', resolver)).toBe('A'); + expect(resolver).toHaveBeenCalledTimes(2); + }); + + test('createAutoResolveWeakCache', () => { + const cache = createAutoResolveWeakCache<{ name: string }, string>(); + + const resolver = jest.fn((v: { name: string }) => v.name.toUpperCase()); + + const tagHello = { name: 'hello' }; + const tagHello2 = { ...tagHello }; + const tagA = { name: 'a' }; + expect(cache.get(tagHello)).toBe(undefined); + expect(cache.get(tagHello, resolver)).toBe('HELLO'); + expect(resolver).toHaveBeenCalledTimes(1); + expect(cache.get(tagHello, resolver)).toBe('HELLO'); + expect(resolver).toHaveBeenCalledTimes(1); + cache.set(tagHello, 'hello'); + expect(cache.get(tagHello, resolver)).toBe('hello'); + expect(resolver).toHaveBeenCalledTimes(1); + expect(cache.get(tagA, resolver)).toBe('A'); + expect(resolver).toHaveBeenCalledTimes(2); + expect(cache.get(tagHello2)).toBe(undefined); + expect(cache.get(tagHello2, resolver)).toBe('HELLO'); + expect(resolver).toHaveBeenCalledTimes(3); + expect(cache.stats()).toEqual({ + hits: 2, + misses: 5, + deletes: 0, + resolved: 3, + sets: 1, + disposals: 0, + clears: 0, + }); + expect(cache.get(tagHello2, resolver)).toBe('HELLO'); + expect(cache.stats()).toEqual({ + hits: 3, + misses: 5, + deletes: 0, + resolved: 3, + sets: 1, + disposals: 0, + clears: 0, + }); + cache.delete(tagHello); + expect(cache.stats()).toEqual({ + hits: 3, + misses: 5, + deletes: 1, + resolved: 3, + sets: 1, + disposals: 0, + clears: 0, + }); + expect(cache.get(tagHello, resolver)).toBe('HELLO'); + expect(cache.stats()).toEqual({ + hits: 3, + misses: 6, + deletes: 1, + resolved: 4, + sets: 1, + disposals: 0, + clears: 0, + }); + const weakMap = cache.map; + cache.clear(); + const weakMap2 = cache.map; + expect(weakMap2).toBe(cache.map); + expect(weakMap2).not.toBe(weakMap); + expect(cache.stats()).toEqual({ + hits: 0, + misses: 0, + deletes: 0, + resolved: 0, + sets: 0, + disposals: 0, + clears: 1, + }); + cache.dispose(); + expect(cache.map).not.toBe(weakMap2); + expect(cache.stats()).toEqual({ + hits: 0, + misses: 0, + deletes: 0, + resolved: 0, + sets: 0, + disposals: 1, + clears: 2, + }); + }); +}); diff --git a/packages/__utils/src/AutoResolve.ts b/packages/__utils/src/AutoResolve.ts new file mode 100644 index 000000000..0eadf16df --- /dev/null +++ b/packages/__utils/src/AutoResolve.ts @@ -0,0 +1,168 @@ +interface IDisposable { + dispose(): void; +} + +export function autoResolve(map: Map, key: K, resolve: (k: K) => V): V { + const found = map.get(key); + if (found !== undefined || map.has(key)) return found as V; + const value = resolve(key); + map.set(key, value); + return value; +} + +export interface CacheStats { + hits: number; + misses: number; + resolved: number; + deletes: number; + sets: number; + clears: number; + disposals: number; +} + +export type AutoResolveCacheStats = Readonly; + +class CacheStatsTracker implements CacheStats { + hits: number = 0; + misses: number = 0; + resolved: number = 0; + deletes: number = 0; + sets: number = 0; + clears: number = 0; + disposals: number = 0; + + stats(): AutoResolveCacheStats { + return { + hits: this.hits, + misses: this.misses, + resolved: this.resolved, + deletes: this.deletes, + sets: this.sets, + clears: this.clears, + disposals: this.disposals, + }; + } + + clear(): void { + this.hits = 0; + this.misses = 0; + this.resolved = 0; + this.deletes = 0; + this.sets = 0; + ++this.clears; + } +} + +export class AutoResolveCache implements IDisposable { + readonly map = new Map(); + + get(k: K): V | undefined; + get(k: K, resolve: (k: K) => V): V; + get(k: K, resolve?: (k: K) => V): V | undefined; + get(k: K, resolve?: (k: K) => V): V | undefined { + return resolve ? autoResolve(this.map, k, resolve) : this.map.get(k); + } + + has(k: K): boolean { + return this.map.has(k); + } + + set(k: K, v: V): this { + this.map.set(k, v); + return this; + } + + delete(k: K): boolean { + return this.map.delete(k); + } + + clear(): void { + this.map.clear(); + } + + dispose(): void { + this.clear(); + } +} + +export function createAutoResolveCache(): AutoResolveCache { + return new AutoResolveCache(); +} + +export interface IWeakMap { + get(k: K): V | undefined; + set(k: K, v: V): this; + has(k: K): boolean; + delete(key: K): boolean; +} + +export function autoResolveWeak(map: IWeakMap, key: K, resolve: (k: K) => V): V { + const found = map.get(key); + if (found !== undefined || map.has(key)) return found as V; + const value = resolve(key); + map.set(key, value); + return value; +} + +export class AutoResolveWeakCache implements IWeakMap { + private _map = new WeakMap(); + + private _stats = new CacheStatsTracker(); + + get(k: K): V | undefined; + get(k: K, resolve: (k: K) => V): V; + get(k: K, resolve?: (k: K) => V): V | undefined; + get(k: K, resolve?: (k: K) => V): V | undefined { + const map = this._map; + const found = map.get(k); + if (found !== undefined || map.has(k)) { + ++this._stats.hits; + return found as V; + } + ++this._stats.misses; + if (!resolve) { + return undefined; + } + ++this._stats.resolved; + const value = resolve(k); + map.set(k, value); + return value; + } + + get map() { + return this._map; + } + + has(k: K): boolean { + return this._map.has(k); + } + + set(k: K, v: V): this { + ++this._stats.sets; + this._map.set(k, v); + return this; + } + + clear(): void { + this._stats.clear(); + this._map = new WeakMap(); + } + + delete(k: K): boolean { + ++this._stats.deletes; + return this._map.delete(k); + } + + dispose(): void { + ++this._stats.disposals; + this.clear(); + } + + stats(): AutoResolveCacheStats { + return this._stats.stats(); + } +} + +export function createAutoResolveWeakCache(): AutoResolveWeakCache { + return new AutoResolveWeakCache(); +} diff --git a/packages/__utils/src/index.ts b/packages/__utils/src/index.ts index 1054c7f82..f16e17edb 100644 --- a/packages/__utils/src/index.ts +++ b/packages/__utils/src/index.ts @@ -1,3 +1,4 @@ +export { AutoResolveCache, createAutoResolveCache, createAutoResolveWeakCache } from './AutoResolve.js'; export * from './errors.js'; export { LogFileConnection } from './logFile.js'; export * from './uriHelper.js'; diff --git a/packages/__utils/tsconfig.json b/packages/__utils/tsconfig.json index ffdea91ac..753c33948 100644 --- a/packages/__utils/tsconfig.json +++ b/packages/__utils/tsconfig.json @@ -1,5 +1,7 @@ { - "compilerOptions": {}, + "compilerOptions": { + "esModuleInterop": true + }, "files": [], "references": [{ "path": "./tsconfig.cjs.json" }, { "path": "./tsconfig.esm.json" }] } diff --git a/packages/client/src/autocomplete.ts b/packages/client/src/autocomplete.ts index bb7ec80da..6652017f6 100644 --- a/packages/client/src/autocomplete.ts +++ b/packages/client/src/autocomplete.ts @@ -1,88 +1,66 @@ +import { createAutoResolveCache } from '@internal/common-utils'; +import type { GetConfigurationForDocumentResult } from 'code-spell-checker-server/api'; +import type { InlineCompletionContext, InlineCompletionItemProvider, Position, TextDocument } from 'vscode'; import * as vscode from 'vscode'; +import { InlineCompletionItem, InlineCompletionList, Range, SnippetString } from 'vscode'; +import { DocumentConfigCache } from './client'; import * as di from './di'; import { getCSpellDiags } from './diags'; import type { Disposable } from './disposable'; +import type { SpellingDiagnostic } from './issueTracker'; import { getSettingFromVSConfig, inspectConfigByScopeAndKey } from './settings/vsConfig'; // cspell:ignore bibtex doctex expl jlweave rsweave // See [Issue #1450](https://github.com/streetsidesoftware/vscode-spell-checker/issues/1450) const blockLangIdsForInlineCompletion = new Set(['tex', 'bibtex', 'latex', 'latex-expl3', 'jlweave', 'rsweave', 'doctex']); -export async function registerCspellInlineCompletionProviders(subscriptions: Disposable[]): Promise { - const inlineCompletionLangIds = await calcInlineCompletionIds(); - subscriptions.push( - vscode.languages.registerCompletionItemProvider(inlineCompletionLangIds, cspellInlineCompletionProvider, ':'), - vscode.languages.registerCompletionItemProvider(inlineCompletionLangIds, cspellInlineCompletionProvider, ' '), - vscode.languages.registerCompletionItemProvider(inlineCompletionLangIds, cspellInlineDictionaryNameCompletionProvider, ' '), - vscode.languages.registerCompletionItemProvider(inlineCompletionLangIds, cspellInlineIssuesCompletionProvider, ' '), - ); -} - -const cspellInlineCompletionProvider: vscode.CompletionItemProvider = { - provideCompletionItems(document: vscode.TextDocument, position: vscode.Position) { - const diags = findNearestDiags(getCSpellDiags(document.uri), position, 1); - const isCSpell = /cspell:\s?$/i; - // get all text until the `position` and check if it reads `cspell.` - const linePrefix = document.lineAt(position).text.substr(0, position.character); - if (!isCSpell.test(linePrefix)) { - return undefined; - } - - const context: CompletionContext = { - words: diags.map((d) => document.getText(d.range)), - }; +const regExCSpellInDocDirective = /\b(?:spell-?checker|c?spell)::?(.*)/gi; +const regExCSpellDirectiveKey = /(?<=\b(?:spell-?checker|c?spell)::?)(?!:)\s*(.*)/i; +// const regExInFileSettings = [regExCSpellInDocDirective, /\b(LocalWords:?.*)/g]; - return completions.map((c) => (typeof c === 'function' ? c(context) : c)).map(genCompletionItem); - }, - resolveCompletionItem(item: vscode.CompletionItem) { - return item; - }, -}; +const directivePrefixes = [ + { pfx: 'cspell:', min: 3 }, + { pfx: 'cSpell:', min: 2 }, + { pfx: 'spell:', min: 5 }, + { pfx: 'spell-checker:', min: 5 }, + { pfx: 'spellchecker:', min: 5 }, + { pfx: 'LocalWords:', min: 6 }, +] as const; -function genCompletionItem(c: Completion): vscode.CompletionItem { - const item = new vscode.CompletionItem(c.label, c.kind || vscode.CompletionItemKind.Text); - item.insertText = new vscode.SnippetString(c.insertText); - item.documentation = c.description; - item.sortText = c.sortText; - item.commitCharacters = c.commitCharacters || [' ']; - return item; +export async function registerCspellInlineCompletionProviders(subscriptions: Disposable[]): Promise { + subscriptions.push(vscode.languages.registerInlineCompletionItemProvider({ pattern: '**' }, inlineDirectiveCompletionProvider)); } interface Completion { label: string; insertText: string; + snippetText?: string; description: string; sortText?: string; kind?: vscode.CompletionItemKind; commitCharacters?: string[]; } -interface CompletionContext { - words: string[]; -} - -type CompletionFn = (context: CompletionContext) => Completion; - -const completions: (Completion | CompletionFn)[] = [ +const inlineCompletions: Completion[] = [ { label: 'words', insertText: 'words', description: 'Words to be allowed in the document', sortText: '1', - kind: vscode.CompletionItemKind.Snippet, }, { label: 'ignore words', insertText: 'ignore', description: 'Words to be ignored in the document', sortText: '2', - kind: vscode.CompletionItemKind.Snippet, }, { label: 'ignoreRegExp', - insertText: 'ignoreRegExp /${1:expression}/g', + insertText: 'ignoreRegExp', + snippetText: 'ignoreRegExp /${1:expression}/g', description: 'Ignore text matching the regular expression.', + kind: vscode.CompletionItemKind.Snippet, }, { label: 'disable-next-line', @@ -110,11 +88,11 @@ const completions: (Completion | CompletionFn)[] = [ insertText: 'dictionaries', commitCharacters: [' '], description: 'Add dictionaries to be used in this document.', - kind: vscode.CompletionItemKind.Snippet, }, { label: 'locale', - insertText: 'locale ${1:en}', + insertText: 'locale', + snippetText: 'locale ${1:en}', description: 'Set the language locale to be used in this document. (i.e. fr,en)', kind: vscode.CompletionItemKind.Snippet, }, @@ -130,83 +108,6 @@ const completions: (Completion | CompletionFn)[] = [ }, ]; -// function flatten(nested: T[][]): T[] { -// function* _flatten(nested: T[][]) { -// for (const a of nested) { -// yield* a; -// } -// } - -// return [..._flatten(nested)]; -// } - -function findNearestDiags(diags: vscode.Diagnostic[], position: vscode.Position, count: number): vscode.Diagnostic[] { - /** A Simple distance calculation weighted towards lines over characters while trying to preserve order. */ - function dist(diag: vscode.Diagnostic) { - const p0 = diag.range.start; - const deltaLine = Math.abs(p0.line - position.line); - return deltaLine * 1000 + p0.character; - } - - const sorted = [...diags].sort((a, b) => dist(a) - dist(b)); - return sorted.slice(0, count); -} - -const cspellInlineDictionaryNameCompletionProvider: vscode.CompletionItemProvider = { - async provideCompletionItems(document: vscode.TextDocument, position: vscode.Position) { - // get all text until the `position` and check if it reads `cspell.` - const linePrefix = document.lineAt(position).text.substr(0, position.character); - - const isDictionaryRequest = /cspell:\s*dictionaries/i; - - if (!isDictionaryRequest.test(linePrefix)) { - return undefined; - } - - const settings = await di.get('client').getConfigurationForDocument(document); - const docSettings = settings.docSettings || settings.settings; - if (!docSettings) return undefined; - - const enabledDicts = new Set(docSettings.dictionaries || []); - const dicts = (docSettings.dictionaryDefinitions || []) - .map((def) => def.name) - .filter((a) => !!a) - .filter((name) => !enabledDicts.has(name)); - - return dicts.map((d) => new vscode.CompletionItem(d, vscode.CompletionItemKind.Text)).map((c) => ((c.commitCharacters = [' ']), c)); - }, - resolveCompletionItem(item: vscode.CompletionItem) { - return item; - }, -}; - -const cspellInlineIssuesCompletionProvider: vscode.CompletionItemProvider = { - provideCompletionItems(document: vscode.TextDocument, position: vscode.Position) { - // get all text until the `position` and check if it reads `cspell.` - const line = document.lineAt(position); - const linePrefix = line.text.substr(0, position.character); - const isDictionaryRequest = /cspell:\s*(words|ignore)/i; - if (!isDictionaryRequest.test(linePrefix)) { - return undefined; - } - const wordsOnline = new Set(line.text.split(/[\s:.]/)); - const allDiags = getCSpellDiags(document.uri).filter((d) => !wordsOnline.has(document.getText(d.range))); - - const diags = findNearestDiags(allDiags, position, 10); - - if (!diags.length) return undefined; - - const words = [...new Set(diags.map((d) => document.getText(d.range)))].sort(); - - return words - .map((word) => new vscode.CompletionItem(word, vscode.CompletionItemKind.Text)) - .map((c) => ((c.commitCharacters = [' ']), c)); - }, - resolveCompletionItem(item: vscode.CompletionItem) { - return item; - }, -}; - interface ShowSuggestionsSettings { value: boolean; byLangId: Map; @@ -225,9 +126,260 @@ function getShowSuggestionsSettings(): ShowSuggestionsSettings { return { value, byLangId }; } -async function calcInlineCompletionIds(): Promise { +async function _calcInlineCompletionIds(): Promise { const langIds = await vscode.languages.getLanguages(); const { value, byLangId } = getShowSuggestionsSettings(); const inlineCompletionLangIds = langIds.filter((lang) => byLangId.get(lang) ?? (!blockLangIdsForInlineCompletion.has(lang) && value)); return inlineCompletionLangIds; } + +interface DictionaryInfoForDoc { + available: string[] | undefined; + enabled: string[] | undefined; +} + +class CSpellInlineDirectiveCompletionProvider implements InlineCompletionItemProvider { + private cacheConfig: DocumentConfigCache; + private cacheDocDictionaries = createAutoResolveCache(); + + constructor() { + this.cacheConfig = new DocumentConfigCache((doc) => di.get('client').getConfigurationForDocument(doc)); + } + + provideInlineCompletionItems( + document: TextDocument, + position: Position, + context: InlineCompletionContext, + _token: vscode.CancellationToken, + ) { + if (blockLangIdsForInlineCompletion.has(document.languageId)) return undefined; + + const line = document.lineAt(position.line); + const lineText = line.text; + const linePrefix = lineText.slice(0, position.character); + const match = linePrefix.match(regExCSpellInDocDirective); + console.log('inlineDirectiveCompletionProvider %o', { match, context, linePrefix }); + if (!match) { + return generateDirectivePrefixCompletionItems(linePrefix, position); + } + + const result: InlineCompletionList = { + items: [], + }; + + const regDir = new RegExp(regExCSpellDirectiveKey); + regDir.lastIndex = match.index || 0; + const matchDir = regDir.exec(linePrefix); + if (!matchDir) return undefined; + + const directive = matchDir[1]; + const startChar = (matchDir.index || 0) + matchDir[0].length - directive.length; + + console.log('inlineDirectiveCompletionProvider %o', { directive, context }); + + if (directive.startsWith('dictionaries') || directive.startsWith('dictionary')) { + return generateDictionaryNameInlineCompletionItems(document, position, lineText, startChar, (document) => + this.getListOfDictionaries(document), + ); + } + + if (directive.startsWith('words') || directive.startsWith('ignore')) { + return generateWordInlineCompletionItems(document, position, lineText, startChar); + } + + const parts = directive.split(/\s+/); + if (parts.length > 1) return undefined; + + const range = new vscode.Range(position.line, startChar, position.line, position.character); + const completions = inlineCompletions.filter((c) => c.insertText.startsWith(parts[0])).map((c) => toInlineCompletionItem(c, range)); + + result.items.push(...completions); + + return result; + } + + getConfigForDocument(document: TextDocument): GetConfigurationForDocumentResult | undefined { + return this.cacheConfig.get(document.uri); + } + + getListOfDictionaries(document: TextDocument): DictionaryInfoForDoc { + const settings = this.getConfigForDocument(document); + + const key = document.uri.toString(); + const result = this.cacheDocDictionaries.get(key, () => ({ available: undefined, enabled: undefined })); + + if (!settings) return result; + const docSettings = settings.docSettings || settings.settings; + if (!docSettings) return result; + const calc = createDictionaryInfoForDoc(settings); + this.cacheDocDictionaries.set(key, calc); + return calc; + } +} + +function generateDirectivePrefixCompletionItems(linePrefix: string, position: Position): InlineCompletionList | undefined { + const result: InlineCompletionList = { + items: [], + }; + + const p = linePrefix.lastIndexOf(' '); + const startChar = p + 1; + const directivePrefix = linePrefix.slice(startChar); + const dLen = directivePrefix.length; + const matches = directivePrefixes.filter((p) => p.pfx.startsWith(directivePrefix) && dLen >= p.min).map((p) => p.pfx); + console.log('generateDirectivePrefixCompletionItems %o', { linePrefix, position, matches }); + if (!matches.length) return undefined; + + const range = new Range(position.line, startChar, position.line, position.character); + const items = matches.map((insertText) => new InlineCompletionItem(insertText, range)); + + result.items.push(...items); + + console.log('generateDirectivePrefixCompletionItems %o', { linePrefix, position, matches, items }); + + return result; +} + +function toInlineCompletionItem(item: Completion, range: Range): InlineCompletionItem { + if (item.snippetText) { + const snippet = new SnippetString(item.snippetText); + const result = new InlineCompletionItem(snippet, range); + result.filterText = item.insertText; + } + const result = new InlineCompletionItem(item.insertText, range); + return result; +} + +function generateDictionaryNameInlineCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + line: string, + startIndexForDirective: number, + getListOfDictionaries: (document: vscode.TextDocument) => DictionaryInfoForDoc, +): InlineCompletionList | undefined { + const regDir = new RegExp(regExCSpellDirectiveKey, 's'); + regDir.lastIndex = startIndexForDirective; + const matchDirective = regDir.exec(line); + if (!matchDirective) return undefined; + + const directive = matchDirective[1]; + const startDirChar = (matchDirective.index || 0) + matchDirective[0].length - directive.length; + const curIndex = position.character; + + const endIndex = findNextNonWordChar(line, startDirChar); + + if (endIndex < curIndex) return undefined; + + const dictInfo = getListOfDictionaries(document); + if (!dictInfo.available) return undefined; + + const regExHasSpaceAfter = /\s|$/ms; + regExHasSpaceAfter.lastIndex = curIndex; + const suffix = regExHasSpaceAfter.exec(line) ? '' : ' '; + const lastWordBreak = line.lastIndexOf(' ', curIndex - 1) + 1; + const prefix = lastWordBreak <= startDirChar ? ' ' : ''; + + const regExSplitNames = /[\s,]+/g; + + const namesBefore = line.slice(startDirChar, lastWordBreak).split(regExSplitNames); + const namesAfter = line.slice(curIndex, endIndex).split(regExSplitNames); + + const enabledDicts = new Set([...(dictInfo.enabled || []), ...namesBefore, ...namesAfter]); + + const dicts = dictInfo.available.filter((name) => !enabledDicts.has(name)); + + if (!dicts.length) return undefined; + + const range = new Range(position.line, lastWordBreak, position.line, curIndex); + + return new InlineCompletionList(dicts.map((insertText) => new InlineCompletionItem(prefix + insertText + suffix, range))); +} + +/** + * Handle `words` and `ignore` inline completions. + * @param document - document + * @param position - cursor position + * @param line - line text + * @param startIndexForDirective - start index of the directive + */ +function generateWordInlineCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + line: string, + startIndexForDirective: number, +): InlineCompletionList | undefined { + const regDir = new RegExp(regExCSpellDirectiveKey, 's'); + regDir.lastIndex = startIndexForDirective; + const matchDirective = regDir.exec(line); + if (!matchDirective) return undefined; + + const directive = matchDirective[1]; + const startDirChar = (matchDirective.index || 0) + matchDirective[0].length - directive.length; + const curIndex = position.character; + + const endIndex = findNextNonWordChar(line, startDirChar); + + if (endIndex < curIndex) return undefined; + + const issues = getIssues(document); + if (!issues.length) return undefined; + + const regExHasSpaceAfter = /\s|$/ms; + regExHasSpaceAfter.lastIndex = curIndex; + const suffix = regExHasSpaceAfter.exec(line) ? '' : ' '; + const lastWordBreak = line.lastIndexOf(' ', curIndex - 1) + 1; + const prefix = lastWordBreak <= startDirChar ? ' ' : ''; + const words = sortIssuesBy(document, position, issues); + console.log('words: %o', { words, directive, curIndex, endIndex, lastWordBreak, prefix, suffix }); + const range = new Range(position.line, lastWordBreak, position.line, curIndex); + + return new InlineCompletionList(words.map((insertText) => new InlineCompletionItem(prefix + insertText + suffix, range))); +} + +function sortIssuesBy(document: TextDocument, position: Position, issues: SpellingDiagnostic[]): string[] { + // Look for close by issues first, otherwise sort alphabetically. + + const numLines = 3; + const line = position.line; + const nearbyRange = new Range(Math.max(line - numLines, 0), 0, line + numLines, 0); + const nearbyIssues = issues.filter((i) => nearbyRange.contains(i.range)); + if (nearbyIssues.length) { + nearbyIssues.sort((a, b) => Math.abs(a.range.start.line - line) - Math.abs(b.range.start.line - line)); + const words = new Set(nearbyIssues.map((i) => document.getText(i.range))); + return [...words]; + } + const words = [...new Set(issues.map((i) => document.getText(i.range)))]; + words.sort(); + return words; +} + +function findNextNonWordChar(line: string, start: number): number { + const regExNonDictionaryNameCharacters = /[^a-z0-9_\s,\p{L}-]/giu; + regExNonDictionaryNameCharacters.lastIndex = start; + const r = regExNonDictionaryNameCharacters.exec(line); + if (!r) return line.length; + return r.index; +} + +const inlineDirectiveCompletionProvider: InlineCompletionItemProvider = new CSpellInlineDirectiveCompletionProvider(); + +function createDictionaryInfoForDoc(config: GetConfigurationForDocumentResult): DictionaryInfoForDoc { + try { + const dicts = (config.docSettings?.dictionaryDefinitions || []) + .filter((a) => !!a) + .map((def) => def.name) + .filter((a) => !!a); + const enabled = config.docSettings?.dictionaries || []; + return { available: dicts, enabled }; + } catch (e) { + return { available: undefined, enabled: undefined }; + } +} + +function getIssues(doc: TextDocument) { + return getCSpellDiags(doc.uri).filter((issue) => !issue.data?.issueType); +} + +export const __delete_me__ = { + _calcInlineCompletionIds, +}; diff --git a/packages/client/src/client/DocumentConfigCache.ts b/packages/client/src/client/DocumentConfigCache.ts new file mode 100644 index 000000000..a3e004833 --- /dev/null +++ b/packages/client/src/client/DocumentConfigCache.ts @@ -0,0 +1,91 @@ +import type { Uri } from 'vscode'; + +import type { CSpellClient } from './client'; +import type { GetConfigurationForDocumentResult } from './server'; + +interface CacheItem { + config: GetConfigurationForDocumentResult | undefined; + timestamp: number; + pending: Promise | undefined; +} + +export class DocumentConfigCache { + private configs = new Map(); + + constructor( + public getConfigurationForDocument: CSpellClient['getConfigurationForDocument'], + public staleTimeMs = 1000, + public maxAgeMs = 5000, + ) { + this.configs = new Map(); + } + + get(uri: Uri) { + const item = this.configs.get(uri.toString()); + if (!item || !item.config || this.isTooOld(item)) { + this.fetch(uri); + return undefined; + } + if (!this.isState(item)) { + this.fetch(uri); + } + return item.config; + } + + set(uri: Uri, config: GetConfigurationForDocumentResult) { + const key = uri.toString(); + const item = this.configs.get(key) || { config, timestamp: performance.now(), pending: undefined }; + item.config = config; + this.configs.set(key, item); + } + + delete(uri: Uri) { + const key = uri.toString(); + return this.configs.delete(key); + } + + clear() { + this.configs.clear(); + } + + private async fetchAsync(uri: Uri, item: CacheItem): Promise { + try { + const config = await this.getConfigurationForDocument({ uri }); + item.config = config; + item.timestamp = performance.now(); + item.pending = undefined; + return config; + } finally { + item.pending = undefined; + this.cleanup(); + } + } + + private fetch(uri: Uri) { + const key = uri.toString(); + const item: CacheItem = this.configs.get(key) || { config: undefined, timestamp: performance.now(), pending: undefined }; + if (item.pending) { + return item.pending; + } + this.configs.set(key, item); + const pending = this.fetchAsync(uri, item); + item.pending = pending; + return pending; + } + + private isState(item: CacheItem) { + return item.timestamp + this.staleTimeMs > performance.now(); + } + + private isTooOld(item: CacheItem) { + return item.timestamp + this.maxAgeMs < performance.now(); + } + + private cleanup() { + for (const [key, item] of this.configs) { + if (!item.pending && this.isTooOld(item)) { + this.configs.delete(key); + } + } + } +} diff --git a/packages/client/src/client/index.ts b/packages/client/src/client/index.ts index 15d66734b..5468b7f67 100644 --- a/packages/client/src/client/index.ts +++ b/packages/client/src/client/index.ts @@ -1,4 +1,5 @@ export * from './client'; +export { DocumentConfigCache } from './DocumentConfigCache'; export type { ClientSideCommandHandlerApi, ConfigKind, diff --git a/packages/client/src/diags.ts b/packages/client/src/diags.ts index 7d52c8480..c8a0d6fc2 100644 --- a/packages/client/src/diags.ts +++ b/packages/client/src/diags.ts @@ -1,23 +1,26 @@ +import type { IssueType } from '@cspell/cspell-types'; import type { Diagnostic, Range, Selection, TextDocument, Uri } from 'vscode'; import { diagnosticSource } from './constants'; import { getDependencies } from './di'; +import type { SpellingDiagnostic } from './issueTracker'; import { isWordLike } from './settings/CSpellSettings'; import { isDefined, uniqueFilter } from './util'; /** * Return cspell diags for a given uri. * @param docUri - uri of diag to look for. + * @param issueType - optional issue type to filter on -- by default it returns only spelling issues. * @returns any cspell diags found matching the uri. */ -export function getCSpellDiags(docUri: Uri | undefined): Diagnostic[] { +export function getCSpellDiags(docUri: Uri | undefined, issueType?: IssueType): SpellingDiagnostic[] { const issueTracker = getDependencies().issueTracker; const diags = (docUri && issueTracker.getDiagnostics(docUri)) || []; - const cSpellDiags = filterDiags(diags); + const cSpellDiags = filterDiags(diags).filter((d) => d.data?.issueType === issueType || (!d.data?.issueType && !issueType)); return cSpellDiags; } -export function filterDiags(diags: readonly Diagnostic[], source = diagnosticSource): Diagnostic[] { +export function filterDiags(diags: readonly D[], source = diagnosticSource): D[] { return diags.filter((d) => d.source === source); } From 60d62c419ec14f3cb98f770ce5f3e6eb73e902fb Mon Sep 17 00:00:00 2001 From: Jason Dent Date: Fri, 8 Dec 2023 13:30:06 +0100 Subject: [PATCH 2/2] use the setting --- .../_includes/generated-docs/configuration.md | 2 +- package.json | 2 +- .../_server/spell-checker-config.schema.json | 2 +- .../cspellConfig/SpellCheckerSettings.mts | 2 +- packages/client/src/autocomplete.test.ts | 6 ++- packages/client/src/autocomplete.ts | 54 ++++++------------- 6 files changed, 24 insertions(+), 44 deletions(-) diff --git a/docs/_includes/generated-docs/configuration.md b/docs/_includes/generated-docs/configuration.md index efb29ed25..a6dd8a927 100644 --- a/docs/_includes/generated-docs/configuration.md +++ b/docs/_includes/generated-docs/configuration.md @@ -611,7 +611,7 @@ Description **Note:** VS Code must be restarted for this setting to take effect. Default -: _`false`_ +: _`true`_ --- diff --git a/package.json b/package.json index 01a7ececa..2a61ea660 100644 --- a/package.json +++ b/package.json @@ -3205,7 +3205,7 @@ "type": "number" }, "cSpell.showAutocompleteSuggestions": { - "default": false, + "default": true, "markdownDescription": "Show CSpell in-document directives as you type.\n\n**Note:** VS Code must be restarted for this setting to take effect.", "scope": "language-overridable", "type": "boolean" diff --git a/packages/_server/spell-checker-config.schema.json b/packages/_server/spell-checker-config.schema.json index 2a2c1f00c..4d63c03c7 100644 --- a/packages/_server/spell-checker-config.schema.json +++ b/packages/_server/spell-checker-config.schema.json @@ -2788,7 +2788,7 @@ "type": "number" }, "cSpell.showAutocompleteSuggestions": { - "default": false, + "default": true, "description": "Show CSpell in-document directives as you type.\n\n**Note:** VS Code must be restarted for this setting to take effect.", "markdownDescription": "Show CSpell in-document directives as you type.\n\n**Note:** VS Code must be restarted for this setting to take effect.", "scope": "language-overridable", diff --git a/packages/_server/src/config/cspellConfig/SpellCheckerSettings.mts b/packages/_server/src/config/cspellConfig/SpellCheckerSettings.mts index 973cc9457..6583493e4 100644 --- a/packages/_server/src/config/cspellConfig/SpellCheckerSettings.mts +++ b/packages/_server/src/config/cspellConfig/SpellCheckerSettings.mts @@ -128,7 +128,7 @@ export interface SpellCheckerSettings extends SpellCheckerShouldCheckDocSettings * * **Note:** VS Code must be restarted for this setting to take effect. * @scope language-overridable - * @default false + * @default true */ showAutocompleteSuggestions?: boolean; diff --git a/packages/client/src/autocomplete.test.ts b/packages/client/src/autocomplete.test.ts index 8369b69de..4cbd3ac36 100644 --- a/packages/client/src/autocomplete.test.ts +++ b/packages/client/src/autocomplete.test.ts @@ -7,12 +7,14 @@ vi.mock('vscode'); vi.mock('vscode-languageclient/node'); const mockedRegisterCompletionItemProvider = vi.mocked(languages.registerCompletionItemProvider); +const mockedRegisterInlineCompletionItemProvider = vi.mocked(languages.registerInlineCompletionItemProvider); describe('autocomplete', () => { test('registerCspellInlineCompletionProviders', async () => { const disposables: { dispose(): any }[] = []; await registerCspellInlineCompletionProviders(disposables); - expect(mockedRegisterCompletionItemProvider).toHaveBeenCalledTimes(4); - expect(disposables).toHaveLength(4); + expect(mockedRegisterCompletionItemProvider).toHaveBeenCalledTimes(0); + expect(mockedRegisterInlineCompletionItemProvider).toHaveBeenCalledTimes(1); + expect(disposables).toHaveLength(1); }); }); diff --git a/packages/client/src/autocomplete.ts b/packages/client/src/autocomplete.ts index 6652017f6..71dd55d6e 100644 --- a/packages/client/src/autocomplete.ts +++ b/packages/client/src/autocomplete.ts @@ -9,11 +9,7 @@ import * as di from './di'; import { getCSpellDiags } from './diags'; import type { Disposable } from './disposable'; import type { SpellingDiagnostic } from './issueTracker'; -import { getSettingFromVSConfig, inspectConfigByScopeAndKey } from './settings/vsConfig'; - -// cspell:ignore bibtex doctex expl jlweave rsweave -// See [Issue #1450](https://github.com/streetsidesoftware/vscode-spell-checker/issues/1450) -const blockLangIdsForInlineCompletion = new Set(['tex', 'bibtex', 'latex', 'latex-expl3', 'jlweave', 'rsweave', 'doctex']); +import { getSettingFromVSConfig } from './settings/vsConfig'; const regExCSpellInDocDirective = /\b(?:spell-?checker|c?spell)::?(.*)/gi; const regExCSpellDirectiveKey = /(?<=\b(?:spell-?checker|c?spell)::?)(?!:)\s*(.*)/i; @@ -108,29 +104,13 @@ const inlineCompletions: Completion[] = [ }, ]; -interface ShowSuggestionsSettings { - value: boolean; - byLangId: Map; -} - -function getShowSuggestionsSettings(): ShowSuggestionsSettings { +function getShowAutocompleteSuggestions(docUri: vscode.Uri): boolean { const key = 'showAutocompleteSuggestions'; - const setting = inspectConfigByScopeAndKey(undefined, key); - const byLangOverrideIds = setting.languageIds ?? []; - - const value = getSettingFromVSConfig(key, undefined) ?? false; - const byLangOverrides = byLangOverrideIds.map( - (languageId) => [languageId, getSettingFromVSConfig(key, { languageId })] as [string, boolean], - ); - const byLangId = new Map(byLangOverrides); - return { value, byLangId }; -} - -async function _calcInlineCompletionIds(): Promise { - const langIds = await vscode.languages.getLanguages(); - const { value, byLangId } = getShowSuggestionsSettings(); - const inlineCompletionLangIds = langIds.filter((lang) => byLangId.get(lang) ?? (!blockLangIdsForInlineCompletion.has(lang) && value)); - return inlineCompletionLangIds; + try { + return getSettingFromVSConfig(key, docUri) ?? true; + } catch (e) { + return false; + } } interface DictionaryInfoForDoc { @@ -149,16 +129,18 @@ class CSpellInlineDirectiveCompletionProvider implements InlineCompletionItemPro provideInlineCompletionItems( document: TextDocument, position: Position, - context: InlineCompletionContext, + _context: InlineCompletionContext, _token: vscode.CancellationToken, ) { - if (blockLangIdsForInlineCompletion.has(document.languageId)) return undefined; + if (!getShowAutocompleteSuggestions(document.uri)) return undefined; + const cfg = this.getConfigForDocument(document); + if (!cfg) return undefined; const line = document.lineAt(position.line); const lineText = line.text; const linePrefix = lineText.slice(0, position.character); const match = linePrefix.match(regExCSpellInDocDirective); - console.log('inlineDirectiveCompletionProvider %o', { match, context, linePrefix }); + // console.log('inlineDirectiveCompletionProvider %o', { match, context, linePrefix }); if (!match) { return generateDirectivePrefixCompletionItems(linePrefix, position); } @@ -175,7 +157,7 @@ class CSpellInlineDirectiveCompletionProvider implements InlineCompletionItemPro const directive = matchDir[1]; const startChar = (matchDir.index || 0) + matchDir[0].length - directive.length; - console.log('inlineDirectiveCompletionProvider %o', { directive, context }); + // console.log('inlineDirectiveCompletionProvider %o', { directive, context }); if (directive.startsWith('dictionaries') || directive.startsWith('dictionary')) { return generateDictionaryNameInlineCompletionItems(document, position, lineText, startChar, (document) => @@ -227,7 +209,7 @@ function generateDirectivePrefixCompletionItems(linePrefix: string, position: Po const directivePrefix = linePrefix.slice(startChar); const dLen = directivePrefix.length; const matches = directivePrefixes.filter((p) => p.pfx.startsWith(directivePrefix) && dLen >= p.min).map((p) => p.pfx); - console.log('generateDirectivePrefixCompletionItems %o', { linePrefix, position, matches }); + // console.log('generateDirectivePrefixCompletionItems %o', { linePrefix, position, matches }); if (!matches.length) return undefined; const range = new Range(position.line, startChar, position.line, position.character); @@ -235,7 +217,7 @@ function generateDirectivePrefixCompletionItems(linePrefix: string, position: Po result.items.push(...items); - console.log('generateDirectivePrefixCompletionItems %o', { linePrefix, position, matches, items }); + // console.log('generateDirectivePrefixCompletionItems %o', { linePrefix, position, matches, items }); return result; } @@ -330,7 +312,7 @@ function generateWordInlineCompletionItems( const lastWordBreak = line.lastIndexOf(' ', curIndex - 1) + 1; const prefix = lastWordBreak <= startDirChar ? ' ' : ''; const words = sortIssuesBy(document, position, issues); - console.log('words: %o', { words, directive, curIndex, endIndex, lastWordBreak, prefix, suffix }); + // console.log('words: %o', { words, directive, curIndex, endIndex, lastWordBreak, prefix, suffix }); const range = new Range(position.line, lastWordBreak, position.line, curIndex); return new InlineCompletionList(words.map((insertText) => new InlineCompletionItem(prefix + insertText + suffix, range))); @@ -379,7 +361,3 @@ function createDictionaryInfoForDoc(config: GetConfigurationForDocumentResult): function getIssues(doc: TextDocument) { return getCSpellDiags(doc.uri).filter((issue) => !issue.data?.issueType); } - -export const __delete_me__ = { - _calcInlineCompletionIds, -};