A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1package paste
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "path"
9 "strings"
10
11 "github.com/pborman/getopt/v2"
12 "mvdan.cc/sh/v3/interp"
13 "tangled.org/xeiaso.net/kefka/command"
14)
15
16type Impl struct{}
17
18func (Impl) Exec(_ context.Context, ec *command.ExecContext, args []string) error {
19 if ec == nil {
20 return errors.New("paste: nil ExecContext")
21 }
22
23 stdout := ec.Stdout
24 if stdout == nil {
25 stdout = io.Discard
26 }
27 stderr := ec.Stderr
28 if stderr == nil {
29 stderr = io.Discard
30 }
31
32 set := getopt.New()
33 set.SetProgram("paste")
34 set.SetParameters("[FILE]...")
35
36 usage := func() {
37 fmt.Fprint(stderr, "Usage: paste [OPTION]... [FILE]...\n")
38 fmt.Fprint(stderr, "Write lines consisting of the sequentially corresponding lines from\n")
39 fmt.Fprint(stderr, "each FILE, separated by TABs, to standard output.\n")
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")
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")
46 fmt.Fprint(stderr, " -s, --serial paste one file at a time instead of in parallel\n")
47 fmt.Fprint(stderr, " --help display this help and exit\n")
48 }
49 set.SetUsage(usage)
50
51 delimiters := set.StringLong("delimiters", 'd', "\t", "reuse characters from LIST instead of TABs")
52 serial := set.BoolLong("serial", 's', "paste one file at a time instead of in parallel")
53 help := set.BoolLong("help", 0, "display this help and exit")
54
55 if err := set.Getopt(append([]string{"paste"}, args...), nil); err != nil {
56 fmt.Fprintf(stderr, "paste: %s\n", err)
57 usage()
58 return interp.ExitStatus(1)
59 }
60 if *help {
61 usage()
62 return nil
63 }
64
65 files := set.Args()
66 if len(files) == 0 {
67 fmt.Fprint(stderr, "usage: paste [-s] [-d delimiters] file ...\n")
68 return interp.ExitStatus(1)
69 }
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
77 stdinCount := 0
78 for _, f := range files {
79 if f == "-" {
80 stdinCount++
81 }
82 }
83
84 var stdinLines []string
85 if stdinCount > 0 {
86 var err error
87 stdinLines, err = readStdinLines(ec)
88 if err != nil {
89 return err
90 }
91 }
92
93 fileContents := make([][]string, 0, len(files))
94 stdinIndex := 0
95 for _, file := range files {
96 if file == "-" {
97 var slice []string
98 for i := stdinIndex; i < len(stdinLines); i += stdinCount {
99 slice = append(slice, stdinLines[i])
100 }
101 fileContents = append(fileContents, slice)
102 stdinIndex++
103 continue
104 }
105 lines, err := readFileLines(ec, file, stderr)
106 if err != nil {
107 return err
108 }
109 fileContents = append(fileContents, lines)
110 }
111
112 var output strings.Builder
113
114 if *serial {
115 for _, lines := range fileContents {
116 output.WriteString(joinWithDelimiters(lines, delims))
117 output.WriteByte('\n')
118 }
119 } else {
120 maxLines := 0
121 for _, lines := range fileContents {
122 if len(lines) > maxLines {
123 maxLines = len(lines)
124 }
125 }
126 for lineIdx := 0; lineIdx < maxLines; lineIdx++ {
127 parts := make([]string, len(fileContents))
128 for i, lines := range fileContents {
129 if lineIdx < len(lines) {
130 parts[i] = lines[lineIdx]
131 }
132 }
133 output.WriteString(joinWithDelimiters(parts, delims))
134 output.WriteByte('\n')
135 }
136 }
137
138 io.WriteString(stdout, output.String())
139 return nil
140}
141
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.
144type delim struct {
145 r rune
146 empty bool
147}
148
149func 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
181func joinWithDelimiters(parts []string, delims []delim) string {
182 if len(parts) == 0 {
183 return ""
184 }
185 if len(parts) == 1 {
186 return parts[0]
187 }
188 if len(delims) == 0 {
189 return strings.Join(parts, "")
190 }
191 var b strings.Builder
192 b.WriteString(parts[0])
193 for i := 1; i < len(parts); i++ {
194 d := delims[(i-1)%len(delims)]
195 if !d.empty {
196 b.WriteRune(d.r)
197 }
198 b.WriteString(parts[i])
199 }
200 return b.String()
201}
202
203func splitLines(content string) []string {
204 if content == "" {
205 return nil
206 }
207 lines := strings.Split(content, "\n")
208 if len(lines) > 0 && lines[len(lines)-1] == "" {
209 lines = lines[:len(lines)-1]
210 }
211 return lines
212}
213
214func readStdinLines(ec *command.ExecContext) ([]string, error) {
215 if ec.Stdin == nil {
216 return nil, nil
217 }
218 data, err := io.ReadAll(ec.Stdin)
219 if err != nil {
220 return nil, interp.ExitStatus(1)
221 }
222 return splitLines(string(data)), nil
223}
224
225func readFileLines(ec *command.ExecContext, file string, stderr io.Writer) ([]string, error) {
226 if ec.FS == nil {
227 fmt.Fprintf(stderr, "paste: %s: No such file or directory\n", file)
228 return nil, interp.ExitStatus(1)
229 }
230 full := resolvePath(ec, file)
231 f, err := ec.FS.Open(full)
232 if err != nil {
233 fmt.Fprintf(stderr, "paste: %s: No such file or directory\n", file)
234 return nil, interp.ExitStatus(1)
235 }
236 data, err := io.ReadAll(f)
237 f.Close()
238 if err != nil {
239 fmt.Fprintf(stderr, "paste: %s: %v\n", file, err)
240 return nil, interp.ExitStatus(1)
241 }
242 return splitLines(string(data)), nil
243}
244
245func resolvePath(ec *command.ExecContext, p string) string {
246 dir := ec.Dir
247 if dir == "" {
248 dir = "."
249 }
250 if path.IsAbs(p) {
251 p = strings.TrimPrefix(p, "/")
252 if p == "" {
253 return "."
254 }
255 return path.Clean(p)
256 }
257 joined := path.Join(dir, p)
258 if joined == "" {
259 return "."
260 }
261 return joined
262}