chromium/components/spellcheck/browser/spellcheck_platform_mac.mm

// Copyright 2012 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// Integration with macOS built-in spellchecker.

#include "components/spellcheck/browser/spellcheck_platform.h"

#import <Cocoa/Cocoa.h>

#include "base/apple/foundation_util.h"
#include "base/check.h"
#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
#include "base/notreached.h"
#include "base/strings/sys_string_conversions.h"
#include "base/time/time.h"
#include "components/spellcheck/common/spellcheck_common.h"
#include "components/spellcheck/common/spellcheck_result.h"
#include "content/public/browser/browser_thread.h"

class PlatformSpellChecker;

using base::TimeTicks;
using content::BrowserThread;

namespace {
// The number of characters in the first part of the language code.
const unsigned int kShortLanguageCodeSize = 2;

// +[NSSpellChecker sharedSpellChecker] can throw exceptions depending
// on the state of the pasteboard, or possibly as a result of
// third-party code (when setting up services entries).  The following
// receives nil if an exception is thrown, in which case
// spell-checking will not work, but it also will not crash the
// browser.
NSSpellChecker* SharedSpellChecker() {
  @try {
    return NSSpellChecker.sharedSpellChecker;
  } @catch (id exception) {
    return nil;
  }
}

// A private utility function to convert hunspell language codes to macOS
// language codes.
NSString* ConvertLanguageCodeToMac(const std::string& hunspell_lang_code) {
  NSString* whole_code = base::SysUTF8ToNSString(hunspell_lang_code);

  if (whole_code.length > kShortLanguageCodeSize) {
    NSString* lang_code = [whole_code
                           substringToIndex:kShortLanguageCodeSize];
    // Add 1 here to skip the underscore.
    NSString* region_code = [whole_code
                             substringFromIndex:(kShortLanguageCodeSize + 1)];

    // Check for the special case of en-US and pt-PT, since macOS lists these
    // as just en and pt respectively.
    // TODO(pwicks): Find out if there are other special cases for languages
    // not installed on the system by default. Are there others like pt-PT?
    if (([lang_code isEqualToString:@"en"] &&
       [region_code isEqualToString:@"US"]) ||
        ([lang_code isEqualToString:@"pt"] &&
       [region_code isEqualToString:@"PT"])) {
      return lang_code;
    }

    // Otherwise, just build a string that uses an underscore instead of a
    // dash between the language and the region code, since this is the
    // format that macOS uses.
    NSString* macos_language =
        [NSString stringWithFormat:@"%@_%@", lang_code, region_code];
    return macos_language;
  } else {
    // Special case for Polish.
    if ([whole_code isEqualToString:@"pl"]) {
      return @"pl_PL";
    }
    // This is just a language code with the same format as macOS
    // language code.
    return whole_code;
  }
}

std::string ConvertLanguageCodeFromMac(NSString* lang_code) {
  // TODO(pwicks):figure out what to do about Multilingual
  // Guards for strange cases.
  if ([lang_code isEqualToString:@"en"]) {
    return std::string("en-US");
  }
  if ([lang_code isEqualToString:@"pt"]) {
    return std::string("pt-PT");
  }
  if ([lang_code isEqualToString:@"pl_PL"]) {
    return std::string("pl");
  }

  if (lang_code.length > kShortLanguageCodeSize &&
      [lang_code characterAtIndex:kShortLanguageCodeSize] == '_') {
    return base::SysNSStringToUTF8([NSString
        stringWithFormat:@"%@-%@",
                         [lang_code substringToIndex:kShortLanguageCodeSize],
                         [lang_code
                             substringFromIndex:(kShortLanguageCodeSize + 1)]]);
  }
  return base::SysNSStringToUTF8(lang_code);
}

} // namespace

namespace spellcheck_platform {

void GetAvailableLanguages(std::vector<std::string>* spellcheck_languages) {
  for (NSString* lang_code in SharedSpellChecker().availableLanguages) {
    spellcheck_languages->push_back(
              ConvertLanguageCodeFromMac(lang_code));
  }
}

void RetrieveSpellcheckLanguages(
    PlatformSpellChecker* spell_checker_instance,
    RetrieveSpellcheckLanguagesCompleteCallback callback) {
  NOTIMPLEMENTED();
  std::move(callback).Run(std::vector<std::string>());
}

void AddSpellcheckLanguagesForTesting(
    PlatformSpellChecker* spell_checker_instance,
    const std::vector<std::string>& languages) {
  NOTIMPLEMENTED();
}

std::string GetSpellCheckerLanguage() {
  return ConvertLanguageCodeFromMac(SharedSpellChecker().language);
}

bool SpellCheckerAvailable() {
  return true;
}

bool SpellCheckerProvidesPanel() {
  // macOS has a Spelling Panel, so we can return true here.
  return true;
}

bool SpellingPanelVisible() {
  // This should only be called from the main thread.
  DCHECK(NSThread.isMainThread);
  return SharedSpellChecker().spellingPanel.visible;
}

void ShowSpellingPanel(bool show) {
  if (show) {
    [SharedSpellChecker().spellingPanel
        performSelectorOnMainThread:@selector(makeKeyAndOrderFront:)
                         withObject:nil
                      waitUntilDone:YES];
  } else {
    [SharedSpellChecker().spellingPanel
        performSelectorOnMainThread:@selector(close)
                         withObject:nil
                      waitUntilDone:YES];
  }
}

void UpdateSpellingPanelWithMisspelledWord(const std::u16string& word) {
  NSString * word_to_display = base::SysUTF16ToNSString(word);
  [SharedSpellChecker()
      performSelectorOnMainThread:
        @selector(updateSpellingPanelWithMisspelledWord:)
                       withObject:word_to_display
                    waitUntilDone:YES];
}

void PlatformSupportsLanguage(PlatformSpellChecker* spell_checker_instance,
                              const std::string& current_language,
                              base::OnceCallback<void(bool)> callback) {
  // First, convert the language to an macOS language code.
  NSString* mac_lang_code = ConvertLanguageCodeToMac(current_language);

  // Then grab the languages available.
  NSArray* availableLanguages = SharedSpellChecker().availableLanguages;

  // Return true if the given language is supported by macOS.
  std::move(callback).Run([availableLanguages containsObject:mac_lang_code]);
}

void SetLanguage(PlatformSpellChecker* spell_checker_instance,
                 const std::string& lang_to_set,
                 base::OnceCallback<void(bool)> callback) {
  // Do not set any language right now, since Chrome should honor the
  // system spellcheck settings. (http://crbug.com/166046)
  // Fix this once Chrome actually allows setting a spellcheck language
  // in chrome://settings.
  //  NSString* NS_lang_to_set = ConvertLanguageCodeToMac(lang_to_set);
  //  [SharedSpellChecker() setLanguage:NS_lang_to_set];
  std::move(callback).Run(true);
}

void DisableLanguage(PlatformSpellChecker* spell_checker_instance,
                     const std::string& lang_to_disable) {}

static int last_seen_tag_;

bool CheckSpelling(const std::u16string& word_to_check, int tag) {
  last_seen_tag_ = tag;

  // -[NSSpellChecker checkSpellingOfString] returns an NSRange that
  // we can look at to determine if a word is misspelled.
  NSRange spell_range = {0,0};

  // Convert the word to an NSString.
  NSString* NS_word_to_check = base::SysUTF16ToNSString(word_to_check);
  // Check the spelling, starting at the beginning of the word.
  spell_range = [SharedSpellChecker() checkSpellingOfString:NS_word_to_check
                                                 startingAt:0
                                                   language:nil
                                                       wrap:NO
                                     inSpellDocumentWithTag:tag
                                                  wordCount:nullptr];

  // If the length of the misspelled word == 0,
  // then there is no misspelled word.
  bool word_correct = (spell_range.length == 0);
  return word_correct;
}

void FillSuggestionList(const std::u16string& wrong_word,
                        std::vector<std::u16string>* optional_suggestions) {
  NSString* ns_wrong_word = base::SysUTF16ToNSString(wrong_word);
  NSSpellChecker* checker = SharedSpellChecker();
  NSString* language = [checker language];
  NSArray* guesses =
      [checker guessesForWordRange:NSMakeRange(0, ns_wrong_word.length)
                          inString:ns_wrong_word
                          language:language
            inSpellDocumentWithTag:last_seen_tag_];
  int i = 0;
  for (NSString* guess in guesses) {
    optional_suggestions->push_back(base::SysNSStringToUTF16(guess));
    if (++i >= spellcheck::kMaxSuggestions)
      break;
  }
}

void AddWord(PlatformSpellChecker* spell_checker_instance,
             const std::u16string& word) {
  NSString* word_to_add = base::SysUTF16ToNSString(word);
  [SharedSpellChecker() learnWord:word_to_add];
}

void RemoveWord(PlatformSpellChecker* spell_checker_instance,
                const std::u16string& word) {
  NSString *word_to_remove = base::SysUTF16ToNSString(word);
  [SharedSpellChecker() unlearnWord:word_to_remove];
}

int GetDocumentTag() {
  NSInteger doc_tag = [NSSpellChecker uniqueSpellDocumentTag];
  return static_cast<int>(doc_tag);
}

void IgnoreWord(PlatformSpellChecker* spell_checker_instance,
                const std::u16string& word) {
  [SharedSpellChecker() ignoreWord:base::SysUTF16ToNSString(word)
            inSpellDocumentWithTag:last_seen_tag_];
}

void CloseDocumentWithTag(int tag) {
  [SharedSpellChecker() closeSpellDocumentWithTag:static_cast<NSInteger>(tag)];
}

void RequestTextCheck(PlatformSpellChecker* spell_checker_instance,
                      int document_tag,
                      const std::u16string& text,
                      TextCheckCompleteCallback passed_callback) {
  NSString* text_to_check = base::SysUTF16ToNSString(text);
  NSRange range_to_check = NSMakeRange(0, text_to_check.length);
  __block TextCheckCompleteCallback callback(std::move(passed_callback));

  [SharedSpellChecker()
      requestCheckingOfString:text_to_check
                        range:range_to_check
                        types:NSTextCheckingTypeSpelling
                      options:nil
       inSpellDocumentWithTag:document_tag
            completionHandler:^(NSInteger,
                                NSArray *results,
                                NSOrthography*,
                                NSInteger) {
          std::vector<SpellCheckResult> check_results;
          for (NSTextCheckingResult* result in results) {
            // Deliberately ignore non-spelling results. macOS at the very least
            // delivers a result of NSTextCheckingTypeOrthography for the
            // whole fragment, which underlines the entire checked range.
            if (result.resultType != NSTextCheckingTypeSpelling) {
              continue;
            }

            // In this use case, the spell checker should never
            // return anything but a single range per result.
            check_results.emplace_back(SpellCheckResult::SPELLING,
                                       result.range.location,
                                       result.range.length);
          }
          // TODO(groby): Verify we don't need to post from here.
          std::move(callback).Run(check_results);
      }];
}

class SpellcheckerStateInternal {
 public:
  SpellcheckerStateInternal();
  ~SpellcheckerStateInternal();

 private:
  BOOL automaticallyIdentifiesLanguages_;
  NSString* language_;
};

SpellcheckerStateInternal::SpellcheckerStateInternal() {
  language_ = SharedSpellChecker().language;
  automaticallyIdentifiesLanguages_ =
      SharedSpellChecker().automaticallyIdentifiesLanguages;
  [SharedSpellChecker() setLanguage:@"en"];
  SharedSpellChecker().automaticallyIdentifiesLanguages = NO;
}

SpellcheckerStateInternal::~SpellcheckerStateInternal() {
  [SharedSpellChecker() setLanguage:language_];
  SharedSpellChecker().automaticallyIdentifiesLanguages =
      automaticallyIdentifiesLanguages_;
}

ScopedEnglishLanguageForTest::ScopedEnglishLanguageForTest()
    : state_(new SpellcheckerStateInternal) {
}

ScopedEnglishLanguageForTest::~ScopedEnglishLanguageForTest() {
  delete state_;
}

}  // namespace spellcheck_platform