🖊️
SKK実装入門 (1) ローマ字 -> ひらがな変換
第一回 この記事
次回
はじめに
日本語話者にとって、IME は生活にかかせないものでしょう。
しかし、その仕組みについて考えたことがある人は意外と少ないのではないでしょうか?
この連載では、皆さんご存じの IME である skk を Neovim 上で実装していきます。
(ご存じない方はこちらが意外と詳しいのでどうぞ)
主に参考にするのはこちらです。最新のskk実装 (in Vim) で、TypeScript で記述されています。
この記事も skkeleton を使って書いています。第一回では、ローマ字をひらがなに変換する仕組みを作ります。
SKK 固有の話はまだですが、是非お楽しみ下さい。
免責事項
- この連載の目的は仕組みの理解です。
- 実用的なものを作るためではないので、あくまで学習用としてご覧下さい。
- なるべく簡潔な実装になることを優先します。
- 筆者は専門家ではないので、これが最適な実装とは限りません。
技術選定
- Neovim
- 私が好き
- TUIフレームワークとして便利です
- Lua
- 依存無しで Neovim プラグインを書くなら Vim script か Lua の二択です
- 私が慣れているのと、言語仕様が非常にシンプルで学習コストが低いのでこちらを採用
Neovim Lua 固有の話を知りたい場合はこれを読みましょう。
Lua 自体の入門記事も先頭に幾つか載っています。
また、型情報を Language Server に渡すため LuaLS の annotations を用います。
リポジトリ
このリポジトリを使います。
この記事時点の進捗コミットはこちら。
アルゴリズム
- 入力は一文字ずつ渡されます。
- それまでの入力を覚えておく必要があるので、
Context
クラスを導入します。
- それまでの入力を覚えておく必要があるので、
-
KanaTable
は変換ルールです。- 型は
{ input: string, output: string, next: string }[]
とします。 - Google 日本語入力でいう、「入力」「出力」「次の入力」からなる table を要素とする配列です。
-
bb -> っb
やbba -> っば
が簡単に実現できます。
- 型は
- 次の入力を見ないと変換候補が確定できないものには注意が必要です。
-
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 上でひらがなを入力できるようにします。お楽しみに!
Discussion
実をいうとskkプラグインの実装をやろうとしていまして、すごい参考になります。
既存のもののソースを読んだり、emacsのskkを読んだりしましたが厳しいもんがありまして。
次回以降のシリーズを追わせていただきます。