fix: net ports 表格按显示宽度对齐,修正中文表头导致的错位
text/tabwriter 按字节数计算列宽,中文表头每个字 3 字节但只占 2 列, 导致表头下方的数据列整体错位。改为自行计算终端显示宽度(CJK 算 2 列) 补空格,行尾不留空白;监听地址 0.0.0.0/:: 统一显示为 *。
This commit is contained in:
@@ -9,7 +9,6 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// listeningPort is one listening socket with its owner, normalised across
|
// 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.
|
// 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) {
|
func renderListeningPorts(ports []listeningPort, req BuiltinRequest) {
|
||||||
if len(ports) == 0 {
|
if len(ports) == 0 {
|
||||||
if req.Stderr != nil {
|
if req.Stderr != nil {
|
||||||
@@ -79,19 +82,87 @@ func renderListeningPorts(ports []listeningPort, req BuiltinRequest) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tw := tabwriter.NewWriter(req.Stdout, 0, 0, 2, ' ', 0)
|
rows := [][]string{{"端口", "协议", "地址", "PID", "进程"}}
|
||||||
fmt.Fprintln(tw, "端口\t协议\t地址\tPID\t进程")
|
|
||||||
for _, p := range ports {
|
for _, p := range ports {
|
||||||
pid, process := p.PID, p.Process
|
pid, process := p.PID, p.Process
|
||||||
if pid == "" {
|
if pid == "" {
|
||||||
pid, process = "-", "-"
|
pid = "-"
|
||||||
}
|
}
|
||||||
if process == "" {
|
if process == "" {
|
||||||
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.
|
// listenCommandFor picks the platform's command.
|
||||||
|
|||||||
@@ -218,3 +218,75 @@ func TestNetPortsRawKeepsCommandOutput(t *testing.T) {
|
|||||||
func strconvQuote(s string) string {
|
func strconvQuote(s string) string {
|
||||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user