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) + } + } +}