From 8b7ab9cede26e082a86f43463cc5e0e3a224ff61 Mon Sep 17 00:00:00 2001 From: "Dylan R. E. Moonfire" Date: Mon, 2 May 2016 00:48:34 -0500 Subject: [PATCH 1/5] Changed spell-checking to be plugin-based. * Changed the package to allow for external packages to provide additional checking. (Closes #74) - Disabled the task-based handling because of passing plugins. - Two default plugins are included: system-based dictionaries and "known words". - Suggestions and "add to dictionary" are also provided via interfaces. (Closes #10) - Modified various calls so they are aware of the where the buffer is located. * Modified system to allow for multiple plugins/checkers to identify correctness. - Incorrect words must be incorrect for all checkers. - Any checker that treats a word as valid is considered valid for the buffer. * Extracted system-based dictionary support into separate checker. - System dictionaries can now check across multiple system locales. - Locale selection can be changed via package settings. (Closes #21) - Multiple locales can be selected. (Closes #11) - External search paths can be used for Linux and OS X. - Default language is based on the process environment, with a fallback to the browser, before finally using `en-US` as a fallback. * Extracted hard-coded approved list into a separate checker. - User can add additional "known words" via settings. - Added an option to add more known words via the suggestion dialog. * Updated ignore files and added EditorConfig settings for development. * Various coffee-centric formatting. --- .editorconfig | 15 ++ .gitignore | 2 + README.md | 42 ++++ lib/corrections-view.coffee | 47 +++- lib/known-words-checker.coffee | 62 +++++ lib/main.coffee | 62 ++++- lib/spell-check-handler.coffee | 45 ++-- lib/spell-check-manager.coffee | 306 +++++++++++++++++++++++ lib/spell-check-task.coffee | 49 +++- lib/spell-check-view.coffee | 30 ++- lib/system-checker.coffee | 91 +++++++ package.json | 48 +++- script/benchmark.coffee | 0 spec/spell-check-spec.coffee | 62 +++-- styles/spell-check.atom-text-editor.less | 4 + 15 files changed, 783 insertions(+), 82 deletions(-) create mode 100644 .editorconfig create mode 100644 lib/known-words-checker.coffee create mode 100644 lib/spell-check-manager.coffee create mode 100644 lib/system-checker.coffee mode change 100755 => 100644 script/benchmark.coffee diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..e98111f9 --- /dev/null +++ b/.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/.gitignore b/.gitignore index fd4f2b06..84f486e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ +*~ +npm-debug.log node_modules .DS_Store diff --git a/README.md b/README.md index 5d0d2bba..41b69bfc 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,45 @@ for the _Spell Check_ package. Here are some examples: `source.coffee`, ## Changing the dictionary Currently, only the English (US) dictionary is supported. Follow [this issue](https://github.com/atom/spell-check/issues/11) for updates. + +## Writing Providers + +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 object describing the spell-check plugin or an array of them. Each spell-check plugin must implement the following: + +* 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. +* getName(): string + * Returns the human-readable name for the plugin. This is used on the status screen and in various dialogs/popups. +* 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. +* getStatus(): string + * Returns a string that describes the current status or state of the plugin. This is to allow a plugin to identify why it is disabled or to indicate version numbers. This can be formatted for Markdown, including links, and will be displayed on a status screen (eventually). +* providesSpelling(buffer): boolean + * If this returns true, then the plugin will be included when looking for incorrect and correct words via the `check` function. +* check(buffer, text: string): { correct: [range], incorrect: [range] } + * `correct` and `incorrect` are both optional. If they are skipped, then it means the plugin does not contribute to the correctness or incorrectness of any word. If they are present but empty, it means there are no correct or incorrect words respectively. + * The `range` objects have a signature of `{ start: X, end: Y }`. +* providesSuggestions(buffer): boolean + * If this returns true, then the plugin will be included when querying for suggested words via the `suggest` function. +* suggest(buffer, 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(buffer): boolean + * If this returns true, then the dictionary allows a word to be added to the dictionary. +* getAddingTargets(buffer): [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/lib/corrections-view.coffee b/lib/corrections-view.coffee index 80d542a4..0835f3a6 100644 --- a/lib/corrections-view.coffee +++ b/lib/corrections-view.coffee @@ -3,9 +3,9 @@ module.exports = class CorrectionsView extends SelectListView - initialize: (@editor, @corrections, @marker) -> + initialize: (@editor, @corrections, @marker, @updateTarget, @updateCallback) -> super - @addClass('corrections popover-list') + @addClass('spell-check-corrections corrections popover-list') @attach() attach: -> @@ -20,22 +20,51 @@ class CorrectionsView extends SelectListView @cancel() @remove() - confirmed: (correction) -> + confirmed: (item) -> @cancel() - return unless correction + return unless item @editor.transact => - @editor.setSelectedBufferRange(@marker.getBufferRange()) - @editor.insertText(correction) + if item.isSuggestion + # Update the buffer with the correction. + @editor.setSelectedBufferRange(@marker.getRange()) + @editor.insertText(item.suggestion) + else + # Build up the arguments object for this buffer and text. + projectPath = null + relativePath = null + if @editor.buffer?.file?.path + [projectPath, relativePath] = atom.project.relativizePath(@editor.buffer.file.path) + args = { + id: @id, + projectPath: projectPath, + relativePath: relativePath + } + + # Send the "add" request to the plugin. + item.plugin.add args, item + + # Update the buffer to handle the corrections. + @updateCallback.bind(@updateTarget)() cancelled: -> @overlayDecoration.destroy() @restoreFocus() - viewForItem: (word) -> - element = document.createElement('li') - element.textContent = word + viewForItem: (item) -> + 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. + em = document.createElement "em" + em.textContent = item.label + element.appendChild em element + getFilterKey: -> + "label" + selectNextItemView: -> super false diff --git a/lib/known-words-checker.coffee b/lib/known-words-checker.coffee new file mode 100644 index 00000000..450f1964 --- /dev/null +++ b/lib/known-words-checker.coffee @@ -0,0 +1,62 @@ +class KnownWordsChecker + enableAdd: false + spelling: null + checker: null + + constructor: (knownWords) -> + # Set up the spelling manager we'll be using. + spellingManager = require "spelling-manager" + @spelling = new spellingManager.TokenSpellingManager + @checker = new spellingManager.BufferSpellingChecker @spelling + + # Set our known words. + @setKnownWords knownWords + + deactivate: -> + console.log(@getid() + "deactivating") + + getId: -> "spell-check:known-words" + getName: -> "Known Words" + getPriority: -> 10 + isEnabled: -> true + getStatus: -> "Working correctly." + providesSpelling: (args) -> true + providesSuggestions: (args) -> true + providesAdding: (args) -> @enableAdd + + check: (args, text) -> + ranges = [] + checked = @checker.check text + for token in checked + if token.status is 1 + ranges.push {start: token.start, end: token.end} + {correct: ranges} + + suggest: (args, word) -> + @spelling.suggest word + + getAddingTargets: (args) -> + if @enableAdd + [{sensitive: false, label: "Add to " + @getName()}] + else + [] + + add: (args, target) -> + c = atom.config.get 'spell-check.knownWords' + c.push target.word + atom.config.set 'spell-check.knownWords', c + + setAddKnownWords: (newValue) -> + @enableAdd = newValue + + setKnownWords: (knownWords) -> + # Clear out the old list. + @spelling.sensitive = {} + @spelling.insensitive = {} + + # Add the new ones into the list. + if knownWords + for ignore in knownWords + @spelling.add ignore + +module.exports = KnownWordsChecker diff --git a/lib/main.coffee b/lib/main.coffee index e211914a..d3a2f178 100644 --- a/lib/main.coffee +++ b/lib/main.coffee @@ -2,13 +2,52 @@ SpellCheckView = null spellCheckViews = {} module.exports = + instance: null + activate: -> + # Create the unified handler for all spellchecking. + SpellCheckerManager = require './spell-check-manager.coffee' + @instance = SpellCheckerManager + that = this + + # Initialize the spelling manager so it can perform deferred loading. + @instance.locales = atom.config.get('spell-check.locales') + @instance.localePaths = atom.config.get('spell-check.localePaths') + @instance.useLocales = atom.config.get('spell-check.useLocales') + + atom.config.onDidChange 'spell-check.locales', ({newValue, oldValue}) -> + that.instance.locales = atom.config.get('spell-check.locales') + that.instance.reloadLocales() + that.updateViews() + atom.config.onDidChange 'spell-check.localePaths', ({newValue, oldValue}) -> + that.instance.localePaths = atom.config.get('spell-check.localePaths') + that.instance.reloadLocales() + that.updateViews() + atom.config.onDidChange 'spell-check.useLocales', ({newValue, oldValue}) -> + that.instance.useLocales = atom.config.get('spell-check.useLocales') + that.instance.reloadLocales() + that.updateViews() + + # Add in the settings for known words checker. + @instance.knownWords = atom.config.get('spell-check.knownWords') + @instance.addKnownWords = atom.config.get('spell-check.addKnownWords') + + atom.config.onDidChange 'spell-check.knownWords', ({newValue, oldValue}) -> + that.instance.knownWords = atom.config.get('spell-check.knownWords') + that.instance.reloadKnownWords() + that.updateViews() + atom.config.onDidChange 'spell-check.addKnownWords', ({newValue, oldValue}) -> + that.instance.addKnownWords = atom.config.get('spell-check.addKnownWords') + that.instance.reloadKnownWords() + that.updateViews() + + # Hook up the UI and processing. @commandSubscription = atom.commands.add 'atom-workspace', 'spell-check:toggle': => @toggle() @viewsByEditor = new WeakMap @disposable = atom.workspace.observeTextEditors (editor) => SpellCheckView ?= require './spell-check-view' - spellCheckView = new SpellCheckView(editor) + spellCheckView = new SpellCheckView(editor, @instance) # save the {editor} into a map editorId = editor.id @@ -17,14 +56,29 @@ module.exports = spellCheckViews[editorId]['active'] = true @viewsByEditor.set(editor, spellCheckView) - misspellingMarkersForEditor: (editor) -> - @viewsByEditor.get(editor).markerLayer.getMarkers() - deactivate: -> + @instance.deactivate() + @instance = null @commandSubscription.dispose() @commandSubscription = null @disposable.dispose() + consumeSpellCheckers: (plugins) -> + unless plugins instanceof Array + plugins = [ plugins ] + + for plugin in plugins + @instance.addPluginChecker plugin + + misspellingMarkersForEditor: (editor) -> + @viewsByEditor.get(editor).markerLayer.getMarkers() + + updateViews: -> + for editorId of spellCheckViews + view = spellCheckViews[editorId] + if view['active'] + view['view'].updateMisspellings() + # Internal: Toggles the spell-check activation state. toggle: -> editorId = atom.workspace.getActiveTextEditor().id diff --git a/lib/spell-check-handler.coffee b/lib/spell-check-handler.coffee index b44054d1..fbf1d820 100644 --- a/lib/spell-check-handler.coffee +++ b/lib/spell-check-handler.coffee @@ -1,32 +1,17 @@ -SpellChecker = require 'spellchecker' +# Background task for checking the text of a buffer and returning the +# spelling. Since this can be an expensive operation, it is intended to be run +# in the background with the results returned asynchronously. +backgroundCheck = (data) -> + # Load a manager in memory and let it initialize. + SpellCheckerManager = require './spell-check-manager.coffee' + instance = SpellCheckerManager + instance.locales = data.args.locales + instance.localePaths = data.args.localePaths + instance.useLocales = data.args.useLocales + instance.knownWords = data.args.knownWords + instance.addKnownWords = data.args.addKnownWords -module.exports = ({id, text}) -> - SpellChecker.add("GitHub") - SpellChecker.add("github") + misspellings = instance.check data.args, data.text + {id: data.args.id, misspellings} - misspelledCharacterRanges = SpellChecker.checkSpelling(text) - - row = 0 - rangeIndex = 0 - characterIndex = 0 - misspellings = [] - while characterIndex < text.length and rangeIndex < misspelledCharacterRanges.length - lineBreakIndex = text.indexOf('\n', characterIndex) - if lineBreakIndex is -1 - lineBreakIndex = Infinity - - loop - range = misspelledCharacterRanges[rangeIndex] - if range and range.start < lineBreakIndex - misspellings.push([ - [row, range.start - characterIndex], - [row, range.end - characterIndex] - ]) - rangeIndex++ - else - break - - characterIndex = lineBreakIndex + 1 - row++ - - {id, misspellings} +module.exports = backgroundCheck diff --git a/lib/spell-check-manager.coffee b/lib/spell-check-manager.coffee new file mode 100644 index 00000000..d5866435 --- /dev/null +++ b/lib/spell-check-manager.coffee @@ -0,0 +1,306 @@ +class SpellCheckerManager + checkers: [] + locales: [] + localePaths: [] + useLocales: false + localeCheckers: null + knownWords: [] + addKnownWords: false + knownWordsChecker: null + + addPluginChecker: (checker) -> + console.log "spell-check: addPluginChecker:", checker + @addSpellChecker checker + addSpellChecker: (checker) -> + console.log "spell-check: addSpellChecker:", checker + @checkers.push checker + + removeSpellChecker: (spellChecker) -> + @checkers = @checkers.filter (plugin) -> plugin isnt spellChecker + + check: (args, text) -> + # Make sure our deferred initialization is done. + @init() + + # We need a couple packages. + 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. + correct = new multirange.MultiRange([]) + incorrects = [] + + for checker in @checkers + # We only care if this plugin contributes to checking spelling. + if not checker.isEnabled() or not checker.providesSpelling(args) + continue + + # Get the results which includes positive (correct) and negative (incorrect) + # ranges. + results = checker.check(args, text) + + if results.correct + for range in results.correct + correct.appendRange(range.start, range.end) + + if results.incorrect + newIncorrect = new multirange.MultiRange([]) + incorrects.push(newIncorrect) + + for range in 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. + misspellings = [] + + if incorrects.length is 0 + return {id: args.id, 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. + intersection = null + index = 1 + + for incorrect in incorrects + if intersection is null + intersection = incorrect + else + intersection.intersect(incorrects[index]) + + # If we have no intersection, then nothing to report as a problem. + if intersection.length is 0 + return {id: args.id, 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) + + # Convert the text ranges (index into the string) into Atom buffer + # coordinates ( row and column). + row = 0 + rangeIndex = 0 + lineBeginIndex = 0 + while lineBeginIndex < text.length and 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. + lineEndIndex = text.indexOf('\n', lineBeginIndex) + if lineEndIndex is -1 + lineEndIndex = Infinity + + # Loop through and get all the ranegs for this line. + loop + range = intersection.ranges[rangeIndex] + if range and 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. + lineRange = new multirange.MultiRange([]).appendRange(lineBeginIndex, lineEndIndex) + 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. + @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. + {id: args.id, misspellings: misspellings} + + suggest: (args, word) -> + # Make sure our deferred initialization is done. + @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. + suggestions = [] + + for checker in @checkers + # We only care if this plugin contributes to checking to suggestions. + if not checker.isEnabled() or not checker.providesSuggestions(args) + continue + + # Get the suggestions for this word. + index = 0 + priority = checker.getPriority() + + for suggestion in checker.suggest(args, word) + suggestions.push {isSuggestion: true, priority: priority, index: index++, suggestion: suggestion, label: suggestion} + + # Once we have the suggestions, then sort them to intersperse the results. + keys = Object.keys(suggestions).sort (key1, key2) -> + value1 = suggestions[key1] + value2 = suggestions[key2] + weight1 = value1.priority + value1.index + weight2 = value2.priority + value2.index + + if weight1 isnt 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. + results = [] + seen = [] + for key in keys + s = suggestions[key] + if seen.hasOwnProperty s.suggestion + continue + results.push s + seen[s.suggestion] = 1 + + # We also grab the "add to dictionary" listings. + that = this + keys = Object.keys(@checkers).sort (key1, key2) -> + value1 = that.checkers[key1] + value2 = that.checkers[key2] + value1.getPriority() - value2.getPriority() + + for key in keys + # We only care if this plugin contributes to checking to suggestions. + checker = @checkers[key] + if not checker.isEnabled() or not checker.providesAdding(args) + continue + + # Add all the targets to the list. + targets = checker.getAddingTargets args + for target in targets + target.plugin = checker + target.word = word + target.isSuggestion = false + results.push target + + # Return the resulting list of options. + 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. + 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. + parts = substring.split /(\s+)/ + substringIndex = 0 + for part in parts + if not /\s+/.test part + markBeginIndex = range[0] - lineBeginIndex + substringIndex + markEndIndex = markBeginIndex + part.length + misspellings.push([[row, markBeginIndex], [row, markEndIndex]]) + + substringIndex += part.length + + return + + # There were no spaces, so just return the entire list. + misspellings.push([ + [row, range[0] - lineBeginIndex], + [row, range[1] - lineBeginIndex] + ]) + + init: -> + # See if we need to initialize the system checkers. + if @localeCheckers is null + # Initialize the collection. If we aren't using any, then stop doing anything. + console.log "spell-check: loading locales", @useLocales, @locales + @localeCheckers = [] + + if @useLocales + # If we have a blank location, use the default based on the process. If + # set, then it will be the best language. + if not @locales.length + defaultLocale = process.env.LANG + if defaultLocale + @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 not @locales.length + defaultLocale = navigator.language + if defaultLocale and defaultLocale.length is 5 + separatorChar = defaultLocale.charAt(2) + if separatorChar is '_' or separatorChar is '-' + @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 not @locales.length + @locales = ['en_US'] + + # Go through the new list and create new locale checkers. + SystemChecker = require "./system-checker" + for locale in @locales + checker = new SystemChecker locale, @localePaths + @addSpellChecker checker + @localeCheckers.push checker + + # See if we need to reload the known words. + if @knownWordsChecker is null + console.log "spell-check: loading known words" + KnownWordsChecker = require './known-words-checker.coffee' + @knownWordsChecker = new KnownWordsChecker @knownWords + @knownWordsChecker.enableAdd = @addKnownWords + @addSpellChecker @knownWordsChecker + + deactivate: -> + @checkers = [] + @locales = [] + @localePaths = [] + @useLocales= false + @localeCheckers = null + @knownWords = [] + @addKnownWords = false + @knownWordsChecker = null + + reloadLocales: -> + if @localeCheckers + console.log "spell-check: unloading locales" + for localeChecker in @localeCheckers + @removeSpellChecker localeChecker + @localeCheckers = null + + reloadKnownWords: -> + if @knownWordsChecker + console.log "spell-check: unloading known words" + @removeSpellChecker @knownWordsChecker + @knownWordsChecker = null + +manager = new SpellCheckerManager +module.exports = manager + +# KnownWordsChecker = require './known-words-checker.coffee' +# knownWords = atom.config.get('spell-check.knownWords') +# addKnownWords = atom.config.get('spell-check.addKnownWords') diff --git a/lib/spell-check-task.coffee b/lib/spell-check-task.coffee index 709c7810..64b8ecd9 100644 --- a/lib/spell-check-task.coffee +++ b/lib/spell-check-task.coffee @@ -1,14 +1,14 @@ {Task} = require 'atom' idCounter = 0 -# Wraps a single {Task} so that multiple views reuse the same task but it is -# terminated once all views are removed. module.exports = class SpellCheckTask + @handler: null @callbacksById: {} - constructor: -> + constructor: (handler) -> @id = idCounter++ + @handler = handler terminate: -> delete @constructor.callbacksById[@id] @@ -17,12 +17,45 @@ class SpellCheckTask @constructor.task?.terminate() @constructor.task = null - start: (text) -> - @constructor.task ?= new Task(require.resolve('./spell-check-handler')) - @constructor.task?.start {@id, text}, @constructor.dispatchMisspellings + start: (buffer) -> + # Figure out the paths since we need that for checkers that are project-specific. + projectPath = null + relativePath = null + if buffer?.file?.path + [projectPath, relativePath] = atom.project.relativizePath(buffer.file.path) + + # We also need to pull out the spelling manager to we can grab fields from that. + instance = require('./spell-check-manager') + + # Create an arguments that passes everything over. Since tasks are run in a + # separate background process, they can't use the initialized values from + # our instance and buffer. We also can't pass complex items across since + # they are serialized as JSON. + args = { + id: @id, + projectPath: projectPath, + relativePath: relativePath, + locales: instance.locales, + localePaths: instance.localePaths, + useLocales: instance.useLocales, + knownWords: instance.knownWords, + addKnownWords: instance.addKnownWords + } + text = buffer.getText() + + # At the moment, we are having some trouble passing the external plugins + # over to a Task. So, we do this inline for the time being. + # # Dispatch the request. + # handlerFilename = require.resolve './spell-check-handler' + # @constructor.task ?= new Task handlerFilename + # @constructor.task?.start {args, text}, @constructor.dispatchMisspellings + + # Call the checking in a blocking manner. + data = instance.check args, text + @constructor.dispatchMisspellings data onDidSpellCheck: (callback) -> @constructor.callbacksById[@id] = callback - @dispatchMisspellings: ({id, misspellings}) => - @callbacksById[id]?(misspellings) + @dispatchMisspellings: (data) => + @callbacksById[data.id]?(data.misspellings) diff --git a/lib/spell-check-view.coffee b/lib/spell-check-view.coffee index 966614bf..d2858f74 100644 --- a/lib/spell-check-view.coffee +++ b/lib/spell-check-view.coffee @@ -10,19 +10,19 @@ class SpellCheckView @content: -> @div class: 'spell-check' - constructor: (@editor) -> + constructor: (@editor, @handler) -> @disposables = new CompositeDisposable - @task = new SpellCheckTask() + @task = new SpellCheckTask(@handler) @initializeMarkerLayer() @correctMisspellingCommand = atom.commands.add atom.views.getView(@editor), 'spell-check:correct-misspelling', => if marker = @markerLayer.findMarkers({containsBufferPosition: @editor.getCursorBufferPosition()})[0] CorrectionsView ?= require './corrections-view' @correctionsView?.destroy() - @correctionsView = new CorrectionsView(@editor, @getCorrections(marker), marker) + @correctionsView = new CorrectionsView(@editor, @getCorrections(marker), marker, this, @updateMisspellings) @task.onDidSpellCheck (misspellings) => - @detroyMarkers() + @destroyMarkers() @addMarkers(misspellings) if @buffer? @disposables.add @editor.onDidChangePath => @@ -59,7 +59,7 @@ class SpellCheckView @correctionsView?.remove() unsubscribeFromBuffer: -> - @detroyMarkers() + @destroyMarkers() if @buffer? @bufferDisposable.dispose() @@ -77,7 +77,7 @@ class SpellCheckView grammar = @editor.getGrammar().scopeName _.contains(atom.config.get('spell-check.grammars'), grammar) - detroyMarkers: -> + destroyMarkers: -> @markerLayer.destroy() @markerLayerDecoration.destroy() @initializeMarkerLayer() @@ -89,11 +89,21 @@ class SpellCheckView updateMisspellings: -> # Task::start can throw errors atom/atom#3326 try - @task.start(@buffer.getText()) + @task.start @editor.buffer catch error console.warn('Error starting spell check task', error.stack ? error) getCorrections: (marker) -> - SpellChecker ?= require 'spellchecker' - misspelling = @editor.getTextInBufferRange(marker.getBufferRange()) - corrections = SpellChecker.getCorrectionsForMisspelling(misspelling) + # Build up the arguments object for this buffer and text. + projectPath = null + relativePath = null + if @buffer?.file?.path + [projectPath, relativePath] = atom.project.relativizePath(@buffer.file.path) + args = { + projectPath: projectPath, + relativePath: relativePath + } + + # Get the misspelled word and then request corrections. + misspelling = @editor.getTextInBufferRange marker.getRange() + corrections = @handler.suggest args, misspelling diff --git a/lib/system-checker.coffee b/lib/system-checker.coffee new file mode 100644 index 00000000..5cd44376 --- /dev/null +++ b/lib/system-checker.coffee @@ -0,0 +1,91 @@ +spellchecker = require 'spellchecker' + +class SystemChecker + spellchecker: null + locale: null + enabled: true + reason: null + paths: null + + constructor: (locale, paths) -> + @locale = locale + @paths = paths + + deactivate: -> + console.log @getId(), "deactivating" + + getId: -> "spell-check:" + @locale.toLowerCase().replace("_", "-") + getName: -> "System Dictionary (" + @locale + ")" + getPriority: -> 100 # System level data, has no user input. + isEnabled: -> @enabled + getStatus: -> + if @enabled + "Working correctly." + else + @reason + + providesSpelling: (args) -> true + providesSuggestions: (args) -> true + providesAdding: (args) -> false # Users shouldn't be adding to the system dictionary. + + check: (args, text) -> + @deferredInit() + {incorrect: @spellchecker.checkSpelling(text)} + + suggest: (args, word) -> + @deferredInit() + @spellchecker.getCorrectionsForMisspelling(word) + + deferredInit: -> + # If we already have a spellchecker, then we don't have to do anything. + if @spellchecker + return + + # Initialize the spell checker which can take some time. + @spellchecker = new spellchecker.Spellchecker + + # Windows uses its own API and the paths are unimportant, only attempting + # to load it works. + if /win32/.test process.platform + if @spellchecker.setDictionary @locale, "C:\\" + console.log @getId(), "Windows API" + return + + # Check the paths supplied by the user. + for path in @paths + if @spellchecker.setDictionary @locale, path + console.log @getId(), path + return + + # For Linux, we have to search the directory paths to find the dictionary. + if /linux/.test process.platform + if @spellchecker.setDictionary @locale, "/usr/share/hunspell" + console.log @getId(), "/usr/share/hunspell" + return + if @spellchecker.setDictionary @locale, "/usr/share/myspell/dicts" + console.log @getId(), "/usr/share/myspell/dicts" + return + + # OS X uses the following paths. + if /darwin/.test process.platform + if @spellchecker.setDictionary @locale, "/" + console.log @getId(), "OS X API" + return + if @spellchecker.setDictionary @locale, "/System/Library/Spelling" + console.log @getId(), "/System/Library/Spelling" + return + + # Try the packaged library inside the node_modules. `getDictionaryPath` is + # not available, so we have to fake it. This will only work for en-US. + path = require 'path' + vendor = path.join __dirname, "..", "node_modules", "spellchecker", "vendor", "hunspell_dictionaries" + if @spellchecker.setDictionary @locale, vendor + console.log @getId(), vendor + return + + # If we fell through all the if blocks, then we couldn't load the dictionary. + @enabled = false + @reason = "Cannot find dictionary for " + @locale + "." + console.log @getId(), "Can't load " + @locale + ": " + @reason + +module.exports = SystemChecker diff --git a/package.json b/package.json index 974dd63f..db521e60 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "description": "Highlights misspelled words and shows possible corrections.", "dependencies": { "atom-space-pen-views": "^2.0.0", + "multi-integer-range": "^1.4.0", "spellchecker": "3.2.3", + "spelling-manager": "0.3.0", "underscore-plus": "^1" }, "repository": "https://github.com/atom/spell-check", @@ -23,10 +25,54 @@ "text.plain", "text.plain.null-grammar" ], - "description": "List of scopes for languages which will be checked for misspellings. See [the README](https://github.com/atom/spell-check#spell-check-package-) for more information on finding the correct scope for a specific language." + "description": "List of scopes for languages which will be checked for misspellings. See [the README](https://github.com/atom/spell-check#spell-check-package-) for more information on finding the correct scope for a specific language.", + "order": "1" + }, + "useLocales": { + "type": "boolean", + "default": "true", + "description": "If unchecked, then the locales below will not be used for spell-checking and no spell-checking using system dictionaries will be provided.", + "order": "2" + }, + "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`. For Windows, the appropriate language must be installed using *Region and language settings*. If this is blank, then the default language for the user will be used.", + "order": 3 + }, + "localePaths": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "description": "List of additional paths to search for dictionary files. If a locale cannot be found in these, the internal code will attempt to find it using common search paths. This is used for Linux and OS X.", + "order": 4 + }, + "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": 5 + }, + "addKnownWords": { + "type": "boolean", + "default": false, + "description": "If checked, then the suggestions will include options to add to the known words list above.", + "order": 6 } }, "devDependencies": { "coffeelint": "^1.9.7" + }, + "consumedServices": { + "spell-check": { + "versions": { + "^1.0.0": "consumeSpellCheckers" + } + } } } diff --git a/script/benchmark.coffee b/script/benchmark.coffee old mode 100755 new mode 100644 diff --git a/spec/spell-check-spec.coffee b/spec/spell-check-spec.coffee index 24b920f0..beaab865 100644 --- a/spec/spell-check-spec.coffee +++ b/spec/spell-check-spec.coffee @@ -32,6 +32,7 @@ describe "Spell check", -> editorElement = atom.views.getView(editor) it "decorates all misspelled words", -> + atom.config.set('spell-check.locales', ['en-US']) editor.setText("This middle of thiss\nsentencts\n\nhas issues and the \"edn\" 'dsoe' too") atom.config.set('spell-check.grammars', ['source.js']) @@ -47,8 +48,9 @@ describe "Spell check", -> expect(textForMarker(misspellingMarkers[2])).toEqual "edn" expect(textForMarker(misspellingMarkers[3])).toEqual "dsoe" - it "doesn't consider our company's name to be a spelling error", -> - editor.setText("GitHub (aka github): Where codez are built.") + it "decorates misspelled words with a leading space", -> + atom.config.set('spell-check.locales', ['en-US']) + editor.setText("\nchok bok") atom.config.set('spell-check.grammars', ['source.js']) misspellingMarkers = null @@ -57,12 +59,14 @@ describe "Spell check", -> misspellingMarkers.length > 0 runs -> - expect(misspellingMarkers.length).toBe 1 - expect(textForMarker(misspellingMarkers[0])).toBe "codez" - - it "hides decorations when a misspelled word is edited", -> - editor.setText('notaword') - advanceClock(editor.getBuffer().getStoppedChangingDelay()) + expect(misspellingMarkers.length).toBe 2 + expect(textForMarker(misspellingMarkers[0])).toEqual "chok" + expect(textForMarker(misspellingMarkers[1])).toEqual "bok" + + it "allow entering of known words", -> + atom.config.set('spell-check.knownWords', ['GitHub', '!github', 'codez']) + atom.config.set('spell-check.locales', ['en-US']) + editor.setText("GitHub (aka github): Where codez are builz.") atom.config.set('spell-check.grammars', ['source.js']) misspellingMarkers = null @@ -72,25 +76,40 @@ describe "Spell check", -> runs -> expect(misspellingMarkers.length).toBe 1 - editor.moveToEndOfLine() - editor.insertText('a') - advanceClock(editor.getBuffer().getStoppedChangingDelay()) + expect(textForMarker(misspellingMarkers[0])).toBe "builz" - misspellingMarkers = getMisspellingMarkers() + # This test was commented out because we moved away from the Task handling. + # Once that is enabled, this test should be valid again. + #it "hides decorations when a misspelled word is edited", -> + # editor.setText('notaword') + # advanceClock(editor.getBuffer().getStoppedChangingDelay()) + # atom.config.set('spell-check.grammars', ['source.js']) - expect(misspellingMarkers.length).toBe 1 - expect(misspellingMarkers[0].isValid()).toBe false + # misspellingMarkers = null + # waitsFor -> + # misspellingMarkers = getMisspellingMarkers() + # misspellingMarkers.length > 0 + + # runs -> + # expect(misspellingMarkers.length).toBe 1 + # editor.moveToEndOfLine() + # editor.insertText('a') + # advanceClock(editor.getBuffer().getStoppedChangingDelay()) + + # misspellingMarkers = getMisspellingMarkers() + + # expect(misspellingMarkers.length).toBe 1 + # expect(misspellingMarkers[0].isValid()).toBe false describe "when spell checking for a grammar is removed", -> it "removes all the misspellings", -> + atom.config.set('spell-check.locales', ['en-US']) editor.setText('notaword') advanceClock(editor.getBuffer().getStoppedChangingDelay()) atom.config.set('spell-check.grammars', ['source.js']) - misspellingMarkers = null waitsFor -> - misspellingMarkers = getMisspellingMarkers() - misspellingMarkers.length > 0 + getMisspellingMarkers().length > 0 runs -> expect(getMisspellingMarkers().length).toBe 1 @@ -99,14 +118,13 @@ describe "Spell check", -> describe "when spell checking for a grammar is toggled off", -> it "removes all the misspellings", -> + atom.config.set('spell-check.locales', ['en-US']) editor.setText('notaword') advanceClock(editor.getBuffer().getStoppedChangingDelay()) atom.config.set('spell-check.grammars', ['source.js']) - misspellingMarkers = null waitsFor -> - misspellingMarkers = getMisspellingMarkers() - misspellingMarkers.length > 0 + getMisspellingMarkers().length > 0 runs -> expect(getMisspellingMarkers().length).toBe 1 @@ -115,6 +133,7 @@ describe "Spell check", -> describe "when the editor's grammar changes to one that does not have spell check enabled", -> it "removes all the misspellings", -> + atom.config.set('spell-check.locales', ['en-US']) editor.setText('notaword') advanceClock(editor.getBuffer().getStoppedChangingDelay()) atom.config.set('spell-check.grammars', ['source.js']) @@ -130,6 +149,7 @@ describe "Spell check", -> describe "when 'spell-check:correct-misspelling' is triggered on the editor", -> 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", -> + atom.config.set('spell-check.locales', ['en-US']) editor.setText('tofether') advanceClock(editor.getBuffer().getStoppedChangingDelay()) atom.config.set('spell-check.grammars', ['source.js']) @@ -157,6 +177,7 @@ describe "Spell check", -> describe "when the cursor touches a misspelling that has no corrections", -> it "displays a message saying no corrections found", -> + atom.config.set('spell-check.locales', ['en-US']) editor.setText('zxcasdfysyadfyasdyfasdfyasdfyasdfyasydfasdf') advanceClock(editor.getBuffer().getStoppedChangingDelay()) atom.config.set('spell-check.grammars', ['source.js']) @@ -172,6 +193,7 @@ describe "Spell check", -> describe "when the editor is destroyed", -> it "destroys all misspelling markers", -> + atom.config.set('spell-check.locales', ['en-US']) editor.setText('mispelling') atom.config.set('spell-check.grammars', ['source.js']) diff --git a/styles/spell-check.atom-text-editor.less b/styles/spell-check.atom-text-editor.less index 6a89a4b1..ee7013b3 100644 --- a/styles/spell-check.atom-text-editor.less +++ b/styles/spell-check.atom-text-editor.less @@ -1,3 +1,7 @@ .spell-check-misspelling .region { border-bottom: 2px dotted hsla(0, 100%, 60%, 0.75); } + +.spell-check-corrections { + width: 25em !important; +} From dff766e6ab3b62b47be1c39f02d0b57bb047b3d3 Mon Sep 17 00:00:00 2001 From: "Dylan R. E. Moonfire" Date: Mon, 2 May 2016 00:37:45 -0500 Subject: [PATCH 2/5] Updated plugin-based implementation from provided feedback. - Switched to a task-based system. - Reworked the plugins so they return full paths to a `require`able instance. - Modified the activation/deactivation to create a single background task for all checking. - Initial request for corrections will have a delay while an in-process manager is created. - Added flow control via `send` and `emit` to communicate configuration changes with the task process. - Changed to a word-based searching implementation. - Added a simplified tokenizer that includes some accented characters. - An internal cache is used for a single round of check to reduce overhead. - Removed the use of integer ranges and simplified processing logic. - Removed a number of `console.log` calls used for debugging. - Updated documentation. - Split out plugin creation into a separate document with a reference in the `README.md`. --- PLUGINS.md | 65 ++++++++ README.md | 44 +----- lib/corrections-view.coffee | 2 +- lib/known-words-checker.coffee | 13 +- lib/main.coffee | 120 +++++++++----- lib/spell-check-handler.coffee | 45 ++++-- lib/spell-check-manager.coffee | 275 +++++++++++++++++++++------------ lib/spell-check-task.coffee | 39 +---- lib/spell-check-view.coffee | 15 +- lib/system-checker.coffee | 28 +++- package.json | 2 +- 11 files changed, 409 insertions(+), 239 deletions(-) create mode 100644 PLUGINS.md diff --git a/PLUGINS.md b/PLUGINS.md new file mode 100644 index 00000000..6813070c --- /dev/null +++ b/PLUGINS.md @@ -0,0 +1,65 @@ +# 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.coffee' + +The path given must resolve to a singleton instance of a class. + + class ProjectChecker + # Magical code + checker = new ProjectChecker() + module.exports = checker + +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/projet/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. +* checkArray(checkArgs, words: string[]): boolean?[] + * This takes an array of words in a given line. This will be called once for every line inside the buffer. It also also not include words already requested earlier in the buffer. + * The output is an array of the same length as words which has three values, one for each word given: + * `null`: The checker provides no opinion on correctness. + * `false`: The word is specifically false. + * `true`: The word is correctly spelled. + * True always takes precedence, then false. If every checker provides `null`, then the word is considered spelled correctly. +* 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/README.md b/README.md index 41b69bfc..693be438 100644 --- a/README.md +++ b/README.md @@ -26,46 +26,10 @@ for the _Spell Check_ package. Here are some examples: `source.coffee`, ## Changing the dictionary -Currently, only the English (US) dictionary is supported. Follow [this issue](https://github.com/atom/spell-check/issues/11) for updates. +To change the language of the dictionary, set the "Locales" configuration option to the IEFT tag (en-US, fr-FR, etc). More than one language can be used, simply separate them by commas. -## Writing Providers +For Windows 8 and 10, you must install the language using the regional settings before the language can be chosen inside Atom. -The `spell-check` allows for additional dictionaries to be used at the same time using Atom's `providedServices` element in the `package.json` file. +## Plugins - "providedServices": { - "spell-check": { - "versions": { - "1.0.0": "nameOfFunctionToProvideSpellCheck" - } - } - } - -The `nameOfFunctionToProvideSpellCheck` function may return either a single object describing the spell-check plugin or an array of them. Each spell-check plugin must implement the following: - -* 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. -* getName(): string - * Returns the human-readable name for the plugin. This is used on the status screen and in various dialogs/popups. -* 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. -* getStatus(): string - * Returns a string that describes the current status or state of the plugin. This is to allow a plugin to identify why it is disabled or to indicate version numbers. This can be formatted for Markdown, including links, and will be displayed on a status screen (eventually). -* providesSpelling(buffer): boolean - * If this returns true, then the plugin will be included when looking for incorrect and correct words via the `check` function. -* check(buffer, text: string): { correct: [range], incorrect: [range] } - * `correct` and `incorrect` are both optional. If they are skipped, then it means the plugin does not contribute to the correctness or incorrectness of any word. If they are present but empty, it means there are no correct or incorrect words respectively. - * The `range` objects have a signature of `{ start: X, end: Y }`. -* providesSuggestions(buffer): boolean - * If this returns true, then the plugin will be included when querying for suggested words via the `suggest` function. -* suggest(buffer, 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(buffer): boolean - * If this returns true, then the dictionary allows a word to be added to the dictionary. -* getAddingTargets(buffer): [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. +_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. diff --git a/lib/corrections-view.coffee b/lib/corrections-view.coffee index 0835f3a6..068a303f 100644 --- a/lib/corrections-view.coffee +++ b/lib/corrections-view.coffee @@ -26,7 +26,7 @@ class CorrectionsView extends SelectListView @editor.transact => if item.isSuggestion # Update the buffer with the correction. - @editor.setSelectedBufferRange(@marker.getRange()) + @editor.setSelectedBufferRange(@marker.getBufferRange()) @editor.insertText(item.suggestion) else # Build up the arguments object for this buffer and text. diff --git a/lib/known-words-checker.coffee b/lib/known-words-checker.coffee index 450f1964..5344c654 100644 --- a/lib/known-words-checker.coffee +++ b/lib/known-words-checker.coffee @@ -13,7 +13,8 @@ class KnownWordsChecker @setKnownWords knownWords deactivate: -> - console.log(@getid() + "deactivating") + #console.log(@getid() + "deactivating") + return getId: -> "spell-check:known-words" getName: -> "Known Words" @@ -32,6 +33,16 @@ class KnownWordsChecker ranges.push {start: token.start, end: token.end} {correct: ranges} + checkArray: (args, words) -> + results = [] + for word, index in words + result = @check args, word + if result.correct.length is 0 + results.push null + else + results.push true + results + suggest: (args, word) -> @spelling.suggest word diff --git a/lib/main.coffee b/lib/main.coffee index d3a2f178..e2f4853d 100644 --- a/lib/main.coffee +++ b/lib/main.coffee @@ -1,45 +1,49 @@ +{Task} = require 'atom' + SpellCheckView = null spellCheckViews = {} module.exports = - instance: null - activate: -> - # Create the unified handler for all spellchecking. - SpellCheckerManager = require './spell-check-manager.coffee' - @instance = SpellCheckerManager + # Set up the task for handling spell-checking in the background. This is + # what is actually in the background. + handlerFilename = require.resolve './spell-check-handler' + @task ?= new Task handlerFilename + + # Set up our callback to track when settings changed. that = this + @task.on "spell-check:settings-changed", (ignore) -> + console.log("updating views because of change", that) + that.updateViews() - # Initialize the spelling manager so it can perform deferred loading. - @instance.locales = atom.config.get('spell-check.locales') - @instance.localePaths = atom.config.get('spell-check.localePaths') - @instance.useLocales = atom.config.get('spell-check.useLocales') + # 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. + @globalArgs = { + locales: atom.config.get('spell-check.locales'), + localePaths: atom.config.get('spell-check.localePaths'), + useLocales: atom.config.get('spell-check.useLocales'), + knownWords: atom.config.get('spell-check.knownWords'), + addKnownWords: atom.config.get('spell-check.addKnownWords'), + checkerPaths: [] + } + @sendGlobalArgs() atom.config.onDidChange 'spell-check.locales', ({newValue, oldValue}) -> - that.instance.locales = atom.config.get('spell-check.locales') - that.instance.reloadLocales() - that.updateViews() + that.globalArgs.locales = atom.config.get('spell-check.locales') + that.sendGlobalArgs() atom.config.onDidChange 'spell-check.localePaths', ({newValue, oldValue}) -> - that.instance.localePaths = atom.config.get('spell-check.localePaths') - that.instance.reloadLocales() - that.updateViews() + that.globalArgs.localePaths = atom.config.get('spell-check.localePaths') + that.sendGlobalArgs() atom.config.onDidChange 'spell-check.useLocales', ({newValue, oldValue}) -> - that.instance.useLocales = atom.config.get('spell-check.useLocales') - that.instance.reloadLocales() - that.updateViews() - - # Add in the settings for known words checker. - @instance.knownWords = atom.config.get('spell-check.knownWords') - @instance.addKnownWords = atom.config.get('spell-check.addKnownWords') - + that.globalArgs.useLocales = atom.config.get('spell-check.useLocales') + that.sendGlobalArgs() atom.config.onDidChange 'spell-check.knownWords', ({newValue, oldValue}) -> - that.instance.knownWords = atom.config.get('spell-check.knownWords') - that.instance.reloadKnownWords() - that.updateViews() + that.globalArgs.knownWords = atom.config.get('spell-check.knownWords') + that.sendGlobalArgs() atom.config.onDidChange 'spell-check.addKnownWords', ({newValue, oldValue}) -> - that.instance.addKnownWords = atom.config.get('spell-check.addKnownWords') - that.instance.reloadKnownWords() - that.updateViews() + that.globalArgs.addKnownWords = atom.config.get('spell-check.addKnownWords') + that.sendGlobalArgs() # Hook up the UI and processing. @commandSubscription = atom.commands.add 'atom-workspace', @@ -47,28 +51,54 @@ module.exports = @viewsByEditor = new WeakMap @disposable = atom.workspace.observeTextEditors (editor) => SpellCheckView ?= require './spell-check-view' - spellCheckView = new SpellCheckView(editor, @instance) + + # 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. + spellCheckView = new SpellCheckView(editor, @task, (ignore) => @getInstance @globalArgs) # save the {editor} into a map editorId = editor.id spellCheckViews[editorId] = {} spellCheckViews[editorId]['view'] = spellCheckView spellCheckViews[editorId]['active'] = true - @viewsByEditor.set(editor, spellCheckView) + @viewsByEditor.set editor, spellCheckView deactivate: -> - @instance.deactivate() + console.log "spell-check: deactiving" + @instance?.deactivate() @instance = null + @task?.terminate() + @task = null @commandSubscription.dispose() @commandSubscription = null + + # 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. + @viewsByEditor = new WeakMap + + # Finish up by disposing everything else associated with the plugin. @disposable.dispose() - consumeSpellCheckers: (plugins) -> - unless plugins instanceof Array - plugins = [ plugins ] + # Registers any Atom packages that provide our service. Because we use a Task, + # we have to load the plugin's checker in both that service and in the Atom + # process (for coming up with corrections). Since everything passed to the + # task must be JSON serialized, we pass the full path to the task and let it + # require it on that end. + consumeSpellCheckers: (checkerPaths) -> + # Normalize it so we always have an array. + unless checkerPaths instanceof Array + checkerPaths = [ checkerPaths ] - for plugin in plugins - @instance.addPluginChecker plugin + # Go through and add any new plugins to the list. + changed = false + for checkerPath in checkerPaths + if checkerPath not in @globalArgs.checkerPaths + @task?.send {type: "checker", checkerPath: checkerPath} + @instance?.addCheckerPath checkerPath + @globalArgs.checkerPaths.push checkerPath + changed = true misspellingMarkersForEditor: (editor) -> @viewsByEditor.get(editor).markerLayer.getMarkers() @@ -79,6 +109,22 @@ module.exports = if view['active'] view['view'].updateMisspellings() + sendGlobalArgs: -> + @task.send {type: "global", global: @globalArgs} + + # Retrieves, creating if required, a spelling manager for use with synchronous + # operations such as retrieving corrections. + getInstance: (globalArgs) -> + if not @instance + SpellCheckerManager = require './spell-check-manager.coffee' + @instance = SpellCheckerManager + @instance.setGlobalArgs globalArgs + + for checkerPath in globalArgs.checkerPaths + @instance.addCheckerPath checkerPath + + return @instance + # Internal: Toggles the spell-check activation state. toggle: -> editorId = atom.workspace.getActiveTextEditor().id diff --git a/lib/spell-check-handler.coffee b/lib/spell-check-handler.coffee index fbf1d820..49a8f2f9 100644 --- a/lib/spell-check-handler.coffee +++ b/lib/spell-check-handler.coffee @@ -1,17 +1,34 @@ -# Background task for checking the text of a buffer and returning the -# spelling. Since this can be an expensive operation, it is intended to be run -# in the background with the results returned asynchronously. -backgroundCheck = (data) -> - # Load a manager in memory and let it initialize. - SpellCheckerManager = require './spell-check-manager.coffee' - instance = SpellCheckerManager - instance.locales = data.args.locales - instance.localePaths = data.args.localePaths - instance.useLocales = data.args.useLocales - instance.knownWords = data.args.knownWords - instance.addKnownWords = data.args.addKnownWords +# This is the task local handler for the manager so we can reuse the manager +# throughout the life of the task. +SpellCheckerManager = require './spell-check-manager.coffee' +instance = SpellCheckerManager +instance.isTask = true + +# Because of the heavy use of configuration options for the packages and our +# inability to listen/access config settings from this process, we need to get +# the settings in a roundabout manner via sending messages through the process. +# This has an additional complexity because other packages may need to send +# messages through the main `spell-check` task so they can update *their* +# checkers inside the task process. +# +# Below the dispatcher for all messages from the server. The type argument is +# require, how it is handled is based on the type. +process.on "message", (message) -> + switch + when message.type is "global" then loadGlobalSettings message.global + when message.type is "checker" then instance.addCheckerPath message.checkerPath + # Quietly ignore unknown message types. - misspellings = instance.check data.args, data.text - {id: data.args.id, misspellings} +# This handles updating the global configuration settings for +# `spell-check` along with the built-in checkers (locale and knownWords). +loadGlobalSettings = (data) -> + instance.setGlobalArgs data + +# This is the function that is called by the views whenever data changes. It +# returns with the misspellings along with an identifier that will let the task +# wrapper route it to the appropriate view. +backgroundCheck = (data) -> + misspellings = instance.check data, data.text + {id: data.id, misspellings: misspellings.misspellings} module.exports = backgroundCheck diff --git a/lib/spell-check-manager.coffee b/lib/spell-check-manager.coffee index d5866435..745f547b 100644 --- a/lib/spell-check-manager.coffee +++ b/lib/spell-check-manager.coffee @@ -1,5 +1,6 @@ class SpellCheckerManager checkers: [] + checkerPaths: [] locales: [] localePaths: [] useLocales: false @@ -7,10 +8,76 @@ class SpellCheckerManager knownWords: [] addKnownWords: false knownWordsChecker: null + isTask: false + + setGlobalArgs: (data) -> + # We need underscore to do the array comparisons. + _ = require "underscore-plus" + + # Check to see if any values have changed. When they have, they clear out + # the applicable checker which forces a reload. + changed = false + removeLocaleCheckers = false + removeKnownWordsChecker = false + + if not _.isEqual(@locales, data.locales) + # If the locales is blank, then we always create a default one. However, + # any new data.locales will remain blank. + if not @localeCheckers or data.locales?.length isnt 0 + @locales = data.locales + removeLocaleCheckers = true + if not _.isEqual(@localePaths, data.localePaths) + @localePaths = data.localePaths + removeLocaleCheckers = true + if @useLocales isnt data.useLocales + @useLocales = data.useLocales + removeLocaleCheckers = true + if @knownWords isnt data.knownWords + @knownWords = data.knownWords + removeKnownWordsChecker = true + changed = true + if @addKnownWords isnt data.addKnownWords + @addKnownWords = data.addKnownWords + removeKnownWordsChecker = true + # We don't update `changed` since it doesn't affect the plugins. + + # If we made a change to the checkers, we need to remove them from the + # system so they can be reinitialized. + if removeLocaleCheckers and @localeCheckers + checkers = @localeCheckers + for checker in checkers + @removeSpellChecker @checker + @localeCheckers = null + changed = true + + if removeKnownWordsChecker and @knownWordsChecker + @removeSpellChecker @knownWordsChecker + @knownWordsChecker = null + changed = true + + # If we had any change to the system, we need to send a message back to the + # main process so it can trigger a recheck which then calls `init` which + # then locales any changed locales or known words checker. + if changed + @emitSettingsChanged() + + emitSettingsChanged: -> + if @isTask + emit("spell-check:settings-changed") + + addCheckerPath: (checkerPath) -> + checker = require checkerPath + console.log "spell-check: addCheckerPath:", checkerPath, checker + @addPluginChecker checker addPluginChecker: (checker) -> - console.log "spell-check: addPluginChecker:", checker + # Add the spell checker to the list. @addSpellChecker checker + + # We only emit a settings change for plugins since the core checkers are + # handled in a different manner. + @emitSettingsChanged() + addSpellChecker: (checker) -> console.log "spell-check: addSpellChecker:", checker @checkers.push checker @@ -22,115 +89,129 @@ class SpellCheckerManager # Make sure our deferred initialization is done. @init() - # We need a couple packages. - 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. - correct = new multirange.MultiRange([]) - incorrects = [] - - for checker in @checkers - # We only care if this plugin contributes to checking spelling. - if not checker.isEnabled() or not checker.providesSpelling(args) - continue - - # Get the results which includes positive (correct) and negative (incorrect) - # ranges. - results = checker.check(args, text) - - if results.correct - for range in results.correct - correct.appendRange(range.start, range.end) - - if results.incorrect - newIncorrect = new multirange.MultiRange([]) - incorrects.push(newIncorrect) - - for range in 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. - misspellings = [] - - if incorrects.length is 0 - return {id: args.id, 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. - intersection = null - index = 1 - - for incorrect in incorrects - if intersection is null - intersection = incorrect - else - intersection.intersect(incorrects[index]) - - # If we have no intersection, then nothing to report as a problem. - if intersection.length is 0 - return {id: args.id, 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) - - # Convert the text ranges (index into the string) into Atom buffer - # coordinates ( row and column). + # Unfortunately, the version of Javascript that Atom uses doesn't really + # play well with Unicode characters. This means we can't use `\w+` to cover + # all the characters. Instead, we have to use a convoluted method until + # Chromium and Atom learn how to work with Unicode. The range here is to + # cover a "reasonable" range of characters including some accented ones. + wRegexClass = '[\\w\\u0080-\\u00FF\\u0100-\\u017F\\u0180-\\u024F]' + wRegexPattern = '(' + wRegexClass + '+(?:\'' + wRegexClass + '+)?)' + wRegex = new RegExp wRegexPattern + + # Splitting apart a line into words can be somewhat difficult and language- + # dependent. Until we have the ability to have a language-specific setting + # or services, we use a generic method. + natural = require "natural" + tokenizer = new natural.RegexpTokenizer {pattern: wRegex} + + # We have a small local cache here. Because of how the checkers work today, + # if "baz" is wrong in one place, it will be wrong in the entire document. + # Likewise, English and most other languages has a lot of redundancy with + # pronouns, articles, and the like that if we don't have to call the checker + # then we will be more performant. We keep the cache locale to a single + # buffer call so we don't have to worry about invalidation. + cache = {} + + # Because we check individual words, we can do this on a line-by-line basis + # and keep the coordinate processing relatively simple since ranges are + # given as character indexes within a given line. This loop goes through the + # the input and processes each line individually. row = 0 - rangeIndex = 0 lineBeginIndex = 0 - while lineBeginIndex < text.length and rangeIndex < intersection.ranges.length + misspellings = [] + + while lineBeginIndex < text.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. lineEndIndex = text.indexOf('\n', lineBeginIndex) if lineEndIndex is -1 lineEndIndex = Infinity - # Loop through and get all the ranegs for this line. - loop - range = intersection.ranges[rangeIndex] - if range and 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. - lineRange = new multirange.MultiRange([]).appendRange(lineBeginIndex, lineEndIndex) - 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. - @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 - + # Grab the next line from the text buffer and split it into tokens. + line = text.substring lineBeginIndex, lineEndIndex + tokens = tokenizer.tokenize line + + # Loop through the tokens and process each one that looks like a word. We + # build up a list of every word (token) and its position within the line. + startSearch = 0 + words = [] + for token in tokens + # If we don't have at least one character, skip it. + if not /\w/.test(token) + startSearch += token.length + continue + + # Figure out where this token appears in the buffer. We have to do this + # since we'll be skipping over whitespace and non-word tokens. Once we + # have the components, add it to the list. + tokenIndex = line.indexOf token, startSearch + startSearch = tokenIndex + token.length + words.push {word: token, start: tokenIndex, end: startSearch, t: line.substring(tokenIndex, startSearch)} + + # We have a collection of words with their position. The next step is to + # gather up all the tokens that aren't in the cache into an arrow so we + # can send them to the checkers. + unknownWords = [] + for word in words + # See if we are in the cache, if so, then skip it. This applies even to + # the second occurance of a new word because we'll be processing them + # in sequence and it will be resolved properly. + if word.word in cache + continue + + # Cache a null so we don't duplciate and push the word into what we will + # request from the checkers. + cache[word.word] = null + unknownWords.push word.word + + # If we have unknown words, we need to pass it into the checkers. This + # will populate the cache object with the answers. + @checkWords args, unknownWords, cache + + # Go through the list of words again, this time adding misspellings to + # the resulting list. + for word in words + # The results will always be in the cache because of the block above us. + isCorrect = cache[word.word] + + if isCorrect is false + misspellings.push([[row, word.start], [row, word.end]]) + + # Move to the next line lineBeginIndex = lineEndIndex + 1 row++ # Return the resulting misspellings. {id: args.id, misspellings: misspellings} + checkWords: (args, words, cache) -> + # If we have an empty list, then don't bother. + if words.length is 0 + return + + # Go through the active checkers and verify the list against each one. + for checker in @checkers + # We only care if this plugin contributes to checking spelling. + if not checker.isEnabled() or not checker.providesSpelling(args) + continue + + # Get the results from the checker. We pass in a list of words and we get + # an array of results back. For each one, it is either `false` for + # incorrect, `true` for correct, or `null` for no opinion. + results = checker.checkArray args, words + + # Go through the results and merge them. `false` and `true` both override + # `null`; `true` overrides everything. + for result, index in results + word = words[index] + cache[word] = switch + when result is true or cache[word] is true + true + when result is false + false + else + cache[word] + suggest: (args, word) -> # Make sure our deferred initialization is done. @init() @@ -269,7 +350,7 @@ class SpellCheckerManager # See if we need to reload the known words. if @knownWordsChecker is null - console.log "spell-check: loading known words" + console.log "spell-check: loading known words", @knownWords KnownWordsChecker = require './known-words-checker.coffee' @knownWordsChecker = new KnownWordsChecker @knownWords @knownWordsChecker.enableAdd = @addKnownWords diff --git a/lib/spell-check-task.coffee b/lib/spell-check-task.coffee index 64b8ecd9..d53d3280 100644 --- a/lib/spell-check-task.coffee +++ b/lib/spell-check-task.coffee @@ -1,4 +1,3 @@ -{Task} = require 'atom' idCounter = 0 module.exports = @@ -6,17 +5,12 @@ class SpellCheckTask @handler: null @callbacksById: {} - constructor: (handler) -> + constructor: (@task) -> @id = idCounter++ - @handler = handler terminate: -> delete @constructor.callbacksById[@id] - if Object.keys(@constructor.callbacksById).length is 0 - @constructor.task?.terminate() - @constructor.task = null - start: (buffer) -> # Figure out the paths since we need that for checkers that are project-specific. projectPath = null @@ -24,35 +18,14 @@ class SpellCheckTask if buffer?.file?.path [projectPath, relativePath] = atom.project.relativizePath(buffer.file.path) - # We also need to pull out the spelling manager to we can grab fields from that. - instance = require('./spell-check-manager') - - # Create an arguments that passes everything over. Since tasks are run in a - # separate background process, they can't use the initialized values from - # our instance and buffer. We also can't pass complex items across since - # they are serialized as JSON. + # Submit the spell check request to the background task. args = { id: @id, - projectPath: projectPath, - relativePath: relativePath, - locales: instance.locales, - localePaths: instance.localePaths, - useLocales: instance.useLocales, - knownWords: instance.knownWords, - addKnownWords: instance.addKnownWords + projectPath, + relativePath, + text: buffer.getText() } - text = buffer.getText() - - # At the moment, we are having some trouble passing the external plugins - # over to a Task. So, we do this inline for the time being. - # # Dispatch the request. - # handlerFilename = require.resolve './spell-check-handler' - # @constructor.task ?= new Task handlerFilename - # @constructor.task?.start {args, text}, @constructor.dispatchMisspellings - - # Call the checking in a blocking manner. - data = instance.check args, text - @constructor.dispatchMisspellings data + @task?.start args, @constructor.dispatchMisspellings onDidSpellCheck: (callback) -> @constructor.callbacksById[@id] = callback diff --git a/lib/spell-check-view.coffee b/lib/spell-check-view.coffee index d2858f74..79d9bc75 100644 --- a/lib/spell-check-view.coffee +++ b/lib/spell-check-view.coffee @@ -10,10 +10,10 @@ class SpellCheckView @content: -> @div class: 'spell-check' - constructor: (@editor, @handler) -> + constructor: (@editor, @task, @getInstance) -> @disposables = new CompositeDisposable - @task = new SpellCheckTask(@handler) @initializeMarkerLayer() + @taskWrapper = new SpellCheckTask @task @correctMisspellingCommand = atom.commands.add atom.views.getView(@editor), 'spell-check:correct-misspelling', => if marker = @markerLayer.findMarkers({containsBufferPosition: @editor.getCursorBufferPosition()})[0] @@ -21,7 +21,7 @@ class SpellCheckView @correctionsView?.destroy() @correctionsView = new CorrectionsView(@editor, @getCorrections(marker), marker, this, @updateMisspellings) - @task.onDidSpellCheck (misspellings) => + @taskWrapper.onDidSpellCheck (misspellings) => @destroyMarkers() @addMarkers(misspellings) if @buffer? @@ -52,7 +52,7 @@ class SpellCheckView destroy: -> @unsubscribeFromBuffer() @disposables.dispose() - @task.terminate() + @taskWrapper.terminate() @markerLayer.destroy() @markerLayerDecoration.destroy() @correctMisspellingCommand.dispose() @@ -89,7 +89,7 @@ class SpellCheckView updateMisspellings: -> # Task::start can throw errors atom/atom#3326 try - @task.start @editor.buffer + @taskWrapper.start @editor.buffer catch error console.warn('Error starting spell check task', error.stack ? error) @@ -105,5 +105,6 @@ class SpellCheckView } # Get the misspelled word and then request corrections. - misspelling = @editor.getTextInBufferRange marker.getRange() - corrections = @handler.suggest args, misspelling + instance = @getInstance() + misspelling = @editor.getTextInBufferRange marker.getBufferRange() + corrections = instance.suggest args, misspelling diff --git a/lib/system-checker.coffee b/lib/system-checker.coffee index 5cd44376..ee34daa9 100644 --- a/lib/system-checker.coffee +++ b/lib/system-checker.coffee @@ -12,7 +12,8 @@ class SystemChecker @paths = paths deactivate: -> - console.log @getId(), "deactivating" + #console.log @getId(), "deactivating" + return getId: -> "spell-check:" + @locale.toLowerCase().replace("_", "-") getName: -> "System Dictionary (" + @locale + ")" @@ -32,6 +33,17 @@ class SystemChecker @deferredInit() {incorrect: @spellchecker.checkSpelling(text)} + checkArray: (args, words) -> + @deferredInit() + results = [] + for word, index in words + result = @check args, word + if result.incorrect.length is 0 + results.push null + else + results.push false + results + suggest: (args, word) -> @deferredInit() @spellchecker.getCorrectionsForMisspelling(word) @@ -48,31 +60,31 @@ class SystemChecker # to load it works. if /win32/.test process.platform if @spellchecker.setDictionary @locale, "C:\\" - console.log @getId(), "Windows API" + #console.log @getId(), "Windows API" return # Check the paths supplied by the user. for path in @paths if @spellchecker.setDictionary @locale, path - console.log @getId(), path + #console.log @getId(), path return # For Linux, we have to search the directory paths to find the dictionary. if /linux/.test process.platform if @spellchecker.setDictionary @locale, "/usr/share/hunspell" - console.log @getId(), "/usr/share/hunspell" + #console.log @getId(), "/usr/share/hunspell" return if @spellchecker.setDictionary @locale, "/usr/share/myspell/dicts" - console.log @getId(), "/usr/share/myspell/dicts" + #console.log @getId(), "/usr/share/myspell/dicts" return # OS X uses the following paths. if /darwin/.test process.platform if @spellchecker.setDictionary @locale, "/" - console.log @getId(), "OS X API" + #console.log @getId(), "OS X API" return if @spellchecker.setDictionary @locale, "/System/Library/Spelling" - console.log @getId(), "/System/Library/Spelling" + #console.log @getId(), "/System/Library/Spelling" return # Try the packaged library inside the node_modules. `getDictionaryPath` is @@ -80,7 +92,7 @@ class SystemChecker path = require 'path' vendor = path.join __dirname, "..", "node_modules", "spellchecker", "vendor", "hunspell_dictionaries" if @spellchecker.setDictionary @locale, vendor - console.log @getId(), vendor + #console.log @getId(), vendor return # If we fell through all the if blocks, then we couldn't load the dictionary. diff --git a/package.json b/package.json index db521e60..ecdbe53c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "Highlights misspelled words and shows possible corrections.", "dependencies": { "atom-space-pen-views": "^2.0.0", - "multi-integer-range": "^1.4.0", + "natural": "^0.4.0", "spellchecker": "3.2.3", "spelling-manager": "0.3.0", "underscore-plus": "^1" From f47f54cd8c06406bbc88cba01e9f46caa4475c8f Mon Sep 17 00:00:00 2001 From: "Dylan R. E. Moonfire" Date: Wed, 6 Jul 2016 22:25:22 -0500 Subject: [PATCH 3/5] Converting checking to use `multi-integer-range` with a task-based checking. * Updated unit tests to verify functionality of multiple plugin responses. * Taught system checking how to identify correct as well as incorrect. * Updated documentation to reflect plugin logic. --- PLUGINS.md | 16 +-- README.md | 23 ++-- appveyor.yml | 27 ++++ lib/known-words-checker.coffee | 13 +- lib/main.coffee | 22 +-- lib/spell-check-handler.coffee | 2 +- lib/spell-check-manager.coffee | 221 +++++++++++++++---------------- lib/system-checker.coffee | 13 +- package.json | 5 +- spec/eot-spec-checker.coffee | 8 ++ spec/known-1-spec-checker.coffee | 8 ++ spec/known-2-spec-checker.coffee | 8 ++ spec/known-3-spec-checker.coffee | 8 ++ spec/known-4-spec-checker.coffee | 8 ++ spec/spec-checker.coffee | 50 +++++++ spec/spell-check-spec.coffee | 168 ++++++++++++++++++++--- 16 files changed, 414 insertions(+), 186 deletions(-) create mode 100644 appveyor.yml create mode 100644 spec/eot-spec-checker.coffee create mode 100644 spec/known-1-spec-checker.coffee create mode 100644 spec/known-2-spec-checker.coffee create mode 100644 spec/known-3-spec-checker.coffee create mode 100644 spec/known-4-spec-checker.coffee create mode 100644 spec/spec-checker.coffee diff --git a/PLUGINS.md b/PLUGINS.md index 6813070c..57480b26 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -30,7 +30,7 @@ A common parameter type is `checkArgs`, this is a hash with the following signat args = { projectPath: "/absolute/path/to/project/root, - relativePath: "relative/path/from/projet/root" + relativePath: "relative/path/from/project/root" } Below the required methods for the checker instance. @@ -44,13 +44,13 @@ Below the required methods for the checker instance. * 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. -* checkArray(checkArgs, words: string[]): boolean?[] - * This takes an array of words in a given line. This will be called once for every line inside the buffer. It also also not include words already requested earlier in the buffer. - * The output is an array of the same length as words which has three values, one for each word given: - * `null`: The checker provides no opinion on correctness. - * `false`: The word is specifically false. - * `true`: The word is correctly spelled. - * True always takes precedence, then false. If every checker provides `null`, then the word is considered spelled correctly. +* 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] diff --git a/README.md b/README.md index 693be438..35754857 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ -# Spell Check Package [![Build Status](https://travis-ci.org/atom/spell-check.svg?branch=master)](https://travis-ci.org/atom/spell-check) +# Spell Check package +[![OS X Build Status](https://travis-ci.org/atom/spell-check.svg?branch=master)](https://travis-ci.org/atom/spell-check) [![Windows Build Status](https://ci.appveyor.com/api/projects/status/1620a5reqw6kdolv/branch/master?svg=true)](https://ci.appveyor.com/project/Atom/spell-check/branch/master) [![Dependency Status](https://david-dm.org/atom/spell-check.svg)](https://david-dm.org/atom/spell-check) + +**This is a test package. Disable the built-in `spell-check` module before using it.** Highlights misspelling in Atom and shows possible corrections. -Use `cmd+shift+:` to bring up the list of corrections when your cursor is on a -misspelled word. +Use cmd-shift-: 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: @@ -12,17 +14,10 @@ By default spell check is enabled for the following files: * Git Commit Message * AsciiDoc -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/atom/command-palette) -(cmd+shift+p), 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`. +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/atom/command-palette) +(cmd-shift-p), 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 diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 00000000..2b0fde43 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,27 @@ +version: "{build}" + +platform: x64 + +branches: + only: + - master + +clone_depth: 10 + +skip_tags: true + +environment: + APM_TEST_PACKAGES: + + matrix: + - ATOM_CHANNEL: stable + - ATOM_CHANNEL: beta + +install: + - ps: Install-Product node 4 + +build_script: + - ps: iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/atom/ci/master/build-package.ps1')) + +test: off +deploy: off diff --git a/lib/known-words-checker.coffee b/lib/known-words-checker.coffee index 5344c654..57f67d0c 100644 --- a/lib/known-words-checker.coffee +++ b/lib/known-words-checker.coffee @@ -19,7 +19,8 @@ class KnownWordsChecker getId: -> "spell-check:known-words" getName: -> "Known Words" getPriority: -> 10 - isEnabled: -> true + isEnabled: -> @spelling.sensitive or @spelling.insensitive + getStatus: -> "Working correctly." providesSpelling: (args) -> true providesSuggestions: (args) -> true @@ -33,16 +34,6 @@ class KnownWordsChecker ranges.push {start: token.start, end: token.end} {correct: ranges} - checkArray: (args, words) -> - results = [] - for word, index in words - result = @check args, word - if result.correct.length is 0 - results.push null - else - results.push true - results - suggest: (args, word) -> @spelling.suggest word diff --git a/lib/main.coffee b/lib/main.coffee index e2f4853d..51ddf904 100644 --- a/lib/main.coffee +++ b/lib/main.coffee @@ -12,8 +12,7 @@ module.exports = # Set up our callback to track when settings changed. that = this - @task.on "spell-check:settings-changed", (ignore) -> - console.log("updating views because of change", that) + @task.on "spell-check:settings-changed", () -> that.updateViews() # Since the spell-checking is done on another process, we gather up all the @@ -30,19 +29,19 @@ module.exports = @sendGlobalArgs() atom.config.onDidChange 'spell-check.locales', ({newValue, oldValue}) -> - that.globalArgs.locales = atom.config.get('spell-check.locales') + that.globalArgs.locales = newValue that.sendGlobalArgs() atom.config.onDidChange 'spell-check.localePaths', ({newValue, oldValue}) -> - that.globalArgs.localePaths = atom.config.get('spell-check.localePaths') + that.globalArgs.localePaths = newValue that.sendGlobalArgs() atom.config.onDidChange 'spell-check.useLocales', ({newValue, oldValue}) -> - that.globalArgs.useLocales = atom.config.get('spell-check.useLocales') + that.globalArgs.useLocales = newValue that.sendGlobalArgs() atom.config.onDidChange 'spell-check.knownWords', ({newValue, oldValue}) -> - that.globalArgs.knownWords = atom.config.get('spell-check.knownWords') + that.globalArgs.knownWords = newValue that.sendGlobalArgs() atom.config.onDidChange 'spell-check.addKnownWords', ({newValue, oldValue}) -> - that.globalArgs.addKnownWords = atom.config.get('spell-check.addKnownWords') + that.globalArgs.addKnownWords = newValue that.sendGlobalArgs() # Hook up the UI and processing. @@ -56,13 +55,14 @@ module.exports = # 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. - spellCheckView = new SpellCheckView(editor, @task, (ignore) => @getInstance @globalArgs) + spellCheckView = new SpellCheckView(editor, @task, () => @getInstance @globalArgs) # save the {editor} into a map editorId = editor.id spellCheckViews[editorId] = {} spellCheckViews[editorId]['view'] = spellCheckView spellCheckViews[editorId]['active'] = true + spellCheckViews[editorId]['editor'] = editor @viewsByEditor.set editor, spellCheckView deactivate: -> @@ -74,6 +74,12 @@ module.exports = @commandSubscription.dispose() @commandSubscription = null + # Clear out the known views. + for editorId of spellCheckViews + view = spellCheckViews[editorId] + view['editor'].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. @viewsByEditor = new WeakMap diff --git a/lib/spell-check-handler.coffee b/lib/spell-check-handler.coffee index 49a8f2f9..5484e4bc 100644 --- a/lib/spell-check-handler.coffee +++ b/lib/spell-check-handler.coffee @@ -13,7 +13,7 @@ instance.isTask = true # # Below the dispatcher for all messages from the server. The type argument is # require, how it is handled is based on the type. -process.on "message", (message) -> +process.on "message", (message) => switch when message.type is "global" then loadGlobalSettings message.global when message.type is "checker" then instance.addCheckerPath message.checkerPath diff --git a/lib/spell-check-manager.coffee b/lib/spell-check-manager.coffee index 745f547b..e782350e 100644 --- a/lib/spell-check-manager.coffee +++ b/lib/spell-check-manager.coffee @@ -46,7 +46,7 @@ class SpellCheckerManager if removeLocaleCheckers and @localeCheckers checkers = @localeCheckers for checker in checkers - @removeSpellChecker @checker + @removeSpellChecker checker @localeCheckers = null changed = true @@ -67,7 +67,6 @@ class SpellCheckerManager addCheckerPath: (checkerPath) -> checker = require checkerPath - console.log "spell-check: addCheckerPath:", checkerPath, checker @addPluginChecker checker addPluginChecker: (checker) -> @@ -79,7 +78,6 @@ class SpellCheckerManager @emitSettingsChanged() addSpellChecker: (checker) -> - console.log "spell-check: addSpellChecker:", checker @checkers.push checker removeSpellChecker: (spellChecker) -> @@ -89,129 +87,128 @@ class SpellCheckerManager # Make sure our deferred initialization is done. @init() - # Unfortunately, the version of Javascript that Atom uses doesn't really - # play well with Unicode characters. This means we can't use `\w+` to cover - # all the characters. Instead, we have to use a convoluted method until - # Chromium and Atom learn how to work with Unicode. The range here is to - # cover a "reasonable" range of characters including some accented ones. - wRegexClass = '[\\w\\u0080-\\u00FF\\u0100-\\u017F\\u0180-\\u024F]' - wRegexPattern = '(' + wRegexClass + '+(?:\'' + wRegexClass + '+)?)' - wRegex = new RegExp wRegexPattern - - # Splitting apart a line into words can be somewhat difficult and language- - # dependent. Until we have the ability to have a language-specific setting - # or services, we use a generic method. - natural = require "natural" - tokenizer = new natural.RegexpTokenizer {pattern: wRegex} - - # We have a small local cache here. Because of how the checkers work today, - # if "baz" is wrong in one place, it will be wrong in the entire document. - # Likewise, English and most other languages has a lot of redundancy with - # pronouns, articles, and the like that if we don't have to call the checker - # then we will be more performant. We keep the cache locale to a single - # buffer call so we don't have to worry about invalidation. - cache = {} - - # Because we check individual words, we can do this on a line-by-line basis - # and keep the coordinate processing relatively simple since ranges are - # given as character indexes within a given line. This loop goes through the - # the input and processes each line individually. - row = 0 - lineBeginIndex = 0 + # We need a couple packages. + 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. + correct = new multirange.MultiRange([]) + incorrects = [] + + for checker in @checkers + # We only care if this plugin contributes to checking spelling. + if not checker.isEnabled() or not checker.providesSpelling(args) + continue + + # Get the results which includes positive (correct) and negative (incorrect) + # ranges. If we have an incorrect range but no correct, everything not + # in incorrect is considered correct. + results = checker.check(args, text) + + if results.invertIncorrectAsCorrect and results.incorrect + # We need to add the opposite of the incorrect as correct elements in + # the list. We do this by creating a subtraction. + invertedCorrect = new multirange.MultiRange([[0, text.length]]) + removeRange = new multirange.MultiRange([]) + for range in 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 in results.correct + correct.appendRange(range.start, range.end) + + if results.incorrect + newIncorrect = new multirange.MultiRange([]) + incorrects.push(newIncorrect) + + for range in 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. misspellings = [] - while lineBeginIndex < text.length + if incorrects.length is 0 + return {id: args.id, 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. + intersection = null + index = 1 + + for incorrect in incorrects + if intersection is null + intersection = incorrect + else + intersection.append(incorrect) + + # If we have no intersection, then nothing to report as a problem. + if intersection.length is 0 + return {id: args.id, 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) + + # Convert the text ranges (index into the string) into Atom buffer + # coordinates ( row and column). + row = 0 + rangeIndex = 0 + lineBeginIndex = 0 + while lineBeginIndex < text.length and 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. lineEndIndex = text.indexOf('\n', lineBeginIndex) if lineEndIndex is -1 lineEndIndex = Infinity - # Grab the next line from the text buffer and split it into tokens. - line = text.substring lineBeginIndex, lineEndIndex - tokens = tokenizer.tokenize line - - # Loop through the tokens and process each one that looks like a word. We - # build up a list of every word (token) and its position within the line. - startSearch = 0 - words = [] - for token in tokens - # If we don't have at least one character, skip it. - if not /\w/.test(token) - startSearch += token.length - continue - - # Figure out where this token appears in the buffer. We have to do this - # since we'll be skipping over whitespace and non-word tokens. Once we - # have the components, add it to the list. - tokenIndex = line.indexOf token, startSearch - startSearch = tokenIndex + token.length - words.push {word: token, start: tokenIndex, end: startSearch, t: line.substring(tokenIndex, startSearch)} - - # We have a collection of words with their position. The next step is to - # gather up all the tokens that aren't in the cache into an arrow so we - # can send them to the checkers. - unknownWords = [] - for word in words - # See if we are in the cache, if so, then skip it. This applies even to - # the second occurance of a new word because we'll be processing them - # in sequence and it will be resolved properly. - if word.word in cache - continue - - # Cache a null so we don't duplciate and push the word into what we will - # request from the checkers. - cache[word.word] = null - unknownWords.push word.word - - # If we have unknown words, we need to pass it into the checkers. This - # will populate the cache object with the answers. - @checkWords args, unknownWords, cache - - # Go through the list of words again, this time adding misspellings to - # the resulting list. - for word in words - # The results will always be in the cache because of the block above us. - isCorrect = cache[word.word] - - if isCorrect is false - misspellings.push([[row, word.start], [row, word.end]]) - - # Move to the next line + # Loop through and get all the ranegs for this line. + loop + range = intersection.ranges[rangeIndex] + if range and 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. + lineRange = new multirange.MultiRange([]).appendRange(lineBeginIndex, lineEndIndex) + 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. + @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. {id: args.id, misspellings: misspellings} - checkWords: (args, words, cache) -> - # If we have an empty list, then don't bother. - if words.length is 0 - return - - # Go through the active checkers and verify the list against each one. - for checker in @checkers - # We only care if this plugin contributes to checking spelling. - if not checker.isEnabled() or not checker.providesSpelling(args) - continue - - # Get the results from the checker. We pass in a list of words and we get - # an array of results back. For each one, it is either `false` for - # incorrect, `true` for correct, or `null` for no opinion. - results = checker.checkArray args, words - - # Go through the results and merge them. `false` and `true` both override - # `null`; `true` overrides everything. - for result, index in results - word = words[index] - cache[word] = switch - when result is true or cache[word] is true - true - when result is false - false - else - cache[word] - suggest: (args, word) -> # Make sure our deferred initialization is done. @init() diff --git a/lib/system-checker.coffee b/lib/system-checker.coffee index ee34daa9..025a22eb 100644 --- a/lib/system-checker.coffee +++ b/lib/system-checker.coffee @@ -31,18 +31,7 @@ class SystemChecker check: (args, text) -> @deferredInit() - {incorrect: @spellchecker.checkSpelling(text)} - - checkArray: (args, words) -> - @deferredInit() - results = [] - for word, index in words - result = @check args, word - if result.incorrect.length is 0 - results.push null - else - results.push false - results + {invertIncorrectAsCorrect: true, incorrect: @spellchecker.checkSpelling(text)} suggest: (args, word) -> @deferredInit() diff --git a/package.json b/package.json index ecdbe53c..d83cc1a2 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,17 @@ { "name": "spell-check", - "version": "0.67.1", + "version": "0.77.4", "main": "./lib/main", "description": "Highlights misspelled words and shows possible corrections.", "dependencies": { "atom-space-pen-views": "^2.0.0", + "multi-integer-range": "^2.0.0", "natural": "^0.4.0", "spellchecker": "3.2.3", "spelling-manager": "0.3.0", "underscore-plus": "^1" }, - "repository": "https://github.com/atom/spell-check", + "repository": "https://github.com/dmoonfire/spell-check", "license": "MIT", "engines": { "atom": "*" diff --git a/spec/eot-spec-checker.coffee b/spec/eot-spec-checker.coffee new file mode 100644 index 00000000..aa16108e --- /dev/null +++ b/spec/eot-spec-checker.coffee @@ -0,0 +1,8 @@ +SpecChecker = require './spec-checker' + +class EndOfTestSpecChecker extends SpecChecker + constructor: () -> + super("eot", true, ["eot"]) + +checker = new EndOfTestSpecChecker +module.exports = checker diff --git a/spec/known-1-spec-checker.coffee b/spec/known-1-spec-checker.coffee new file mode 100644 index 00000000..a3cbea7b --- /dev/null +++ b/spec/known-1-spec-checker.coffee @@ -0,0 +1,8 @@ +SpecChecker = require './spec-checker' + +class Known1SpecChecker extends SpecChecker + constructor: () -> + super("known-1", false, ["k1a", "k0b", "k0a"]) + +checker = new Known1SpecChecker +module.exports = checker diff --git a/spec/known-2-spec-checker.coffee b/spec/known-2-spec-checker.coffee new file mode 100644 index 00000000..233d128b --- /dev/null +++ b/spec/known-2-spec-checker.coffee @@ -0,0 +1,8 @@ +SpecChecker = require './spec-checker' + +class Known2SpecChecker extends SpecChecker + constructor: () -> + super("known-2", true, ["k2a", "k0c", "k0a"]) + +checker = new Known2SpecChecker +module.exports = checker diff --git a/spec/known-3-spec-checker.coffee b/spec/known-3-spec-checker.coffee new file mode 100644 index 00000000..226fcc2d --- /dev/null +++ b/spec/known-3-spec-checker.coffee @@ -0,0 +1,8 @@ +SpecChecker = require './spec-checker' + +class Known3SpecChecker extends SpecChecker + constructor: () -> + super("known-3", false, ["k3a", "k0b", "k0a"]) + +checker = new Known3SpecChecker +module.exports = checker diff --git a/spec/known-4-spec-checker.coffee b/spec/known-4-spec-checker.coffee new file mode 100644 index 00000000..c0ba58e7 --- /dev/null +++ b/spec/known-4-spec-checker.coffee @@ -0,0 +1,8 @@ +SpecChecker = require './spec-checker' + +class Known4SpecChecker extends SpecChecker + constructor: () -> + super("known-4", true, ["k4a", "k0c", "k0a"]) + +checker = new Known4SpecChecker +module.exports = checker diff --git a/spec/spec-checker.coffee b/spec/spec-checker.coffee new file mode 100644 index 00000000..729c9364 --- /dev/null +++ b/spec/spec-checker.coffee @@ -0,0 +1,50 @@ +class SpecChecker + spelling: null + checker: null + + constructor: (@id, @isNegative, knownWords) -> + # Set up the spelling manager we'll be using. + spellingManager = require "spelling-manager" + @spelling = new spellingManager.TokenSpellingManager + @checker = new spellingManager.BufferSpellingChecker @spelling + + # Set our known words. + @setKnownWords knownWords + + console.log "constructor", @getId() + + deactivate: -> + return + + getId: -> "spell-check:spec:" + @id + getName: -> "Spec Checker" + getPriority: -> 10 + isEnabled: -> true + getStatus: -> "Working correctly." + providesSpelling: (args) -> true + providesSuggestions: (args) -> false + providesAdding: (args) -> false + + check: (args, text) -> + ranges = [] + checked = @checker.check text + for token in checked + if token.status is 1 + ranges.push {start: token.start, end: token.end} + + if @isNegative + {incorrect: ranges} + else + {correct: ranges} + + setKnownWords: (knownWords) -> + # Clear out the old list. + @spelling.sensitive = {} + @spelling.insensitive = {} + + # Add the new ones into the list. + if knownWords + for ignore in knownWords + @spelling.add ignore + +module.exports = SpecChecker diff --git a/spec/spell-check-spec.coffee b/spec/spell-check-spec.coffee index beaab865..5af83f72 100644 --- a/spec/spell-check-spec.coffee +++ b/spec/spell-check-spec.coffee @@ -78,28 +78,26 @@ describe "Spell check", -> expect(misspellingMarkers.length).toBe 1 expect(textForMarker(misspellingMarkers[0])).toBe "builz" - # This test was commented out because we moved away from the Task handling. - # Once that is enabled, this test should be valid again. - #it "hides decorations when a misspelled word is edited", -> - # editor.setText('notaword') - # advanceClock(editor.getBuffer().getStoppedChangingDelay()) - # atom.config.set('spell-check.grammars', ['source.js']) + it "hides decorations when a misspelled word is edited", -> + editor.setText('notaword') + advanceClock(editor.getBuffer().getStoppedChangingDelay()) + atom.config.set('spell-check.grammars', ['source.js']) - # misspellingMarkers = null - # waitsFor -> - # misspellingMarkers = getMisspellingMarkers() - # misspellingMarkers.length > 0 + misspellingMarkers = null + waitsFor -> + misspellingMarkers = getMisspellingMarkers() + misspellingMarkers.length > 0 - # runs -> - # expect(misspellingMarkers.length).toBe 1 - # editor.moveToEndOfLine() - # editor.insertText('a') - # advanceClock(editor.getBuffer().getStoppedChangingDelay()) + runs -> + expect(misspellingMarkers.length).toBe 1 + editor.moveToEndOfLine() + editor.insertText('a') + advanceClock(editor.getBuffer().getStoppedChangingDelay()) - # misspellingMarkers = getMisspellingMarkers() + misspellingMarkers = getMisspellingMarkers() - # expect(misspellingMarkers.length).toBe 1 - # expect(misspellingMarkers[0].isValid()).toBe false + expect(misspellingMarkers.length).toBe 1 + expect(misspellingMarkers[0].isValid()).toBe false describe "when spell checking for a grammar is removed", -> it "removes all the misspellings", -> @@ -203,3 +201,137 @@ describe "Spell check", -> runs -> editor.destroy() expect(getMisspellingMarkers().length).toBe 0 + + describe "when using checker plugins", -> + it "no opinion on input means correctly spells", -> + spellCheckModule.consumeSpellCheckers require.resolve('./known-1-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-2-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-3-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-4-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./eot-spec-checker.coffee') + atom.config.set('spell-check.locales', ['en-US']) + atom.config.set('spell-check.useLocales', false) + editor.setText('eot') + atom.config.set('spell-check.grammars', ['source.js']) + + waitsFor -> + getMisspellingMarkers().length > 0 + + runs -> + expect(getMisspellingMarkers().length).toBe 1 + editor.destroy() + expect(getMisspellingMarkers().length).toBe 0 + + it "correctly spelling k1a", -> + spellCheckModule.consumeSpellCheckers require.resolve('./known-1-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-2-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-3-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-4-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./eot-spec-checker.coffee') + atom.config.set('spell-check.locales', ['en-US']) + atom.config.set('spell-check.useLocales', false) + editor.setText('k1a eot') + atom.config.set('spell-check.grammars', ['source.js']) + + waitsFor -> + getMisspellingMarkers().length > 0 + + runs -> + expect(getMisspellingMarkers().length).toBe 1 + editor.destroy() + expect(getMisspellingMarkers().length).toBe 0 + + it "correctly mispelling k2a", -> + spellCheckModule.consumeSpellCheckers require.resolve('./known-1-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-2-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-3-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-4-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./eot-spec-checker.coffee') + atom.config.set('spell-check.locales', ['en-US']) + atom.config.set('spell-check.useLocales', false) + editor.setText('k2a eot') + atom.config.set('spell-check.grammars', ['source.js']) + + waitsFor -> + getMisspellingMarkers().length > 0 + + runs -> + expect(getMisspellingMarkers().length).toBe 2 + editor.destroy() + expect(getMisspellingMarkers().length).toBe 0 + + it "correctly mispelling k2a with text in middle", -> + spellCheckModule.consumeSpellCheckers require.resolve('./known-1-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-2-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-3-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-4-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./eot-spec-checker.coffee') + atom.config.set('spell-check.locales', ['en-US']) + atom.config.set('spell-check.useLocales', false) + editor.setText('k2a good eot') + atom.config.set('spell-check.grammars', ['source.js']) + + waitsFor -> + getMisspellingMarkers().length > 0 + + runs -> + expect(getMisspellingMarkers().length).toBe 2 + editor.destroy() + expect(getMisspellingMarkers().length).toBe 0 + + it "word is both correct and incorrect is correct", -> + spellCheckModule.consumeSpellCheckers require.resolve('./known-1-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-2-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-3-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-4-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./eot-spec-checker.coffee') + atom.config.set('spell-check.locales', ['en-US']) + atom.config.set('spell-check.useLocales', false) + editor.setText('k0a eot') + atom.config.set('spell-check.grammars', ['source.js']) + + waitsFor -> + getMisspellingMarkers().length > 0 + + runs -> + expect(getMisspellingMarkers().length).toBe 1 + editor.destroy() + expect(getMisspellingMarkers().length).toBe 0 + + it "word is correct twice is correct", -> + spellCheckModule.consumeSpellCheckers require.resolve('./known-1-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-2-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-3-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-4-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./eot-spec-checker.coffee') + atom.config.set('spell-check.locales', ['en-US']) + atom.config.set('spell-check.useLocales', false) + editor.setText('k0b eot') + atom.config.set('spell-check.grammars', ['source.js']) + + waitsFor -> + getMisspellingMarkers().length > 0 + + runs -> + expect(getMisspellingMarkers().length).toBe 1 + editor.destroy() + expect(getMisspellingMarkers().length).toBe 0 + + it "word is incorrect twice is incorrect", -> + spellCheckModule.consumeSpellCheckers require.resolve('./known-1-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-2-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-3-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./known-4-spec-checker.coffee') + spellCheckModule.consumeSpellCheckers require.resolve('./eot-spec-checker.coffee') + atom.config.set('spell-check.locales', ['en-US']) + atom.config.set('spell-check.useLocales', false) + editor.setText('k0c eot') + atom.config.set('spell-check.grammars', ['source.js']) + + waitsFor -> + getMisspellingMarkers().length > 0 + + runs -> + expect(getMisspellingMarkers().length).toBe 2 + editor.destroy() + expect(getMisspellingMarkers().length).toBe 0 From a509817472dae2391e583398084ed856f35a4694 Mon Sep 17 00:00:00 2001 From: "Dylan R. E. Moonfire" Date: Wed, 6 Jul 2016 22:43:30 -0500 Subject: [PATCH 4/5] Updating plugin code for lint rules. --- lib/main.coffee | 4 ++-- lib/spell-check-handler.coffee | 2 +- spec/eot-spec-checker.coffee | 4 ++-- spec/known-1-spec-checker.coffee | 4 ++-- spec/known-2-spec-checker.coffee | 4 ++-- spec/known-3-spec-checker.coffee | 4 ++-- spec/known-4-spec-checker.coffee | 4 ++-- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/main.coffee b/lib/main.coffee index 51ddf904..cf3b247d 100644 --- a/lib/main.coffee +++ b/lib/main.coffee @@ -12,7 +12,7 @@ module.exports = # Set up our callback to track when settings changed. that = this - @task.on "spell-check:settings-changed", () -> + @task.on "spell-check:settings-changed", (ignore) -> that.updateViews() # Since the spell-checking is done on another process, we gather up all the @@ -55,7 +55,7 @@ module.exports = # 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. - spellCheckView = new SpellCheckView(editor, @task, () => @getInstance @globalArgs) + spellCheckView = new SpellCheckView(editor, @task, => @getInstance @globalArgs) # save the {editor} into a map editorId = editor.id diff --git a/lib/spell-check-handler.coffee b/lib/spell-check-handler.coffee index 5484e4bc..49a8f2f9 100644 --- a/lib/spell-check-handler.coffee +++ b/lib/spell-check-handler.coffee @@ -13,7 +13,7 @@ instance.isTask = true # # Below the dispatcher for all messages from the server. The type argument is # require, how it is handled is based on the type. -process.on "message", (message) => +process.on "message", (message) -> switch when message.type is "global" then loadGlobalSettings message.global when message.type is "checker" then instance.addCheckerPath message.checkerPath diff --git a/spec/eot-spec-checker.coffee b/spec/eot-spec-checker.coffee index aa16108e..a0436d6e 100644 --- a/spec/eot-spec-checker.coffee +++ b/spec/eot-spec-checker.coffee @@ -1,8 +1,8 @@ SpecChecker = require './spec-checker' class EndOfTestSpecChecker extends SpecChecker - constructor: () -> - super("eot", true, ["eot"]) + constructor: -> + super("eot", true, ["eot"]) checker = new EndOfTestSpecChecker module.exports = checker diff --git a/spec/known-1-spec-checker.coffee b/spec/known-1-spec-checker.coffee index a3cbea7b..1418303c 100644 --- a/spec/known-1-spec-checker.coffee +++ b/spec/known-1-spec-checker.coffee @@ -1,8 +1,8 @@ SpecChecker = require './spec-checker' class Known1SpecChecker extends SpecChecker - constructor: () -> - super("known-1", false, ["k1a", "k0b", "k0a"]) + constructor: -> + super("known-1", false, ["k1a", "k0b", "k0a"]) checker = new Known1SpecChecker module.exports = checker diff --git a/spec/known-2-spec-checker.coffee b/spec/known-2-spec-checker.coffee index 233d128b..8303fe83 100644 --- a/spec/known-2-spec-checker.coffee +++ b/spec/known-2-spec-checker.coffee @@ -1,8 +1,8 @@ SpecChecker = require './spec-checker' class Known2SpecChecker extends SpecChecker - constructor: () -> - super("known-2", true, ["k2a", "k0c", "k0a"]) + constructor: -> + super("known-2", true, ["k2a", "k0c", "k0a"]) checker = new Known2SpecChecker module.exports = checker diff --git a/spec/known-3-spec-checker.coffee b/spec/known-3-spec-checker.coffee index 226fcc2d..d96f274a 100644 --- a/spec/known-3-spec-checker.coffee +++ b/spec/known-3-spec-checker.coffee @@ -1,8 +1,8 @@ SpecChecker = require './spec-checker' class Known3SpecChecker extends SpecChecker - constructor: () -> - super("known-3", false, ["k3a", "k0b", "k0a"]) + constructor: -> + super("known-3", false, ["k3a", "k0b", "k0a"]) checker = new Known3SpecChecker module.exports = checker diff --git a/spec/known-4-spec-checker.coffee b/spec/known-4-spec-checker.coffee index c0ba58e7..89664bc7 100644 --- a/spec/known-4-spec-checker.coffee +++ b/spec/known-4-spec-checker.coffee @@ -1,8 +1,8 @@ SpecChecker = require './spec-checker' class Known4SpecChecker extends SpecChecker - constructor: () -> - super("known-4", true, ["k4a", "k0c", "k0a"]) + constructor: -> + super("known-4", true, ["k4a", "k0c", "k0a"]) checker = new Known4SpecChecker module.exports = checker From c228c87254ff3830b351733f73c600edd17976d2 Mon Sep 17 00:00:00 2001 From: "Dylan R. E. Moonfire" Date: Fri, 12 Aug 2016 14:34:20 -0500 Subject: [PATCH 5/5] Updated code with requested changes: * Removed ".coffee" from require statements. * Moved empty array initialization closer to where it was populated. --- lib/main.coffee | 6 +++--- lib/spell-check-handler.coffee | 2 +- lib/spell-check-manager.coffee | 13 ++++--------- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/lib/main.coffee b/lib/main.coffee index cf3b247d..44f0b40d 100644 --- a/lib/main.coffee +++ b/lib/main.coffee @@ -118,11 +118,11 @@ module.exports = sendGlobalArgs: -> @task.send {type: "global", global: @globalArgs} - # Retrieves, creating if required, a spelling manager for use with synchronous - # operations such as retrieving corrections. + # Retrieves, creating if required, a spelling manager for use with + # synchronous operations such as retrieving corrections. getInstance: (globalArgs) -> if not @instance - SpellCheckerManager = require './spell-check-manager.coffee' + SpellCheckerManager = require './spell-check-manager' @instance = SpellCheckerManager @instance.setGlobalArgs globalArgs diff --git a/lib/spell-check-handler.coffee b/lib/spell-check-handler.coffee index 49a8f2f9..92d02d37 100644 --- a/lib/spell-check-handler.coffee +++ b/lib/spell-check-handler.coffee @@ -1,6 +1,6 @@ # This is the task local handler for the manager so we can reuse the manager # throughout the life of the task. -SpellCheckerManager = require './spell-check-manager.coffee' +SpellCheckerManager = require './spell-check-manager' instance = SpellCheckerManager instance.isTask = true diff --git a/lib/spell-check-manager.coffee b/lib/spell-check-manager.coffee index e782350e..3fce31c6 100644 --- a/lib/spell-check-manager.coffee +++ b/lib/spell-check-manager.coffee @@ -133,10 +133,8 @@ class SpellCheckerManager # If we don't have any incorrect spellings, then there is nothing to worry # about, so just return and stop processing. - misspellings = [] - if incorrects.length is 0 - return {id: args.id, misspellings} + return {id: args.id, 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 @@ -156,7 +154,7 @@ class SpellCheckerManager # If we have no intersection, then nothing to report as a problem. if intersection.length is 0 - return {id: args.id, misspellings} + return {id: args.id, 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 @@ -169,6 +167,7 @@ class SpellCheckerManager row = 0 rangeIndex = 0 lineBeginIndex = 0 + misspellings = [] while lineBeginIndex < text.length and 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. @@ -348,7 +347,7 @@ class SpellCheckerManager # See if we need to reload the known words. if @knownWordsChecker is null console.log "spell-check: loading known words", @knownWords - KnownWordsChecker = require './known-words-checker.coffee' + KnownWordsChecker = require './known-words-checker' @knownWordsChecker = new KnownWordsChecker @knownWords @knownWordsChecker.enableAdd = @addKnownWords @addSpellChecker @knownWordsChecker @@ -378,7 +377,3 @@ class SpellCheckerManager manager = new SpellCheckerManager module.exports = manager - -# KnownWordsChecker = require './known-words-checker.coffee' -# knownWords = atom.config.get('spell-check.knownWords') -# addKnownWords = atom.config.get('spell-check.addKnownWords')