sublime Textプラグインで編集中の文字列を操作

前回(Sublime Textプラグイン作成に関する話題 - 愚鈍人)は、いろいろと話題を詰め込みすぎてなにがなんだかわからなくなったので、今回は的を絞って...

【 目次 】

markdownで記事を書いていると、選択した複数行の行頭にまとめて引用記号(>)を挿入したくなってきた。
sublimeにこんな機能が無いか探してみたがわからない。

こんな時は自作のプラグインで作れないかと思考錯誤してみた。

sublimeのAPIは前回も記したとおり、「SublimeText3 - Sublime Text API Reference(翻訳) - Qiita」が参考になる。

regionとpoint、そして行番号と列番号

sublimeでテキストを操作するには、テキストの位置をどのようにして操作するかがキモとなるように思う。

point

文字位置を示す概念としてpointなるものがある。
APIのドキュメントを読んでみると、pointオブジェクトなるものがあるのかと誤解してしまうが、pointはオブジェクト(つまりPointクラスのインスタンス)では無く、どうもテキストファイルからの先頭から数えた0から始まる文字位置(改行文字CRやLFを含また)を示すint型の数値のようだ。(多分そうだと思う)

region

テキストファイルの開始文字位置(point)から終了文字位置(point)までの文字列範囲を示すオブジェクト。
10文字目から20文字目までの文字範囲を示すregionオブジェクトを作成するには、

region = sublime.Region(10, 20)

regionから開始文字位置や終了文字位置を取得するには

start_point = region.begin()    # 開始文字位置、先の例では10を返す。
end_point = region.end()        # 終了文字位置、先の例では20を返す。

regionには文字列領域の開始位置を返すbeginメソッド,終了位置を返すendメソッドと、選択範囲の開始点の文字位置を示すaプロパティ,終了点の文字位置を示すbプロパティがある。
注意しなければならないのは、テキストファイルの前の文字位置から後ろの文字位置までを選択した場合、

region.begin() = region.a
region.end() = region.b

となるのに対して、後ろの位置から前の文字位置までの文字を選択すると

region.begin() = region.b
region.end() = region.a

と逆になってしまうようだ。

選択範囲のregionを取得するselメソッド

selメソッドを使用すると選択範囲のregionオブジェクトを取得する事ができる。
sublimeは複数の領域を選択範囲とする事が可能なので、選択範囲を示すregionも複数存在している可能性がある。

このため、selメソッドの戻り値はSelectionと呼ばれるregionオブジェクトのリストとなる。

pythonのリスト型については

従って、プラグインより選択範囲を取得して操作をおこなうには、以下のコードが定番となる。

class ExampleCommand(sublime_plugin.TextCommand):
    def run(self, edit):
        for region in self.view.sel():
            if not region.empty():
                # 選択範囲(region)が空でなければ何かの操作をおこなう。

pointと行番号,列番号の相互変換

文字位置pointから行番号と列番号(0から始まる)を取得するには、

row, col = self.view.rowcol(point)

逆に行番号と列番号から文字位置を取得するには、

point = self.view.text_point(row, col)

カーソル位置のpointを取得する。

regionオブジェクトの終点のpoint,region.bはカーソル位置を示しているようだ(これも多分)。
以下にカ-ソル位置のpointをコンソールに表示するプラグインのコードを示す。

class GetCursorPosCommand(sublime_plugin.TextCommand):
    def run(self, edit):
        for region in self.view.sel():
            cursor_pos = region.b
            print(self.view.rowcol(cursor_pos))

コマンドを実行するにはコンソールより

view.run_command('get_cursor_pos')

カーソル位置を移動

pointで指定した位置にカーソルを移動させるにはどうしたらいいのだろうか?
APIを探してみてもそれらしいものがみつからない。

ふと思いついたのがview.run_command('コマンド名')を使ってカーソル移動のコマンドを実行させる事。

メニューよりPreferences > Key Bindings – Default で、 Default (xxx).sublime-keymapを参照して、カーソル移動のキーバインドを探してみる。

Default (Windows).sublime-keymap

    { "keys": ["left"], "command": "move", "args": {"by": "characters", "forward": false} },
    { "keys": ["right"], "command": "move", "args": {"by": "characters", "forward": true} },
    { "keys": ["up"], "command": "move", "args": {"by": "lines", "forward": false} },
    { "keys": ["down"], "command": "move", "args": {"by": "lines", "forward": true} },
    ...
    { "keys": ["ctrl+home"], "command": "move_to", "args": {"to": "bof", "extend": false} },
    { "keys": ["ctrl+end"], "command": "move_to", "args": {"to": "eof", "extend": false} },

movemove_toなどのコマンドを実行させれば良い事はわかるが、文字位置を指定する例が無いようだ。

考えあぐねた末、Default のプラグインのmovemove_toコマンドのソースを探してみる。

Sublime Text 3の場合、インストールデレクトリの直下のPackagesデレクトリ(C:\Program Files\Sublime Text 3\Packages)にDefault.sublime-packageというファイルが存在する。
このファイルはzipファイルであるらしく、これをコピーして拡張子zipを付加して解凍する事でDefaultパッケージのソースを得る事ができる。
このDefaultのパッケージの中を探してみると、movemove_toコマンドのソースを見つける事はできなかったが、goto_line.pyというpythonのソースが見つかる。

このgoto_line.pyを参考にカーソル移動のコマンドを実装してみたのが以下のコード。

goto_cursor_posコマンド

1
2
3
4
5
6
7
class GotoCursorPosCommand(sublime_plugin.TextCommand):
    def run(self, edit, point_str):
        point = int(point_str)
        self.view.sel().clear()
        region = sublime.Region(point)
        self.view.sel().add(region)
        self.view.show(point)

コマンドの引数には文字列を指定しなければならないので、渡された引数をint型のpoint値に変換。(3行目)
カーソル位置を変更するには現在の選択範囲を一旦クリヤー(4行目)した後、新たにpoint位置のregionオブジェクトを作成(5行目)して選択範囲に加える(6行目)ようだ。
更に、pointの位置まで画面をスクロール(7行目)。

selメソッドの戻り値は前述したとおりpythonのリスト型であるため、リスト型のメソッドclearaddを使って操作がおこなえる事になる。

point=5の位置にカーソルを移動させるためにはコンソールより以下を実行。

view.run_command('goto_cursor_pos', {"point_str":"5"})

カーソルを移動させる方法を探すのに手間取ってしまたが、このやり方は選択領域をいろいろと操作するのに今後役立ちそう。

regionやpoint位置の文字を操作する。

pointやregionより文字列を取り出すsubstr

substrメソッドの引数にpointやregionを指定する事で、pointやregionの文字列を取り出すことができる。
以下に、選択範囲の選択開始位置の文字と選択終了(カーソル)位置の文字および選択領域の文字列をコンソールに出力する例を示す。

class GetStrCommand(sublime_plugin.TextCommand):
    def run(self, edit):
        for region in self.view.sel():
            start_point = region.a
            print("start_point={0}".format(self.view.substr(start_point)))

            end_point = region.b
            print("end_point={0}".format(self.view.substr(end_point)))

            print("region={0}".format(self.view.substr(region)))

コンソールより以下を実行。

view.run_command('get_str')

pointやregionへ文字列を設定する

point位置に文字列を挿入するにはinsertメソッドを。

self.view.insert(edit, point, "挿入する文字列")

region内の文字列を置き換えるにはreplaceメソッドを。

self.view.replace(edit, region, "置き換える文字列")

region内の文字列を削除するにはeraceメソッドを。

self.view.erace(edit, region)

単語や行のregionを取得する。

行のregion - lineメソッド,full_line

pointまたはreginが含まれれる行全体のregionを取得するには、lineメソッドの引数にpointまたはreginを指定する。
引数として指定したregionが複数行をまたぐ場合は、lineメソッドは含まれる行の全てを含むregionを返す。

line_region1 = self.view.line(point)    # point位置の行を示すregionオブジェクトを返す
line_region2 = self.view.line(region)   # regionが含まれる行(複数行の場合もある)を示すregionオブジェクトを返す

行全体のregionを取得するメソッドとしてlineメソッドの他にfull_lineメソッドがある。
lineメソッドとfull_lineメソッドの違いは、lineメソッドが最終行の改行を含まないのに対してfull_lineメソッドは改行を含む。

以下のコードはlineメソッドとfull_lineメソッドの違いを確認するためのものである。

class LineTestCommand(sublime_plugin.TextCommand):
    def run(self, edit):
        for region in self.view.sel():
            line_region = self.view.line(region)
            print("line : 「{0}」".format(self.view.substr(line_region)))

            full_line_region = self.view.full_line(region)
            print("full_line : 「{0}」".format(self.view.substr(full_line_region)))

複数行を選択してコンソールからコマンドを実行。

view.run_command('line_test')

コンソールに表示される実行結果を確認するとfull_lineメソッドには最終行の改行が余分に含まれている。

選択した行ごとのregionオブジェクトのリスト - linesメソッド

複数行を選択した状態でlineメソッドを実行すると複数行を含む(複数行が連結された)regionオブジェクトを返すのに対して、linesメソッドを実行すると選択した行ごとのregionオブジェクトのリストを返す。

以下はlinesメソッドの例である。

class LinesTestCommand(sublime_plugin.TextCommand):
    def run(self, edit):
        for region in self.view.sel():
            lines_region = self.view.lines(region)
            for line_region in lines_region:
                row, _ = self.view.rowcol(line_region.begin())
                print("{0:>4} : {1}".format(row + 1, self.view.substr(line_region)))

複数行を選択してコンソールからコマンドを実行。

view.run_command('lines_test')

単語 - wordメソッド

指定されたpointやregionの単語を取得するにはwordメソッドを。
しかし、日本語の場合は単語の区切りの判別が難しいため、単語の取得は無理なようだ。
ところでwordメソッドの引数に複数の単語を含むregionを指定した場合、先頭の単語から末尾の単語を含んだregionを返すようだ。

せっかくなので、wordメソッドより返されるregionをもとに、pythonのsplitメソッドを使って選択した領域に含まれる単語のリストを取得してみよう。

class WordListTestCommand(sublime_plugin.TextCommand):
    def run(self, edit):
        import re
        for region in self.view.sel():
            word_region = self.view.word(region)
            word_list = filter(lambda word_str: word_str != "", 
                re.split('\W+', self.view.substr(word_region)))
            for word_str in word_list:
                    print(word_str)

コンソールより以下を実行。

view.run_command('word_list_test')

単語の抽出って、どう認識するのかっていうのは。

応用編

選択した複数行の行頭にまとめて引用記号(>)を挿入

メールの返信やmarkdownの引用などで選択した複数行の行頭に、まとめて引用記号(>)を挿入したくなって。
(こんな機能はもっと良く調べれば、sublimeにもともとある機能かもしれないが。)

class BlockQuoteCommand(sublime_plugin.TextCommand):
    def run(self, edit):
        for region in self.view.sel():
            start_point = region.begin()
            end_point = region.end()
            start_row, _ = self.view.rowcol(start_point)
            end_row, end_col = self.view.rowcol(end_point)
            if end_col == 0 :
                end_row = end_row - 1

            for row in range(start_row, end_row + 1):
                self.view.insert(edit, self.view.text_point(row, 0), ">")

逆に、行頭の引用記号(>)を削除。

class UnBlockQuoteCommand(sublime_plugin.TextCommand):
    def run(self, edit):
        for region in self.view.sel():
            start_point = region.begin()
            end_point = region.end()
            start_row, _ = self.view.rowcol(start_point)
            end_row, end_col = self.view.rowcol(end_point)
            if end_col == 0 :
                end_row = end_row - 1

            for row in range(start_row, end_row + 1):
                self.view.insert(edit, self.view.text_point(row, 0), ">")

選択行の行頭と行末に指定した文字列を挿入

上記、引用記号挿入の汎用版として、引数で指定した文字列を選択行の行頭と行末に挿入するコマンドも作ってみた。

class MySurroundLineCommand(sublime_plugin.TextCommand):
    def run(self, edit, begin_str,end_str):
        for region in self.view.sel():
            start_point = region.begin()
            end_point = region.end()
            start_row, _ = self.view.rowcol(start_point)
            end_row, end_col = self.view.rowcol(end_point)
            if end_col == 0 :
                end_row = end_row - 1

            for row in range(start_row, end_row + 1):
                line_region = self.view.line(self.view.text_point(row, 0))
                line_text = self.view.substr(line_region)
                self.view.replace(edit, line_region, begin_str + line_text + end_str)

コマンド行より以下を実行すると、選択行が<>で囲まれる。

view.run_command('my_surround_line',{"begin_str":"<", "end_str":""})

end_str引数に空文字""を指定すればBlockQuoteCommandと同じ動作に。

逆に行頭と行末の指定した文字列を削除するには。

class MyUnSurroundLineCommand(sublime_plugin.TextCommand):
    def run(self, edit, begin_str,end_str):
        begin_str_len = len(begin_str)
        end_str_len = len(end_str)
        for region in self.view.sel():
            start_point = region.begin()
            end_point = region.end()
            start_row, _ = self.view.rowcol(start_point)
            end_row, end_col = self.view.rowcol(end_point)
            if end_col == 0 :
                end_row = end_row - 1

            for row in range(start_row, end_row + 1):
                line_region = self.view.line(self.view.text_point(row, 0))
                line_text = self.view.substr(line_region)

                if begin_str_len >0 and line_text.startswith(begin_str):
                    line_text = line_text[begin_str_len :]
                if end_str_len >0 and line_text.endswith(end_str):
                    line_text = line_text[: len(line_text) - begin_str_len]

                self.view.replace(edit, line_region, line_text)

よく考えたらMySurroundLineCommandはlinesメソッドを使って, もっと簡潔に記述できそうな気がする。

このコードは正しく動作しない

class MySurroundLine2Command(sublime_plugin.TextCommand):
    def run(self, edit, begin_str,end_str):
        for region in self.view.sel():
            lines_region = self.view.lines(region)
            for line_region in lines_region:
                line_str = self.view.substr(line_region)
                replace_str = begin_str + line_str + end_str
                self.view.replace(edit, line_region, replace_str)

ところがこのコードはうまく動かない。
どうも、replaceメソッドを実行する事によって次のline_regionの文字位置がズレてしまうようだ。

ページのトップへ戻る