A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(paste): parse backslash escapes in -d list

Implements GNU coreutils compatibility for paste:

- -d list parses \\n, \\t, \\\\, \\0 escape sequences
(\\0 acts as a "no separator" empty slot, matching
actual GNU coreutils 9.7 behavior)
- Empty -d "" concatenates with no separator
- Cycle resets per file in serial (-s) mode

Refs: docs/posix2018/CONFORMANCE.md
Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso 1052b986 11c6c20c

+142 -9
+58 -9
command/internal/paste/paste.go
··· 38 38 fmt.Fprint(stderr, "Write lines consisting of the sequentially corresponding lines from\n") 39 39 fmt.Fprint(stderr, "each FILE, separated by TABs, to standard output.\n") 40 40 fmt.Fprint(stderr, "With no FILE, or when FILE is -, read standard input.\n\n") 41 - fmt.Fprint(stderr, " -d, --delimiters=LIST reuse characters from LIST instead of TABs\n") 41 + fmt.Fprint(stderr, " -d, --delimiters=LIST reuse characters from LIST instead of TABs;\n") 42 + fmt.Fprint(stderr, " recognized backslash escapes are \\n, \\t, \\\\,\n") 43 + fmt.Fprint(stderr, " and \\0 (which represents the empty/no-delimiter\n") 44 + fmt.Fprint(stderr, " slot in the cycle). An empty LIST is accepted\n") 45 + fmt.Fprint(stderr, " and behaves like \\0 (no separator emitted).\n") 42 46 fmt.Fprint(stderr, " -s, --serial paste one file at a time instead of in parallel\n") 43 47 fmt.Fprint(stderr, " --help display this help and exit\n") 44 48 } ··· 64 68 return interp.ExitStatus(1) 65 69 } 66 70 71 + delims, err := parseDelimiters(*delimiters) 72 + if err != nil { 73 + fmt.Fprintf(stderr, "paste: %s\n", err) 74 + return interp.ExitStatus(1) 75 + } 76 + 67 77 stdinCount := 0 68 78 for _, f := range files { 69 79 if f == "-" { ··· 99 109 fileContents = append(fileContents, lines) 100 110 } 101 111 102 - delim := *delimiters 103 112 var output strings.Builder 104 113 105 114 if *serial { 106 115 for _, lines := range fileContents { 107 - output.WriteString(joinWithDelimiters(lines, delim)) 116 + output.WriteString(joinWithDelimiters(lines, delims)) 108 117 output.WriteByte('\n') 109 118 } 110 119 } else { ··· 121 130 parts[i] = lines[lineIdx] 122 131 } 123 132 } 124 - output.WriteString(joinWithDelimiters(parts, delim)) 133 + output.WriteString(joinWithDelimiters(parts, delims)) 125 134 output.WriteByte('\n') 126 135 } 127 136 } ··· 130 139 return nil 131 140 } 132 141 133 - func joinWithDelimiters(parts []string, delimiters string) string { 142 + // delim is one element of the parsed delimiter cycle. empty=true represents the 143 + // '\0' POSIX sentinel: at this slot in the cycle, no character is emitted. 144 + type delim struct { 145 + r rune 146 + empty bool 147 + } 148 + 149 + func parseDelimiters(list string) ([]delim, error) { 150 + if list == "" { 151 + return nil, nil 152 + } 153 + var out []delim 154 + runes := []rune(list) 155 + for i := 0; i < len(runes); i++ { 156 + c := runes[i] 157 + if c != '\\' { 158 + out = append(out, delim{r: c}) 159 + continue 160 + } 161 + if i+1 >= len(runes) { 162 + return nil, errors.New("delimiter list ends with an unescaped backslash") 163 + } 164 + i++ 165 + switch runes[i] { 166 + case 'n': 167 + out = append(out, delim{r: '\n'}) 168 + case 't': 169 + out = append(out, delim{r: '\t'}) 170 + case '\\': 171 + out = append(out, delim{r: '\\'}) 172 + case '0': 173 + out = append(out, delim{empty: true}) 174 + default: 175 + return nil, fmt.Errorf("'\\%c' is not a valid delimiter", runes[i]) 176 + } 177 + } 178 + return out, nil 179 + } 180 + 181 + func joinWithDelimiters(parts []string, delims []delim) string { 134 182 if len(parts) == 0 { 135 183 return "" 136 184 } 137 185 if len(parts) == 1 { 138 186 return parts[0] 139 187 } 140 - delimRunes := []rune(delimiters) 141 - if len(delimRunes) == 0 { 188 + if len(delims) == 0 { 142 189 return strings.Join(parts, "") 143 190 } 144 191 var b strings.Builder 145 192 b.WriteString(parts[0]) 146 193 for i := 1; i < len(parts); i++ { 147 - idx := (i - 1) % len(delimRunes) 148 - b.WriteRune(delimRunes[idx]) 194 + d := delims[(i-1)%len(delims)] 195 + if !d.empty { 196 + b.WriteRune(d.r) 197 + } 149 198 b.WriteString(parts[i]) 150 199 } 151 200 return b.String()
+84
command/internal/paste/paste_test.go
··· 26 26 write("a.txt", []byte("a1\na2\na3\n")) 27 27 write("b.txt", []byte("b1\nb2\n")) 28 28 write("c.txt", []byte("c1\nc2\nc3\nc4\n")) 29 + write("d.txt", []byte("d1\nd2\n")) 30 + write("f1.txt", []byte("p\nq\nr\n")) 31 + write("f2.txt", []byte("s\nt\n")) 32 + write("f3.txt", []byte("u\nv\nw\n")) 29 33 write("empty.txt", []byte("")) 30 34 write("nofinalnl.txt", []byte("x1\nx2")) 31 35 return fs ··· 149 153 name: "unknown flag errors", 150 154 args: []string{"--nope"}, 151 155 wantErr: true, 156 + }, 157 + { 158 + name: "literal tab via shell escape", 159 + args: []string{"-d", "\t", "a.txt", "b.txt"}, 160 + wantStdout: "a1\tb1\na2\tb2\na3\t\n", 161 + }, 162 + { 163 + name: "backslash-t escape parses as tab", 164 + args: []string{"-d", `\t`, "a.txt", "b.txt"}, 165 + wantStdout: "a1\tb1\na2\tb2\na3\t\n", 166 + }, 167 + { 168 + name: "backslash-n escape parses as newline", 169 + args: []string{"-d", `\n`, "a.txt", "b.txt"}, 170 + wantStdout: "a1\nb1\na2\nb2\na3\n\n", 171 + }, 172 + { 173 + name: "backslash-backslash escape parses as literal backslash", 174 + args: []string{"-d", `\\`, "a.txt", "b.txt"}, 175 + wantStdout: "a1\\b1\na2\\b2\na3\\\n", 176 + }, 177 + { 178 + name: "backslash-zero is empty separator slot", 179 + args: []string{"-d", `\0X`, "a.txt", "b.txt", "c.txt", "d.txt"}, 180 + wantStdout: "a1b1Xc1d1\na2b2Xc2d2\na3Xc3\nXc4\n", 181 + }, 182 + { 183 + name: "mixed backslash escapes cycle", 184 + args: []string{"-d", `a\nb`, "f1.txt", "f2.txt", "f3.txt"}, 185 + wantStdout: "pas\nu\nqat\nv\nra\nw\n", 186 + }, 187 + { 188 + // Three files, cycle [a, empty, b]; each line uses two 189 + // delimiter slots: 'a' between col1/col2, '' (empty) between 190 + // col2/col3. Cycle resets per output line in non-serial mode. 191 + name: "a-null-b cycle skips between cols 2 and 3", 192 + args: []string{"-d", `a\0b`, "f1.txt", "f2.txt", "f3.txt"}, 193 + wantStdout: "pasu\nqatv\nraw\n", 194 + }, 195 + { 196 + // Cycle [a, empty, b] reaches the 'b' element when there are 197 + // four files: separators are a, empty, b between cols 1-2, 2-3, 198 + // 3-4 respectively. 199 + name: "a-null-b cycle reaches third element with four files", 200 + args: []string{"-d", `a\0b`, "f1.txt", "f2.txt", "f3.txt", "f1.txt"}, 201 + wantStdout: "pasubp\nqatvbq\nrawbr\n", 202 + }, 203 + { 204 + // POSIX: empty list is unspecified. GNU and our impl silently 205 + // accept it as "no separator", matching paste -d '\0'. 206 + name: "empty delimiter list matches \\0 behavior", 207 + args: []string{"-d", "", "a.txt", "b.txt", "c.txt"}, 208 + wantStdout: "a1b1c1\na2b2c2\na3c3\nc4\n", 209 + }, 210 + { 211 + name: "mixed backslash escapes cycle reaches third element in serial", 212 + args: []string{"-s", "-d", `a\nb`, "c.txt"}, 213 + wantStdout: "c1ac2\nc3bc4\n", 214 + }, 215 + { 216 + name: "invalid backslash escape errors", 217 + args: []string{"-d", `\q`, "a.txt", "b.txt"}, 218 + wantErrSub: `'\q' is not a valid delimiter`, 219 + wantErr: true, 220 + }, 221 + { 222 + name: "trailing backslash errors", 223 + args: []string{"-d", `\`, "a.txt", "b.txt"}, 224 + wantErrSub: "delimiter list ends with an unescaped backslash", 225 + wantErr: true, 226 + }, 227 + { 228 + name: "serial single delimiter joins lines", 229 + args: []string{"-s", "-d", "X", "a.txt"}, 230 + wantStdout: "a1Xa2Xa3\n", 231 + }, 232 + { 233 + name: "serial cyclic delimiters reset between files", 234 + args: []string{"-s", "-d", "XY", "f1.txt", "f2.txt"}, 235 + wantStdout: "pXqYr\nsXt\n", 152 236 }, 153 237 } 154 238