diff --git a/package.json b/package.json index f470877840..76ff6f11ef 100644 --- a/package.json +++ b/package.json @@ -158,7 +158,7 @@ "snippets": "github:pulsar-edit/snippets#ba70705", "solarized-dark-syntax": "file:packages/solarized-dark-syntax", "solarized-light-syntax": "file:packages/solarized-light-syntax", - "spell-check": "https://codeload.github.com/atom/spell-check/legacy.tar.gz/refs/tags/v0.77.1", + "spell-check": "file:packages/spell-check", "status-bar": "file:packages/status-bar", "styleguide": "file:./packages/styleguide", "superstring": "^2.4.4", @@ -228,7 +228,7 @@ "package-generator": "file:./packages/package-generator", "settings-view": "file:./packages/settings-view", "snippets": "1.6.1", - "spell-check": "0.77.1", + "spell-check": "file:./packages/spell-check", "status-bar": "file:./packages/status-bar", "styleguide": "file:./packages/styleguide", "symbols-view": "0.118.4", diff --git a/packages/README.md b/packages/README.md index 9677d61cae..c6a466bd1d 100644 --- a/packages/README.md +++ b/packages/README.md @@ -77,7 +77,7 @@ See [RFC 003](https://github.com/atom/atom/blob/master/docs/rfcs/003-consolidate | **line-ending-selector** | [`./line-ending-selector`](./line-ending-selector) | | | **link** | [`./link`](./link) | | | **markdown-preview** | [`./markdown-preview`](./markdown-preview) | | -| **notifications** | [`atom/notifications`][notifications] | [#18277](https://github.com/atom/atom/issues/18277) | +| **notifications** | [`./notifications`](./notifications) | | | **one-dark-syntax** | [`./one-dark-syntax`](./one-dark-syntax) | | | **one-dark-ui** | [`./one-dark-ui`](./one-dark-ui) | | | **one-light-syntax** | [`./one-light-syntax`](./one-light-syntax) | | @@ -88,7 +88,7 @@ See [RFC 003](https://github.com/atom/atom/blob/master/docs/rfcs/003-consolidate | **snippets** | [`pulsar-edit/snippets`][snippets] | | | **solarized-dark-syntax** | [`./solarized-dark-syntax`](./solarized-dark-syntax) | | | **solarized-light-syntax** | [`./solarized-light-syntax`](./solarized-light-syntax) | | -| **spell-check** | [`atom/spell-check`][spell-check] | | +| **spell-check** | [`./spell-check`](./spell-check) | | | **status-bar** | [`./status-bar`](./status-bar) | | | **styleguide** | [`./styleguide`](./styleguide) | | | **symbols-view** | [`pulsar-edit/symbols-view`][symbols-view] | | @@ -101,7 +101,5 @@ See [RFC 003](https://github.com/atom/atom/blob/master/docs/rfcs/003-consolidate | **wrap-guide** | [`./wrap-guide`](./wrap-guide) | | [github]: https://github.com/pulsar-edit/github -[notifications]: https://github.com/pulsar-edit/notifications [snippets]: https://github.com/pulsar-edit/snippets -[spell-check]: https://github.com/pulsar-edit/spell-check [symbols-view]: https://github.com/pulsar-edit/symbols-view diff --git a/packages/spell-check/.editorconfig b/packages/spell-check/.editorconfig new file mode 100644 index 0000000000..e98111f982 --- /dev/null +++ b/packages/spell-check/.editorconfig @@ -0,0 +1,15 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_size = 2 +indent_style = space +insert_final_newline = true +max_line_length = 80 +tab_width = 2 +trim_trailing_whitespace = true + +[*.{js,ts,coffee}] +quote_type = single diff --git a/packages/spell-check/.gitignore b/packages/spell-check/.gitignore new file mode 100644 index 0000000000..dce220447b --- /dev/null +++ b/packages/spell-check/.gitignore @@ -0,0 +1,5 @@ +*~ +npm-debug.log +node_modules +.DS_Store +package-lock.json diff --git a/packages/spell-check/.prettierignore b/packages/spell-check/.prettierignore new file mode 100644 index 0000000000..9a80b65821 --- /dev/null +++ b/packages/spell-check/.prettierignore @@ -0,0 +1,8 @@ +.cache +package.json +package-lock.json +public +__generated__ +gen +apollo.config.js +schema.json diff --git a/packages/spell-check/.prettierrc b/packages/spell-check/.prettierrc new file mode 100644 index 0000000000..d0b55e5ee9 --- /dev/null +++ b/packages/spell-check/.prettierrc @@ -0,0 +1,7 @@ +{ + "endOfLine": "lf", + "semi": true, + "singleQuote": true, + "tabWidth": 4, + "trailingComma": "es5" +} diff --git a/packages/spell-check/CONTRIBUTING.md b/packages/spell-check/CONTRIBUTING.md new file mode 100644 index 0000000000..9c8ac3e5b5 --- /dev/null +++ b/packages/spell-check/CONTRIBUTING.md @@ -0,0 +1 @@ +[See how you can contribute](https://github.com/pulsar-edit/.github/blob/main/CONTRIBUTING.md) diff --git a/packages/spell-check/DEVELOP.md b/packages/spell-check/DEVELOP.md new file mode 100644 index 0000000000..e106c3f880 --- /dev/null +++ b/packages/spell-check/DEVELOP.md @@ -0,0 +1,15 @@ +Here are additional details for debugging for working with the library. + +## Plugins + +_Spell Check_ allows for plugins to provide additional spell checking functionality. See the `PLUGINS.md` file in the repository on how to write a plugin. + +## Debugging + +Debugging messages for this library can be enabled by going into the developer console and running the following: + +``` +localStorage.debug = 'spell-check:*' +``` + +A reload of the window may be required. diff --git a/packages/spell-check/LICENSE.md b/packages/spell-check/LICENSE.md new file mode 100644 index 0000000000..4d231b4563 --- /dev/null +++ b/packages/spell-check/LICENSE.md @@ -0,0 +1,20 @@ +Copyright (c) 2014 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/spell-check/PLUGINS.md b/packages/spell-check/PLUGINS.md new file mode 100644 index 0000000000..d270a53d16 --- /dev/null +++ b/packages/spell-check/PLUGINS.md @@ -0,0 +1,69 @@ +# Plugins + +The `spell-check` allows for additional dictionaries to be used at the same time using Atom's `providedServices` element in the `package.json` file. + + "providedServices": { + "spell-check": { + "versions": { + "1.0.0": "nameOfFunctionToProvideSpellCheck" + } + } + } + +The `nameOfFunctionToProvideSpellCheck` function may return either a single `require`able path or an array of them. This must be an absolute path to a class that provides a checker instance (below). + + provideSpellCheck: -> + require.resolve './project-checker' + +The path given must either resolve to a singleton instance of a class or a default export in a ES6 module. + + class ProjectChecker + # Magical code + checker = new ProjectChecker() + module.exports = checker + +For a default using Typescript: + + export default class ProjectChecker {} + +See the `spell-check-project` for an example implementation. + +# Checker + +A common parameter type is `checkArgs`, this is a hash with the following signature. + + args = { + projectPath: "/absolute/path/to/project/root, + relativePath: "relative/path/from/project/root" + } + +Below the required methods for the checker instance. + +* getId(): string + * This returns the canonical identifier for this plugin. Typically, this will be the package name with an optional suffix for options, such as `spell-check-project` or `spell-check:en-US`. This identifier will be used for some control plugins (such as `spell-check-project`) to enable or disable the plugin. + * This will also used to pass information from the Atom process into the background task once that is implemented. +* getPriority(): number + * Determines how significant the plugin is for information with lower numbers being more important. Typically, user-entered data (such as the config `knownWords` configuration or a project's dictionary) will be lower than system data (priority 100). +* isEnabled(): boolean + * If this returns true, then the plugin will considered for processing. +* providesSpelling(checkArgs): boolean + * If this returns true, then the plugin will be included when looking for incorrect and correct words via the `check` function. +* check(checkArgs, text: string): [results] + * This takes the entire text buffer and will be called once per buffer. + * The output is an array with three parameters, all optional: `{ invertIncorrectAsCorrect: true, incorrect: [ranges], correct: [ranges] }` + * The ranges are a zero-based index of a start and stop character (`[1, 23]`). + * `invertIncorrectAsCorrect` means take the incorrect range and assume everything not in this list is correct. + * Correct words always take precedence, even if another checker indicates a word is incorrect. + * If a word or character is neither correct or incorrect, it is considered correct. +* providesSuggestions(checkArgs): boolean + * If this returns true, then the plugin will be included when querying for suggested words via the `suggest` function. +* suggest(checkArgs, word: string): [suggestion: string] + * Returns a list of suggestions for a given word ordered so the most important is at the beginning of the list. +* providesAdding(checkArgs): boolean + * If this returns true, then the dictionary allows a word to be added to the dictionary. +* getAddingTargets(checkArgs): [target] + * Gets a list of targets to show to the user. + * The `target` object has a minimum signature of `{ label: stringToShowTheUser }`. For example, `{ label: "Ignore word (case-sensitive)" }`. + * This is a list to allow plugins to have multiple options, such as adding it as a case-sensitive or insensitive, temporary verses configuration, etc. +* add(buffer, target, word) + * Adds a word to the dictionary, using the target for identifying which one is used. diff --git a/packages/spell-check/README.md b/packages/spell-check/README.md new file mode 100644 index 0000000000..76360050f9 --- /dev/null +++ b/packages/spell-check/README.md @@ -0,0 +1,123 @@ +# Spell Check package + +Highlights misspelling in Pulsar and shows possible corrections. + +Use cmd-shift-: for Mac or ctrl-shift-: for Windows or Linux to bring up the list of corrections when your cursor is on a misspelled word. + +By default spell check is enabled for the following files: + +* Plain Text +* GitHub Markdown +* Git Commit Message +* AsciiDoc +* reStructuredText + +You can override this from the _Spell Check_ settings in the Settings View (cmd-,). The Grammars config option is a list of scopes for which the package will check for spelling errors. + +To enable _Spell Check_ for your current file type: put your cursor in the file, open the [Command Palette](https://github.com/pulsar-edit/command-palette) +(cmd-shift-p for Mac or ctrl-shift-p for Windows or Linux), and run the `Editor: Log Cursor Scope` command. This will trigger a notification which will contain a list of scopes. The first scope that's listed is the one you should add to the list of scopes in the settings for the _Spell Check_ package. Here are some examples: `source.coffee`, `text.plain`, `text.html.basic`. + +## Changing the dictionary + +Except for Mac, Pulsar needs to know what language to use to perform spell-checking. To list these, set the "Locales" configuration option to the [IETF tag](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry) (en-US, fr-FR, etc). More than one language can be used, simply separate them by commas. + +If no locale is given, then Pulsar will attempt to infer the language based on environment variables and settings. + +If any value is given for the "Locales", then Pulsar will not automatically add the browser language. So, if your browser is United States English (`en-US`), leaving this blank will still do US English checking. However, if it the "Locales" is set to French (`fr-FR`), then the checker will only check French. If the "Locales" is set to `en-US, fr-FR`, then both languages will be checked. + +### Missing Languages + +This plugin uses the existing system dictionaries. If a locale is selected that is not installed, a warning will pop up when a document that would be spell-checked is loaded. To disable this, either remove the incorrect language from the "Locales" configuration or clear the check on "Use Locales" to disable it entirely. + +To get the search paths used to look for a dictionary, make sure the "Notices Mode" is set to "console" or "both", then reload Atom. The developer's console will have the directory list. + +## Mac + +On the Mac, checking "Use System" will use the operating system's spellchecking library. This uses all of the user's loaded dictionaries and doesn't require any customization within Pulsar. + +Checking "Use Locales" and providing locales would use Hunspell as additional dictionaries. Having "Use Locales" checked but no locales given will do nothing. + +## Windows 8 and Higher + +For Windows 8 and 10, this package uses the Windows spell checker, so you must install the language using the regional settings before the language can be chosen inside Pulsar. + +![Add the language from the Language and Regions settings panel](docs/windows-10-language-settings.png) + +If your Windows user does not have Administration privileges, you'll need to do an extra step once the language has been added to enable the spell checker. To do so, you need to install the "Basic typing" language option by following the next steps (you'll be asked for your administrator password): + +![Click on the "Options" button on the added language](docs/windows-10-language-settings-2.png) + +![Download the "Basic Typing" language option](docs/windows-10-language-settings-3.png) + +Once the additional language is added, Pulsar will need to be restarted and configured to use it. Add the IEFT tag into the "Locales" setting for the language to be set. + +If a Hunspell dictionary is found on a path (see below), it will be used in favor of the Windows API. + +## Linux + +For all Linux-based operating systems, "Use System" does nothing. It can remained checked but has no impact. "Use Locales" is required for spell-checking. + +### Debian, Ubuntu, and Mint + +On Ubuntu, installing "Language Support" may solve problems with the dictionaries. For other distributions (or if Language Support doesn't work), you may use `apt` to install the dictionaries. + +``` +sudo apt-get install hunspell-en-gb +sudo apt-get install myspell-en-gb +``` + +On RedHat, the following should work for Italian: + +``` +sudo dnf install hunspell +sudo dnf install hunspell-it +``` + +You can get a list of currently installed languages with: + +``` +/usr/bin/hunspell -D +``` + +Pulsar may require a restart to pick up newly installed dictionaries. + +### Arch Linux + +A language may be installed by running: + +``` +pacman -S hunspell-en_GB +``` + +For the time being, a soft link may be required if the dictionary provided is "large". + +``` +cd /usr/share/hunspell +sudo ln -s en_GB-large.dic en_GB.dic +sudo ln -s en_GB-large.aff en_GB.aff +``` + +## Hunspell Dictionaries + +For all platforms, a Hunspell-compatible dictionary is also supported. To use this, a `.dic` and `.aff` need to be located in one of the default search directories or in a directory entered into "Locale paths" (multiples may be entered with commas separating them). If the appropriate files are found for the locale and "Use Locales" is checked, then the dictionary will be used. + +For example, if the following is set, then `/usr/share/hunspell/en_US.dic` will be used: + +- Use Locales: checked +- Locales: `en-US` +- Locale Paths: `/usr/share/hunspell` + +If "Locales" is not provided, then the user's current language will be inferred from environmental settings. + +In addition to what is provided, the following paths are checked: + +- `/usr/share/hunspell` (Linux only) +- `/usr/share/myspell` (Linux only) +- `/usr/share/myspell/dicts` (Linux only) +- `/` (Mac only) +- `/System/Library/Spelling` (Mac only) +- `C:\` (Windows only) + +Dictionaries can be downloaded from various sites (such as [wooorm's repository](https://github.com/wooorm/dictionaries) or [LibreOffice's](https://github.com/LibreOffice/dictionaries)), but the file has to be renamed `locale.dic` and `locale.aff`. + +*Example locations to download are not an endorsement.* diff --git a/packages/spell-check/docs/windows-10-language-settings-2.png b/packages/spell-check/docs/windows-10-language-settings-2.png new file mode 100644 index 0000000000..82cc3897d7 Binary files /dev/null and b/packages/spell-check/docs/windows-10-language-settings-2.png differ diff --git a/packages/spell-check/docs/windows-10-language-settings-3.png b/packages/spell-check/docs/windows-10-language-settings-3.png new file mode 100644 index 0000000000..3ec5f9fe3b Binary files /dev/null and b/packages/spell-check/docs/windows-10-language-settings-3.png differ diff --git a/packages/spell-check/docs/windows-10-language-settings.png b/packages/spell-check/docs/windows-10-language-settings.png new file mode 100644 index 0000000000..e7eb79f4a0 Binary files /dev/null and b/packages/spell-check/docs/windows-10-language-settings.png differ diff --git a/packages/spell-check/keymaps/spell-check.cson b/packages/spell-check/keymaps/spell-check.cson new file mode 100644 index 0000000000..35165cf997 --- /dev/null +++ b/packages/spell-check/keymaps/spell-check.cson @@ -0,0 +1,21 @@ +'.platform-darwin atom-text-editor': + 'cmd-:': 'spell-check:correct-misspelling' + +'.platform-darwin .corrections atom-text-editor': + 'cmd-:': 'core:cancel' + +'.platform-win32 atom-text-editor': + 'ctrl-:': 'spell-check:correct-misspelling' + +'.platform-win32 .corrections atom-text-editor': + 'ctrl-:': 'core:cancel' + +'.platform-linux atom-text-editor': + 'ctrl-:': 'spell-check:correct-misspelling' + +'.platform-linux .corrections atom-text-editor': + 'ctrl-:': 'core:cancel' + +'.corrections atom-text-editor[mini]': + 'enter': 'core:confirm' + 'tab': 'core:confirm' diff --git a/packages/spell-check/lib/checker-env.js b/packages/spell-check/lib/checker-env.js new file mode 100644 index 0000000000..23a404b2ff --- /dev/null +++ b/packages/spell-check/lib/checker-env.js @@ -0,0 +1,25 @@ +module.exports = { + isLinux() { + return /linux/.test(process.platform); + }, + isWindows() { + return /win32/.test(process.platform); + }, // TODO: Windows < 8 or >= 8 + isDarwin() { + return /darwin/.test(process.platform); + }, + preferHunspell() { + return !!process.env.SPELLCHECKER_PREFER_HUNSPELL; + }, + + isSystemSupported() { + return this.isWindows() || this.isDarwin(); + }, + isLocaleSupported() { + return true; + }, + + useLocales() { + return this.isLinux() || this.isWindows() || this.preferHunspell(); + }, +}; diff --git a/packages/spell-check/lib/corrections-view.js b/packages/spell-check/lib/corrections-view.js new file mode 100644 index 0000000000..ba6a1348a1 --- /dev/null +++ b/packages/spell-check/lib/corrections-view.js @@ -0,0 +1,102 @@ +/** @babel */ + +import SelectListView from 'atom-select-list'; + +export default class CorrectionsView { + constructor(editor, corrections, marker, updateTarget, updateCallback) { + this.editor = editor; + this.corrections = corrections; + this.marker = marker; + this.updateTarget = updateTarget; + this.updateCallback = updateCallback; + this.selectListView = new SelectListView({ + emptyMessage: 'No corrections', + items: this.corrections, + filterKeyForItem: (item) => item.label, + elementForItem: (item) => { + const element = document.createElement('li'); + if (item.isSuggestion) { + // This is a word replacement suggestion. + element.textContent = item.label; + } else { + // This is an operation such as add word. + const em = document.createElement('em'); + em.textContent = item.label; + element.appendChild(em); + } + return element; + }, + didConfirmSelection: (item) => { + this.editor.transact(() => { + if (item.isSuggestion) { + // Update the buffer with the correction. + this.editor.setSelectedBufferRange( + this.marker.getBufferRange() + ); + this.editor.insertText(item.suggestion); + } else { + // Build up the arguments object for this buffer and text. + let projectPath = null; + let relativePath = null; + if ( + this.editor && + this.editor.buffer && + this.editor.buffer.file && + this.editor.buffer.file.path + ) { + [ + projectPath, + relativePath, + ] = atom.project.relativizePath( + this.editor.buffer.file.path + ); + } + + const args = { id: this.id, projectPath, relativePath }; + // Send the "add" request to the plugin. + item.plugin.add(args, item); + // Update the buffer to handle the corrections. + this.updateCallback.bind(this.updateTarget)(); + } + }); + this.destroy(); + }, + didConfirmEmptySelection: () => { + this.destroy(); + }, + didCancelSelection: () => { + this.destroy(); + }, + }); + this.selectListView.element.classList.add( + 'spell-check-corrections', + 'corrections', + 'popover-list' + ); + } + + attach() { + this.previouslyFocusedElement = document.activeElement; + this.overlayDecoration = this.editor.decorateMarker(this.marker, { + type: 'overlay', + item: this.selectListView, + }); + process.nextTick(() => { + atom.views.readDocument(() => { + this.selectListView.focus(); + }); + }); + } + + async destroy() { + if (!this.destroyed) { + this.destroyed = true; + this.overlayDecoration.destroy(); + await this.selectListView.destroy(); + if (this.previouslyFocusedElement) { + this.previouslyFocusedElement.focus(); + this.previouslyFocusedElement = null; + } + } + } +} diff --git a/packages/spell-check/lib/known-words-checker.js b/packages/spell-check/lib/known-words-checker.js new file mode 100644 index 0000000000..757eaf0c4c --- /dev/null +++ b/packages/spell-check/lib/known-words-checker.js @@ -0,0 +1,93 @@ +class KnownWordsChecker { + static initClass() { + this.prototype.enableAdd = false; + this.prototype.spelling = null; + this.prototype.checker = null; + } + + constructor(knownWords) { + // Set up the spelling manager we'll be using. + const spellingManager = require('spelling-manager'); + this.spelling = new spellingManager.TokenSpellingManager(); + this.checker = new spellingManager.BufferSpellingChecker(this.spelling); + + // Set our known words. + this.setKnownWords(knownWords); + } + + deactivate() {} + + getId() { + return 'spell-check:known-words'; + } + getName() { + return 'Known Words'; + } + getPriority() { + return 10; + } + isEnabled() { + return this.spelling.sensitive || this.spelling.insensitive; + } + + getStatus() { + return 'Working correctly.'; + } + providesSpelling(args) { + return true; + } + providesSuggestions(args) { + return true; + } + providesAdding(args) { + return this.enableAdd; + } + + check(args, text) { + const ranges = []; + const checked = this.checker.check(text); + const id = this.getId(); + for (let token of checked) { + if (token.status === 1) { + ranges.push({ start: token.start, end: token.end }); + } + } + return { id, correct: ranges }; + } + + suggest(args, word) { + return this.spelling.suggest(word); + } + + getAddingTargets(args) { + if (this.enableAdd) { + return [{ sensitive: false, label: 'Add to ' + this.getName() }]; + } else { + return []; + } + } + + add(args, target) { + const c = atom.config.get('spell-check.knownWords'); + c.push(target.word); + return atom.config.set('spell-check.knownWords', c); + } + + setAddKnownWords(newValue) { + return (this.enableAdd = newValue); + } + + setKnownWords(knownWords) { + // Clear out the old list. + this.spelling.sensitive = {}; + this.spelling.insensitive = {}; + + // Add the new ones into the list. + if (knownWords) { + return knownWords.map((ignore) => this.spelling.add(ignore)); + } + } +} +KnownWordsChecker.initClass(); + +module.exports = KnownWordsChecker; diff --git a/packages/spell-check/lib/locale-checker.js b/packages/spell-check/lib/locale-checker.js new file mode 100644 index 0000000000..75c66d4117 --- /dev/null +++ b/packages/spell-check/lib/locale-checker.js @@ -0,0 +1,228 @@ +const spellchecker = require('spellchecker'); +const pathspec = require('atom-pathspec'); +const env = require('./checker-env'); + +// The locale checker is a checker that takes a locale string (`en-US`) and +// optionally a path and then checks it. +class LocaleChecker { + static initClass() { + this.prototype.spellchecker = null; + this.prototype.locale = null; + this.prototype.enabled = true; + this.prototype.reason = null; + this.prototype.paths = null; + this.prototype.checkDictionaryPath = true; + this.prototype.checkDefaultPaths = true; + } + + constructor(locale, paths, hasSystemChecker, inferredLocale) { + this.locale = locale; + this.paths = paths; + this.enabled = true; + this.hasSystemChecker = hasSystemChecker; + this.inferredLocale = inferredLocale; + if (atom.config.get('spell-check.enableDebug')) { + debug = require('debug'); + this.log = debug('spell-check:locale-checker').extend(locale); + } else { + this.log = (str) => {}; + } + this.log( + 'enabled', + this.isEnabled(), + 'hasSystemChecker', + this.hasSystemChecker, + 'inferredLocale', + this.inferredLocale + ); + } + + deactivate() {} + + getId() { + return ( + 'spell-check:locale:' + this.locale.toLowerCase().replace('_', '-') + ); + } + getName() { + return 'Locale Dictionary (' + this.locale + ')'; + } + getPriority() { + return 100; + } // Hard-coded system level data, has no user input. + isEnabled() { + return this.enabled; + } + getStatus() { + return this.reason; + } + providesSpelling(args) { + return this.enabled; + } + providesSuggestions(args) { + return this.enabled; + } + providesAdding(args) { + return false; + } + + check(args, text) { + this.deferredInit(); + const id = this.getId(); + if (this.enabled) { + return this.spellchecker + .checkSpellingAsync(text) + .then((incorrect) => { + if (this.log.enabled) { + this.log('check', incorrect); + } + return { id, invertIncorrectAsCorrect: true, incorrect }; + }); + } else { + return { id, status: this.getStatus() }; + } + } + + suggest(args, word) { + this.deferredInit(); + return this.spellchecker.getCorrectionsForMisspelling(word); + } + + deferredInit() { + // If we already have a spellchecker, then we don't have to do anything. + let path; + if (this.spellchecker) { + return; + } + + // Initialize the spell checker which can take some time. We also force + // the use of the Hunspell library even on Mac OS X. The "system checker" + // is the one that uses the built-in dictionaries from the operating system. + const checker = new spellchecker.Spellchecker(); + checker.setSpellcheckerType(spellchecker.ALWAYS_USE_HUNSPELL); + + // Build up a list of paths we are checking so we can report them fully + // to the user if we fail. + const searchPaths = []; + for (path of this.paths) { + searchPaths.push(pathspec.getPath(path)); + } + + // Add operating system specific paths to the search list. + if (this.checkDefaultPaths) { + if (env.isLinux()) { + searchPaths.push('/usr/share/hunspell'); + searchPaths.push('/usr/share/myspell'); + searchPaths.push('/usr/share/myspell/dicts'); + } + + if (env.isDarwin()) { + searchPaths.push('/'); + searchPaths.push('/System/Library/Spelling'); + } + + if (env.isWindows()) { + searchPaths.push('C:\\'); + } + } + + // Attempt to load all the paths for the dictionary until we find one. + this.log('checking paths', searchPaths); + for (path of searchPaths) { + if (checker.setDictionary(this.locale, path)) { + this.log('found checker', path); + this.spellchecker = checker; + return; + } + } + + // On Windows, if we can't find the dictionary using the paths, then we also + // try the spelling API. This uses system checker with the given locale, but + // doesn't provide a path. We do this at the end to let Hunspell be used if + // the user provides that. + if (env.isWindows()) { + const systemChecker = new spellchecker.Spellchecker(); + systemChecker.setSpellcheckerType(spellchecker.ALWAYS_USE_SYSTEM); + if (systemChecker.setDictionary(this.locale, '')) { + this.log('using Windows Spell API'); + this.spellchecker = systemChecker; + return; + } + } + + // If all else fails, try the packaged en-US dictionary in the `spellcheck` + // library. + if (this.checkDictionaryPath) { + if ( + checker.setDictionary( + this.locale, + spellchecker.getDictionaryPath() + ) + ) { + this.log('using packaged locale', path); + this.spellchecker = checker; + return; + } + } + + // If we are using the system checker and we infered the locale, then we + // don't want to show an error. This is because the system checker may have + // handled it already. + if (this.hasSystemChecker && this.inferredLocale) { + this.log( + 'giving up quietly because of system checker and inferred locale' + ); + this.enabled = false; + this.reason = + 'Cannot load the locale dictionary for `' + + this.locale + + '`. No warning because system checker is in use and locale is inferred.'; + return; + } + + // If we fell through all the if blocks, then we couldn't load the dictionary. + this.enabled = false; + this.reason = + 'Cannot load the locale dictionary for `' + this.locale + '`.'; + const message = + 'The package `spell-check` cannot load the ' + + 'checker for `' + + this.locale + + '`.' + + ' See the settings for ways of changing the languages used, ' + + ' resolving missing dictionaries, or hiding this warning.'; + + let searches = + '\n\nThe plugin checked the following paths for dictionary files:\n* ' + + searchPaths.join('\n* '); + + if (!env.useLocales()) { + searches = + '\n\nThe plugin tried to use the system dictionaries to find the locale.'; + } + + const noticesMode = atom.config.get('spell-check.noticesMode'); + + if (noticesMode === 'console' || noticesMode === 'both') { + console.log(this.getId(), message + searches); + } + if (noticesMode === 'popup' || noticesMode === 'both') { + return atom.notifications.addWarning(message, { + buttons: [ + { + className: 'btn', + onDidClick() { + return atom.workspace.open( + 'atom://config/packages/spell-check' + ); + }, + text: 'Settings', + }, + ], + }); + } + } +} +LocaleChecker.initClass(); + +module.exports = LocaleChecker; diff --git a/packages/spell-check/lib/main.js b/packages/spell-check/lib/main.js new file mode 100644 index 0000000000..df66c98767 --- /dev/null +++ b/packages/spell-check/lib/main.js @@ -0,0 +1,281 @@ +const { CompositeDisposable } = require('atom'); + +let SpellCheckView = null; +let spellCheckViews = {}; + +const LARGE_FILE_SIZE = 2 * 1024 * 1024; + +let log = (str) => {}; + +module.exports = { + activate() { + if (atom.config.get('spell-check.enableDebug')) { + debug = require('debug'); + log = debug('spell-check'); + } + + log('initializing'); + + this.subs = new CompositeDisposable(); + + // Since the spell-checking is done on another process, we gather up all the + // arguments and pass them into the task. Whenever these change, we'll update + // the object with the parameters and resend it to the task. + this.globalArgs = { + // These are the settings that are part of the main `spell-check` package. + locales: atom.config.get('spell-check.locales'), + localePaths: atom.config.get('spell-check.localePaths'), + useSystem: atom.config.get('spell-check.useSystem'), + useLocales: atom.config.get('spell-check.useLocales'), + knownWords: atom.config.get('spell-check.knownWords'), + addKnownWords: atom.config.get('spell-check.addKnownWords'), + + // Collection of all the absolute paths to checkers which will be + // `require` on the process side to load the checker. We have to do this + // because we can't pass the actual objects from the main Atom process to + // the background safely. + checkerPaths: [], + }; + + const manager = this.getInstance(this.globalArgs); + + // Hook up changes to the configuration settings. + this.excludedScopeRegexLists = []; + this.subs.add( + atom.config.observe( + 'spell-check.excludedScopes', + (excludedScopes) => { + this.excludedScopeRegexLists = excludedScopes.map( + (excludedScope) => + excludedScope + .split(/\s+/)[0] + .split('.') + .filter((className) => className) + .map( + (className) => + new RegExp(`\\b${className}\\b`) + ) + ); + return this.updateViews(); + } + ) + ); + + this.subs.add( + atom.config.onDidChange( + 'spell-check.locales', + ({ newValue, oldValue }) => { + this.globalArgs.locales = newValue; + return manager.setGlobalArgs(this.globalArgs); + } + ) + ); + this.subs.add( + atom.config.onDidChange( + 'spell-check.localePaths', + ({ newValue, oldValue }) => { + this.globalArgs.localePaths = newValue; + return manager.setGlobalArgs(this.globalArgs); + } + ) + ); + this.subs.add( + atom.config.onDidChange( + 'spell-check.useSystem', + ({ newValue, oldValue }) => { + this.globalArgs.useSystem = newValue; + return manager.setGlobalArgs(this.globalArgs); + } + ) + ); + this.subs.add( + atom.config.onDidChange( + 'spell-check.useLocales', + ({ newValue, oldValue }) => { + this.globalArgs.useLocales = newValue; + return manager.setGlobalArgs(this.globalArgs); + } + ) + ); + this.subs.add( + atom.config.onDidChange( + 'spell-check.knownWords', + ({ newValue, oldValue }) => { + this.globalArgs.knownWords = newValue; + return manager.setGlobalArgs(this.globalArgs); + } + ) + ); + this.subs.add( + atom.config.onDidChange( + 'spell-check.addKnownWords', + ({ newValue, oldValue }) => { + this.globalArgs.addKnownWords = newValue; + return manager.setGlobalArgs(this.globalArgs); + } + ) + ); + + // Hook up the UI and processing. + this.subs.add( + atom.commands.add('atom-workspace', { + 'spell-check:toggle': () => this.toggle(), + }) + ); + this.viewsByEditor = new WeakMap(); + this.contextMenuEntries = []; + return this.subs.add( + atom.workspace.observeTextEditors((editor) => { + if (this.viewsByEditor.has(editor)) { + return; + } + + // For now, just don't spell check large files. + if (editor.getBuffer().getLength() > LARGE_FILE_SIZE) { + return; + } + + // Defer loading the spell check view if we actually need it. This also + // avoids slowing down Atom's startup by getting it loaded on demand. + if (SpellCheckView == null) { + SpellCheckView = require('./spell-check-view'); + } + + // The SpellCheckView needs both a handle for the task to handle the + // background checking and a cached view of the in-process manager for + // getting corrections. We used a function to a function because scope + // wasn't working properly. + // Each view also needs the list of added context menu entries so that + // they can dispose old corrections which were not created by the current + // active editor. A reference to this entire module is passed right now + // because a direct reference to @contextMenuEntries wasn't updating + // properly between different SpellCheckView's. + const spellCheckView = new SpellCheckView( + editor, + this, + manager + ); + + // save the {editor} into a map + const editorId = editor.id; + spellCheckViews[editorId] = { + view: spellCheckView, + active: true, + editor, + }; + + // Make sure that the view is cleaned up on editor destruction. + var destroySub = editor.onDidDestroy(() => { + spellCheckView.destroy(); + delete spellCheckViews[editorId]; + return this.subs.remove(destroySub); + }); + this.subs.add(destroySub); + + return this.viewsByEditor.set(editor, spellCheckView); + }) + ); + }, + + deactivate() { + if (this.instance != null) { + this.instance.deactivate(); + } + this.instance = null; + + // Clear out the known views. + for (let editorId in spellCheckViews) { + const { view } = spellCheckViews[editorId]; + view.destroy(); + } + spellCheckViews = {}; + + // While we have WeakMap.clear, it isn't a function available in ES6. So, we + // just replace the WeakMap entirely and let the system release the objects. + this.viewsByEditor = new WeakMap(); + + // Finish up by disposing everything else associated with the plugin. + return this.subs.dispose(); + }, + + // Registers any Atom packages that provide our service. + consumeSpellCheckers(checkerPaths) { + // Normalize it so we always have an array. + if (!(checkerPaths instanceof Array)) { + checkerPaths = [checkerPaths]; + } + + // Go through and add any new plugins to the list. + return (() => { + const result = []; + for (let checkerPath of checkerPaths) { + if (!this.globalArgs.checkerPaths.includes(checkerPath)) { + if (this.instance != null) { + this.instance.addCheckerPath(checkerPath); + } + result.push(this.globalArgs.checkerPaths.push(checkerPath)); + } else { + result.push(undefined); + } + } + return result; + })(); + }, + + misspellingMarkersForEditor(editor) { + return this.viewsByEditor.get(editor).markerLayer.getMarkers(); + }, + + updateViews() { + return (() => { + const result = []; + for (let editorId in spellCheckViews) { + const view = spellCheckViews[editorId]; + if (view['active']) { + result.push(view['view'].updateMisspellings()); + } else { + result.push(undefined); + } + } + return result; + })(); + }, + + // Retrieves, creating if required, the single SpellingManager instance. + getInstance(globalArgs) { + if (!this.instance) { + const SpellCheckerManager = require('./spell-check-manager'); + this.instance = SpellCheckerManager; + this.instance.setGlobalArgs(globalArgs); + + for (let checkerPath of globalArgs.checkerPaths) { + this.instance.addCheckerPath(checkerPath); + } + } + + return this.instance; + }, + + // Internal: Toggles the spell-check activation state. + toggle() { + if (!atom.workspace.getActiveTextEditor()) { + return; + } + const editorId = atom.workspace.getActiveTextEditor().id; + + if (!spellCheckViews.hasOwnProperty(editorId)) { + // The editor was never registered with a view, ignore it + return; + } + + if (spellCheckViews[editorId]['active']) { + // deactivate spell check for this {editor} + spellCheckViews[editorId]['active'] = false; + return spellCheckViews[editorId]['view'].unsubscribeFromBuffer(); + } else { + // activate spell check for this {editor} + spellCheckViews[editorId]['active'] = true; + return spellCheckViews[editorId]['view'].subscribeToBuffer(); + } + }, +}; diff --git a/packages/spell-check/lib/spell-check-manager.js b/packages/spell-check/lib/spell-check-manager.js new file mode 100644 index 0000000000..2c50c3e551 --- /dev/null +++ b/packages/spell-check/lib/spell-check-manager.js @@ -0,0 +1,544 @@ +const env = require('./checker-env'); + +class SpellCheckerManager { + static initClass() { + this.prototype.checkers = []; + this.prototype.checkerPaths = []; + this.prototype.locales = []; + this.prototype.localePaths = []; + this.prototype.useLocales = false; + this.prototype.systemChecker = null; + this.prototype.knownWordsChecker = null; + this.prototype.localeCheckers = null; + this.prototype.knownWords = []; + this.prototype.addKnownWords = false; + } + + setGlobalArgs(data) { + // We need underscore to do the array comparisons. + const _ = require('underscore-plus'); + + // Check to see if any values have changed. When they have, then clear out + // the applicable checker which forces a reload. We have three basic + // checkers that are packaged in this: + // - system: Used for the built-in checkers for Windows and Mac + // - knownWords: For a configuration-based collection of known words + // - locale: For linux or when SPELLCHECKER_PREFER_HUNSPELL is set + + // Handle known words checker. + let removeKnownWordsChecker = false; + + if (!_.isEqual(this.knownWords, data.knownWords)) { + this.knownWords = data.knownWords; + removeKnownWordsChecker = true; + } + if (this.addKnownWords !== data.addKnownWords) { + this.addKnownWords = data.addKnownWords; + removeKnownWordsChecker = true; + } + + if (removeKnownWordsChecker && this.knownWordsChecker) { + this.removeSpellChecker(this.knownWordsChecker); + this.knownWordsChecker = null; + } + + // Handle system checker. We also will remove the locale checkers if we + // change the system checker because we show different messages if we cannot + // find a locale based on the use of the system checker. + let removeSystemChecker = false; + let removeLocaleCheckers = false; + + if (this.useSystem !== data.useSystem) { + this.useSystem = data.useSystem; + removeSystemChecker = true; + removeLocaleCheckers = true; + } + + if (removeSystemChecker && this.systemChecker) { + this.removeSpellChecker(this.systemChecker); + this.systemChecker = undefined; + } + + // Handle locale checkers. + if (!_.isEqual(this.locales, data.locales)) { + // If the locales is blank, then we always create a default one. However, + // any new data.locales will remain blank. + if ( + !this.localeCheckers || + (data.locales != null ? data.locales.length : undefined) !== 0 + ) { + this.locales = data.locales; + removeLocaleCheckers = true; + } + } + if (!_.isEqual(this.localePaths, data.localePaths)) { + this.localePaths = data.localePaths; + removeLocaleCheckers = true; + } + if (this.useLocales !== data.useLocales) { + this.useLocales = data.useLocales; + removeLocaleCheckers = true; + } + + if (removeLocaleCheckers && this.localeCheckers) { + const checkers = this.localeCheckers; + for (let checker of checkers) { + this.removeSpellChecker(checker); + } + return (this.localeCheckers = null); + } + } + + addCheckerPath(checkerPath) { + // Load the given path via require. + let checker = require(checkerPath); + + // If this a ES6 module, then we need to construct it. We require + // the coders to export it as `default` since we don't have another + // way of figuring out which object to instantiate. + if (checker.default) { + checker = new checker.default(); + } + + // Add in the resulting checker. + return this.addPluginChecker(checker); + } + + addPluginChecker(checker) { + // Add the spell checker to the list. + return this.addSpellChecker(checker); + } + + addSpellChecker(checker) { + return this.checkers.push(checker); + } + + removeSpellChecker(spellChecker) { + return (this.checkers = this.checkers.filter( + (plugin) => plugin !== spellChecker + )); + } + + check(args, text) { + // Make sure our deferred initialization is done. + this.init(); + + // We need a couple packages but we want to lazy load them to + // reduce load time. + const multirange = require('multi-integer-range'); + + // For every registered spellchecker, we need to find out the ranges in the + // text that the checker confirms are correct or indicates is a misspelling. + // We keep these as separate lists since the different checkers may indicate + // the same range for either and we need to be able to remove confirmed words + // from the misspelled ones. + const correct = new multirange.MultiRange([]); + const incorrects = []; + const promises = []; + + for (let checker of this.checkers) { + // We only care if this plugin contributes to checking spelling. + if (!checker.isEnabled() || !checker.providesSpelling(args)) { + continue; + } + + // Get the possibly asynchronous results which include positive + // (correct) and negative (incorrect) ranges. If we have an incorrect + // range but no correct, everything not in incorrect is considered correct. + promises.push(Promise.resolve(checker.check(args, text))); + } + + return Promise.all(promises).then((allResults) => { + let range; + if (this.log.enabled) { + this.log('check results', allResults, text); + } + + for (let results of allResults) { + if (results.invertIncorrectAsCorrect && results.incorrect) { + // We need to add the opposite of the incorrect as correct elements in + // the list. We do this by creating a subtraction. + const invertedCorrect = new multirange.MultiRange([ + [0, text.length], + ]); + const removeRange = new multirange.MultiRange([]); + for (range of results.incorrect) { + removeRange.appendRange(range.start, range.end); + } + invertedCorrect.subtract(removeRange); + + // Everything in `invertedCorrect` is correct, so add it directly to + // the list. + correct.append(invertedCorrect); + } else if (results.correct) { + for (range of results.correct) { + correct.appendRange(range.start, range.end); + } + } + + if (results.incorrect) { + const newIncorrect = new multirange.MultiRange([]); + incorrects.push(newIncorrect); + + for (range of results.incorrect) { + newIncorrect.appendRange(range.start, range.end); + } + } + } + + // If we don't have any incorrect spellings, then there is nothing to worry + // about, so just return and stop processing. + if (this.log.enabled) { + this.log('merged correct ranges', correct); + this.log('merged incorrect ranges', incorrects); + } + + if (incorrects.length === 0) { + this.log('no spelling errors'); + return { misspellings: [] }; + } + + // Build up an intersection of all the incorrect ranges. We only treat a word + // as being incorrect if *every* checker that provides negative values treats + // it as incorrect. We know there are at least one item in this list, so pull + // that out. If that is the only one, we don't have to do any additional work, + // otherwise we compare every other one against it, removing any elements + // that aren't an intersection which (hopefully) will produce a smaller list + // with each iteration. + let intersection = null; + + for (let incorrect of incorrects) { + if (intersection === null) { + intersection = incorrect; + } else { + intersection.append(incorrect); + } + } + + // If we have no intersection, then nothing to report as a problem. + if (intersection.length === 0) { + this.log('no spelling after intersections'); + return { misspellings: [] }; + } + + // Remove all of the confirmed correct words from the resulting incorrect + // list. This allows us to have correct-only providers as opposed to only + // incorrect providers. + if (correct.ranges.length > 0) { + intersection.subtract(correct); + } + + if (this.log.enabled) { + this.log('check intersections', intersection); + } + + // Convert the text ranges (index into the string) into Atom buffer + // coordinates ( row and column). + let row = 0; + let rangeIndex = 0; + let lineBeginIndex = 0; + const misspellings = []; + while ( + lineBeginIndex < text.length && + rangeIndex < intersection.ranges.length + ) { + // Figure out where the next line break is. If we hit -1, then we make sure + // it is a higher number so our < comparisons work properly. + let lineEndIndex = text.indexOf('\n', lineBeginIndex); + if (lineEndIndex === -1) { + lineEndIndex = Infinity; + } + + // Loop through and get all the ranegs for this line. + while (true) { + range = intersection.ranges[rangeIndex]; + if (range && range[0] < lineEndIndex) { + // Figure out the character range of this line. We need this because + // @addMisspellings doesn't handle jumping across lines easily and the + // use of the number ranges is inclusive. + const lineRange = new multirange.MultiRange( + [] + ).appendRange(lineBeginIndex, lineEndIndex); + const rangeRange = new multirange.MultiRange( + [] + ).appendRange(range[0], range[1]); + lineRange.intersect(rangeRange); + + // The range we have here includes whitespace between two concurrent + // tokens ("zz zz zz" shows up as a single misspelling). The original + // version would split the example into three separate ones, so we + // do the same thing, but only for the ranges within the line. + this.addMisspellings( + misspellings, + row, + lineRange.ranges[0], + lineBeginIndex, + text + ); + + // If this line is beyond the limits of our current range, we move to + // the next one, otherwise we loop again to reuse this range against + // the next line. + if (lineEndIndex >= range[1]) { + rangeIndex++; + } else { + break; + } + } else { + break; + } + } + + lineBeginIndex = lineEndIndex + 1; + row++; + } + + // Return the resulting misspellings. + return { misspellings }; + }); + } + + suggest(args, word) { + // Make sure our deferred initialization is done. + let checker, index, key, priority, suggestion; + this.init(); + + // Gather up a list of corrections and put them into a custom object that has + // the priority of the plugin, the index in the results, and the word itself. + // We use this to intersperse the results together to avoid having the + // preferred answer for the second plugin below the least preferred of the + // first. + const suggestions = []; + + for (checker of this.checkers) { + // We only care if this plugin contributes to checking to suggestions. + if (!checker.isEnabled() || !checker.providesSuggestions(args)) { + continue; + } + + // Get the suggestions for this word. + index = 0; + priority = checker.getPriority(); + + for (suggestion of checker.suggest(args, word)) { + suggestions.push({ + isSuggestion: true, + priority, + index: index++, + suggestion, + label: suggestion, + }); + } + } + + // Once we have the suggestions, then sort them to intersperse the results. + let keys = Object.keys(suggestions).sort(function (key1, key2) { + const value1 = suggestions[key1]; + const value2 = suggestions[key2]; + const weight1 = value1.priority + value1.index; + const weight2 = value2.priority + value2.index; + + if (weight1 !== weight2) { + return weight1 - weight2; + } + + return value1.suggestion.localeCompare(value2.suggestion); + }); + + // Go through the keys and build the final list of suggestions. As we go + // through, we also want to remove duplicates. + const results = []; + const seen = []; + for (key of keys) { + const s = suggestions[key]; + if (seen.hasOwnProperty(s.suggestion)) { + continue; + } + results.push(s); + seen[s.suggestion] = 1; + } + + // We also grab the "add to dictionary" listings. + const that = this; + keys = Object.keys(this.checkers).sort(function (key1, key2) { + const value1 = that.checkers[key1]; + const value2 = that.checkers[key2]; + return value1.getPriority() - value2.getPriority(); + }); + + for (key of keys) { + // We only care if this plugin contributes to checking to suggestions. + checker = this.checkers[key]; + if (!checker.isEnabled() || !checker.providesAdding(args)) { + continue; + } + + // Add all the targets to the list. + const targets = checker.getAddingTargets(args); + for (let target of targets) { + target.plugin = checker; + target.word = word; + target.isSuggestion = false; + results.push(target); + } + } + + // Return the resulting list of options. + return results; + } + + addMisspellings(misspellings, row, range, lineBeginIndex, text) { + // Get the substring of text, if there is no space, then we can just return + // the entire result. + const substring = text.substring(range[0], range[1]); + + if (/\s+/.test(substring)) { + // We have a space, to break it into individual components and push each + // one to the misspelling list. + const parts = substring.split(/(\s+)/); + let substringIndex = 0; + for (let part of parts) { + if (!/\s+/.test(part)) { + const markBeginIndex = + range[0] - lineBeginIndex + substringIndex; + const markEndIndex = markBeginIndex + part.length; + misspellings.push([ + [row, markBeginIndex], + [row, markEndIndex], + ]); + } + + substringIndex += part.length; + } + + return; + } + + // There were no spaces, so just return the entire list. + return misspellings.push([ + [row, range[0] - lineBeginIndex], + [row, range[1] - lineBeginIndex], + ]); + } + + init() { + // Set up logging. + if (atom.config.get('spell-check.enableDebug')) { + debug = require('debug'); + this.log = debug('spell-check:spell-check-manager'); + } else { + this.log = (str) => {}; + } + + // Set up the system checker. + const hasSystemChecker = this.useSystem && env.isSystemSupported(); + if (this.useSystem && this.systemChecker === null) { + const SystemChecker = require('./system-checker'); + this.systemChecker = new SystemChecker(); + this.addSpellChecker(this.systemChecker); + } + + // Set up the known words. + if (this.knownWordsChecker === null) { + const KnownWordsChecker = require('./known-words-checker'); + this.knownWordsChecker = new KnownWordsChecker(this.knownWords); + this.knownWordsChecker.enableAdd = this.addKnownWords; + this.addSpellChecker(this.knownWordsChecker); + } + + // See if we need to initialize the built-in checkers. + if (this.useLocales && this.localeCheckers === null) { + // Set up the locale checkers. + let defaultLocale; + this.localeCheckers = []; + + // If we have a blank location, use the default based on the process. If + // set, then it will be the best language. We keep track if we are using + // the default locale to control error messages. + let inferredLocale = false; + + if (!this.locales.length) { + defaultLocale = process.env.LANG; + if (defaultLocale) { + inferredLocale = true; + this.locales = [defaultLocale.split('.')[0]]; + } + } + + // If we can't figure out the language from the process, check the + // browser. After testing this, we found that this does not reliably + // produce a proper IEFT tag for languages; on OS X, it was providing + // "English" which doesn't work with the locale selection. To avoid using + // it, we use some tests to make sure it "looks like" an IEFT tag. + if (!this.locales.length) { + defaultLocale = navigator.language; + if (defaultLocale && defaultLocale.length === 5) { + const separatorChar = defaultLocale.charAt(2); + if (separatorChar === '_' || separatorChar === '-') { + inferredLocale = true; + this.locales = [defaultLocale]; + } + } + } + + // If we still can't figure it out, use US English. It isn't a great + // choice, but it is a reasonable default not to mention is can be used + // with the fallback path of the `spellchecker` package. + if (!this.locales.length) { + inferredLocale = true; + this.locales = ['en_US']; + } + + // Go through the new list and create new locale checkers. + const LocaleChecker = require('./locale-checker'); + return (() => { + const result = []; + for (let locale of this.locales) { + const checker = new LocaleChecker( + locale, + this.localePaths, + hasSystemChecker, + inferredLocale + ); + this.addSpellChecker(checker); + result.push(this.localeCheckers.push(checker)); + } + return result; + })(); + } + } + + deactivate() { + this.checkers = []; + this.locales = []; + this.localePaths = []; + this.useSystem = false; + this.useLocales = false; + this.knownWords = []; + this.addKnownWords = false; + + this.systemChecker = null; + this.localeCheckers = null; + return (this.knownWordsChecker = null); + } + + reloadLocales() { + if (this.localeCheckers) { + for (let localeChecker of this.localeCheckers) { + this.removeSpellChecker(localeChecker); + } + return (this.localeCheckers = null); + } + } + + reloadKnownWords() { + if (this.knownWordsChecker) { + this.removeSpellChecker(this.knownWordsChecker); + return (this.knownWordsChecker = null); + } + } +} +SpellCheckerManager.initClass(); + +const manager = new SpellCheckerManager(); +module.exports = manager; diff --git a/packages/spell-check/lib/spell-check-task.js b/packages/spell-check/lib/spell-check-task.js new file mode 100644 index 0000000000..226d7d16d5 --- /dev/null +++ b/packages/spell-check/lib/spell-check-task.js @@ -0,0 +1,129 @@ +let SpellCheckTask; +let idCounter = 0; + +module.exports = SpellCheckTask = (function () { + SpellCheckTask = class SpellCheckTask { + static initClass() { + this.handler = null; + this.jobs = []; + } + + constructor(manager) { + this.manager = manager; + this.id = idCounter++; + } + + terminate() { + return this.constructor.removeFromArray( + this.constructor.jobs, + (j) => j.args.id === this.id + ); + } + + start(editor, onDidSpellCheck) { + // Figure out the paths since we need that for checkers that are project-specific. + const buffer = editor.getBuffer(); + let projectPath = null; + let relativePath = null; + + if (buffer != null && buffer.file && buffer.file.path) { + [projectPath, relativePath] = atom.project.relativizePath( + buffer.file.path + ); + } + + // Remove old jobs for this SpellCheckTask from the shared jobs list. + this.constructor.removeFromArray( + this.constructor.jobs, + (j) => j.args.id === this.id + ); + + // Create an job that contains everything we'll need to do the work. + const job = { + manager: this.manager, + callbacks: [onDidSpellCheck], + editorId: editor.id, + args: { + id: this.id, + projectPath, + relativePath, + text: buffer.getText(), + }, + }; + + // If we already have a job for this work piggy-back on it with our callback. + if (this.constructor.piggybackExistingJob(job)) { + return; + } + + // Do the work now if not busy or queue it for later. + this.constructor.jobs.unshift(job); + if (this.constructor.jobs.length === 1) { + return this.constructor.startNextJob(); + } + } + + static piggybackExistingJob(newJob) { + if (this.jobs.length > 0) { + for (let job of this.jobs) { + if (this.isDuplicateRequest(job, newJob)) { + job.callbacks = job.callbacks.concat(newJob.callbacks); + return true; + } + } + } + return false; + } + + static isDuplicateRequest(a, b) { + return ( + a.args.projectPath === b.args.projectPath && + a.args.relativePath === b.args.relativePath + ); + } + + static removeFromArray(array, predicate) { + if (array.length > 0) { + for (let i = 0; i < array.length; i++) { + if (predicate(array[i])) { + const found = array[i]; + array.splice(i, 1); + return found; + } + } + } + } + + static startNextJob() { + const activeEditor = atom.workspace.getActiveTextEditor(); + if (!activeEditor) return; + + const activeEditorId = activeEditor.id; + const job = + this.jobs.find((j) => j.editorId === activeEditorId) || + this.jobs[0]; + + return job.manager + .check(job.args, job.args.text) + .then((results) => { + this.removeFromArray( + this.jobs, + (j) => j.args.id === job.args.id + ); + for (let callback of job.callbacks) { + callback(results.misspellings); + } + + if (this.jobs.length > 0) { + return this.startNextJob(); + } + }); + } + + static clear() { + return (this.jobs = []); + } + }; + SpellCheckTask.initClass(); + return SpellCheckTask; +})(); diff --git a/packages/spell-check/lib/spell-check-view.js b/packages/spell-check/lib/spell-check-view.js new file mode 100644 index 0000000000..c6545bf810 --- /dev/null +++ b/packages/spell-check/lib/spell-check-view.js @@ -0,0 +1,318 @@ +let SpellCheckView; +const _ = require('underscore-plus'); +const { CompositeDisposable } = require('atom'); +const SpellCheckTask = require('./spell-check-task'); + +let CorrectionsView = null; + +module.exports = SpellCheckView = class SpellCheckView { + constructor(editor, spellCheckModule, manager) { + this.addContextMenuEntries = this.addContextMenuEntries.bind(this); + this.makeCorrection = this.makeCorrection.bind(this); + this.editor = editor; + this.spellCheckModule = spellCheckModule; + this.manager = manager; + this.disposables = new CompositeDisposable(); + this.initializeMarkerLayer(); + + this.taskWrapper = new SpellCheckTask(this.manager); + + this.correctMisspellingCommand = atom.commands.add( + atom.views.getView(this.editor), + 'spell-check:correct-misspelling', + () => { + let marker; + if ( + (marker = this.markerLayer.findMarkers({ + containsBufferPosition: this.editor.getCursorBufferPosition(), + })[0]) + ) { + if (CorrectionsView == null) { + CorrectionsView = require('./corrections-view'); + } + if (this.correctionsView != null) { + this.correctionsView.destroy(); + } + this.correctionsView = new CorrectionsView( + this.editor, + this.getCorrections(marker), + marker, + this, + this.updateMisspellings + ); + return this.correctionsView.attach(); + } + } + ); + + atom.views + .getView(this.editor) + .addEventListener('contextmenu', this.addContextMenuEntries); + + this.disposables.add( + this.editor.onDidChangePath(() => { + return this.subscribeToBuffer(); + }) + ); + + this.disposables.add( + this.editor.onDidChangeGrammar(() => { + return this.subscribeToBuffer(); + }) + ); + + this.disposables.add( + atom.config.onDidChange('editor.fontSize', () => { + return this.subscribeToBuffer(); + }) + ); + + this.disposables.add( + atom.config.onDidChange('spell-check.grammars', () => { + return this.subscribeToBuffer(); + }) + ); + + this.subscribeToBuffer(); + + this.disposables.add(this.editor.onDidDestroy(this.destroy.bind(this))); + } + + initializeMarkerLayer() { + this.markerLayer = this.editor.addMarkerLayer({ + maintainHistory: false, + }); + return (this.markerLayerDecoration = this.editor.decorateMarkerLayer( + this.markerLayer, + { + type: 'highlight', + class: 'spell-check-misspelling', + deprecatedRegionClass: 'misspelling', + } + )); + } + + destroy() { + this.unsubscribeFromBuffer(); + this.disposables.dispose(); + this.taskWrapper.terminate(); + this.markerLayer.destroy(); + this.markerLayerDecoration.destroy(); + this.correctMisspellingCommand.dispose(); + if (this.correctionsView != null) { + this.correctionsView.destroy(); + } + return this.clearContextMenuEntries(); + } + + unsubscribeFromBuffer() { + this.destroyMarkers(); + + if (this.buffer != null) { + this.bufferDisposable.dispose(); + return (this.buffer = null); + } + } + + subscribeToBuffer() { + this.unsubscribeFromBuffer(); + + if (this.spellCheckCurrentGrammar()) { + this.buffer = this.editor.getBuffer(); + this.bufferDisposable = new CompositeDisposable( + this.buffer.onDidStopChanging( + () => this.updateMisspellings(), + this.editor.onDidTokenize(() => this.updateMisspellings()) + ) + ); + return this.updateMisspellings(); + } + } + + spellCheckCurrentGrammar() { + const grammar = this.editor.getGrammar().scopeName; + return _.contains(atom.config.get('spell-check.grammars'), grammar); + } + + destroyMarkers() { + this.markerLayer.destroy(); + this.markerLayerDecoration.destroy(); + return this.initializeMarkerLayer(); + } + + addMarkers(misspellings) { + return (() => { + const result = []; + for (let misspelling of misspellings) { + const scope = this.editor.scopeDescriptorForBufferPosition( + misspelling[0] + ); + if (!this.scopeIsExcluded(scope)) { + result.push( + this.markerLayer.markBufferRange(misspelling, { + invalidate: 'touch', + }) + ); + } else { + result.push(undefined); + } + } + return result; + })(); + } + + updateMisspellings() { + return this.taskWrapper.start(this.editor, (misspellings) => { + this.destroyMarkers(); + if (this.buffer != null) { + return this.addMarkers(misspellings); + } + }); + } + + getCorrections(marker) { + // Build up the arguments object for this buffer and text. + let projectPath = null; + let relativePath = null; + + if (this.buffer != null && this.buffer.file && this.buffer.file.path) { + [projectPath, relativePath] = atom.project.relativizePath( + this.buffer.file.path + ); + } + + const args = { + projectPath, + relativePath, + }; + + // Get the misspelled word and then request corrections. + const misspelling = this.editor.getTextInBufferRange( + marker.getBufferRange() + ); + return this.manager.suggest(args, misspelling); + } + + addContextMenuEntries(mouseEvent) { + let marker; + this.clearContextMenuEntries(); + // Get buffer position of the right click event. If the click happens outside + // the boundaries of any text, the method defaults to the buffer position of + // the last character in the editor. + const currentScreenPosition = atom.views + .getView(this.editor) + .component.screenPositionForMouseEvent(mouseEvent); + const currentBufferPosition = this.editor.bufferPositionForScreenPosition( + currentScreenPosition + ); + + // Check to see if the selected word is incorrect. + if ( + (marker = this.markerLayer.findMarkers({ + containsBufferPosition: currentBufferPosition, + })[0]) + ) { + const corrections = this.getCorrections(marker); + if (corrections.length > 0) { + this.spellCheckModule.contextMenuEntries.push({ + menuItem: atom.contextMenu.add({ + 'atom-text-editor': [{ type: 'separator' }], + }), + }); + + let correctionIndex = 0; + for (let correction of corrections) { + const contextMenuEntry = {}; + // Register new command for correction. + var commandName = + 'spell-check:correct-misspelling-' + correctionIndex; + contextMenuEntry.command = (( + correction, + contextMenuEntry + ) => { + return atom.commands.add( + atom.views.getView(this.editor), + commandName, + () => { + this.makeCorrection(correction, marker); + return this.clearContextMenuEntries(); + } + ); + })(correction, contextMenuEntry); + + // Add new menu item for correction. + contextMenuEntry.menuItem = atom.contextMenu.add({ + 'atom-text-editor': [ + { label: correction.label, command: commandName }, + ], + }); + this.spellCheckModule.contextMenuEntries.push( + contextMenuEntry + ); + correctionIndex++; + } + + return this.spellCheckModule.contextMenuEntries.push({ + menuItem: atom.contextMenu.add({ + 'atom-text-editor': [{ type: 'separator' }], + }), + }); + } + } + } + + makeCorrection(correction, marker) { + if (correction.isSuggestion) { + // Update the buffer with the correction. + this.editor.setSelectedBufferRange(marker.getBufferRange()); + return this.editor.insertText(correction.suggestion); + } else { + // Build up the arguments object for this buffer and text. + let projectPath = null; + let relativePath = null; + + if ( + this.editor.buffer != null && + this.editor.buffer.file && + this.editor.buffer.file.path + ) { + [projectPath, relativePath] = atom.project.relativizePath( + this.editor.buffer.file.path + ); + } + + const args = { + id: this.id, + projectPath, + relativePath, + }; + + // Send the "add" request to the plugin. + correction.plugin.add(args, correction); + + // Update the buffer to handle the corrections. + return this.updateMisspellings.bind(this)(); + } + } + + clearContextMenuEntries() { + for (let entry of this.spellCheckModule.contextMenuEntries) { + if (entry.command != null) { + entry.command.dispose(); + } + if (entry.menuItem != null) { + entry.menuItem.dispose(); + } + } + + return (this.spellCheckModule.contextMenuEntries = []); + } + + scopeIsExcluded(scopeDescriptor, excludedScopes) { + return this.spellCheckModule.excludedScopeRegexLists.some((regexList) => + scopeDescriptor.scopes.some((scopeName) => + regexList.every((regex) => regex.test(scopeName)) + ) + ); + } +}; diff --git a/packages/spell-check/lib/system-checker.js b/packages/spell-check/lib/system-checker.js new file mode 100644 index 0000000000..fa4dbc3a01 --- /dev/null +++ b/packages/spell-check/lib/system-checker.js @@ -0,0 +1,89 @@ +let instance; +const spellchecker = require('spellchecker'); +const pathspec = require('atom-pathspec'); +const env = require('./checker-env'); + +// Initialize the global spell checker which can take some time. We also force +// the use of the system or operating system library instead of Hunspell. +if (env.isSystemSupported()) { + instance = new spellchecker.Spellchecker(); + instance.setSpellcheckerType(spellchecker.ALWAYS_USE_SYSTEM); + + if (!instance.setDictionary('', '')) { + instance = undefined; + } +} else { + instance = undefined; +} + +// The `SystemChecker` is a special case to use the built-in system spell-checking +// provided by some platforms, such as Windows 8+ and macOS. This also doesn't have +// settings for specific locales because we need to use default, otherwise macOS +// starts to throw an occasional error if you use multiple locales at the same time +// due to some memory bug. +class SystemChecker { + constructor() { + if (atom.config.get('spell-check.enableDebug')) { + debug = require('debug'); + this.log = debug('spell-check:system-checker'); + } else { + this.log = (str) => {}; + } + this.log('enabled', this.isEnabled(), this.getStatus()); + } + + deactivate() {} + + getId() { + return 'spell-check:system'; + } + getName() { + return 'System Checker'; + } + getPriority() { + return 110; + } + isEnabled() { + return instance; + } + getStatus() { + if (instance) { + return 'working correctly'; + } else { + return 'not supported on platform'; + } + } + + providesSpelling(args) { + return this.isEnabled(); + } + providesSuggestions(args) { + return this.isEnabled(); + } + providesAdding(args) { + return false; + } // Users can't add yet. + + check(args, text) { + const id = this.getId(); + + if (this.isEnabled()) { + // We use the default checker here and not the locale-specific one so it + // will check all languages at the same time. + return instance.checkSpellingAsync(text).then((incorrect) => { + if (this.log.enabled) { + this.log('check', incorrect); + } + return { id, invertIncorrectAsCorrect: true, incorrect }; + }); + } else { + return { id, status: this.getStatus() }; + } + } + + suggest(args, word) { + return instance.getCorrectionsForMisspelling(word); + } +} + +module.exports = SystemChecker; diff --git a/packages/spell-check/menus/spell-check.cson b/packages/spell-check/menus/spell-check.cson new file mode 100644 index 0000000000..d2426ab8a9 --- /dev/null +++ b/packages/spell-check/menus/spell-check.cson @@ -0,0 +1,20 @@ +'menu': [ + { + 'label': 'Packages' + 'submenu': [ + { + 'label': 'Spell Check' + 'submenu': [ + { + 'label': 'Toggle' + 'command': 'spell-check:toggle' + } + ] + } + ] + } +], +'context-menu': + 'atom-text-editor:not([mini])': [ + {label: 'Correct Spelling', command: 'spell-check:correct-misspelling'} + ] diff --git a/packages/spell-check/package.json b/packages/spell-check/package.json new file mode 100644 index 0000000000..61ac11aa6d --- /dev/null +++ b/packages/spell-check/package.json @@ -0,0 +1,134 @@ +{ + "name": "spell-check", + "version": "0.77.1", + "main": "./lib/main", + "description": "Highlights misspelled words and shows possible corrections.", + "dependencies": { + "atom-pathspec": "^0.0.0", + "atom-select-list": "^0.7.0", + "debug": "^4.1.1", + "multi-integer-range": "^2.0.0", + "natural": "^0.4.0", + "spellchecker": "^3.7.1", + "spelling-manager": "^1.1.0", + "underscore-plus": "^1" + }, + "devDependencies": { + "husky": "^4.2.5", + "prettier": "^2.1.1" + }, + "repository": "https://github.com/pulsar-edit/pulsar", + "license": "MIT", + "engines": { + "atom": "*" + }, + "scripts": { + "format": "prettier --write \"spec/*.js\" \"lib/**/*.js\" \"script/*.js\" --loglevel warn" + }, + "configSchema": { + "grammars": { + "type": "array", + "default": [ + "source.asciidoc", + "source.gfm", + "text.git-commit", + "text.plain", + "text.plain.null-grammar", + "source.rst", + "text.restructuredtext" + ], + "description": "List of scopes for languages which will be checked for misspellings. See [the README](https://github.com/pulsar-edit/pulsar/blob/master/packages/spell-check/README.md) for more information on finding the correct scope for a specific language.", + "order": 1 + }, + "excludedScopes": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of sub-scopes that will be ignored. Specify the most detailed scope to avoid ignoring otherwise relevant text. The scopes will be parsed as regular expressions. See [the README](https://github.com/pulsar-edit/pulsar/blob/master/packages/spell-check/README.md) for more information on finding the correct scope for a specific language.", + "order": 2 + }, + "useSystem": { + "type": "boolean", + "default": true, + "description": "If checked, use the built-in spell checking on macOS and some versions of Windows. This setting is ignored on Linux, even if checked.", + "order": 3 + }, + "useLocales": { + "type": "boolean", + "default": true, + "description": "If checked, then the locales below will be used for checking.", + "order": 4 + }, + "locales": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "description": "List of locales to use for the system spell-checking. Examples would be `en-US` or `de-DE`. If this is blank, then the default language for the user will be used.", + "order": 5 + }, + "localePaths": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "description": "List of additional paths to search for dictionary files when checking using locales. If a locale cannot be found in these, the internal code will attempt to find it using common search paths.", + "order": 6 + }, + "knownWords": { + "type": "array", + "default": [], + "description": "List words that are considered correct even if they do not appear in any other dictionary. Words with capitals or ones that start with `!` are case-sensitive.", + "order": 7 + }, + "addKnownWords": { + "type": "boolean", + "default": false, + "description": "If checked, then the suggestions will include options to add to the known words list above.", + "order": 8 + }, + "noticesMode": { + "type": "string", + "default": "both", + "description": "Choose where loading errors and other notices are displayed: popup, console, or both.", + "order": 9, + "enum": [ + { + "value": "both", + "description": "Display notices in popups and in the console" + }, + { + "value": "popup", + "description": "Display notices only in popups" + }, + { + "value": "console", + "description": "Display notices only on the console" + } + ] + }, + "enableDebug": { + "type": "boolean", + "default": false, + "title": "Enable debug information for spell check", + "order": 10 + } + }, + "consumedServices": { + "spell-check": { + "versions": { + "^1.0.0": "consumeSpellCheckers" + } + } + }, + "husky": { + "hooks": { + "pre-commit": "npm run format", + "pre-push": "npm run format" + } + } +} diff --git a/packages/spell-check/script/benchmark.js b/packages/spell-check/script/benchmark.js new file mode 100644 index 0000000000..890c88870b --- /dev/null +++ b/packages/spell-check/script/benchmark.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +const handler = require('../lib/spell-check-handler'); +const fs = require('fs'); + +const pathToCheck = process.argv[2]; +console.log('Spellchecking %s...', pathToCheck); + +const text = fs.readFileSync(pathToCheck, 'utf8'); + +const t0 = Date.now(); +const result = handler({ id: 1, text }); +const t1 = Date.now(); + +console.log( + 'Found %d misspellings in %d milliseconds', + result.misspellings.length, + t1 - t0 +); diff --git a/packages/spell-check/spec/async-spec-helpers.js b/packages/spell-check/spec/async-spec-helpers.js new file mode 100644 index 0000000000..87a9cbb87a --- /dev/null +++ b/packages/spell-check/spec/async-spec-helpers.js @@ -0,0 +1,68 @@ +exports.beforeEach = function (fn) { + global.beforeEach(function () { + const result = fn(); + if (result instanceof Promise) { + waitsForPromise(() => result); + } + }); +}; + +exports.afterEach = function (fn) { + global.afterEach(function () { + const result = fn(); + if (result instanceof Promise) { + waitsForPromise(() => result); + } + }); +}; + +for (const name of ['it', 'fit', 'ffit', 'fffit']) { + exports[name] = function (description, fn) { + if (fn === undefined) { + global[name](description); + return; + } + + global[name](description, function () { + const result = fn(); + if (result instanceof Promise) { + waitsForPromise(() => result); + } + }); + }; +} + +exports.conditionPromise = async function ( + condition, + description = condition.toString() +) { + const startTime = Date.now(); + + while (true) { + await exports.timeoutPromise(100); + + if (await condition()) { + return; + } + + if (Date.now() - startTime > 120000) { + throw new Error('Timed out waiting on ' + description); + } + } +}; + +exports.timeoutPromise = function (timeout) { + return new Promise(function (resolve) { + global.setTimeout(resolve, timeout); + }); +}; + +exports.waitsForPromise = function (fn) { + const promise = fn(); + global.waitsFor('spec promise to resolve', function (done) { + promise.then(done, function (error) { + jasmine.getEnv().currentSpec.fail(error); + done(); + }); + }); +}; diff --git a/packages/spell-check/spec/eot-spec-checker.js b/packages/spell-check/spec/eot-spec-checker.js new file mode 100644 index 0000000000..e367d40bac --- /dev/null +++ b/packages/spell-check/spec/eot-spec-checker.js @@ -0,0 +1,10 @@ +const SpecChecker = require('./spec-checker'); + +class EndOfTestSpecChecker extends SpecChecker { + constructor() { + super('eot', true, ['eot']); + } +} + +const checker = new EndOfTestSpecChecker(); +module.exports = checker; diff --git a/packages/spell-check/spec/known-1-spec-checker.js b/packages/spell-check/spec/known-1-spec-checker.js new file mode 100644 index 0000000000..340dc030a0 --- /dev/null +++ b/packages/spell-check/spec/known-1-spec-checker.js @@ -0,0 +1,10 @@ +const SpecChecker = require('./spec-checker'); + +class Known1SpecChecker extends SpecChecker { + constructor() { + super('known-1', false, ['k1a', 'k0b', 'k0a']); + } +} + +const checker = new Known1SpecChecker(); +module.exports = checker; diff --git a/packages/spell-check/spec/known-2-spec-checker.js b/packages/spell-check/spec/known-2-spec-checker.js new file mode 100644 index 0000000000..51ac91004e --- /dev/null +++ b/packages/spell-check/spec/known-2-spec-checker.js @@ -0,0 +1,10 @@ +const SpecChecker = require('./spec-checker'); + +class Known2SpecChecker extends SpecChecker { + constructor() { + super('known-2', true, ['k2a', 'k0c', 'k0a']); + } +} + +const checker = new Known2SpecChecker(); +module.exports = checker; diff --git a/packages/spell-check/spec/known-3-spec-checker.js b/packages/spell-check/spec/known-3-spec-checker.js new file mode 100644 index 0000000000..19460607f3 --- /dev/null +++ b/packages/spell-check/spec/known-3-spec-checker.js @@ -0,0 +1,10 @@ +const SpecChecker = require('./spec-checker'); + +class Known3SpecChecker extends SpecChecker { + constructor() { + super('known-3', false, ['k3a', 'k0b', 'k0a']); + } +} + +const checker = new Known3SpecChecker(); +module.exports = checker; diff --git a/packages/spell-check/spec/known-4-spec-checker.js b/packages/spell-check/spec/known-4-spec-checker.js new file mode 100644 index 0000000000..e589b39734 --- /dev/null +++ b/packages/spell-check/spec/known-4-spec-checker.js @@ -0,0 +1,10 @@ +const SpecChecker = require('./spec-checker'); + +class Known4SpecChecker extends SpecChecker { + constructor() { + super('known-4', true, ['k4a', 'k0c', 'k0a']); + } +} + +const checker = new Known4SpecChecker(); +module.exports = checker; diff --git a/packages/spell-check/spec/known-unicode-spec-checker.js b/packages/spell-check/spec/known-unicode-spec-checker.js new file mode 100644 index 0000000000..6ef84ea712 --- /dev/null +++ b/packages/spell-check/spec/known-unicode-spec-checker.js @@ -0,0 +1,10 @@ +const SpecChecker = require('./spec-checker'); + +class KnownUnicodeSpecChecker extends SpecChecker { + constructor() { + super('known-unicode', false, ['абырг']); + } +} + +const checker = new KnownUnicodeSpecChecker(); +module.exports = checker; diff --git a/packages/spell-check/spec/locale-checker-spec.js b/packages/spell-check/spec/locale-checker-spec.js new file mode 100644 index 0000000000..187c675e00 --- /dev/null +++ b/packages/spell-check/spec/locale-checker-spec.js @@ -0,0 +1,59 @@ +const LocaleChecker = require('../lib/locale-checker'); +const env = require('../lib/checker-env'); +const { it, fit, ffit } = require('./async-spec-helpers'); + +describe('Locale Checker', function () { + it('can load en-US without paths', async function () { + checker = new LocaleChecker('en-US', [], false, false); + checker.deferredInit(); + + expect(checker.isEnabled()).toEqual(true); + expect(checker.getStatus()).toEqual(null); + }); + + it('cannot load xx-XX without paths', async function () { + checker = new LocaleChecker('xx-XX', [], false, false); + checker.deferredInit(); + + expect(checker.isEnabled()).toEqual(false); + expect(checker.getStatus()).toEqual( + 'Cannot load the locale dictionary for `xx-XX`.' + ); + }); + + it('cannot quietly load xx-XX without paths with system', async function () { + checker = new LocaleChecker('xx-XX', [], true, true); + checker.deferredInit(); + + expect(checker.isEnabled()).toEqual(false); + expect(checker.getStatus()).toEqual( + 'Cannot load the locale dictionary for `xx-XX`. No warning because system checker is in use and locale is inferred.' + ); + }); + + // On Windows, not using the built-in path should use the + // Spelling API. + if (env.isWindows()) { + it('can load en-US from Windows API', async function () { + checker = new LocaleChecker('en-US', [], false, false); + checker.checkDictionaryPath = false; + checker.checkDefaultPaths = false; + checker.deferredInit(); + + expect(checker.isEnabled()).toEqual(true); + expect(checker.getStatus()).toEqual(null); + }); + } else { + it('cannot load en-US without paths or fallback', async function () { + checker = new LocaleChecker('en-US', [], false, false); + checker.checkDictionaryPath = false; + checker.checkDefaultPaths = false; + checker.deferredInit(); + + expect(checker.isEnabled()).toEqual(false); + expect(checker.getStatus()).toEqual( + 'Cannot load the locale dictionary for `en-US`.' + ); + }); + } +}); diff --git a/packages/spell-check/spec/spec-checker.js b/packages/spell-check/spec/spec-checker.js new file mode 100644 index 0000000000..ccc88903e1 --- /dev/null +++ b/packages/spell-check/spec/spec-checker.js @@ -0,0 +1,78 @@ +// This is functionally identical to the known words checker except that it +// is intended to be immutable while running tests. This means the constructor +// adds the words instead of calling `add` directly. +class SpecChecker { + static initClass() { + this.prototype.spelling = null; + this.prototype.checker = null; + } + + constructor(id, isNegative, knownWords) { + // Set up the spelling manager we'll be using. + this.id = id; + this.isNegative = isNegative; + const spellingManager = require('spelling-manager'); + this.spelling = new spellingManager.TokenSpellingManager(); + this.checker = new spellingManager.BufferSpellingChecker(this.spelling); + + // Set our known words. + this.setKnownWords(knownWords); + } + + deactivate() {} + + getId() { + return 'spell-check:spec:' + this.id; + } + getName() { + return 'Spec Checker'; + } + getPriority() { + return 10; + } + isEnabled() { + return true; + } + getStatus() { + return 'Working correctly.'; + } + providesSpelling(args) { + return true; + } + providesSuggestions(args) { + return false; + } + providesAdding(args) { + return false; + } + + check(args, text) { + const ranges = []; + const checked = this.checker.check(text); + for (let token of checked) { + if (token.status === 1) { + ranges.push({ start: token.start, end: token.end }); + } + } + + if (this.isNegative) { + return { incorrect: ranges }; + } else { + return { correct: ranges }; + } + } + + setKnownWords(knownWords) { + // Clear out the old list. + this.spelling.sensitive = {}; + this.spelling.insensitive = {}; + + // Add the new ones into the list. + if (knownWords) { + return knownWords.map((ignore) => this.spelling.add(ignore)); + } + } +} +SpecChecker.initClass(); + +module.exports = SpecChecker; diff --git a/packages/spell-check/spec/spell-check-spec.js b/packages/spell-check/spec/spell-check-spec.js new file mode 100644 index 0000000000..103992ef31 --- /dev/null +++ b/packages/spell-check/spec/spell-check-spec.js @@ -0,0 +1,679 @@ +const SpellCheckTask = require('../lib/spell-check-task'); +const env = require('../lib/checker-env'); +const { sep } = require('path'); +const { + it, + fit, + ffit, + beforeEach, + afterEach, + conditionPromise, + timeoutPromise, +} = require('./async-spec-helpers'); + +describe('Spell check', function () { + let workspaceElement, editor, editorElement, spellCheckModule; + + const textForMarker = (marker) => + editor.getTextInBufferRange(marker.getBufferRange()); + + const getMisspellingMarkers = () => + spellCheckModule.misspellingMarkersForEditor(editor); + + beforeEach(async function () { + jasmine.useRealClock(); + + workspaceElement = atom.views.getView(atom.workspace); + await atom.packages.activatePackage('language-text'); + await atom.packages.activatePackage('language-javascript'); + await atom.workspace.open(`${__dirname}${sep}sample.js`); + const package = await atom.packages.activatePackage('spell-check'); + spellCheckModule = package.mainModule; + + // Disable the grammers so nothing is done until we turn it back on. + atom.config.set('spell-check.grammars', []); + + // Set the settings to a specific setting to avoid side effects. + atom.config.set('spell-check.useSystem', false); + atom.config.set('spell-check.useLocales', false); + atom.config.set('spell-check.locales', ['en-US']); + + // Attach everything and ready to test. + jasmine.attachToDOM(workspaceElement); + editor = atom.workspace.getActiveTextEditor(); + editorElement = atom.views.getView(editor); + }); + + afterEach(() => SpellCheckTask.clear()); + + it('decorates all misspelled words', async function () { + atom.config.set('spell-check.useLocales', true); + editor.insertText( + 'This middle of thiss\nsentencts\n\nhas issues and the "edn" \'dsoe\' too' + ); + atom.config.set('spell-check.grammars', ['source.js']); + + await conditionPromise(() => getMisspellingMarkers().length === 4); + const misspellingMarkers = getMisspellingMarkers(); + expect(textForMarker(misspellingMarkers[0])).toEqual('thiss'); + expect(textForMarker(misspellingMarkers[1])).toEqual('sentencts'); + expect(textForMarker(misspellingMarkers[2])).toEqual('edn'); + expect(textForMarker(misspellingMarkers[3])).toEqual('dsoe'); + }); + + it('decorates misspelled words with a leading space', async function () { + atom.config.set('spell-check.useLocales', true); + editor.setText('\nchok bok'); + atom.config.set('spell-check.grammars', ['source.js']); + + await conditionPromise(() => getMisspellingMarkers().length === 2); + const misspellingMarkers = getMisspellingMarkers(); + expect(textForMarker(misspellingMarkers[0])).toEqual('chok'); + expect(textForMarker(misspellingMarkers[1])).toEqual('bok'); + }); + + it('allows certains scopes to be excluded from spell checking', async function () { + editor.setText( + 'speledWrong = 5;\n' + + 'function speledWrong() {}\n' + + 'class SpeledWrong {}' + ); + atom.config.set('spell-check.useLocales', true); + atom.config.set('spell-check.grammars', [ + 'source.js', + 'text.plain.null-grammar', + ]); + atom.config.set('spell-check.excludedScopes', ['.function.entity']); + + { + await conditionPromise(() => getMisspellingMarkers().length > 0); + const markers = getMisspellingMarkers(); + expect(markers.map((marker) => marker.getBufferRange())).toEqual([ + [ + [0, 0], + [0, 11], + ], + [ + [2, 6], + [2, 17], + ], + ]); + } + + { + atom.config.set('spell-check.excludedScopes', ['.functio.entity']); + await conditionPromise(() => getMisspellingMarkers().length === 3); + const markers = getMisspellingMarkers(); + expect(markers.map((marker) => marker.getBufferRange())).toEqual([ + [ + [0, 0], + [0, 11], + ], + [ + [1, 9], + [1, 20], + ], + [ + [2, 6], + [2, 17], + ], + ]); + } + + { + atom.config.set('spell-check.excludedScopes', ['.meta.class']); + await conditionPromise(() => getMisspellingMarkers().length === 2); + const markers = getMisspellingMarkers(); + expect(markers.map((marker) => marker.getBufferRange())).toEqual([ + [ + [0, 0], + [0, 11], + ], + [ + [1, 9], + [1, 20], + ], + ]); + } + + { + atom.grammars.assignLanguageMode(editor, null); + await conditionPromise(() => getMisspellingMarkers().length === 3); + const markers = getMisspellingMarkers(); + expect(markers.map((marker) => marker.getBufferRange())).toEqual([ + [ + [0, 0], + [0, 11], + ], + [ + [1, 9], + [1, 20], + ], + [ + [2, 6], + [2, 17], + ], + ]); + } + }); + + it('allow entering of known words', async function () { + atom.config.set('spell-check.knownWords', [ + 'GitHub', + '!github', + 'codez', + ]); + atom.config.set('spell-check.useLocales', true); + editor.setText('GitHub (aka github): Where codez are builz.'); + atom.config.set('spell-check.grammars', ['source.js']); + + await conditionPromise(() => getMisspellingMarkers().length === 1); + const misspellingMarkers = getMisspellingMarkers(); + expect(textForMarker(misspellingMarkers[0])).toBe('builz'); + }); + + it('hides decorations when a misspelled word is edited', async function () { + editor.setText('notaword'); + advanceClock(editor.getBuffer().getStoppedChangingDelay()); + atom.config.set('spell-check.useLocales', true); + atom.config.set('spell-check.grammars', ['source.js']); + await conditionPromise(() => getMisspellingMarkers().length === 1); + + editor.moveToEndOfLine(); + editor.insertText('a'); + await conditionPromise(() => { + const misspellingMarkers = getMisspellingMarkers(); + return ( + misspellingMarkers.length === 1 && + !misspellingMarkers[0].isValid() + ); + }); + }); + + describe('when spell checking for a grammar is removed', () => + it('removes all the misspellings', async function () { + atom.config.set('spell-check.useLocales', true); + editor.setText('notaword'); + atom.config.set('spell-check.grammars', ['source.js']); + await conditionPromise(() => getMisspellingMarkers().length === 1); + + atom.config.set('spell-check.grammars', []); + expect(getMisspellingMarkers().length).toBe(0); + })); + + describe('when spell checking for a grammar is toggled off', () => + it('removes all the misspellings', async function () { + atom.config.set('spell-check.useLocales', true); + editor.setText('notaword'); + atom.config.set('spell-check.grammars', ['source.js']); + await conditionPromise(() => getMisspellingMarkers().length === 1); + + atom.commands.dispatch(workspaceElement, 'spell-check:toggle'); + expect(getMisspellingMarkers().length).toBe(0); + })); + + describe("when the editor's grammar changes to one that does not have spell check enabled", () => + it('removes all the misspellings', async function () { + atom.config.set('spell-check.useLocales', true); + editor.setText('notaword'); + atom.config.set('spell-check.grammars', ['source.js']); + + await conditionPromise(() => getMisspellingMarkers().length === 1); + const misspellingMarkers = getMisspellingMarkers(); + editor.setGrammar(atom.grammars.selectGrammar('.txt')); + expect(getMisspellingMarkers().length).toBe(0); + })); + + describe("when 'spell-check:correct-misspelling' is triggered on the editor", function () { + describe('when the cursor touches a misspelling that has corrections', () => + it('displays the corrections for the misspelling and replaces the misspelling when a correction is selected', async function () { + atom.config.set('spell-check.useLocales', true); + editor.setText('tofether'); + atom.config.set('spell-check.grammars', ['source.js']); + let correctionsElement = null; + + await conditionPromise( + () => getMisspellingMarkers().length === 1 + ); + expect(getMisspellingMarkers()[0].isValid()).toBe(true); + + atom.commands.dispatch( + editorElement, + 'spell-check:correct-misspelling' + ); + correctionsElement = editorElement.querySelector( + '.corrections' + ); + expect(correctionsElement).toBeDefined(); + expect( + correctionsElement.querySelectorAll('li').length + ).toBeGreaterThan(0); + expect( + correctionsElement.querySelectorAll('li')[0].textContent + ).toBe('together'); + + atom.commands.dispatch(correctionsElement, 'core:confirm'); + expect(editor.getText()).toBe('together'); + expect(editor.getCursorBufferPosition()).toEqual([0, 8]); + + expect(getMisspellingMarkers()[0].isValid()).toBe(false); + expect(editorElement.querySelector('.corrections')).toBeNull(); + })); + + describe('when the cursor touches a misspelling that has no corrections', () => + it('displays a message saying no corrections found', async function () { + atom.config.set('spell-check.useLocales', true); + editor.setText('zxcasdfysyadfyasdyfasdfyasdfyasdfyasydfasdf'); + atom.config.set('spell-check.grammars', ['source.js']); + await conditionPromise( + () => getMisspellingMarkers().length > 0 + ); + + atom.commands.dispatch( + editorElement, + 'spell-check:correct-misspelling' + ); + expect( + editorElement.querySelectorAll('.corrections').length + ).toBe(1); + expect( + editorElement.querySelectorAll('.corrections li').length + ).toBe(0); + expect( + editorElement.querySelector('.corrections').textContent + ).toMatch(/No corrections/); + })); + }); + + describe('when a right mouse click is triggered on the editor', function () { + describe('when the cursor touches a misspelling that has corrections', () => + it('displays the context menu items for the misspelling and replaces the misspelling when a correction is selected', async function () { + atom.config.set('spell-check.useLocales', true); + editor.setText('tofether'); + advanceClock(editor.getBuffer().getStoppedChangingDelay()); + atom.config.set('spell-check.grammars', ['source.js']); + await conditionPromise( + () => getMisspellingMarkers().length === 1 + ); + + expect(getMisspellingMarkers()[0].isValid()).toBe(true); + editorElement.dispatchEvent(new MouseEvent('contextmenu')); + + // Check that the proper context menu entries are created for the misspelling. + // A misspelling will have atleast 2 context menu items for the lines separating + // the corrections. + expect( + spellCheckModule.contextMenuEntries.length + ).toBeGreaterThan(2); + const commandName = 'spell-check:correct-misspelling-0'; + const menuItemLabel = 'together'; + + { + const editorCommands = atom.commands.findCommands({ + target: editorElement, + }); + const correctionCommand = editorCommands.filter( + (command) => command.name === commandName + )[0]; + const correctionMenuItem = atom.contextMenu.itemSets.filter( + (item) => item.items[0].label === menuItemLabel + )[0]; + expect(correctionCommand).toBeDefined(); + expect(correctionMenuItem).toBeDefined(); + } + + atom.commands.dispatch(editorElement, commandName); + // Check that the misspelling is corrected and the context menu entries are properly disposed. + expect(editor.getText()).toBe('together'); + expect(editor.getCursorBufferPosition()).toEqual([0, 8]); + expect(getMisspellingMarkers()[0].isValid()).toBe(false); + expect(spellCheckModule.contextMenuEntries.length).toBe(0); + + { + const editorCommands = atom.commands.findCommands({ + target: editorElement, + }); + const correctionCommand = editorCommands.filter( + (command) => command.name === commandName + )[0]; + const correctionMenuItem = atom.contextMenu.itemSets.filter( + (item) => item.items[0].label === menuItemLabel + )[0]; + expect(correctionCommand).toBeUndefined(); + expect(correctionMenuItem).toBeUndefined(); + } + })); + + describe('when the cursor touches a misspelling and adding known words is enabled', () => + it("displays the 'Add to Known Words' option and adds that word when the option is selected", async function () { + atom.config.set('spell-check.useLocales', true); + editor.setText('zxcasdfysyadfyasdyfasdfyasdfyasdfyasydfasdf'); + advanceClock(editor.getBuffer().getStoppedChangingDelay()); + atom.config.set('spell-check.grammars', ['source.js']); + atom.config.set('spell-check.addKnownWords', true); + + expect(atom.config.get('spell-check.knownWords').length).toBe( + 0 + ); + + await conditionPromise( + () => getMisspellingMarkers().length === 1 + ); + + expect(getMisspellingMarkers()[0].isValid()).toBe(true); + editorElement.dispatchEvent(new MouseEvent('contextmenu')); + + // Check that the 'Add to Known Words' entry is added to the context menu. + // There should be 1 entry for 'Add to Known Words' and 2 entries for the line separators. + expect(spellCheckModule.contextMenuEntries.length).toBe(3); + const commandName = 'spell-check:correct-misspelling-0'; + const menuItemLabel = 'together'; + + { + const editorCommands = atom.commands.findCommands({ + target: editorElement, + }); + const correctionCommand = editorCommands.filter( + (command) => command.name === commandName + )[0]; + + const correctionMenuItem = atom.contextMenu.itemSets.filter( + (item) => item.items[0].label === menuItemLabel + )[0]; + expect(correctionCommand).toBeDefined; + expect(correctionMenuItem).toBeDefined; + } + + atom.commands.dispatch(editorElement, commandName); + // Check that the misspelling is added as a known word, that there are no more misspelling + // markers in the editor, and that the context menu entries are properly disposed. + waitsFor(() => getMisspellingMarkers().length === 0); + expect(atom.config.get('spell-check.knownWords').length).toBe( + 1 + ); + expect(spellCheckModule.contextMenuEntries.length).toBe(0); + + { + const editorCommands = atom.commands.findCommands({ + target: editorElement, + }); + const correctionCommand = editorCommands.filter( + (command) => command.name === commandName + )[0]; + const correctionMenuItem = atom.contextMenu.itemSets.filter( + (item) => item.items[0].label === menuItemLabel + )[0]; + expect(correctionCommand).toBeUndefined(); + expect(correctionMenuItem).toBeUndefined(); + } + })); + }); + + describe('when the editor is destroyed', () => + it('destroys all misspelling markers', async function () { + atom.config.set('spell-check.useLocales', true); + editor.setText('mispelling'); + atom.config.set('spell-check.grammars', ['source.js']); + + await conditionPromise(() => getMisspellingMarkers().length > 0); + editor.destroy(); + expect(getMisspellingMarkers().length).toBe(0); + // Check that all the views have been cleaned up. + expect(spellCheckModule.updateViews().length).toBe(0); + })); + + describe('when using checker plugins', function () { + it('no opinion on input means correctly spells', async function () { + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-1-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-2-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-3-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-4-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./eot-spec-checker') + ); + editor.setText('eot'); + atom.config.set('spell-check.grammars', ['source.js']); + + await conditionPromise(() => getMisspellingMarkers().length === 1); + editor.destroy(); + expect(getMisspellingMarkers().length).toBe(0); + }); + + it('correctly spelling k1a', async function () { + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-1-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-2-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-3-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-4-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./eot-spec-checker') + ); + editor.setText('k1a eot'); + atom.config.set('spell-check.grammars', ['source.js']); + await conditionPromise(() => getMisspellingMarkers().length === 1); + editor.destroy(); + expect(getMisspellingMarkers().length).toBe(0); + }); + + it('correctly mispelling k2a', async function () { + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-1-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-2-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-3-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-4-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./eot-spec-checker') + ); + editor.setText('k2a eot'); + atom.config.set('spell-check.grammars', ['source.js']); + + await conditionPromise(() => getMisspellingMarkers().length === 2); + editor.destroy(); + expect(getMisspellingMarkers().length).toBe(0); + }); + + it('correctly mispelling k2a with text in middle', async function () { + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-1-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-2-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-3-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-4-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./eot-spec-checker') + ); + editor.setText('k2a good eot'); + atom.config.set('spell-check.grammars', ['source.js']); + + await conditionPromise(() => getMisspellingMarkers().length === 2); + editor.destroy(); + expect(getMisspellingMarkers().length).toBe(0); + }); + + it('word is both correct and incorrect is correct', async function () { + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-1-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-2-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-3-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-4-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./eot-spec-checker') + ); + editor.setText('k0a eot'); + atom.config.set('spell-check.grammars', ['source.js']); + + await conditionPromise(() => getMisspellingMarkers().length === 1); + editor.destroy(); + expect(getMisspellingMarkers().length).toBe(0); + }); + + it('word is correct twice is correct', async function () { + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-1-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-2-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-3-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-4-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./eot-spec-checker') + ); + editor.setText('k0b eot'); + atom.config.set('spell-check.grammars', ['source.js']); + + await conditionPromise(() => getMisspellingMarkers().length === 1); + editor.destroy(); + expect(getMisspellingMarkers().length).toBe(0); + }); + + it('word is incorrect twice is incorrect', async function () { + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-1-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-2-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-3-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-4-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./eot-spec-checker') + ); + editor.setText('k0c eot'); + atom.config.set('spell-check.grammars', ['source.js']); + + await conditionPromise(() => getMisspellingMarkers().length === 2); + editor.destroy(); + expect(getMisspellingMarkers().length).toBe(0); + }); + + it('treats unknown Unicode words as incorrect', async function () { + spellCheckModule.consumeSpellCheckers( + require.resolve('./eot-spec-checker') + ); + editor.setText('абырг eot'); + atom.config.set('spell-check.grammars', ['source.js']); + expect(atom.config.get('spell-check.knownWords').length).toBe(0); + + await conditionPromise(() => getMisspellingMarkers().length > 0); + const markers = getMisspellingMarkers(); + expect(markers[0].getBufferRange()).toEqual({ + start: { row: 0, column: 6 }, + end: { row: 0, column: 9 }, + }); + + editor.destroy(); + expect(getMisspellingMarkers().length).toBe(0); + }); + + it('treats known Unicode words as correct', async function () { + spellCheckModule.consumeSpellCheckers( + require.resolve('./known-unicode-spec-checker') + ); + spellCheckModule.consumeSpellCheckers( + require.resolve('./eot-spec-checker') + ); + editor.setText('абырг eot'); + atom.config.set('spell-check.grammars', ['source.js']); + expect(atom.config.get('spell-check.knownWords').length).toBe(0); + + await conditionPromise(() => getMisspellingMarkers().length === 1); + editor.destroy(); + expect(getMisspellingMarkers().length).toBe(0); + }); + }); + + // These tests are only run on Macs because the CI for Windows doesn't have + // spelling provided. + if (env.isSystemSupported() && env.isDarwin()) { + describe('when using system checker plugin', function () { + it('marks chzz as not a valid word but cheese is', async function () { + atom.config.set('spell-check.useSystem', true); + editor.setText('cheese chzz'); + atom.config.set('spell-check.grammars', ['source.js']); + + await conditionPromise(() => { + markers = getMisspellingMarkers(); + console.log(markers); + return ( + markers.length === 1 && + markers[0].getBufferRange().start.column === 7 && + markers[0].getBufferRange().end.column === 11 + ); + }); + + editor.destroy(); + expect(getMisspellingMarkers().length).toBe(0); + }); + + it('marks multiple words as wrong', async function () { + atom.config.set('spell-check.useSystem', true); + editor.setText('cheese chz chzz chzzz'); + atom.config.set('spell-check.grammars', ['source.js']); + + await conditionPromise(() => { + markers = getMisspellingMarkers(); + return ( + markers.length === 3 && + markers[0].getBufferRange().start.column === 7 && + markers[0].getBufferRange().end.column === 10 && + markers[1].getBufferRange().start.column === 11 && + markers[1].getBufferRange().end.column === 15 && + markers[2].getBufferRange().start.column === 16 && + markers[2].getBufferRange().end.column === 21 + ); + }); + + editor.destroy(); + expect(getMisspellingMarkers().length).toBe(0); + }); + }); + } else { + console.log( + "Skipping system checker tests because they don't run on Windows CI or Linux" + ); + } +}); diff --git a/packages/spell-check/styles/spell-check.atom-text-editor.less b/packages/spell-check/styles/spell-check.atom-text-editor.less new file mode 100644 index 0000000000..ee7013b395 --- /dev/null +++ b/packages/spell-check/styles/spell-check.atom-text-editor.less @@ -0,0 +1,7 @@ +.spell-check-misspelling .region { + border-bottom: 2px dotted hsla(0, 100%, 60%, 0.75); +} + +.spell-check-corrections { + width: 25em !important; +} diff --git a/yarn.lock b/yarn.lock index ce0a0ae080..b1772d6ef5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6519,15 +6519,15 @@ log-symbols@^4.1.0: is-unicode-supported "^0.1.0" log4js@*: - version "6.7.0" - resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.7.0.tgz#fff671a74b2f6e956d135c3c756c79072809a23b" - integrity sha512-KA0W9ffgNBLDj6fZCq/lRbgR6ABAodRIDHrZnS48vOtfKa4PzWImb0Md1lmGCdO3n3sbCm/n1/WmrNlZ8kCI3Q== + version "6.9.1" + resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.9.1.tgz#aba5a3ff4e7872ae34f8b4c533706753709e38b6" + integrity sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g== dependencies: date-format "^4.0.14" debug "^4.3.4" flatted "^3.2.7" rfdc "^1.3.0" - streamroller "^3.1.3" + streamroller "^3.1.5" loglevel-plugin-prefix@^0.8.4: version "0.8.4" @@ -8776,9 +8776,8 @@ spdx-license-ids@^3.0.0: resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.12.tgz#69077835abe2710b65f03969898b6637b505a779" integrity sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA== -"spell-check@https://codeload.github.com/atom/spell-check/legacy.tar.gz/refs/tags/v0.77.1": +"spell-check@file:packages/spell-check": version "0.77.1" - resolved "https://codeload.github.com/atom/spell-check/legacy.tar.gz/refs/tags/v0.77.1#231d2ed87c0333a1c1d652301f58062a4bd689b9" dependencies: atom-pathspec "^0.0.0" atom-select-list "^0.7.0" @@ -8899,10 +8898,10 @@ stream-via@^1.0.4: resolved "https://registry.yarnpkg.com/stream-via/-/stream-via-1.0.4.tgz#8dccbb0ac909328eb8bc8e2a4bd3934afdaf606c" integrity sha512-DBp0lSvX5G9KGRDTkR/R+a29H+Wk2xItOF+MpZLLNDWbEV9tGPnqLPxHEYjmiz8xGtJHRIqmI+hCjmNzqoA4nQ== -streamroller@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-3.1.3.tgz#d95689a8c29b30d093525d0baffe6616fd62ca7e" - integrity sha512-CphIJyFx2SALGHeINanjFRKQ4l7x2c+rXYJ4BMq0gd+ZK0gi4VT8b+eHe2wi58x4UayBAKx4xtHpXT/ea1cz8w== +streamroller@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-3.1.5.tgz#1263182329a45def1ffaef58d31b15d13d2ee7ff" + integrity sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw== dependencies: date-format "^4.0.14" debug "^4.3.4"