Get-ScriptStory.ps1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 |
function Get-ScriptStory { <# .Synopsis Gets a Script's story .Description Gets the Script's "Story" Script Stories are a simple markdown summary of all single-line comments within a script (aside from those in the param block). .Example Get-Command Get-ScriptStory | Get-ScriptStory .Notes #> [CmdletBinding(DefaultParameterSetName='ScriptBlock')] param( # A script block [Parameter(Mandatory=$true,Position=0,ValueFromPipeline=$true,ParameterSetName='ScriptBlock')] [ScriptBlock] $ScriptBlock, # A block of text [Parameter(Mandatory=$true,Position=0,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,ParameterSetName='ScriptText')] [Alias('ScriptContents', 'Definition')] [string] $Text, # The friendly names of code regions or begin,process, or end blocks. [Collections.IDictionary] $RegionName = @{ begin = "Before any input" process = "On Each Input" end = "After all input" }, [int] $HeadingSize = 3) process { function foo($x, $y) { # Documentation should be ignored } # First, we want to convert any text input to -ScriptBlock. if ($PSCmdlet.ParameterSetName -eq 'ScriptText') { $ScriptBlock = [ScriptBlock]::Create($Text) } # Next, we tokenize the script and force it into an array. $tokens = @([Management.Automation.PSParser]::Tokenize("$ScriptBlock", [ref]$null)) # We need to keep track of how many levels of regions we're in, so create a $RegionStack. $regionStack = [Collections.Stack]::new() # We'll also want to make a StringBuilder (because it will be faster). $sb= [text.stringbuilder]::new() # Last but not least, we'll want to keep track of a block depth, so initialize that to zero. $blockDepth = 0 #region Walk Thru Tokens for ($i =0; $i -lt $tokens.Length; $i++) { # As we pass GroupStarts and GroupEnds, nudge the block depth. if ($tokens[$i].Type -eq 'GroupStart') { $blockDepth++ } if ($tokens[$i].Type -eq 'GroupEnd') { $blockDepth-- } #region Handle natural regions # In addition to any regions specified in documentation, # we can treat the begin, process, and end blocks as effective regions. if ($tokens[$i].Type -eq 'keyword' -and 'begin', 'process', 'end' -contains $tokens[$i].content -and $blockDepth -le 1 ) { # When we encounter one of these regions, pop the region stack if ($regionStack.Count) { $null = $regionStack.Pop() } # and push the current region. $null =$regionStack.Push($tokens[$i].Content) # Generate the header, which consists of: $keywordHeader = # a newline, [Environment]::NewLine + # N Markdown headers, ('#' * ([Math]::Min(6, $regionStack.Count + $HeadingSize - 1))) + ' ' + # the friendly name for the region (or just it's content), $(if ($RegionName[$tokens[$i].Content]) { $RegionName[$tokens[$i].Content] } else { $tokens[$i].Content }) + # and another newline. [Environment]::NewLine # Then, append the header. $null = $sb.Append($keywordHeader) continue } #endregion Handle natural regions #region Skip Parameter Block # We don't want all of the documentation. # Specifically, we want to avoid any parameter documentation and nested functions. # To do this, we need to notice the param and function keyword when it shows up. if ($tokens[$i].Type -eq 'keyword' -and 'param', 'function' -contains $tokens[$i].Content) { # Once we've found it, we advance until we find the next GroupStart. $j = $i + 1 while ($tokens[$j].Type -ne 'GroupStart') { $j++ } $skipGroupCount = 1 if ($tokens[$j].Content -eq '(' -and # If the GroupStart was an open paranthesis $tokens[$i].Content -eq 'function'# and we're dealing with a nested function, ) { $skipGroupCount = 2 # we're going to need to this next bit twice. } foreach ($n in 1..$skipGroupCount) { # Look for the GroupStart. while ($tokens[$j].Type -ne 'GroupStart') { $j++ } # Then we set a variable to track depth $depth = 0 do { # and walk thru the tokens if ($tokens[$j].Type -eq 'GroupStart') { $depth++ } if ($tokens[$j].Type -eq 'GroupEnd') { $depth-- } $j++ } while ($depth -and $tokens[$j]) # until the depth is 0 again. } $i = $j # Finally we set the iterator to current position (thus skipping the param block). } #endregion Skip Parameter Block #region Check for Paragraph Breaks # Next we need to check for paragraph breaks. if ($i -ge 2 -and $tokens[$i].Type -eq 'Newline' -and # If the current token is a newline, $tokens[$i -1].Type -eq 'Newline') # and the token before that was also a newline, { # then it's probably a paragraph break if ($i -ge 3 -and $tokens[$i - 2].Type -eq 'GroupEnd') { # (Unless it followed a GroupEnd). continue } # When we encounter a paragraph break, output two newlines. $null = $sb.Append([Environment]::NewLine * 2) } #endregion Check for Paragraph Breaks #region Process Comments # At this point, we don't care about anything other than comments. # So if it's not a comment, continue past them. if ($tokens[$i].Type -ne 'Comment') { continue } $Comment = $tokens[$i].Content.Trim([Environment]::NewLine).Trim() if ($Comment.StartsWith('<')) { # If it's a block comment, # make sure it's not a special-purpose block comment (like inline help). $trimmedComment = $comment.Trim('<#').Trim([Environment]::NewLine).Trim() if ('?', '.', '{','-','|' -contains $trimmedComment[0]) { # If it was, continue # continue on. } # If it wasn't, trim the block comment and newlines. $Comment = $Comment.Trim().Trim("><#").Trim([Environment]::NewLine) } # We'll need to know if it's a region # so we'll use some fancy Regex to extract it's name # (and if it's an EndRegion or not). if ($Comment.Trim() -match '#(?<IsEnd>end){0,1}region(?<RegionName>.{1,})') { $thisRegionName = $Matches.RegionName.Trim() if ($Matches.IsEnd) { # If it was an EndRegion, pop it off of the Region Stack. $null = $regionStack.Pop() } else { # If it wasn't, push it onto the Region Stack. $null = $regionStack.Push($thisRegionName) # Then, output it's name a markdown header, # using the count of RegionStack to determine H1, H2, etc. $regionContent = [Environment]::NewLine + ('#' * ([Math]::Min(6, $regionStack.Count + $HeadingSize - 1))) + ' '+ $(if ($RegionName[$thisRegionName]) { $RegionName[$thisRegionName] } else { $Matches.RegionName.Trim() }) + [Environment]::NewLine $null = $sb.Append($regionContent) } # We still don't want the region name to become part of the story, # so continue to the next token. continue } # Whatever comment is left is new story content. $newStory = $Comment.TrimStart('#').Trim() # If there's any content already, if ($sb.Length) { # before we put it into the string, $null = if ($sb[-1] -eq '.') { # add a double space (after a period), $sb.Append(' ') } else { # or a single space. $sb.Append(' ') } } $shouldHaveNewline = $newStory.StartsWith('*') -or $newStory.StartsWith('-') -or ($lastStory -and ($lastStory.StartsWith('*') -or $lastStory.StartsWith('-'))) if ($shouldHaveNewline) { $null = $sb.Append([Environment]::NewLine) } # Finally, append the new story content. $null = $sb.Append($newStory) #endregion Process Comments } #endregion Walk Thru Tokens # After everything is done, output the content of the string builder. "$sb" } } |