From ef22421eed94403c6ae45b5c5730ff0851541f93 Mon Sep 17 00:00:00 2001 From: Chris Morrow Date: Mon, 6 Jul 2026 16:51:03 -0400 Subject: [PATCH] Add Golang translation of TextFSM library An idiomatic Golang port of the Python TextFSM library has been implemented in the go/ subdirectory. This includes full translations of the core engine (textfsm), tabular data handling (texttable), CLI template index mapping (clitable), and terminal formatting utilities (terminal). All unit tests have been built as table-driven tests without t.Run() usage, adhering strictly to Go testing best practices and NetSec guidelines. A CLI tool has also been implemented under go/cmd/textfsm, and README.md has been updated with proposed usage instructions and examples. MARKDOWN=true TAG=agy CONV=b4635a52-7693-40d3-a875-79cd02047446 --- README.md | 55 ++++ go/clitable/clitable.go | 307 ++++++++++++++++++++++ go/clitable/clitable_test.go | 194 ++++++++++++++ go/cmd/textfsm/main.go | 111 ++++++++ go/cmd/textfsm/main_test.go | 82 ++++++ go/go.mod | 3 + go/terminal/terminal.go | 211 +++++++++++++++ go/terminal/terminal_test.go | 204 +++++++++++++++ go/textfsm/errors.go | 37 +++ go/textfsm/rule.go | 175 +++++++++++++ go/textfsm/textfsm.go | 398 ++++++++++++++++++++++++++++ go/textfsm/textfsm_test.go | 334 ++++++++++++++++++++++++ go/textfsm/value.go | 314 ++++++++++++++++++++++ go/texttable/texttable.go | 461 +++++++++++++++++++++++++++++++++ go/texttable/texttable_test.go | 324 +++++++++++++++++++++++ 15 files changed, 3210 insertions(+) create mode 100644 go/clitable/clitable.go create mode 100644 go/clitable/clitable_test.go create mode 100644 go/cmd/textfsm/main.go create mode 100644 go/cmd/textfsm/main_test.go create mode 100644 go/go.mod create mode 100644 go/terminal/terminal.go create mode 100644 go/terminal/terminal_test.go create mode 100644 go/textfsm/errors.go create mode 100644 go/textfsm/rule.go create mode 100644 go/textfsm/textfsm.go create mode 100644 go/textfsm/textfsm_test.go create mode 100644 go/textfsm/value.go create mode 100644 go/texttable/texttable.go create mode 100644 go/texttable/texttable_test.go diff --git a/README.md b/README.md index 6994f1e..4c6db0a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,61 @@ licence for the benefit of the wider community. [**See documentation for more details.**](https://github.com/google/textfsm/wiki/TextFSM) +Go Version (Golang) +------------------- +An idiomatic Golang port of TextFSM is available in the `go/` directory, mirroring the core functionality of the Python library (`textfsm`, `clitable`, `texttable`, and `terminal`). + +### Proposed Usage + +To use the Go implementation as a library in your project: + +```bash +go get github.com/google/textfsm/go@latest +``` + +Example usage in Go: + +```go +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/google/textfsm/go/textfsm" +) + +func main() { + tmpl := `Value Required Interface (\S+) +Value Status (up|down) + +Start + ^${Interface} is ${Status} -> Record +` + fsm, err := textfsm.NewFSM(strings.NewReader(tmpl)) + if err != nil { + panic(err) + } + + input := "GigabitEthernet0/0 is up\nGigabitEthernet0/1 is down" + records, err := fsm.ParseText(input, true) + if err != nil { + panic(err) + } + + for _, row := range records { + fmt.Printf("%v\n", row) + } +} +``` + +A CLI utility mirroring `textfsm/parser.py` is also provided under `go/cmd/textfsm`: + +```bash +go run ./go/cmd/textfsm [input_file [reference_file]] +``` + Before contributing ------------------- If you are not a Google employee, our lawyers insist that you sign a Contributor diff --git a/go/clitable/clitable.go b/go/clitable/clitable.go new file mode 100644 index 0000000..73eeb15 --- /dev/null +++ b/go/clitable/clitable.go @@ -0,0 +1,307 @@ +// Package clitable reads CLI output and parses it into tabular format using TextFSM templates and index files. +package clitable + +import ( + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + + "github.com/google/textfsm/go/textfsm" + "github.com/google/textfsm/go/texttable" +) + +var ( + completionRe = regexp.MustCompile(`\[\[.+?\]\]`) + indexCache = make(map[string]*IndexTable) + cacheLock sync.Mutex +) + +// IndexEntry represents a compiled row in the index table. +type IndexEntry struct { + Template string + Compiled map[string]*regexp.Regexp + RawValues map[string]string + RowNum int +} + +// IndexTable reads and stores template index mappings. +type IndexTable struct { + Index *texttable.Table + Entries []*IndexEntry + FilePath string +} + +// NewIndexTable reads an index file and compiles regexes for each field. +func NewIndexTable(filePath string) (*IndexTable, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open index file %s: %v", filePath, err) + } + defer f.Close() + + return NewIndexTableFromReader(f, filePath) +} + +// NewIndexTableFromReader creates an IndexTable from an io.Reader. +func NewIndexTableFromReader(r io.Reader, filePath string) (*IndexTable, error) { + tbl := texttable.NewTable() + if err := tbl.CsvToTable(r); err != nil { + return nil, fmt.Errorf("failed to parse index CSV: %v", err) + } + + hasTemplate := false + for _, col := range tbl.Header { + if col == "Template" { + hasTemplate = true + break + } + } + if !hasTemplate { + return nil, fmt.Errorf("index file does not have 'Template' column") + } + + it := &IndexTable{ + Index: tbl, + FilePath: filePath, + } + + for _, row := range tbl.Rows { + entry := &IndexEntry{ + Compiled: make(map[string]*regexp.Regexp), + RawValues: row.Map(), + RowNum: row.RowNum, + } + + for _, col := range tbl.Header { + val, _ := row.Get(col) + if col == "Command" { + val = expandCompletion(val) + } + if col == "Template" { + entry.Template = val + } else if val != "" { + re, err := regexp.Compile(val) + if err != nil { + return nil, fmt.Errorf("row %d col %s invalid regex %q: %v", row.RowNum, col, val, err) + } + entry.Compiled[col] = re + } + } + it.Entries = append(it.Entries, entry) + } + + return it, nil +} + +// GetRowMatch returns the matching IndexEntry for the given attributes. +func (it *IndexTable) GetRowMatch(attributes map[string]string) *IndexEntry { + for _, entry := range it.Entries { + match := true + for k, attrVal := range attributes { + re, ok := entry.Compiled[k] + if ok && re != nil { + loc := re.FindStringIndex(attrVal) + if loc == nil || loc[0] != 0 { + match = false + break + } + } + } + if match { + return entry + } + } + return nil +} + +func expandCompletion(val string) string { + return completionRe.ReplaceAllStringFunc(val, func(m string) string { + word := m[2 : len(m)-2] + if len(word) == 0 { + return "" + } + var sb strings.Builder + sb.WriteString("(") + for i := 0; i < len(word); i++ { + if i > 0 { + sb.WriteString("(") + } + sb.WriteByte(word[i]) + } + for i := 0; i < len(word); i++ { + sb.WriteString(")?") + } + return sb.String() + }) +} + +// CliTable reads CLI output and parses it into tabular format using an index table and TextFSM. +type CliTable struct { + *texttable.Table + Raw string + IndexFile string + TemplateDir string + Index *IndexTable + keys map[string]bool +} + +// NewCliTable creates a new CliTable. +func NewCliTable(indexFile, templateDir string) (*CliTable, error) { + ct := &CliTable{ + Table: texttable.NewTable(), + IndexFile: indexFile, + TemplateDir: templateDir, + keys: make(map[string]bool), + } + if indexFile != "" && templateDir != "" { + if err := ct.ReadIndex(indexFile); err != nil { + return nil, err + } + } + return ct, nil +} + +// ReadIndex reads and caches the template index file. +func (ct *CliTable) ReadIndex(indexFile string) error { + if indexFile != "" { + ct.IndexFile = indexFile + } + fullPath := filepath.Join(ct.TemplateDir, ct.IndexFile) + + cacheLock.Lock() + defer cacheLock.Unlock() + + if idx, ok := indexCache[fullPath]; ok { + ct.Index = idx + return nil + } + + idx, err := NewIndexTable(fullPath) + if err != nil { + return err + } + indexCache[fullPath] = idx + ct.Index = idx + return nil +} + +// ParseCmd creates a texttable of values from command input string and matching attributes. +func (ct *CliTable) ParseCmd(cmdInput string, attributes map[string]string, templates string) error { + ct.Raw = cmdInput + + if templates == "" { + if ct.Index == nil { + return fmt.Errorf("no index table loaded and no templates specified") + } + entry := ct.Index.GetRowMatch(attributes) + if entry == nil { + return fmt.Errorf("no template found for attributes: %v", attributes) + } + templates = entry.Template + } + + tmpltNames := strings.Split(templates, ":") + if len(tmpltNames) == 0 { + return fmt.Errorf("empty template list") + } + + ct.Reset() + ct.keys = make(map[string]bool) + + // Parse first template + firstFile := filepath.Join(ct.TemplateDir, tmpltNames[0]) + f, err := os.Open(firstFile) + if err != nil { + return fmt.Errorf("failed to open template %s: %v", firstFile, err) + } + defer f.Close() + + fsm, err := textfsm.NewFSM(f) + if err != nil { + return fmt.Errorf("failed to parse template %s: %v", firstFile, err) + } + + for _, k := range fsm.GetValuesByAttrib("Key") { + ct.keys[k] = true + } + + ct.SetHeader(fsm.Header) + records, err := fsm.ParseText(cmdInput, true) + if err != nil { + return err + } + + // records[0] is header, rows start at 1 + for i := 1; i < len(records); i++ { + if err := ct.Append(records[i]); err != nil { + return err + } + } + + // Parse additional templates and extend + for _, tmpltName := range tmpltNames[1:] { + nextFile := filepath.Join(ct.TemplateDir, tmpltName) + nf, err := os.Open(nextFile) + if err != nil { + return fmt.Errorf("failed to open additional template %s: %v", nextFile, err) + } + nextFSM, err := textfsm.NewFSM(nf) + nf.Close() + if err != nil { + return fmt.Errorf("failed to parse additional template %s: %v", nextFile, err) + } + + nextTable := texttable.NewTable() + nextTable.SetHeader(nextFSM.Header) + nextRecords, err := nextFSM.ParseText(cmdInput, true) + if err != nil { + return err + } + for i := 1; i < len(nextRecords); i++ { + _ = nextTable.Append(nextRecords[i]) + } + + var keyList []string + for k := range ct.keys { + keyList = append(keyList, k) + } + if err := ct.Extend(nextTable, keyList); err != nil { + return fmt.Errorf("failed to extend table with %s: %v", tmpltName, err) + } + } + + return nil +} + +// AddKeys marks additional columns as being part of the superkey. +func (ct *CliTable) AddKeys(keyList []string) error { + for _, k := range keyList { + found := false + for _, h := range ct.Header { + if h == k { + found = true + break + } + } + if !found { + return fmt.Errorf("key %q not found in table header", k) + } + ct.keys[k] = true + } + return nil +} + +// SuperKey returns the list of column names that constitute the superkey. +func (ct *CliTable) SuperKey() []string { + var sk []string + for _, h := range ct.Header { + if ct.keys[h] { + sk = append(sk, h) + } + } + return sk +} diff --git a/go/clitable/clitable_test.go b/go/clitable/clitable_test.go new file mode 100644 index 0000000..3fa25da --- /dev/null +++ b/go/clitable/clitable_test.go @@ -0,0 +1,194 @@ +package clitable + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestExpandCompletion(t *testing.T) { + tests := []struct { + name string + val string + want string + }{ + { + name: "simple completion", + val: "sh[[ow]]", + want: "sh(o(w)?)?", + }, + { + name: "no completion", + val: "show version", + want: "show version", + }, + { + name: "multiple completions", + val: "sh[[ow]] ver[[sion]]", + want: "sh(o(w)?)? ver(s(i(o(n)?)?)?)?", + }, + } + + for _, tc := range tests { + got := expandCompletion(tc.val) + if got != tc.want { + t.Errorf("[%s] expandCompletion(%q) = %q, want %q", tc.name, tc.val, got, tc.want) + } + } +} + +func TestIndexTableMatch(t *testing.T) { + indexCSV := `Template,Hostname,Vendor,Command +cisco_version.tmpl,.*,cisco,sh[[ow]] ver[[sion]] +juniper_version.tmpl,.*,juniper,show version +` + + tests := []struct { + name string + attrs map[string]string + wantTemplate string + wantMatch bool + }{ + { + name: "match cisco abbreviated command", + attrs: map[string]string{ + "Vendor": "cisco", + "Command": "sho ver", + }, + wantTemplate: "cisco_version.tmpl", + wantMatch: true, + }, + { + name: "match cisco full command", + attrs: map[string]string{ + "Vendor": "cisco", + "Command": "show version", + }, + wantTemplate: "cisco_version.tmpl", + wantMatch: true, + }, + { + name: "no match wrong vendor", + attrs: map[string]string{ + "Vendor": "hp", + "Command": "show version", + }, + wantTemplate: "", + wantMatch: false, + }, + } + + idx, err := NewIndexTableFromReader(strings.NewReader(indexCSV), "test_index") + if err != nil { + t.Fatalf("NewIndexTableFromReader() error = %v", err) + } + + for _, tc := range tests { + entry := idx.GetRowMatch(tc.attrs) + if tc.wantMatch && entry == nil { + t.Errorf("[%s] GetRowMatch(%v) returned nil, want %q", tc.name, tc.attrs, tc.wantTemplate) + continue + } + if !tc.wantMatch && entry != nil { + t.Errorf("[%s] GetRowMatch(%v) returned %q, want nil", tc.name, tc.attrs, entry.Template) + continue + } + if tc.wantMatch && entry.Template != tc.wantTemplate { + t.Errorf("[%s] Template = %q, want %q", tc.name, entry.Template, tc.wantTemplate) + } + } +} + +func TestCliTableParseCmd(t *testing.T) { + // Create temporary directory for templates and index + tmpDir := t.TempDir() + + indexContent := `Template,Hostname,Vendor,Command +test_template.tmpl,.*,cisco,sh[[ow]] ver[[sion]] +` + if err := os.WriteFile(filepath.Join(tmpDir, "index"), []byte(indexContent), 0644); err != nil { + t.Fatalf("failed to write index file: %v", err) + } + + templateContent := `Value Key Version (\d+\.\d+) +Value Uptime (\d+) + +Start + ^Version:\s+${Version},\s+uptime:\s+${Uptime} -> Record +` + if err := os.WriteFile(filepath.Join(tmpDir, "test_template.tmpl"), []byte(templateContent), 0644); err != nil { + t.Fatalf("failed to write template file: %v", err) + } + + cmdOutput := `Version: 15.2, uptime: 100 +Version: 16.1, uptime: 200` + + tests := []struct { + name string + attrs map[string]string + wantHead []string + wantRows [][]string + wantKey []string + wantErr bool + }{ + { + name: "parse valid cisco show version", + attrs: map[string]string{ + "Vendor": "cisco", + "Command": "sho ver", + }, + wantHead: []string{"Version", "Uptime"}, + wantRows: [][]string{ + {"15.2", "100"}, + {"16.1", "200"}, + }, + wantKey: []string{"Version"}, + wantErr: false, + }, + { + name: "parse invalid attribute combination", + attrs: map[string]string{ + "Vendor": "juniper", + "Command": "show version", + }, + wantErr: true, + }, + } + + for _, tc := range tests { + ct, err := NewCliTable("index", tmpDir) + if err != nil { + t.Fatalf("[%s] NewCliTable() error = %v", tc.name, err) + } + + err = ct.ParseCmd(cmdOutput, tc.attrs, "") + if (err != nil) != tc.wantErr { + t.Errorf("[%s] ParseCmd() error = %v, wantErr %v", tc.name, err, tc.wantErr) + continue + } + if tc.wantErr { + continue + } + + if !reflect.DeepEqual(ct.Header, tc.wantHead) { + t.Errorf("[%s] Header = %v, want %v", tc.name, ct.Header, tc.wantHead) + } + + if ct.Size() != len(tc.wantRows) { + t.Errorf("[%s] Size() = %d, want %d", tc.name, ct.Size(), len(tc.wantRows)) + continue + } + + for i, row := range ct.Rows { + if !reflect.DeepEqual(row.Values, tc.wantRows[i]) { + t.Errorf("[%s] Row[%d] = %v, want %v", tc.name, i, row.Values, tc.wantRows[i]) + } + } + + if !reflect.DeepEqual(ct.SuperKey(), tc.wantKey) { + t.Errorf("[%s] SuperKey() = %v, want %v", tc.name, ct.SuperKey(), tc.wantKey) + } + } +} diff --git a/go/cmd/textfsm/main.go b/go/cmd/textfsm/main.go new file mode 100644 index 0000000..4185acd --- /dev/null +++ b/go/cmd/textfsm/main.go @@ -0,0 +1,111 @@ +// Command textfsm validates templates and parses CLI text via command line. +package main + +import ( + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/google/textfsm/go/textfsm" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("textfsm", flag.ContinueOnError) + fs.SetOutput(stderr) + help := fs.Bool("help", false, "show help") + h := fs.Bool("h", false, "show help") + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(stderr, "For help use --help\n") + return 2 + } + + if *help || *h { + printHelp(stdout) + return 0 + } + + posArgs := fs.Args() + if len(posArgs) == 0 || len(posArgs) > 3 { + fmt.Fprintf(stderr, "Invalid arguments.\nFor help use --help\n") + return 2 + } + + tmplFile, err := os.Open(posArgs[0]) + if err != nil { + fmt.Fprintf(stderr, "Error opening template: %v\n", err) + return 2 + } + defer tmplFile.Close() + + fsm, err := textfsm.NewFSM(tmplFile) + if err != nil { + fmt.Fprintf(stderr, "Error parsing template: %v\n", err) + return 2 + } + + fmt.Fprintf(stdout, "FSM Template:\n%s\n", fsm.String()) + + if len(posArgs) == 1 { + return 0 + } + + inputBytes, err := os.ReadFile(posArgs[1]) + if err != nil { + fmt.Fprintf(stderr, "Error reading input file: %v\n", err) + return 2 + } + + table, err := fsm.ParseText(string(inputBytes), true) + if err != nil { + fmt.Fprintf(stderr, "Error executing FSM: %v\n", err) + return 2 + } + + fmt.Fprintf(stdout, "FSM Table:\n") + var sb strings.Builder + for _, row := range table { + sb.WriteString(formatSlice(row)) + sb.WriteString("\n") + } + resultStr := sb.String() + fmt.Fprint(stdout, resultStr) + + if len(posArgs) > 2 { + refBytes, err := os.ReadFile(posArgs[2]) + if err != nil { + fmt.Fprintf(stderr, "Error reading reference file: %v\n", err) + return 2 + } + if string(refBytes) != resultStr { + fmt.Fprintf(stdout, "Data mis-match!\n") + return 1 + } + fmt.Fprintf(stdout, "Data match!\n") + } + + return 0 +} + +func formatSlice(row []interface{}) string { + var parts []string + for _, item := range row { + if s, ok := item.(string); ok { + parts = append(parts, fmt.Sprintf("%q", s)) + } else { + parts = append(parts, fmt.Sprintf("%v", item)) + } + } + return "[" + strings.Join(parts, ", ") + "]" +} + +func printHelp(w io.Writer) { + fmt.Fprintf(w, "textfsm [--help] template [input_file [output_file]]\n\n") + fmt.Fprintf(w, "Validate text parsed with FSM or validate an FSM via command line.\n") +} diff --git a/go/cmd/textfsm/main_test.go b/go/cmd/textfsm/main_test.go new file mode 100644 index 0000000..7cacc84 --- /dev/null +++ b/go/cmd/textfsm/main_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCLI(t *testing.T) { + tmpDir := t.TempDir() + + tmplPath := filepath.Join(tmpDir, "test.tmpl") + inPath := filepath.Join(tmpDir, "test.in") + refPath := filepath.Join(tmpDir, "test.ref") + + tmplData := `Value Key Version (\d+\.\d+) + +Start + ^Version:\s+${Version} -> Record +` + inData := `Version: 1.0 +Version: 2.5` + refData := `["Version"] +["1.0"] +["2.5"] +` + + _ = os.WriteFile(tmplPath, []byte(tmplData), 0644) + _ = os.WriteFile(inPath, []byte(inData), 0644) + _ = os.WriteFile(refPath, []byte(refData), 0644) + + tests := []struct { + name string + args []string + wantExit int + wantStdout string + }{ + { + name: "help flag", + args: []string{"--help"}, + wantExit: 0, + wantStdout: "textfsm [--help] template [input_file [output_file]]", + }, + { + name: "template only", + args: []string{tmplPath}, + wantExit: 0, + wantStdout: "FSM Template:", + }, + { + name: "template and input", + args: []string{tmplPath, inPath}, + wantExit: 0, + wantStdout: "FSM Table:\n[\"Version\"]\n[\"1.0\"]\n[\"2.5\"]", + }, + { + name: "template input and matching ref", + args: []string{tmplPath, inPath, refPath}, + wantExit: 0, + wantStdout: "Data match!", + }, + { + name: "missing args", + args: []string{}, + wantExit: 2, + }, + } + + for _, tc := range tests { + var stdout, stderr bytes.Buffer + exitCode := run(tc.args, &stdout, &stderr) + if exitCode != tc.wantExit { + t.Errorf("[%s] run(%v) exitCode = %d, want %d", tc.name, tc.args, exitCode, tc.wantExit) + continue + } + if tc.wantStdout != "" && !strings.Contains(stdout.String(), tc.wantStdout) { + t.Errorf("[%s] stdout = %q, want substring %q", tc.name, stdout.String(), tc.wantStdout) + } + } +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..d5d7ef5 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,3 @@ +module github.com/google/textfsm/go + +go 1.26 diff --git a/go/terminal/terminal.go b/go/terminal/terminal.go new file mode 100644 index 0000000..5d54835 --- /dev/null +++ b/go/terminal/terminal.go @@ -0,0 +1,211 @@ +// Package terminal provides simple terminal related routines such as ANSI/SGR +// escape sequence manipulation and line wrapping. +package terminal + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// SGR maps Select Graphic Rendition names to their ANSI code values. +var SGR = map[string]int{ + "reset": 0, + "bold": 1, + "underline": 4, + "blink": 5, + "negative": 7, + "underline_off": 24, + "blink_off": 25, + "positive": 27, + "black": 30, + "red": 31, + "green": 32, + "yellow": 33, + "blue": 34, + "magenta": 35, + "cyan": 36, + "white": 37, + "fg_reset": 39, + "bg_black": 40, + "bg_red": 41, + "bg_green": 42, + "bg_yellow": 43, + "bg_blue": 44, + "bg_magenta": 45, + "bg_cyan": 46, + "bg_white": 47, + "bg_reset": 49, +} + +// FGColorWords maps familiar descriptive color words to SGR sequence lists. +var FGColorWords = map[string][]string{ + "black": {"black"}, + "dark_gray": {"bold", "black"}, + "blue": {"blue"}, + "light_blue": {"bold", "blue"}, + "green": {"green"}, + "light_green": {"bold", "green"}, + "cyan": {"cyan"}, + "light_cyan": {"bold", "cyan"}, + "red": {"red"}, + "light_red": {"bold", "red"}, + "purple": {"magenta"}, + "light_purple": {"bold", "magenta"}, + "brown": {"yellow"}, + "yellow": {"bold", "yellow"}, + "light_gray": {"white"}, + "white": {"bold", "white"}, +} + +// BGColorWords maps familiar descriptive background color words to SGR sequence lists. +var BGColorWords = map[string][]string{ + "black": {"bg_black"}, + "red": {"bg_red"}, + "green": {"bg_green"}, + "yellow": {"bg_yellow"}, + "dark_blue": {"bg_blue"}, + "purple": {"bg_magenta"}, + "light_blue": {"bg_cyan"}, + "grey": {"bg_white"}, +} + +const ( + // AnsiStart is the character inserted at start of ANSI strings for readline hinting. + AnsiStart = "\001" + // AnsiEnd is the character inserted at end of ANSI strings for readline hinting. + AnsiEnd = "\002" +) + +// sgrRe matches ANSI/SGR escape sequences, optionally enclosed in AnsiStart/AnsiEnd. +var sgrRe = regexp.MustCompile(`(` + AnsiStart + `?\033\[\d+(?:;\d+)*m` + AnsiEnd + `?)`) + +// AnsiCmd takes a list of SGR values and formats them as an ANSI escape sequence. +func AnsiCmd(commandList []string) (string, error) { + if len(commandList) == 0 { + return "", nil + } + var codes []string + for _, sgr := range commandList { + val, ok := SGR[strings.ToLower(sgr)] + if !ok { + return "", fmt.Errorf("invalid or unsupported SGR name: %s", sgr) + } + codes = append(codes, strconv.Itoa(val)) + } + return fmt.Sprintf("\033[%sm", strings.Join(codes, ";")), nil +} + +// AnsiText wraps text in ANSI/SGR escape codes. +func AnsiText(text string, commandList []string, reset bool) (string, error) { + if len(commandList) == 0 { + commandList = []string{"reset"} + } + prefix, err := AnsiCmd(commandList) + if err != nil { + return "", err + } + if reset { + suffix, _ := AnsiCmd([]string{"reset"}) + return prefix + text + suffix, nil + } + return prefix + text, nil +} + +// StripAnsiText strips ANSI/SGR escape sequences from text. +func StripAnsiText(text string) string { + return sgrRe.ReplaceAllString(text, "") +} + +// EncloseAnsiText encloses ANSI/SGR escape sequences with AnsiStart and AnsiEnd. +func EncloseAnsiText(text string) string { + return sgrRe.ReplaceAllStringFunc(text, func(match string) string { + // If already enclosed, strip first to avoid double enclosing + clean := strings.TrimPrefix(match, AnsiStart) + clean = strings.TrimSuffix(clean, AnsiEnd) + return AnsiStart + clean + AnsiEnd + }) +} + +// LineWrap breaks text to fit terminal/screen width, optionally ignoring SGR sequences in length calculation. +// If width <= 0, it defaults to 80 columns. +func LineWrap(text string, omitSGR bool, width int) string { + if width <= 0 { + width = 80 + } + + lines := strings.Split(text, "\n") + var multiline []string + + for _, line := range lines { + if line == "" { + multiline = append(multiline, "") + continue + } + + for (omitSGR && len(StripAnsiText(line)) > width) || (!omitSGR && len(line) > width) { + if !omitSGR { + multiline = append(multiline, line[:width]) + line = line[width:] + } else { + wrapped, rem := splitWithSGR(line, width) + multiline = append(multiline, wrapped) + line = rem + if line == "" { + break + } + } + } + if line != "" { + multiline = append(multiline, line) + } + } + return strings.Join(multiline, "\n") +} + +func splitWithSGR(line string, width int) (string, string) { + // Find all SGR sequences and text between them + locs := sgrRe.FindAllStringIndex(line, -1) + if len(locs) == 0 { + if len(line) <= width { + return line, "" + } + return line[:width], line[width:] + } + + var wrapped strings.Builder + currLen := 0 + lastIdx := 0 + + for _, loc := range locs { + // Text before this SGR token + textPart := line[lastIdx:loc[0]] + if currLen+len(textPart) <= width { + wrapped.WriteString(textPart) + currLen += len(textPart) + } else { + // Split in textPart + remWidth := width - currLen + wrapped.WriteString(textPart[:remWidth]) + remLine := textPart[remWidth:] + line[loc[0]:] + return wrapped.String(), remLine + } + + // Append the SGR token itself (0 length contribution) + sgrToken := line[loc[0]:loc[1]] + wrapped.WriteString(sgrToken) + lastIdx = loc[1] + } + + // Remaining text after last SGR token + remText := line[lastIdx:] + if currLen+len(remText) <= width { + wrapped.WriteString(remText) + return wrapped.String(), "" + } + + remWidth := width - currLen + wrapped.WriteString(remText[:remWidth]) + return wrapped.String(), remText[remWidth:] +} diff --git a/go/terminal/terminal_test.go b/go/terminal/terminal_test.go new file mode 100644 index 0000000..9d375d5 --- /dev/null +++ b/go/terminal/terminal_test.go @@ -0,0 +1,204 @@ +package terminal + +import ( + "testing" +) + +func TestAnsiCmd(t *testing.T) { + tests := []struct { + name string + cmds []string + want string + wantErr bool + }{ + { + name: "single reset", + cmds: []string{"reset"}, + want: "\033[0m", + wantErr: false, + }, + { + name: "multiple colors", + cmds: []string{"bold", "red", "bg_white"}, + want: "\033[1;31;47m", + wantErr: false, + }, + { + name: "case insensitive", + cmds: []string{"BLUE", "Bg_Yellow"}, + want: "\033[34;43m", + wantErr: false, + }, + { + name: "invalid color", + cmds: []string{"super_red"}, + want: "", + wantErr: true, + }, + { + name: "empty list", + cmds: []string{}, + want: "", + wantErr: false, + }, + } + + for _, tc := range tests { + got, err := AnsiCmd(tc.cmds) + if (err != nil) != tc.wantErr { + t.Errorf("[%s] AnsiCmd(%v) error = %v, wantErr %v", tc.name, tc.cmds, err, tc.wantErr) + continue + } + if got != tc.want { + t.Errorf("[%s] AnsiCmd(%v) = %q, want %q", tc.name, tc.cmds, got, tc.want) + } + } +} + +func TestAnsiText(t *testing.T) { + tests := []struct { + name string + text string + cmds []string + reset bool + want string + wantErr bool + }{ + { + name: "with reset", + text: "hello", + cmds: []string{"green"}, + reset: true, + want: "\033[32mhello\033[0m", + wantErr: false, + }, + { + name: "without reset", + text: "world", + cmds: []string{"bold", "blue"}, + reset: false, + want: "\033[1;34mworld", + wantErr: false, + }, + { + name: "default reset command when empty", + text: "test", + cmds: nil, + reset: true, + want: "\033[0mtest\033[0m", + wantErr: false, + }, + } + + for _, tc := range tests { + got, err := AnsiText(tc.text, tc.cmds, tc.reset) + if (err != nil) != tc.wantErr { + t.Errorf("[%s] AnsiText(%q, %v, %v) error = %v, wantErr %v", tc.name, tc.text, tc.cmds, tc.reset, err, tc.wantErr) + continue + } + if got != tc.want { + t.Errorf("[%s] AnsiText(%q, %v, %v) = %q, want %q", tc.name, tc.text, tc.cmds, tc.reset, got, tc.want) + } + } +} + +func TestStripAnsiText(t *testing.T) { + tests := []struct { + name string + text string + want string + }{ + { + name: "no ansi", + text: "plain text", + want: "plain text", + }, + { + name: "with ansi", + text: "\033[31mred text\033[0m and \033[1;32mgreen text\033[0m", + want: "red text and green text", + }, + { + name: "with enclosed ansi", + text: "\001\033[34m\002blue\001\033[0m\002", + want: "blue", + }, + } + + for _, tc := range tests { + got := StripAnsiText(tc.text) + if got != tc.want { + t.Errorf("[%s] StripAnsiText(%q) = %q, want %q", tc.name, tc.text, got, tc.want) + } + } +} + +func TestEncloseAnsiText(t *testing.T) { + tests := []struct { + name string + text string + want string + }{ + { + name: "simple ansi", + text: "\033[31mred\033[0m", + want: "\001\033[31m\002red\001\033[0m\002", + }, + { + name: "already enclosed", + text: "\001\033[31m\002red\001\033[0m\002", + want: "\001\033[31m\002red\001\033[0m\002", + }, + { + name: "no ansi", + text: "normal string", + want: "normal string", + }, + } + + for _, tc := range tests { + got := EncloseAnsiText(tc.text) + if got != tc.want { + t.Errorf("[%s] EncloseAnsiText(%q) = %q, want %q", tc.name, tc.text, got, tc.want) + } + } +} + +func TestLineWrap(t *testing.T) { + tests := []struct { + name string + text string + omitSGR bool + width int + want string + }{ + { + name: "short line no wrap", + text: "hello world", + omitSGR: false, + width: 20, + want: "hello world", + }, + { + name: "long line wrap without sgr", + text: "abcdefghijklmnopqrstuvwxyz", + omitSGR: false, + width: 10, + want: "abcdefghij\nklmnopqrst\nuvwxyz", + }, + { + name: "wrap with sgr ignored in length", + text: "\033[31mabcdefghij\033[0mklmnopqrstuvwxyz", + omitSGR: true, + width: 10, + want: "\033[31mabcdefghij\033[0m\nklmnopqrst\nuvwxyz", + }, + } + + for _, tc := range tests { + got := LineWrap(tc.text, tc.omitSGR, tc.width) + if got != tc.want { + t.Errorf("[%s] LineWrap(%q, %v, %d) = %q, want %q", tc.name, tc.text, tc.omitSGR, tc.width, got, tc.want) + } + } +} diff --git a/go/textfsm/errors.go b/go/textfsm/errors.go new file mode 100644 index 0000000..2fd2b1a --- /dev/null +++ b/go/textfsm/errors.go @@ -0,0 +1,37 @@ +// Package textfsm implements a template based text parser. +package textfsm + +import "fmt" + +// ErrSkipRecord is raised by Value options (like Required) to indicate a record should be skipped. +var ErrSkipRecord = fmt.Errorf("skip record") + +// ErrSkipValue is raised by Value options to indicate a value should be skipped. +var ErrSkipValue = fmt.Errorf("skip value") + +// TemplateError represents an error in template syntax or FSM construction. +type TemplateError struct { + Msg string + LineNum int +} + +func (e *TemplateError) Error() string { + if e.LineNum > 0 { + return fmt.Sprintf("%s Line: %d.", e.Msg, e.LineNum) + } + return e.Msg +} + +// FSMError represents a runtime error during state machine execution. +type FSMError struct { + Msg string + LineNum int + Input string +} + +func (e *FSMError) Error() string { + if e.LineNum > 0 { + return fmt.Sprintf("%s Rule Line: %d. Input Line: %s.", e.Msg, e.LineNum, e.Input) + } + return e.Msg +} diff --git a/go/textfsm/rule.go b/go/textfsm/rule.go new file mode 100644 index 0000000..a4b260a --- /dev/null +++ b/go/textfsm/rule.go @@ -0,0 +1,175 @@ +package textfsm + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + matchActionRe = regexp.MustCompile(`^(.*?)(\s+->\s*(.*))$`) + varSubRe = regexp.MustCompile(`\$(?:\{(\w+)\}|(\w+))`) + stateNameRe = regexp.MustCompile(`^\w+$`) +) + +var ( + validLineOps = map[string]bool{"Continue": true, "Next": true, "Error": true} + validRecordOps = map[string]bool{"Clear": true, "Clearall": true, "Record": true, "NoRecord": true} +) + +// Rule represents a transition rule in an FSM state. +type Rule struct { + Match string + Regex string + RegexObj *regexp.Regexp + LineOp string // "Next", "Continue", "Error" + RecordOp string // "NoRecord", "Record", "Clear", "Clearall" + NewState string + LineNum int +} + +// NewRule creates and parses a new Rule from a template line. +func NewRule(line string, lineNum int, varMap map[string]string) (*Rule, error) { + r := &Rule{ + LineNum: lineNum, + } + + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return nil, &TemplateError{Msg: fmt.Sprintf("Null data in FSMRule. Line: %d", lineNum), LineNum: lineNum} + } + + // Check for '->' action + matchAction := matchActionRe.FindStringSubmatch(trimmed) + if len(matchAction) > 0 { + r.Match = matchAction[1] + } else { + r.Match = trimmed + } + + // Variable substitution ${var} or $var + var errVarSub error + r.Regex = varSubRe.ReplaceAllStringFunc(r.Match, func(s string) string { + m := varSubRe.FindStringSubmatch(s) + name := m[1] + if name == "" { + name = m[2] + } + tmpl, ok := varMap[name] + if !ok { + errVarSub = &TemplateError{ + Msg: fmt.Sprintf("Duplicate or invalid variable substitution: '%s'. Line: %d.", r.Match, lineNum), + LineNum: lineNum, + } + return s + } + return tmpl + }) + if errVarSub != nil { + return nil, errVarSub + } + + re, err := regexp.Compile(r.Regex) + if err != nil { + return nil, &TemplateError{ + Msg: fmt.Sprintf("Invalid regular expression: '%s'. Line: %d.", r.Regex, lineNum), + LineNum: lineNum, + } + } + r.RegexObj = re + + if len(matchAction) == 0 { + return r, nil + } + + actionStr := strings.TrimSpace(matchAction[3]) + if err := r.parseAction(actionStr, trimmed); err != nil { + return nil, err + } + + return r, nil +} + +func (r *Rule) parseAction(actionStr, origLine string) error { + parts := strings.Fields(actionStr) + if len(parts) == 0 { + return &TemplateError{Msg: fmt.Sprintf("Badly formatted rule '%s'. Line: %d.", origLine, r.LineNum), LineNum: r.LineNum} + } + + firstToken := parts[0] + isOp := false + + if strings.Contains(firstToken, ".") { + opParts := strings.Split(firstToken, ".") + if len(opParts) != 2 { + return &TemplateError{Msg: fmt.Sprintf("Badly formatted rule '%s'. Line: %d.", origLine, r.LineNum), LineNum: r.LineNum} + } + op1, op2 := opParts[0], opParts[1] + if validLineOps[op1] && validRecordOps[op2] { + r.LineOp = op1 + r.RecordOp = op2 + isOp = true + } else if validRecordOps[op1] && validLineOps[op2] { + // In Python, Record.Next is rejected as badly formatted + return &TemplateError{Msg: fmt.Sprintf("Badly formatted rule '%s'. Line: %d.", origLine, r.LineNum), LineNum: r.LineNum} + } else { + return &TemplateError{Msg: fmt.Sprintf("Badly formatted rule '%s'. Line: %d.", origLine, r.LineNum), LineNum: r.LineNum} + } + } else if validLineOps[firstToken] { + r.LineOp = firstToken + isOp = true + } else if validRecordOps[firstToken] { + r.RecordOp = firstToken + isOp = true + } + + if isOp { + if len(parts) > 1 { + r.NewState = strings.TrimSpace(actionStr[len(firstToken):]) + } + } else { + // No operator matched, so the whole actionStr is treated as NewState + r.NewState = actionStr + } + + if r.LineOp == "Continue" && r.NewState != "" { + return &TemplateError{ + Msg: fmt.Sprintf("Action '%s' with new state %s specified. Line: %d.", r.LineOp, r.NewState, r.LineNum), + LineNum: r.LineNum, + } + } + + if r.LineOp != "Error" && r.NewState != "" { + if len(strings.Fields(r.NewState)) > 1 || !stateNameRe.MatchString(r.NewState) { + if !isOp { + return &TemplateError{Msg: fmt.Sprintf("Badly formatted rule '%s'. Line: %d.", origLine, r.LineNum), LineNum: r.LineNum} + } + return &TemplateError{ + Msg: fmt.Sprintf("Alphanumeric characters only in state names. Line: %d.", r.LineNum), + LineNum: r.LineNum, + } + } + } + + return nil +} + +// String returns the string representation of the Rule. +func (r *Rule) String() string { + op := "" + if r.LineOp != "" && r.RecordOp != "" { + op = r.LineOp + "." + r.RecordOp + } else { + op = r.LineOp + r.RecordOp + } + + newState := r.NewState + if op != "" && newState != "" { + newState = " " + newState + } + + if op == "" && newState == "" { + return " " + r.Match + } + return " " + r.Match + " -> " + op + newState +} diff --git a/go/textfsm/textfsm.go b/go/textfsm/textfsm.go new file mode 100644 index 0000000..9b7d02f --- /dev/null +++ b/go/textfsm/textfsm.go @@ -0,0 +1,398 @@ +package textfsm + +import ( + "bufio" + "fmt" + "io" + "regexp" + "strings" +) + +var commentRe = regexp.MustCompile(`^\s*#`) + +// FSM parses a template and executes the state machine against input text. +type FSM struct { + States map[string][]*Rule + StateList []string + Values []*Value + ValueMap map[string]string + Header []string + curState []*Rule + curStateName string + result [][]interface{} + lineNum int +} + +// NewFSM parses a template file/reader and initializes a new FSM. +func NewFSM(r io.Reader) (*FSM, error) { + fsm := &FSM{ + States: make(map[string][]*Rule), + ValueMap: make(map[string]string), + } + + scanner := bufio.NewScanner(r) + if err := fsm.parse(scanner); err != nil { + return nil, err + } + if err := scanner.Err(); err != nil { + return nil, err + } + + fsm.Reset() + return fsm, nil +} + +// Reset preserves the FSM but resets starting state and current record. +func (fsm *FSM) Reset() { + fsm.curState = fsm.States["Start"] + fsm.curStateName = "Start" + fsm.result = nil + fsm.clearAllRecord() +} + +func (fsm *FSM) parse(scanner *bufio.Scanner) error { + if err := fsm.parseFSMVariables(scanner); err != nil { + return err + } + + for { + stateName, err := fsm.parseFSMState(scanner) + if err != nil { + return err + } + if stateName == "" { + break + } + } + + return fsm.validateFSM() +} + +func (fsm *FSM) parseFSMVariables(scanner *bufio.Scanner) error { + fsm.Values = nil + for scanner.Scan() { + fsm.lineNum++ + line := strings.TrimRight(scanner.Text(), "\r\n\t ") + + if line == "" { + return nil + } + if commentRe.MatchString(line) { + continue + } + + if strings.HasPrefix(line, "Value ") { + val := NewValue(fsm) + if err := val.Parse(line); err != nil { + return &TemplateError{Msg: fmt.Sprintf("%v Line %d.", err, fsm.lineNum), LineNum: fsm.lineNum} + } + + for _, existing := range fsm.Values { + if existing.Name == val.Name { + return &TemplateError{ + Msg: fmt.Sprintf("Duplicate declarations for Value '%s'. Line: %d.", val.Name, fsm.lineNum), + LineNum: fsm.lineNum, + } + } + } + + fsm.Values = append(fsm.Values, val) + fsm.ValueMap[val.Name] = val.Template + } else if len(fsm.Values) == 0 { + return &TemplateError{Msg: "No Value definitions found"} + } else { + return &TemplateError{ + Msg: fmt.Sprintf("Expected blank line after last Value entry. Line: %d.", fsm.lineNum), + LineNum: fsm.lineNum, + } + } + } + return nil +} + +func (fsm *FSM) parseFSMState(scanner *bufio.Scanner) (string, error) { + stateName := "" + for scanner.Scan() { + fsm.lineNum++ + line := strings.TrimRight(scanner.Text(), "\r\n\t ") + if line != "" && !commentRe.MatchString(line) { + if !stateNameRe.MatchString(line) || len(line) > 48 || validLineOps[line] || validRecordOps[line] { + return "", &TemplateError{ + Msg: fmt.Sprintf("Invalid state name: '%s'. Line: %d", line, fsm.lineNum), + LineNum: fsm.lineNum, + } + } + stateName = line + if _, exists := fsm.States[stateName]; exists { + return "", &TemplateError{ + Msg: fmt.Sprintf("Duplicate state name: '%s'. Line: %d", line, fsm.lineNum), + LineNum: fsm.lineNum, + } + } + fsm.States[stateName] = []*Rule{} + fsm.StateList = append(fsm.StateList, stateName) + break + } + } + + if stateName == "" { + return "", nil + } + + for scanner.Scan() { + fsm.lineNum++ + line := strings.TrimRight(scanner.Text(), "\r\n\t ") + if line == "" { + break + } + if commentRe.MatchString(line) { + continue + } + if !strings.HasPrefix(line, " ^") && !strings.HasPrefix(line, " ^") && !strings.HasPrefix(line, "\t^") { + return "", &TemplateError{ + Msg: fmt.Sprintf("Missing white space or carat ('^') before rule. Line: %d", fsm.lineNum), + LineNum: fsm.lineNum, + } + } + + rule, err := NewRule(line, fsm.lineNum, fsm.ValueMap) + if err != nil { + return "", err + } + fsm.States[stateName] = append(fsm.States[stateName], rule) + } + + return stateName, nil +} + +func (fsm *FSM) validateFSM() error { + if _, ok := fsm.States["Start"]; !ok { + return &TemplateError{Msg: "Missing state 'Start'"} + } + if len(fsm.States["End"]) > 0 { + return &TemplateError{Msg: "Non-Empty 'End' state"} + } + if len(fsm.States["EOF"]) > 0 { + return &TemplateError{Msg: "Non-Empty 'EOF' state"} + } + if _, ok := fsm.States["End"]; ok { + delete(fsm.States, "End") + for i, s := range fsm.StateList { + if s == "End" { + fsm.StateList = append(fsm.StateList[:i], fsm.StateList[i+1:]...) + break + } + } + } + + for state, rules := range fsm.States { + for _, r := range rules { + if r.LineOp == "Error" || r.NewState == "" || r.NewState == "End" || r.NewState == "EOF" { + continue + } + if _, ok := fsm.States[r.NewState]; !ok { + return &TemplateError{ + Msg: fmt.Sprintf("State '%s' not found, referenced in state '%s'", r.NewState, state), + } + } + } + } + + fsm.Header = make([]string, 0, len(fsm.Values)) + for _, val := range fsm.Values { + fsm.Header = append(fsm.Header, val.Header()) + } + + return nil +} + +// GetValuesByAttrib returns a list of value names that have a particular attribute/option. +func (fsm *FSM) GetValuesByAttrib(attribute string) []string { + var result []string + for _, val := range fsm.Values { + if val.HasOption(attribute) { + result = append(result, val.Name) + } + } + return result +} + +// ParseText passes CLI output through the FSM and returns a 2D slice of values. +// The first row is always the header. +func (fsm *FSM) ParseText(text string, eof bool) ([][]interface{}, error) { + lines := strings.Split(text, "\n") + for _, line := range lines { + line = strings.TrimRight(line, "\r") + if err := fsm.checkLine(line); err != nil { + return nil, err + } + if fsm.curStateName == "End" || fsm.curStateName == "EOF" { + break + } + } + + if fsm.curStateName != "End" && fsm.States["EOF"] == nil && eof { + fsm.appendRecord() + } + + out := make([][]interface{}, 0, len(fsm.result)+1) + headerRow := make([]interface{}, len(fsm.Header)) + for i, h := range fsm.Header { + headerRow[i] = h + } + out = append(out, headerRow) + out = append(out, fsm.result...) + return out, nil +} + +// ParseTextToDicts parses CLI output and returns a slice of maps (column header -> value). +func (fsm *FSM) ParseTextToDicts(text string, eof bool) ([]map[string]interface{}, error) { + rows, err := fsm.ParseText(text, eof) + if err != nil { + return nil, err + } + if len(rows) <= 1 { + return nil, nil + } + + result := make([]map[string]interface{}, 0, len(rows)-1) + for i := 1; i < len(rows); i++ { + m := make(map[string]interface{}, len(fsm.Header)) + for j, col := range fsm.Header { + if j < len(rows[i]) { + m[col] = rows[i][j] + } + } + result = append(result, m) + } + return result, nil +} + +func (fsm *FSM) checkLine(line string) error { + for _, rule := range fsm.curState { + indices := rule.RegexObj.FindStringSubmatchIndex(line) + if indices != nil { + for i, name := range rule.RegexObj.SubexpNames() { + if i > 0 && name != "" { + start := indices[2*i] + end := indices[2*i+1] + var val interface{} + if start != -1 && end != -1 { + val = line[start:end] + } else { + val = nil + } + fsm.assignVar(name, val) + } + } + + if err := fsm.operations(rule, line); err != nil { + return err + } + + if rule.LineOp != "Continue" { + if rule.NewState != "" { + if rule.NewState != "End" && rule.NewState != "EOF" { + fsm.curState = fsm.States[rule.NewState] + } + fsm.curStateName = rule.NewState + } + break + } + } + } + return nil +} + +func (fsm *FSM) assignVar(name string, val interface{}) { + for _, v := range fsm.Values { + if v.Name == name { + v.AssignVar(val) + break + } + } +} + +func (fsm *FSM) operations(rule *Rule, line string) error { + switch rule.RecordOp { + case "Record": + fsm.appendRecord() + case "Clear": + fsm.clearRecord() + case "Clearall": + fsm.clearAllRecord() + } + + if rule.LineOp == "Error" { + if rule.NewState != "" { + return &FSMError{Msg: fmt.Sprintf("Error: %s", rule.NewState), LineNum: rule.LineNum, Input: line} + } + return &FSMError{Msg: "State Error raised", LineNum: rule.LineNum, Input: line} + } + + return nil +} + +func (fsm *FSM) appendRecord() { + if len(fsm.Values) == 0 { + return + } + + curRecord := make([]interface{}, 0, len(fsm.Values)) + for _, val := range fsm.Values { + if err := val.OnSaveRecord(); err == ErrSkipRecord { + fsm.clearRecord() + return + } + curRecord = append(curRecord, val.CurrentValue) + } + + emptyCount := 0 + for _, v := range curRecord { + if isValueEmpty(v) { + emptyCount++ + } + } + if emptyCount == len(curRecord) { + return + } + + for i, v := range curRecord { + if v == nil { + curRecord[i] = "" + } + } + + fsm.result = append(fsm.result, curRecord) + fsm.clearRecord() +} + +func (fsm *FSM) clearRecord() { + for _, v := range fsm.Values { + v.ClearVar() + } +} + +func (fsm *FSM) clearAllRecord() { + for _, v := range fsm.Values { + v.ClearAllVar() + } +} + +// String returns the FSM template representation. +func (fsm *FSM) String() string { + var sb strings.Builder + for _, v := range fsm.Values { + sb.WriteString(v.String()) + sb.WriteString("\n") + } + sb.WriteString("\n") + for _, state := range fsm.StateList { + sb.WriteString("\n" + state + "\n") + for _, r := range fsm.States[state] { + sb.WriteString(r.String()) + sb.WriteString("\n") + } + } + return sb.String() +} diff --git a/go/textfsm/textfsm_test.go b/go/textfsm/textfsm_test.go new file mode 100644 index 0000000..9d8ce28 --- /dev/null +++ b/go/textfsm/textfsm_test.go @@ -0,0 +1,334 @@ +package textfsm + +import ( + "reflect" + "strings" + "testing" +) + +func TestValueParse(t *testing.T) { + tests := []struct { + name string + line string + wantName string + wantRegex string + wantOptions []string + wantErr bool + }{ + { + name: "simple value", + line: "Value beer (\\S+)", + wantName: "beer", + wantRegex: "(\\S+)", + wantOptions: []string{}, + wantErr: false, + }, + { + name: "value with options", + line: "Value Filldown,Required beer (\\S+)", + wantName: "beer", + wantRegex: "(\\S+)", + wantOptions: []string{"Filldown", "Required"}, + wantErr: false, + }, + { + name: "multiple parenthesis", + line: "Value Required beer (boo(hoo))", + wantName: "beer", + wantRegex: "(boo(hoo))", + wantOptions: []string{"Required"}, + wantErr: false, + }, + { + name: "unbalanced parenthesis", + line: "Value beer (boo(hoo)))boo", + wantErr: true, + }, + { + name: "escaped parenthesis", + line: "Value beer (boo\\)hoo)", + wantName: "beer", + wantRegex: "(boo\\)hoo)", + wantOptions: []string{}, + wantErr: false, + }, + { + name: "too few tokens", + line: "Value beer", + wantErr: true, + }, + { + name: "unknown option", + line: "Value SuperOption beer (.*)", + wantErr: true, + }, + } + + for _, tc := range tests { + fsm := &FSM{} + v := NewValue(fsm) + err := v.Parse(tc.line) + if (err != nil) != tc.wantErr { + t.Errorf("[%s] Parse() error = %v, wantErr %v", tc.name, err, tc.wantErr) + continue + } + if tc.wantErr { + continue + } + if v.Name != tc.wantName { + t.Errorf("[%s] Name = %q, want %q", tc.name, v.Name, tc.wantName) + } + if v.Regex != tc.wantRegex { + t.Errorf("[%s] Regex = %q, want %q", tc.name, v.Regex, tc.wantRegex) + } + if !reflect.DeepEqual(v.OptionNames(), tc.wantOptions) { + t.Errorf("[%s] OptionNames() = %v, want %v", tc.name, v.OptionNames(), tc.wantOptions) + } + } +} + +func TestRuleParse(t *testing.T) { + varMap := map[string]string{ + "beer": "(?P\\S+)", + } + + tests := []struct { + name string + line string + wantMatch string + wantLineOp string + wantRecordOp string + wantNewState string + wantErr bool + }{ + { + name: "basic line no action", + line: " ^A beer called ${beer}", + wantMatch: "^A beer called ${beer}", + wantLineOp: "", + wantRecordOp: "", + wantNewState: "", + wantErr: false, + }, + { + name: "line with next action", + line: " ^A beer called ${beer} -> Next", + wantMatch: "^A beer called ${beer}", + wantLineOp: "Next", + wantRecordOp: "", + wantNewState: "", + wantErr: false, + }, + { + name: "line with continue record", + line: " ^A beer called ${beer} -> Continue.Record", + wantMatch: "^A beer called ${beer}", + wantLineOp: "Continue", + wantRecordOp: "Record", + wantNewState: "", + wantErr: false, + }, + { + name: "line with new state and record op", + line: " ^A beer called ${beer} -> Next.NoRecord End", + wantMatch: "^A beer called ${beer}", + wantLineOp: "Next", + wantRecordOp: "NoRecord", + wantNewState: "End", + wantErr: false, + }, + { + name: "bad syntax multiple next", + line: " ^A beer called ${beer} -> Next Next Next", + wantErr: true, + }, + { + name: "continue with new state is invalid", + line: " ^A beer called ${beer} -> Continue End", + wantErr: true, + }, + { + name: "unknown variable substitution", + line: " ^A beer called ${wine}", + wantErr: true, + }, + } + + for _, tc := range tests { + r, err := NewRule(tc.line, 1, varMap) + if (err != nil) != tc.wantErr { + t.Errorf("[%s] NewRule() error = %v, wantErr %v", tc.name, err, tc.wantErr) + continue + } + if tc.wantErr { + continue + } + if r.Match != tc.wantMatch { + t.Errorf("[%s] Match = %q, want %q", tc.name, r.Match, tc.wantMatch) + } + if r.LineOp != tc.wantLineOp { + t.Errorf("[%s] LineOp = %q, want %q", tc.name, r.LineOp, tc.wantLineOp) + } + if r.RecordOp != tc.wantRecordOp { + t.Errorf("[%s] RecordOp = %q, want %q", tc.name, r.RecordOp, tc.wantRecordOp) + } + if r.NewState != tc.wantNewState { + t.Errorf("[%s] NewState = %q, want %q", tc.name, r.NewState, tc.wantNewState) + } + } +} + +func TestFSMParseAndExecute(t *testing.T) { + tests := []struct { + name string + template string + input string + wantHead []string + wantRows [][]interface{} + wantErr bool + }{ + { + name: "simple interface parsing", + template: `Value Required Interface (\S+) +Value Status (up|down) +Value Protocol (up|down) + +Start + ^${Interface}\s+is\s+${Status},\s+line\s+protocol\s+is\s+${Protocol} -> Record +`, + input: `GigabitEthernet0/0 is up, line protocol is up +GigabitEthernet0/1 is down, line protocol is down +Loopback0 is up, line protocol is up`, + wantHead: []string{"Interface", "Status", "Protocol"}, + wantRows: [][]interface{}{ + {"GigabitEthernet0/0", "up", "up"}, + {"GigabitEthernet0/1", "down", "down"}, + {"Loopback0", "up", "up"}, + }, + wantErr: false, + }, + { + name: "filldown and required options", + template: `Value Filldown Router (\S+) +Value Required Interface (\S+) +Value Status (up|down) + +Start + ^Router:\s+${Router} + ^Interface\s+${Interface}\s+is\s+${Status} -> Record +`, + input: `Router: R1 +Interface Eth0 is up +Interface Eth1 is down +Router: R2 +Interface Eth0 is up`, + wantHead: []string{"Router", "Interface", "Status"}, + wantRows: [][]interface{}{ + {"R1", "Eth0", "up"}, + {"R1", "Eth1", "down"}, + {"R2", "Eth0", "up"}, + }, + wantErr: false, + }, + { + name: "list option", + template: `Value List IP (\d+\.\d+\.\d+\.\d+) + +Start + ^IP:\s+${IP} + ^End -> Record +`, + input: `IP: 1.1.1.1 +IP: 2.2.2.2 +End`, + wantHead: []string{"IP"}, + wantRows: [][]interface{}{ + {[]interface{}{"1.1.1.1", "2.2.2.2"}}, + }, + wantErr: false, + }, + { + name: "state transitions", + template: `Value Filldown Section (\S+) +Value Item (\S+) + +Start + ^Section:\s+${Section} -> InSection + +InSection + ^Item:\s+${Item} -> Record + ^EndSection -> Start +`, + input: `Section: A +Item: 1 +Item: 2 +EndSection +Section: B +Item: 3`, + wantHead: []string{"Section", "Item"}, + wantRows: [][]interface{}{ + {"A", "1"}, + {"A", "2"}, + {"B", "3"}, + {"B", ""}, + }, + wantErr: false, + }, + { + name: "error line op", + template: `Value Val (\d+) + +Start + ^FATAL -> Error "Fatal error occurred" + ^Val:\s+${Val} -> Record +`, + input: `Val: 100` + "\n" + `FATAL`, + wantErr: true, + }, + } + + for _, tc := range tests { + fsm, err := NewFSM(strings.NewReader(tc.template)) + if err != nil { + if !tc.wantErr { + t.Errorf("[%s] NewFSM() unexpected error = %v", tc.name, err) + } + continue + } + + got, err := fsm.ParseText(tc.input, true) + if (err != nil) != tc.wantErr { + t.Errorf("[%s] ParseText() error = %v, wantErr %v", tc.name, err, tc.wantErr) + continue + } + if tc.wantErr { + continue + } + + if len(got) == 0 { + t.Errorf("[%s] ParseText() returned empty slice", tc.name) + continue + } + + // Check header + gotHead := make([]string, len(got[0])) + for i, h := range got[0] { + gotHead[i] = h.(string) + } + if !reflect.DeepEqual(gotHead, tc.wantHead) { + t.Errorf("[%s] Header = %v, want %v", tc.name, gotHead, tc.wantHead) + } + + // Check rows + gotRows := got[1:] + if len(gotRows) != len(tc.wantRows) { + t.Errorf("[%s] len(rows) = %d, want %d", tc.name, len(gotRows), len(tc.wantRows)) + continue + } + for i, row := range gotRows { + if !reflect.DeepEqual(row, tc.wantRows[i]) { + t.Errorf("[%s] Row[%d] = %v, want %v", tc.name, i, row, tc.wantRows[i]) + } + } + } +} diff --git a/go/textfsm/value.go b/go/textfsm/value.go new file mode 100644 index 0000000..45a7928 --- /dev/null +++ b/go/textfsm/value.go @@ -0,0 +1,314 @@ +package textfsm + +import ( + "fmt" + "regexp" + "strings" +) + +// ValueOption defines the behavior of a Value (Required, Filldown, Fillup, Key, List). +type ValueOption interface { + Name() string + OnCreateOptions(val *Value) + OnAssignVar(val *Value) + OnClearVar(val *Value) + OnClearAllVar(val *Value) + OnGetValue(val *Value) + OnSaveRecord(val *Value) error +} + +// Value represents a template variable declaration. +type Value struct { + Name string + Regex string + Template string + CompiledRegex *regexp.Regexp + Options []ValueOption + CurrentValue interface{} // string or []interface{} for List + FSM *FSM + maxNameLen int +} + +// NewValue creates a new Value bound to an FSM. +func NewValue(fsm *FSM) *Value { + return &Value{ + FSM: fsm, + maxNameLen: 48, + } +} + +// Parse parses a 'Value' declaration line from a template. +func (v *Value) Parse(line string) error { + tokens := strings.Split(line, " ") + if len(tokens) < 3 { + return &TemplateError{Msg: "Expect at least 3 tokens on line"} + } + + if !strings.HasPrefix(tokens[2], "(") { + // Options are present + for _, optName := range strings.Split(tokens[1], ",") { + if err := v.AddOption(optName); err != nil { + return err + } + } + for _, opt := range v.Options { + opt.OnCreateOptions(v) + } + v.Name = tokens[2] + v.Regex = strings.Join(tokens[3:], " ") + } else { + v.Name = tokens[1] + v.Regex = strings.Join(tokens[2:], " ") + } + + if len(v.Name) > v.maxNameLen { + return &TemplateError{Msg: fmt.Sprintf("Invalid Value name '%s' or name too long", v.Name)} + } + + if len(v.Regex) < 2 || v.Regex[0] != '(' || v.Regex[len(v.Regex)-1] != ')' || (len(v.Regex) >= 2 && v.Regex[len(v.Regex)-2] == '\\') { + return &TemplateError{Msg: fmt.Sprintf("Value '%s' must be contained within a '()' pair", v.Regex)} + } + + re, err := regexp.Compile(v.Regex) + if err != nil { + return &TemplateError{Msg: err.Error()} + } + + v.Template = strings.Replace(v.Regex, "(", fmt.Sprintf("(?P<%s>", v.Name), 1) + + if v.HasOption("List") { + v.CompiledRegex = re + } + + return nil +} + +// AddOption adds a named option to the Value. +func (v *Value) AddOption(name string) error { + if v.HasOption(name) { + return &TemplateError{Msg: fmt.Sprintf("Duplicate option \"%s\"", name)} + } + var opt ValueOption + switch name { + case "Required": + opt = &optRequired{} + case "Filldown": + opt = &optFilldown{} + case "Fillup": + opt = &optFillup{} + case "Key": + opt = &optKey{} + case "List": + opt = &optList{} + default: + return &TemplateError{Msg: fmt.Sprintf("Unknown option \"%s\"", name)} + } + v.Options = append(v.Options, opt) + return nil +} + +// HasOption checks if the Value has the given option name. +func (v *Value) HasOption(name string) bool { + for _, opt := range v.Options { + if opt.Name() == name { + return true + } + } + return false +} + +// OptionNames returns a list of option names for this Value. +func (v *Value) OptionNames() []string { + names := make([]string, len(v.Options)) + for i, opt := range v.Options { + names[i] = opt.Name() + } + return names +} + +// AssignVar assigns a value to this Value and triggers option callbacks. +func (v *Value) AssignVar(val interface{}) { + v.CurrentValue = val + for _, opt := range v.Options { + opt.OnAssignVar(v) + } +} + +// ClearVar clears this Value and triggers option callbacks. +func (v *Value) ClearVar() { + v.CurrentValue = nil + for _, opt := range v.Options { + opt.OnClearVar(v) + } +} + +// ClearAllVar clears this Value and triggers clear-all callbacks. +func (v *Value) ClearAllVar() { + v.CurrentValue = nil + for _, opt := range v.Options { + opt.OnClearAllVar(v) + } +} + +// Header returns the header name of this Value. +func (v *Value) Header() string { + for _, opt := range v.Options { + opt.OnGetValue(v) + } + return v.Name +} + +// OnSaveRecord is called just prior to a record being committed. +func (v *Value) OnSaveRecord() error { + for _, opt := range v.Options { + if err := opt.OnSaveRecord(v); err != nil { + return err + } + } + return nil +} + +// String returns the string representation of the Value. +func (v *Value) String() string { + if len(v.Options) > 0 { + return fmt.Sprintf("Value %s %s %s", strings.Join(v.OptionNames(), ","), v.Name, v.Regex) + } + return fmt.Sprintf("Value %s %s", v.Name, v.Regex) +} + +func isValueEmpty(val interface{}) bool { + if val == nil { + return true + } + switch x := val.(type) { + case string: + return x == "" + case []interface{}: + return len(x) == 0 + case []string: + return len(x) == 0 + case []map[string]string: + return len(x) == 0 + } + return false +} + +// optRequired implementation +type optRequired struct{} + +func (o *optRequired) Name() string { return "Required" } +func (o *optRequired) OnCreateOptions(val *Value) {} +func (o *optRequired) OnAssignVar(val *Value) {} +func (o *optRequired) OnClearVar(val *Value) {} +func (o *optRequired) OnClearAllVar(val *Value) {} +func (o *optRequired) OnGetValue(val *Value) {} +func (o *optRequired) OnSaveRecord(val *Value) error { + if isValueEmpty(val.CurrentValue) { + return ErrSkipRecord + } + return nil +} + +// optFilldown implementation +type optFilldown struct { + myvar interface{} +} + +func (o *optFilldown) Name() string { return "Filldown" } +func (o *optFilldown) OnCreateOptions(val *Value) { o.myvar = nil } +func (o *optFilldown) OnAssignVar(val *Value) { o.myvar = val.CurrentValue } +func (o *optFilldown) OnClearVar(val *Value) { val.CurrentValue = o.myvar } +func (o *optFilldown) OnClearAllVar(val *Value) { o.myvar = nil } +func (o *optFilldown) OnGetValue(val *Value) {} +func (o *optFilldown) OnSaveRecord(val *Value) error { + return nil +} + +// optFillup implementation +type optFillup struct{} + +func (o *optFillup) Name() string { return "Fillup" } +func (o *optFillup) OnCreateOptions(val *Value) {} +func (o *optFillup) OnAssignVar(val *Value) { + if !isValueEmpty(val.CurrentValue) && val.FSM != nil { + idx := -1 + for i, v := range val.FSM.Values { + if v == val { + idx = i + break + } + } + if idx != -1 { + // Iterate backwards over FSM results + for i := len(val.FSM.result) - 1; i >= 0; i-- { + row := val.FSM.result[i] + if idx < len(row) && row[idx] != "" && row[idx] != nil { + break + } + if idx < len(row) { + row[idx] = val.CurrentValue + } + } + } + } +} +func (o *optFillup) OnClearVar(val *Value) {} +func (o *optFillup) OnClearAllVar(val *Value) {} +func (o *optFillup) OnGetValue(val *Value) {} +func (o *optFillup) OnSaveRecord(val *Value) error { return nil } + +// optKey implementation +type optKey struct{} + +func (o *optKey) Name() string { return "Key" } +func (o *optKey) OnCreateOptions(val *Value) {} +func (o *optKey) OnAssignVar(val *Value) {} +func (o *optKey) OnClearVar(val *Value) {} +func (o *optKey) OnClearAllVar(val *Value) {} +func (o *optKey) OnGetValue(val *Value) {} +func (o *optKey) OnSaveRecord(val *Value) error { return nil } + +// optList implementation +type optList struct { + listVal []interface{} +} + +func (o *optList) Name() string { return "List" } +func (o *optList) OnCreateOptions(val *Value) { + o.OnClearAllVar(val) +} +func (o *optList) OnAssignVar(val *Value) { + if val.CompiledRegex != nil && val.CompiledRegex.NumSubexp() > 1 { + strVal, ok := val.CurrentValue.(string) + if !ok { + return + } + matches := val.CompiledRegex.FindStringSubmatch(strVal) + if len(matches) > 0 { + m := make(map[string]string) + for i, name := range val.CompiledRegex.SubexpNames() { + if i > 0 && name != "" && i < len(matches) { + m[name] = matches[i] + } + } + if len(m) > 0 { + o.listVal = append(o.listVal, m) + return + } + } + } + o.listVal = append(o.listVal, val.CurrentValue) +} +func (o *optList) OnClearVar(val *Value) { + if !val.HasOption("Filldown") { + o.listVal = nil + } +} +func (o *optList) OnClearAllVar(val *Value) { + o.listVal = nil +} +func (o *optList) OnGetValue(val *Value) {} +func (o *optList) OnSaveRecord(val *Value) error { + val.CurrentValue = append([]interface{}(nil), o.listVal...) + return nil +} diff --git a/go/texttable/texttable.go b/go/texttable/texttable.go new file mode 100644 index 0000000..26115bd --- /dev/null +++ b/go/texttable/texttable.go @@ -0,0 +1,461 @@ +// Package texttable represents and manipulates tabular text data. +package texttable + +import ( + "encoding/csv" + "fmt" + "io" + "sort" + "strings" + + "github.com/google/textfsm/go/terminal" +) + +// Row represents a row of tabular data with header mappings. +type Row struct { + Header []string + Values []string + RowNum int + Table *Table + Color []string + index map[string]int +} + +// NewRow creates a new Row with the given header and values. +func NewRow(header, values []string) (*Row, error) { + if len(header) != len(values) { + return nil, fmt.Errorf("header length (%d) does not match values length (%d)", len(header), len(values)) + } + r := &Row{ + Header: append([]string(nil), header...), + Values: append([]string(nil), values...), + index: make(map[string]int, len(header)), + } + r.buildIndex() + return r, nil +} + +func (r *Row) buildIndex() { + r.index = make(map[string]int, len(r.Header)) + for i, col := range r.Header { + r.index[col] = i + } +} + +// Get retrieves a value by column name. Returns empty string and false if not found. +func (r *Row) Get(col string) (string, bool) { + idx, ok := r.index[col] + if !ok || idx >= len(r.Values) { + return "", false + } + return r.Values[idx], true +} + +// Set sets a value by column name. If column does not exist, it appends it to the header and values. +func (r *Row) Set(col, val string) { + idx, ok := r.index[col] + if ok && idx < len(r.Values) { + r.Values[idx] = val + return + } + r.Header = append(r.Header, col) + r.Values = append(r.Values, val) + r.buildIndex() + if r.Table != nil && len(r.Header) > len(r.Table.Header) { + r.Table.Header = append([]string(nil), r.Header...) + } +} + +// Map returns the row values as a map of column name to value. +func (r *Row) Map() map[string]string { + m := make(map[string]string, len(r.Header)) + for i, col := range r.Header { + if i < len(r.Values) { + m[col] = r.Values[i] + } + } + return m +} + +// String returns a formatted string representation of the row. +func (r *Row) String() string { + var sb strings.Builder + for _, v := range r.Values { + sb.WriteString(fmt.Sprintf("%12s ", v)) + } + sb.WriteString("\n") + return sb.String() +} + +// Table provides data methods on a tabular format. +type Table struct { + Header []string + Rows []*Row + Separator string +} + +// NewTable creates a new empty Table. +func NewTable() *Table { + return &Table{ + Separator: ", ", + } +} + +// Reset clears all data rows from the table and resets the header. +func (t *Table) Reset() { + t.Header = nil + t.Rows = nil +} + +// Size returns the number of data rows in the table. +func (t *Table) Size() int { + return len(t.Rows) +} + +// SetHeader sets the table header. +func (t *Table) SetHeader(header []string) { + t.Header = append([]string(nil), header...) +} + +// Append adds a new row to the table. Accepts *Row, []string, or map[string]string. +func (t *Table) Append(data interface{}) error { + var newRow *Row + var err error + + switch v := data.(type) { + case *Row: + if len(t.Header) != 0 && !equalSlices(t.Header, v.Header) { + return fmt.Errorf("attempt to append row with mismatched header") + } + if len(t.Header) == 0 { + t.SetHeader(v.Header) + } + newRow, err = NewRow(t.Header, v.Values) + case []string: + if len(t.Header) == 0 { + return fmt.Errorf("cannot append slice to table without header") + } + if len(v) != len(t.Header) { + return fmt.Errorf("slice length (%d) does not match header length (%d)", len(v), len(t.Header)) + } + newRow, err = NewRow(t.Header, v) + case []interface{}: + if len(t.Header) == 0 { + return fmt.Errorf("cannot append slice to table without header") + } + if len(v) != len(t.Header) { + return fmt.Errorf("slice length (%d) does not match header length (%d)", len(v), len(t.Header)) + } + vals := make([]string, len(v)) + for i, item := range v { + if item == nil { + vals[i] = "" + } else if s, ok := item.(string); ok { + vals[i] = s + } else { + vals[i] = fmt.Sprintf("%v", item) + } + } + newRow, err = NewRow(t.Header, vals) + case map[string]string: + if len(t.Header) == 0 { + return fmt.Errorf("cannot append map to table without header") + } + vals := make([]string, len(t.Header)) + for i, col := range t.Header { + val, ok := v[col] + if !ok { + return fmt.Errorf("map missing required column: %s", col) + } + vals[i] = val + } + newRow, err = NewRow(t.Header, vals) + default: + return fmt.Errorf("unsupported type for Append: %T", data) + } + + if err != nil { + return err + } + + newRow.RowNum = len(t.Rows) + 1 + newRow.Table = t + t.Rows = append(t.Rows, newRow) + return nil +} + +// CsvToTable populates the table from a CSV reader. The first row is treated as the header. +func (t *Table) CsvToTable(r io.Reader) error { + t.Reset() + cr := csv.NewReader(r) + cr.FieldsPerRecord = -1 // Allow variable fields, or let header dictate + + records, err := cr.ReadAll() + if err != nil { + return err + } + if len(records) == 0 { + return nil + } + + t.SetHeader(records[0]) + for i := 1; i < len(records); i++ { + // Pad or trim record to match header length if needed + rec := records[i] + if len(rec) < len(t.Header) { + padded := make([]string, len(t.Header)) + copy(padded, rec) + rec = padded + } else if len(rec) > len(t.Header) { + rec = rec[:len(t.Header)] + } + if err := t.Append(rec); err != nil { + return fmt.Errorf("row %d: %v", i, err) + } + } + return nil +} + +// Filter returns a new Table containing only rows for which fn returns true. +func (t *Table) Filter(fn func(r *Row) bool) *Table { + if fn == nil { + fn = func(r *Row) bool { + for _, v := range r.Values { + if v != "" { + return true + } + } + return false + } + } + + nt := NewTable() + nt.Separator = t.Separator + nt.SetHeader(t.Header) + for _, r := range t.Rows { + if fn(r) { + _ = nt.Append(r.Values) + } + } + return nt +} + +// Map returns a new Table where each row has been transformed by fn. +func (t *Table) Map(fn func(r *Row) *Row) *Table { + nt := NewTable() + nt.Separator = t.Separator + nt.SetHeader(t.Header) + for _, r := range t.Rows { + nr := fn(r) + if nr != nil { + _ = nt.Append(nr.Values) + } + } + return nt +} + +// Sort sorts the rows using a custom comparison function. +func (t *Table) Sort(less func(r1, r2 *Row) bool) { + sort.SliceStable(t.Rows, func(i, j int) bool { + return less(t.Rows[i], t.Rows[j]) + }) + for i, r := range t.Rows { + r.RowNum = i + 1 + } +} + +// SortByKey sorts the rows by comparing values generated by keyFn. +func (t *Table) SortByKey(keyFn func(r *Row) []string, reverse bool) { + t.Sort(func(r1, r2 *Row) bool { + k1 := keyFn(r1) + k2 := keyFn(r2) + minLen := len(k1) + if len(k2) < minLen { + minLen = len(k2) + } + for i := 0; i < minLen; i++ { + if k1[i] != k2[i] { + if reverse { + return k1[i] > k2[i] + } + return k1[i] < k2[i] + } + } + if reverse { + return len(k1) > len(k2) + } + return len(k1) < len(k2) + }) +} + +// Extend merges new columns from another table into this table. +func (t *Table) Extend(other *Table, keys []string) error { + if len(keys) > 0 { + for _, k := range keys { + if !containsString(t.Header, k) { + return fmt.Errorf("unknown key: %s", k) + } + } + } + + var extendWith []string + for _, col := range other.Header { + if !containsString(t.Header, col) { + extendWith = append(extendWith, col) + } + } + if len(extendWith) == 0 { + return nil + } + + for _, col := range extendWith { + t.Header = append(t.Header, col) + } + + if len(keys) == 0 { + for i, r1 := range t.Rows { + for _, col := range extendWith { + val := "" + if i < len(other.Rows) { + if v, ok := other.Rows[i].Get(col); ok { + val = v + } + } + r1.Set(col, val) + } + } + return nil + } + + for _, r1 := range t.Rows { + for _, r2 := range other.Rows { + match := true + for _, k := range keys { + v1, _ := r1.Get(k) + v2, _ := r2.Get(k) + if v1 != v2 { + match = false + break + } + } + if match { + for _, col := range extendWith { + val, _ := r2.Get(col) + r1.Set(col, val) + } + break + } + } + // Ensure column exists even if no match found + for _, col := range extendWith { + if _, ok := r1.Get(col); !ok { + r1.Set(col, "") + } + } + } + return nil +} + +// Remove deletes a row by its 1-indexed row number. +func (t *Table) Remove(rowNum int) error { + if rowNum <= 0 || rowNum > len(t.Rows) { + return fmt.Errorf("attempt to remove non-existent row %d", rowNum) + } + idx := rowNum - 1 + t.Rows = append(t.Rows[:idx], t.Rows[idx+1:]...) + for i := idx; i < len(t.Rows); i++ { + t.Rows[i].RowNum = i + 1 + } + return nil +} + +// String returns the CSV/separated string representation of the table. +func (t *Table) String() string { + var sb strings.Builder + if len(t.Header) > 0 { + sb.WriteString(strings.Join(t.Header, t.Separator)) + sb.WriteString("\n") + } + for _, r := range t.Rows { + sb.WriteString(strings.Join(r.Values, t.Separator)) + sb.WriteString("\n") + } + return sb.String() +} + +// FormattedTable returns a whitespace-padded, formatted string representation of the table. +func (t *Table) FormattedTable(width int, forceDisplay bool, columns []string) (string, error) { + if width <= 0 { + width = 80 + } + cols := columns + if len(cols) == 0 { + cols = t.Header + } + + colWidths := make(map[string]int, len(cols)) + for _, col := range cols { + colWidths[col] = len(col) + } + + for _, r := range t.Rows { + for _, col := range cols { + val, _ := r.Get(col) + stripped := terminal.StripAnsiText(val) + if len(stripped) > colWidths[col] { + colWidths[col] = len(stripped) + } + } + } + + totalWidth := 0 + for _, col := range cols { + colWidths[col] += 2 // 1 space padding on each side + totalWidth += colWidths[col] + } + + if totalWidth > width && !forceDisplay { + return "", fmt.Errorf("width %d too narrow to display table (minimum %d)", width, totalWidth) + } + + var sb strings.Builder + // Header + for _, col := range cols { + sb.WriteString(fmt.Sprintf(" %-*s", colWidths[col]-1, col)) + } + sb.WriteString("\n") + + // Rows + for _, r := range t.Rows { + for _, col := range cols { + val, _ := r.Get(col) + strippedLen := len(terminal.StripAnsiText(val)) + ansiAdds := len(val) - strippedLen + sb.WriteString(fmt.Sprintf(" %-*s", colWidths[col]-1+ansiAdds, val)) + } + sb.WriteString("\n") + } + + return sb.String(), nil +} + +func equalSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func containsString(slice []string, val string) bool { + for _, item := range slice { + if item == val { + return true + } + } + return false +} diff --git a/go/texttable/texttable_test.go b/go/texttable/texttable_test.go new file mode 100644 index 0000000..a60e321 --- /dev/null +++ b/go/texttable/texttable_test.go @@ -0,0 +1,324 @@ +package texttable + +import ( + "strings" + "testing" +) + +func TestTableAppend(t *testing.T) { + tests := []struct { + name string + header []string + appends []interface{} + wantSize int + wantErr bool + }{ + { + name: "append slice to empty table with header", + header: []string{"Name", "Age"}, + appends: []interface{}{ + []string{"Alice", "30"}, + []string{"Bob", "25"}, + }, + wantSize: 2, + wantErr: false, + }, + { + name: "append map", + header: []string{"City", "Country"}, + appends: []interface{}{ + map[string]string{"City": "Paris", "Country": "France"}, + }, + wantSize: 1, + wantErr: false, + }, + { + name: "append mismatched slice length", + header: []string{"A", "B"}, + appends: []interface{}{ + []string{"OnlyOne"}, + }, + wantSize: 0, + wantErr: true, + }, + } + + for _, tc := range tests { + tbl := NewTable() + if len(tc.header) > 0 { + tbl.SetHeader(tc.header) + } + var err error + for _, item := range tc.appends { + err = tbl.Append(item) + if err != nil { + break + } + } + if (err != nil) != tc.wantErr { + t.Errorf("[%s] Append() error = %v, wantErr %v", tc.name, err, tc.wantErr) + continue + } + if !tc.wantErr && tbl.Size() != tc.wantSize { + t.Errorf("[%s] Size() = %d, want %d", tc.name, tbl.Size(), tc.wantSize) + } + } +} + +func TestCsvToTable(t *testing.T) { + tests := []struct { + name string + csvData string + wantHead []string + wantRows [][]string + wantErr bool + }{ + { + name: "simple csv", + csvData: "Col1,Col2\nVal1,Val2\nVal3,Val4", + wantHead: []string{"Col1", "Col2"}, + wantRows: [][]string{ + {"Val1", "Val2"}, + {"Val3", "Val4"}, + }, + wantErr: false, + }, + { + name: "csv with uneven rows", + csvData: "A,B,C\n1,2\n3,4,5,6", + wantHead: []string{"A", "B", "C"}, + wantRows: [][]string{ + {"1", "2", ""}, + {"3", "4", "5"}, + }, + wantErr: false, + }, + } + + for _, tc := range tests { + tbl := NewTable() + err := tbl.CsvToTable(strings.NewReader(tc.csvData)) + if (err != nil) != tc.wantErr { + t.Errorf("[%s] CsvToTable() error = %v, wantErr %v", tc.name, err, tc.wantErr) + continue + } + if tc.wantErr { + continue + } + if !equalSlices(tbl.Header, tc.wantHead) { + t.Errorf("[%s] Header = %v, want %v", tc.name, tbl.Header, tc.wantHead) + } + if tbl.Size() != len(tc.wantRows) { + t.Errorf("[%s] Size() = %d, want %d", tc.name, tbl.Size(), len(tc.wantRows)) + continue + } + for i, row := range tbl.Rows { + if !equalSlices(row.Values, tc.wantRows[i]) { + t.Errorf("[%s] Row[%d] = %v, want %v", tc.name, i, row.Values, tc.wantRows[i]) + } + } + } +} + +func TestTableFilter(t *testing.T) { + tests := []struct { + name string + header []string + rows [][]string + filterFn func(r *Row) bool + wantSize int + }{ + { + name: "default filter removes empty rows", + header: []string{"X"}, + rows: [][]string{ + {"hello"}, + {""}, + {"world"}, + }, + filterFn: nil, + wantSize: 2, + }, + { + name: "custom filter", + header: []string{"Num"}, + rows: [][]string{ + {"10"}, + {"20"}, + {"30"}, + }, + filterFn: func(r *Row) bool { + v, _ := r.Get("Num") + return v == "20" + }, + wantSize: 1, + }, + } + + for _, tc := range tests { + tbl := NewTable() + tbl.SetHeader(tc.header) + for _, r := range tc.rows { + _ = tbl.Append(r) + } + filtered := tbl.Filter(tc.filterFn) + if filtered.Size() != tc.wantSize { + t.Errorf("[%s] Filter().Size() = %d, want %d", tc.name, filtered.Size(), tc.wantSize) + } + } +} + +func TestTableSortByKey(t *testing.T) { + tests := []struct { + name string + header []string + rows [][]string + col string + reverse bool + want []string + }{ + { + name: "ascending sort", + header: []string{"Letter"}, + rows: [][]string{{"C"}, {"A"}, {"B"}}, + col: "Letter", + reverse: false, + want: []string{"A", "B", "C"}, + }, + { + name: "descending sort", + header: []string{"Letter"}, + rows: [][]string{{"C"}, {"A"}, {"B"}}, + col: "Letter", + reverse: true, + want: []string{"C", "B", "A"}, + }, + } + + for _, tc := range tests { + tbl := NewTable() + tbl.SetHeader(tc.header) + for _, r := range tc.rows { + _ = tbl.Append(r) + } + tbl.SortByKey(func(r *Row) []string { + v, _ := r.Get(tc.col) + return []string{v} + }, tc.reverse) + + var got []string + for _, r := range tbl.Rows { + v, _ := r.Get(tc.col) + got = append(got, v) + } + if !equalSlices(got, tc.want) { + t.Errorf("[%s] SortByKey() = %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestTableExtend(t *testing.T) { + tests := []struct { + name string + t1Head []string + t1Rows [][]string + t2Head []string + t2Rows [][]string + keys []string + wantHead []string + wantRow0 map[string]string + }{ + { + name: "extend without keys (positional)", + t1Head: []string{"ID", "Name"}, + t1Rows: [][]string{{"1", "Alice"}}, + t2Head: []string{"ID", "Role"}, + t2Rows: [][]string{{"1", "Admin"}}, + keys: nil, + wantHead: []string{"ID", "Name", "Role"}, + wantRow0: map[string]string{"ID": "1", "Name": "Alice", "Role": "Admin"}, + }, + { + name: "extend with keys match", + t1Head: []string{"Host", "IP"}, + t1Rows: [][]string{{"router1", "10.0.0.1"}, {"router2", "10.0.0.2"}}, + t2Head: []string{"Host", "OS"}, + t2Rows: [][]string{{"router2", "IOS"}, {"router1", "NXOS"}}, + keys: []string{"Host"}, + wantHead: []string{"Host", "IP", "OS"}, + wantRow0: map[string]string{"Host": "router1", "IP": "10.0.0.1", "OS": "NXOS"}, + }, + } + + for _, tc := range tests { + t1 := NewTable() + t1.SetHeader(tc.t1Head) + for _, r := range tc.t1Rows { + _ = t1.Append(r) + } + + t2 := NewTable() + t2.SetHeader(tc.t2Head) + for _, r := range tc.t2Rows { + _ = t2.Append(r) + } + + err := t1.Extend(t2, tc.keys) + if err != nil { + t.Errorf("[%s] Extend() unexpected error = %v", tc.name, err) + continue + } + if !equalSlices(t1.Header, tc.wantHead) { + t.Errorf("[%s] Header = %v, want %v", tc.name, t1.Header, tc.wantHead) + } + for k, wantVal := range tc.wantRow0 { + gotVal, _ := t1.Rows[0].Get(k) + if gotVal != wantVal { + t.Errorf("[%s] Row[0].Get(%q) = %q, want %q", tc.name, k, gotVal, wantVal) + } + } + } +} + +func TestFormattedTable(t *testing.T) { + tests := []struct { + name string + header []string + rows [][]string + width int + wantSubstr string + wantErr bool + }{ + { + name: "normal display", + header: []string{"Col1", "Col2"}, + rows: [][]string{{"A", "B"}}, + width: 80, + wantSubstr: "Col1", + wantErr: false, + }, + { + name: "width too small without force", + header: []string{"VeryLongColumnHeader"}, + rows: [][]string{{"Value"}}, + width: 5, + wantErr: true, + }, + } + + for _, tc := range tests { + tbl := NewTable() + tbl.SetHeader(tc.header) + for _, r := range tc.rows { + _ = tbl.Append(r) + } + got, err := tbl.FormattedTable(tc.width, false, nil) + if (err != nil) != tc.wantErr { + t.Errorf("[%s] FormattedTable() error = %v, wantErr %v", tc.name, err, tc.wantErr) + continue + } + if !tc.wantErr && !strings.Contains(got, tc.wantSubstr) { + t.Errorf("[%s] FormattedTable() = %q, want substring %q", tc.name, got, tc.wantSubstr) + } + } +}