Helix-Editorで日本語文章に対応した簡易的な文字移動を実装

今回の内容は少しハックな内容なので注意

:point_right: githubからソースコードはクローンしているのが前提

壊れたり著しくパフォーマンスを欠くことはないが一応注意

試すだけならこの記事にあるコードをコピペして`cargo run`でhelixを実行し，helix内で`Space+?`でコマンドパレットを起動して関数を実行するだけで良い

## まとめ

結果的には以下の様な動きになる

+ wを押し続けた場合

![impl_w_move.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2630694/9fe81b29-dc89-4f9f-1ccb-f7a48fea56bc.gif)

+ eを押し続けた場合

![impl_e_move.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2630694/7ff1f976-95ae-d408-249a-a54a452edf91.gif)

+ bを押し続けた場合

![impl_b_move.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2630694/cfbf5148-3f1a-2826-3953-7523d373035e.gif)


ひらがな，カタカナ，漢字それぞれを単語境界として移動できるようになった

helixでは`w`が「次の単語の先頭の**前**」，`e`が「次の単語の最後」となっている

そのため，ひらがな漢字などが連続している箇所では`w`，`e`で単語が同じ箇所へ移動してしまう

また，既存の関数を利用しているので日本語以外に関しても，いつも通りの動作になっている


## Helixでの基本移動について

Helixでは空白や改行以外がほぼ同じアルファベッドとして認識されている

そのため，日本語の漢字やカタカナ，絵文字などの区切り文字になりそうな文字も全て一つの単語として扱われてしまう

![japanese_w_e_move.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2630694/899cf5cf-0b17-3d79-dde1-cbea85158ee8.gif)

そこで日本語のひらがな，カタカナ，漢字，全角の記号でw，e，bの単語移動を強化する関数を作った

## Helixの内部を読む

HelixではUIや表示方法などそれぞれでCrateがわけられており，だいたいどこをいじればいいかが分かりやすい

今回のカーソル移動はエディタのCoreの部分にあたる

coreでは文字の扱い方や移動などエディタの基本機能が含まれている

もし，ポップアップやUIを変えたければそれこそviewやUIの箇所を重点的に見ればハックしやすい

~~それからもう一つ重要なのは`helix-term/src/commands.rs`の箇所である~~

~~ここを覗いてもらえればわかるが，コマンドと説明，コマンドのための関数が全てここに集約されている~~

~~そのため，ここから関数を追加していくのが一番やりやすいと思う~~

## unicodeの範囲

- ひらがな
- 半角ひらがな
- カタカナ
- 半角カナ
- 漢字

```rust:unicode_range.rs
#[derive(Debug, Eq, PartialEq)]
pub enum CustomWord {
    Hiragana,
    Katakana,
    Kanji,
    HankakuKatakana,
    HankakuHiragana,
}

impl CustomWord {
    pub fn from_char(ch: char) -> Option<CustomWord> {
        match ch {
            '\u{4E00}'..='\u{9FFF}'
            | '\u{3400}'..='\u{4DBF}'
            | '\u{20000}'..='\u{2A6DF}'
            | '\u{2A700}'..='\u{2B73F}'
            | '\u{2B740}'..='\u{2B81F}'
            | '\u{2B820}'..='\u{2CEAF}'
            | '\u{2CEB0}'..='\u{2EBEF}'
            | '\u{30000}'..='\u{3134F}' => Some(CustomWord::Kanji),
            '\u{3040}'..='\u{309F}' => Some(CustomWord::Hiragana),
            '\u{30A0}'..='\u{30FF}' => Some(CustomWord::Katakana),
            '\u{FF66}'..='\u{FF9F}' => Some(CustomWord::HankakuKatakana),
            '\u{FF00}'..='\u{FF5F}' => Some(CustomWord::HankakuHiragana),
            _ => None,
        }
    }
}
```

```diff_rust:lib.rs
mod transaction;
+ mod unicode_range;
pub mod uri;
pub mod wrap;
```

## 文字の判定

Helix限定ではないが、多くのエディタでは文字列を一文字ずつ判定をする

その文字が、次の文字と同じカテゴリかを判定する

```diff_rust:char.rs
use crate::unicode_range::CustomWord;
use crate::LineEnding;

#[derive(Debug, Eq, PartialEq)]
pub enum CharCategory {
    Whitespace,
    Eol,
    Word,
    Punctuation,
++  CustomWord(CustomWord),
    Unknown,
}

#[inline]
pub fn categorize_char(ch: char) -> CharCategory {
    if char_is_line_ending(ch) {
        CharCategory::Eol
    } else if ch.is_whitespace() {
        CharCategory::Whitespace
++  } else if let Some(custom_word) = CustomWord::from_char(ch) {
++      CharCategory::CustomWord(custom_word)
    } else if char_is_word(ch) {
        CharCategory::Word
    } else if char_is_punctuation(ch) {
        CharCategory::Punctuation
    } else {
        CharCategory::Unknown
    }
}
```

## 古い実装

よくよく調べてみたらもっとシンプルにできたので以下の文章は無視して大丈夫

ただし、上記のやり方であってもまだ拡張性がないのでどうしたものかと考え中

## 関数と説明の追加

先ほど説明した`commands.rs`の`static_commands!`内で名前と説明を追加する

move系の箇所に置いた方が良いと思うが今回はお試しなので一番下に追加する

```rust
    #[rustfmt::skip]
    static_commands!(
        no_op, "Do nothing",
        ...
        command_palette, "Open command palette",

        move_next_japanese_word_start: "move next Japanese word start",
        move_next_japanese_word_end: "move next Japanese word end",
        move_prev_japanese_word_start: "move previous Japanese word start",
    );
```

今回の関数は移動のための関数なので同様に`w`, `e`, `b`の関数がどのように実装されているかを見る

```rust
fn move_word_impl<F>(cx: &mut Context, move_fn: F)
where
    F: Fn(RopeSlice, Range, usize) -> Range,
{
    let count = cx.count();
    let (view, doc) = current!(cx.editor);
    let text = doc.text().slice(..);

    let selection = doc
        .selection(view.id)
        .clone()
        .transform(|range| move_fn(text, range, count));
    doc.set_selection(view.id, selection);
}

fn move_next_word_start(cx: &mut Context) {
    move_word_impl(cx, movement::move_next_word_start)
}

fn move_prev_word_start(cx: &mut Context) {
    move_word_impl(cx, movement::move_prev_word_start)
}

fn move_next_word_end(cx: &mut Context) {
    move_word_impl(cx, movement::move_next_word_end)
}
```

このように代入する関数名だけ変わってmove_word_implに渡されているので，同様にそれぞれの関数を作ってパラメータだけ変更しておく

```rust
fn move_next_japanese_word_start(cx: &mut Context) {
    move_word_impl(cx, movement::move_next_japanese_word_start)
}

fn move_next_japanese_word_end(cx: &mut Context) {
    move_word_impl(cx, movement::move_next_japanese_word_end)
}

fn move_prev_japanese_word_start(cx: &mut Context) {
    move_word_impl(cx, movement::move_prev_japanese_word_start)
}
```

あたりまえだが，movementでエラーが発生する

そこで`helix-core/src/movements.rs`へ移動する

## movements.rsでの移動関数の実装を読む

movements.rsではファイル名からわかる通り，カーソルの移動系の関数が全て実装されている

move_next_word_start関数を検索すると，今度はenumの箇所だけ変わってword_moveに渡されている

```rust
pub fn move_next_word_start(slice: RopeSlice, range: Range, count: usize) -> Range {
    word_move(slice, range, count, WordMotionTarget::NextWordStart)
}

pub fn move_next_word_end(slice: RopeSlice, range: Range, count: usize) -> Range {
    word_move(slice, range, count, WordMotionTarget::NextWordEnd)
}

pub fn move_prev_word_start(slice: RopeSlice, range: Range, count: usize) -> Range {
    word_move(slice, range, count, WordMotionTarget::PrevWordStart)
}

fn word_move(slice: RopeSlice, range: Range, count: usize, target: WordMotionTarget) -> Range {
    let is_prev = matches!(
        target,
        WordMotionTarget::PrevWordStart
            | WordMotionTarget::PrevLongWordStart
            | WordMotionTarget::PrevWordEnd
    );

    if (is_prev && range.head == 0) || (!is_prev && range.head == slice.len_chars()) {
        return range;
    }

    #[allow(clippy::collapsible_else_if)]
    let start_range = if is_prev {
        if range.anchor < range.head {
            Range::new(range.head, prev_grapheme_boundary(slice, range.head))
        } else {
            Range::new(next_grapheme_boundary(slice, range.head), range.head)
        }
    } else {
        if range.anchor < range.head {
            Range::new(prev_grapheme_boundary(slice, range.head), range.head)
        } else {
            Range::new(range.head, next_grapheme_boundary(slice, range.head))
        }
    };

    // Do the main work.
    (0..count).fold(start_range, |r, _| {
        slice.chars_at(r.head).range_to_target(target, r)
    })
}
```

Rangeがエディタ内部で使われている[ropey](https://github.com/cessen/ropey)([ropeデータ構造](https://ja.wordofwayne.com/wiki/Rope_(data_structure)))のスライスになっている

anchorとheadの二つがそれぞれがスライスの始端と終端を示す範囲になっている

```
Range(1, 9) 前方
T[his is a] sample text
Range(9, 1) 後方
T]his is a[ sample text
Range(1, 1) 一か所
T[]his is a sample text
```

word_move()では単純にenumでprevだったらrangeの範囲を逆にしているだけになっている

`Do the main work`と書かれた箇所を見る

するとfoldでスライスをrange_to_target()で処理しているのがわかる

### range_to_target()を読む

トレイトは無視して中身を読む

```rust
    fn range_to_target(&mut self, target: WordMotionTarget, origin: Range) -> Range {
        let is_prev = matches!(
            target,
            WordMotionTarget::PrevWordStart
                | WordMotionTarget::PrevLongWordStart
                | WordMotionTarget::PrevWordEnd
        );

        // Reverse the iterator if needed for the motion direction.
        if is_prev {
            self.reverse();
        }

        // Function to advance index in the appropriate motion direction.
        let advance: &dyn Fn(&mut usize) = if is_prev {
            &|idx| *idx = idx.saturating_sub(1)
        } else {
            &|idx| *idx += 1
        };

        // Initialize state variables.
        let mut anchor = origin.anchor;
        let mut head = origin.head;
        let mut prev_ch = {
            let ch = self.prev();
            if ch.is_some() {
                self.next();
            }
            ch
        };

        // Skip any initial newline characters.
        while let Some(ch) = self.next() {
            if char_is_line_ending(ch) {
                prev_ch = Some(ch);
                advance(&mut head);
            } else {
                self.prev();
                break;
            }
        }
        if prev_ch.map(char_is_line_ending).unwrap_or(false) {
            anchor = head;
        }

        // Find our target position(s).
        let head_start = head;
        #[allow(clippy::while_let_on_iterator)] // Clippy's suggestion to fix doesn't work here.
        while let Some(next_ch) = self.next() {
            if prev_ch.is_none() || reached_target(target, prev_ch.unwrap(), next_ch) {
                if head == head_start {
                    anchor = head;
                } else {
                    break;
                }
            }
            prev_ch = Some(next_ch);
            advance(&mut head);
        }

        // Un-reverse the iterator if needed.
        if is_prev {
            self.reverse();
        }

        Range::new(anchor, head)
    }
```

range(anchor, head)からわかる通り，anchor位置と移動後のheadの位置までの範囲を返している

ここでもprevなら逆にするという処理をしている

headが変更されている箇所を読み進めるとadvanceで定義されたクロージャでindexを変えてrangeの範囲を変えているのがわかる

また，今回は扱わないが改行の処理もここでやっているので，改行で移動したくない場合はこれをいじればカーソル移動を制御できそう

headはどう変化しているかを見ると`Find our target position(s)`からreached_target()に渡されているのがわかるので更に移動する

### reached_target()

reaced_target()とそこで使われている関数

ようやっとこのファイル内での最終地点に到達した

```rust
fn is_word_boundary(a: char, b: char) -> bool {
    categorize_char(a) != categorize_char(b)
}

fn reached_target(target: WordMotionTarget, prev_ch: char, next_ch: char) -> bool {
    match target {
        WordMotionTarget::NextWordStart | WordMotionTarget::PrevWordEnd => {
            is_word_boundary(prev_ch, next_ch)
                && (char_is_line_ending(next_ch) || !next_ch.is_whitespace())
        }
        WordMotionTarget::NextWordEnd | WordMotionTarget::PrevWordStart => {
            is_word_boundary(prev_ch, next_ch)
                && (!prev_ch.is_whitespace() || char_is_line_ending(next_ch))
        }
        WordMotionTarget::NextLongWordStart => {
            is_long_word_boundary(prev_ch, next_ch)
                && (char_is_line_ending(next_ch) || !next_ch.is_whitespace())
        }
        WordMotionTarget::NextLongWordEnd | WordMotionTarget::PrevLongWordStart => {
            is_long_word_boundary(prev_ch, next_ch)
                && (!prev_ch.is_whitespace() || char_is_line_ending(next_ch))
        }
    }
}
```

これもやっていることは単純で，連続するcharを比べて単語境界や空白，改行を見て移動範囲を決定しているのがわかる

ここの単語境界が今回のメインで実装する場所になる

空白や行末であれば区切りになるのは同じにしたのでこれは変えない

単語境界を正規表現でやっているのかを見てみると普通にcategorize_char()で文字の種類を判断しているので，最終目的地であるhelix-core/src/chars.rsをへ移動する

実装は後でやる

## helix-core/src/chars.rsを読む

各単語や空白などをunicodeで分類し，enumでカテゴライズしている

```rust
#[derive(Debug, Eq, PartialEq)]
pub enum CharCategory {
    Whitespace,
    Eol,
    Word,
    Punctuation,
    Unknown,
}

#[inline]
pub fn categorize_char(ch: char) -> CharCategory {
    if char_is_line_ending(ch) {
        CharCategory::Eol
    } else if ch.is_whitespace() {
        CharCategory::Whitespace
    } else if char_is_word(ch) {
        CharCategory::Word
    } else if char_is_punctuation(ch) {
        CharCategory::Punctuation
    } else {
        CharCategory::Unknown
    }
}
```

単語以外の記号や改行は同じ分類で単語の種類分けだけを変えたい

char_is_word()では英数字ならtrueを返すcharの組み込みメソッドが使われている

```rust
pub fn char_is_word(ch: char) -> bool {
    ch.is_alphanumeric() || ch == '_'
}
```

unicodeでひらがな，カタカナ，漢字を分類できれば良いことが分かる

## 日本語のみに対応可能な実装

今回の実装は日本語のみだが，もう少し抽象化できそうな感じがする

ここまで来るのに疲れたので諦めました

セパレータなどをconfigで設定して日本語だけではなく特定の記号でも区切りとしてできる可能性がある

また，今回はunicodeでの分類だが，wasmでpluginを作れるようになれば形態素解析を用いた移動が可能となる

既に[lindera](https://github.com/lindera-morphology/lindera)と[sudachi](https://github.com/WorksApplications/sudachi.rs)，[goya](https://github.com/Leko/goya)など日本語の形態素解析の実装はあるのであとはpluginを待てばできそう

### chars.rsでの実装

カテゴライズ用のenumを作る

日本語以外は普通に既存物を利用したいので元のカテゴリをバリアントとして内包しておく

https://www.unicode.org/charts/charindex.html

このサイトから日本語のひらがな，カタカナ，漢字のunicodeの範囲を求める

この範囲には半角カナとか全角記号の一部とかを全て入れてはいない

それを使って単純にcharが，いずれかの範囲にあれば作成したenumで分類するようにする

`#[inline]`のアトリビュートは初めて知った

[小さな関数とそれを呼び出す関数でインライン化する際など](https://qiita.com/drafts/1875748b8ae782a4238a/edit)で高速化できる

今回のように「関数を呼び出す関数」と「小さな関数」が複数ある場合には有効だそう

```rust
#[derive(Debug, Eq, PartialEq)]
pub enum JapaneseCharCategory {
    CharCategory(CharCategory),
    Hiragana,
    Katakana,
    Kanji,
}

#[inline]
pub fn categorize_japanese_char(ch: char) -> JapaneseCharCategory {
    if char_is_hiragana(ch) {
        JapaneseCharCategory::Hiragana
    } else if char_is_katakana(ch) {
        JapaneseCharCategory::Katakana
    } else if char_is_kanji(ch) {
        JapaneseCharCategory::Kanji
    } else {
        let category: CharCategory = categorize_char(ch);
        JapaneseCharCategory::CharCategory(category)
    }
}

const HIRAGANA_CHAR_START: char = '\u{3040}';
const HIRAGANA_CHAR_END: char = '\u{309f}';
const KATAKANA_CHAR_START: char = '\u{30a0}';
const KATAKANA_CHAR_END: char = '\u{30ff}';
const KANJI_CHAR_START: char = '\u{4e00}';
const KANJI_CHAR_END: char = '\u{9faf}';

#[inline]
pub fn char_is_hiragana(ch: char) -> bool {
    ch >= HIRAGANA_CHAR_START && ch <= HIRAGANA_CHAR_END
}

#[inline]
pub fn char_is_katakana(ch: char) -> bool {
    ch >= KATAKANA_CHAR_START && ch <= KATAKANA_CHAR_END
}

#[inline]
pub fn char_is_kanji(ch: char) -> bool {
    ch >= KANJI_CHAR_START && ch <= KANJI_CHAR_END
}
```

### movements.rsでの実装

JapanesePrevWordEndが入っているが，いらなければ消しても良い

こちらでも関数を使い分けるためのenumを実装し，既存のをバリアントとして内包しておく

日本語の単語境界を追加し，各モーションごとに止まりたい場所を決める

```rust
#[derive(Copy, Clone, Debug)]
pub enum JapaneseWordMotionTarget {
    WordMotionTarget(WordMotionTarget),
    NextJapaneseWordStart,
    NextJapaneseWordEnd,
    PrevJapaneseWordStart,
    PrevJapaneseWordEnd,
}

fn is_japanese_word_boundary(a: char, b: char) -> bool {
    categorize_japanese_char(a) != categorize_japanese_char(b)
}
fn reached_japanese_target(target: JapaneseWordMotionTarget, prev_ch: char, next_ch: char) -> bool {
    match target {
        JapaneseWordMotionTarget::NextJapaneseWordStart
        | JapaneseWordMotionTarget::PrevJapaneseWordEnd => {
            is_japanese_word_boundary(prev_ch, next_ch)
                && (char_is_line_ending(next_ch) || !next_ch.is_whitespace())
        }
        JapaneseWordMotionTarget::NextJapaneseWordEnd
        | JapaneseWordMotionTarget::PrevJapaneseWordStart => {
            is_japanese_word_boundary(prev_ch, next_ch)
                && (!prev_ch.is_whitespace() || char_is_line_ending(next_ch))
        }
        JapaneseWordMotionTarget::WordMotionTarget(word_target) => {
            reached_target(word_target, prev_ch, next_ch)
        }
    }
}
```

スペースや改行などは同じで，単語境界だけ今回のchars.rsで作ったカテゴライズの関数に変更した

rante_to_japanese_targetを利用するためにトレイトで定義を追加する

既存のを利用したいが上手く使えなかったので分けた

```rust
pub trait CharHelpers {
    fn range_to_target(&mut self, target: WordMotionTarget, origin: Range) -> Range;
    fn range_to_japanese_target(
        &mut self,
        target: JapaneseWordMotionTarget,
        origin: Range,
    ) -> Range;
}
```

```rust
    fn range_to_japanese_target(
        &mut self,
        target: JapaneseWordMotionTarget,
        origin: Range,
    ) -> Range {
        let is_prev_japanese_word = matches!(
            target,
            JapaneseWordMotionTarget::PrevJapaneseWordStart
                | JapaneseWordMotionTarget::PrevJapaneseWordEnd
        );

        // Reverse the iterator if needed for the motion direction.
        if is_prev_japanese_word {
            self.reverse();
        }

        // Function to advance index in the appropriate motion direction.
        let advance: &dyn Fn(&mut usize) = if is_prev_japanese_word {
            &|idx| *idx = idx.saturating_sub(1)
        } else {
            &|idx| *idx += 1
        };

        // Initialize state variables.
        let mut anchor = origin.anchor;
        let mut head = origin.head;
        let mut prev_ch = {
            let ch = self.prev();
            if ch.is_some() {
                self.next();
            }
            ch
        };

        // Skip any initial newline characters.
        while let Some(ch) = self.next() {
            if char_is_line_ending(ch) {
                prev_ch = Some(ch);
                advance(&mut head);
            } else {
                self.prev();
                break;
            }
        }
        if prev_ch.map(char_is_line_ending).unwrap_or(false) {
            anchor = head;
        }

        // Find our target position(s).
        let head_start = head;
        #[allow(clippy::while_let_on_iterator)] // Clippy's suggestion to fix doesn't work here.
        while let Some(next_ch) = self.next() {
            if prev_ch.is_none() || reached_japanese_target(target, prev_ch.unwrap(), next_ch) {
                if head == head_start {
                    anchor = head;
                } else {
                    break;
                }
            }
            prev_ch = Some(next_ch);
            advance(&mut head);
        }

        // Un-reverse the iterator if needed.
        if is_prev_japanese_word {
            self.reverse();
        }

        Range::new(anchor, head)
    }
```

これも既存のとまぜたかったが上手くいかなかったので分けた

targetだけで上手く分けられないかを考えているが挫折中

```rust
fn japanese_word_move(
    slice: RopeSlice,
    range: Range,
    count: usize,
    target: JapaneseWordMotionTarget,
) -> Range {
    let is_prev = matches!(
        target,
        JapaneseWordMotionTarget::PrevJapaneseWordStart
            | JapaneseWordMotionTarget::PrevJapaneseWordEnd
    );

    // Special-case early-out.
    if (is_prev && range.head == 0) || (!is_prev && range.head == slice.len_chars()) {
        return range;
    }

    #[allow(clippy::collapsible_else_if)] // Makes the structure clearer in this case.
    let start_range = if is_prev {
        if range.anchor < range.head {
            Range::new(range.head, prev_grapheme_boundary(slice, range.head))
        } else {
            Range::new(next_grapheme_boundary(slice, range.head), range.head)
        }
    } else {
        if range.anchor < range.head {
            Range::new(prev_grapheme_boundary(slice, range.head), range.head)
        } else {
            Range::new(range.head, next_grapheme_boundary(slice, range.head))
        }
    };

    // Do the main work.
    (0..count).fold(start_range, |r, _| {
        slice.chars_at(r.head).range_to_japanese_target(target, r)
    })
}
```

コマンド呼び出しのための関数を追加

```rust 
pub fn move_next_japanese_word_start(slice: RopeSlice, range: Range, count: usize) -> Range {
    japanese_word_move(
        slice,
        range,
        count,
        JapaneseWordMotionTarget::NextJapaneseWordStart,
    )
}

pub fn move_next_japanese_word_end(slice: RopeSlice, range: Range, count: usize) -> Range {
    japanese_word_move(
        slice,
        range,
        count,
        JapaneseWordMotionTarget::NextJapaneseWordEnd,
    )
}

pub fn move_prev_japanese_word_start(slice: RopeSlice, range: Range, count: usize) -> Range {
    japanese_word_move(
        slice,
        range,
        count,
        JapaneseWordMotionTarget::PrevJapaneseWordStart,
    )
}

pub fn move_prev_japanese_word_end(slice: RopeSlice, range: Range, count: usize) -> Range {
    japanese_word_move(
        slice,
        range,
        count,
        JapaneseWordMotionTarget::PrevJapaneseWordEnd,
    )
}
```

## もっとシンプルに書く場合

`CustomWord`を定義して別途範囲を判断するようにする

抽象的な名前にしてしまっているが、今回の中身は日本語のみなので気を付ける

```rust
use crate::unicode_range::CustomWord;
use crate::LineEnding;

#[derive(Debug, Eq, PartialEq)]
pub enum CharCategory {
    Whitespace,
    Eol,
    Word,
    Punctuation,
    CustomWord(CustomWord),
    Unknown,
}

#[inline]
pub fn categorize_char(ch: char) -> CharCategory {
    if char_is_line_ending(ch) {
        CharCategory::Eol
    } else if ch.is_whitespace() {
        CharCategory::Whitespace
    } else if let Some(custom_word) = CustomWord::from_char(ch) {
        CharCategory::CustomWord(custom_word)
    } else if char_is_word(ch) {
        CharCategory::Word
    } else if char_is_punctuation(ch) {
        CharCategory::Punctuation
    } else {
        CharCategory::Unknown
    }
}
```

- unicode_range.rsを作成

```rust
#[derive(Debug, Eq, PartialEq)]
pub enum CustomWord {
    Hiragana,
    Katakana,
    Kanji,
    HankakuKatakana,
    HankakuHiragana,
}

impl CustomWord {
    pub fn from_char(ch: char) -> Option<CustomWord> {
        match ch {
            '\u{4E00}'..='\u{9FFF}'
            | '\u{3400}'..='\u{4DBF}'
            | '\u{20000}'..='\u{2A6DF}'
            | '\u{2A700}'..='\u{2B73F}'
            | '\u{2B740}'..='\u{2B81F}'
            | '\u{2B820}'..='\u{2CEAF}'
            | '\u{2CEB0}'..='\u{2EBEF}'
            | '\u{30000}'..='\u{3134F}' => Some(CustomWord::Kanji),
            '\u{3040}'..='\u{309F}' => Some(CustomWord::Hiragana),
            '\u{30A0}'..='\u{30FF}' => Some(CustomWord::Katakana),
            '\u{FF66}'..='\u{FF9F}' => Some(CustomWord::HankakuKatakana),
            '\u{FF00}'..='\u{FF5F}' => Some(CustomWord::HankakuHiragana),
            _ => None,
        }
    }
}
```

- lib.rs追加

## 終わり

とりあえずはこれで日本語以外はいつもの動作で日本語の時には今回作った関数が動作する

ひらがな，カタカナ，漢字がそれぞれの境界として判別される

もちろん上手くいっていない箇所もあり，helixでは`w`が「単語の頭の前」に移動する

そのため，日本語文で空白がない場合は，「単語の頭の前」が「前の単語の後ろ」になるため，`w`と`e`が同じ挙動になってしまう

もう少しRustを上手く使えれば抽象化できそうだが，私の今の実力ではかなわないので諦めた

個人的な興味として形態素解析を使って文節ごとなどにも応用するのも考えていた

それをエディタに求めるほどのことをまだしていないので考えている途中