diff --git a/textfsm/debugger.py b/textfsm/debugger.py new file mode 100644 index 0000000..2eb9997 --- /dev/null +++ b/textfsm/debugger.py @@ -0,0 +1,335 @@ +#!/usr/bin/python +# +# Copyright 2011 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" Visual Debugger + +Provides a HTML-based debugging tool that allows authors of templates +to view the behavior of templates when applied to some example CLI text. +State changes are represented with color coding such that state +transitions are clearly represented during parsing. + +Matches on lines are highlighted to show extracted values and hovering +over a match shows the value and corresponding regex that was matched. +""" +from collections import namedtuple +from textwrap import dedent + +import re + +LINE_SATURATION = 40 +LINE_LIGHTNESS = 60 +MATCH_SATURATION = 100 +MATCH_LIGHTNESS = 30 + + +class LineHistory(namedtuple('LineHistory', ['line', 'state', 'matches', 'match_index_pairs'])): + """" The match history for a given line when parsed using the FSM. + + Contains the regex match objects for that line, + which are converted to indices for highlighting + """ + + +class MatchedPair(namedtuple('MatchPair', ['match_obj', 'rule'])): + """" Stores the line history when parsed using the FSM.""" + + +class StartStopIndex(namedtuple('StartStopIndex', ['start', 'end', 'value'])): + """Represents the start and stop indices of a match for a given template value.""" + def __eq__(self, other): + return self.start == other.start and self.end == other.end + + def __gt__(self, other): + return self.start > other.start + + +class VisualDebugger(object): + """Responsible for building the parse history of a TextFSM object into a visual html doc. """ + + def __init__(self, fsm, cli_text): + self.fsm = fsm + self.cli_text = cli_text + self.state_colormap = {} + + @staticmethod + def add_prelude_boilerplate(html_file): + prelude_lines = dedent(''' + + + + + visual debugger + ''') + + html_file.write(prelude_lines) + + def build_state_colors(self): + """Basic colour wheel selection for state highlighting""" + cntr = 1 + for state_name in self.fsm.states.keys(): + self.state_colormap[state_name] = (67 * cntr) % 360 + cntr += 1 + + @staticmethod + def hsl_css(h, s, l): + """Return the CSS string for HSL background color.""" + return " background-color: hsl({},{}%,{}%);\n".format(h, s, l) + + def add_css_styling(self, html_file): + css_prelude_lines = dedent(''' + \n" + ] + + html_file.writelines(css_closing_lines) + + def merge_indexes(self, match_index_pairs): + """Merge overlapping index pairs that may occur due to multiple rule matches.""" + + def overlapping(index_a, index_b): + if index_a.end > index_b.start and index_a.start < index_b.end: + return True + if index_a.start < index_b.end and index_b.start < index_a.end: + return True + if index_a.start < index_b.start and index_a.end > index_b.end: + return True + if index_b.start < index_a.start and index_b.end > index_a.end: + return True + + def merge_pairs(index_a, index_b): + start = 0 + if index_a.start < index_b.start: + start = index_a.start + else: + start = index_b.start + if index_a.end < index_b.end: + end = index_b.end + else: + end = index_a.end + return StartStopIndex(start, end, [index_a.value, index_b.value]) + + for pair in match_index_pairs: + overlap = False + match_index_pairs.remove(pair) + for check_pair in match_index_pairs: + if overlapping(pair, check_pair): + overlap = True + match_index_pairs.remove(check_pair) + match_index_pairs.append(merge_pairs(pair, check_pair)) + break + if not overlap: + match_index_pairs.append(pair) + + def add_cli_text(self, html_file): + """Builds the HTML elements of the debug page including: + - Colored States Header Bar + - Highlighted CLI Text + """ + + cli_text_prelude = [ + "\n", + "
", + "

States:

\n" + ] + + for state in self.state_colormap.keys(): + cli_text_prelude += [ + "\n".format(state, state) + ] + + cli_text_prelude += [ + "
\n", + "\n", + "

CLI Text:

\n", + "
\n"
+    ]
+
+    html_file.writelines(cli_text_prelude)
+
+    lines = self.cli_text.splitlines()
+    lines = [line + '\n' for line in lines]
+
+    # Process each line history and add highlighting where matches occur.
+    l_count = 0
+    for line_history in self.fsm.parse_history:
+      # Only process highlights where matches occur.
+      if line_history.match_index_pairs:
+        built_line = ""
+        prev_end = 0
+        match_count = 0
+
+        for index in line_history.match_index_pairs:
+          if index.start < 0 or index.end < 0:
+            continue
+
+          # Strip out useless pattern format characters and value label.
+          # Escape chevrons in regex pattern.
+          re_patterns = []
+          values = []
+          if type(index.value) is list:
+            values = index.value
+            for v in index.value:
+              value_pattern = self.fsm.value_map[v]
+              re_patterns.append(re.sub('\?P<.*?>', '', value_pattern).replace('<', '<').replace('>', '>'))
+          else:
+            values.append(index.value)
+            value_pattern = self.fsm.value_map[index.value]
+            re_patterns.append(re.sub('\?P<.*?>', '', value_pattern).replace('<', '<').replace('>', '>'))
+
+          # Build section of match and escape non HTML chevrons if present
+          built_line += (
+              lines[l_count][prev_end:index.start].replace('<', '<').replace('>', '>')
+              + "".format(line_history.state, l_count, match_count)
+              + lines[l_count][index.start:index.end].replace('<', '<').replace('>', '>')
+              + "{} >> {}".format(', '.join(re_patterns), ', '.join(values))
+          )
+          prev_end = index.end
+          match_count += 1
+
+        built_line += lines[l_count][line_history.match_index_pairs[-1].end:].replace('<', '<').replace('>', '>')
+        lines[l_count] = built_line
+      else:
+        # Escape non HTML tag chevrons if present
+        lines[l_count] = lines[l_count].replace('<', '<').replace('>', '>')
+
+      # Add final span wrapping tag for line state color
+      lines[l_count] = ("".format(line_history.state)
+                        + lines[l_count] + "")
+      l_count += 1
+
+    # Close off document
+    end_body_end_html = dedent('''
+        
+ + + ''') + + html_file.writelines(lines) + + html_file.write(end_body_end_html) + + def build_debug_html(self): + """Calls HTML building procedures in sequence to create debug HTML doc.""" + with open("debug.html", "w+") as f: + self.add_prelude_boilerplate(f) + self.build_state_colors() + self.add_css_styling(f) + self.add_cli_text(f) diff --git a/textfsm/parser.py b/textfsm/parser.py index 801c2f3..3a61de2 100755 --- a/textfsm/parser.py +++ b/textfsm/parser.py @@ -34,6 +34,10 @@ import string import sys +from debugger import MatchedPair +from debugger import LineHistory +from debugger import VisualDebugger + class Error(Exception): """Base class for errors.""" @@ -568,6 +572,10 @@ def __init__(self, template, options_class=_DEFAULT_OPTIONS): self._cur_state = None # Name of the current state. self._cur_state_name = None + # Visual debug mode flag + self.visual_debug = False + # Keep track of parse history when in debug mode. + self.parse_history = [] # Read and parse FSM definition. # Restore the file pointer once done. @@ -923,9 +931,16 @@ def _CheckLine(self, line): Args: line: A string, the current input line. """ + line_history = None + if self.visual_debug: + line_history = LineHistory(line, self._cur_state_name, [], []) + for rule in self._cur_state: matched = self._CheckRule(rule, line) if matched: + if self.visual_debug and line_history: + line_history.matches.append(MatchedPair(matched, rule)) + for value in matched.groupdict(): self._AssignVar(matched, value) @@ -937,6 +952,9 @@ def _CheckLine(self, line): self._cur_state_name = rule.new_state break + if self.visual_debug and line_history: + self.parse_history.append(line_history) + def _CheckRule(self, rule, line): """Check a line against the given rule. @@ -1048,7 +1066,7 @@ def main(argv=None): argv = sys.argv try: - opts, args = getopt.getopt(argv[1:], 'h', ['help']) + opts, args = getopt.getopt(argv[1:], 'h', ['help', 'visual-debug']) except getopt.error as msg: raise Usage(msg) @@ -1073,7 +1091,17 @@ def main(argv=None): with open(args[1], 'r') as f: cli_input = f.read() + for opt, _ in opts: + if opt == '--visual-debug': + print("visual debug mode, open 'debug.html' for template behaviour when parsing the CLI text.") + fsm.visual_debug = True + table = fsm.ParseText(cli_input) + + if fsm.visual_debug: + debugger = VisualDebugger(fsm, cli_input) + debugger.build_debug_html() + print('FSM Table:') result = str(fsm.header) + '\n' for line in table: @@ -1094,7 +1122,7 @@ def main(argv=None): if __name__ == '__main__': - help_msg = '%s [--help] template [input_file [output_file]]\n' % sys.argv[0] + help_msg = '%s [--help] [--visual-debug] template [input_file [output_file]]\n' % sys.argv[0] try: sys.exit(main()) except Usage as err: