hashtable をKey-Valueのコレクションとして使いたい

のですが、普通にforeach, ForeEach-Object しただけでは期待した動作になりませんでした。

  • 期待する結果
PS C:\> $items = @{aaa=111;bbb=222;ccc=333}
PS C:\> $items | ForEach-Object {"{0}={1}" -f $_.Key, $_.Value}
aaa=111
bbb=222
ccc=333
  • 実際は
PS C:\> $items = @{aaa=111;bbb=222;ccc=333}
PS C:\> $items | ForEach-Object {"{0}={1}" -f $_.Key, $_.Value}
=
  • 要素が列挙されず、Hashtableがそのまま渡されてる!
PS C:\> $items | ForEach-Object {$_.GetType().FullName}
System.Collections.Hashtable
  • System.Collrctions.IEnumerableが実装されているはずですよね?
PS C:\> $items.GetEnumerator().GetType().FullName
System.Collections.Hashtable+HashtableEnumerator

IEnumerable.GetEnumerator はちゃんとあります。

  • Enumeratorでforeachしてみる?
PS C:\> $items.GetEnumerator() | ForEach-Object {$_.GetType().FullName}
System.Collections.DictionaryEntry
System.Collections.DictionaryEntry
System.Collections.DictionaryEntry

あ!できた?

PS C:\> $items.GetEnumerator() | ForEach-Object {"{0}={1}" -f $_.Key, $_.Value}
aaa=111
bbb=222
ccc=333

おおお!できました。

  • でもGetEnumerator()つけるのなんかダサい気がする

調べてみると、以下のような書き方が一般的のようですね。

Hashtableをforeachしても・・・ - PowerShell Scripting Weblog

PS C:\> $items.Keys | ForEach-Object {"{0}={1}" -f $_, $items[$_]}
bbb=222
ccc=333
aaa=111

コマンドレットにパイプで渡すと?

  • テスト用のfunctionで見てみましょう
function Test-Parameter
{
    [CmdletBinding()]
    Param(
        [Parameter(ValueFromPipeLine=$true, Mandatory=$true)]
        [PSObject]
        $InputObject
    )
    Begin
    {
        "*** Begin ***"
    }
    Process
    {
        "*** Process ***"
        " - InputObject: {0}" -f $InputObject.GetType()
        if($InputObject -is [System.Collections.IEnumerable])
        {
            " - foreach {"
            foreach($o in $InputObject){"    {0}={1}" -f $o.GetType(),$o}
            " }"
        }
        else
        {
            " - InputObject: {0}={1}" -f $InputObject.GetType(),$InputObject
        }
    }
    End
    {
        "*** End ***"
    }
}
PS C:\> $items = @{aaa=111;bbb=222;ccc=333}
PS C:\> $items | Test-Parameter

*** Begin ***
*** Process ***
 - InputObject: System.Collections.Hashtable
 - foreach {
    System.Collections.Hashtable=System.Collections.Hashtable
 }
*** End ***

想像通りですが、パイプで渡しても列挙されずに、ProcessにはHashtableが1回だけ入ってきました。

  • GetEnumerator()してから渡してみる
PS C:\> $items.GetEnumerator() | Test-Parameter
*** Begin ***
*** Process ***
 - InputObject: System.Collections.DictionaryEntry
 - InputObject: System.Collections.DictionaryEntry=System.Collections.DictionaryEntry
*** Process ***
 - InputObject: System.Collections.DictionaryEntry
 - InputObject: System.Collections.DictionaryEntry=System.Collections.DictionaryEntry
*** Process ***
 - InputObject: System.Collections.DictionaryEntry
 - InputObject: System.Collections.DictionaryEntry=System.Collections.DictionaryEntry
*** End ***

ちゃんと要素数だけ DictionaryEntry が渡されてきました。

コレクションを処理するコマンドレットに、Hashtableをコレクションとして渡したいケースがあれば、 Hashtable.GetEnumerator() で渡すのもアリかも。

他にも

string 型もIEnumerable<char> を実装していますが、そのままでは列挙しません。

PS C:\> "abcdefg" | ForEach-Object {"{0}:{1}" -f $_.GetType().FullName,$_}
System.String:abcdefg

PS C:\> "abcdefg".GetEnumerator() | ForEach-Object {"{0}:{1}" -f $_.GetType().FullName,$_}
System.Char:a
System.Char:b
System.Char:c
System.Char:d
System.Char:e
System.Char:f
System.Char:g

まあ、string をchar配列として使いたいケースは稀ですし、使いたい場合はToCharArray()で変換可能ですので、あまり気にする必要はないかも。

PS C:\> "abcdefg".ToCharArray() | ForEach-Object {"{0}:{1}" -f $_.GetType().FullName,$_}
System.Char:a
System.Char:b
System.Char:c
System.Char:d
System.Char:e
System.Char:f
System.Char:g

お好みのエディタにMarkdownプレビュー機能を追加するPowerShellスクリプト

PowerShell始めました

最近、興味はありつつ手を出せずにいたPowerShellに入門し始めました。
特に業務上必要になったという訳ではないのですが、仕事の合間を見つけては 日々のこまごまとした作業を自動化するスクリプトを書いたりして勉強しています。

今後は、PowerShell関連の記事も少しずつ書いて行きたいと思います。

なお、現在は以下の環境で開発、動作確認を行っています。

特に断りが無い限りは、この環境下で実行することを前提とします。

Markdownテキストをプレビュー表示するスクリプト

ブログのタイトルは釣りですが、Markdownをお手軽にプレビュー表示できて、 リアルタイムではないですが自動更新もしてくれるスクリプトです。

PowerShell (*1) で次のように実行すると、 指定したファイルが、その拡張子に関連付けされたアプリケーション(エディタ)で開き、 同時にプレビュー表示を行うためのブラウザ(IE)が開きます。

(*1) PowerShellは管理者権限で実行している必要があります(理由は後述します)。

PS > .\Markdown.ps1
PS > Start-Markdown "path\to\MarkdownText.md"

こんな感じで表示されます。

f:id:pierre3:20140913182502p:plain

エディタで内容を変更して上書き保存すると、自動的にプレビューの内容も更新されます。

サンプルコード

スクリプトファイル(.ps1)のサンプルはGistに置いてあります。

https://gist.github.com/pierre3/b17830375b620d1557a5

概要

主な処理の内容は以下の通りです。

  1. 指定したファイルを渡してテキストエディタを起動する
  2. MarkdownテキストをHTMLに変換する
  3. Internet Explorerを起動してPreview表示する
  4. Markdownテキストファイルが更新(保存)されたらプレビュー表示も更新する

1. 指定したファイルを渡してテキストエディタを起動する。

function Start-Markdown
{
    Param(
        # Markdown テキストファイルのパス
        [Parameter(Mandatory=$true)]
        [string]$Path
    )
    
    # 指定したファイルが無ければ作る
    if(-not(Test-Path $Path))
    {
        New-Item $Path -ItemType file -Force
    }
    #  Markdownテキストが格納されているディレクトリをフルパスで取得
    $sourceDir = Split-Path (Get-ChildItem $Path).FullName -Parent

    # ファイルの拡張子に関連付けされたアプリケーション(エディタ)で起動する
    start $Path

    # ...
}

処理はStart-Markdownという名前の関数として実装します。
パラメータには 編集したいMarkdownテキストファイルのパスを指定します。

start <ファイル名> でファイルを拡張子に紐づくアプリケーションで開くようにしていますが、 開くアプリケーションをパラメータで指定できるようにした方が良いかも。

2. MarkdownテキストをHTMLに変換する

MarkdownパーサにはMarkdownSharpを使用します。

以下からソースコード(Visual Studioのソリューション一式)をダウンロードします。 http://code.google.com/p/markdownsharp/downloads/list

ソリューションにはテストプロジェクトその他が含まれていますが、 MarkdownSharp 内の Markdown.cs がMarkdownSharpの本体で、ここに全コードが記述されています。
今回は、このソースファイル(Markdown.cs)をPowerShellに直接読み込ませて使用ます。

  • MarkdownSharp
    • Markdown.cs
  • MarkdownSharpTest
  • MarkdownSharp.sln

Markdown.csスクリプトファイルと同じ階層に「CSharp」フォルダを作成してその中に置いて使います。
また、Markdownを変換して生成したHTMLファイルは、「Html」フォルダ内に出力するようにしています。

  • [Root]
    • Markdown.ps1
    • [CSharp]
      • Markdown.cs
    • [Html]
      • MarkdownPreview.html
# HTMLテンプレート
$html=
@'
<!DOCTYPE html>
<html>
    <head>
        <title>Markdown Preview</title>
    </head>
    <body>
    {0}
    </body>
</html>
'@

$previewPath = "$scriptDir\Html\MarkdownPreview.html"

# ソースファイルをコンパイルして、PowerShellで使用可能にする
# コード内で参照しているアセンブリがある場合は`-ReferencedAssemblies`で指定
Add-Type -Path "$scriptDir\CSharp\Markdown.cs" -ReferencedAssemblies System.Configuration

# Markdown オブジェクトの生成
$markdown = New-Object MarkdownSharp.Markdown

# Markdownテキストの内容を取得
# パラメータ -raw スイッチを指定すると、ファイルの全内容がそのまま返される
# -raw を指定しない場合、1行単位に分割された配列が返却される
$rawText = Get-Content $Path -raw

# MarkdownテキストをHTMLに変換してファイルに書き出し
# `-f` で String.Format() と同様のことができる
$html -f $markdown.Transform($rawText) | Out-File $previewPath -Encoding utf8

3. Internet Explorerを起動してPreview表示する

# IEのCOMオブジェクトを生成
$ie = New-Object -ComObject InternetExplorer.Application

# 不要なツールバー等を非表示にする
$ie.StatusBar = $false
$ie.AddressBar = $false
$ie.MenuBar = $false
$ie.ToolBar = $false

# プレビューHTMLを表示
$ie.Navigate($previewPath)
$ie.Visible = $true

ここで注意が必要なのは、PowerShellを管理者権限で実行していない場合、$ie.Navigate()の直後に 以下の例外が発生し、以降COMオブジェクトの参照、操作が一切できなくなってしまいます。
(この時 IE本体にはファイルの内容が問題なく表示され、IEを直接操作することは可能です。)

起動されたオブジェクトはクライアントから切断されました。 (HRESULT からの例外:0x80010108 (RPC_E_DISCONNECTED))
発生場所 C:\..\Markdown.ps1:65 文字:5
+     $ie.Visible = $true
+     ~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : OperationStopped: (:) [], COMException
    + FullyQualifiedErrorId : System.Runtime.InteropServices.COMException

これは、file://プロトコル(ローカルファイルのパスを指定)でNavigateした場合に起きる現象で、
セキュリティ上の理由からなのか、Navigateした瞬間に強制的に参照が切られてしまうようです。

PowerShellを管理者権限で実行していた場合は、この問題は発生しないので、ひとまずそれで対処することとします。

4. Markdownテキストファイルが更新(保存)されたらプレビューも更新する

ファイルの更新を監視する処理には .Net Framework のSystem.IO.FileSystemWatcherを使用します。 FileSystemWatcher.WaitForChanged() メソッドでファイルの更新を待機します。
また、IEが閉じられているかを確認するために、WaitForChanged()の第2引数でタイムアウトを設定しています。 タイムアウト時間毎にIEの状態を確認して、閉じられていたらプレビュー表示を終了します。

# Markdownテキストファイルの変更を監視する File Watcherを生成
$watcher = New-Object System.IO.FileSystemWatcher
$watcher.Path = $sourceDir
$watcher.Filter = Split-Path $Path -Leaf
$watcher.NotifyFilter = [System.IO.NotifyFilters]::FileName -bor [System.IO.NotifyFilters]::LastWrite

# Markdownテキストが更新(保存)されたらHTMLに変換してInternet Explorerでプレビューする
# IEが開いている間繰り返す
while($ie.ReadyState -ne $null)
{
    # Markdownテキストファイルの更新日時が変わる(または タイムアウト5秒)まで待機
    $result = $watcher.WaitForChanged([System.IO.WatcherChangeTypes]::Changed, 5000)
    if(-not $result.TimedOut)
    {
        # タイムアウトでなければ変換&プレビュー
        $rawText = Get-Content $Path -raw
        $html -f $StyleSheet, $markdown.Transform($rawText) | Out-File $previewPath -Encoding utf8
        $ie.Refresh()
    }
    Start-Sleep -Milliseconds 100
}
 

まとめ

今回は、PowerShellそのものというよりも、COMや.Net Frameworkといった周辺要素を使ったサンプルとなりましたが、 思ったより簡単な記述で様々な要素を絡めた処理が実現できることが実感できました。

前回の記事

Roslynで作ってみた。C#インタプリンタ(もどき) - pierre3のブログ

のようなことをしなくても、簡単にソースコードを動的にコンパイルして実行できてしまうんですね~