diff --git a/internal/executor/netports.go b/internal/executor/netports.go index 32434d0..d8c6470 100644 --- a/internal/executor/netports.go +++ b/internal/executor/netports.go @@ -9,7 +9,6 @@ import ( "sort" "strconv" "strings" - "text/tabwriter" ) // listeningPort is one listening socket with its owner, normalised across @@ -71,6 +70,10 @@ func runNetPorts(ctx context.Context, req BuiltinRequest) error { } // renderListeningPorts writes the table, sorted by port. +// +// text/tabwriter measures in bytes, which misaligns the CJK headings (each +// character is three bytes wide but only two columns on screen), so the +// columns are padded by display width instead. func renderListeningPorts(ports []listeningPort, req BuiltinRequest) { if len(ports) == 0 { if req.Stderr != nil { @@ -79,19 +82,87 @@ func renderListeningPorts(ports []listeningPort, req BuiltinRequest) { return } - tw := tabwriter.NewWriter(req.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "端口\t协议\t地址\tPID\t进程") + rows := [][]string{{"端口", "协议", "地址", "PID", "进程"}} for _, p := range ports { pid, process := p.PID, p.Process if pid == "" { - pid, process = "-", "-" + pid = "-" } if process == "" { process = "-" } - fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", p.Port, p.Proto, p.Address, pid, process) + rows = append(rows, []string{ + strconv.Itoa(p.Port), p.Proto, displayAddress(p.Address), pid, process, + }) } - _ = tw.Flush() + + // The last column is not padded, so lines end without trailing spaces. + widths := make([]int, len(rows[0])) + for _, row := range rows { + for i, cell := range row { + if w := displayWidth(cell); w > widths[i] { + widths[i] = w + } + } + } + + // Two spaces between columns, as the rest of kopsc's tables use. + const gap = 2 + var b strings.Builder + for _, row := range rows { + b.Reset() + for i, cell := range row { + b.WriteString(cell) + if i < len(row)-1 { + b.WriteString(strings.Repeat(" ", widths[i]-displayWidth(cell)+gap)) + } + } + fmt.Fprintln(req.Stdout, strings.TrimRight(b.String(), " ")) + } +} + +// displayAddress shows a listening address the way ss/netstat print it: a +// wildcard bind is "*", an IPv6 wildcard is "::". +func displayAddress(addr string) string { + switch addr { + case "", "0.0.0.0", "::": + return "*" + } + return addr +} + +// displayWidth counts the columns a string occupies in a terminal: two for +// wide (CJK) runes, one otherwise. Combining marks are ignored. +func displayWidth(s string) int { + width := 0 + for _, r := range s { + width += runeWidth(r) + } + return width +} + +// runeWidth is the usual wcwidth approximation: East Asian wide characters +// take two columns, everything else one. +func runeWidth(r rune) int { + switch { + case r == 0: + return 0 + case r < 32: + return 0 + case r < 0x1100: + return 1 + case r <= 0x115F, // Hangul Jamo + r >= 0x2E80 && r <= 0xA4CF, // CJK radicals, Kana, CJK ideographs + r >= 0xAC00 && r <= 0xD7A3, // Hangul syllables + r >= 0xF900 && r <= 0xFAFF, // CJK compatibility ideographs + r >= 0xFE30 && r <= 0xFE6F, // CJK compatibility forms + r >= 0xFF00 && r <= 0xFF60, // fullwidth forms + r >= 0xFFE0 && r <= 0xFFE6, + r >= 0x1F300 && r <= 0x1F64F, // emoji + r >= 0x20000 && r <= 0x3FFFD: + return 2 + } + return 1 } // listenCommandFor picks the platform's command. diff --git a/internal/executor/netports_test.go b/internal/executor/netports_test.go index 69928a3..3ecfd27 100644 --- a/internal/executor/netports_test.go +++ b/internal/executor/netports_test.go @@ -218,3 +218,75 @@ func TestNetPortsRawKeepsCommandOutput(t *testing.T) { func strconvQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } + +func TestDisplayWidth(t *testing.T) { + cases := map[string]int{ + "端口": 4, // each CJK rune is two columns + "PID": 3, + "127.0.0.1": 9, + "进程": 4, + "": 0, + } + for in, want := range cases { + if got := displayWidth(in); got != want { + t.Errorf("displayWidth(%q) = %d, want %d", in, got, want) + } + } +} + +// TestRenderListeningPortsAlignsCJKHeader guards the alignment bug: with the +// old byte-based tabwriter the CJK headings pushed every data column, because +// each heading rune counts as three bytes but only two columns. +func TestRenderListeningPortsAlignsCJKHeader(t *testing.T) { + ports := []listeningPort{ + {Proto: "TCP", Address: "127.0.0.1", Port: 22, PID: "901", Process: "sshd"}, + {Proto: "TCP", Address: "0.0.0.0", Port: 3306, PID: "2442", Process: "docker"}, + } + var out bytes.Buffer + renderListeningPorts(ports, BuiltinRequest{Stdout: &out, Stderr: &out}) + + lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n") + if len(lines) != 3 { + t.Fatalf("expected header + 2 rows, got %d:\n%s", len(lines), out.String()) + } + + // "协议" is the heading above the "TCP" column, so that cell must start at + // the same display column on every line — the bug put it further right. + want := columnStart(lines[0], "协议") + for i, line := range lines[1:] { + if got := columnStart(line, "TCP"); got != want { + t.Errorf("row %d: TCP starts at display column %d, header 协议 at %d:\n%s", + i+1, got, want, out.String()) + } + } + + for _, line := range lines { + if line != strings.TrimRight(line, " ") { + t.Errorf("trailing spaces in %q", line) + } + } +} + +// columnStart returns the display column at which cell begins in line. +func columnStart(line, cell string) int { + i := strings.Index(line, cell) + if i < 0 { + return -1 + } + return displayWidth(line[:i]) +} + +func TestDisplayAddress(t *testing.T) { + cases := map[string]string{ + "0.0.0.0": "*", + "::": "*", + "": "*", + "127.0.0.1": "127.0.0.1", + "::1": "::1", + } + for in, want := range cases { + if got := displayAddress(in); got != want { + t.Errorf("displayAddress(%q) = %q, want %q", in, got, want) + } + } +}