Search code examples
xsltwixgacheat

Merge nodes with XSL v.1.0 based on a substring of an Attribute


I have an XML file generated with heat.exe from wix-toolset. That wraps each <File> object inside a <Component>. I have to modify this with an XSLT v1.0, so that all <File> where @Source contains the same FileName (without extension) should be extracted to one <Component>. Usually, @Source only ends with ".dll" or ".config".

Additional the <File>s ending with:

  • ".config" should set the @KeyPath to "no"
  • ".dll" should have an extra attribute @Assembly with value ".net"

Here is a sample XML I which has to be transformed:

<?xml version="1.0" encoding="utf-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Fragment>
    <DirectoryRef Id="GAC35">
        <Component Id="cmp5BC59A7DCA65D1B974894AAA758DB693" Guid="{1A2AC82E-7AD9-4CB6-BF42-4D31FAD7786E}">
            <File Id="filE7FFE881A5ECF045432F46FBA78AEDD4" KeyPath="yes" Source="$(var.HarvestLoggingGac35Policy)\Policy.1.0.Logging.config" />
        </Component>
        <Component Id="cmp7F80B805A2BDCF92241FB8019B91FF1C" Guid="{0F88D7E9-355A-40C1-AC8C-29BBB27690FB}">
            <File Id="filB35F1D68CCC038864F21E76D8A9F5977" KeyPath="yes" Source="$(var.HarvestLoggingGac35Policy)\Policy.1.0.Logging.dll" />
        </Component>
        <Component Id="test1" Guid="{1A2AC82E-7AD9-4CB6-BF42-4D31FAD7786E}">
            <File Id="filE7FFE881A5ECF045432F46FBA78AEDD4" KeyPath="yes" Source="$(var.HarvestLoggingGac35Policy)\Policy.2.0.Logging.config" />
        </Component>
        <Component Id="test12" Guid="{0F88D7E9-355A-40C1-AC8C-29BBB27690FB}">
            <File Id="filB35F1D68CCC038864F21E76D8A9F5977" KeyPath="yes" Source="$(var.HarvestLoggingGac35Policy)\Policy.2.0.Logging.dll" />
        </Component>
    </DirectoryRef>
</Fragment>
<Fragment>
    <ComponentGroup Id="HeatGenerated_Gac35Policies">
        <ComponentRef Id="cmp5BC59A7DCA65D1B974894AAA758DB693" />
        <ComponentRef Id="cmp7F80B805A2BDCF92241FB8019B91FF1C" />
        <ComponentRef Id="test1" />
        <ComponentRef Id="test2" />
    </ComponentGroup>
</Fragment>
</Wix>

Then this should be the expected ouput with XSL v1:

<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Fragment>
  <DirectoryRef Id="GAC35">
     <Component Id="cmp5BC59A7DCA65D1B974894AAA758DB693"
                Guid="{1A2AC82E-7AD9-4CB6-BF42-4D31FAD7786E}">
        <File Id="filE7FFE881A5ECF045432F46FBA78AEDD4"
              KeyPath="no"
              Source="$(var.HarvestLoggingGac35Policy)\Policy.1.0.Logging.config"/>
        <File Id="filB35F1D68CCC038864F21E76D8A9F5977"
              KeyPath="yes"
              Source="$(var.HarvestLoggingGac35Policy)\Policy.1.0.Logging.dll"
              Assembly=".net"/>
     </Component>
     <Component Id="test1"
                Guid="guid1">
        <File Id="filE7FFE881A5ECF045432F46FBA78AEDD4"
              KeyPath="no"
              Source="$(var.HarvestLoggingGac35Policy)\Policy.2.0.Logging.config"/>
        <File Id="filB35F1D68CCC038864F21E76D8A9F5977"
              KeyPath="yes"
              Source="$(var.HarvestLoggingGac35Policy)\Policy.2.0.Logging.dll"
              Assembly=".net"/>
     </Component>
  </DirectoryRef>
</Fragment>
<Fragment>
  <ComponentGroup Id="HeatGenerated_Gac35Policies">
     <ComponentRef Id="cmp5BC59A7DCA65D1B974894AAA758DB693"/>
     <ComponentRef Id="test1"/>
  </ComponentGroup>
</Fragment>
</Wix>

EDIT: This was my own last "solution" (not complete) until I have used the answer from @michael.hor257k

<?xml version="1.0" ?>
 <xsl:stylesheet version="1.0"
            xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
            xmlns:wix="http://schemas.microsoft.com/wix/2006/wi"
            xmlns="http://schemas.microsoft.com/wix/2006/wi">

<!-- Copy all attributes and elements to the output. -->
<xsl:output method="xml"
          indent="yes"
          omit-xml-declaration="yes"/>
<xsl:strip-space elements="*"/>

<xsl:template match="@* | node()">
<xsl:copy>
  <xsl:apply-templates select="@* | node()"/>
</xsl:copy>
</xsl:template>


<xsl:template match="wix:Component">

<xsl:variable name="fileExtension">
  <xsl:choose>
    <xsl:when test="contains(wix:File/@Source, '.config')">
      <xsl:value-of select="'.config'"/>
    </xsl:when>
    <xsl:when test="contains(wix:File/@Source, '.dll')">
      <xsl:value-of select="'.dll'"/>
    </xsl:when>
  </xsl:choose>
</xsl:variable>

<xsl:variable name="precedingFileExtension">
  <xsl:choose>
    <xsl:when test="contains(preceding-sibling::wix:Component/wix:File/@Source, '.config')">
      <xsl:value-of select="'.config'"/>
    </xsl:when>
    <xsl:when test="contains(preceding-sibling::wix:Component/wix:File/@Source, '.dll')">
      <xsl:value-of select="'.dll'"/>
    </xsl:when>
  </xsl:choose>
</xsl:variable>

<xsl:apply-templates select="wix:File[(substring-before(substring-after(@Source,'\'), $fileExtension) = substring-before(substring-after(preceding::wix:Component/wix:File/@Source,'\'), $precedingFileExtension))]" mode="files"/>
</xsl:template>


<xsl:template match="wix:File" mode="files">

<xsl:variable name="fileExtension">
  <xsl:choose>
    <xsl:when test="contains(@Source, '.config')">
      <xsl:value-of select="'.config'"/>
    </xsl:when>
    <xsl:when test="contains(@Source, '.dll')">
      <xsl:value-of select="'.dll'"/>
    </xsl:when>
  </xsl:choose>
</xsl:variable>

<xsl:variable name="otherFileExtension">
  <xsl:choose>
    <xsl:when test="contains(@Source, '.config')">
      <xsl:value-of select="'.dll'"/>
    </xsl:when>
    <xsl:when test="contains(@Source, '.dll')">
      <xsl:value-of select="'.config'"/>
    </xsl:when>
  </xsl:choose>
</xsl:variable>

<Component>
  <xsl:copy-of select="parent::wix:Component/@*" />
  <xsl:copy>
    <xsl:copy-of select="@*" />
  </xsl:copy>
  <xsl:variable name="source" select="substring-before(substring-after(@Source,'\'), $fileExtension)"/>

  <xsl:apply-templates select="//wix:File[substring-before(substring-after(@Source,'\'), $otherFileExtension)=$source]" />
</Component>
</xsl:template>



<xsl:key name="policy-config-file"
         match="wix:File[contains(@Source, '.config')]"
         use="@Id" />

<xsl:template match="wix:File[key('policy-config-file', @Id)]" >
  <xsl:element name="File">
    <xsl:apply-templates select="@*" />
    <xsl:attribute name="KeyPath">
      <xsl:value-of select="'no'"/>
    </xsl:attribute>
  </xsl:element>
</xsl:template>

</xsl:stylesheet>

Solution

  • You cannot group nodes by something they do not have. If you want to group the components by their filename without the extension, you must start by adding such value to each component - either as an element or as an attribute. This is because removing the extension is not trivial in XSLT 1.0 and cannot be accomplished by a single XPath expression.

    Once you have done that, you can proceed and apply Muenchian grouping to the interim result:

    XSLT 1.0

    <xsl:stylesheet version="1.0" 
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    xmlns:wix="http://schemas.microsoft.com/wix/2006/wi"
    xmlns:exsl="http://exslt.org/common"
    extension-element-prefixes="exsl">
    <xsl:output method="xml" version="1.0" encoding="UTF-8" indent="yes"/>
    <xsl:strip-space elements="*"/>
    
    <xsl:key name="k" match="wix:Component" use="key"/>
    
    <!-- identity transform -->
    <xsl:template match="@*|node()">
        <xsl:copy>
            <xsl:apply-templates select="@*|node()"/>
        </xsl:copy>
    </xsl:template>
    
    <xsl:template match="wix:DirectoryRef">
        <!-- first pass -->
        <xsl:variable name="components">
            <xsl:for-each select="wix:Component">
                <xsl:copy>
                    <xsl:apply-templates select="@* | *"/>
                    <key>
                        <xsl:call-template name="remove-last-token">
                            <xsl:with-param name="text" select="wix:File/@Source"/>
                            <xsl:with-param name="delimiter" select="'.'"/>
                        </xsl:call-template>
                    </key>
                </xsl:copy>
            </xsl:for-each>
        </xsl:variable>
        <!-- output -->
        <xsl:copy>
            <xsl:copy-of select="@*"/>
            <xsl:for-each select="exsl:node-set($components)/wix:Component[count(. | key('k', key)[1]) = 1]">
                <xsl:copy>
                    <xsl:copy-of select="@*"/>
                    <xsl:copy-of select="key('k', key)/wix:File"/>
                </xsl:copy>
            </xsl:for-each>
        </xsl:copy>
    </xsl:template>
    
    <xsl:template name="remove-last-token">
        <xsl:param name="text"/>
        <xsl:param name="delimiter"/>
        <xsl:value-of select="substring-before($text, $delimiter)"/>
        <xsl:if test="contains(substring-after($text, $delimiter), $delimiter)">
            <xsl:value-of select="$delimiter"/>
            <xsl:call-template name="remove-last-token">
                <xsl:with-param name="text" select="substring-after($text, $delimiter)"/>
                <xsl:with-param name="delimiter" select="$delimiter"/>
            </xsl:call-template>
        </xsl:if>
    </xsl:template>
    
    </xsl:stylesheet>
    

    Demo: http://xsltransform.net/93dEHG9


    Addendum:

    If you can safely assume that the extension will always be either .dll or .config, then you could simplify this to:

    XSLT 1.0

    <xsl:stylesheet version="1.0" 
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    xmlns:wix="http://schemas.microsoft.com/wix/2006/wi">
    <xsl:output method="xml" version="1.0" encoding="UTF-8" indent="yes"/>
    <xsl:strip-space elements="*"/>
    
    <xsl:key name="k" match="wix:Component" use="substring-before(concat(substring-before(concat(wix:File/@Source, '.dll'), '.dll'), '.config'), '.config')"/>
    
    <!-- identity transform -->
    <xsl:template match="@*|node()">
        <xsl:copy>
            <xsl:apply-templates select="@*|node()"/>
        </xsl:copy>
    </xsl:template>
    
    <xsl:template match="wix:DirectoryRef">
        <xsl:copy>
            <xsl:copy-of select="@*"/>
            <xsl:for-each select="wix:Component[count(. | key('k', substring-before(concat(substring-before(concat(wix:File/@Source, '.dll'), '.dll'), '.config'), '.config'))[1]) = 1]">
                <xsl:copy>
                    <xsl:copy-of select="@*"/>
                    <xsl:copy-of select="key('k', substring-before(concat(substring-before(concat(wix:File/@Source, '.dll'), '.dll'), '.config'), '.config'))/wix:File"/>
                </xsl:copy>
            </xsl:for-each>
        </xsl:copy>
    </xsl:template>
    
    </xsl:stylesheet>