diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/join-string.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/join-string.cs new file mode 100644 index 00000000000..35be218ce5d --- /dev/null +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/join-string.cs @@ -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 +{ + /// + /// Join-Object implementation. + /// + [Cmdlet(VerbsCommon.Join, "String", RemotingCapability = RemotingCapability.None, DefaultParameterSetName = "default")] + [OutputType(typeof(string))] + public sealed class JoinStringCommand : PSCmdlet + { + /// A bigger default to not get re-allocations in common use cases. + 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; + + /// + /// Gets or sets the property name or script block to use as the value to join. + /// + [Parameter(Position = 0)] + [ArgumentCompleter(typeof(PropertyNameCompleter))] + public PSPropertyExpression Property { get; set; } + + /// + /// Gets or sets the delimiter to join the output with. + /// + [Parameter(Position = 1)] + [ArgumentCompleter(typeof(JoinItemCompleter))] + [AllowEmptyString] + public string Separator + { + get => _separator ?? LanguagePrimitives.ConvertTo(GetVariableValue("OFS")); + set => _separator = value; + } + + /// + /// Gets or sets text to include before the joined input text. + /// + [Parameter] + [Alias("op")] + public string OutputPrefix { get; set; } + + /// + /// Gets or sets text to include after the joined input text. + /// + [Parameter] + [Alias("os")] + public string OutputSuffix { get; set; } + + /// + /// Gets or sets if the output items should we wrapped in single quotes. + /// + [Parameter(ParameterSetName = "SingleQuote")] + public SwitchParameter SingleQuote { get; set; } + + /// + /// Gets or sets if the output items should we wrapped in double quotes. + /// + [Parameter(ParameterSetName = "DoubleQuote")] + public SwitchParameter DoubleQuote { get; set; } + + /// + /// Gets or sets a format string that is applied to each input object. + /// + [Parameter(ParameterSetName = "Format")] + [ArgumentCompleter(typeof(JoinItemCompleter))] + public string FormatString { get; set; } + + /// + /// Gets or sets if the current culture should be used with formatting instead of the invariant culture. + /// + [Parameter] + public SwitchParameter UseCulture { get; set; } + + /// + /// Gets or sets the input object to join into text. + /// + [Parameter(ValueFromPipeline = true)] + public PSObject InputObject { get; set; } + + /// + protected override void BeginProcessing() + { + _quoteChar = SingleQuote ? '\'' : DoubleQuote ? '"' : char.MinValue; + _outputBuilder.Append(OutputPrefix); + if (UseCulture) + { + _cultureInfo = CultureInfo.CurrentCulture; + } + } + + /// + 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(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); + } + } + } + + /// + protected override void EndProcessing() + { + _outputBuilder.Append(OutputSuffix); + WriteObject(_outputBuilder.ToString()); + } + } + + internal class JoinItemCompleter : IArgumentCompleter + { + public IEnumerable 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 CompleteFormatString(string wordToComplete) + { + var res = new List(); + 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 CompleteSeparator(string wordToComplete) + { + var res = new List(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 + } + } + } +} diff --git a/src/Modules/Unix/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1 b/src/Modules/Unix/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1 index 86e09756362..e901694f4f9 100644 --- a/src/Modules/Unix/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1 +++ b/src/Modules/Unix/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1 @@ -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", diff --git a/src/Modules/Windows/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1 b/src/Modules/Windows/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1 index ea7e3690574..316852c1728 100644 --- a/src/Modules/Windows/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1 +++ b/src/Modules/Windows/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1 @@ -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", diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionCompleters.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionCompleters.cs index c20057c397c..0b2a220f16e 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionCompleters.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionCompleters.cs @@ -3746,7 +3746,7 @@ private static void NativeCompletionMemberName(CompletionContext context, List CompleteMember(CompletionContext context, 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 { @@ -5126,7 +5126,7 @@ private static bool IsInDscContext(ExpressionAst expression) return Ast.GetAncestorAst(expression) != null; } - private static void CompleteMemberByInferredType(CompletionContext context, IEnumerable inferredTypes, List results, string memberName, Func filter, bool isStatic) + internal static void CompleteMemberByInferredType(TypeInferenceContext context, IEnumerable inferredTypes, List results, string memberName, Func filter, bool isStatic) { bool extensionMethodsAdded = false; HashSet typeNameUsed = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -5137,8 +5137,9 @@ private static void CompleteMemberByInferredType(CompletionContext context, IEnu { 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); @@ -5271,7 +5272,7 @@ private static bool IsWriteablePropertyMember(object member) return false; } - private static bool IsPropertyMember(object member) + internal static bool IsPropertyMember(object member) { return member is PropertyInfo || member is FieldInfo @@ -6149,7 +6150,7 @@ internal static List CompleteHashtableKey(CompletionContext co { var result = new List(); 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; } @@ -6260,7 +6261,7 @@ internal static List CompleteHashtableKey(CompletionContext co var inferredType = AstTypeInference.InferTypeOf(commandAst, completionContext.TypeInferenceContext, TypeInferenceRuntimePermissions.AllowSafeEval); var result = new List(); CompleteMemberByInferredType( - completionContext, inferredType, + completionContext.TypeInferenceContext, inferredType, result, completionContext.WordToComplete + "*", IsWriteablePropertyMember, isStatic: false); return result; case "Select-Object": @@ -6905,4 +6906,79 @@ public object VisitParenExpression(ParenExpressionAst parenExpressionAst) return parenExpressionAst.Pipeline.Accept(this); } } + + /// + /// Completes with the property names of the InputObject. + /// + internal class PropertyNameCompleter : IArgumentCompleter + { + private readonly string _parameterNameOfInput; + + /// + /// Initializes a new instance of the class. + /// + public PropertyNameCompleter() + { + _parameterNameOfInput = "InputObject"; + } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the property of the input object for witch to complete with property names. + public PropertyNameCompleter(string parameterNameOfInput) + { + _parameterNameOfInput = parameterNameOfInput; + } + + IEnumerable 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 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(); + + CompletionCompleters.CompleteMemberByInferredType(typeInferenceContext, prevType, result, wordToComplete + "*", filter: CompletionCompleters.IsPropertyMember, isStatic: false); + return result; + } + } } diff --git a/src/System.Management.Automation/engine/LanguagePrimitives.cs b/src/System.Management.Automation/engine/LanguagePrimitives.cs index 0c954de68d6..017eee6e308 100644 --- a/src/System.Management.Automation/engine/LanguagePrimitives.cs +++ b/src/System.Management.Automation/engine/LanguagePrimitives.cs @@ -3243,7 +3243,7 @@ private static string ConvertNonNumericToString(object valueToConvert, try { typeConversion.WriteLine("Converting object to string."); - return PSObject.ToStringParser(ecFromTLS, valueToConvert); + return PSObject.ToStringParser(ecFromTLS, valueToConvert, formatProvider); } catch (ExtendedTypeSystemException e) { diff --git a/src/System.Management.Automation/engine/MshObject.cs b/src/System.Management.Automation/engine/MshObject.cs index cf5c4defd3d..aaba1fc91d1 100644 --- a/src/System.Management.Automation/engine/MshObject.cs +++ b/src/System.Management.Automation/engine/MshObject.cs @@ -1207,10 +1207,31 @@ private static string ToStringEmptyBaseObject(ExecutionContext context, PSObject /// When there is a brokered ToString but it failed, or when the ToString on obj throws an exception. /// internal static string ToStringParser(ExecutionContext context, object obj) + { + return ToStringParser(context, obj, CultureInfo.InvariantCulture); + } + + /// + /// Returns the string representation of obj. + /// + /// ExecutionContext used to fetch the separator. + /// + /// 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. + /// + /// The formatProvider to be passed to ToString. + /// A string representation of the object. + /// + /// When there is a brokered ToString but it failed, or when the ToString on obj throws an 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) { diff --git a/src/System.Management.Automation/engine/parser/Compiler.cs b/src/System.Management.Automation/engine/parser/Compiler.cs index 41084c4db2b..d2814ed129b 100644 --- a/src/System.Management.Automation/engine/parser/Compiler.cs +++ b/src/System.Management.Automation/engine/parser/Compiler.cs @@ -368,7 +368,7 @@ internal static class CachedReflectionInfo 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 @@ private static RuntimeDefinedParameter GetRuntimeDefinedParameter(ParameterAst p 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. diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/Join-String.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/Join-String.Tests.ps1 new file mode 100644 index 00000000000..ce979da8af2 --- /dev/null +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/Join-String.Tests.ps1 @@ -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 + } + } + +} diff --git a/test/powershell/engine/Basic/DefaultCommands.Tests.ps1 b/test/powershell/engine/Basic/DefaultCommands.Tests.ps1 index cc60c28641f..04781833081 100644 --- a/test/powershell/engine/Basic/DefaultCommands.Tests.ps1 +++ b/test/powershell/engine/Basic/DefaultCommands.Tests.ps1 @@ -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)