MSBuild Variable Expansion
The official documentation is a bit sparse in terms the syntax and semantics of variable expansion within MSBuild files, so I want to try and build out some more complete technical documentation for my own reference.
MSBuild has 3 types of variable expansion expressions
- Metadata
%( ... ) - Property
$( ... ) - Item
@( ... )
They each have slightly different syntax so we will go through them individually and present a rough ABNF grammar for them which I have reverse-engineered from the code in Expander.cs (as retrieved on 2025-03-11, at commit af0a20f3).
The grammars presented below all omit whitespace in an effort to make things easier to read. Whitespace is generally allowed between any two tokens.
The shared SCREAMING_SNAKE_CASE rules are included in the Shared Grammar Rules section at the end. Note that ARGUMENTS is included at the end because it is reused everywhere, but it is incredibly complex compared to all of the other rules in that section.
metadata_expansion = "%(" [ IDENTIFIER "." ] IDENTIFIER ")"
A metadata expansion only has two simple forms
%(MetadataName)%(ItemType.MetadataName)
The MetadataName specifies which metadata field to look up. ItemType specifies on what items to look up that field.
The ItemType can only be omitted when the expression appears in a context that is operating on a specific set of items already, so that it can be assumed that set is what you are fetching metadata from.
indexer = "[" [ ARGUMENTS ] "]"
method_call = "." IDENTIFIER [ "(" [ ARGUMENTS ] ")" ]
registry_lookup = "Registry:" REGISTRY_PATH [ "@" REGISTRY_KEY ]
static_method =
"[" TYPE_NAME "]::" IDENTIFIER [ "(" [ ARGUMENTS ] ")" ]
remainder = *( indexer / method_call )
property_expansion_body = IDENTIFIER remainder
property_expansion_body =/ static_method remainder
property_expansion_body =/ registry_lookup
property_expansion = "$(" [ property_expansion_body ] ")"
A property expansion has 3 forms
- Simple properties
$(PropertyName) - Static method invocations e.g.
$([ClassName]::MethodName("argument"))
- Registry properties e.g.
$(Registry:Path@Location)
$(PropertyName)
$(PropertyName[3])
$(PropertyName.Replace('from', 'to'))
$(PropertyName.Replace('from', 'to')[0].RotateLeft(1))
These are the simple case where you provide the name of a property to retrieve its value.
A property name may be followed by any number of indexers (e.g. [index]) or method invocations (e.g. .method(argument)) that operate on the value read from the property.
$([System.Guid]::NewGuid())
$([MSBuild]::Add($(NumberOne), $(NumberTwo)))
$([System.String]::New('string').Replace('s', 'S'))
You are able to invoke a static method on a class available to the MSBuild runtime by specifying a full class name and the method to invoke.
As with simple properties property any number of indexers or method invocations may be chained afterwards.
$(Registry:HKEY_LOCAL_MACHINE\Software\Microsoft\.NETFramework)
$(REGISTRY:HKEY_LOCAL_MACHINE\Software\Vendor\Tools@TaskLocation)
Registry lookups simply retrieve the registry value at the specified path and location.
The location is delimited with an @ symbol. The location can also be omitted in which case the value at the default location for that path will be retrieved.
Registry lookups cannot have methods or indexers chained after them.
Both simple properties and static method invocations can be followed by any number of method calls or indexers.
An indexer is simply a shorthand for calling the appropriate method to index into a value. So depending on the type of the value being indexed it will correspond to something different.
- strings use
get_Chars(meaning they use the indexer of theCharsproperty of a string) - Arrays use
GetValue - everything else uses
get_Item(which will correspond to the indexer of theItemproperty)
Examples:
$(PropertyName[3])
#=> the 4th character of the value of PropertyName
$([System.IO.Directory]::GetFiles(".")[0][1])
#=> the 2nd character of the 1st file in the current directory
A method call is just what it sounds like. You can specify a method and it will simply attempt to call the method by name on the specified object. A method invocation is specified by putting a . followed by the method name.
Parenthesis may be placed after the method name to provide 0 or more comma separated arguments.
Examples:
$(PropertyName.Replace('from', 'to').Replace('to', 'from'))
$(PropertyName.Length)
$([System.DateTime]::Now.Year)
$([System.DateTime]::Now.AddDays(100))
The arguments passed to method invocations and indexers are parsed through the following steps:
- Split the content between the
()or[]at commas unless the commas appear between matched quotes or parentheses. - Trim whitespace from both ends of each argument.
- Remove matching pairs of quotes if they are the first and last characters of an argument.
These rules have some significant quirks compared to how you might be used to arguments or strings being parsed in a programming language, so it is recommended that you be very careful about how you write your arguments.
$([System.String]::New('part1,''part2,'))
#=> part1,''part2,
$([System.String]::New('part1,'"part2,"))
#=> 'part1,'"part2,"
$([System.String]::New(part1 part2))
#=> part1 part2
After spitting the arguments, each has property expansion performed on it recursively.
item_function = IDENTIFIER "(" [ ARGUMENTS ] ")"
transform = "->" ( SINGLE_QUOTED_STRING | item_function )
item_expansion = "@("
IDENTIFIER *( transform ) [ "," SINGLE_QUOTED_STRING ]
")"
An item expansion is, at least syntax-wise, much simpler than a property expansion. It only really has the one meaningful form composed of 3 parts:
- The
ItemTypeto use as input - A chain of 0 or more transforms
- An optional separator string
Transforms take a set of items and modify it in some way to produce a new one. In most cases this means producing a new set of items where each Include value is mapped into a new one, with the metadata values for each item being copied over unchanged.
The only exceptions to the above are that some of the intrinsic item functions which can do things like filtering the item set or instead producing output items where the Include value is unchanged but the metadata is updated.
@(Items->get_Length()->Replace('1', '2'))
@(Items->Count())
@(Items->HasMetadata('MetadataName'))
Item functions are either methods/properties/fields of System.String or a list of what are called intrinsic item functions.
There are some slight differences between the functions you can specify here and the methods that can be specified in a property expansion. One crucial difference is that you no longer have the ability to omit the parenthesis to specify a property/field.
To specify that you wish to access a property you would instead specify a function prefixed with get_, e.g. accessing Length is done via ->get_Length().
Also different from property expansion is that here each transform step is always producing a new set of items, even something like the intrinsic function Count() produces a single item whose Include value is the number of items that were in the input.
@(Items->'%(MetadataName)')
@(Items->HasMetadata('MetadataName')
->'%(MetadataName): %(OtherMetadataName)')
If the transform is a single quoted string it has the effect of mapping the Include value of each item into that string. Then after that metadata expansion is performed within the resulting string in the context of the item being processed.
The metadata expansion described here is the only case where expansion is done out of order. Expansion is normally performed in the order of metadata → property → item.
There is special logic in the code to do metadata expansion which checks if the metadata is inside of a transform expression and if so it leaves it as is for the item expansion to handle.
@(Items, ',')
@(Items->Replace('a', 'b'), 'something weird')
@(Items->'%(MetadataName)', '---')
If an item separator is specified at the end of the item expansion instead of outputing a new set of items, the expansion will produce the final Include values joined together by the specified separator.
This output is literally just a string so it doesn’t preserve any metadata.
The rules ALPHA and DIGIT are defined in Appendix B of the ABNF spec for general use in grammars.
Unfortunately ABNF, like EBNF, doesn't handle unicode well so to represent the flexibility of some of these rules I'm going to have to give up and add a simple extension to the ABNF syntax to have negated character groups.
The definition of this extension below makes use of the rules defined in Section 4 of RFC5234.
any-character = num-val / char-val
negated-character-group =
"!("
*c-wsp
any-character
*c-wsp
*( "/" *c-wsp any-character )
*c-wsp
")"
This enables you to write !( "'" ) to mean any character except for a single quote.
IDENTIFIER = ALPHA / "_" *( ALPHA / DIGIT / "_" / "-")
SINGLE_QUOTED_STRING = "'" *( !("'") ) "'"
TYPE_NAME = *( !("]") )
REGISTRY_PATH = *( !("@") )
REGISTRY_LOCATION = ANY_SEQUENCE
ARGUMENTS = ARGUMENT *( "," ARGUMENT )
The above rules also require a few supplemental rules that do not appear elsewhere.
DOUBLE_QUOTED_STRING = %x22 *( !(%x22) ) %x22
BACKTICK_STRING = "`" *( !("`") ) "`"
QUOTED_STRING = SINGLE_QUOTED_STRING
QUOTED_STRING =/ DOUBLE_QUOTED_STRING
QUOTED_STRING =/ BACKTICK_STRING
NOT_QUOTE_OR_PAREN = !( "'" / %x22 / "`" / "(" / ")" )
MATCHING_PARENS_SEGMENT =
"(" *(
QUOTED_STRING
/ MATCHING_PARENS_SEGMENT
/ *NOT_QUOTE_OR_PAREN
) ")"
ANY_SEQUENCE = MATCHING_PARENS_SEGMENT
ANY_SEQUENCE =/ QUOTED_STRING
ANY_SEQUENCE =/ NOT_QUOTE_OR_PAREN
ARGUMENT_NON_SPECIAL = !( "'" / %x22 / "`" / "(" / ")" / "," )
ARGUMENT = *(
QUOTED_STRING / "$" MATCHING_PARENS_SEGMENT / ARGUMENT_NON_SPECIAL
)