From 7917725697762cd57d325cc71f84fe8e3c30db3f Mon Sep 17 00:00:00 2001 From: "David L. Day" <1132144+davidlday@users.noreply.github.com> Date: Sat, 30 Nov 2019 09:20:01 -0500 Subject: [PATCH] Ignored Word Lists (#55) * version -> 0.3.0 * prepped for auto deployment * only deploy if os = linux * use condition instead of if, and specify master branch * added skip_cleanup * started dictionary class * added dictionary * actually made it a class * consolidated loadConfiguration and reloadConfiguration * expanded LTDictionary class * updated dependencies * dependency updates * attempt at lt-service class * cleaned up duplicated commands * bump engine to 1.39.0 * removed service for later * moved dictionary to linter folder, use state stores * added extension context * added dictionary * undo changes * fixed command titles / categories * linter is codeactionprovider * refactor * launch in test workspace * removed unused code * manage ignored words in configuration manager * added ignored words lists and scoped managed settings * added ignored words lists * Use sets to manage word lists. Added word list save methods. * removed scopes on managed settings. will apply later. * added spelling test document * lint plain text as annotated text * register ignore word commands * added ignored lists features. * fixed setting constant * split suggest into resuable functions * Cast set to arrays. * added more to spell check use case * registered remove ignored word commands. * added new commands to test * added boolean to show hints for ignored words * fixed copy / paste errors * updated test workstation settings * added methos for getting ignored words * added ability to hide hints for ignored words * updated changelog for ignored words * added to test case. * ignored words case insensitive * fixed saving ignored word lists * labeled changelog for release --- CHANGELOG.md | 8 ++ package.json | 17 +++ src/common/configuration-manager.ts | 102 +++++++++++++- src/extension.ts | 28 ++++ src/linter/linter.ts | 128 ++++++++++++++---- .../workspace/.vscode/settings.json | 6 + .../workspace/markdown/spelling.md | 5 + src/test/runTest.ts | 4 +- src/test/suite/extension.test.ts | 8 +- 9 files changed, 277 insertions(+), 29 deletions(-) create mode 100644 src/test-fixtures/workspace/.vscode/settings.json create mode 100644 src/test-fixtures/workspace/markdown/spelling.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 145e1352..e08cdc31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to the "languagetool-linter" extension will be documented in ## [Unreleased] +## [0.9.0] - 2019-11-29 + +### Added + +* Ignored Words lists at the User and Workspace levels. + * The Workspace level list appears in the User settings, but is ignored. This is due to how settings work as overrides instead of cumulative. + * Ignored words have an optional hint shown to remove the word from the ignored list. + ## [0.8.1] - 2019-11-17 ### Fixed diff --git a/package.json b/package.json index 738ec94b..c00e7d8b 100644 --- a/package.json +++ b/package.json @@ -314,6 +314,23 @@ "type": "string", "default": "", "description": "IDs of categories to be disabled, comma-separated." + }, + "languageToolLinter.languageTool.ignoredWordsGlobal": { + "type": "array", + "scope": "application", + "default": [], + "description": "Global list of words to ignore in spellcheck." + }, + "languageToolLinter.languageTool.ignoredWordsInWorkspace": { + "type": "array", + "scope": "window", + "default": [], + "description": "Workspace list of words to ignore in spellcheck. This setting is ignored at the User (Global) level." + }, + "languageToolLinter.languageTool.ignoredWordHint": { + "type": "boolean", + "default": true, + "description": "Shows a hint for ignored words, providing a Quick Fix to remove the word from the ignored list." } } } diff --git a/src/common/configuration-manager.ts b/src/common/configuration-manager.ts index 1e105d07..109feaca 100644 --- a/src/common/configuration-manager.ts +++ b/src/common/configuration-manager.ts @@ -1,4 +1,4 @@ -import { TextDocument, WorkspaceConfiguration, workspace, ConfigurationChangeEvent, Disposable, window } from 'vscode'; +import { TextDocument, WorkspaceConfiguration, workspace, ConfigurationChangeEvent, Disposable, window, ExtensionContext, ConfigurationTarget } from 'vscode'; import { LT_DOCUMENT_LANGUAGE_IDS, LT_CONFIGURATION_ROOT, LT_SERVICE_PARAMETERS, LT_SERVICE_EXTERNAL, LT_CHECK_PATH, LT_SERVICE_MANAGED, LT_SERVICE_PUBLIC, LT_PUBLIC_URL, LT_OUTPUT_CHANNEL } from './constants'; import * as portfinder from "portfinder"; import * as execa from "execa"; @@ -10,11 +10,18 @@ export class ConfigurationManager implements Disposable { private serviceUrl: string | undefined; private managedPort: number | undefined; private process: execa.ExecaChildProcess | undefined; + private globallyIgnoredWords: Set; + private workspaceIgnoredWords: Set; + private static SETTING_IGNORE_GLOBAL: string = "languageTool.ignoredWordsGlobal"; + private static SETTING_IGNORE_WORKSPACE: string = "languageTool.ignoredWordsInWorkspace"; + private static SETTING_HINT_IGNORED: string = "languageTool.ignoredWordHint"; constructor() { this.config = workspace.getConfiguration(LT_CONFIGURATION_ROOT); this.serviceUrl = this.findServiceUrl(this.getServiceType()); this.startManagedService(); + this.globallyIgnoredWords = this.getGloballyIgnoredWords(); + this.workspaceIgnoredWords = this.getWorkspaceIgnoredWords(); } dispose(): void { @@ -39,6 +46,18 @@ export class ConfigurationManager implements Disposable { || event.affectsConfiguration("languageToolLinter.managed.jarFile"))) { this.startManagedService(); } + // Error about conflicting settings + if (event.affectsConfiguration("languageToolLinter.languageTool.preferredVariants") + || event.affectsConfiguration("languageToolLinter.languageTool.language")) { + if (this.config.get("languageToolLinter.languageTool.language") !== "auto" + && this.config.get("languageToolLinter.languageTool.preferredVariants") !== undefined) { + window.showErrorMessage('', ...["Set Language to 'auto'", "Clear Preferred Variants"]).then((selection) => { + console.log(selection); + }); + } + } + this.globallyIgnoredWords = this.getGloballyIgnoredWords(); + this.workspaceIgnoredWords = this.getWorkspaceIgnoredWords(); } isAutoFormatEnabled(): boolean { @@ -181,5 +200,86 @@ export class ConfigurationManager implements Disposable { } } + // Manage Ignored Words Lists + isIgnoredWord(word: string): boolean { + return this.isGloballyIgnoredWord(word) || this.isWorkspaceIgnoredWord(word); + } + + // Is word ignored at the User Level? + isGloballyIgnoredWord(word: string): boolean { + return this.globallyIgnoredWords.has(word.toLowerCase()); + } + + // Is word ignored at the Workspace Level? + isWorkspaceIgnoredWord(word: string): boolean { + return this.workspaceIgnoredWords.has(word.toLowerCase()); + } + + // Save words to settings + private saveIgnoredWords(words: Set, section: string, configurationTarget: ConfigurationTarget): void { + let wordArray: Array = Array.from(words).sort(); + this.config.update(section, wordArray, configurationTarget); + } + + // Get Globally ingored words from settings. + private getGloballyIgnoredWords(): Set { + return new Set(this.config.get>(ConfigurationManager.SETTING_IGNORE_GLOBAL)); + } + + // Save word to User Level ignored word list. + private saveGloballyIgnoredWords(): void { + this.saveIgnoredWords(this.globallyIgnoredWords, ConfigurationManager.SETTING_IGNORE_GLOBAL, ConfigurationTarget.Global); + } + + // Get Workspace ignored words from settings. + private getWorkspaceIgnoredWords(): Set { + return new Set(this.config.get>(ConfigurationManager.SETTING_IGNORE_WORKSPACE)); + } + + // Save word to Workspace Level ignored word list. + private saveWorkspaceIgnoredWords(): void { + this.saveIgnoredWords(this.workspaceIgnoredWords, ConfigurationManager.SETTING_IGNORE_WORKSPACE, ConfigurationTarget.Workspace); + } + + // Add word to User Level ignored word list. + ignoreWordGlobally(word: string): void { + let lowerCaseWord: string = word.toLowerCase(); + if (!this.isGloballyIgnoredWord(lowerCaseWord)) { + this.globallyIgnoredWords.add(lowerCaseWord); + this.saveGloballyIgnoredWords(); + } + } + + // Add word to Workspace Level ignored word list. + ignoreWordInWorkspace(word: string): void { + let lowerCaseWord: string = word.toLowerCase(); + if (!this.isWorkspaceIgnoredWord(lowerCaseWord)) { + this.workspaceIgnoredWords.add(lowerCaseWord); + this.saveWorkspaceIgnoredWords(); + } + } + + // Remove word from User Level ignored word list. + removeGloballyIgnoredWord(word: string): void { + let lowerCaseWord: string = word.toLowerCase(); + if (this.isGloballyIgnoredWord(lowerCaseWord)) { + this.globallyIgnoredWords.delete(lowerCaseWord); + this.saveGloballyIgnoredWords(); + } + } + + // Remove word from Workspace Level ignored word list. + removeWorkspaceIgnoredWord(word: string): void { + let lowerCaseWord: string = word.toLowerCase(); + if (this.isWorkspaceIgnoredWord(lowerCaseWord)) { + this.workspaceIgnoredWords.delete(lowerCaseWord); + this.saveWorkspaceIgnoredWords(); + } + } + + // Show hints for ignored words? + showIgnoredWordHints(): boolean { + return this.config.get(ConfigurationManager.SETTING_HINT_IGNORED) as boolean; + } } diff --git a/src/extension.ts b/src/extension.ts index 87adc4a9..612fe794 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -104,6 +104,34 @@ export function activate(context: vscode.ExtensionContext) { linter.resetDiagnostics(); })); + // Register "Ignore Word Globally" TextEditorCommand + let ignoreWordGlobally = vscode.commands.registerTextEditorCommand("languagetoolLinter.ignoreWordGlobally", (editor, edit, ...args) => { + configMan.ignoreWordGlobally(args[0]); + linter.requestLint(editor.document, 0); + }); + context.subscriptions.push(ignoreWordGlobally); + + // Register "Ignore Word in Workspace" TextEditorCommand + let ignoreWordInWorkspace = vscode.commands.registerTextEditorCommand("languagetoolLinter.ignoreWordInWorkspace", (editor, edit, ...args) => { + configMan.ignoreWordInWorkspace(args[0]); + linter.requestLint(editor.document, 0); + }); + context.subscriptions.push(ignoreWordInWorkspace); + + // Register "Remove Globally Ignored Word" TextEditorCommand + let removeGloballyIgnoredWord = vscode.commands.registerTextEditorCommand("languagetoolLinter.removeGloballyIgnoredWord", (editor, edit, ...args) => { + configMan.removeGloballyIgnoredWord(args[0]); + linter.requestLint(editor.document, 0); + }); + context.subscriptions.push(removeGloballyIgnoredWord); + + // Register "Remove Workspace Ignored Word" TextEditorCommand + let removeWorkspaceIgnoredWord = vscode.commands.registerTextEditorCommand("languagetoolLinter.removeWorkspaceIgnoredWord", (editor, edit, ...args) => { + configMan.removeWorkspaceIgnoredWord(args[0]); + linter.requestLint(editor.document, 0); + }); + context.subscriptions.push(removeWorkspaceIgnoredWord); + // Register "Lint Current Document" TextEditorCommand let lintCommand = vscode.commands.registerTextEditorCommand("languagetoolLinter.lintCurrentDocument", (editor, edit) => { linter.requestLint(editor.document, 0); diff --git a/src/linter/linter.ts b/src/linter/linter.ts index b654b79e..464fdb86 100644 --- a/src/linter/linter.ts +++ b/src/linter/linter.ts @@ -14,10 +14,12 @@ * limitations under the License. */ -import { TextDocument, WorkspaceEdit, CodeAction, Location, Diagnostic, Position, - Range, CodeActionKind, DiagnosticSeverity, DiagnosticCollection, languages, Uri, CodeActionProvider, CodeActionContext, CancellationToken } from 'vscode'; +import { + TextDocument, WorkspaceEdit, CodeAction, Location, Diagnostic, Position, + Range, CodeActionKind, DiagnosticSeverity, DiagnosticCollection, languages, Uri, CodeActionProvider, CodeActionContext, CancellationToken, workspace +} from 'vscode'; import { ConfigurationManager } from '../common/configuration-manager'; -import { LT_TIMEOUT_MS, LT_SERVICE_PARAMETERS, LT_OUTPUT_CHANNEL, LT_DIAGNOSTIC_SOURCE, LT_DISPLAY_NAME } from '../common/constants'; +import { LT_TIMEOUT_MS, LT_OUTPUT_CHANNEL, LT_DIAGNOSTIC_SOURCE, LT_DISPLAY_NAME } from '../common/constants'; import * as rp from "request-promise-native"; import * as rehypeBuilder from "annotatedtext-rehype"; import * as remarkBuilder from "annotatedtext-remark"; @@ -128,6 +130,11 @@ export class Linter implements CodeActionProvider { return JSON.stringify(rehypeBuilder.build(text, this.rehypeBuilderOptions)); } + // Build annotatedtext from PLAINTEXT + buildAnnotatedPlaintext(text: string): string { + return JSON.stringify({ "text": text }); + } + // Perform Lint on Document lintDocument(document: TextDocument): void { if (this.configManager.isSupportedDocument(document)) { @@ -138,7 +145,8 @@ export class Linter implements CodeActionProvider { let annotatedHTML: string = this.buildAnnotatedHTML(document.getText()); this.lintAnnotatedText(document, annotatedHTML); } else { - this.lintPlaintext(document); + let annotatedPlaintext: string = this.buildAnnotatedPlaintext(document.getText()); + this.lintAnnotatedText(document, annotatedPlaintext); } } } @@ -176,15 +184,6 @@ export class Linter implements CodeActionProvider { } } - // Lint Plain Text Document - lintPlaintext(document: TextDocument): void { - if (this.configManager.isSupportedDocument(document)) { - let ltPostDataDict: any = this.getPostDataTemplate(); - ltPostDataDict["text"] = document.getText(); - this.callLanguageTool(document, ltPostDataDict); - } - } - // Lint Annotated Text lintAnnotatedText(document: TextDocument, annotatedText: string): void { if (this.configManager.isSupportedDocument(document)) { @@ -199,29 +198,101 @@ export class Linter implements CodeActionProvider { let matches = response.matches; let diagnostics: Diagnostic[] = []; let actions: CodeAction[] = []; - matches.forEach(function (match: ILanguageToolMatch) { + matches.forEach((match: ILanguageToolMatch) => { let start: Position = document.positionAt(match.offset); let end: Position = document.positionAt(match.offset + match.length); let diagnosticRange: Range = new Range(start, end); let diagnosticMessage: string = match.rule.id + ": " + match.message; let diagnostic: Diagnostic = new Diagnostic(diagnosticRange, diagnosticMessage, DiagnosticSeverity.Warning); - match.replacements.forEach(function (replacement: ILanguageToolReplacement) { - let actionTitle: string = "'" + replacement.value + "'"; + diagnostic.source = LT_DIAGNOSTIC_SOURCE; + // Spelling Rules + if (Linter.isSpellingRule(match.rule.id)) { + let spellingActions: CodeAction[] = this.getSpellingRuleActions(document, diagnostic, match); + if (spellingActions.length > 0) { + diagnostics.push(diagnostic); + spellingActions.forEach( (action) => { + actions.push(action); + }); + } + } else { + diagnostics.push(diagnostic); + this.getRuleActions(document, diagnostic, match).forEach((action) => { + actions.push(action); + }); + } + }); + this.codeActionMap.set(document.uri.toString(), actions); + this.diagnosticMap.set(document.uri.toString(), diagnostics); + this.resetDiagnostics(); + } + + private getSpellingRuleActions(document: TextDocument, diagnostic: Diagnostic, match: ILanguageToolMatch): CodeAction[] { + let actions: CodeAction[] = []; + let word: string = document.getText(diagnostic.range); + if (this.configManager.isIgnoredWord(word)) { + if (this.configManager.showIgnoredWordHints()) { + // Change severity for ignored words. + diagnostic.severity = DiagnosticSeverity.Hint; + if (this.configManager.isGloballyIgnoredWord(word)) { + let actionTitle: string = "Remove '" + word + "' from always ignored words."; + let action: CodeAction = new CodeAction(actionTitle, CodeActionKind.QuickFix); + action.command = { title: actionTitle, command: "languagetoolLinter.removeGloballyIgnoredWord", arguments: [word] }; + action.diagnostics = []; + action.diagnostics.push(diagnostic); + actions.push(action); + } + if (this.configManager.isWorkspaceIgnoredWord(word)) { + let actionTitle: string = "Remove '" + word + "' from Workspace ignored words."; + let action: CodeAction = new CodeAction(actionTitle, CodeActionKind.QuickFix); + action.command = { title: actionTitle, command: "languagetoolLinter.removeWorkspaceIgnoredWord", arguments: [word] }; + action.diagnostics = []; + action.diagnostics.push(diagnostic); + actions.push(action); + } + } + } else { + let actionTitle: string = "Always ignore '" + word + "'"; + let action: CodeAction = new CodeAction(actionTitle, CodeActionKind.QuickFix); + action.command = { title: actionTitle, command: "languagetoolLinter.ignoreWordGlobally", arguments: [word] }; + action.diagnostics = []; + action.diagnostics.push(diagnostic); + actions.push(action); + if (workspace !== undefined) { + let actionTitle: string = "Ignore '" + word + "' in Workspace"; let action: CodeAction = new CodeAction(actionTitle, CodeActionKind.QuickFix); - let location: Location = new Location(document.uri, diagnosticRange); - let edit: WorkspaceEdit = new WorkspaceEdit(); - edit.replace(document.uri, location.range, replacement.value); - action.edit = edit; + action.command = { title: actionTitle, command: "languagetoolLinter.ignoreWordInWorkspace", arguments: [word] }; action.diagnostics = []; action.diagnostics.push(diagnostic); actions.push(action); + } + this.getReplacementActions(document, diagnostic, match.replacements).forEach((action: CodeAction) => { + actions.push(action); }); - diagnostic.source = LT_DIAGNOSTIC_SOURCE; - diagnostics.push(diagnostic); + } + return actions; + } + + private getRuleActions(document: TextDocument, diagnostic: Diagnostic, match: ILanguageToolMatch): CodeAction[] { + let actions: CodeAction[] = []; + this.getReplacementActions(document, diagnostic, match.replacements).forEach((action: CodeAction) => { + actions.push(action); }); - this.codeActionMap.set(document.uri.toString(), actions); - this.diagnosticMap.set(document.uri.toString(), diagnostics); - this.resetDiagnostics(); + return actions; + } + + private getReplacementActions(document: TextDocument, diagnostic: Diagnostic, replacements: ILanguageToolReplacement[]): CodeAction[] { + let actions: CodeAction[] = []; + replacements.forEach((replacement: ILanguageToolReplacement) => { + let actionTitle: string = "'" + replacement.value + "'"; + let action: CodeAction = new CodeAction(actionTitle, CodeActionKind.QuickFix); + let edit: WorkspaceEdit = new WorkspaceEdit(); + edit.replace(document.uri, diagnostic.range, replacement.value); + action.edit = edit; + action.diagnostics = []; + action.diagnostics.push(diagnostic); + actions.push(action); + }); + return actions; } // Reset the Diagnostic Collection @@ -232,5 +303,12 @@ export class Linter implements CodeActionProvider { }); } + // Is the rule a Spelling rule? + // See: https://forum.languagetool.org/t/identify-spelling-rules/4775/3 + static isSpellingRule(ruleId: string): boolean { + return ruleId.indexOf("MORFOLOGIK_RULE") !== -1 || ruleId.indexOf("SPELLER_RULE") !== -1 + || ruleId.indexOf("HUNSPELL_NO_SUGGEST_RULE") !== -1 || ruleId.indexOf("HUNSPELL_RULE") !== -1 + || ruleId.indexOf("FR_SPELLING_RULE") !== -1; + } } diff --git a/src/test-fixtures/workspace/.vscode/settings.json b/src/test-fixtures/workspace/.vscode/settings.json new file mode 100644 index 00000000..79488171 --- /dev/null +++ b/src/test-fixtures/workspace/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "languageToolLinter.languageTool.ignoredWordsInWorkspace": [ + "misspeeled" + ], + "languageToolLinter.languageTool.disabledRules": "DASH_RULE" +} diff --git a/src/test-fixtures/workspace/markdown/spelling.md b/src/test-fixtures/workspace/markdown/spelling.md new file mode 100644 index 00000000..dbe8c4f9 --- /dev/null +++ b/src/test-fixtures/workspace/markdown/spelling.md @@ -0,0 +1,5 @@ +# Spell Check + +This is a misspeeled word. This is a second misspeeled worrd. This is a third sentence staring with This. and this sentence needs a period + +This one doesn’t. Annother misspelled word. And annother. diff --git a/src/test/runTest.ts b/src/test/runTest.ts index ca1e30b9..2c46213b 100644 --- a/src/test/runTest.ts +++ b/src/test/runTest.ts @@ -12,10 +12,10 @@ async function main() { // Passed to --extensionTestsPath const extensionTestsPath = path.resolve(__dirname, './suite/index'); - const testWorkspace = path.resolve(__dirname, '../test-fixtures/workspace'); + const testWorkspace = path.resolve(__dirname, '../../test/test-fixtures/workspace'); // Download VS Code, unzip it and run the integration test - await runTests({ extensionDevelopmentPath, extensionTestsPath }); + await runTests({ extensionDevelopmentPath, extensionTestsPath, launchArgs: [testWorkspace] }); } catch (err) { console.error('Failed to run tests'); process.exit(1); diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index e8742add..37fe565f 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -26,7 +26,13 @@ suite('Extension Test Suite', () => { test('Extension should register all commands', () => { return vscode.commands.getCommands(true).then((commands) => { - const EXPECTED_COMMANDS: string[] = ["languagetoolLinter.lintCurrentDocument","languagetoolLinter.autoFormatDocument"]; + const EXPECTED_COMMANDS: string[] = ["languagetoolLinter.lintCurrentDocument", + "languagetoolLinter.autoFormatDocument", + "languagetoolLinter.ignoreWordGlobally", + "languagetoolLinter.ignoreWordInWorkspace", + "languagetoolLinter.removeGloballyIgnoredWord", + "languagetoolLinter.removeWorkspaceIgnoredWord" + ]; const FOUND_COMMANDS = commands.filter((value) => { return EXPECTED_COMMANDS.indexOf(value)>=0 || value.startsWith('languagetoolLinter.'); });