nanoを手探ってみる#3:fuzzy finderは無理?

結論から言いますと、nanoのファイル読み込み機能にfuzzy finderを付け足すのは技術的にも時間的にも厳しそうということで、あきらめました。その代わり、開くファイルをインクレメンタルサーチできるような仕組みを実装したいと思います。

何を実装するか

nanoのファイル読み込み機能には元からタブ補完と候補ファイルの一覧表示があります。ただし、候補ファイルの一覧表示は少々不便です。読み込みファイル名を入力する画面で一度タブを押すとタブ補完が働き、続けてタブを押して初めてその時点での入力文字列にマッチするファイルの一覧が表示されます。これをタブ2回ではなく文字を打つたびに表示することができれば、インクレメンタルサーチのような形で入力するファイル名を絞り込めるのではと考えました。実現したい挙動はファイル読み込み画面にて、

  1. 文字を打つたびにその時点でマッチする候補ファイルの一覧を表示。
  2. タブを押すと今まで通り補完が働く。

の2点です。

どこでタブを処理しているか

1.については2度タブを押したときに起こるものを文字入力のたびに起こせばよいので、まずは読み込み機能が使われているときにタブの処理がどうなっているかを調べました。編集画面からCtrl+Rを押してファイル読み込み機能を呼び出すと、do_insertfile()が実行されます。do_insertfile()の内容はfiles.cに書かれていますが、その中でも1095行からのdo_prompt()で、ユーザーからの入力を受け取っているようです。

 i = do_prompt(TRUE,
#ifndef DISABLE_TABCOMP
        TRUE,
#endif
#ifndef NANO_TINY
        execute ? MEXTCMD :
#endif
        MINSERTFILE, given,
#ifndef DISABLE_HISTORIES
        NULL,
#endif
        edit_refresh, msg,
#ifndef DISABLE_OPERATINGDIR
        operating_dir != NULL && strcmp(operating_dir, ".") != 0 ?
        operating_dir :
#endif
        "./");

マクロが入り乱れて見づらいですが、do_prompt()の中を探っていきます。do_prompt()の定義はprompt.cに書いてあり、その中を見ていくと今度は680行で呼ばれているacquire_an_answer()が文字通りユーザーからの答えを受け取る関数のようです。

func = acquire_an_answer(&retval, allow_tabs,
#ifndef DISABLE_TABCOMP
            allow_files, &listed,
#endif
#ifndef DISABLE_HISTORIES
            history_list,
#endif
            refresh_func);

acquire_an_answer()の定義もprompt.c内で済まされており、その中の510行から621行までは無限ループになっています。

while (TRUE) {
    /* Ensure the cursor is shown when waiting for input. */
    curs_set(1);

    kbinput = do_statusbar_input(&ran_func, &finished, refresh_func);
<略>
    func = func_from_key(&kbinput);

    if (func == do_cancel || func == do_enter)
        break;

#ifndef DISABLE_TABCOMP
    if (func != do_tab)
        tabbed = FALSE;

    if (func == do_tab) {
#ifndef DISABLE_HISTORIES
        if (history_list != NULL) {
        if (last_kbinput != sc_seq_or(do_tab, TAB_CODE))
            complete_len = strlen(answer);

        if (complete_len > 0) {
            answer = get_history_completion(history_list,
                    answer, complete_len);
            statusbar_x = strlen(answer);
        }
        } else
#endif
        if (allow_tabs)
        answer = input_tab(answer, allow_files, &statusbar_x,
                    &tabbed, refresh_func, listed);
    } else
#endif /* !DISABLE_TABCOMP */
<略>
    } // while(TRUE)の終わり

514行目のkbinput = do_statusbar_input();のところで逐一キーを受け取りそれをもとに処理が行われるようです。肝心のタブの処理は、550行目の

 if (allow_tabs)
        answer = input_tab(answer, allow_files, &statusbar_x,
                    &tabbed, refresh_func, listed);

の部分で間違いなさそうです。関数名がinput_tab()な上、周りに#ifndef DIABLE_TABCOMPなんていうあからさまなマクロがあるので。

タブ処理の中身を改変

タブ補完処理の関数を見つけられたので、その内容を見ていきます。input_tab()の定義はfiles.cの中にあります。

char *input_tab(char *buf, bool allow_files, size_t *place,
    bool *lastwastab, void (*refresh_func)(void), bool *listed)
{
    size_t num_matches = 0, buf_len;
    char **matches = NULL;

    assert(buf != NULL && place != NULL && *place <= strlen(buf) &&
        lastwastab != NULL && refresh_func != NULL && listed != NULL);

    *listed = FALSE;

    /* If the word starts with `~' and there is no slash in the word,
     * then try completing this word as a username. */
    if (*place > 0 && *buf == '~') {
    const char *slash = strchr(buf, '/');

    if (slash == NULL || slash >= buf + *place)
        matches = username_tab_completion(buf, &num_matches, *place);
    }

    /* Match against files relative to the current working directory. */
    if (matches == NULL)
    matches = cwd_tab_completion(buf, allow_files, &num_matches, *place);

    buf_len = strlen(buf);

    if (num_matches == 0 || *place != buf_len)
    beep();
    else {
    size_t match, common_len = 0;
    char *mzero, *glued;
    const char *lastslash = revstrstr(buf, "/", buf + *place);
    size_t lastslash_len = (lastslash == NULL) ? 0 : lastslash - buf + 1;
    char *match1 = charalloc(mb_cur_max());
    char *match2 = charalloc(mb_cur_max());
    int match1_len, match2_len;

    /* Get the number of characters that all matches have in common. */
    while (TRUE) {
            // 候補ファイル全てに共通する文字列の長さを計算
<略>
        common_len += match1_len;
    }

    free(match1);
    free(match2);

    mzero = charalloc(lastslash_len + common_len + 1);

    strncpy(mzero, buf, lastslash_len);
    strncpy(mzero + lastslash_len, matches[0], common_len);

    common_len += lastslash_len;
    mzero[common_len] = '\0';

    /* Cover also the case of the user specifying a relative path. */
    glued = charalloc(strlen(present_path) + strlen(mzero) + 1);
    sprintf(glued, "%s%s", present_path, mzero);

    assert(common_len >= *place);

    if (num_matches == 1 && (is_dir(mzero) || is_dir(glued))) {
        mzero[common_len++] = '/';

        assert(common_len > *place);
    }

    if (num_matches > 1 && (common_len != *place || !*lastwastab))
        beep();

    /* If the matches have something in common, show that part. */
    if (common_len != *place) {
        // マッチする文字列を表示。要はタブ補完
    }

    if (!*lastwastab)
        *lastwastab = TRUE;
    else if (num_matches > 1) {
       // 候補一覧を表示
<略>
    }

    free(glued);
    free(mzero);
    }

    free_chararray(matches, num_matches);

    /* When we didn't list any matches now, refresh the edit window, just
     * in case a previous tab showed a list, so we know where we are. */
    if (!*listed)
    refresh_func();

    return buf;
}

引数のbool *lastwastabでひとつ前に押されたキーがタブであるか否かを判断しているようなので、これが常にTRUEになるようにしてみると…やはりタブ1度でファイルの一覧が表示されるようになりました。ただ、これをそのまま文字が入力されるたびに呼んでしまうと、タブ補完も行われてしまうので、うまく文字入力ができなくなります。そこで、input_tab()からタブ補完部分を省きマッチする候補ファイルの一覧表示のみを行う関数list_candidate()を作りました。

char *list_candidate(char *buf, bool allow_files, size_t *place,
        void (*refresh_func)(void), bool *listed)
{
    size_t num_matches = 0, buf_len;
    char **matches = NULL;

    assert(buf != NULL && place != NULL && *place <= strlen(buf) &&
            refresh_func != NULL && listed != NULL);

    *listed = FALSE;

    /* If the word starts with `~' and there is no slash in the word,
     * then try completing this word as a username. */
    if (*place > 0 && *buf == '~') {
        const char *slash = strchr(buf, '/');

        if (slash == NULL || slash >= buf + *place)
            matches = username_tab_completion(buf, &num_matches, *place);
    }

    /* Match against files relative to the current working directory. */
    if (matches == NULL)
       matches = cwd_tab_completion(buf, allow_files, &num_matches, *place);

    buf_len = strlen(buf);

    if (/*num_matches == 0 ||*/ *place != buf_len)
       beep();
    else {

        size_t match;
        if (num_matches >= 1) {
           // 候補ファイル一覧を表示
<略>
        }
    }

    free_chararray(matches, num_matches);

    /* When we didn't list any matches now, refresh the edit window, just
     * in case a previous tab showed a list, so we know where we are. */
    if (!*listed)
        refresh_func();

    return buf;
}

一つ前に入力されたキーがなんであったかは関係ないので、引数からbool *lastwastabを除き、マッチした候補ファイルの中から共通する文字列の長さを調べるwhile (TRUE) {で始まるループもなくしました。当然、タブ補完部分も省きましたが、他にも

    if (!*lastwastab)
        *lastwastab = TRUE;
    else if (num_matches > 1) {
        // 候補ファイル一覧を表示
<略>
    }

のようにマッチする候補が2つ以上ないと一覧が表示されなかったところを

    if (num_matches >= 1) {
        // 候補ファイル一覧を表示
<略>
    }

とすることで、候補が1つ以上あれば一覧表示が出てくるようにしました。最後に、prompt.cのacquire_an_answer()の中で、answer = input_tab();の下に

 } else if (((func == NULL && kbinput != -1) ||
            func == do_backspace || func == do_delete) &&
            (currmenu & MINSERTFILE)) {
        list_candidate(answer, allow_files, &statusbar_x,
                    refresh_func, &listed);

と追記することで、キー入力のたびにファイル一覧が表示されるようにすることができました。 #4に続きます。