Repository navigation
Expand file tree
/
Copy pathwrite.go
More file actions
269 lines (249 loc) · 7.06 KB
/
Copy pathwrite.go
File metadata and controls
269 lines (249 loc) · 7.06 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
package main
import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/antonmedv/fx/internal/engine"
"github.com/antonmedv/fx/internal/ident"
. "github.com/antonmedv/fx/internal/jsonx"
)
// write runs :w[rite][!] [file]: it writes the displayed JSON, the query
// result if a query is applied, to file, or to the input file without one.
// Overwriting the input file with a query result, or any other existing
// file, asks for confirmation, unless ! is given.
//
// :w !cmd pipes the JSON to the shell command's stdin instead, as in vim:
// :w !less, :w !vim -, :w !code -, :w !pbcopy.
func (m *model) write(c call) tea.Cmd {
return m.writeThen(c, nil)
}
// writeQuit runs :wq[!] [file]: write, and quit once written.
func (m *model) writeQuit(c call) tea.Cmd {
return m.writeThen(c, func() tea.Cmd { return tea.Quit })
}
// writeThen is write, running then once the file is written, if it is.
func (m *model) writeThen(c call, then func() tea.Cmd) tea.Cmd {
if reason := m.writeBlocked(); reason != "" {
return m.errorf("%s", reason)
}
if cmd, ok := strings.CutPrefix(c.arg, "!"); ok {
return m.pipe(strings.TrimSpace(cmd), then)
}
path := c.arg
if path == "" {
if engine.FilePath == "" {
return m.errorf("No file name")
}
path = engine.FilePath
}
path = expandHome(path)
same := engine.FilePath != "" && sameFile(path, engine.FilePath)
if same && (inputFormat != nil || flagRaw) {
return m.errorf("Can't write JSON over \"%s\", write to another file", engine.FilePath)
}
write := func() tea.Cmd {
if !m.writeFile(path, same) || then == nil {
return nil
}
return then()
}
switch {
case c.bang:
return write()
case same && m.original != nil:
return m.ask(fmt.Sprintf("Overwrite \"%s\" with the query result?", path), write)
case !same && fileExists(path):
return m.ask(fmt.Sprintf("\"%s\" exists, overwrite?", path), write)
}
return write()
}
// writeBlocked returns why the view can't be written now, or "".
func (m *model) writeBlocked() string {
switch {
case !m.eof:
return "Input is still loading"
case m.restoring || m.query != nil && !m.query.done:
return "Query is still running"
case m.loadErr != nil:
// The input is incomplete, whatever view is shown.
return "Input has errors: " + firstLine(m.loadErr.Error())
case len(m.queryErrors) > 0:
return "Query result has errors"
}
json, text := countDocs(m.top)
switch {
case m.original == nil && text > 0:
// Recovered non-JSON lines would be dropped from the input.
return "Input contains text that is not JSON"
case json == 0:
return "Nothing to write"
}
return ""
}
// countDocs counts the top-level documents from top on that are JSON and
// that are text (recovered non-JSON input, println output).
func countDocs(top *Node) (json, text int) {
for doc := top; doc != nil; doc = nextDoc(doc) {
if doc.Kind == Err {
text++
} else {
json++
}
}
return json, text
}
func firstLine(s string) string {
line, _, _ := strings.Cut(s, "\n")
return line
}
// writeFile writes the displayed JSON to path and reports whether it did.
// same reports whether path is the input file.
func (m *model) writeFile(path string, same bool) bool {
data := m.viewJSON()
if same {
// Windows can't rename over the open input file.
engine.CloseInput()
}
if err := engine.WriteFile(path, data); err != nil {
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
m.errorf("Can't write \"%s\": %v", path, pathErr.Err)
} else {
m.errorf("%v", err)
}
return false
}
m.infof("%s", writtenMessage(path, data))
return true
}
// writtenMessage is the success report of :w: the target with the line and
// the byte count of data.
func writtenMessage(name string, data []byte) string {
return fmt.Sprintf("\"%s\" %dL, %dB written", name, bytes.Count(data, []byte{'\n'}), len(data))
}
// execProcess runs a command in place of the UI. Tests run it in-process.
var execProcess = tea.ExecProcess
// shellDoneMsg reports that the command run by :w !cmd has exited.
type shellDoneMsg struct {
report string // the success message
err error
then func() tea.Cmd
}
// pipe runs :w !cmd: it feeds the displayed JSON to the shell command's
// stdin. The command takes over the terminal while it runs, so pagers and
// editors work. then runs once the command exits successfully, if it is.
func (m *model) pipe(cmd string, then func() tea.Cmd) tea.Cmd {
if cmd == "" {
return m.errorf("Argument required")
}
data := m.viewJSON()
sh := shellCommand(cmd)
sh.Stdin = bytes.NewReader(data)
// A child left behind holding stdin, as with :w !tool &, must not
// block Wait, and so the UI, forever.
sh.WaitDelay = time.Second
report := writtenMessage("!"+cmd, data)
return execProcess(sh, func(err error) tea.Msg {
return shellDoneMsg{report: report, err: err, then: then}
})
}
func (m *model) shellDone(msg shellDoneMsg) tea.Cmd {
if msg.err != nil {
var exitErr *exec.ExitError
if errors.As(msg.err, &exitErr) {
if code := exitErr.ExitCode(); code >= 0 {
return m.errorf("shell returned %d", code)
}
// Killed by a signal: "signal: interrupt".
return m.errorf("%v", exitErr)
}
return m.errorf("%v", msg.err)
}
m.infof("%s", msg.report)
if msg.then != nil {
return msg.then()
}
return nil
}
// viewJSON serializes the displayed JSON documents: a single one indented,
// several as JSON Lines, so a JSON Lines input keeps its format. Text lines
// (println output) are left out.
func (m *model) viewJSON() []byte {
var docs []*Node
for doc := m.top; doc != nil; doc = nextDoc(doc) {
if doc.Kind != Err {
docs = append(docs, doc)
}
}
var b bytes.Buffer
if len(docs) == 1 {
writeIndented(&b, docs[0])
return b.Bytes()
}
for _, doc := range docs {
writeDoc(&b, doc)
}
return b.Bytes()
}
// writeIndented serializes the top-level document doc indented like the
// view, the collapsed parts included.
func writeIndented(b *bytes.Buffer, doc *Node) {
for it := doc; it != nil; {
if !it.IsWrap() {
for range it.Depth {
b.WriteString(ident.Ident)
}
if it.Key != "" {
b.WriteString(it.Key)
b.WriteString(": ")
}
b.WriteString(it.Value)
if it.Comma && it != doc.End {
b.WriteByte(',')
}
b.WriteByte('\n')
}
if it == doc.End || doc.End == nil {
break
}
if it.IsCollapsed() {
it = it.Collapsed
} else {
it = it.Next
}
}
}
// expandHome replaces a leading ~ with the home directory.
func expandHome(path string) string {
if path != "~" && !strings.HasPrefix(path, "~/") {
return path
}
home, err := os.UserHomeDir()
if err != nil {
return path
}
return filepath.Join(home, path[1:])
}
// sameFile reports whether a and b name the same file. A file that does
// not exist yet is compared by its absolute path.
func sameFile(a, b string) bool {
ai, errA := os.Stat(a)
bi, errB := os.Stat(b)
if errA == nil && errB == nil {
return os.SameFile(ai, bi)
}
absA, errA := filepath.Abs(a)
absB, errB := filepath.Abs(b)
return errA == nil && errB == nil && absA == absB
}
func fileExists(path string) bool {
_, err := os.Lstat(path)
return err == nil
}