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(nl): add -d/-f/-h/-l/-p and logical-page sections

Implements GNU coreutils compatibility for nl:

- -d delim (logical-page delimiter chars; default \\:)
- -f type / -h type (footer/header numbering)
- -l num (consecutive blank-line grouping)
- -p (don't reset numbering at logical page breaks)
- Logical pages: input divided into header/body/footer
via \\:\\:\\:, \\:\\:, \\: markers; numbering resets per
section; -h/-b/-f control which sections get numbered
- Unnumbered lines emit width+sep_len spaces

Retains -b, -i, -n, -s, -v, -w.

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

Xe Iaso 4c2d5a76 64a3dfa5

+241 -30
+163 -28
command/internal/nl/nl.go
··· 6 6 "fmt" 7 7 "io" 8 8 "path" 9 + "regexp" 9 10 "strconv" 10 11 "strings" 11 12 ··· 15 16 ) 16 17 17 18 type Impl struct{} 19 + 20 + type sectionStyle struct { 21 + kind string 22 + re *regexp.Regexp 23 + } 18 24 19 25 type options struct { 20 - bodyStyle string 26 + bodyStyle sectionStyle 27 + headerStyle sectionStyle 28 + footerStyle sectionStyle 21 29 numberFormat string 22 30 width int 23 31 separator string 24 32 startNumber int 25 33 increment int 34 + joinBlanks int 35 + noRenumber bool 36 + headerMark string 37 + bodyMark string 38 + footerMark string 26 39 } 27 40 28 41 func (Impl) Exec(_ context.Context, ec *command.ExecContext, args []string) error { ··· 47 60 fmt.Fprint(stderr, "Usage: nl [OPTION]... [FILE]...\n") 48 61 fmt.Fprint(stderr, "Write each FILE to standard output, with line numbers added.\n") 49 62 fmt.Fprint(stderr, "If no FILE is specified, standard input is read.\n\n") 50 - fmt.Fprint(stderr, " -b STYLE Body numbering style: a (all), t (non-empty), n (none)\n") 63 + fmt.Fprint(stderr, " -b STYLE Body numbering style: a (all), t (non-empty), n (none), pBRE\n") 64 + fmt.Fprint(stderr, " -d CC Use CC for logical page delimiters (default \\:)\n") 65 + fmt.Fprint(stderr, " -f STYLE Footer numbering style (default n)\n") 66 + fmt.Fprint(stderr, " -h STYLE Header numbering style (default n)\n") 67 + fmt.Fprint(stderr, " -i INCR Line number increment (default: 1)\n") 68 + fmt.Fprint(stderr, " -l NUMBER Group of NUMBER empty lines counted as one (default 1)\n") 51 69 fmt.Fprint(stderr, " -n FORMAT Number format: ln (left), rn (right), rz (right zeros)\n") 52 - fmt.Fprint(stderr, " -w WIDTH Number width (default: 6)\n") 70 + fmt.Fprint(stderr, " -p Do not reset line numbers at logical page delimiters\n") 53 71 fmt.Fprint(stderr, " -s SEP Separator after number (default: TAB)\n") 54 72 fmt.Fprint(stderr, " -v START Starting line number (default: 1)\n") 55 - fmt.Fprint(stderr, " -i INCR Line number increment (default: 1)\n") 73 + fmt.Fprint(stderr, " -w WIDTH Number width (default: 6)\n") 56 74 fmt.Fprint(stderr, " --help display this help and exit\n") 57 75 } 58 76 set.SetUsage(usage) 59 77 60 78 bodyStyle := set.String('b', "t", "body numbering style") 79 + delimSpec := set.String('d', `\:`, "page delimiter pair") 80 + footerStyle := set.String('f', "n", "footer numbering style") 81 + headerStyle := set.String('h', "n", "header numbering style") 82 + incrSpec := set.String('i', "1", "line number increment") 83 + joinSpec := set.String('l', "1", "blank line group size") 61 84 numberFormat := set.String('n', "rn", "number format") 62 - widthSpec := set.String('w', "6", "number width") 85 + noRenumber := set.Bool('p', "do not reset line numbers at page delimiters") 63 86 separator := set.String('s', "\t", "separator after number") 64 87 startSpec := set.String('v', "1", "starting line number") 65 - incrSpec := set.String('i', "1", "line number increment") 88 + widthSpec := set.String('w', "6", "number width") 66 89 help := set.BoolLong("help", 0, "display this help and exit") 67 90 68 91 if err := set.Getopt(append([]string{"nl"}, args...), nil); err != nil { ··· 75 98 return nil 76 99 } 77 100 78 - switch *bodyStyle { 79 - case "a", "t", "n": 80 - default: 101 + body, err := parseStyle(*bodyStyle) 102 + if err != nil { 81 103 fmt.Fprintf(stderr, "nl: invalid body numbering style: '%s'\n", *bodyStyle) 82 104 return interp.ExitStatus(1) 83 105 } 106 + header, err := parseStyle(*headerStyle) 107 + if err != nil { 108 + fmt.Fprintf(stderr, "nl: invalid header numbering style: '%s'\n", *headerStyle) 109 + return interp.ExitStatus(1) 110 + } 111 + footer, err := parseStyle(*footerStyle) 112 + if err != nil { 113 + fmt.Fprintf(stderr, "nl: invalid footer numbering style: '%s'\n", *footerStyle) 114 + return interp.ExitStatus(1) 115 + } 84 116 85 117 switch *numberFormat { 86 118 case "ln", "rn", "rz": ··· 107 139 return interp.ExitStatus(1) 108 140 } 109 141 142 + join, err := strconv.Atoi(*joinSpec) 143 + if err != nil || join < 1 { 144 + fmt.Fprintf(stderr, "nl: invalid line group size: '%s'\n", *joinSpec) 145 + return interp.ExitStatus(1) 146 + } 147 + 148 + d1, d2 := parseDelim(*delimSpec) 149 + headerMark := strings.Repeat(d1+d2, 3) 150 + bodyMark := strings.Repeat(d1+d2, 2) 151 + footerMark := d1 + d2 152 + 110 153 opts := options{ 111 - bodyStyle: *bodyStyle, 154 + bodyStyle: body, 155 + headerStyle: header, 156 + footerStyle: footer, 112 157 numberFormat: *numberFormat, 113 158 width: width, 114 159 separator: *separator, 115 160 startNumber: startNumber, 116 161 increment: increment, 162 + joinBlanks: join, 163 + noRenumber: *noRenumber, 164 + headerMark: headerMark, 165 + bodyMark: bodyMark, 166 + footerMark: footerMark, 117 167 } 118 168 119 169 files := set.Args() 120 - lineNumber := opts.startNumber 170 + state := &numberState{lineNumber: opts.startNumber, section: 'b'} 121 171 var output strings.Builder 122 172 123 173 if len(files) == 0 { ··· 125 175 if err != nil { 126 176 return err 127 177 } 128 - out, _ := processContent(content, opts, lineNumber) 129 - output.WriteString(out) 178 + output.WriteString(processContent(content, opts, state)) 130 179 } else { 131 180 for _, file := range files { 132 181 content, err := readFile(ec, file, stderr) ··· 134 183 io.WriteString(stdout, output.String()) 135 184 return err 136 185 } 137 - out, next := processContent(content, opts, lineNumber) 138 - output.WriteString(out) 139 - lineNumber = next 186 + output.WriteString(processContent(content, opts, state)) 140 187 } 141 188 } 142 189 ··· 144 191 return nil 145 192 } 146 193 147 - func processContent(content string, opts options, currentNumber int) (string, int) { 194 + type numberState struct { 195 + lineNumber int 196 + section byte 197 + blankRun int 198 + } 199 + 200 + // parseStyle parses a section numbering style. The forms are: 201 + // - "a" (all), "t" (non-empty), "n" (none) 202 + // - "pSTRING" — number lines matching STRING. POSIX/GNU specify a 203 + // basic regular expression here; we accept Go's RE2 syntax instead 204 + // of implementing a separate BRE engine. Most simple anchors and 205 + // character classes used in practice are compatible. 206 + func parseStyle(s string) (sectionStyle, error) { 207 + switch s { 208 + case "a", "t", "n": 209 + return sectionStyle{kind: s}, nil 210 + } 211 + if strings.HasPrefix(s, "p") && len(s) > 1 { 212 + re, err := regexp.Compile(s[1:]) 213 + if err != nil { 214 + return sectionStyle{}, err 215 + } 216 + return sectionStyle{kind: "p", re: re}, nil 217 + } 218 + return sectionStyle{}, fmt.Errorf("invalid style %q", s) 219 + } 220 + 221 + func parseDelim(s string) (string, string) { 222 + if s == "" { 223 + return "", "" 224 + } 225 + runes := []rune(s) 226 + if len(runes) == 1 { 227 + return string(runes[0]), ":" 228 + } 229 + return string(runes[0]), string(runes[1]) 230 + } 231 + 232 + func processContent(content string, opts options, st *numberState) string { 148 233 if content == "" { 149 - return "", currentNumber 234 + return "" 150 235 } 151 236 152 237 lines := strings.Split(content, "\n") ··· 156 241 } 157 242 158 243 var out strings.Builder 159 - lineNumber := currentNumber 160 244 for i, line := range lines { 161 245 if i > 0 { 162 246 out.WriteByte('\n') 163 247 } 164 - if shouldNumber(line, opts.bodyStyle) { 165 - out.WriteString(formatLineNumber(lineNumber, opts.numberFormat, opts.width)) 248 + 249 + if marker, ok := matchMarker(line, opts); ok { 250 + st.section = marker 251 + st.blankRun = 0 252 + if !opts.noRenumber { 253 + st.lineNumber = opts.startNumber 254 + } 255 + continue 256 + } 257 + 258 + style := opts.bodyStyle 259 + switch st.section { 260 + case 'h': 261 + style = opts.headerStyle 262 + case 'f': 263 + style = opts.footerStyle 264 + } 265 + 266 + numbered := shouldNumber(line, style, opts.joinBlanks, st) 267 + if numbered { 268 + out.WriteString(formatLineNumber(st.lineNumber, opts.numberFormat, opts.width)) 166 269 out.WriteString(opts.separator) 167 270 out.WriteString(line) 168 - lineNumber += opts.increment 271 + st.lineNumber += opts.increment 169 272 } else { 170 - out.WriteString(strings.Repeat(" ", opts.width)) 171 - out.WriteString(opts.separator) 273 + out.WriteString(strings.Repeat(" ", opts.width+len(opts.separator))) 172 274 out.WriteString(line) 173 275 } 174 276 } 175 277 if hasTrailingNewline { 176 278 out.WriteByte('\n') 177 279 } 178 - return out.String(), lineNumber 280 + return out.String() 281 + } 282 + 283 + func matchMarker(line string, opts options) (byte, bool) { 284 + if opts.footerMark == "" { 285 + return 0, false 286 + } 287 + if line == opts.headerMark { 288 + return 'h', true 289 + } 290 + if line == opts.bodyMark { 291 + return 'b', true 292 + } 293 + if line == opts.footerMark { 294 + return 'f', true 295 + } 296 + return 0, false 179 297 } 180 298 181 - func shouldNumber(line, style string) bool { 182 - switch style { 299 + func shouldNumber(line string, style sectionStyle, joinBlanks int, st *numberState) bool { 300 + isBlank := line == "" 301 + if !isBlank { 302 + st.blankRun = 0 303 + } 304 + switch style.kind { 183 305 case "a": 184 - return true 306 + if !isBlank { 307 + return true 308 + } 309 + st.blankRun++ 310 + if st.blankRun >= joinBlanks { 311 + st.blankRun = 0 312 + return true 313 + } 314 + return false 185 315 case "t": 186 316 return strings.TrimSpace(line) != "" 187 317 case "n": 188 318 return false 319 + case "p": 320 + if style.re == nil { 321 + return false 322 + } 323 + return style.re.MatchString(line) 189 324 } 190 325 return false 191 326 }
+78 -2
command/internal/nl/nl_test.go
··· 29 29 write("empty.txt", []byte("")) 30 30 write("part1.txt", []byte("a\nb\n")) 31 31 write("part2.txt", []byte("c\nd\n")) 32 + write("sections.txt", []byte("\\:\\:\\:\nh1\nh2\n\\:\\:\nb1\n\nb2\n\\:\nf1\nf2\n")) 33 + write("manyblanks.txt", []byte("a\n\n\n\n\nb\n\n\n\n\nc\n")) 34 + write("custom.txt", []byte("#@#@#@\nh1\n#@#@\nb1\n#@\nf1\n")) 32 35 return fs 33 36 } 34 37 ··· 64 67 { 65 68 name: "default skips blank lines (style t)", 66 69 args: []string{"blanks.txt"}, 67 - wantStdout: " 1\talpha\n \t\n 2\tbravo\n \t\n 3\tcharlie\n", 70 + wantStdout: " 1\talpha\n \n 2\tbravo\n \n 3\tcharlie\n", 68 71 }, 69 72 { 70 73 name: "style a numbers all lines", ··· 74 77 { 75 78 name: "style n numbers no lines", 76 79 args: []string{"-b", "n", "hello.txt"}, 77 - wantStdout: " \thello\n \tworld\n", 80 + wantStdout: " hello\n world\n", 78 81 }, 79 82 { 80 83 name: "style attached to flag (-ba)", ··· 185 188 args: []string{"--nope"}, 186 189 wantErr: true, 187 190 }, 191 + { 192 + name: "header and footer numbered separately, page resets", 193 + args: []string{"-ha", "-ba", "-fa", "sections.txt"}, 194 + wantStdout: "\n 1\th1\n 2\th2\n\n 1\tb1\n 2\t\n 3\tb2\n\n 1\tf1\n 2\tf2\n", 195 + }, 196 + { 197 + name: "p flag keeps numbering across page breaks", 198 + args: []string{"-ha", "-ba", "-fa", "-p", "-v", "10", "sections.txt"}, 199 + wantStdout: "\n 10\th1\n 11\th2\n\n 12\tb1\n 13\t\n 14\tb2\n\n 15\tf1\n 16\tf2\n", 200 + }, 201 + { 202 + name: "header and footer default to n", 203 + args: []string{"sections.txt"}, 204 + wantStdout: "\n h1\n h2\n\n 1\tb1\n \n 2\tb2\n\n f1\n f2\n", 205 + }, 206 + { 207 + name: "explicit -h n -b a -f n numbers only body including blanks", 208 + args: []string{"-h", "n", "-b", "a", "-f", "n", "sections.txt"}, 209 + wantStdout: "\n h1\n h2\n\n 1\tb1\n 2\t\n 3\tb2\n\n f1\n f2\n", 210 + }, 211 + { 212 + name: "join blank lines with l 3", 213 + args: []string{"-ba", "-l", "3", "manyblanks.txt"}, 214 + wantStdout: " 1\ta\n \n \n 2\t\n \n 3\tb\n \n \n 4\t\n \n 5\tc\n", 215 + }, 216 + { 217 + name: "unnumbered prefix is width plus sep length", 218 + args: []string{"-bn", "-w", "3", "-s", ": ", "hello.txt"}, 219 + wantStdout: " hello\n world\n", 220 + }, 221 + { 222 + name: "custom delimiter", 223 + args: []string{"-d", "#@", "-ha", "-ba", "-fa", "custom.txt"}, 224 + wantStdout: "\n 1\th1\n\n 1\tb1\n\n 1\tf1\n", 225 + }, 226 + { 227 + name: "single-char delim defaults second to colon", 228 + args: []string{"-d", "#", "-ha", "custom.txt"}, 229 + wantStdout: " 1\t#@#@#@\n 2\th1\n 3\t#@#@\n 4\tb1\n 5\t#@\n 6\tf1\n", 230 + }, 231 + { 232 + name: "pBRE numbers only matching lines", 233 + args: []string{"-b", "pba", "hello.txt"}, 234 + wantStdout: " hello\n world\n", 235 + }, 236 + { 237 + name: "pBRE matches", 238 + args: []string{"-b", "p^h", "hello.txt"}, 239 + wantStdout: " 1\thello\n world\n", 240 + }, 241 + { 242 + name: "invalid header style", 243 + args: []string{"-h", "x", "hello.txt"}, 244 + wantErrSub: "invalid header numbering style", 245 + wantErr: true, 246 + }, 247 + { 248 + name: "invalid footer style", 249 + args: []string{"-f", "x", "hello.txt"}, 250 + wantErrSub: "invalid footer numbering style", 251 + wantErr: true, 252 + }, 253 + { 254 + name: "invalid join blanks", 255 + args: []string{"-l", "0", "hello.txt"}, 256 + wantErrSub: "invalid line group size", 257 + wantErr: true, 258 + }, 188 259 } 189 260 190 261 for _, tt := range tests { ··· 220 291 } 221 292 if !strings.Contains(stderr, "-b STYLE") { 222 293 t.Errorf("body style flag missing from help: %q", stderr) 294 + } 295 + for _, want := range []string{"-d CC", "-f STYLE", "-h STYLE", "-l NUMBER", "-p"} { 296 + if !strings.Contains(stderr, want) { 297 + t.Errorf("flag %q missing from help: %q", want, stderr) 298 + } 223 299 } 224 300 }