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 = 2)

    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"
    }
}