Archives

Archives / 2006 / December
  • A templating engine using PowerShell expressions

    [Update Januari 5, 2008: fixed a small bug, content was always saved in encoding ANSI, resulting in the loss of special characters.

    Changed the line:

    Set-Content -Path $destination -value $expandedText

    to

    Set-Content -Path $destination -value $expandedText -encoding $Encoding

    The attached file is updated as well.

    ]

    While working on our Software Factory for SharePoint 2007 solutions I needed to do some simple template expansion. First choice would be to use the Text Templates from the DSL toolset as available in the Visual Studio 2005 SDK, and write the templates in the T4 template language. Problem is that the template expansion I need to do right now needs to expand variables, function  and expressions in the PowerShell language. So I created a small PowerShell script to implement the Template-Expand command to do just that. First some simple explanatory but useless examples:

    $a='template'
    function MyFunction($action) { if ($action -eq 1) { 'a function'} else { 'WRONG!' }}
    ./Template-Expand -text 'This is a [[$a]] test to execute [[MyFunction -action 1]] and to add 2+3=[[2+3]] '
    

    Results in:

    This is a template test to execute a function and to add 2+3=5

    You can also assign the output to a variable like in the following example. In this example I changed the default left and right markers [[ and ]] to the same syntax  used in the T4 template language:

    $result = ./Template-Expand -leftMarker '<#=' -rightMarker '#>' -text 'This is a <#= $a #> test to execute <#= MyFunction -action 1 #> and to add 2+3=<#= 2+3 #>'

    The variable $result now contains the expanded template text.

    Nota bene that the markers are used to construct our matching regular expression as follows: [regex]"$leftMarker(.*?)$rightMarker", so the marker strings must escape special regular expression characters. The default value for the left marker is for example "\[\[".

    I also added some extra options, like the possibility to read the template from a file, and write the expanded template text to a destination file using the options -path and -destination.

    If you have a template file template.txt with the following content:

    <values>
        <value>
    [[    
        $a=10
        $b=20
        $a*$b
        for ($i=0; $i -lt 3; $i++)
        {
            $i*5
            $i*10
        }
    ]]    
        </value>
    </values>

    You can execute the template expansion with the following command:

    ./template-expand -path template.txt -destination templateexpanded.txt

    This will result in a file templateexpanded.txt with the following content:

    <values>
        <value>
    200 0 0 5 10 10 20    
        </value>
    </values>

    I know, the example is useless, but you get the drift;-) Important thing to notice in the example, expressions can consist of multiple lines!

    You can also define functions within your template as in the following example:

    [[
    function SayHelloWorld
    {
        "Hello world!"
    }
    ]]
    And then he said:
    [[SayHelloWorld]]

    If you want to have the configuration of variables and functions in a separate powershell file, use the -psConfigurationPath. The specified file (which must have a .ps1 extension) will be sourced, so variables and functions don't have to be defined in the global context.

    Thanks to this blog entry by the PowerShell team I got the needed delegation stuff working.

    And now the code, happy templating en let me know if it works for you or which features you are missing!!

    Save the code below to Template-Expand.ps1. I also added this file as an attachment to this blog post.

    ---------------cut-----------------cut--------------cut-------------cut------------cut---------cut-------------

    # ==============================================================================================
    # 
    # Microsoft PowerShell Source File -- Created with SAPIEN Technologies PrimalScript 4.1
    # 
    # NAME: Template-Expand.ps1
    # 
    # AUTHOR : Serge van den Oever, Macaw
    # DATE   : December 30, 2006
    # VERSION: 1.0
    #
    # I needed a MatchEvaluator delegate, and found an example at http://blogs.msdn.com/powershell/archive/2006/07/25/678259.aspx
    # ==============================================================================================
    
    # Template-Expand
    # Simple templating engine to expand a given template text containing PowerShell expressions.
    #
    # Arguments:
    # $text (optional): The text of the template to do the expansion on (use either $text or $path)
    # $path (optional): Path to template to do the expansion on (use either $text or $path)
    # $destination (optional): Destination path to write expansion result to. If not specified, the
    #                           expansion result is result as text
    # $psConfigurationPath (optional) : Path to file containing PowerShell code. File will be 
    #                                   sources using ". file", so variables can be declared 
    #                                   without global scope
    # $leftMarker (optional): Left marker for detecting expand expression in template
    # $rightMarker (optional): Right marker for detecting expand expression in template
    # $encoding (optional): Encoding to use when reading the template file
    #
    # Simple usage usage: 
    # $message="hello"; ./Template-Delegate -text 'I would like to say [[$message]] to the world'
    
    param
    (
        $text = $null,
        $path = $null,
        $destination = $null,
        $psConfigurationPath = $null,
        $leftMarker = "\[\[",
        $rightMarker = "]]",
        $Encoding = "UTF8"
    )
    
    # ==============================================================================================
    # Code below from http://blogs.msdn.com/powershell/archive/2006/07/25/678259.aspx
    # Creates a delegate scriptblock
    # ==============================================================================================
    
    # Helper function to emit an IL opcode
    function emit
    {
        param
        (
            $opcode = $(throw "Missing: opcode")
        )
        
        if ( ! ($op = [System.Reflection.Emit.OpCodes]::($opcode)))
        {
            throw "emit: opcode '$opcode' is undefined"
        }
    
        if ($args.Length -gt 0)
        {
            $ilg.Emit($op, $args[0])
        }
        else
        {
            $ilg.Emit($op)
        }
    }
    
    function GetDelegate
    {
        param
        (
            [type]$type, 
            [ScriptBlock]$scriptBlock
        )
    
        # Get the method info for this delegate invoke...
        $delegateInvoke = $type.GetMethod("Invoke")
        
        # Get the argument type signature for the delegate invoke
        $parameters = @($delegateInvoke.GetParameters())
        $returnType = $delegateInvoke.ReturnParameter.ParameterType
        
        $argList = new-object Collections.ArrayList
        [void] $argList.Add([ScriptBlock])
        foreach ($p in $parameters)
        {
            [void] $argList.Add($p.ParameterType);
        }
        
        $dynMethod = new-object reflection.emit.dynamicmethod ("",
            $returnType, $argList.ToArray(), [object], $false)
        $ilg = $dynMethod.GetILGenerator()
        
        # Place the scriptblock on the stack for the method call
        emit Ldarg_0
        
        emit Ldc_I4 ($argList.Count - 1)  # Create the parameter array
        emit Newarr ([object])
        
        for ($opCount = 1; $opCount -lt $argList.Count; $opCount++)
        {
            emit Dup                    # Dup the array reference
            emit Ldc_I4 ($opCount - 1); # Load the index
            emit Ldarg $opCount         # Load the argument
            if ($argList[$opCount].IsValueType) # Box if necessary
         {
                emit Box $argList[$opCount]
         }
            emit Stelem ([object])  # Store it in the array
        }
        
        # Now emit the call to the ScriptBlock invoke method
        emit Call ([ScriptBlock].GetMethod("InvokeReturnAsIs"))
        
        if ($returnType -eq [void])
        {
            # If the return type is void, pop the returned object
            emit Pop
        }
        else
        {
            # Otherwise emit code to convert the result type which looks
            # like LanguagePrimitives.ConvertTo(value, type)
        
            $signature = [object], [type]
            $convertMethod =
                [Management.Automation.LanguagePrimitives].GetMethod(
                    "ConvertTo", $signature);
            $GetTypeFromHandle = [Type].GetMethod("GetTypeFromHandle");
            emit Ldtoken $returnType  # And the return type token...
            emit Call $GetTypeFromHandle
            emit Call $convertMethod
        }
        emit Ret
        
        #
        # Now return a delegate from this dynamic method...
        #
        
        $dynMethod.CreateDelegate($type, $scriptBlock)
    }
    
    # ==============================================================================================
    
    Write-Verbose "Template-Expand:"
    if ($path -ne $null)
    {
        if (!(Test-Path -Path $path))
        {
            throw "Template-Expand: path `'$path`' can't be found"
        }
    
        # Read text and join the returned Object[] with newlines
        $text = [string]::join([environment]::newline, (Get-Content -Path $path -Encoding $Encoding))
    }
    
    if ($text -eq $null)
    {
        throw 'Template-Expand: template to expand should be specified through -text or -path option'
    }
    
    if ($psConfigurationPath -ne $null)
    {
        # Source the powershell configuration, so we don't have to declare variables in the 
        # configuration globally
        if (!(Test-Path -Path $psConfigurationPath))
        {
            throw "Template-Expand: psConfigurationPath `'$psConfigurationPath`' can't be found"
        }
        . $psConfigurationPath
    }
    
    $pattern = New-Object -Type System.Text.RegularExpressions.Regex `
                          -ArgumentList "$leftMarker(.*?)$rightMarker",([System.Text.RegularExpressions.RegexOptions]::Singleline)
    $matchEvaluatorDelegate = GetDelegate `
        System.Text.RegularExpressions.MatchEvaluator {
               $match = $args[0]
               $expression = $match.get_Groups()[1].Value # content between markers
               Write-Verbose "  -- expanding expression: $expression"
               trap { Write-Error "Expansion on template `'$name`' failed. Can't evaluate expression `'$expression`'. The following error occured: $_"; break }
               Invoke-Expression -command $expression
        }
    # Execute the pattern replacements and return the result
    $expandedText = $pattern.Replace($text, $matchEvaluatorDelegate)
    
    if ($destination -eq $null)
    {
        # Return as string
        $expandedText 
    }
    else
    {
        Set-Content -Path $destination -value $expandedText -encoding $Encoding
    }
  • PowerShell pitfalls: reading text from file using get-content

    I had a really strange effect in PowerShell that puzzled me for hours!

    I have the following script:

    $a = @'
    One
    Two
    '@
    $a
    $p = [regex]"One"
    $p.Replace($a, "OneReplaced")
    
    $b = get-content -path templ.txt
    $b
    $q = [regex]"One"
    $q.Replace($b, "OneReplaced")

    And a file templ.txt containing the following text:

    One
    Two

    When I execute the script I get the following output:

    One
    Two
    OneReplaced
    Two
    One
    Two
    OneReplaced Two

    So what happens:

    I initialize a variable $a with two lines of text: line 1: One, line 2: Two. When I display variable $a it shows One and Two on two seperate lines. I now replace One with OneReplaced. Output of the replacement is two lines of text. Line 1: OneReplaced, line 2: Two.

    Everything ok so far.

    I now read the contents of variable $b from the file templ.txt. This file contains two lines of text: line 1: One, line 2: Two. When I display variable $b it shows One and Two on two seperate lines. I now replace One with OneReplaced. Output of the replacement is ONE LINE of text: OneReplaced Two.

    This is not what I expected.

    After a lot of debugging I found out why this happened. When you do $b = get-content -path templ.txt you don't get a string back, but an object array. You can see that when you do: (get-content -path templ.txt).GetType(), this displays:

    IsPublic IsSerial Name                                     BaseType
    -------- -------- ----                                     --------
    True     True     Object[]                                 System.Array

    If you inspect the Object[] variable $b, you see that $b[0] = "One" and $b[1] = "Two".

    When the command $q.Replace($b, "OneReplaced") is executed, the variable $b of type Object[] is cast to a string. This cast combines the string objects in the Object[] by appending them with a space in the middle.

    So what is the simple solution to all this: when reading the content, join all lines with a newline, as in the following code line:

    $b = [string]::join([environment]::newline, (get-content -path templ.txt))

    Such a pity that this costed me 4 hours, I thought it was in the regular expression replacement:-(

    But the good thing is that I can now solve that nasty bug in my Template Engine using PowerShell expressions.

  • PowerShell: calculating a relative path

    Sometimes you need a simple thing like calculating the relative path of a file given its full path and a base path. For example you have a file c:\a\b\c\d\e.doc and a base path c:\a\b\c, the relative path is now d\e.doc. I use the following PowerShell function to do this, actually using only .Net framework commands;-)

    function global:RelativePath
    {
        param
        (
            [string]$path = $(throw "Missing: path"),
            [string]$basepath = $(throw "Missing: base path")
        )
        
        return [system.io.path]::GetFullPath($path).SubString([system.io.path]::GetFullPath($basepath).Length + 1)
    }    
    

    Note that I use GetFullPath to get rid of things like .. in a path, like in c:\a\b..\c\d\e.

  • PowerShell and debugging

    I still did not found a good debugging environment for my PowerShell development. The thing I use most is a command I found in this blog post, part of a great blog post series on PowerShell debugging on the Windows PowerShell blog by the PowerShell team. The command allows you to set a breakpoint at any location in your script, and enter an interactive shell where you can do whatever you need to do. I made a minimal modification to the command so I can see at which breakpoint I am:

    # Start-Debug (alias: bp)
    # Stop running current script and go into interactive mode so values of variables can be inspected
    function global:Start-Debug
    {
        param
        (
            $name = ""
        )
           $scriptName = $MyInvocation.ScriptName
           function prompt
           {
              "Debugging [{0}]>" -f $(if ([String]::IsNullOrEmpty($scriptName)) { "globalscope:$name" } else { "$scriptName:$name" } )
           }
           $host.EnterNestedPrompt()
    }
    Set-Alias bp Start-Debug -Scope "global"

    You can now set a breakpoint in your code by adding just the command bp, or by adding a parameter like in bp "new piece of code" so you get a prompt indicating at which breakpoint you are.

    If you enter the nested prompt you can do things like listing all variables with the command ls variable:*, or show the values of all ther currently defined environment variables with ls env:*.

  • PowerShell: "Cleaning" a path name, searching for smarter solution...

    When you construct a path in powershell with for example Join-Path, you can get things like ".."in your path.

    For example:

    Join-Path -Path "c:\program files" -ChildPath "..\Temp"

    results in:

    "c:\program files\..\Temp"

    Instead of:

    "c:\Temp"

     I had to solve this problem, and now came up with the below dirty solution. Any cleaner solutions are appreciated;-)

     

    # CleanPathName
    # Clean a given path from elements like .. in the path and trailing '\',
    # so c:\program files\..\temp\ becomes c:\temp.
    # The given Path must exist.
    # Should be rewritten if a cleaner approach is found
    function global:CleanPathName
    {
      param
      (
        [string]$path = $(throw "Missing: path")
      )
      $orgLocation = Get-Location
      Set-Location -Path $path
      $cleanPath = Get-Location
     

    # restore original location
      Set-Location -Path $orgLocation

      return $cleanPath.Path
    }

  • PowerShell and using .Net enum types

    [NOTE: Because this page is the first hit in Google when you search on Powershell + enum, and I landed on this page too often myself, I decided to expand the page with some additional information] 

    Scripting is heaven when you can utilize the complete .Net framework. One thing that was not directly clear for me was how to use enum values when calling .Net functions. It happened to be really easy, just cast the string representative of the enum value.

    $myString = "/A/B/C//D/E//F/G"

    $myParts = $myString.Split("/", [System.StringSplitOptions]"RemoveEmptyEntries")

    results in an array of A,B,C,D,E,F,G

    UPDATE: it happens to be even easier, you can say:

    [System.Text.RegularExpressions.RegexOptions]::Singleline

    And you can even binary-or them together:

    [System.Text.RegularExpressions.RegexOptions]::Singleline -bor [System.Text.RegularExpressions.RegexOptions]::ExplicitCapture

    It is also possible that an enum is defined within an enclosed type, in this case use [<namespace>.<enclosing type>+<nested type>]::EnumValue (thanks Alex)

    For example:

    [Microsoft.SharePoint.SPViewCollection+SPViewType]::Gantt

    It is also possible to create a new real .net enum from PowerShell script. See http://blogs.msdn.com/powershell/archive/2007/01/23/how-to-create-enum-in-powershell.aspx

    And in PowerShell 2.0 you can do it even cleaner: http://thepowershellguy.com/blogs/posh/archive/2008/06/02/powershell-v2-ctp2-making-custom-enums-using-add-type.aspx

     

  • PowerShell Community Extensions 1.0 Released @ CodePlex

    Keith Hill released the PowerShell Community Extensions version 1.0 at http://www.codeplex.com/PowerShellCX. This well documented set of extra CmdLets, aliases and scripts provides you with a lot of goodies that make life in both PowerShell as a Shell and PowerShell as a scripting language way easier.

    One of the nice goodies is the Get-CmdletMaml cmdlet which reflects over a snapin assembly and produces PowerShell MAML help.  It uses a number of attributes defined by PowerShell and .NET as well as some defined by PSCX. Maml is an XML format to describe help on PowerShell cmdlets and their arguments.

    It also contains the latest version of TabExpansion functionality I wrote about in this blog post.

    See the Documentation for details on all the new commands. Check this out if you live your life in PowerShell.

    And by the way: CodePlex provides source code to the goodies, so there is a lot of code here to learn from how to do PowerShell development!

  • PowerShell: $null and parameter type

    One of those things that can take you ages to find out: I create a function where a parameters that can either have a string value or can be $null:

    function doit
    {
      param
      (
        [string]$a = $( throw "Missing: parameter a"),
        $b = $( throw "Missing: parameter b")
      )

      if ($a -eq $null) { Write-Host "a is null" } else { write-Host "a is not null" }
      if ($b -eq $null) { Write-Host "b is null" } else { Write-Host "b is not null" }
    }

    If I call this function with: doit $null $null

    I get the following result:

    a is not null
    b is null

    What happened: because I specified a type [string] for parameter a, the $null value gets casted to a string with value "". 

  • PowerShell: strict mode and the variable provider

    When working in script languages where declaration of variables is not used there is always the problem of typos in names. PowerShell has a possibility to use 'strict" mode: when a variable is used without an initial assignment you get an error:

    set-psdebug -strict -trace 0

    But now I have problems with third-party scripts that check for the existance of variables by comparing them to $null like this:

    if ($var -eq $null) { ... }

    This throws an error in strict mode.

    To solve this problem use the variable provider. The variable provider gives you access to all variables. There is also a provider for functions, the environment variables, etc. You can even write your own providers.

    Check this out:

    ls variable:*

    ls function:*

    ls env:*

    To prevent the error in the variable existance check do the following:

    if (!(Test-Path variable:var)) { ... }

  • PowerShell: Tab Expansion wonders

    I assume that by now everyone is using PowerShell as their default shell, if not, time to get rid of that ancient cmd.exe thingy;-) If you are working within PowerShell, one of the powerful features is tab expansion. When you start a command you can type the first letters of the command, press tab and voila. But it does not have to stop there! Tab expansion can be extended, and that is whart a lot of people are doing!

    For some background on tab expansion see the blog entry by the powershell team on this topic: http://blogs.msdn.com/powershell/archive/2006/04/26/584551.aspx

    You can find a great series on developing tab expansion at monadblog.blogspot.com.

    And /\/\o\/\/ has another great series at www.thepowershellguy.com

    I'm currently using a tab expansion script from http://powershell.wiki.com/TabExpansion, it's wonderful!

  • WPF/E is there, but one thing puzzles me...

    The samples of WPF/E look stunning, the kind of effects we are used to see in Flash applications. I had a quick look at the WPF/E SDK, and one thing puzzled me: it looks like WPF/E currently only provides a DOM, and that the Javascript runtime as available in your browser is used to access the WPF/E DOM. This means that you still have to solve all Javascript language differences between different browser platforms. It also means that things like interaction with the server (for example AJAX calls) must be handled through the browser Javascript. If my quick observations are correct it means that for WPF/E we are still dependend on the same cross-browser AJAX solution libraries as we are using for our current AJAX sites; libraries like prototype, , scriptaculous, AJAX .NET Professional, and of course ASP.NET Ajax.

    At one side this is great, all knowledge on this technology can be reused and the possibilities are infinite. On the other side: there is not a well defined boundary to do your programming in, where you are sure it works on all platforms. I think this is one of the advantages of a platform like Flash.

    On the other hand: in a future release a micro version of the .Net framework will be embedded, maybe this will provide this boundary. In the mean time I forsee all the cross browser problems we all love in our current "old technology" web solutions.

  • ohloh.net: actual facts on 3000 open-source projects with 220 million lines of source code - impressive!!

    Thanks to born2code (Dutch blogger) I was introduced to the impressive site http://ohloh.net, a directory of open-source projects where the code repositories of the projects are crawled to gather all kinds of statistic and historical facts.

    http://ohloh.net is launched by ex Microsoft employees to evaluate open-source projects.

    Why is this site interesting? It can provide you with information on why you could put trust in the project to include in your own solution because it has an active community of developers, or that you should be careful because it is a one man show, or because ti is developed in the languages that your development team can support.

    For example the Mono project has the following statistics:

    And if you need some "facts" on what it would cost you if you have to do the same development yourself: 

    For more background information on the site see the followoing articles:

    Check it out!

  • PowerShell and some adventures in environment variables, quotes and output wrapping

    Summary: Solving issues with implementing a PowerShell script that generates PowerShell code with correct quoting and no output wrapping, and calling this PowerShell generator script from a cmd.exe batch script.

    The story:

    In my adventures with PowerShell I have such a simple problem that is giving me a headache for hours now.

    I want to write out a set environment variables to a PowerShell file as PowerShell variables with the same name as the environment variable that are assigned the value of the environment variable.

    So there is an environment variable set as follows:

    set MyFirstLittleEnvironmentVariable=Hello Amsterdam!

    And I want to write this out to:

    $MyFirstLittleEnvironmentVariable = 'Hello Amsterdam!'

    Powerful as PowerShell is, this is simple. For example I want to write out all variables starting with "My":

    Get-Item -path env:My* | foreach { [String]::Format("{0}{1} = {2}{3}{4}", "`$", $_.Name, "`'", $_.Value, "`'")

    Note all the trickery to get the quotes around the value, if you know a smarter way, please let me know. This costed me another hour:-(

    This all works nice and sweet, if I execute this command from a PowerShell prompt I get exactly what I want.

    Now I want to redirect this output into a file. I save the above command to file SaveMyEnvironmentVariables.ps1, and then I execute the following command:

    SaveMyEnvironmentVariables.ps1 > "c:\My Script Files\MyEnvironmentVariables.ps1"

    And what happens: the outputted lines are wrapped at 80 characters, not something you want when generating code!

    After some digging I found some some links that helped me out a little bit, but still not solved the problem:

    In my situation my output goes through Host-Out, and Host-Out has by default a formatting specified of 80 characters. See also help about_diplay.xml in your PowerShell command prompt.

    I want to save my output by redirecting the output of my PowerShell script to another file. I could not get this working.

    My current solution is:

    Get-Item -path env:My* | foreach { [String]::Format("{0}{1} = {2}{3}{4}", "`$", $_.Name, "`'", $_.Value, "`'") } | Out-File -FilePath $Args[0] -width 2147483647

    UPDATE: Thanks to The PowerShell Guy I could bring my solution back to the way more readable version below:

    Get-Item -path env:My* | foreach { "`$$($_.Name) = `'$($_.Value)`'" } | Out-File -FilePath $Args[0] -width 2147483647

    And for real good examples of the usage of PowerShell, have a look at http://www.thepowershellguy.com.

    Where Args[0] is the first parameter specified to the script and 2147483647 is the max width (it's a signed 32 bit parameter).

    I now have to call my script as follows:

    SaveMyEnvironmentVariables.ps1 "c:\My Script Files\MyEnvironmentVariables.ps1" from the PowerShell prompt.

    But actually I need to call it from a good old cmd.exe batch script. And there is got complex, so that is why I initially decided to solve my problem by redirecting my output. Examine the following statement carefully and especially look at the quotes;-), it took me another half an hour to solve all the problems you get with spaces in paths:

    PowerShell -Command "& 'c:\My Script Files\SaveMyEnvironmentVariables.ps1' 'c:\My Script Files\MyEnvironmentVariables.ps1'"

  • NAnt XmlList command updated

    A while a go I wrote a handly NAnt task to select data from XML files using XPath expressions. A few days ago I got a reaction on the blog by Matt who wants to try to get it in NAntContrib, and suddenly I got a new and improved version by Jonni Faiga through e-mail!!!

    I also included a small zip file with the source code, a small build script, and a dll (probably for .Net 2.0).

    Extract the zip file somewhere, go to the directory in a command shell, execute NAnt and you have a tested dll for your .Net platform. Copy the resulting dll Macaw.XmlList.dll next to your NAnt executable and for the rest of your programming live you have the power of the xmllist command at your fingertips!

    Matt, I hope you can get it included in NantContrib!

    The new and improved version:

    // Serge van den Oever (serge@macaw.nl)
    // Based on idea from weblog entry: http://blogs.geekdojo.net/rcase/archive/2005/01/06/5971.aspx combined with the code of xmlpeek.
    // Feedback by Matt (http://weblogs.asp.net/soever/archive/2005/05/08/406101.aspx)
    // Extended by Jonni Faiga [december 1, 2006]
    // Publication of this source in weblog entry: http://weblogs.asp.net/soever/archive/2006/12/01/nant-xmllist-command-updated.aspx
    
    using System;
    using System.Globalization;
    using System.IO;
    using System.Text;
    using System.Xml;
    using System.Collections.Specialized;
    
    using NAnt.Core;
    using NAnt.Core.Attributes;
    using NAnt.Core.Types;
    
    namespace Macaw
    {
        /// <summary>
        /// Extracts text from an XML file at the locations specified by an XPath 
        /// expression, and return those texts separated by a delimiter string.
        /// </summary>
        /// <remarks>
        /// <para>
        /// If the XPath expression specifies multiple nodes the node are seperated
        /// by the delimiter string, if no nodes are matched, an empty string is returned.
        /// </para>
        /// </remarks>
        /// <example>
        ///   <para>
        ///   The example provided assumes that the following XML file (xmllisttest.xml)
        ///   exists in the current build directory.
        ///   </para>
        ///   <code>
        ///     <![CDATA[
        ///    <?xml version="1.0" encoding="utf-8" ?> 
        /// <xmllisttest>
        /// <firstnode attrib="attrib1">node1</firstnode>
        /// <secondnode attrib="attrib2">
        /// <subnode attrib="attribone">one</subnode>
        /// <subnode attrib="attribtwo">two</subnode>
        /// <subnode attrib="attribthree">three</subnode>
        /// <subnode attrib="attribtwo">two</subnode>
        /// </secondnode>
        /// <thirdnode xmlns="http://thirdnodenamespace">namespacednode</thirdnode>
        /// <fourthnode>${myproperty}</fourthnode>
        /// <fifthnode>${myproperty=='Hi'}</fifthnode>    
        /// </xmllisttest>    
        ///        ]]>
        ///   </code>
        /// </example>
        /// <example>
        ///   <para>
        ///   The example reads numerous values from this file:
        ///   </para>
        ///   <code>
        ///     <![CDATA[
        /// <?xml version="1.0" encoding="utf-8" ?> 
        /// <project name="tests.build" default="test" basedir=".">
        ///     <target name="test">
        ///         <!-- TEST1: node exists, is single node, get value -->
        ///         <xmllist file="xmllisttest.xml" property="prop1" delim="," xpath="/xmllisttest/firstnode"/>    
        ///         <echo message="prop1=${prop1}"/>
        ///         <fail message="TEST1: Expected: prop1=node1" unless="${prop1 == 'node1'}"/>
        ///         
        ///         <!-- TEST2: node does not exist -->
        ///         <xmllist file="xmllisttest.xml" property="prop2" delim="," xpath="/xmllisttest/nonexistantnode" />    
        ///         <echo message="prop2='${prop2}'"/>
        ///         <fail message="TEST2: Expected: prop2='empty'" unless="${prop2 == ''}"/>
        ///     
        ///         <!-- TEST3: node exists, get attribute value -->
        ///         <xmllist file="xmllisttest.xml" property="prop3" delim="," xpath="/xmllisttest/firstnode/@attrib" />    
        ///         <echo message="prop3=${prop3}"/>
        ///         <fail message="TEST3: Expected: prop3=attrib1" unless="${prop3 == 'attrib1'}"/>
        ///     
        ///         <!-- TEST4: nodes exists, get multiple values -->
        ///         <xmllist file="xmllisttest.xml" property="prop5" delim="," xpath="/xmllisttest/secondnode/subnode" />    
        ///         <echo message="prop5=${prop5}"/>
        ///         <fail message="TEST4: Expected: prop5=one,two,three,two" unless="${prop5 == 'one,two,three,two'}"/>
        ///     
        ///         <!-- TEST5: nodes exists, get multiple attribute values -->
        ///         <xmllist file="xmllisttest.xml" property="prop5" delim="," xpath="/xmllisttest/secondnode/subnode/@attrib" />    
        ///         <echo message="prop5=${prop5}"/>
        ///         <fail message="TEST5: Expected: prop5=attribone,attribtwo,attribthree,attribtwo" unless="${prop5 == 'attribone,attribtwo,attribthree,attribtwo'}"/>
        ///     
        ///         <!-- TEST6: nodes exists, get multiple values, but only unique values -->
        ///         <xmllist file="xmllisttest.xml" property="prop6" delim="," xpath="/xmllisttest/secondnode/subnode" unique="true"/>    
        ///         <echo message="prop6=${prop6}"/>
        ///         <fail message="TEST6: Expected: prop6=one,two,three" unless="${prop6 == 'one,two,three'}"/>
        ///     
        ///         <!-- TEST7: nodes exists, get multiple attribute values -->
        ///         <xmllist file="xmllisttest.xml" property="prop7" delim="," xpath="/xmllisttest/secondnode/subnode/@attrib" unique="true"/>    
        ///         <echo message="prop7=${prop7}"/>
        ///         <fail message="TEST7: Expected: prop7=attribone,attribtwo,attribthree" unless="${prop7 == 'attribone,attribtwo,attribthree'}"/>
        ///         
        ///         <!-- TEST8: node exists, is single node, has namespace http://thirdnodenamespace, get value -->
        ///         <xmllist file="xmllisttest.xml" property="prop8" delim="," xpath="/xmllisttest/x:thirdnode">    
        ///             <namespaces>
        ///                 <namespace prefix="x" uri="http://thirdnodenamespace" />
        ///             </namespaces>
        ///         </xmllist>
        ///         <echo message="prop8=${prop8}"/>
        ///         <fail message="TEST8: Expected: prop8=namespacednode" unless="${prop8 == 'namespacednode'}"/>
        /// 
        ///         <!-- TEST9: node exists, is single node, get value expanded via current nant properties-->
        ///         <property name="myproperty" value="Hi"/>
        ///         <xmllist file="xmllisttest.xml" property="prop9" delim="," xpath="/xmllisttest/fourthnode"/>
        ///         <echo message="prop9=${prop9}"/>
        ///         <fail message="TEST9: Expected: prop1=${myproperty}" unless="${prop9 == myproperty}"/>
        /// 
        ///         <!-- TEST10: node exists, is single node, get value expanded via current nant function-->
        ///         <xmllist file="xmllisttest.xml" property="prop10" delim="," xpath="/xmllisttest/fifthnode"/>
        ///         <echo message="prop10=${prop10}"/>
        ///         <fail message="TEST10: Expected: prop10=True" unless="${prop10 == 'True'}"/>
        ///     </target>
        /// </project>
        ///        ]]>
        ///   </code>
        ///   Result when you run this code:
        ///   <code>
        ///        <![CDATA[
        ///     test:
        /// 
        ///     [echo] prop1="node1"
        ///     [echo] prop2="''"
        ///     [echo] prop3="attrib1"
        ///     [echo] prop5="one,two,three,two"
        ///     [echo] prop5="attribone,attribtwo,attribthree,attribtwo"
        ///     [echo] prop6="one,two,three"
        ///     [echo] prop7="attribone,attribtwo,attribthree"
        ///     [echo] prop8="namespacednode"
        ///     [echo] prop9="Hi"
        ///     [echo] prop10="True"
        /// 
        ///     BUILD SUCCEEDED
        ///        ]]
        ///   </code>
        /// </example>
        [TaskName ("xmllist")]
        public class XmlListTask : Task
        {
            #region Private Instance Fields
    
            private FileInfo _xmlFile;
            private string _xPath;
            private string _property;
            private string _delimiter = ",";
            private bool _unique = false; // assume we return all values
            private XmlNamespaceCollection _namespaces = new XmlNamespaceCollection();
            private bool _expandProps = true;
    
            #endregion Private Instance Fields
    
            #region Public Instance Properties
            /// <summary>
            /// The name of the file that contains the XML document
            /// that is going to be interrogated.
            /// </summary>
            [TaskAttribute("file", Required=true)]
            public FileInfo XmlFile 
            {
                get
                {
                    return _xmlFile;
                }
                set
                {
                    _xmlFile = value;
                }
            }
    
            /// <summary>
            /// The XPath expression used to select which nodes to read.
            /// </summary>
            [TaskAttribute ("xpath", Required = true)]
            [StringValidator (AllowEmpty = false)]
            public string XPath
            {
                get
                {
                    return _xPath;
                }
                set
                {
                    _xPath = value;
                }
            }
    
            /// <summary>
            /// The property that receives the text representation of the XML inside 
            /// the nodes returned from the XPath expression, seperated by the specified delimiter.
            /// </summary>
            [TaskAttribute ("property", Required = true)]
            [StringValidator (AllowEmpty = false)]
            public string Property
            {
                get
                {
                    return _property;
                }
                set
                {
                    _property = value;
                }
            }
    
            /// <summary>
            /// The delimiter string.
            /// </summary>
              [TaskAttribute ("delim", Required = false)]
            [StringValidator (AllowEmpty = false)]
            public string Delimiter
            {
                get
                {
                    return _delimiter;
                }
                set
                {
                    _delimiter = value;
                }
            }
    
            /// <summary>
            /// If unique, no duplicate vaslues are returned. By default unique is false and all values are returned.
            /// </summary>
            [TaskAttribute ("unique", Required = false)]
            [BooleanValidator()]
            public bool Unique
            {
                get
                {
                    return _unique;
                }
                set
                {
                    _unique = value;
                }
            }
    
            /// <summary>
            /// Namespace definitions to resolve prefixes in the XPath expression.
            /// </summary>
            [BuildElementCollection("namespaces", "namespace")]
            public XmlNamespaceCollection Namespaces 
            {
                get
                {
                    return _namespaces;
                }
                set
                {
                    _namespaces = value;
                }
            }
            /// <summary>
            /// If true, the any nant-style properties on the result will be
            /// expanded before returning. Default is true.
            /// </summary>
            [TaskAttribute("expandprops")]
            [BooleanValidator()]
            public bool ExpandProperties {
                get{ return _expandProps; }
                set { _expandProps = value; }
            }
    
            #endregion Public Instance Properties
    
            #region Override implementation of Task
    
            /// <summary>
            /// Executes the XML reading task.
            /// </summary>
            protected override void ExecuteTask() 
            {
                Log(Level.Verbose, "Looking at '{0}' with XPath expression '{1}'.", 
                    XmlFile.FullName,  XPath);
    
                // ensure the specified xml file exists
                if (!XmlFile.Exists) 
                {
                    throw new BuildException(string.Format(CultureInfo.InvariantCulture, 
                        "The XML file '{0}' does not exist.", XmlFile.FullName), Location);
                }
                try 
                {
                    XmlDocument document = LoadDocument(XmlFile.FullName);
                    Properties[Property] = ExpandProps(GetNodeContents(XPath, document));
                } 
                catch (BuildException ex) 
                {
                    throw ex; // Just re-throw the build exceptions.
                } 
                catch (Exception ex) 
                {
                    throw new BuildException(string.Format(CultureInfo.InvariantCulture,
                        "Retrieving the information from '{0}' failed.", XmlFile.FullName), 
                        Location, ex);
                }
            }
            
            #endregion Override implementation of Task
            
            #region private Instance Methods
    
            /// <summary>
            /// Loads an XML document from a file on disk.
            /// </summary>
            /// <param name="fileName">The file name of the file to load the XML document from.</param>
            /// <returns>
            /// A <see cref="XmlDocument">document</see> containing
            /// the document object representing the file.
            /// </returns>
            private XmlDocument LoadDocument(string fileName)  
            {
                XmlDocument document = null;
    
                try 
                {
                    document = new XmlDocument();
                    document.Load(fileName);
                    return document;
                } 
                catch (Exception ex) 
                {
                    throw new BuildException(string.Format(CultureInfo.InvariantCulture,
                        "Can't load XML file '{0}'.", fileName), Location, 
                        ex);
                }
            }
    
            /// <summary>
            /// Gets the contents of the list of nodes specified by the XPath expression.
            /// </summary>
            /// <param name="xpath">The XPath expression used to determine the nodes.</param>
            /// <param name="document">The XML document to select the nodes from.</param>
            /// <returns>
            /// The contents of the nodes specified by the XPath expression, delimited by 
            /// the delimiter string.
            /// </returns>
            private string GetNodeContents(string xpath, XmlDocument document) 
            {
                XmlNodeList nodes;
    
                try 
                {
                    XmlNamespaceManager nsMgr = new XmlNamespaceManager(document.NameTable);
                    foreach (XmlNamespace xmlNamespace in Namespaces) 
                    {
                        if (xmlNamespace.IfDefined && !xmlNamespace.UnlessDefined) 
                        {
                            nsMgr.AddNamespace(xmlNamespace.Prefix, xmlNamespace.Uri);
                        }
                    }
                    nodes = document.SelectNodes(xpath, nsMgr);
                } 
                catch (Exception ex) 
                {
                    throw new BuildException(string.Format(CultureInfo.InvariantCulture,
                        "Failed to execute the xpath expression {0}.", xpath), 
                        Location, ex);
                }
    
                Log(Level.Verbose, "Found '{0}' nodes with the XPath expression '{1}'.",
                    nodes.Count, xpath);
    
                // collect all strings in a string collection, skip duplications if Unique is true
                StringCollection texts = new StringCollection();
                foreach (XmlNode node in nodes)
                {
                    string text = node.InnerText;
                    if (!Unique || !texts.Contains(text))
                    {
                        texts.Add(text);
                    }
                }
                
                // Concatenate the strings in the string collection to a single string, delimited by Delimiter
                StringBuilder builder = new StringBuilder();
                foreach (string text in texts)
                {
                    if (builder.Length > 0)
                    {
                        builder.Append(Delimiter);
                    }
                    builder.Append(text);
                }
    
                return builder.ToString();
            }
            /// <summary>
            /// Expands project properties in the string
            /// </summary>
            /// <param name="result"></param>
            /// <returns></returns>
            private string ExpandProps(string result) {
                if (Properties == null || !ExpandProperties) {
                    return result;
                }
                return Properties.ExpandProperties(result, null);
            }
            #endregion private Instance Methods
        }
    }