komorebikoboshiのブログ

プログラミング記事(趣味レベル)が多め。

PATHの通ったフォルダにある名前が重複したファイルを探す

まえおき

プログラミングの解説書には最初のほうに大抵、「環境変数PATHに"~\bin"を追加しましょう」と書かれています。また、CUIアプリケーションのreadmeには「インストールフォルダをPATHに追加しておくと幸せになれるかも」とか書かれてることも多いです。で、どんどんPATHに追加していったわけですがある日ふと思いました、「多すぎじゃね?」と。
PATHに登録したフォルダにあるファイルはカレントディレクトリがどこであろうとファイル名のみでアクセスできる(exeとかなら拡張子も省略可能)のですが、これ、もし同じ名前のファイルが2つ以上あったらどうなるんだろう。ファイル名が違っても、「Nantoka.exe」と「nanToka.bat」は同じ「nantoka」というコマンドで実行できてしまう。これはなんというか、気持ちわるぅ。
ということで、コマンド名(拡張子を除いたファイル名)が重複しているファイルを探すRubyスクリプトを書いてみた。

コード

#coding: Windows-31J

path = ENV["PATH"].split(";")
       .select{|x| Dir.exist?(x)}
       .map{|x| x =~ /\\$/ ? x.chop : x}

pattern = "(\\" + ENV["PATHEXT"].gsub(";","|\\") + ")$"
reg_pathext = Regexp.new(pattern,Regexp::IGNORECASE)

cmd_path = Hash.new([].freeze)

path.each do |path_item|
  Dir.open(path_item) do |d|
    d.select{|x| x =~ reg_pathext}.each do |file|
      cmd = file[0,file.rindex(".",-1)].downcase
      fullpath = path_item + "\\" + file
      cmd_path[cmd] += [fullpath]
    end
  end
end

cmd_path.select{|k,v| v.length >= 2}.each do |k,v|
  puts k
  puts v
  puts
end

Windows XP SP3、Ruby1.9.3で動作確認しました。

適当にコメントしてみる

#coding: Windows-31J

path = ENV["PATH"].split(";")
       .select{|x| Dir.exist?(x)}
       .map{|x| x =~ /\\$/ ? x.chop : x}

Ruby1.9系を使っているので先頭にmagic comment。日本語環境しか考慮していません。メモ帳にコピペするだけで使える匠の粋な計らい……すみません、手を抜いただけです。*1
ENV["PATH"]で環境変数PATHを取得できるのであとはsplitで割ってやる。あらかじめ存在しないフォルダを除外して末尾の\を抜いておくという先見の明……嘘です、エラー出して気づきました。
最初

.map{|x| x.chop if x =~ /\\$/}

これだけで「末尾に\が付けば末尾の文字を取り除く。そうでないなら何もしない」となると思っていたのですが、それだったら\が付かない要素が nilに代わっていました。if文は条件が偽でelse節がなければnilを返すらしいので(制御構造)、ちょっと冗長な感じに。

pattern = "(\\" + ENV["PATHEXT"].gsub(";","|\\") + ")$"
reg_pathext = Regexp.new(pattern,Regexp::IGNORECASE)

省略できる拡張子は環境変数PATHEXTに入っているのでそれを取り出してsplitして~、とやろうとしたのですが、よく考えたら「;」を適当に置換してしまえば済みますね。うちの環境ではこんな感じの正規表現になります

/(\.COM|\.EXE|\.BAT|\.CMD|\.VBS|\.VBE|\.JS|\.JSE|\.WSF|\.WSH|\.PSC1)$/i

続き

cmd_path = Hash.new([].freeze)

path.each do |path_item|
  Dir.open(path_item) do |d|
    d.select{|x| x =~ reg_pathext}.each do |file|
      cmd = file[0,file.rindex(".",-1)].downcase
      fullpath = path_item + "\\" + file
      cmd_path[cmd] += [fullpath]
    end
  end
end

今回のスクリプトの心臓部です。最初

class CommandPath

  attr_reader :cmd,:fullpath

  def initialize(dir,file)
    @fullpath = dir + "\\" + file
    @cmd = file[0,file.rindex('.',-1)].downcase
  end

end

こんな感じのクラスを作って全部配列に突っ込んで、selectしてsortして同じcmdが並んでたら別の配列に突っ込んでいけばいいんだろ?とか思ってたけど実際にやってみるとなんかややこしい。で、マニュアルを見ていたらgroup_by(指定した値をキーに連想配列を作る。module Enumerable)という便利そうなものがあった。
値の要素数が2以上のを抽出すれば楽勝じゃね
→そもそも最初から連想配列作ればクラスいらなくね?
→hash[key].push(value)じゃ要素を突っ込めないのか
→Hashのデフォルト値を空の配列にしておけばいいんじゃね
→なんかマニュアルに面倒くさそうなことが書いてある。pushは使っちゃダメなのか
→書かれてたとおりに普通に配列を足していくべ#←いまここ
とかいう紆余曲折(実際はもっと長い)があって今の実装に。Dir.openをブロック構文で使うとリソースの開放を考えなくていいので楽ダネ。cmdはファイル名の先頭から「.」までを抜き出して小文字化。fullpathは単純に対象ディレクトリパスとファイル名を\いれて結合。

cmd_path.select{|k,v| v.length >= 2}.each do |k,v|
  puts k
  puts v
  puts
end

最後です。さっきの連想配列から値の要素数が2以上のもの(かぶりがあったもの)を抜き出して表示しています。
で、このスクリプトを実際に走らせてみた結果がこれです。

regini
C:\Program Files\Windows Resource Kits\Tools\regini.exe
C:\WINDOWS\system32\regini.exe

sleep
C:\Program Files\Windows Resource Kits\Tools\sleep.exe
C:\MinGW\msys\1.0\bin\sleep.exe

tail
C:\Program Files\Windows Resource Kits\Tools\tail.exe
C:\MinGW\msys\1.0\bin\tail.exe

tcmon
C:\Program Files\Windows Resource Kits\Tools\tcmon.bat
C:\Program Files\Windows Resource Kits\Tools\tcmon.exe

expand
C:\WINDOWS\system32\expand.exe
C:\MinGW\msys\1.0\bin\expand.exe

find
C:\WINDOWS\system32\find.exe
C:\MinGW\msys\1.0\bin\find.exe

ftp
C:\WINDOWS\system32\ftp.exe
C:\MinGW\msys\1.0\bin\ftp.exe

hostname
C:\WINDOWS\system32\hostname.exe
C:\MinGW\msys\1.0\bin\hostname.exe

notepad
C:\WINDOWS\system32\notepad.exe
C:\WINDOWS\notepad.exe

rcp
C:\WINDOWS\system32\rcp.exe
C:\MinGW\msys\1.0\bin\rcp.exe

rexec
C:\WINDOWS\system32\rexec.exe
C:\MinGW\msys\1.0\bin\rexec.exe

rsh
C:\WINDOWS\system32\rsh.exe
C:\MinGW\msys\1.0\bin\rsh.exe

slrundll
C:\WINDOWS\system32\slrundll.exe
C:\WINDOWS\slrundll.exe

sort
C:\WINDOWS\system32\sort.exe
C:\MinGW\msys\1.0\bin\sort.exe

taskman
C:\WINDOWS\system32\taskman.exe
C:\WINDOWS\TASKMAN.EXE

telnet
C:\WINDOWS\system32\telnet.exe
C:\MinGW\msys\1.0\bin\telnet.exe

tftp
C:\WINDOWS\system32\tftp.exe
C:\MinGW\msys\1.0\bin\tftp.exe

winhlp32
C:\WINDOWS\system32\winhlp32.exe
C:\WINDOWS\winhlp32.exe

winrm
C:\WINDOWS\system32\winrm.cmd
C:\WINDOWS\system32\winrm.vbs

iconv
C:\Ruby\bin\iconv.exe
C:\MinGW\bin\iconv.exe


MSYSをPATHから除外しとこうかなあ。

今後の方針とあとがきのようなもの

で、今後の事なんですけど。ソースコードを晒すときはなるべく今回のようにコメントを入れていこうかなあ、と。自分が何を考えてそのコードを書いたのかを書くことで、自分よりもスキルも知識もある人がいっぱいいるなかでコードを載せることに意味をつけれる気がします。どのように考えて書いたかなんて百人中百人が違うはずだから。まあ、自分が気になるからってのもありますけどね、他人のプログラムの組み方とか。
そんなわけで、これは解説じゃなくて、コメント。自分が書いたコードをネタに話をしているわけです。
まあ、今回のように全レスする勢いで書くのは多分今後ないかと。間延びしますし、なにより疲れる!次からはツッコミがいのある部分に絞って書けるようになろう。
あとはこまごまとした雑記

今回のスクリプトでは「selectで抽出してmapで加工、eachで仕上げ」というメソッドチェーンがたびたび出てきますが、これ無駄にループを回していますね。
例えば最初の部分では

path = []

ENV["PATH"].split(";").each do |x|
  if(Dir.exist(x))
    path << x =~ /\\$/ ? x.chop : x
  end
end

こう書けばループが1回で済みます。まあ自分は高階関数でメソッドチェーンでぐりぐりするのが好きですが。幸い趣味グラマーなので実行効率を無視して好きに書いても(公開しなければ)怒られません。Ruby2.0で入ると言われているEnumerator::Lazyを使うと改善される?(怠惰なRubyistへの道 - Enumerator::Lazy の使いかた // Speaker DeckRuby 2.0 メモ: Lazy と LINQ とループ融合

今回名前がかぶった実行ファイルはほとんどが、どちらも同じような機能をするのですが、なかには明確に違いがあるものもあります。
例えばexpand。WINDOWS\system32にある方は圧縮ファイルの解凍なのですが、MSYSにくっついている方はタブ文字をスペースいくつかに置き換える機能です。今回のソースコードの整形にはこれを使ってみました。

*1:よく考えたらASCII文字しか使ってないからファイル保存時の文字コードは関係ないなあ