From 833330f48acff00a605f7efad502f2ddcc23a6e5 Mon Sep 17 00:00:00 2001 From: "Dylan R. E. Moonfire" Date: Sat, 12 Mar 2016 12:41:04 -0600 Subject: [PATCH] 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 5f7c29b7..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.getRange()) - @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 9663db3d..f4be0975 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({containsPoint: @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() @@ -94,11 +94,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.getRange()) - 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 10e4a58c..0deffffa 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 820758b7..f0b3ee48 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; +}