🖊️

SKK実装入門 (1) ローマ字 -> ひらがな変換

2023/05/07に公開

第一回 この記事
次回

はじめに

日本語話者にとって、IME は生活にかかせないものでしょう。
しかし、その仕組みについて考えたことがある人は意外と少ないのではないでしょうか?

この連載では、皆さんご存じの IME である skk を Neovim 上で実装していきます。
(ご存じない方はこちら意外と詳しいのでどうぞ)

主に参考にするのはこちらです。最新のskk実装 (in Vim) で、TypeScript で記述されています。
https://github.com/vim-skk/skkeleton
この記事も skkeleton を使って書いています。

第一回では、ローマ字をひらがなに変換する仕組みを作ります。
SKK 固有の話はまだですが、是非お楽しみ下さい。

免責事項

  • この連載の目的は仕組みの理解です。
    • 実用的なものを作るためではないので、あくまで学習用としてご覧下さい。
    • なるべく簡潔な実装になることを優先します。
  • 筆者は専門家ではないので、これが最適な実装とは限りません。

技術選定

  • Neovim
    • 私が好き
    • TUIフレームワークとして便利です
  • Lua
    • 依存無しで Neovim プラグインを書くなら Vim script か Lua の二択です
    • 私が慣れているのと、言語仕様が非常にシンプルで学習コストが低いのでこちらを採用

Neovim Lua 固有の話を知りたい場合はこれを読みましょう。
Lua 自体の入門記事も先頭に幾つか載っています。

また、型情報を Language Server に渡すため LuaLS の annotations を用います。
https://github.com/LuaLS/lua-language-server/wiki/Annotations

リポジトリ

このリポジトリを使います。
https://github.com/uga-rosa/skk-learning.nvim

この記事時点の進捗コミットはこちら。
https://github.com/uga-rosa/skk-learning.nvim/tree/2dd341c1d33bf13b1fdf667582e32588659a3c7b

アルゴリズム

  • 入力は一文字ずつ渡されます。
    • それまでの入力を覚えておく必要があるので、Context クラスを導入します。
  • KanaTable は変換ルールです。
    • 型は { input: string, output: string, next: string }[] とします。
    • Google 日本語入力でいう、「入力」「出力」「次の入力」からなる table を要素とする配列です。
    • bb -> っbbba -> っば が簡単に実現できます。
  • 次の入力を見ないと変換候補が確定できないものには注意が必要です。
    • nn -> んnm -> んm など。
    • 以下のルールを同時に導入します。
      • { input = "nn", output = "ん", next = "" }
      • { input = "n", output = "ん", next = "" }
    • 入力に対する前方一致で変換ルールを絞り込みます。
    • 候補が一つかつ完全一致になったら変換を確定します。
    • 0 個になった場合は
      • 直前までの入力に完全一致する候補があれば、それを確定し新しい入力を積みます。
      • 無ければ誤入力です

実装

  • KanaRule は変換ルールの定義です。
  • 簡単のため kanaRules はハードコードしました。
  • kana で名前空間を切っているのは、カタカナや全角英数への変換も後々実装するためです。
kana/kana_table.lua
---@class KanaRule
---@field input string
---@field output string
---@field next string

---@type KanaRule[]
local kanaRules = {
  { input = "-", output = "ー", next = "" },
  { input = "~", output = "〜", next = "" },
  { input = ".", output = "。", next = "" },
  -- 以下略
}

---@class KanaTable
---@field rules KanaRule[]
local KanaTable = {
  rules = kanaRules,
}

---@return KanaTable
function KanaTable.new()
  return setmetatable({}, { __index = KanaTable })
end

---inputとの前方一致で絞り込む
---@param pre string
---@return KanaRule[]
function KanaTable:filter(pre)
  return vim.tbl_filter(function(rule)
    return vim.startswith(rule.input, pre)
  end, self.rules)
end

return KanaTable
  • Context は入力状態を保持するためのクラスです。
context.lua
local kanaTable = require("skk.kana.kana_table")

---@class Context
---@field kanaTable KanaTable 全ての変換ルール
---@field fixed string もう確定したひらがな
---@field feed string 未確定のローマ字
---@field tmpResult? KanaRule feedに完全一致する変換ルール
local Context = {}

function Context.new()
  local self = setmetatable({}, { __index = Context })
  self.kanaTable = kanaTable.new()
  self.fixed = ""
  self.feed = ""
  return self
end

---@param candidates? KanaRule[]
function Context:updateTmpResult(candidates)
  candidates = candidates or self.kanaTable:filter(self.feed)
  self.tmpResult = nil
  for _, candidate in ipairs(candidates) do
    if candidate.input == self.feed then
      self.tmpResult = candidate
      break
    end
  end
end

return Context
  • Input.kanaInput() でローマ字入力をひらがなに変換します。
  • 今回のメイン処理です。
input.lua
local Input = {}

---@param context Context
---@param char string
function Input.kanaInput(context, char)
  local input = context.feed .. char
  local candidates = context.kanaTable:filter(input)
  if #candidates == 1 and candidates[1].input == input then
    -- 候補が一つかつ完全一致。確定
    context.fixed = context.fixed .. candidates[1].output
    context.feed = candidates[1].next
    context:updateTmpResult()
  elseif #candidates > 0 then
    -- 未確定
    context.feed = input
    context:updateTmpResult(candidates)
  elseif context.tmpResult then
    -- 新しい入力によりtmpResultで確定
    context.fixed = context.fixed .. context.tmpResult.output
    context.feed = context.tmpResult.next
    context:updateTmpResult()
    Input.kanaInput(context, char)
  else
    -- 入力ミス。context.tmpResultは既にnil
    context.feed = ""
    Input.kanaInput(context, char)
  end
end

return Input

テスト

以下のテストが通ったところで第一回は終了とします。
テストフレームワークには vusted を使っています。

input_spec.lua
local Input = require("skk.input")
local Context = require("skk.context")

---@type Context
local context

---@param input string
---@param expect string
local function test(input, expect)
  for char in vim.gsplit(input, "") do
    Input.kanaInput(context, char)
  end
  assert.are.equals(expect, context.fixed)
end

describe("Tests for input.lua", function()
  before_each(function()
    context = Context.new()
  end)

  it("single char", function()
    test("ka", "か")
  end)

  it("multiple chars (don't use tmpResult)", function()
    test("ohayou", "おはよう")
  end)

  it("multiple chars (use tmpResult)", function()
    test("amenbo", "あめんぼ")
  end)

  it("multiple chars (use tmpResult and its next)", function()
    test("uwwwa", "うwっわ")
  end)

  it("mistaken input", function()
    test("rkakyra", "から")
  end)
end)

次回予告

第二回では、実際に Neovim 上でひらがなを入力できるようにします。お楽しみに!

GitHubで編集を提案

Discussion