nanoを手探ってみる#4:nanorcの読み込み

前回とはトピックが変わり、nanorcを起動時以外にも読み込める機能を実現したことについて書きます。

設定ファイルの読み込みについて

世の中に数多く存在しているLinux系のアプリケーションは、起動時に設定ファイルを読み込みます。設定ファイルはvimのであれば.vimrc、Emacsの場合は.emacs.d/init.el、bashなら.bashrcなどがあり、rcファイルと呼ばれることもあります。ほとんどのアプリケーションでは起動のときだけでなく、実行中のときも明示的に設定ファイルを読み込みなおすことができます。例えばVimなら

:source ~/.my_vimrc

とすれば改めて設定が読み込まれます。もちろんnanoにも.nanorcという設定ファイルがあるのですが、これは起動時にしか読み込めません。そこで、実行時にもファイルを読み込む機能をつけることにしました。

ソースからわかったこと

nanoではmain()内の無限ループwhile (TRUE) {...}でユーザーが打鍵するキーを受け取っていました。また、ユーザーが送ったキーに対応するコマンドを呼び出している関数も見つかったのでこの中身をいじれば新しいコマンドを定義できそうなことがわかりました。global.c内のadd_to_sclist()を使えばsclistにショートカットを登録できるようです。

add_to_sclist()とadd_to_func()

自分で定義した関数をadd_to_sclist()でショートカットに追加してみたところ、とくになにも起こりませんでした。add_to_sclist()で登録する前にadd_to_funcs()で関数を関数一覧に登録する必要があったようで、これをするとうまくいきました。なぜ、ショートカットを追加するのにわざわざ2段階処理を踏むのかは謎です。add_to_funcs()の引数にはint menusというものがあり、これは関数を実行するときのFLAGみたいです。このFLAGにはたくさんの種類がありnano.hの543行のところで定義されています。

#define MMAIN           (1<<0)
#define MWHEREIS       (1<<1)
#define MREPLACE       (1<<2)
#define MREPLACEWITH       (1<<3)
#define MGOTOLINE      (1<<4)
#define MWRITEFILE     (1<<5)
<略>

このFLAGの意味がいまいちよくわかりません。

FLAGの正体

今一度コードをしっかり読んでみると、FLAGはnanoのmodeを表していることがわかりました。MWHEREISなら文字列検索モード、MREPLACEなら文字列置換モード、MWRITEFILEならファイル書き込み(保存)モードといった感じです。Vimはmodeがあるエディタとして有名ですが、nanoはVimととの違いを強調するためにmodelessという言葉で紹介されることも多いです。そのようなエディタが内部でmodeの仕組みを採用していたのは意外でした。

成果

global.cのモード定義のところに

#define MREADRC         (1<<15)

という一文を追加して新たにrc読み込み専用のmodeを用意しました。このFLAGでadd_to_funcs()をした後でadd_to_sclist()をすると、ユーザーが入力したpathから設定ファイルを読み込むことに成功したので、当初の目的は達成です。適切にmodeを設定して、プロンプト表示を出す関数を呼ぶと、modeに対応したヘルプ(ショートカットキーの一覧)も表示されます。ついでに、あると便利な機能としてset autoindentなど設定コマンドを単独で実行できる機能もつけてみました。

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に続きます。

nanoを手探ってみる#2: デバッグ出力と操作画面の分離

前回はnanoのビルドが終わるところまでいきました。早速ソースコードで見つけた関数にブレイクポイントを設置して、gdbで処理を見てみます。ファイル読み込み機能に手を加えたいので、do_insertfile()という関数にブレイクポイントを張って実行します。

gdb ./nano
<略>
(gdb) b do_insertfile
Breakpoint 1 at 0x40a6f7: file files.c, line 1064.
(gdb) r

Ctrl+Rを押すとファイル読み込み機能が呼ばれて処理が止まるのですが…

f:id:meloidae:20161106121757p:plain

nanoのUI、デバッグ用のログ、gdbコマンドラインが混ざってすごいことになります。これでは出力が見づらくてデバッグがしにくいので、デバッグ出力と操作画面を分離させる方法を考えてみました。

gdb attachを使う

gdbにはattachという機能があり、この機能を使うと先に起動しておいたプログラムのプロセスに、あとからgdbの制御をかませることができます。使い方はgdbを起動してから

(gdb) attach [pid]

とするか、gdb起動時に-pオプションをつけて

gdb -p [pid]

とすればいいです。pidの値は

pgrep nano

とコマンドを打てば表示されます。attachの何が便利かというと、gdbをnanoとは別のターミナル画面で開くことができます。つまり、nanoのUIとgdbの制御画面を分離できるわけです。これだけだとまだnanoのUIとログ出力が混ざったままなので、nanoのstderr出力(デバッグのログはここから出ます)を適当なログファイルのリダイレクトします。さらにログファイルをtailすれば常にログを確認しながらデバッグができます。

まとめると、3つのターミナル画面を用意して1つめのターミナルでnanoを起動。このときstderr出力をlog.txtにリダイレクトする。

./nano 2> log.txt

2つめのターミナルでgdbをnanoにattachし、起動。(sudoがないとattachできません。)

sudo gdb -p $(pgrep nano) 

3つめのターミナルでlog.txtをtail。

tail -f log.txt

ログが見やすくなって解析が捗ります。(16.04になってますが、見本用に撮っただけなので気にしないでください。)

f:id:meloidae:20161106131315p:plain

ターミナルを複数起動するのが面倒という人にはtmuxなどのターミナルマルチプレクサもおすすめです。

#3に続きます。

nanoを手探ってみる#1: ビルドする

大学で「大規模ソフトウェアを手探る」という課題があったので、そのときにnanoに手を加えたときの記録を書いていきます。OSはUbuntu14.04でペアを組んで二人で作業しました。目標はnanoのファイル読み込み機能にfuzzy finderをつけることです。

nanoとは

GNU nanoはワシントン大学開発のpicoというTUIテキストエディタのクローンとなるべく作られたテキストエディタです。動作が軽快な他、下によく使うショートカットキーの一覧が表示されるので、初めて使う人でも操作に戸惑うことがないです。vimemacsほどの拡張性・多機能性はありませんが、ちょっとしたスクリプトを編集する程度なら十分すぎるくらいです。多くのLinux(Ubuntu14.04含む)やOSXディストリビューションに規定のエディタとして入っているにも関わらず、vimemacsに役目を奪われてしまう悲しい宿命を背負ったエディタでもあります。

f:id:meloidae:20161106102225p:plain

まずはビルドから

http://git.savannah.gnu.org/cgit/nano.git/をgit cloneすればソースが手に入ります。cloneしたフォルダ内のREADME.GITの内容に従ってビルドすればいいようなので、とりあえず、Prerequisitesに書いてあるパッケージをインストールします。

sudo apt-get install autoconf automake gettext git groff pkg-config texinfo libglib2.0-dev

これらに加えてどうやらcursesというものも必要らしいのでそれもインストールします。

sudo apt-get install libncurses5-dev

README.GITに従い、nanoのディレクトリの中で

 ./autogen.sh
CFLAGS="-O0 -g -DDEBUG" ./configure --prefix=$HOME/nano_install

とコマンドを打ちます。CFLAGSは左から最適化なし、gdbデバッグ用、デバッグ用の出力をstderrに出すオプションです。autogenはうまくいったのですが、configureは途中でautopointがない、というエラーメッセージが出て失敗してしまいました。書いてあったの全部入れたのになんでやと思って調べると、autopointをインストールすればよいと当たり前のことが書いてある記事を見つけました。

sudo apt-get install autopoint

そのあと再度configureするとうまくいきました。あとは

make
make install

とすると$HOME/nano_install内のbinフォルダにnanoとrnanoの実行ファイルが生成されるました。これでデバッガを使って中身を探りながらコードを書き換えることができます。
#2に続きます。