2016-03-30 14:20:52 -07:00
/********************************************************************++
Copyright (c) Microsoft Corporation. All rights reserved.
--********************************************************************/
2016-07-29 13:02:49 -07:00
2016-03-30 14:20:52 -07:00
using System.IO ;
using System.Collections ;
using System.Collections.Generic ;
using System.Collections.ObjectModel ;
using System.Globalization ;
using System.Linq ;
using System.Management.Automation.Language ;
using System.Management.Automation.Runspaces ;
using System.Xml ;
using System.Text ;
using System.Text.RegularExpressions ;
using System.Management.Automation.Help ;
using System.Reflection ;
namespace System.Management.Automation
{
/// <summary>
/// Parses help comments and turns them into HelpInfo objects
/// </summary>
internal class HelpCommentsParser
{
private HelpCommentsParser ()
{
}
private HelpCommentsParser ( List < string > parameterDescriptions )
{
2016-07-29 13:02:49 -07:00
_parameterDescriptions = parameterDescriptions ;
2016-03-30 14:20:52 -07:00
}
private HelpCommentsParser ( CommandInfo commandInfo , List < string > parameterDescriptions )
{
FunctionInfo fi = commandInfo as FunctionInfo ;
if ( fi != null )
{
2016-07-29 13:02:49 -07:00
_scriptBlock = fi . ScriptBlock ;
_commandName = fi . Name ;
2016-03-30 14:20:52 -07:00
}
else
{
ExternalScriptInfo si = commandInfo as ExternalScriptInfo ;
if ( si != null )
{
2016-07-29 13:02:49 -07:00
_scriptBlock = si . ScriptBlock ;
_commandName = si . Path ;
2016-03-30 14:20:52 -07:00
}
}
2016-07-29 13:02:49 -07:00
_commandMetadata = commandInfo . CommandMetadata ;
_parameterDescriptions = parameterDescriptions ;
2016-03-30 14:20:52 -07:00
}
private readonly Language . CommentHelpInfo _sections = new Language . CommentHelpInfo ();
private readonly Dictionary < string , string > _parameters = new Dictionary < string , string >();
private readonly List < string > _examples = new List < string >();
private readonly List < string > _inputs = new List < string >();
private readonly List < string > _outputs = new List < string >();
private readonly List < string > _links = new List < string >();
internal bool isExternalHelpSet = false ;
2016-07-29 13:02:49 -07:00
private ScriptBlock _scriptBlock ;
private CommandMetadata _commandMetadata ;
private string _commandName ;
private List < string > _parameterDescriptions ;
private XmlDocument _doc ;
2016-08-27 02:46:25 +04:00
internal static readonly string mshURI = "http://msh" ;
2016-03-30 14:20:52 -07:00
internal static readonly string mamlURI = "http://schemas.microsoft.com/maml/2004/10" ;
internal static readonly string commandURI = "http://schemas.microsoft.com/maml/dev/command/2004/10" ;
internal static readonly string devURI = "http://schemas.microsoft.com/maml/dev/2004/10" ;
2016-07-29 13:02:49 -07:00
private const string directive = @"^\s*\.(\w+)(\s+(\S.*))?\s*$" ;
private const string blankline = @"^\s*$" ;
2016-08-27 02:46:25 +04:00
// Although "http://msh" is the default namespace, it still must be explicitly qualified with non-empty prefix,
// because XPath 1.0 will associate empty prefix with "null" namespace (not with "default") and query will fail.
// See: http://www.w3.org/TR/1999/REC-xpath-19991116/#node-tests
2016-03-30 14:20:52 -07:00
internal static readonly string ProviderHelpCommandXPath =
2016-08-27 02:46:25 +04:00
"/msh:helpItems/msh:providerHelp/msh:CmdletHelpPaths/msh:CmdletHelpPath{0}/command:command[command:details/command:verb='{1}' and command:details/command:noun='{2}']" ;
2016-03-30 14:20:52 -07:00
private void DetermineParameterDescriptions ()
{
int i = 0 ;
2016-07-29 13:02:49 -07:00
foreach ( string parameterName in _commandMetadata . StaticCommandParameterMetadata . BindableParameters . Keys )
2016-03-30 14:20:52 -07:00
{
string description ;
if (! _parameters . TryGetValue ( parameterName . ToUpperInvariant (), out description ))
{
2016-07-29 13:02:49 -07:00
if ( i < _parameterDescriptions . Count )
2016-03-30 14:20:52 -07:00
{
2016-07-29 13:02:49 -07:00
_parameters . Add ( parameterName . ToUpperInvariant (), _parameterDescriptions [ i ]);
2016-03-30 14:20:52 -07:00
}
}
++ i ;
}
}
private string GetParameterDescription ( string parameterName )
{
Diagnostics . Assert (! string . IsNullOrEmpty ( parameterName ), "Parameter name must not be empty" );
string description ;
_parameters . TryGetValue ( parameterName . ToUpperInvariant (), out description );
return description ;
}
private XmlElement BuildXmlForParameter (
string parameterName ,
bool isMandatory ,
bool valueFromPipeline ,
bool valueFromPipelineByPropertyName ,
string position ,
Type type ,
string description ,
bool supportsWildcards ,
string defaultValue ,
bool forSyntax )
{
2016-07-29 13:02:49 -07:00
XmlElement command_parameter = _doc . CreateElement ( "command:parameter" , commandURI );
2016-03-30 14:20:52 -07:00
command_parameter . SetAttribute ( "required" , isMandatory ? "true" : "false" );
//command_parameter.SetAttribute("variableLength", "unknown");
command_parameter . SetAttribute ( "globbing" , supportsWildcards ? "true" : "false" );
string fromPipeline ;
if ( valueFromPipeline && valueFromPipelineByPropertyName )
{
fromPipeline = "true (ByValue, ByPropertyName)" ;
}
else if ( valueFromPipeline )
{
fromPipeline = "true (ByValue)" ;
}
else if ( valueFromPipelineByPropertyName )
{
fromPipeline = "true (ByPropertyName)" ;
}
else
{
fromPipeline = "false" ;
}
command_parameter . SetAttribute ( "pipelineInput" , fromPipeline );
command_parameter . SetAttribute ( "position" , position );
2016-07-29 13:02:49 -07:00
XmlElement name = _doc . CreateElement ( "maml:name" , mamlURI );
XmlText name_text = _doc . CreateTextNode ( parameterName );
2016-03-30 14:20:52 -07:00
command_parameter . AppendChild ( name ). AppendChild ( name_text );
if (! string . IsNullOrEmpty ( description ))
{
2016-07-29 13:02:49 -07:00
XmlElement maml_description = _doc . CreateElement ( "maml:description" , mamlURI );
XmlElement maml_para = _doc . CreateElement ( "maml:para" , mamlURI );
XmlText maml_para_text = _doc . CreateTextNode ( description );
2016-03-30 14:20:52 -07:00
command_parameter . AppendChild ( maml_description ). AppendChild ( maml_para ). AppendChild ( maml_para_text );
}
if ( type == null )
type = typeof ( object );
var typeInfo = type . GetTypeInfo ();
var elementType = typeInfo . IsArray ? typeInfo . GetElementType () : type ;
if ( elementType . GetTypeInfo (). IsEnum )
{
2016-07-29 13:02:49 -07:00
XmlElement parameterValueGroup = _doc . CreateElement ( "command:parameterValueGroup" , commandURI );
2016-03-30 14:20:52 -07:00
foreach ( string valueName in Enum . GetNames ( elementType ))
{
2016-07-29 13:02:49 -07:00
XmlElement parameterValue = _doc . CreateElement ( "command:parameterValue" , commandURI );
2016-03-30 14:20:52 -07:00
parameterValue . SetAttribute ( "required" , "false" );
2016-07-29 13:02:49 -07:00
XmlText parameterValue_text = _doc . CreateTextNode ( valueName );
2016-03-30 14:20:52 -07:00
parameterValueGroup . AppendChild ( parameterValue ). AppendChild ( parameterValue_text );
}
command_parameter . AppendChild ( parameterValueGroup );
}
else
{
bool isSwitchParameter = elementType == typeof ( SwitchParameter );
if (! forSyntax || ! isSwitchParameter )
{
2016-07-29 13:02:49 -07:00
XmlElement parameterValue = _doc . CreateElement ( "command:parameterValue" , commandURI );
2016-03-30 14:20:52 -07:00
parameterValue . SetAttribute ( "required" , isSwitchParameter ? "false" : "true" );
//parameterValue.SetAttribute("variableLength", "unknown");
2016-07-29 13:02:49 -07:00
XmlText parameterValue_text = _doc . CreateTextNode ( type . Name );
2016-03-30 14:20:52 -07:00
command_parameter . AppendChild ( parameterValue ). AppendChild ( parameterValue_text );
}
}
if (! forSyntax )
{
2016-07-29 13:02:49 -07:00
XmlElement devType = _doc . CreateElement ( "dev:type" , devURI );
XmlElement typeName = _doc . CreateElement ( "maml:name" , mamlURI );
XmlText typeName_text = _doc . CreateTextNode ( type . Name );
2016-03-30 14:20:52 -07:00
command_parameter . AppendChild ( devType ). AppendChild ( typeName ). AppendChild ( typeName_text );
2016-07-29 13:02:49 -07:00
XmlElement defaultValueElement = _doc . CreateElement ( "dev:defaultValue" , devURI );
XmlText defaultValue_text = _doc . CreateTextNode ( defaultValue );
2016-03-30 14:20:52 -07:00
command_parameter . AppendChild ( defaultValueElement ). AppendChild ( defaultValue_text );
}
return command_parameter ;
}
/// <summary>
/// Create the maml xml after a successful analysis of the comments.
/// </summary>
/// <returns>The xml node for the command constructed</returns>
internal XmlDocument BuildXmlFromComments ()
{
2016-07-29 13:02:49 -07:00
Diagnostics . Assert (! string . IsNullOrEmpty ( _commandName ), "Name can never be null" );
2016-03-30 14:20:52 -07:00
2016-07-29 13:02:49 -07:00
_doc = new XmlDocument ();
XmlElement command = _doc . CreateElement ( "command:command" , commandURI );
2016-03-30 14:20:52 -07:00
command . SetAttribute ( "xmlns:maml" , mamlURI );
command . SetAttribute ( "xmlns:command" , commandURI );
command . SetAttribute ( "xmlns:dev" , devURI );
2016-07-29 13:02:49 -07:00
_doc . AppendChild ( command );
2016-03-30 14:20:52 -07:00
2016-07-29 13:02:49 -07:00
XmlElement details = _doc . CreateElement ( "command:details" , commandURI );
2016-03-30 14:20:52 -07:00
command . AppendChild ( details );
2016-07-29 13:02:49 -07:00
XmlElement name = _doc . CreateElement ( "command:name" , commandURI );
XmlText name_text = _doc . CreateTextNode ( _commandName );
2016-03-30 14:20:52 -07:00
details . AppendChild ( name ). AppendChild ( name_text );
if (! string . IsNullOrEmpty ( _sections . Synopsis ))
{
2016-07-29 13:02:49 -07:00
XmlElement synopsis = _doc . CreateElement ( "maml:description" , mamlURI );
XmlElement synopsis_para = _doc . CreateElement ( "maml:para" , mamlURI );
XmlText synopsis_text = _doc . CreateTextNode ( _sections . Synopsis );
2016-03-30 14:20:52 -07:00
details . AppendChild ( synopsis ). AppendChild ( synopsis_para ). AppendChild ( synopsis_text );
}
#region Syntax
// The syntax is automatically generated from parameter metadata
DetermineParameterDescriptions ();
2016-07-29 13:02:49 -07:00
XmlElement syntax = _doc . CreateElement ( "command:syntax" , commandURI );
MergedCommandParameterMetadata parameterMetadata = _commandMetadata . StaticCommandParameterMetadata ;
2016-03-30 14:20:52 -07:00
if ( parameterMetadata . ParameterSetCount > 0 )
{
for ( int i = 0 ; i < parameterMetadata . ParameterSetCount ; ++ i )
{
BuildSyntaxForParameterSet ( command , syntax , parameterMetadata , i );
}
}
else
{
BuildSyntaxForParameterSet ( command , syntax , parameterMetadata , int . MaxValue );
}
#endregion Syntax
#region Parameters
2016-07-29 13:02:49 -07:00
XmlElement commandParameters = _doc . CreateElement ( "command:parameters" , commandURI );
2016-03-30 14:20:52 -07:00
foreach ( KeyValuePair < string , MergedCompiledCommandParameter > pair in parameterMetadata . BindableParameters )
{
MergedCompiledCommandParameter mergedParameter = pair . Value ;
if ( mergedParameter . BinderAssociation == ParameterBinderAssociation . CommonParameters )
{
continue ;
}
string parameterName = pair . Key ;
string description = GetParameterDescription ( parameterName );
ParameterSetSpecificMetadata parameterSetData ;
bool isMandatory = false ;
bool valueFromPipeline = false ;
bool valueFromPipelineByPropertyName = false ;
string position = "named" ;
int i = 0 ;
CompiledCommandParameter parameter = mergedParameter . Parameter ;
parameter . ParameterSetData . TryGetValue ( ParameterAttribute . AllParameterSets , out parameterSetData );
while ( parameterSetData == null && i < 32 )
{
parameterSetData = parameter . GetParameterSetData ( 1 u << i ++);
}
if ( parameterSetData != null )
{
isMandatory = parameterSetData . IsMandatory ;
valueFromPipeline = parameterSetData . ValueFromPipeline ;
valueFromPipelineByPropertyName = parameterSetData . ValueFromPipelineByPropertyName ;
position = parameterSetData . IsPositional ? ( 1 + parameterSetData . Position ). ToString ( CultureInfo . InvariantCulture ) : "named" ;
}
var compiledAttributes = parameter . CompiledAttributes ;
bool supportsWildcards = compiledAttributes . OfType < SupportsWildcardsAttribute >(). Any ();
string defaultValueStr = "" ;
object defaultValue = null ;
var defaultValueAttribute = compiledAttributes . OfType < PSDefaultValueAttribute >(). FirstOrDefault ();
if ( defaultValueAttribute != null )
{
defaultValueStr = defaultValueAttribute . Help ;
if ( string . IsNullOrEmpty ( defaultValueStr ))
{
defaultValue = defaultValueAttribute . Value ;
}
}
if ( string . IsNullOrEmpty ( defaultValueStr ))
{
if ( defaultValue == null )
{
RuntimeDefinedParameter rdp ;
2016-07-29 13:02:49 -07:00
if ( _scriptBlock . RuntimeDefinedParameters . TryGetValue ( parameterName , out rdp ))
2016-03-30 14:20:52 -07:00
{
defaultValue = rdp . Value ;
}
}
var wrapper = defaultValue as Compiler . DefaultValueExpressionWrapper ;
if ( wrapper != null )
{
defaultValueStr = wrapper . Expression . Extent . Text ;
}
else if ( defaultValue != null )
{
defaultValueStr = PSObject . ToStringParser ( null , defaultValue );
}
}
XmlElement parameterElement = BuildXmlForParameter ( parameterName , isMandatory ,
valueFromPipeline , valueFromPipelineByPropertyName , position ,
parameter . Type , description , supportsWildcards , defaultValueStr , forSyntax : false );
commandParameters . AppendChild ( parameterElement );
}
command . AppendChild ( commandParameters );
#endregion Parameters
if (! string . IsNullOrEmpty ( _sections . Description ))
{
2016-07-29 13:02:49 -07:00
XmlElement description = _doc . CreateElement ( "maml:description" , mamlURI );
XmlElement description_para = _doc . CreateElement ( "maml:para" , mamlURI );
XmlText description_text = _doc . CreateTextNode ( _sections . Description );
2016-03-30 14:20:52 -07:00
command . AppendChild ( description ). AppendChild ( description_para ). AppendChild ( description_text );
}
if (! string . IsNullOrEmpty ( _sections . Notes ))
{
2016-07-29 13:02:49 -07:00
XmlElement alertSet = _doc . CreateElement ( "maml:alertSet" , mamlURI );
XmlElement alert = _doc . CreateElement ( "maml:alert" , mamlURI );
XmlElement alert_para = _doc . CreateElement ( "maml:para" , mamlURI );
XmlText alert_para_text = _doc . CreateTextNode ( _sections . Notes );
2016-03-30 14:20:52 -07:00
command . AppendChild ( alertSet ). AppendChild ( alert ). AppendChild ( alert_para ). AppendChild ( alert_para_text );
}
if ( _examples . Count > 0 )
{
2016-07-29 13:02:49 -07:00
XmlElement examples = _doc . CreateElement ( "command:examples" , commandURI );
2016-03-30 14:20:52 -07:00
int count = 1 ;
foreach ( string example in _examples )
2016-07-29 13:02:49 -07:00
{
XmlElement example_node = _doc . CreateElement ( "command:example" , commandURI );
2016-03-30 14:20:52 -07:00
// The title is automatically generated
2016-07-29 13:02:49 -07:00
XmlElement title = _doc . CreateElement ( "maml:title" , mamlURI );
2016-03-30 14:20:52 -07:00
string titleStr = string . Format ( CultureInfo . InvariantCulture ,
" -------------------------- {0} {1} --------------------------" ,
HelpDisplayStrings . ExampleUpperCase , count ++);
2016-07-29 13:02:49 -07:00
XmlText title_text = _doc . CreateTextNode ( titleStr );
2016-03-30 14:20:52 -07:00
example_node . AppendChild ( title ). AppendChild ( title_text );
string prompt_str ;
string code_str ;
string remarks_str ;
GetExampleSections ( example , out prompt_str , out code_str , out remarks_str );
// Introduction (usually the prompt)
2016-07-29 13:02:49 -07:00
XmlElement introduction = _doc . CreateElement ( "maml:introduction" , mamlURI );
XmlElement introduction_para = _doc . CreateElement ( "maml:para" , mamlURI );
XmlText introduction_para_text = _doc . CreateTextNode ( prompt_str );
2016-03-30 14:20:52 -07:00
example_node . AppendChild ( introduction ). AppendChild ( introduction_para ). AppendChild ( introduction_para_text );
// Example code
2016-07-29 13:02:49 -07:00
XmlElement code = _doc . CreateElement ( "dev:code" , devURI );
XmlText code_text = _doc . CreateTextNode ( code_str );
2016-03-30 14:20:52 -07:00
example_node . AppendChild ( code ). AppendChild ( code_text );
// Remarks are comments on the example
2016-07-29 13:02:49 -07:00
XmlElement remarks = _doc . CreateElement ( "dev:remarks" , devURI );
XmlElement remarks_para = _doc . CreateElement ( "maml:para" , mamlURI );
XmlText remarks_para_text = _doc . CreateTextNode ( remarks_str );
2016-03-30 14:20:52 -07:00
example_node . AppendChild ( remarks ). AppendChild ( remarks_para ). AppendChild ( remarks_para_text );
// The convention is to have 4 blank paras after the example for spacing
for ( int i = 0 ; i < 4 ; i ++)
{
2016-07-29 13:02:49 -07:00
remarks . AppendChild ( _doc . CreateElement ( "maml:para" , mamlURI ));
2016-03-30 14:20:52 -07:00
}
examples . AppendChild ( example_node );
}
command . AppendChild ( examples );
}
if ( _inputs . Count > 0 )
{
2016-07-29 13:02:49 -07:00
XmlElement inputTypes = _doc . CreateElement ( "command:inputTypes" , commandURI );
2016-03-30 14:20:52 -07:00
foreach ( string inputStr in _inputs )
{
2016-07-29 13:02:49 -07:00
XmlElement inputType = _doc . CreateElement ( "command:inputType" , commandURI );
XmlElement type = _doc . CreateElement ( "dev:type" , devURI );
XmlElement maml_name = _doc . CreateElement ( "maml:name" , mamlURI );
XmlText maml_name_text = _doc . CreateTextNode ( inputStr );
2016-03-30 14:20:52 -07:00
inputTypes . AppendChild ( inputType ). AppendChild ( type ). AppendChild ( maml_name ). AppendChild ( maml_name_text );
}
command . AppendChild ( inputTypes );
}
// For outputs, we prefer what was specified in the comments, but if there are no comments
// and the OutputType attribute was specified, we'll use those instead.
IEnumerable outputs = null ;
if ( _outputs . Count > 0 )
{
outputs = _outputs ;
}
2016-07-29 13:02:49 -07:00
else if ( _scriptBlock . OutputType . Count > 0 )
2016-03-30 14:20:52 -07:00
{
2016-07-29 13:02:49 -07:00
outputs = _scriptBlock . OutputType ;
2016-03-30 14:20:52 -07:00
}
if ( outputs != null )
{
2016-07-29 13:02:49 -07:00
XmlElement returnValues = _doc . CreateElement ( "command:returnValues" , commandURI );
2016-03-30 14:20:52 -07:00
foreach ( object output in outputs )
{
2016-07-29 13:02:49 -07:00
XmlElement returnValue = _doc . CreateElement ( "command:returnValue" , commandURI );
XmlElement type = _doc . CreateElement ( "dev:type" , devURI );
XmlElement maml_name = _doc . CreateElement ( "maml:name" , mamlURI );
2016-03-30 14:20:52 -07:00
string returnValueStr = output as string ?? (( PSTypeName ) output ). Name ;
2016-07-29 13:02:49 -07:00
XmlText maml_name_text = _doc . CreateTextNode ( returnValueStr );
2016-03-30 14:20:52 -07:00
returnValues . AppendChild ( returnValue ). AppendChild ( type ). AppendChild ( maml_name ). AppendChild ( maml_name_text );
}
command . AppendChild ( returnValues );
}
if ( _links . Count > 0 )
{
2016-07-29 13:02:49 -07:00
XmlElement links = _doc . CreateElement ( "maml:relatedLinks" , mamlURI );
2016-03-30 14:20:52 -07:00
foreach ( string link in _links )
{
2016-07-29 13:02:49 -07:00
XmlElement navigationLink = _doc . CreateElement ( "maml:navigationLink" , mamlURI );
2016-03-30 14:20:52 -07:00
bool isOnlineHelp = Uri . IsWellFormedUriString ( Uri . EscapeUriString ( link ), UriKind . Absolute );
string nodeName = isOnlineHelp ? "maml:uri" : "maml:linkText" ;
2016-07-29 13:02:49 -07:00
XmlElement linkText = _doc . CreateElement ( nodeName , mamlURI );
XmlText linkText_text = _doc . CreateTextNode ( link );
2016-03-30 14:20:52 -07:00
links . AppendChild ( navigationLink ). AppendChild ( linkText ). AppendChild ( linkText_text );
}
command . AppendChild ( links );
}
2016-07-29 13:02:49 -07:00
return _doc ;
2016-03-30 14:20:52 -07:00
}
private void BuildSyntaxForParameterSet ( XmlElement command , XmlElement syntax , MergedCommandParameterMetadata parameterMetadata , int i )
{
2016-07-29 13:02:49 -07:00
XmlElement syntaxItem = _doc . CreateElement ( "command:syntaxItem" , commandURI );
XmlElement syntaxItemName = _doc . CreateElement ( "maml:name" , mamlURI );
XmlText syntaxItemName_text = _doc . CreateTextNode ( _commandName );
2016-03-30 14:20:52 -07:00
syntaxItem . AppendChild ( syntaxItemName ). AppendChild ( syntaxItemName_text );
Collection < MergedCompiledCommandParameter > compiledParameters =
parameterMetadata . GetParametersInParameterSet ( 1 u << i );
foreach ( MergedCompiledCommandParameter mergedParameter in compiledParameters )
{
if ( mergedParameter . BinderAssociation == ParameterBinderAssociation . CommonParameters )
{
continue ;
}
CompiledCommandParameter parameter = mergedParameter . Parameter ;
ParameterSetSpecificMetadata parameterSetData = parameter . GetParameterSetData ( 1 u << i );
string description = GetParameterDescription ( parameter . Name );
bool supportsWildcards = parameter . CompiledAttributes . Any ( attribute => attribute is SupportsWildcardsAttribute );
XmlElement parameterElement = BuildXmlForParameter ( parameter . Name ,
parameterSetData . IsMandatory , parameterSetData . ValueFromPipeline ,
parameterSetData . ValueFromPipelineByPropertyName ,
parameterSetData . IsPositional ? ( 1 + parameterSetData . Position ). ToString ( CultureInfo . InvariantCulture ) : "named" ,
parameter . Type , description , supportsWildcards , defaultValue : "" , forSyntax : true );
syntaxItem . AppendChild ( parameterElement );
}
command . AppendChild ( syntax ). AppendChild ( syntaxItem );
}
private static void GetExampleSections ( string content , out string prompt_str , out string code_str , out string remarks_str )
{
prompt_str = code_str = "" ;
StringBuilder builder = new StringBuilder ();
int collectingPart = 1 ;
foreach ( char c in content )
{
if ( c == '>' && collectingPart == 1 )
{
builder . Append ( c );
prompt_str = builder . ToString (). Trim ();
builder = new StringBuilder ();
++ collectingPart ;
continue ;
}
if ( c == '\n' && collectingPart < 3 )
{
if ( collectingPart == 1 )
{
prompt_str = "PS C:\\>" ;
}
code_str = builder . ToString (). Trim ();
builder = new StringBuilder ();
collectingPart = 3 ;
continue ;
}
builder . Append ( c );
}
if ( collectingPart == 1 )
{
prompt_str = "PS C:\\>" ;
code_str = builder . ToString (). Trim ();
remarks_str = "" ;
}
else
{
remarks_str = builder . ToString ();
}
}
/// <summary>
/// Split the text in the comment token into multiple lines, appending commentLines.
/// </summary>
/// <param name="comment">A single line or multiline comment token</param>
/// <param name="commentLines"></param>
private static void CollectCommentText ( Token comment , List < string > commentLines )
{
string text = comment . Text ;
CollectCommentText ( text , commentLines );
}
private static void CollectCommentText ( string text , List < string > commentLines )
{
int i = 0 ;
if ( text [ 0 ] == '<' )
{
int start = 2 ;
// The full text includes '<#', so start at index 2 to skip those characters,
// and the full text also includes '#>' at the end, so skip those as well.
for ( i = 2 ; i < text . Length - 2 ; i ++)
{
if ( text [ i ] == '\n' )
{
commentLines . Add ( text . Substring ( start , i - start ));
start = i + 1 ;
}
else if ( text [ i ] == '\r' )
{
commentLines . Add ( text . Substring ( start , i - start ));
// No need to check length here, comment text has at least '#>' at the end.
if ( text [ i + 1 ] == '\n' )
{
i ++;
}
start = i + 1 ;
}
}
commentLines . Add ( text . Substring ( start , i - start ));
}
else
{
for (; i < text . Length ; i ++)
{
// Skip all leading '#' characters as it is a common convention
// to use more than one '#' character.
if ( text [ i ] != '#' )
{
break ;
}
}
commentLines . Add ( text . Substring ( i ));
}
}
/// <summary>
/// Collect the text of a section. Stop collecting the section
/// when a new directive is found (even if it is an unknown directive).
/// </summary>
/// <param name="commentLines">The comment block, as a list of lines.</param>
/// <param name="i"></param>
/// <returns>The text of the help section, with 'i' left on the last line collected.</returns>
private static string GetSection ( List < string > commentLines , ref int i )
{
bool capturing = false ;
int countLeadingWS = 0 ;
StringBuilder sb = new StringBuilder ();
const char nbsp = ( char ) 0xA0 ;
for ( i ++; i < commentLines . Count ; i ++)
{
string line = commentLines [ i ];
if (! capturing && Regex . IsMatch ( line , blankline ))
{
// Skip blank lines before capturing anything in the section.
continue ;
}
if ( Regex . IsMatch ( line , directive ))
{
// Break on any directive even if we haven't started capturing.
i --;
break ;
}
// The first line of a section sets how much whitespace we'll ignore (and hence strip).
if (! capturing )
{
int j = 0 ;
while ( j < line . Length && ( line [ j ] == ' ' || line [ j ] == '\t' || line [ j ] == nbsp ))
{
countLeadingWS ++;
j ++;
}
}
capturing = true ;
// Skip leading whitespace based on the first line in the section, skipping
// only as much whitespace as the first line had, no more (and possibly less.)
int start = 0 ;
while ( start < line . Length && start < countLeadingWS &&
( line [ start ] == ' ' || line [ start ] == '\t' || line [ start ] == nbsp ))
{
start ++;
}
sb . Append ( line . Substring ( start ));
sb . Append ( '\n' );
}
return sb . ToString ();
}
internal string GetHelpFile ( CommandInfo commandInfo )
{
if ( _sections . MamlHelpFile == null )
{
return null ;
}
string helpFileToLoad = _sections . MamlHelpFile ;
Collection < String > searchPaths = new Collection < String >();
string scriptFile = (( IScriptCommandInfo ) commandInfo ). ScriptBlock . File ;
if (! string . IsNullOrEmpty ( scriptFile ))
{
helpFileToLoad = Path . Combine ( Path . GetDirectoryName ( scriptFile ), _sections . MamlHelpFile );
}
else if ( commandInfo . Module != null )
{
helpFileToLoad = Path . Combine ( Path . GetDirectoryName ( commandInfo . Module . Path ), _sections . MamlHelpFile );
}
string location = MUIFileSearcher . LocateFile ( helpFileToLoad , searchPaths );
return location ;
}
internal RemoteHelpInfo GetRemoteHelpInfo ( ExecutionContext context , CommandInfo commandInfo )
{
if ( string . IsNullOrEmpty ( _sections . ForwardHelpTargetName ) || string . IsNullOrEmpty ( _sections . RemoteHelpRunspace ))
{
return null ;
}
// get the PSSession object from the variable specified in the comments
IScriptCommandInfo scriptCommandInfo = ( IScriptCommandInfo ) commandInfo ;
SessionState sessionState = scriptCommandInfo . ScriptBlock . SessionState ;
object runspaceInfoAsObject = sessionState . PSVariable . GetValue ( _sections . RemoteHelpRunspace );
PSSession runspaceInfo ;
2016-07-29 13:02:49 -07:00
if ( runspaceInfoAsObject == null ||
2016-03-30 14:20:52 -07:00
! LanguagePrimitives . TryConvertTo ( runspaceInfoAsObject , out runspaceInfo ))
{
string errorMessage = HelpErrors . RemoteRunspaceNotAvailable ;
throw new InvalidOperationException ( errorMessage );
}
return new RemoteHelpInfo (
2016-07-29 13:02:49 -07:00
context ,
2016-03-30 14:20:52 -07:00
( RemoteRunspace ) runspaceInfo . Runspace ,
commandInfo . Name ,
2016-07-29 13:02:49 -07:00
_sections . ForwardHelpTargetName ,
2016-03-30 14:20:52 -07:00
_sections . ForwardHelpCategory ,
commandInfo . HelpCategory );
}
/// <summary>
/// Look for special comments indicating the comment block is meant
/// to be used for help.
/// </summary>
/// <param name="comments">The list of comments to process</param>
/// <returns>True if any special comments are found, false otherwise.</returns>
internal bool AnalyzeCommentBlock ( List < Token > comments )
{
if ( comments == null || comments . Count == 0 )
{
return false ;
}
List < string > commentLines = new List < string >();
foreach ( Token comment in comments )
{
CollectCommentText ( comment , commentLines );
}
return AnalyzeCommentBlock ( commentLines );
}
private bool AnalyzeCommentBlock ( List < string > commentLines )
{
bool directiveFound = false ;
for ( int i = 0 ; i < commentLines . Count ; i ++)
{
Match match = Regex . Match ( commentLines [ i ], directive );
if ( match . Success )
{
directiveFound = true ;
if ( match . Groups [ 3 ]. Success )
{
switch ( match . Groups [ 1 ]. Value . ToUpperInvariant ())
{
case "PARAMETER" :
{
string param = match . Groups [ 3 ]. Value . ToUpperInvariant (). Trim ();
string section = GetSection ( commentLines , ref i );
2016-07-29 13:02:49 -07:00
if (! _parameters . ContainsKey ( param ))
{
2016-03-30 14:20:52 -07:00
_parameters . Add ( param , section );
}
break ;
}
case "FORWARDHELPTARGETNAME" :
_sections . ForwardHelpTargetName = match . Groups [ 3 ]. Value . Trim ();
break ;
case "FORWARDHELPCATEGORY" :
_sections . ForwardHelpCategory = match . Groups [ 3 ]. Value . Trim ();
break ;
case "REMOTEHELPRUNSPACE" :
_sections . RemoteHelpRunspace = match . Groups [ 3 ]. Value . Trim ();
break ;
case "EXTERNALHELP" :
_sections . MamlHelpFile = match . Groups [ 3 ]. Value . Trim ();
isExternalHelpSet = true ;
break ;
default :
return false ;
}
}
else
{
switch ( match . Groups [ 1 ]. Value . ToUpperInvariant ())
{
case "SYNOPSIS" :
_sections . Synopsis = GetSection ( commentLines , ref i );
break ;
case "DESCRIPTION" :
_sections . Description = GetSection ( commentLines , ref i );
break ;
case "NOTES" :
_sections . Notes = GetSection ( commentLines , ref i );
break ;
case "LINK" :
_links . Add ( GetSection ( commentLines , ref i ). Trim ());
break ;
case "EXAMPLE" :
_examples . Add ( GetSection ( commentLines , ref i ));
break ;
case "INPUTS" :
_inputs . Add ( GetSection ( commentLines , ref i ));
break ;
case "OUTPUTS" :
_outputs . Add ( GetSection ( commentLines , ref i ));
break ;
case "COMPONENT" :
_sections . Component = GetSection ( commentLines , ref i ). Trim ();
break ;
case "ROLE" :
_sections . Role = GetSection ( commentLines , ref i ). Trim ();
break ;
case "FUNCTIONALITY" :
_sections . Functionality = GetSection ( commentLines , ref i ). Trim ();
break ;
default :
return false ;
}
}
}
2016-07-29 13:02:49 -07:00
else if (! Regex . IsMatch ( commentLines [ i ], blankline ))
2016-03-30 14:20:52 -07:00
{
return false ;
}
}
_sections . Examples = new ReadOnlyCollection < string >( _examples );
_sections . Inputs = new ReadOnlyCollection < string >( _inputs );
_sections . Outputs = new ReadOnlyCollection < string >( _outputs );
_sections . Links = new ReadOnlyCollection < string >( _links );
// TODO, Changing this to an IDictionary because ReadOnlyDictionary is available only in .NET 4.5
// This is a temporary workaround and will be fixed later. Tracked by Win8: 354135
_sections . Parameters = new Dictionary < string , string >( _parameters );
return directiveFound ;
}
/// <summary>
/// The analysis of the comments finds the component, functionality, and role fields, but
/// those fields aren't added to the xml because they aren't children of the command xml
/// node, they are under a sibling of the command xml node and apply to all command nodes
/// in a maml file.
/// </summary>
/// <param name="helpInfo">The helpInfo object to set the fields on.</param>
internal void SetAdditionalData ( MamlCommandHelpInfo helpInfo )
{
helpInfo . SetAdditionalDataFromHelpComment (
_sections . Component ,
_sections . Functionality ,
_sections . Role );
}
2016-07-29 13:02:49 -07:00
2016-03-30 14:20:52 -07:00
internal static CommentHelpInfo GetHelpContents ( List < Language . Token > comments , List < string > parameterDescriptions )
{
HelpCommentsParser helpCommentsParser = new HelpCommentsParser ( parameterDescriptions );
helpCommentsParser . AnalyzeCommentBlock ( comments );
return helpCommentsParser . _sections ;
}
internal static HelpInfo CreateFromComments ( ExecutionContext context ,
CommandInfo commandInfo ,
List < Language . Token > comments ,
List < string > parameterDescriptions ,
bool dontSearchOnRemoteComputer ,
out string helpFile , out string helpUriFromDotLink )
{
HelpCommentsParser helpCommentsParser = new HelpCommentsParser ( commandInfo , parameterDescriptions );
helpCommentsParser . AnalyzeCommentBlock ( comments );
if ( helpCommentsParser . _sections . Links != null && helpCommentsParser . _sections . Links . Count != 0 )
{
helpUriFromDotLink = helpCommentsParser . _sections . Links [ 0 ];
}
else
{
helpUriFromDotLink = null ;
}
helpFile = helpCommentsParser . GetHelpFile ( commandInfo );
// If only .ExternalHelp is defined and the help file is not found, then we
// use the metadata driven help
if ( comments . Count == 1 && helpCommentsParser . isExternalHelpSet && helpFile == null )
{
return null ;
}
return CreateFromComments ( context , commandInfo , helpCommentsParser , dontSearchOnRemoteComputer );
}
internal static HelpInfo CreateFromComments ( ExecutionContext context , CommandInfo commandInfo , HelpCommentsParser helpCommentsParser ,
bool dontSearchOnRemoteComputer )
{
if (! dontSearchOnRemoteComputer )
{
RemoteHelpInfo remoteHelpInfo = helpCommentsParser . GetRemoteHelpInfo ( context , commandInfo );
if ( remoteHelpInfo != null )
{
// Add HelpUri if necessary
if ( remoteHelpInfo . GetUriForOnlineHelp () == null )
{
DefaultCommandHelpObjectBuilder . AddRelatedLinksProperties ( remoteHelpInfo . FullHelp ,
commandInfo . CommandMetadata . HelpUri );
}
return remoteHelpInfo ;
}
}
XmlDocument doc = helpCommentsParser . BuildXmlFromComments ();
HelpCategory helpCategory = commandInfo . HelpCategory ;
MamlCommandHelpInfo localHelpInfo = MamlCommandHelpInfo . Load ( doc . DocumentElement , helpCategory );
if ( localHelpInfo != null )
{
helpCommentsParser . SetAdditionalData ( localHelpInfo );
if (! string . IsNullOrEmpty ( helpCommentsParser . _sections . ForwardHelpTargetName )
|| ! string . IsNullOrEmpty ( helpCommentsParser . _sections . ForwardHelpCategory ))
{
if ( string . IsNullOrEmpty ( helpCommentsParser . _sections . ForwardHelpTargetName ))
{
localHelpInfo . ForwardTarget = localHelpInfo . Name ;
}
else
{
localHelpInfo . ForwardTarget = helpCommentsParser . _sections . ForwardHelpTargetName ;
}
if (! string . IsNullOrEmpty ( helpCommentsParser . _sections . ForwardHelpCategory ))
{
try
{
localHelpInfo . ForwardHelpCategory = ( HelpCategory ) Enum . Parse ( typeof ( HelpCategory ), helpCommentsParser . _sections . ForwardHelpCategory , true );
}
catch ( System . ArgumentException )
{
// Ignore conversion errors.
}
}
else
{
localHelpInfo . ForwardHelpCategory = ( HelpCategory . Alias |
HelpCategory . Cmdlet |
HelpCategory . ExternalScript |
HelpCategory . Filter |
HelpCategory . Function |
HelpCategory . ScriptCommand |
HelpCategory . Workflow );
}
}
WorkflowInfo workflowInfo = commandInfo as WorkflowInfo ;
if ( workflowInfo != null )
{
bool common = DefaultCommandHelpObjectBuilder . HasCommonParameters ( commandInfo . Parameters );
bool commonWorkflow = (( commandInfo . CommandType & CommandTypes . Workflow ) ==
CommandTypes . Workflow );
localHelpInfo . FullHelp . Properties . Add ( new PSNoteProperty ( "CommonParameters" , common ));
localHelpInfo . FullHelp . Properties . Add ( new PSNoteProperty ( "WorkflowCommonParameters" , commonWorkflow ));
2016-07-29 13:02:49 -07:00
DefaultCommandHelpObjectBuilder . AddDetailsProperties ( obj : localHelpInfo . FullHelp , name : workflowInfo . Name ,
noun : workflowInfo . Noun , verb : workflowInfo . Verb ,
2016-03-30 14:20:52 -07:00
typeNameForHelp : "MamlCommandHelpInfo" , synopsis : localHelpInfo . Synopsis );
DefaultCommandHelpObjectBuilder . AddSyntaxProperties ( localHelpInfo . FullHelp , workflowInfo . Name ,
workflowInfo . ParameterSets , common , commonWorkflow , "MamlCommandHelpInfo" );
}
2016-07-29 13:02:49 -07:00
2017-01-16 13:31:14 -08:00
// Add HelpUri if necessary
2016-07-29 13:02:49 -07:00
if ( localHelpInfo . GetUriForOnlineHelp () == null )
2016-03-30 14:20:52 -07:00
{
DefaultCommandHelpObjectBuilder . AddRelatedLinksProperties ( localHelpInfo . FullHelp , commandInfo . CommandMetadata . HelpUri );
}
}
return localHelpInfo ;
}
/// <summary>
/// Analyze a block of comments to determine if it is a special help block.
/// </summary>
/// <param name="commentBlock">The block of comments to analyze.</param>
/// <returns>true if the block is our special comment block for help, false otherwise.</returns>
internal static bool IsCommentHelpText ( List < Token > commentBlock )
{
if (( commentBlock == null ) || ( commentBlock . Count == 0 ))
return false ;
HelpCommentsParser generator = new HelpCommentsParser ();
return generator . AnalyzeCommentBlock ( commentBlock );
}
#region Collect comments from AST
private static List < Language . Token > GetCommentBlock ( Language . Token [] tokens , ref int startIndex )
{
var result = new List < Language . Token >();
// Any whitespace between the token and the first comment is allowed.
int nextMaxStartLine = Int32 . MaxValue ;
for ( int i = startIndex ; i < tokens . Length ; i ++)
{
Language . Token current = tokens [ i ];
// If the current token starts on a line beyond the current "chunk",
// then we're done scanning.
if ( current . Extent . StartLineNumber > nextMaxStartLine )
{
startIndex = i ;
break ;
}
if ( current . Kind == TokenKind . Comment )
{
result . Add ( current );
// The next comment must be on either the same line as this comment ends, or
// the next line, but nowhere else, otherwise it's not in the same "chunk".
nextMaxStartLine = current . Extent . EndLineNumber + 1 ;
}
else if ( current . Kind != TokenKind . NewLine )
{
// A non-comment, non-position token means we are no longer collecting comments
startIndex = i ;
break ;
}
}
return result ;
}
private static List < Language . Token > GetPrecedingCommentBlock ( Language . Token [] tokens , int tokenIndex , int proximity )
{
var result = new List < Language . Token >();
int minEndLine = tokens [ tokenIndex ]. Extent . StartLineNumber - proximity ;
for ( int i = tokenIndex - 1 ; i >= 0 ; i --)
{
Language . Token current = tokens [ i ];
if ( current . Extent . EndLineNumber < minEndLine )
break ;
if ( current . Kind == TokenKind . Comment )
{
result . Add ( current );
minEndLine = current . Extent . StartLineNumber - 1 ;
}
else if ( current . Kind != TokenKind . NewLine )
{
break ;
}
}
result . Reverse ();
return result ;
}
private static int FirstTokenInExtent ( Language . Token [] tokens , IScriptExtent extent , int startIndex = 0 )
{
int index ;
for ( index = startIndex ; index < tokens . Length ; ++ index )
{
if (! tokens [ index ]. Extent . IsBefore ( extent ))
{
break ;
}
}
return index ;
}
private static int LastTokenInExtent ( Language . Token [] tokens , IScriptExtent extent , int startIndex )
{
int index ;
for ( index = startIndex ; index < tokens . Length ; ++ index )
{
if ( tokens [ index ]. Extent . IsAfter ( extent ))
{
break ;
}
}
return index - 1 ;
}
internal const int CommentBlockProximity = 2 ;
private static List < string > GetParameterComments ( Language . Token [] tokens , IParameterMetadataProvider ipmp , int startIndex )
{
var result = new List < string >();
var parameters = ipmp . Parameters ;
if ( parameters == null || parameters . Count == 0 )
{
return result ;
}
foreach ( var parameter in parameters )
{
var commentLines = new List < string >();
var firstToken = FirstTokenInExtent ( tokens , parameter . Extent , startIndex );
var comments = GetPrecedingCommentBlock ( tokens , firstToken , CommentBlockProximity );
if ( comments != null )
{
foreach ( var comment in comments )
{
CollectCommentText ( comment , commentLines );
}
}
var lastToken = LastTokenInExtent ( tokens , parameter . Extent , firstToken );
for ( int i = firstToken ; i < lastToken ; ++ i )
{
if ( tokens [ i ]. Kind == TokenKind . Comment )
{
CollectCommentText ( tokens [ i ], commentLines );
}
}
lastToken += 1 ;
comments = GetCommentBlock ( tokens , ref lastToken );
if ( comments != null )
{
foreach ( var comment in comments )
{
CollectCommentText ( comment , commentLines );
}
}
int n = - 1 ;
result . Add ( GetSection ( commentLines , ref n ));
}
return result ;
}
internal static Tuple < List < Language . Token >, List < string >> GetHelpCommentTokens ( IParameterMetadataProvider ipmp ,
Dictionary < Ast , Token []> scriptBlockTokenCache )
{
Diagnostics . Assert ( scriptBlockTokenCache != null , "scriptBlockTokenCache cannot be null" );
2016-07-29 13:02:49 -07:00
var ast = ( Ast ) ipmp ;
2016-03-30 14:20:52 -07:00
var rootAst = ast ;
Ast configAst = null ;
while ( rootAst . Parent != null )
{
rootAst = rootAst . Parent ;
if ( rootAst is ConfigurationDefinitionAst )
{
configAst = rootAst ;
}
}
// tokens saved from reparsing the script.
Language . Token [] tokens = null ;
scriptBlockTokenCache . TryGetValue ( rootAst , out tokens );
if ( tokens == null )
{
ParseError [] errors ;
// storing all comment tokens
Language . Parser . ParseInput ( rootAst . Extent . Text , out tokens , out errors );
scriptBlockTokenCache [ rootAst ] = tokens ;
}
int savedStartIndex ;
int startTokenIndex ;
int lastTokenIndex ;
var funcDefnAst = ast as FunctionDefinitionAst ;
List < Language . Token > commentBlock ;
if ( funcDefnAst != null || configAst != null )
{
// The first comment block preceding the function or configuration keyword is a candidate help comment block.
var funcOrConfigTokenIndex =
savedStartIndex = FirstTokenInExtent ( tokens , configAst == null ? ast . Extent : configAst . Extent );
commentBlock = GetPrecedingCommentBlock ( tokens , funcOrConfigTokenIndex , CommentBlockProximity );
if ( HelpCommentsParser . IsCommentHelpText ( commentBlock ))
{
return Tuple . Create ( commentBlock , GetParameterComments ( tokens , ipmp , savedStartIndex ));
}
// comment block is behind function definition
2016-08-28 05:48:06 +00:00
// we don't support it for configuration declaration as this style is rarely used
2016-03-30 14:20:52 -07:00
if ( funcDefnAst != null )
{
startTokenIndex =
FirstTokenInExtent ( tokens , funcDefnAst . Body . Extent ) + 1 ;
lastTokenIndex = LastTokenInExtent ( tokens , ast . Extent , funcOrConfigTokenIndex );
Diagnostics . Assert ( tokens [ startTokenIndex - 1 ]. Kind == TokenKind . LCurly ,
"Unexpected first token in function" );
Diagnostics . Assert ( tokens [ lastTokenIndex ]. Kind == TokenKind . RCurly ,
"Unexpected last token in function" );
}
else
{
return null ;
}
}
else if ( ast == rootAst )
{
startTokenIndex = savedStartIndex = 0 ;
lastTokenIndex = tokens . Length - 1 ;
}
else
{
// This case should be rare (but common with implicit remoting).
// We have a script block that was used to generate a function like:
// $sb = { }
// set-item function:foo $sb
// help foo
startTokenIndex = savedStartIndex = FirstTokenInExtent ( tokens , ast . Extent ) - 1 ;
lastTokenIndex = LastTokenInExtent ( tokens , ast . Extent , startTokenIndex );
Diagnostics . Assert ( tokens [ startTokenIndex + 1 ]. Kind == TokenKind . LCurly ,
"Unexpected first token in script block" );
Diagnostics . Assert ( tokens [ lastTokenIndex ]. Kind == TokenKind . RCurly ,
"Unexpected last token in script block" );
}
while ( true )
{
commentBlock = GetCommentBlock ( tokens , ref startTokenIndex );
if ( commentBlock . Count == 0 )
break ;
if (! HelpCommentsParser . IsCommentHelpText ( commentBlock ))
continue ;
if ( ast == rootAst )
{
// One more check - make sure the comment doesn't belong to the first function in the script.
var endBlock = (( ScriptBlockAst ) ast ). EndBlock ;
if ( endBlock == null || ! endBlock . Unnamed )
{
return Tuple . Create ( commentBlock , GetParameterComments ( tokens , ipmp , savedStartIndex ));
}
var firstStatement = endBlock . Statements . FirstOrDefault ();
if ( firstStatement is FunctionDefinitionAst )
{
int linesBetween = firstStatement . Extent . StartLineNumber -
commentBlock . Last (). Extent . EndLineNumber ;
if ( linesBetween > CommentBlockProximity )
{
return Tuple . Create ( commentBlock , GetParameterComments ( tokens , ipmp , savedStartIndex ));
}
break ;
}
}
return Tuple . Create ( commentBlock , GetParameterComments ( tokens , ipmp , savedStartIndex ));
}
commentBlock = GetPrecedingCommentBlock ( tokens , lastTokenIndex , tokens [ lastTokenIndex ]. Extent . StartLineNumber );
if ( HelpCommentsParser . IsCommentHelpText ( commentBlock ))
{
return Tuple . Create ( commentBlock , GetParameterComments ( tokens , ipmp , savedStartIndex ));
}
return null ;
}
#endregion Collect comments from AST
2016-07-29 13:02:49 -07:00
}
2016-03-30 14:20:52 -07:00
}