Add cmdlet 'Join-String' for creating text from pipeline input (#7660)

The cmdlet syntax is as follows:
```
Join-String [[-Property] <pspropertyexpression>] [[-Separator] <string>] [-OutputPrefix <string>] [-OutputSuffix <string>] [-UseCulture] [-InputObject <psobject>] [<CommonParameters>]

Join-String [[-Property] <pspropertyexpression>] [[-Separator] <string>] [-OutputPrefix <string>] [-OutputSuffix <string>] [-SingleQuote] [-UseCulture] [-InputObject <psobject>] [<CommonParameters>]

Join-String [[-Property] <pspropertyexpression>] [[-Separator] <string>] [-OutputPrefix <string>] [-OutputSuffix <string>] [-DoubleQuote] [-UseCulture] [-InputObject <psobject>] [<CommonParameters>]

Join-String [[-Property] <pspropertyexpression>] [[-Separator] <string>] [-OutputPrefix <string>] [-OutputSuffix <string>] [-FormatString <string>] [-UseCulture] [-InputObject <psobject>] [<CommonParameters>]
```
This commit is contained in:
Staffan Gustafsson 2018-11-14 22:52:44 +01:00 committed by Dongbo Wang
parent a244c049e8
commit 877b9a9fbf
9 changed files with 451 additions and 13 deletions

View file

@ -0,0 +1,226 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Management.Automation;
using System.Management.Automation.Internal;
using System.Management.Automation.Language;
using System.Text;
namespace Microsoft.PowerShell.Commands.Utility
{
/// <summary>
/// Join-Object implementation.
/// </summary>
[Cmdlet(VerbsCommon.Join, "String", RemotingCapability = RemotingCapability.None, DefaultParameterSetName = "default")]
[OutputType(typeof(string))]
public sealed class JoinStringCommand : PSCmdlet
{
/// <summary>A bigger default to not get re-allocations in common use cases.</summary>
private const int DefaultOutputStringCapacity = 256;
private readonly StringBuilder _outputBuilder = new StringBuilder(DefaultOutputStringCapacity);
private CultureInfo _cultureInfo = CultureInfo.InvariantCulture;
private string _separator;
private char _quoteChar;
private bool _firstInputObject = true;
/// <summary>
/// Gets or sets the property name or script block to use as the value to join.
/// </summary>
[Parameter(Position = 0)]
[ArgumentCompleter(typeof(PropertyNameCompleter))]
public PSPropertyExpression Property { get; set; }
/// <summary>
/// Gets or sets the delimiter to join the output with.
/// </summary>
[Parameter(Position = 1)]
[ArgumentCompleter(typeof(JoinItemCompleter))]
[AllowEmptyString]
public string Separator
{
get => _separator ?? LanguagePrimitives.ConvertTo<string>(GetVariableValue("OFS"));
set => _separator = value;
}
/// <summary>
/// Gets or sets text to include before the joined input text.
/// </summary>
[Parameter]
[Alias("op")]
public string OutputPrefix { get; set; }
/// <summary>
/// Gets or sets text to include after the joined input text.
/// </summary>
[Parameter]
[Alias("os")]
public string OutputSuffix { get; set; }
/// <summary>
/// Gets or sets if the output items should we wrapped in single quotes.
/// </summary>
[Parameter(ParameterSetName = "SingleQuote")]
public SwitchParameter SingleQuote { get; set; }
/// <summary>
/// Gets or sets if the output items should we wrapped in double quotes.
/// </summary>
[Parameter(ParameterSetName = "DoubleQuote")]
public SwitchParameter DoubleQuote { get; set; }
/// <summary>
/// Gets or sets a format string that is applied to each input object.
/// </summary>
[Parameter(ParameterSetName = "Format")]
[ArgumentCompleter(typeof(JoinItemCompleter))]
public string FormatString { get; set; }
/// <summary>
/// Gets or sets if the current culture should be used with formatting instead of the invariant culture.
/// </summary>
[Parameter]
public SwitchParameter UseCulture { get; set; }
/// <summary>
/// Gets or sets the input object to join into text.
/// </summary>
[Parameter(ValueFromPipeline = true)]
public PSObject InputObject { get; set; }
/// <inheritdoc />
protected override void BeginProcessing()
{
_quoteChar = SingleQuote ? '\'' : DoubleQuote ? '"' : char.MinValue;
_outputBuilder.Append(OutputPrefix);
if (UseCulture)
{
_cultureInfo = CultureInfo.CurrentCulture;
}
}
/// <inheritdoc />
protected override void ProcessRecord()
{
if (InputObject != null && InputObject != AutomationNull.Value)
{
var inputValue = Property == null
? InputObject
: Property.GetValues(InputObject, false, true).FirstOrDefault()?.Result;
// conversion to string always succeeds.
if (!LanguagePrimitives.TryConvertTo<string>(inputValue, _cultureInfo, out var stringValue))
{
throw new PSInvalidCastException("InvalidCastFromAnyTypeToString", ExtendedTypeSystem.InvalidCastCannotRetrieveString, null);
}
if (_firstInputObject)
{
_firstInputObject = false;
}
else
{
_outputBuilder.Append(Separator);
}
if (_quoteChar != char.MinValue)
{
_outputBuilder.Append(_quoteChar);
_outputBuilder.Append(stringValue);
_outputBuilder.Append(_quoteChar);
}
else if (string.IsNullOrEmpty(FormatString))
{
_outputBuilder.Append(stringValue);
}
else
{
_outputBuilder.AppendFormat(_cultureInfo, FormatString, stringValue);
}
}
}
/// <inheritdoc />
protected override void EndProcessing()
{
_outputBuilder.Append(OutputSuffix);
WriteObject(_outputBuilder.ToString());
}
}
internal class JoinItemCompleter : IArgumentCompleter
{
public IEnumerable<CompletionResult> CompleteArgument(
string commandName,
string parameterName,
string wordToComplete,
CommandAst commandAst,
IDictionary fakeBoundParameters)
{
switch (parameterName)
{
case "Separator": return CompleteSeparator(wordToComplete);
case "FormatString": return CompleteFormatString(wordToComplete);
}
return null;
}
private IEnumerable<CompletionResult> CompleteFormatString(string wordToComplete)
{
var res = new List<CompletionResult>();
void AddMatching(string completionText)
{
if (completionText.StartsWith(wordToComplete, StringComparison.OrdinalIgnoreCase))
{
res.Add(new CompletionResult(completionText));
}
}
AddMatching("'[{0}]'");
AddMatching("'{0:N2}'");
AddMatching("\"`r`n `${0}\"");
AddMatching("\"`r`n [string] `${0}\"");
return res;
}
private IEnumerable<CompletionResult> CompleteSeparator(string wordToComplete)
{
var res = new List<CompletionResult>(10);
void AddMatching(string completionText, string listText, string toolTip)
{
if (completionText.StartsWith(wordToComplete, StringComparison.OrdinalIgnoreCase))
{
res.Add(new CompletionResult(completionText, listText, CompletionResultType.ParameterValue, toolTip));
}
}
AddMatching("', '", "Comma-Space", "', ' - Comma-Space");
AddMatching("';'", "Semi-Colon", "';' - Semi-Colon ");
AddMatching("'; '", "Semi-Colon-Space", "'; ' - Semi-Colon-Space");
AddMatching($"\"{NewLineText}\"", "Newline", $"{NewLineText} - Newline");
AddMatching("','", "Comma", "',' - Comma");
AddMatching("'-'", "Dash", "'-' - Dash");
AddMatching("' '", "Space", "' ' - Space");
return res;
}
public string NewLineText
{
get
{
#if UNIX
return "`n";
#else
return "`r`n";
#endif
}
}
}
}

View file

@ -14,7 +14,7 @@ CmdletsToExport= "Format-List", "Format-Custom", "Format-Table", "Format-Wide",
"Export-Csv", "Import-Csv", "ConvertTo-Csv", "ConvertFrom-Csv", "Export-Alias", "Invoke-Expression",
"Get-Alias", "Get-Culture", "Get-Date", "Get-Host", "Get-Member", "Get-Random", "Get-UICulture",
"Get-Unique", "Export-PSSession", "Import-PSSession", "Import-Alias", "Import-LocalizedData",
"Select-String", "Measure-Object", "New-Alias", "New-TimeSpan", "Read-Host", "Set-Alias", "Set-Date",
"Join-String", "Select-String", "Measure-Object", "New-Alias", "New-TimeSpan", "Read-Host", "Set-Alias", "Set-Date",
"Start-Sleep", "Tee-Object", "Measure-Command", "Update-TypeData", "Update-FormatData",
"Remove-TypeData", "Get-TypeData", "Write-Host", "Write-Progress", "New-Object", "Select-Object",
"Group-Object", "Sort-Object", "Get-Variable", "New-Variable", "Set-Variable", "Remove-Variable",

View file

@ -14,7 +14,7 @@ CmdletsToExport= "Format-List", "Format-Custom", "Format-Table", "Format-Wide",
"Export-Csv", "Import-Csv", "ConvertTo-Csv", "ConvertFrom-Csv", "Export-Alias", "Invoke-Expression",
"Get-Alias", "Get-Culture", "Get-Date", "Get-Host", "Get-Member", "Get-Random", "Get-UICulture",
"Get-Unique", "Export-PSSession", "Import-PSSession", "Import-Alias", "Import-LocalizedData",
"Select-String", "Measure-Object", "New-Alias", "New-TimeSpan", "Read-Host", "Set-Alias", "Set-Date",
"Join-String", "Select-String", "Measure-Object", "New-Alias", "New-TimeSpan", "Read-Host", "Set-Alias", "Set-Date",
"Start-Sleep", "Tee-Object", "Measure-Command", "Update-TypeData", "Update-FormatData",
"Remove-TypeData", "Get-TypeData", "Write-Host", "Write-Progress", "New-Object", "Select-Object",
"Group-Object", "Sort-Object", "Get-Variable", "New-Variable", "Set-Variable", "Remove-Variable",

View file

@ -3746,7 +3746,7 @@ namespace System.Management.Automation
prevType = AstTypeInference.InferTypeOf(pipelineAst.PipelineElements[i - 1], context.TypeInferenceContext, TypeInferenceRuntimePermissions.AllowSafeEval);
}
CompleteMemberByInferredType(context, prevType, result, context.WordToComplete + "*", filter: IsPropertyMember, isStatic: false);
CompleteMemberByInferredType(context.TypeInferenceContext, prevType, result, context.WordToComplete + "*", filter: IsPropertyMember, isStatic: false);
result.Add(CompletionResult.Null);
}
@ -5027,7 +5027,7 @@ namespace System.Management.Automation
if (inferredTypes != null && inferredTypes.Length > 0)
{
// Use inferred types if we have any
CompleteMemberByInferredType(context, inferredTypes, results, memberName, filter: null, isStatic: @static);
CompleteMemberByInferredType(context.TypeInferenceContext, inferredTypes, results, memberName, filter: null, isStatic: @static);
}
else
{
@ -5131,7 +5131,7 @@ namespace System.Management.Automation
return Ast.GetAncestorAst<ConfigurationDefinitionAst>(expression) != null;
}
private static void CompleteMemberByInferredType(CompletionContext context, IEnumerable<PSTypeName> inferredTypes, List<CompletionResult> results, string memberName, Func<object, bool> filter, bool isStatic)
internal static void CompleteMemberByInferredType(TypeInferenceContext context, IEnumerable<PSTypeName> inferredTypes, List<CompletionResult> results, string memberName, Func<object, bool> filter, bool isStatic)
{
bool extensionMethodsAdded = false;
HashSet<string> typeNameUsed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@ -5142,8 +5142,9 @@ namespace System.Management.Automation
{
continue;
}
typeNameUsed.Add(psTypeName.Name);
var members = context.TypeInferenceContext.GetMembersByInferredType(psTypeName, isStatic, filter);
var members = context.GetMembersByInferredType(psTypeName, isStatic, filter);
foreach (var member in members)
{
AddInferredMember(member, memberNamePattern, results);
@ -5276,7 +5277,7 @@ namespace System.Management.Automation
return false;
}
private static bool IsPropertyMember(object member)
internal static bool IsPropertyMember(object member)
{
return member is PropertyInfo
|| member is FieldInfo
@ -6154,7 +6155,7 @@ namespace System.Management.Automation
{
var result = new List<CompletionResult>();
CompleteMemberByInferredType(
completionContext, AstTypeInference.InferTypeOf(typeAst, completionContext.TypeInferenceContext, TypeInferenceRuntimePermissions.AllowSafeEval),
completionContext.TypeInferenceContext, AstTypeInference.InferTypeOf(typeAst, completionContext.TypeInferenceContext, TypeInferenceRuntimePermissions.AllowSafeEval),
result, completionContext.WordToComplete + "*", IsWriteablePropertyMember, isStatic: false);
return result;
}
@ -6265,7 +6266,7 @@ namespace System.Management.Automation
var inferredType = AstTypeInference.InferTypeOf(commandAst, completionContext.TypeInferenceContext, TypeInferenceRuntimePermissions.AllowSafeEval);
var result = new List<CompletionResult>();
CompleteMemberByInferredType(
completionContext, inferredType,
completionContext.TypeInferenceContext, inferredType,
result, completionContext.WordToComplete + "*", IsWriteablePropertyMember, isStatic: false);
return result;
case "Select-Object":
@ -6910,4 +6911,79 @@ namespace System.Management.Automation
return parenExpressionAst.Pipeline.Accept(this);
}
}
/// <summary>
/// Completes with the property names of the InputObject.
/// </summary>
internal class PropertyNameCompleter : IArgumentCompleter
{
private readonly string _parameterNameOfInput;
/// <summary>
/// Initializes a new instance of the <see cref="PropertyNameCompleter"/> class.
/// </summary>
public PropertyNameCompleter()
{
_parameterNameOfInput = "InputObject";
}
/// <summary>
/// Initializes a new instance of the <see cref="PropertyNameCompleter"/> class.
/// </summary>
/// <param name="parameterNameOfInput">The name of the property of the input object for witch to complete with property names.</param>
public PropertyNameCompleter(string parameterNameOfInput)
{
_parameterNameOfInput = parameterNameOfInput;
}
IEnumerable<CompletionResult> IArgumentCompleter.CompleteArgument(
string commandName,
string parameterName,
string wordToComplete,
CommandAst commandAst,
IDictionary fakeBoundParameters)
{
if (!(commandAst.Parent is PipelineAst pipelineAst))
{
return null;
}
int i;
for (i = 0; i < pipelineAst.PipelineElements.Count; i++)
{
if (pipelineAst.PipelineElements[i] == commandAst)
{
break;
}
}
var typeInferenceContext = new TypeInferenceContext();
IEnumerable<PSTypeName> prevType;
if (i == 0)
{
var parameterAst = (CommandParameterAst)commandAst.Find(ast => ast is CommandParameterAst cpa && cpa.ParameterName == "PropertyName", false);
var pseudoBinding = new PseudoParameterBinder().DoPseudoParameterBinding(commandAst, null, parameterAst, PseudoParameterBinder.BindingType.ParameterCompletion);
if (!pseudoBinding.BoundArguments.TryGetValue(_parameterNameOfInput, out var pair) || !pair.ArgumentSpecified)
{
return null;
}
if (pair is AstPair astPair && astPair.Argument != null)
{
prevType = AstTypeInference.InferTypeOf(astPair.Argument, typeInferenceContext, TypeInferenceRuntimePermissions.AllowSafeEval);
}
return null;
}
else
{
prevType = AstTypeInference.InferTypeOf(pipelineAst.PipelineElements[i - 1], typeInferenceContext, TypeInferenceRuntimePermissions.AllowSafeEval);
}
var result = new List<CompletionResult>();
CompletionCompleters.CompleteMemberByInferredType(typeInferenceContext, prevType, result, wordToComplete + "*", filter: CompletionCompleters.IsPropertyMember, isStatic: false);
return result;
}
}
}

View file

@ -3244,7 +3244,7 @@ namespace System.Management.Automation
try
{
typeConversion.WriteLine("Converting object to string.");
return PSObject.ToStringParser(ecFromTLS, valueToConvert);
return PSObject.ToStringParser(ecFromTLS, valueToConvert, formatProvider);
}
catch (ExtendedTypeSystemException e)
{

View file

@ -1149,10 +1149,31 @@ namespace System.Management.Automation
/// When there is a brokered ToString but it failed, or when the ToString on obj throws an exception.
/// </exception>
internal static string ToStringParser(ExecutionContext context, object obj)
{
return ToStringParser(context, obj, CultureInfo.InvariantCulture);
}
/// <summary>
/// Returns the string representation of obj.
/// </summary>
/// <param name="context">ExecutionContext used to fetch the separator. </param>
/// <param name="obj">
/// object we are trying to call ToString on. If this is not an PSObject we try
/// enumerating and if that fails we call obj.ToString.
/// If this is an PSObject, we look for a brokered ToString.
/// If it is not present, and the BaseObject is null we try listing the properties.
/// If the BaseObject is not null we try enumerating. If that fails we try the BaseObject's ToString.
/// </param>
/// <param name="formatProvider">The formatProvider to be passed to ToString.</param>
/// <returns>A string representation of the object.</returns>
/// <exception cref="ExtendedTypeSystemException">
/// When there is a brokered ToString but it failed, or when the ToString on obj throws an exception.
/// </exception>
internal static string ToStringParser(ExecutionContext context, object obj, IFormatProvider formatProvider)
{
try
{
return ToString(context, obj, null, null, CultureInfo.InvariantCulture, true, true);
return ToString(context, obj, null, null, formatProvider, true, true);
}
catch (ExtendedTypeSystemException etse)
{

View file

@ -368,7 +368,7 @@ namespace System.Management.Automation.Language
internal static readonly FieldInfo PSObject_isDeserialized =
typeof(PSObject).GetField(nameof(PSObject.isDeserialized), instanceFlags);
internal static readonly MethodInfo PSObject_ToStringParser =
typeof(PSObject).GetMethod(nameof(PSObject.ToStringParser), staticFlags);
typeof(PSObject).GetMethod(nameof(PSObject.ToStringParser), staticFlags, null, new[]{typeof(ExecutionContext), typeof(object)}, null);
internal static readonly PropertyInfo PSReference_Value =
typeof(PSReference).GetProperty(nameof(PSReference.Value));
@ -1535,7 +1535,7 @@ namespace System.Management.Automation.Language
if (attribute is ExperimentalAttribute expAttribute)
{
// Only honor the first seen experimental attribute, ignore the others.
// Only honor the first seen experimental attribute, ignore the others.
if (!hasSeenExpAttribute && expAttribute.ToHide) { return null; }
// Do not add experimental attributes to the attribute list.

View file

@ -0,0 +1,114 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
Describe "Join-String" -Tags "CI" {
BeforeAll {
$testObject = Get-ChildItem
}
It "Should be called using the InputObject without error with no other switches" {
{ Join-String -InputObject $testObject } | Should -Not -Throw
}
It "Should return a single string" {
$actual = $testObject | Join-String
$actual.Count | Should -Be 1
$actual | Should -BeOfType System.String
}
It "Should join property values with default separator" {
$expected = $testObject.Name -join $ofs
$actual = $testObject | Join-String -Property Name
$actual | Should -BeExactly $expected
}
It "Should join property values positionally with default separator" {
$expected = $testObject.Name -join $ofs
$actual = $testObject | Join-String Name
$actual | Should -BeExactly $expected
}
It "Should join property values with custom Separator" {
$expected = $testObject.Name -join "; "
$actual = $testObject | Join-String -Property Name -Separator "; "
$actual | Should -BeExactly $expected
}
It "Should join property values SingleQuoted" {
$expected = ($testObject.Name).Foreach{"'$_'"} -join "; "
$actual = $testObject | Join-String -Property Name -Separator "; " -SingleQuote
$actual | Should -BeExactly $expected
}
It "Should join property values DoubleQuoted" {
$expected = ($testObject.Name).Foreach{"""$_"""} -join "; "
$actual = $testObject | Join-String -Property Name -Separator "; " -DoubleQuote
$actual | Should -BeExactly $expected
}
It "Should join property values Formatted" {
$expected = ($testObject.Name).Foreach{"[$_]"} -join "; "
$actual = $testObject | Join-String -Property Name -Separator "; " -Format "[{0}]"
$actual | Should -BeExactly $expected
}
It "Should join script block results with default separator" {
$sb = {$_.Name + $_.Length}
$expected = ($testObject | ForEach-Object $sb) -join $ofs
$actual = $testObject | Join-String -Property $sb
$actual | Should -BeExactly $expected
}
It "Should join script block results with custom separator" {
$sb = {$_.Name + $_.Length}
$expected = ($testObject | ForEach-Object $sb) -join "; "
$actual = $testObject | Join-String -Property $sb -Separator "; "
$actual | Should -BeExactly $expected
}
It "Should join script block results SingleQuoted" {
$sb = {$_.Name + $_.Length}
$expected = ($testObject | ForEach-Object $sb).Foreach{"'$_'"} -join $ofs
$actual = $testObject | Join-String -Property $sb -SingleQuote
$actual | Should -BeExactly $expected
}
It "Should join script block results DoubleQuoted" {
$sb = {$_.Name + $_.Length}
$expected = ($testObject | ForEach-Object $sb).Foreach{"""$_"""} -join $ofs
$actual = $testObject | Join-String -Property $sb -DoubleQuote
$actual | Should -BeExactly $expected
}
It "Should join script block results with Format and separator" {
$sb = {$_.Name + $_.Length}
$expected = ($testObject | ForEach-Object $sb).Foreach{"[{0}]" -f $_} -join "; "
$actual = $testObject | Join-String -Property $sb -Separator "; " -Format "[{0}]"
$actual | Should -BeExactly $expected
}
It "Should Handle OutputPrefix and OutputSuffix" {
$ofs = ','
$expected = "A 1,2,3 B"
$actual = 1..3 | Join-String -OutputPrefix "A " -OutputSuffix " B"
$actual | Should -BeExactly $expected
}
It "Should handle null separator" {
$expected = -join 'hello'.tochararray()
$actual = "hello" | Join-String -separator $null
$actual | Should -BeExactly $expected
}
It "Should tabcomplete InputObject properties" {
$cmd = '[io.fileinfo]::new("c:\temp") | Join-String -Property '
$res = tabexpansion2 $cmd $cmd.length
$completionTexts = $res.CompletionMatches.CompletionText
$Propertys = [io.fileinfo]::new($PSScriptRoot).psobject.properties.Name
foreach ($n in $Propertys) {
$n -in $completionTexts | Should -BeTrue
}
}
}

View file

@ -320,6 +320,7 @@ Describe "Verify approved aliases list" -Tags "CI" {
"Cmdlet", "Invoke-WmiMethod", , $($FullCLR )
"Cmdlet", "Invoke-WSManAction", , $($FullCLR -or $CoreWindows )
"Cmdlet", "Join-Path", , $($FullCLR -or $CoreWindows -or $CoreUnix)
"Cmdlet", "Join-String", , $( $CoreWindows -or $CoreUnix)
"Cmdlet", "Limit-EventLog", , $($FullCLR )
"Cmdlet", "Measure-Command", , $($FullCLR -or $CoreWindows -or $CoreUnix)
"Cmdlet", "Measure-Object", , $($FullCLR -or $CoreWindows -or $CoreUnix)