Visual Studio のコマンドラインクライアントのラッパー vf1s を作った

Visual Studioコマンドラインクライアントのラッパー vf1s を作った。

開発経緯

そもそもは

  1. 会社のルールでバイナリもsvn commitするが、Release版だけでなく、Debug版も用意しなくてはいけない
  2. VBNetには〈バッチビルド〉がなく、一括ビルドできない
  3. 手動で Release選択→ビルド実行→ Debug選択→ビルドすると、Releaseに状態を戻し忘れる
  4. どうせ、提出先ファイルサーバーへのコピーとかはバッチファイル使ってるし、バッチファイルから devenv .com を呼び出して、全部ビルドしたい
  5. devenv .comのディレクトリに場所にパスを通す Visual Studio Command Prompt は起動が重い。そんなに環境変数全部いらんよ
  6. 環境変数が設定されてなくても devenv .com のフルパスさえ分かっていればビルドはできる
  7. devenv .com の場所、VS2015 までと VS2017 以降では探し方が違う。また複数インストールしていると、どちらを呼ぶかの判断もあるので決め打ちは危険
  8. ソリューションファイルが必要としている Visual Studio のバージョンを自動で判断して、適切なバージョンの devenv .com のフルパスを調べて、ついでにわかりにくいオプションも分かりやすいしたツールがあると便利
  9. それ、Go で作ろう

使用するソリューションファイルの決定ルール

  • 引数で指定されていたら、その *.sln ファイルを読む
  • 指定されていなければ、カレントディレクトリにある *.sln を使う。複数ある時はエラー終了

実行する Visual Studio の決定ルール

  • -2010-2019 のオプションがついている時は、それを必ずそのバージョンの Visual Studio を使う。インストールされてなさそうな時はエラー終了
  • どの Visual Studio を実行するかは *.sln ファイルのコメント欄から推定する。( # Visual Studio 2010 などという行が 2,3行目に存在している)
  • *.sln ファイルで指定された Visual Studio がない場合は 2019 から順にインストールされている Visual Studio を探し、見つかった最も新しい Visual Studio を使用する。

Visual Studio クライアント devenv.com の検索ルール

Visual Studio 2010~2015

環境変数に Tools フォルダーにパスが格納されている。

  • 2015 → %VS140COMNTOOLS%
  • 2013 → %VS120COMNTOOLS%
  • 2010 → %VS100COMNTOOLS%

この環境変数に格納されているパスの Tools 部分を IDE に置換すると、devenv.com のあるフォルダー名となる。

Visual Studio 2017~2019

環境変数は廃止されている。そのかわり 2017~2019 では

C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe

というコマンドがある。これの出力から devenv.exe の場所が特定できる。

$ vswhere | findstr productPath
productPath: C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\Common7\IDE\devenv.exe
  • devenv.exe は devenv.com と基本機能は同じだが、コンソールに何も出力されない。コンソール出力を見たいので、拡張子を .com に変えておく。
  • 32bit Windows では C:\Program Files (x86) は存在しない。また、Program Files が C: ドライブ固定とみなすべきではない。vswhere.exe は環境変数 %ProgramFiles% と %ProgramFiles(x86)% 双方の下の Microsoft Visual Studio\Installer から探すようにする。

その上で

  • 最新のみ → vswhere latest
  • 2019 → vswhere -version [16.0,17.0)
  • 2017 → vswhere -version [15.0,16.0)

とすれば Visual Studio のバージョンを指定できる

以上を元にして、我々が呼び出した適切な Visual Studio の devenv.com のフルパスを検出することがほぼ出来るようになった。

使い方

-h オプション、もしくはソリューションファイルがある場所で引数なしで起動すると、ヘルプがでる。

$ vf1s -h
WorkReport.sln: word '2010' found.
%VS100COMNTOOLS% is not set.
look for other versions of Visual Studio.
found 'C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\Common7\IDE\devenv.com'
  -2010
        use Visual Studio 2010
  -2013
        use Visual Studio 2013
  -2015
        use Visual Studio 2015
  -2017
        use Visual Studio 2017
  -2019
        use Visual Studio 2019
  -a    build all configurations
  -c string
        specify the configuraion to build
  -d    build configurations contains /Debug/
  -i    open ide
  -ll
        list products
  -ls
        list products
  -n    dry run
  -r    build configurations contains /Release/
  -re
        rebuild

使用例:

  • vf1s -a … すべてのコンフィギュレーションの組み合わせ ( Win32 or x64 | Debug or Release ) でビルドする
  • vf1s -d … Debug という単語が含まれているコンフィギューレーションをビルドする
  • vf1s -r … Release という単語が含まれているコンフィギューレーションをビルドする
  • vf1s.exe -2019 -c "Release|x86" -re WorkReport.slnVisual Studio 2019 指定で、 WorkReport.sln というソリューションのRelease|x86 というコンフィギュレーションをリビルドする(全部指定)
  • vf1s -i … ビルドはせず、IDE を起動する
  • vf1s -ls … ビルドはせず、ソリューションファイルから作成される予定のプロダクト(EXE/DLL)のファイル名を標準出力に二重引用符で囲んでタブ区切りで出力する
  • vf1s -ll … ビルドはせず、ソリューションファイルから作成される予定のプロダクト(EXE/DLL)のファイル名を標準出力に改行区切りで出力する

なお、そのソリューションにどういうコンフィギュレーションがあるか、どういうファイルが作成されるかについては、ソリューションファイルとプロジェクトファイル(*.vcxproj,*.csproj,*.vbproj)を真面目に読んで推定している。この解析も結構たいへんだったのだが、今回は説明を見送る(XMLencoding/xml で単純に読んで分かる範囲でがんばっているが、まだ完璧とはいえないので)

MSBuild について

Microsoft は devenv .com ではなく MSBuild を使った方がよいと言っている。だが、VBNet の DLL をビルドする時、なぜか拡張子が .dll.dll という名前で生成されてしまう現象が確認されている(Visual Studio 2013か2015くらいだったと思う)。リネームすればよいのだが、それもあまりスマートとは思えないため、自分は devenv.com を使う方が無難と判断している。

最後に

今日、バイナリもアップロードしたので、よかったら使ってみてください。気に入ったら、チャンネル登録お願いします(まぁ、★でもつけてやってください)

以上

NYAGOS 4.4.4_0 を公開しました(その1)

記念すべき「令和元年」最初のリリースになります。括目せよ!(しなくてよい)

  • (#233) \\host-name\share-name を補完できるようになった

宿願である2年越し issue の補完を実装しました。

仕事を VMWare 上の Windows で行うことが多いのですが、ソースを置いているホスト側のフォルダーを net use P: \\vmware-host\Shared[TAB] とドライブをアサインする際に補完できないのが非常に苦痛でした。最近でこそ net use P: \\vmware-host\Shared" "Folders と空白の周辺だけ二重引用符で囲むという技を身につけて楽になっていましたが、以前は \\ までカーソルを戻して二重引用符を挿入していたのです。

無論なんとかしたかったのですが、WNetEnumResourceW をはじめとする mpr.dllAPI の使い方が分からなかった。最近になってようやく解決したのは、先日、記事に書いたとおりです。最終的な実装ソースは dos/netresource.go をご覧ください

だが、\host-name の補完だけはまだひどく遅い。改善に取り組んだ結果、share-name は普通の速度になったものの、host-name だけは最初の1回だけは…。エクスプローラーでは速くはないがまだ許容範囲でした。おそらく永続化したキャッシュを使ってるのでしょうね。こっちも永続化キャッシュを使うという手もありますが、いつ破棄するかという問題がつきもので、扱いが難しい。というわけで今のところ永続化は問題先送りです。

そのかわりというわけではありませんが、待っている間ハングアップしたかのように見えるのを回避するため / (スラッシュ)が回転するアニメをコンソールで表示するようにしました。さぁ、dir \\[TAB] してみましょう!

  • (#238) copyコマンドで進捗表示をするようにした

これも2年越しの issue ですね。CopyFileExW の引数にコールバック関数を渡せばよいだけの話なんですが、実装する気力がなくて放置してました。

手こずるかなーと思っていたのですが、やってみたら、64bit版は結構あっさりできました。具体的な実装コードは nodos/copy_windows.goです。

32bit 版はしばらくしてから動かないのに気づきました。uintptr の幅が違っていて、ファイルサイズを表す uint64 を渡せなくなってたんですね。上位32bitと下位32bitを別のuintptr として渡すようにしました。

  • 環境変数名=値 コマンド名 パラメータ… をサポート

env GOOS=linux go build を結構多用するようになったんですが、env と打つのも面倒になってきました。この時の環境変数は os.Setenv で恒久的に設定するのではなく、os.ProcessStart のパラメーターで子プロセスにのみ引き渡します。

  • バッチファイル用の一時ファイル名が重複する問題を修正

バッチファイルを実行する際、バッチファイルで変更される環境変数を取り込むために、nyagos-(PID).txt という一時ファイルを CMD.EXE にはかせていたんですが、

バッチファイル名1  | バッチファイル名2

とやったら、その一時ファイルのパスが重複してエラーになってしまいました。

  • (#277) set /a 式を実装

えーと、それほど必要性も高くなかったんですが、一旦 issue を自分で立てて「やっぱりやめました」もかっこ悪いので、がんばりました。とはいえ、今 issue にある奴のいくつかは「やっぱりやめました」不可避なんですが汗

この issue 自体は1年ほど寝かしていたものですね。意外と短い

  • (#291) バックグラウンド実行のプロセスのIDを表示するようにした

本件とは別に Use ShellExecuteEx as api to run GUI application. · Issue #288 · zetamatta/nyagos という issue がありまして、これを実現した結果、ShellExecuteExW で起動するプロセスの PID が取れるようになりました。CreateProcessW の方はそう難しくないので、結果、本件が物理的にできるようになりました。もうやるしかないでしょう。

当初は考えていなかったのですが、プロセスが終わった時に「プロセスID Done」みたいな出力も出すようにしました。これ、実はプロセス監視用に goroutine を立ち上げているのですが…まぁ、無限ループ回しているわけではなくて "os".(Process)Wait を呼んでるだけなので、許してくれますよね。

  • (#361) GUIアプリの標準出力がリダイレクトできなかった問題を修正

GUI アプリは ShellExecute* で実行していたので、普通にやると標準出力を切り替えることはできません(sudo win32 みたいに、ShellExecute される側に起動するためのランチャープログラムを用意していたら話は別)。

なので、リダイレクトする時だけ、CreateProcessW(os.ProcessStart) を使うよう修正しました。


今回のリリースでは他にもいろいろ行ったのですが、長いので続きは次回の記事にて…

Subversion で git show とか stash とかの動作をエミュレーション

あまりしっかりテストしていないので、ご利用はバックアップをとった上で、自己責任で… (自分の用事が終ったら放置モード)

svn-show.cmd

svn diff -r1000:1001 とかやっていられないので、svn-show 1001 で同じことをする。

@echo off
setlocal
if "%1" == "" (
    for /F "skip=1 tokens=1" %%I in ('svn log -l 1') do call :show %%I & exit /b
) else (
    call :show "%1"
)
endlocal
exit /b

:show
    set "REV2=%~1"
    set REV2=%REV2:r=%
    set /A REV1=REV2-1
    svn diff -r%REV1%:%REV2%
    exit /b

svn-stash.cmd

svn-stash とすると、コミットされていない修正が svn-stach.1 などというファイルに保存され、修正は消える。元に戻すには svn-stash svn-stash.1 とパッチファイルを引数に渡す。

setlocal
if "%1" == "" ( call :diff ) else ( call :patch "%~1")
endlocal
exit /b

:diff
    set /A cnt=cnt+1
    if exist svn-stash.%cnt%  goto :diff
    svn diff > svn-stash.%cnt%
    svn revert -R .
    exit /b

:patch
    svn patch "%~1"
    exit /b

NYAGOS 4.4.3_0 を公開していただと…

連休前の話ですが、NYAGOS 4.4.3_0 公開したので、更新内容をご説明します。

  • (#116) readline: Ctrl-Z,Ctrl-_ による操作取り消しを実装

3年前に issue を起案してた UNDO をようやく実装しました。やってみたら、意外と簡単でした。まぁ、技術的に難しいというよりも胆力不足で先送りしてたという感じでしょうか(最近、介護中の母を介護施設が預かっていただける曜日が増えたので、体力やら時間やらがかなり改善したのも大きいと思います)

なお、bash と違って、連続する UNDO 動作をまとめるようなことはしません。具体的にいうと、BSキーを4回タイプした後、これを元に戻す時、bash では1回のUNDOで4文字戻りますが、nyagos では4回のUNDOが必要です。これについては、bash のようにまとめても必ずしも使いやすいとも限らないので、無理して合わせる必要ないかなと考えました。

  • (#194) コンソールウインドウの左上のアイコンを更新するようにした。

2年前の起案していた件とありますが、これ、本来は「コンソール窓の左上のアイコン」ではなく、「タスクバーのアイコン」の方。これが nyagos.exe 内蔵のアイコンではなく、CMD.EXE アイコンになってしまうという問題。

今回「コンソール窓」の左上アイコンだけでも変えられるようになったので、関係事項として同issueに書きましたが…本命の方がまだ変えられていないので、クローズならずです。どうすりゃいいんでしょうね、これ。SendMesssage という API は初めて使ったのですが、タスクバーも似たようなことやるんでしょうかね。

CMD.EXE 内蔵コマンドについては、いちいち CMD /C 内蔵コマンド とか打ってられないので、nyagos.d/comspec.luaエイリアスを定義しているのですが、そこに date/time を追加しました(単に気づいてなかっただけ)

date/time なんて使う機会は滅多にないのですが、会社で「現在日時を変えてテストするテスト」をしなくちゃいけないことがあったのですが、一番、手軽に変えるのが date コマンドということで多用したので、追加した次第です。

  • cd 相対パス の後のドライブ毎のカレントディレクトリが狂う不具合を修正
    ( cd C:\x\y\z ; cd .. ; cd \\localhost\c$ ; c: ; pwd -> C:\x (not C:\x\y) )

DOSの頃はカレントディレクトリはドライブごとにあったのですが、Windows では全ドライブで1つしかありません。CMD.EXE(と MSVCRT.DLL)では「=X:」という変な名前の隠し環境変数で X ドライブのカレントディレクトリを保持するようにして、DOSの頃の動作をエミュレートしています。

で、NYAGOS もそれにならっています。(昔は MSVCRT.DLL の機能を使っていた)。で、今回の不具合は、違うドライブに移動する際に、それまでのドライブのカレントディレクトリを保存するのですが、そのタイミングを間違ってしまったという感じです。


で、今回の修正の目玉は事実上「UNDO」しかなくて、ちょっと物足りないのではあったのですが、連休中に結構ガリガリ書くことが予想されたため、一旦、連休前に安定版を出しておいた方が安全ではないかと考えた次第です。実際、かなりガリガリ書いたわけですが、それについては 4.4.4_0 の更新報告にて…

【解決編】UNCパスの補完機能の強化のためにコンピューター名一覧を出したいんだが、違うそうじゃない

記事を書いたら、自己解決する法則でもあるのかなぁ。

// +build run

package main

import (
    "fmt"
    "unsafe"

    "golang.org/x/sys/windows"
)

const RESOURCE_CONNECTED = 1
const RESOURCE_CONTEXT = 5
const RESOURCE_GLOBALNET = 2
const RESOURCE_REMEMBERED = 3
const RESOURCETYPE_ANY = 0
const RESOURCETYPE_DISK = 1
const RESOURCETYPE_PRINT = 2
const RESOURCEDISPLAYTYPE_NETWORK = 6
const RESOURCEUSAGE_CONNECTABLE = 1
const RESOURCEUSAGE_CONTAINER = 2
const RESOURCEUSAGE_ATTACHED = 16
const RESOURCEUSAGE_ALL = 19
const ERROR_NO_MORE_ITEMS = 259

var mpr = windows.NewLazySystemDLL("mpr.dll")
var procWNetOpenEnum = mpr.NewProc("WNetOpenEnumW")
var procWNetEnumResource = mpr.NewProc("WNetEnumResourceW")
var procWNetCloseEnum = mpr.NewProc("WNetCloseEnum")

type netresourceT struct {
    Scope       uint32
    Type        uint32
    DisplayType uint32
    Usage       uint32
    LocalName   *uint16
    RemoteName  *uint16
    Comment     *uint16
    Provider    *uint16
}

func u2str(u *uint16) string {
    if u == nil {
        return ""
    }
    buffer := make([]uint16, 0, 100)
    for *u != 0 {
        buffer = append(buffer, *u)
        u = (*uint16)(unsafe.Pointer(uintptr(unsafe.Pointer(u)) + 2))
    }
    return windows.UTF16ToString(buffer)
}

func WNetEnum(nr *netresourceT, handler func(localName string, remoteName string)) error {
    var handle uintptr

    // localNameW,err := windows.UTF16PtrFromString("localhost")
    // if err != nil {
    // return err
    // }

    rc, _, err := procWNetOpenEnum.Call(
        RESOURCE_GLOBALNET,
        RESOURCETYPE_ANY,
        0,
        uintptr(unsafe.Pointer(nr)),
        uintptr(unsafe.Pointer(&handle)))
    if rc != windows.NO_ERROR {
        return fmt.Errorf("NetOpenEnum: %s", err)
    }
    defer procWNetCloseEnum.Call(handle)
    for {
        var buffer [16*1024]byte
        count := int32(-1)
        size := len(buffer)
        rc, _, err := procWNetEnumResource.Call(
            handle,
            uintptr(unsafe.Pointer(&count)),
            uintptr(unsafe.Pointer(&buffer[0])),
            uintptr(unsafe.Pointer(&size)))

        if rc == windows.NO_ERROR {
            println("open")
            for i := int32(0) ; i < count ; i++ {
                var p *netresourceT
                p = (*netresourceT)(unsafe.Pointer(&buffer[ uintptr(i)*unsafe.Sizeof(*p) ]))
                handler(u2str(p.LocalName), u2str(p.RemoteName))
                WNetEnum(p,handler)
            }
            println("close")
        } else if rc == ERROR_NO_MORE_ITEMS {
            return nil
        } else {
            return fmt.Errorf("NetEnumResource: %s", err)
        }
    }
}

func main() {
    err := WNetEnum(nil,func(localName, remoteName string) {
        println(localName, " -> ", remoteName)
    })
    if err != nil {
        println(err.Error())
    }
}

// https://msdn.microsoft.com/ja-jp/library/cc447030.aspx
// http://eternalwindows.jp/security/share/share06.html

要は再帰的に呼ばないとダメというわけですね

$ go run eachnetdrive_run.go
open
  ->  Microsoft Terminal Services
  ->  Microsoft Windows Network
open
  ->  MICROSOFTACCOUNT
  ->  WORKGROUP
open
  ->  \\ATERM-71C395
  ->  \\DESKTOP-LGGUCRA
open
  ->  \\DESKTOP-LGGUCRA\tmp
close
close
close
  ->  Web Client Network
close

UNCパスの補完機能の強化のためにコンピューター名一覧を出したいんだが、違うそうじゃない

いまひとつうまくゆかん。

// +build gorun

package main

import (
    "fmt"
    "unsafe"

    "golang.org/x/sys/windows"
)

const RESOURCE_CONNECTED = 1
const RESOURCE_CONTEXT = 5
const RESOURCE_GLOBALNET = 2
const RESOURCE_REMEMBERED = 3
const RESOURCETYPE_ANY = 0
const RESOURCETYPE_DISK = 1
const RESOURCETYPE_PRINT = 2
const RESOURCEDISPLAYTYPE_NETWORK = 6
const RESOURCEUSAGE_CONNECTABLE = 1
const RESOURCEUSAGE_CONTAINER = 2
const RESOURCEUSAGE_ATTACHED = 16
const RESOURCEUSAGE_ALL = 19
const ERROR_NO_MORE_ITEMS = 259

var mpr = windows.NewLazySystemDLL("mpr.dll")
var procWNetOpenEnum = mpr.NewProc("WNetOpenEnumW")
var procWNetEnumResource = mpr.NewProc("WNetEnumResourceW")
var procWNetCloseEnum = mpr.NewProc("WNetCloseEnum")

type netresourceT struct {
    Scope       uint32
    Type        uint32
    DisplayType uint32
    Usage       uint32
    LocalName   *uint16
    RemoteName  *uint16
    Comment     *uint16
    Provider    *uint16
    _           [16 * 1024]byte
}

func u2str(u *uint16) string {
    if u == nil {
        return ""
    }
    buffer := make([]uint16, 0, 100)
    for *u != 0 {
        buffer = append(buffer, *u)
        u = (*uint16)(unsafe.Pointer(uintptr(unsafe.Pointer(u)) + 2))
    }
    return windows.UTF16ToString(buffer)
}

func WNetEnum(handler func(localName string, remoteName string)) error {
    var handle uintptr

    // localNameW,err := windows.UTF16PtrFromString("localhost")
    // if err != nil {
    // return err
    // }

    buffer := make([]byte, 1024)

    nr := (*netresourceT)(unsafe.Pointer(&buffer[0]))

    nr.Scope = RESOURCE_GLOBALNET
    nr.Type = RESOURCETYPE_ANY
    nr.DisplayType = RESOURCEDISPLAYTYPE_NETWORK
    nr.Usage = RESOURCEUSAGE_CONTAINER

    rc, _, err := procWNetOpenEnum.Call(
        RESOURCE_GLOBALNET,
        RESOURCETYPE_ANY,
        RESOURCEUSAGE_CONTAINER,
        uintptr(unsafe.Pointer(&buffer[0])),
        uintptr(unsafe.Pointer(&handle)))
    if rc != windows.NO_ERROR {
        return fmt.Errorf("NetOpenEnum: %s", err)
    }
    defer procWNetCloseEnum.Call(handle)
    for {
        count := uint32(1)
        size := 1024
        rc, _, err := procWNetEnumResource.Call(
            handle,
            uintptr(unsafe.Pointer(&count)),
            uintptr(unsafe.Pointer(&buffer[0])),
            uintptr(unsafe.Pointer(&size)))

        if rc == windows.NO_ERROR {
            handler(u2str(nr.LocalName), u2str(nr.RemoteName))
        } else if rc == ERROR_NO_MORE_ITEMS {
            return nil
        } else {
            return fmt.Errorf("NetEnumResource: %s", err)
        }
    }
}

func main() {
    err := WNetEnum(func(localName, remoteName string) {
        println(localName, " -> ", remoteName)
    })
    if err != nil {
        println(err.Error())
    }
}

// https://msdn.microsoft.com/ja-jp/library/cc447030.aspx
// http://eternalwindows.jp/security/share/share06.html

なんか、いい線まで言ってるような気はするんだが

$ go run eachnetdrive_run.go
  ->  Microsoft Terminal Services
  ->  Microsoft Windows Network
  ->  Web Client Network

違うそうじゃない。

NYAGOS保守日記: 空白文字が含まれたパスの補完とチルダ

補完の際、Windowsでは空白を打ち消す文字の定番が決まってないのでパス全体を引用符で囲む。すると ~\Program Files"~\Program Files" みたいになるが、~ が先頭でなくなってしまうので、 ~ を%USERPROFILE% に変換できなくなってしまう。

かといって、~\"Program Files" だと、引用符の前の \ が " の機能を打ち消すように解釈できてしまう。結局、~"\Program Files" という変態補完になったのが現在の nyagos。

だが、最近「~\Program" "Files\ でもいいんじゃね?」という妙案も浮かんでしまって、どうしたもんかなーとか悩んでいたり

一方で、ファイル名の空白を ⌴(U+2334)で表現して、引数を分割する空白と区別させることは出来ないかなぁ…とか考えている。しかし、⌴ を " " へ置換するタイミングが漏れると、不具合のもと。なかなか難しい

一回、展開関数まで書いたりしたんですけどね:

const AntiSpace="\u2334"

func FromAntiSpace(s string) string {
    var buf strings.Builder
    q := false
    for _,c := range s {
        if c == '"' {
            q = !q
        }
        if c != AntiSpace {
            buf.WriteRune(c)
        }else if q {
            buf.WriteByte(' ')
        }else{
            buf.WriteString(`" "`)
        }
    }
    return buf.String()
}

しかし、仮に、空白を ⌴ に置き換えて、二重引用符で囲まなくてもよくしたとしても、& とか | とか、別の機能文字があったりしたら、結局囲まなくてはいけなかったりするしなぁ…。あんまり意味ないか

ちなみに、nyaos 3000で \Share\Program Filesを補完すると~\"Share\Program Files\" となるなぁ。この時は \" を問題視しなかったのかな、俺