Hey, Scripting Guy!Creating A Self-Documenting Script

The Microsoft Scripting Guys

To describe the incredible depth of blue brushed broadly across the sky down here in Buenos Aires, special color names must be used. Cobalt blue, deep-sea blue, midnight blue, holy-mackerel blue—none of these even begin to portray the actual color seen in the canopy covering the old city. Wide sidewalks lined by centuries-old buildings are dotted with little tables. I sit in an empty metal chair. A perfumed breeze drifts lazily by carrying hints of cinnamon, cardamom, and thyme. Seagulls with wings outstretched surf the updrafts with enough skill, grace, and poise to engender envy in the Big Kahuna himself. The café con leche seems especially apropos and is strong enough to demand its own attention against the competition from the sights and sounds of downtown Buenos Aires. I am in town to talk to Microsoft Premier Customers about using VBScript to help manage their networks. I extract a folded map from my camera bag and proceed to plan my route from Calle Florida to the Obelisk on Nueve de Julio Avenue.

I find my location on the map. I am near the Galerias Pacifico shopping gallery. I turn the map over several times as I attempt to orient it with my location in time and space. About this time, a tall, distinguished-looking man in a three-piece dark suit appears and stands just out of the sunlight. His shadow, dancing playfully on the map, causes me to glance up. I stand as he says, "Buenos dias, se˜nor."

"Buenos dias," I dutifully reply.

"Ah, you are an American perhaps?" he replies with a slightly British accent.

I am somewhat taken aback, and hesitantly reply in the affirmative.

"Is this your first time in our fair city?" he inquires.

Once again, I am reticent but say "yes."

"Then I may offer my humble service to you. What is it that you seek?" he politely asks.

I tell him, and he proceeds to give me directions. After that we chat for a few minutes.

"Wow!" I think to myself. "A self-documenting city! No need for maps here. The people are so friendly you barely have time to look at a map before someone is there to assist you."

That was actually a few years ago. Today, at my house in North Carolina, the weather outside is so lovely it reminds me of my visit to Buenos Aires—and of how nice a self-documenting facility can be. We can have our scripts perform a similar service for us, if we take the time to plan ahead. In this article, we will see how to develop a script that will gather comments from other scripts that follow a certain pattern. We can modify the pattern to meet our own needs and can expand the technique beyond creating documentation for scripts.

Let's take a look at a script that has some documentation carefully placed in it. In the GetSetIEStartPage.ps1 script in Figure 1 (which was written for the Hey, Scripting Guy! article, "How Can I Change My Internet Explorer Home Page?", comments are placed in here-strings that are then assigned to a variable named $Comment. A here-string is delineated by a beginning tag of an at sign and a quotation mark (@" ) and a closing tag of a quotation mark and an at sign ( "@). There are three comment blocks we are interested in obtaining. The first contains normal script header information: the title, author, date, keywords, and comments on the script itself. The second and third comment blocks are specifically related to the two main functions contained in the script.

Figure 1 GetSetIEStartPage.ps1

Param([switch]$get,[switch]$set,$computer="localhost")
$Comment = @"
NAME: GetSetIEStartPage.ps1
AUTHOR: ed wilson, Microsoft
DATE: 1/5/2009

KEYWORDS: stdregprov, ie, [wmiclass] type accelerator,
Hey Scripting Guy
COMMENTS: This script uses the wmiclass type accelerator
and the stdregprov to get the ie start pages and to set the
ie start pages. Using ie 7 or better you can have multiple
start pages

"@ #end comment

Function Get-ieStartPage()
{
$Comment = @"
FUNCTION: Get-ieStartPage 
Is used to retrieve the current settings for Internet Explorer 7 and greater.
The value of $hkcu is set to a constant value from the SDK that points
to the Hkey_Current_User. Two methods are used to read
from the registry because the start page is single valued and
the second start pages key is multi-valued.

"@ #end comment
 $hkcu = 2147483649
 $key = "Software\Microsoft\Internet Explorer\Main"
 $property = "Start Page"
 $property2 = "Secondary Start Pages"
 $wmi = [wmiclass]"\\$computer\root\default:stdRegProv"
 ($wmi.GetStringValue($hkcu,$key,$property)).sValue
 ($wmi.GetMultiStringValue($hkcu,$key, $property2)).sValue
} #end Get-ieStartPage

Function Set-ieStartPage()
{
$Comment = @"
FUNCTION: Set-ieStartPage 
Allows you to configure one or more home pages for IE 7 and greater. 
The $aryValues and the $Value variables hold the various home pages.
Specify the complete URL ex: "https://www.ScriptingGuys.Com" make sure
to include the quotation marks around each URL. 

"@ #end comment
  $hkcu = 2147483649
  $key = "Software\Microsoft\Internet Explorer\Main"
  $property = "Start Page"
  $property2 = "Secondary Start Pages"
  $value = "https://www.microsoft.com/technet/scriptcenter/default.mspx"
  $aryValues = "https://social.technet.microsoft.com/Forums/en/ITCG/threads/",
  "https://www.microsoft.com/technet/scriptcenter/resources/qanda/all.mspx"
  $wmi = [wmiclass]"\\$computer\root\default:stdRegProv"
  $rtn = $wmi.SetStringValue($hkcu,$key,$property,$value)
  $rtn2 = $wmi.SetMultiStringValue($hkcu,$key,$property2,$aryValues)
  "Setting $property returned $($rtn.returnvalue)"
  "Setting $property2 returned $($rtn2.returnvalue)"
} #end Set-ieStartPage

# *** entry point to script 
if($get) {Get-ieStartpage}
if($set){Set-ieStartPage}

To write the comments from the source file to another document, we need to open the original script, search for the comments, and then write the appropriate text to a new file. Sounds simple enough. Oh, yeah, we also need a name for our new script. Let's call it GetCommentsFromScript.ps1.

The GetCommentsFromScript.ps1 script, shown in Figure 2, begins with a Param statement, which is used to allow us to provide information to the script at run time. Here is the Param statement.

Figure 2 GetCommentsFromScript.ps1

Param($Script= $(throw "The path to a script is required."))
Function Get-FileName($Script)
{
 $OutPutPath = [io.path]::GetTempPath()
 Join-Path -path $OutPutPath -child "$(Split-Path $script-leaf).txt"
} #end Get-FileName

Function Remove-OutPutFile($OutPutFile)
{
  if(Test-Path -path $OutPutFile) { Remove-    Item $OutPutFile | Out-Null }
} #end Remove-OutPutFile

Function Get-Comments($Script,$OutPutFile)
{
 Get-Content -path $Script |
 Foreach-Object `
  { 
    If($_ -match '^\$comment\s?=\s?@"')
     { 
      $beginComment = $True 
     } #end if match @"
   If($_ -match '"@')
     { 
      $beginComment = $False
     } #end if match "@
   If($beginComment -AND $_ -notmatch '@"') 
     {
      $_ | Out-File -FilePath $OutPutFile -append
     } # end if beginComment
  } #end Foreach
} #end Get-Comments

Function Get-OutPutFile($OutPutFile)
{
 Notepad $OutPutFile
} #end Get-OutPutFile

# *** Entry point to script ***
$OutPutFile = Get-FileName($script)
Remove-OutPutFile($OutPutFile)
Get-Comments -script $script -outputfile $OutPutFile
Get-OutPutFile($OutPutFile)vw

Param($Script= $(throw "The path to a script   is required."))

The advantage of using a command-line parameter is that we don't need to open the script and edit it to provide the path to the script whose comments we are going to copy. We are making this parameter mandatory by assigning a default value to the $Script variable. The default value uses the throw command to raise an error, and this means that the script will always raise an error when run, unless we supply a value for the –script parameter.

Let's digress for a minute and take a look at how the throw statement is used in the DemoThrow.ps1 script in Figure 3. To get past the error that is raised by the throw statement in the Set-Error function, we first need to set the $errorActionPreference variable to SilentlyContinue. This prevents the error from being displayed and lets the script continue. It is the same as the On Error Resume Next setting from VBScript. The If statement is used to evaluate the $value variable. If there is a match, the throw statement is encountered and the exception is thrown. To evaluate the error, we use the Get-ErrorDetails function. The first thing that takes place is the display of the error count, which will have been incremented by 1 because of the error raised by the throw statement. We then take the first error (the error with the index value of 0 is always the most recent one) and send the error object to the Format-List cmdlet. We choose all the properties. The invocation information, however, is returned as an object, and we therefore need to query that object directly. We do this by accessing the invocation object via the InvocationInfo property of the error object. The resulting error information is shown in Figure 4.

Figure 3 DemoThrow.ps1

Function Set-Error
{
 $errorActionPreference = "SilentlyContinue"
 "Before the throw statement: $($error.count) errors"
 $value = "bad"
 If ($value -eq "bad") 
   { throw "The value is bad" }
} #end Set-Error

Function Get-ErrorDetails
{
 "After the throw statement: $($error.count) errors"
 "Error details:"
 $error[0] | Format-List -Property * 
 "Invocation information:"
 $error[0].InvocationInfo
} #end Get-ErrorDetails

# *** Entry Point to Script
Set-Error

Get-ErrorDetails

fig04.gif

Figure 4 The Throw statement is used to raise an error

Now let's get back to our main script, GetCommentsFromScript.ps1. We need a function that will create a filename for the new text document that will contain all of the comments gleaned from the script. To do this, we use the function keyword and follow it with the name for the function. We will call our function Get-FileName in keeping with the Windows PowerShell verb-noun naming convention. Get-FileName will take a single input parameter, the path to the script to be analyzed, which will be held in the $Script variable inside the function. Here's the entry to the Get-FileName function:

Function Get-FileName($Script)
{

Next we obtain the path to the temporary folder on the local computer. There are many ways to do this, including using the environmental Windows PowerShell drive. However, we decided to use the static GetTempPath method from the Io.Path .NET Framework class. The GetTempPath method returns the path to the temporary folder, which is where we will store the newly created text file. We hold the temporary folder path in the $OutPutPath variable as shown here:

$OutPutPath = [io.path]::GetTempPath()

We decide to name our new text file after the name of the script. To do this, we need to separate the script name from the path the script is stored in. We use the Split-Path cmdlet to perform this surgery. The –leaf parameter tells the cmdlet to return the script name. If we had wanted the directory path that contains the script, we would have used the –parent parameter. We put the Split-Path command inside a pair of parenthesis because we want that operation to occur first, then we place a dollar sign in front of the parentheses to create a sub-expression that will execute the code and return the name of the script. We could use.ps1 as the extension for our text file, but that could be confusing because it is the extension for a script. We therefore simply add a .txt extension to the returned filename and place the whole thing in a pair of quotation marks. Now we use the Join-Path cmdlet to create a new path to our output file. The new path is made up of the temporary folder, stored in the $OutPutPath variable, and the filename we created using Split-Path. We could have used string manipulation and concatenation to create the new file path, but it is much more reliable to use Join-Path and Split-Path to perform these kinds of operations. Here's what the code looks like:

Join-Path -path $OutPutPath -child "$(Split-Path $script-leaf).txt"
} #end Get-FileName

We now need to decide how we are going to handle duplicate files. We could prompt the user by saying a duplicate file exists, using code like this:

$Response = Read-Host -Prompt "$OutPutFile already exists. Do you wish to delete it <y / n>?"
if($Response -eq "y")
    { Remove-Item $OutPutFile | Out-Null }
ELSE { "Exiting now." ; exit }

We could implement some kind of naming algorithm that makes a backup of the existing file by renaming it with a .old extension. If we did this, the code would look something like the following:

if(Test-Path -path "$OutPutFile.old") { Remove-Item-Path "$OutPutFile.old" }
Rename-Item -path $OutPutFile -newname "$(Split-Path $OutPutFile -leaf).old"

Or we could simply delete the existing file, which is what I chose to do. The action we want to perform takes place in the Remove-OutPutFile function, which we create by using the Function keyword and specifying the name of the function. We use $OutPutFile to supply the input to the function, as shown here:

Function Remove-OutPutFile($OutPutFile)
{

To determine if the file exists, we use the Test-Path cmdlet and supply the string contained in the $OutPutFile variable to the path parameter. The Test-Path cmdlet returns only a True or a False, depending on whether a file is found. This means we can use the If statement to evaluate the existence of the file. If the file is found, we perform the action in the script block. If the file is not found, the script block is not executed. You can see here that the first command does not find the file, and False is returned. In the second command, the script block is not executed because the file can't be located:

PS C:\> Test-Path c:\missingfile.txt
False
PS C:\> if(Test-Path c:\missingfile.txt){"found file"}
PS C:\>

Inside the Remove-OutPutFile function, the If statement is used to determine if the file referenced by the $OutPutFile already exists. If it does, it is deleted by using the Remove-Item cmdlet. The information that is normally returned when a file is deleted is pipelined to the Out-Null cmdlet, providing for silent operation. The code is shown here:

if(Test-Path -path $OutPutFile) { Remove-Item $OutPutFile | Out-Null }
} #end Remove-OutPutFile

After we have created the name for the output file and deleted any previous output files that may be lying around, it is time to retrieve the comments from the script. To do this, we create the Get-Comments function and pass it both the $Script variable and the $OutPutFile variable, as shown here:

Function Get-Comments($Script,$OutPutFile)
{

Now we read the text of the script using the Get-Content cmdlet, to which we pass the path to the script. When we use Get-Content to read a file, the file is read one line at a time and each line is passed along the pipeline. If we were to store the result in a variable, we would have an array. We can treat the variable $a like any other array, including obtaining the number of elements in the array via the Length property and indexing directly into the array as shown here:

PS C:\fso> $a = Get-Content -Path C:\fso\  GetSetieStartPage.ps1
PS C:\fso> $a.Length
62
PS C:\fso> $a[32]
($wmi.GetMultiStringValue($hkcu,$key,   $property2)).sValue

Here's the line that reads the input script and sends it along the pipeline:

Get-Content -path $Script |

Next we need to look inside each line to see if it belongs to the comment block. To examine each line within a pipeline, we use the ForEach-Object cmdlet, which is similar to a ForEach…Next statement in that it lets us work with an individual object from within a collection, one at a time. The back tick character (`) is used to continue the command to the next line. The action we want to perform on each object as it comes across the pipeline is contained inside a script block, which is delineated with a set of curly brackets (also called braces). This part of the Get-Content function is seen here:

ForEach-Object `
  { 

When we are inside the ForEach-Object cmdlet process block, we want to examine the line of text. To do this, we use the If statement. The $_ automatic variable is used to represent the current line that is on the pipeline. We use the –match operator to perform a regular expression pattern match against the line of text. The –match operator returns a Boolean True or False in response to the pattern The pattern begins on the right side of the –match operator. This section of the script is shown here:

PS C:\fso> '$Comment = @"' -match '^\$comment\s?=\s?@"'
True

The regular expression pattern we are using is composed of a number of special characters:

^—Match at the beginning

\—Escape character so the $ sign is treated as a literal character and not the special character used in regular expressions

$comment—Literal characters

\s?—Zero or more white space characters

=—Literal character

\s?—Zero or more white space characters

@"—Literal characters

The section of code that examines the current line of text on the pipeline is shown here:

If($_ -match '^\$comment\s?=\s?@"')

We create a variable named $beginComment that is used to mark the beginning of the comment block. If we make it past the –match statement, we have found the beginning of the comment block. We set the variable equal to $True as seen here:

{ 
  $beginComment = $True
} #end if match @"

Next we check to see if we are at the end of the comment block. To do this, we again use the –match operator. This time we look for the "@ character sequence, which is used to close a here-string. If we find it, we set the $beginComment variable to False:

If($_ -match '"@')
     { 
      $beginComment = $False
     } #end if match "@

We have made it past the first two If statements: the first one identifies the beginning of the here-string, and the second locates the end of the here-string. Now we want to grab the text to be written to our comment file. To do this, we want the $beginComment variable to be set to True. We also want to make sure that we do not see the at sign quotation mark (@" ) character on the line because it would mean the end of the here-string. To make this determination, we use a compound If statement:

If($beginComment -AND $_ -notmatch '@"') 
     {

Now we write the text to the output file. To do this, we use the $_ automatic variable, which represents the current line of text; we pipeline it to the Out-File cmdlet. The Out-File cmdlet receives the $OutPutFile variable, which contains the path to the comment file. We use the –append parameter to specify that we want to gather all the comments from the script in the comment file. If we did not use the append parameter, the text file would contain only the last comment because, by default, the Out-File cmdlet would overwrite its contents. We then close out all the curly brackets. I consider it a best practice to add a comment after each closing curly bracket that indicates the purpose of the curly bracket. This makes the script much easier to read, as well as easier to troubleshoot and to maintain:

$_ | Out-File -FilePath $OutPutFile -append
     } # end if beginComment
  } #end ForEach
} #end Get-Comments

We now create a function called Get-OutPutFile that will open the output file for us to read. Because the temporary folder is not easy to find and we had the path to the file in the $OutPutFile variable, it makes sense to use the script to open the output file. The Get-OutPutFile function receives a single input variable called $OutPutFile, which contains the path to the comment file we wish to open. When we call the Get-OutPutFile function, we will pass $OutPutFile to it. We could pass any value we wish to the Get-OutPutFile function, and inside the function the value would be referred to by the $OutPutFile variable. We can even pass a string directly (without using quotation marks around the string) to the function:

Function Get-OutPutFile($OutPutFile)
{
 Notepad $OutPutFile
} #end Get-OutPutFile

Get-OutPutFile -outputfile C:\fso\GetSetieStartPage.ps1

In general, when writing a script, if you are going to gather up something to pass to a function, it is a good idea to encase the data in the same variable name that will be used both inside and outside the function. This follows one of our best practices for script development: "Don't mess with the worker section of the script." In this example, when we call the function we are "doing work." To change this in future scripts would require that we edit the string literal value. By placing the string in a variable, we can easily edit the value of the variable. We are, in fact, set up to provide the value of the variable via the command line or by means of something done in another function. Whenever possible, you should avoid placing string literal values directly in the script. In the code that follows, we use a variable to hold the path to the file that will be passed to the Get-OutPutFile function:

Function Get-OutPutFile($OutPutFile)
{
 Notepad $OutPutFile
} #end Get-OutPutFile

$OutPutFile = "C:\fso\GetSetieStartPage.ps1"
Get-OutPutFile -outputfile $OutPutFile

The complete Get-OutPutFile function is shown here:

Function Get-OutPutFile($OutPutFile)
{
 Notepad $OutPutFile
} #end Get-OutPutFile

Instead of typing in a string literal for the path to the output file, the $OutPutFile variable receives the path that is created by the Get-FileName function. The Get-FileName function receives the path to the script that contains the comments to be extracted. The path to this script comes in via the command-line parameter. When a function has a single input parameter, you can pass it to the function by using a set of parentheses. If, on the other hand, the function uses two or more input parameters, you must use the –parameter name syntax:

$OutPutFile = Get-FileName($script)

Next we call the Remove-OutPutFile function (discussed earlier) and pass it the path to the OutPutFile that is contained in the $OutPutFile variable:

Remove-OutPutFile($OutPutFile)

When we are assured of the name of our output file, we call the Get-Comments function to retrieve comments from the script whose path is indicated by the $script variable. The comments will be written to the output file referenced by the $OutPutFile variable. This line of code is seen here:

Get-Comments -script $script -outputfile $OutPutFile

When the comments have all been written to the output file, we finally call the Get-OutPutFile function and pass it the path contained in the $OutPutFile variable. If you don't want the comment file to be opened, you can easily comment the line out of your script, or simply delete it and the Get-OutPutFile function itself from your script. If you are interested in reviewing each file before saving it, leave the line of code in place:

Get-OutPutFile($OutPutFile)

When the GetCommentsFromScript.ps1 script runs, no confirmation message is shown on your screen. The only confirmation that the script worked is the presence of the newly created text file displayed in Notepad, as shown in Figure 5.

fig05.gif

Figure 5 The New Text File Displayed in Notepad

The GetCommentsFromScript.ps1 script can be easily adapted to your own way of writing scripts or even for gathering other kinds of documentation from text-based log files. All you need to do is modify the regular expression pattern that is used to mark the beginning and the end of the portions of text you are interested in collecting. We hope you enjoy the script, and invite you to join us on the Script Center, where we publish a new Hey, Scripting Guy! article every weekday.

Ed Wilson, a well-known scripting expert, is the author of eight books, including Windows PowerShell Scripting Guide (Microsoft Press, 2008) and Microsoft Windows PowerShell Step by Step (Microsoft Press, 2007). Ed holds more than 20 industry certifications, including Microsoft Certified Systems Engineer (MCSE) and Certified Information Systems Security Professional (CISSP). In his spare time, he enjoys woodworking, underwater photography, and scuba diving. And tea.

Craig Liebendorfer is a wordsmith and longtime Microsoft Web editor. Craig still can't believe there's a job that pays him to work with words every day. One of his favorite things is irreverent humor, so he should fit right in here. He considers his greatest accomplishment in life to be his magnificent daughter.