標準愚痴出力

個人的なIT作業ログです。もしかしたら一般的に参考になることが書いているかもしれません(弱気

jujutsu v0.15.1 で、コミットログを gvim.exe で編集できなくなってしまった。

(注意:本件、解決したので、後日、対応方法をまとめます → (解決編) jujutsu v0.15.1 で、コミットログを gvim.exe で編集できなくなってしまった - 標準愚痴出力 )

jj commit を実行すると、gvim.exe がファイルを開かずに起動してしまう。:q で終了すると、jj は次のようにエラーメッセージを表示する。

$ jj commit
Error: Editor 'C:/Users/hymkor/scoop/apps/vim/current/gvim.exe' exited with an error
exit status 1

調査のため、環境変数 JJ_EDITOR に次のバッチファイル foo.cmd を登録してコミット操作を実行する

echo %1,%2,%3,%4,%5,%6
gvim "%1"
$ set JJ_EDITOR=%USERPROFILE%\foo.cmd
$ jj commit

$ echo \\?\C:\Users\hymkor\src\go-minimal-optional\.jj\repo\editor-tifUve.jjdescription,,,,,
\\?\C:\Users\hymkor\src\go-minimal-optional\.jj\repo\editor-tifUve.jjdescription,,,,,

$ gvim "\\?\C:\Users\hymkor\src\go-minimal-optional\.jj\repo\editor-tifUve.jjdescription"
Error: Editor 'C:\Users\hymkor\foo.cmd' exited with an error
exit status 1

コミットログを書く一時ファイルのパスが \\?\C:\... 形式のパス:DOSデバイスパス形式 になっていた。

  • コマンドラインから、同パスを gvim で起動すると症状は再現する
  • だが、メモ帳(notepad.exe)だと、問題なく編集できる

Windows では CON や AUX といったファイル名はディレクトリを問わずデバイスとして扱われるので、下手にアクセスするとアプリケーションがハングアップを起こしてしまう。だが、DOSデバイスパス形式 だと、普通のファイルとみなしてくれる。だから、jj が一時ファイル名の取り扱いを変えたのは理解は出来るし、notepad で開けるファイルであるから gvim で開けない方が悪いといえば悪いんだが…

とりあえず、こういうラッパーを書いて回避した。

jj_edit.cmd:

@ setlocal
@ set "PROMPT=$$ "
set "TARGET=%~1"
set "TARGET=%TARGET:\\?\=%"
"%EDITOR%" "%TARGET%"
@ exit /b %ERRORLEVEL%

環境変数 EDITOR には gvim.exe のフルパスを、環境変数 JJ_EDITOR の方に、このバッチファイルのフルパスを書く。これでコミットログが普通に書けるようになった。

しかし、これDOSデバイスパス形式 は一応まっとうな PATH なので、jj の不具合というわけでもない。かといって、vim にサポートしろというのも無理筋にも思える。どうしたもんかな

DVCS jujutsu (jj) v0.15.0 出てた

追記あり

とりあえず scoop update jj で、v0.14.0 → v0.15.0 への upgrade はしたものの、まだあまり使えていない。個人的に目ぼしいところなど

  • jj move --from/--to can now be abbreviated to jj move -f/-t

move は、よく使うから嬉しい

  • jj git init --colocate can now import an existing Git repository. This is equivalent to jj git init --git-repo=. .

この「git 併用の初期化」、推奨のやり方が二転三転してへん?(← v0.13 → v0.14 で jj init --gitjj git init になったのと、頭の中でゴッチャになっている模様)

  • Dropped support for the deprecated : revset operator. Use :: instead.

内容はともかくとして「〇〇のサポートをやめる」っていう英語、Drop support でもいいのか。

廃止するを意味する英単語、abandon があるけど、ちょっとしっくりこなくて「Remove the feature」とか使ってたけど、「Drop」の方が感覚的にあってる気がする。

  • The on-disk index format changed

これ、ぎょっとするなぁ。バージョンアップ後に jj log を初めて実行すると

$ jj log
Failed to load commit index file '56e84d6b8d7d1b6e050c97d642540437600d7ff3161342263f8103d128aaff73b3c3ce335e883d3acb194f476e087d5484de22f2bf6d121f2ef5f2a70f94ce18' (maybe the format has changed): 指定されたパスが見つかりません。 (os error 3). Reindexing...

とかなる。2回目以降は出ないみたいだけど…


全体として Breaking changes が多いなぁ。まだ、v0.*.* なので何ら問題ないが、自分など数ユーザもいないツールでも互換性の破壊は相当気を遣ったりするので、メジャーはやはり違いますなぁと思ったり

追記

7時間後に v0.15.1 が出てた

別にバグとかじゃなくて、ライブラリ向けのマクロだか何だかをちょっと追加しただけっぽい

$GOEXPERIMENT を定義すると、runtime.Version() の値がかわる

package main

import (
    "runtime"
)

func main(){
    println(runtime.Version())
}

未定義の時:

C:> go run foo.go
go1.22.0

$GOEXPERIMENT に rangefunc を設定した時:

C:> env.exe GOEXPERIMENT=rangefunc go run foo.go
go1.22.0 X:rangefunc

余談

これの実験中に拙作の env for Windows に不具合が見付かった。go1.22.0 X:rangefuncが表示される前に、プロンプトが表示されるのだ。

さくっと修正した。

--- a/src/main.rs
+++ b/src/main.rs
@@ -32,7 +32,7 @@
                 println!("call {}", arg);
             }
             let param: Vec<String> = args.collect();
-            if let Err(err) = std::process::Command::new(arg).args(param).spawn() {
+            if let Err(err) = std::process::Command::new(arg).args(param).status() {
                 return Err(Box::new(err));
             } else {
                 return Ok(());

何回、多重に nyagos が起動しているかをプロンプトで表示させる

Lua のテストで nyagos を起動すると、 exit が要るかどうか、よく分からなくなるので。

次のようなコードを .nyagos へ追加した。

nyagos.env.NYAGOS_RECURSE = tostring( tonumber( nyagos.env.NYAGOS_RECURSE or "0") + 1 )

nyagos.env.prompt = '[' .. nyagos.env.NYAGOS_RECURSE .. '] ' ..
    '$L'.. (nyagos.env.COMPUTERNAME or "") .. ':$P$G$_$$$s'

今回は nyagos.prompt フックを横取りするようなことはせず、単純に環境変数 PROMPT の文字列を変更するだけに留めた。

nyagos のネスト回数は、環境変数 NYAGOS_RECURSE に記録するようにした。未定義ならゼロ扱いとする。

結果は次のとおり:

[1] <DESKTOP-NPOTG52:~>
$ nyagos
Nihongo Yet Another GOing Shell 4.4.14_0-75-g85cf058e-windows-amd64 by go1.20.14
(c) 2014-2024 NYAOS.ORG <https://github.com/nyaosorg/nyagos>
loadfile C:\Users\hymkor\share\bin\.nyagos
start ~\share\bin\.nyagos
loadfile C:\Users\hymkor\Share\etc\gmnlisp_.lua
loadfile C:\Users\hymkor\Share\etc\complete-jj.lua
done amd64/.nyagos
[2] <DESKTOP-NPOTG52:~>
$

余談 (1)

nyagos.env は環境変数の読み書きをするために設けられたが、「現在は」 Lua 標準関数の os.getenv を使っても問題ない。これは LuaインタプリタGopherLua にしたからだ。

昔のnyagos は LuaBinaries の lua54.dll を使っていたので、os.getenv を使うと得られる値がANSI(非UTF8)となってしまう。それを避けるため nyagos.env を作った。1

余談 (2)

Perl の経験があると、Lua を書くとき、よく脳が混線してしまう

  • 文字列連結演算子..
    • Perl では . だったりするので、書く度に .. で良かったか、一瞬躊躇する (だいたいギリギリセーフ)
  • 比較演算子~= (!= 相当)
    • Perl には、正規表現=~ という演算子があるので、癖で =~ と書いてしまう (だいたい手遅れ)

困ったもんだ。


  1. 書いてしばらくしてから気付いたが、Lua標準には setenv 的な環境変数を変更する関数もなかったので、どっちにしても nyagos.env は必要だったね (Lua標準setenv がないのは、ANSI-C にも環境変数を参照する関数はあっても、変更する関数がないため)

nyagos 向けに jj のサブコマンドの補完関数を書いてみる

既に nyagos.d/category/subcomplete.lua というフレームワークがあるけれども、今回はそれを使わず新規に作ってみた1jj (jujutsu) と nyagos 両方のユーザは自分だけだと思うので、当面、nyagos に添付はしない。

用いるAPI

APInyagos.complete_for["xx"]=function(args) ... end を使う。

この関数は xx コマンドのパラメーターにおいて TAB が押下されたタイミングで、その時点でカーソルより左の単語全部が args として渡して呼び出される。この関数にて、補完候補をテーブルとして返してやればよい。

この戻り値は結構ざっくりでよく、"aaaa" の補完に対して "bbbb" という候補がまざっていても、自動的に排除するようになっている。

補完候補の作成

手作業でやると大変なので、半自動で作る。jjjj -h もしくは jj サブコマンド -h を実行すると、ヘルプが出る。この時、行頭が空白二つだけで終わる行に、サブコマンドが書いてある。

例:

$ jj -h
Jujutsu (An experimental VCS)

Usage: jj.exe [OPTIONS] <COMMAND>

Commands:
  abandon    Abandon a revision
  backout    Apply the reverse of a revision on top of another revision
  branch     Manage branches
  :(以下略)

これを読み取る。

function getUsage(command)
    print("$ " .. command)
    local subcommand = {}
    local fd = assert(io.popen(command))
    for line in fd:lines() do
        local m = string.match(line,"^  ([a-z][-a-z]+)")
        if m then
            subcommand[m] = {}
        end
    end
    fd:close()
    return subcommand
end

local jj = getUsage("jj -h")
for name,_ in pairs(jj) do
    if name ~= "help" then
        if string.sub(name,1,1) ~= "-" then
            jj[name] = getUsage("jj ".. name .. " -h")
        end
    end
end

これを実行すると、jj のサブコマンドと、サブコマンドのサブコマンドがテーブル jj が次のような感じに格納される。

jj={
    ["abandon"]={},
    ["backout"]={},
    ["branch"]={
        ["create"]={},
        ["delete"]={},
:以下略

補完候補のセーブ

ヘルプの呼び出しを毎回やっていては、すごく時間がかかるので、作成は手元で一回だけ行い、利用時は作成済みのものを使用するものとする。

テーブルの保存は JSON などで行えれば一番よいが、Lua からの読み書きは大変なので、簡単な汎用シリアライズ関数を作った。

function dump(fd,obj,indent)
    local t = type(obj)
    if t == "string" then
        fd:write('"'..obj..'"')
    elseif t == "number" then
        fd:write(obj)
    elseif t == "table" then
        fd:write("{")
        for key,val in pairs(obj) do
            fd:write("\n"..string.rep("    ",indent+1).."[")
            dump(fd,key,indent+1)
            fd:write("]=")
            dump(fd,val,indent+1)
            fd:write(",")
        end
        if next(obj) then
            fd:write("\n"..string.rep("    ",indent).."}")
        else
            fd:write("}")
        end
    elseif t == "boolean" then
        if obj then
            fd:write("true")
        else
            fd:write("false")
        end
    else
        fd:write("nil")
    end
end

この関数を dump(fd,obj,0) などと呼び出すと、obj の内容を「ロード可能なLuaソースコード」形式で、ファイルハンドル fd に対して出力する。本来であれば、テーブルの値やキーに二重引用符や改行などが含まれていても問題ないようにエスケープ処理が必要だが、今回は文字列は確実に英単語なので、そこまでやっていない。

これをこのように呼び出せば、share.jj に補完テーブルを設定する Lua コードが出力される。

local fd = assert(io.open("complete-jj.lua","w+"))
fd:write("share.jj=")
dump(fd,jj,0)

これでセーブされたコードは lua_f complete-jj.luaassert(loadfile("complete-jj.lua"))() などでロードできる。

補完関数本体

今回は手を抜いて、オプション文字列はスキップするようにした。

nyagos.complete_for["jj"] = function(args)
    if not string.match(args[#args],"^[-a-z]+") then
        return nil
    end

    local j = share.jj
    local last = nil
    while true do
        repeat
            table.remove(args,1)
            if #args <= 0 then
                return last
            end
            last = args[1]
        until string.sub(last,1,1) ~= "-"

        local nextj = j[ last ]
        if not nextj then
            local result = {}
            for key,val in pairs(j) do
                result[#result+1] = key
            end
            if next(result) then
                return result
            else
                return nil
            end
        end
        j = nextj
    end
end

ファイル数の削減

ファイル数がちょっと多くなってしまった。

  1. テーブルを作成する Lua コード (2. を出力する)
  2. テーブルを定義する Lua コード (1. が作成する)
  3. 2 をロードして、補完関数を定義する Lua コード

3つはちょっと多い。ということで、2 と 3 を統合するよう 1 を改造しよう。1 の中で 2 を出力する際に、3 の関数も含めてしまえばよい。そうするとロード処理が省略できる。

local fd = assert(io.open("complete-jj.lua","w+"))
fd:write("share.jj=")
dump(fd,jj,0)

fd:write([[

nyagos.complete_for["jj"] = function(args)
    if not string.match(args[#args],"^[-a-z]+") then
        return nil
    end
    :
    : 中略
    :
]])

fd:close()

これでファイルは二つになった。テーブルを作るソースは make-complete-jj.lua、作られたテーブルを使って補完関数を定義するソースを complete-jj.lua としよう

一応、最終成果も gist に張り付けときます

https://gist.github.com/hymkor/3eafc73125c5b5306c35771842c39f4a

~/.nyagos からロード

こんなコードを追記した。

for _,fname in pairs{"gmnlisp_.lua","complete-jj.lua"} do
    local fullpath = nyagos.pathjoin(nyagos.env.userprofile,"Share\\etc\\" .. fname)
    local fd=io.open(fullpath)
    if fd then
        fd:close()
        print("loadfile " .. fullpath)
        assert(loadfile(fullpath))()
    end
end

もともと ~/Share/etc/gmnlisp_.lua というファイルがあったらロードするというコードだが、それを ~/Share/etc/complete-jj.lua というファイルも見るようにしただけ。

これで jj のサブコマンド補完ができるようになった。オプションの補完が未対応だが、いつかやりたいね


  1. 自分で作ってみたい && 人様のコードを読むのがたいへんだからという、あまりよくない理由

(解説) The unbreakabe CSV editor - "CSView" v1.2.0

今回

  • 本番の業務データの手直し
  • 俺の家計簿のインポート用データ作成

に耐えうる CSV エディターという謎コンセプトのもと、大幅改修を行った。

Unbreakable CSV

よくある CSV エディターでは

"A1","B1","C1"
"A2","B2","C2"
"A3","B3","C3"

B2XX と修正すると

A1,B1,C1
A2,XX,C2
A3,B3,C3

と、全ての二重引用符がはがれてしまったりする。

CSView v1.1.3 までは標準パッケージの "encoding/csv" 経由で読みこんでいたので、二重引用符の有無が読み込んだ時点で失われていた。

CSView v1.2.0 では、文字コード変換、CSVの元文字列の現状保持まで行う独自のサブパッケージ "github.com/hymkor/csview/unbreakable-csv"1 を新規に作成し、使用するようにした。その結果:

"A1","B1","C1"
"A2",XX,"C2"
"A3","B3","C3"

と修正箇所以外、原則的に変更が発生しないようにすることが可能になった。

この例では変更したXX の二重引用符が剥れてしまっているが、" キーで二重引用符で囲むことを明示的に指示することも可能だ。

なお、これは二重引用符だけではなく、BOM の有無、CRLF/LFの混在については同様の扱いとなる。つまり:

  • BOMのあるセルから勝手にBOMは削除しない
  • BOMのないセルに勝手にBOMは追加しない
  • LF行を勝手にCRLFにしない
  • CRLF行を勝手にLF行にしない

これらによって、例えば本番データに何らかの不備があって、手修正しなければいけない場合でも、本ツールが使える。修正後、diff コマンドで本当に自分が手を加えた箇所だけが変わっていることを確認できれば安心できるだろう。

修正有無の管理

  • 修正したセルは下線を引くようにした
  • 修正済みのセルの上で u を押下すると、最初の値を復元できるようにした。
    (そして、オリジナルの値に復元すると下線は消える)

ステータスラインの表示を改善した。

  • セルの値の元データを表示するようにした。
    二重引用符で囲まれているか否かを目視できる。
  • 区切り文字を表示
    ,␍␊ など表示するようにした。(フォントが分かりにくいが)

第二文字コードを指定可能に

今までは UTF8 でない場合の文字コードWindows の現在のコードページとなっていた。だが、Linux などにはコードページがないため、第二文字コードがない状態だった。

そこで csview -iana Shift_JIS FILENAME というように明示的に指定できるようにした。 文字コード名については以下を参照のこと。日本においては、ほぼ Shift_JISEUC-JP で済むが

幅があいまいな文字が含まれている場合、セルの幅が正しくなくなる不具合を修正

→ とか ∇ のこと。v1.1.3 を WindowsTerminal で利用すると、一桁しか使っていないのに二桁使用していると誤認して、表示がズレる問題があった。

これらの文字はUnicodeにおいて、2桁分を消費するか1桁分を消費するか、端末ごとに決まるとされている。nyagos や go-readline-ny では ターミナルの種類を調べて、どちらであるか判断していた。だが、CSView ではそのような個別対応はせず、起動時に∇を出力して、そのカーソル移動量を ESC[6n で得て調べるという汎用的なことをした。これは:

という記事に記載されている Reline (pure Ruby) の方法を真似た2

ヒストリーや補完対応

  • セル入力時: 現在のセルと同じ列の上で使われている値で補完(Ctrl-I)したり参照(Ctrl-P)できるようにした。
  • 保存時:ファイル名補完をサポート

go-readline-ny/completion の補完機能に少し手を入れて3 、補完候補が1個しないない時に空白を末尾に入れないようにした。

まとめ

今回の修正で、データに余計な変更が入らないという強みができた以上、 「Simple CSV viewer/editor」というDescription は、やや弱気すぎるように思われた。そこで「Unbreakable CSV editor 」と少しえらそうなものに改めてみた。

CSView という名前自体も変えようかと思ったが、レポジトリ名はあまりホイホイ変えるべきではないので、今回は保留とした。

これで、ユーザがちょっとくらい増えれば嬉しいところだ。


  1. (2024-03-26追記) v1.3.0 よりgithub.com/hymkor/csview/uncsv と名前を改めた
  2. 元祖は Vim らしい
  3. Release v1.1.0 · nyaosorg/go-readline-ny

go-lazy 二値対応 ("sync".OnceValues 相当品)

Go で遅延初期化を実現するパッケージ:go-lazy を更新した。

このパッケージと同様のものは

でも記載したとおり、Go 1.21 で実装された("sync".OnceValue)。ゆえに go-lazy は基本用済みだが、Go 1.21 以降は Windows 7,8.1,2008Server などのサポートが切られているため、まだ Go 1.18〜1.20 では需要がある。

また、"sync".OnceValue と違い、プログラム起動時に関数コールが発生しないため、少し軽いというメリットもあった。

そんな go-lazy もサポートしていない機能があった。それは "sync".OnceValues にように複数の値を返すような場合である。どういう時に利用するか、当初分からなかったが、最近、遭遇した。それはエラーが発生するような初期化関数を呼ぶ場合だった。

var ConInHandle = sync.OnceValues(func() (uintptr, error) {
    conin := []uint16{'C', 'O', 'N', 'I', 'N', '$', 0}
    h, err := windows.CreateFile(
            &conin[0],
            windows.GENERIC_READ|windows.GENERIC_WRITE,
            uint32(windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE),
            nil,
            windows.OPEN_EXISTING,
            windows.FILE_ATTRIBUTE_NORMAL,
            0)
    if err != nil {
            return 0, fmt.Errorf("windows.CreateFile: %w", err)
    }
    return uintptr(h), err
})

ConInHandle は標準入力がリダイレクトされていても端末入力するためのハンドルだが、利用するためには "CONIN$" をオープンしなければいけない。だが失敗する場合もある。だが、パニックはさせたくなかった。ゆえに呼び出し元に毎回エラー値をハンドルともども返すようにしている。

ゆえに利用箇所では次のように毎回エラーチェックをしなければいけないが、まぁ仕方がない(Goプログラマーなら慣れっこのはず)

    in, err := ConInHandle()
    if err != nil {
        return nil, err
    }

go-lazy版では、次のようになる

var Handle = lazy.Two[uintptr, error]{
    New: func() (uintptr, error) {
        conin := []uint16{'C', 'O', 'N', 'I', 'N', '$', 0}
        h, err := windows.CreateFile(
            &conin[0],
            windows.GENERIC_READ|windows.GENERIC_WRITE,
            uint32(windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE),
            nil,
            windows.OPEN_EXISTING,
            windows.FILE_ATTRIBUTE_NORMAL,
            0)
        if err != nil {
            return 0, fmt.Errorf("windows.CreateFile: %w", err)
        }
        return uintptr(h), err
    },
}
    in, err := Handle.Values()
    if err != nil {
        return nil, err
    }