@smthrs/std 1.0.0-rc.6 → 1.0.0-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,7 @@ import * as ChildProcess from "effect/process/ChildProcess"
21
21
  import * as Schema from "effect/Schema"
22
22
  import * as Stream from "effect/Stream"
23
23
  import * as StdError from "../StdError.ts"
24
+ import { elideMiddle } from "./Text.ts"
24
25
 
25
26
  /**
26
27
  * Failure codes a buffered execution can report, matching the codes the
@@ -66,9 +67,9 @@ export interface ExecResult {
66
67
  readonly stdout: string
67
68
  readonly stderr: string
68
69
  readonly exitCode: number
69
- /** UTF-8 bytes dropped from the head of stdout to stay inside the capture bound. */
70
+ /** UTF-8 bytes of stdout dropped to stay inside the capture bound; see `overflow`. */
70
71
  readonly stdoutDroppedBytes: number
71
- /** UTF-8 bytes dropped from the head of stderr to stay inside the capture bound. */
72
+ /** UTF-8 bytes of stderr dropped to stay inside the capture bound; see `overflow`. */
72
73
  readonly stderrDroppedBytes: number
73
74
  }
74
75
 
@@ -97,18 +98,26 @@ export interface ExecOptions {
97
98
  */
98
99
  readonly stdin?: string | undefined
99
100
  /**
100
- * Bytes retained per captured stream. A stream that prints more keeps its
101
- * tail and reports what it dropped, so a command printing gigabytes costs a
102
- * bounded amount of memory rather than the whole of what it printed.
101
+ * Bytes retained per captured stream. A stream that prints more keeps what
102
+ * `overflow` names and reports what it dropped, so a command printing
103
+ * gigabytes costs a bounded amount of memory rather than the whole of what it
104
+ * printed.
103
105
  *
104
106
  * Absent means unbounded, which is what a caller wants when it needs every
105
107
  * byte a tool produced (`rg --files`, `git worktree list`).
106
108
  */
107
109
  readonly maxCaptureBytes?: number | undefined
108
- /** Defaults to `tail`; `refuse` fails if either stream exceeds the bound. */
109
- readonly overflow?: "tail" | "refuse" | undefined
110
+ /**
111
+ * What a stream past the bound keeps. `tail`, the default, keeps its last
112
+ * bytes. `ends` keeps its first and last halves and puts one line naming the
113
+ * dropped bytes where the middle was. `refuse` fails if either stream exceeds
114
+ * the bound.
115
+ */
116
+ readonly overflow?: Overflow | undefined
110
117
  }
111
118
 
119
+ type Overflow = "tail" | "ends" | "refuse"
120
+
112
121
  /** One captured stream: the text kept, and the bytes dropped to keep it bounded. */
113
122
  interface Capture {
114
123
  readonly text: string
@@ -121,70 +130,143 @@ interface Tail {
121
130
  dropped: number
122
131
  }
123
132
 
133
+ /** A stream's first bytes and its last ones: what `overflow: "ends"` keeps. */
134
+ interface Ends {
135
+ readonly head: Array<Uint8Array>
136
+ headBytes: number
137
+ readonly tail: Tail
138
+ }
139
+
124
140
  const decoder = new TextDecoder("utf-8")
125
141
 
142
+ const emptyTail = (): Tail => ({ chunks: [], bytes: 0, dropped: 0 })
143
+
144
+ /** A UTF-8 continuation byte is `10xxxxxx`; every other byte starts a character. */
145
+ const isContinuation = (byte: number): boolean => (byte & 0b1100_0000) === 0b1000_0000
146
+
147
+ const join = (chunks: ReadonlyArray<Uint8Array>, size: number): Uint8Array => {
148
+ const bytes = new Uint8Array(size)
149
+ let offset = 0
150
+ for (const chunk of chunks) {
151
+ bytes.set(chunk, offset)
152
+ offset += chunk.byteLength
153
+ }
154
+ return bytes
155
+ }
156
+
157
+ /** Appends one chunk and drops from the front whatever no longer fits. */
158
+ const keepTail = (state: Tail, chunk: Uint8Array, maxBytes: number): void => {
159
+ if (chunk.byteLength === 0) return
160
+ state.chunks.push(chunk)
161
+ state.bytes += chunk.byteLength
162
+ while (state.bytes > maxBytes && state.chunks.length > 0) {
163
+ const excess = state.bytes - maxBytes
164
+ const first = state.chunks[0]!
165
+ if (first.byteLength <= excess) {
166
+ state.chunks.shift()
167
+ state.bytes -= first.byteLength
168
+ state.dropped += first.byteLength
169
+ } else {
170
+ state.chunks[0] = first.subarray(excess)
171
+ state.bytes -= excess
172
+ state.dropped += excess
173
+ }
174
+ }
175
+ }
176
+
126
177
  /**
127
178
  * Joins the retained chunks and decodes them, skipping the partial code point a
128
179
  * dropped head can leave behind.
129
180
  *
130
- * A UTF-8 continuation byte is `10xxxxxx`, and a code point is at most four
131
- * bytes, so at most three leading continuation bytes can precede the first
132
- * whole character. Skipping them is counted as dropped rather than decoded to a
133
- * replacement character the process never printed.
181
+ * A code point is at most four bytes, so at most three leading continuation
182
+ * bytes can precede the first whole character. Skipping them is counted as
183
+ * dropped rather than decoded to a replacement character the process never
184
+ * printed.
134
185
  */
135
186
  const decodeTail = (state: Tail): Capture => {
136
- const bytes = new Uint8Array(state.bytes)
137
- let offset = 0
138
- for (const chunk of state.chunks) {
139
- bytes.set(chunk, offset)
140
- offset += chunk.byteLength
141
- }
187
+ const bytes = join(state.chunks, state.bytes)
142
188
  if (state.dropped === 0) return { text: decoder.decode(bytes), droppedBytes: 0 }
143
189
  let start = 0
144
- while (start < bytes.byteLength && start < 3 && (bytes[start]! & 0b1100_0000) === 0b1000_0000) start++
190
+ while (start < bytes.byteLength && start < 3 && isContinuation(bytes[start]!)) start++
145
191
  return { text: decoder.decode(bytes.subarray(start)), droppedBytes: state.dropped + start }
146
192
  }
147
193
 
194
+ /**
195
+ * The length of a cut head without the character the cut left unfinished.
196
+ *
197
+ * Its last character starts at the last byte that is not a continuation byte,
198
+ * and that byte says how many bytes the character needs. One that needs more
199
+ * than the head holds is dropped, for the same reason {@link decodeTail} skips
200
+ * a partial code point.
201
+ */
202
+ const wholeLength = (bytes: Uint8Array): number => {
203
+ for (let back = 1; back <= Math.min(3, bytes.byteLength); back++) {
204
+ const byte = bytes[bytes.byteLength - back]!
205
+ if (isContinuation(byte)) continue
206
+ const width = byte >= 0b1111_0000 ? 4 : byte >= 0b1110_0000 ? 3 : byte >= 0b1100_0000 ? 2 : 1
207
+ return width > back ? bytes.byteLength - back : bytes.byteLength
208
+ }
209
+ return bytes.byteLength
210
+ }
211
+
212
+ /**
213
+ * Decodes a stream kept by its ends. A stream that fit is decoded whole;
214
+ * otherwise each end is cut on a character boundary and one line naming every
215
+ * byte between them stands where the middle was.
216
+ */
217
+ const decodeEnds = (state: Ends): Capture => {
218
+ if (state.tail.dropped === 0) {
219
+ const whole = join([...state.head, ...state.tail.chunks], state.headBytes + state.tail.bytes)
220
+ return { text: decoder.decode(whole), droppedBytes: 0 }
221
+ }
222
+ const head = join(state.head, state.headBytes)
223
+ const kept = wholeLength(head)
224
+ const tail = decodeTail(state.tail)
225
+ const droppedBytes = head.byteLength - kept + tail.droppedBytes
226
+ return { text: elideMiddle(decoder.decode(head.subarray(0, kept)), droppedBytes, tail.text), droppedBytes }
227
+ }
228
+
148
229
  const boundedTail = <E>(
149
230
  stream: Stream.Stream<Uint8Array, E>,
150
231
  maxBytes: number,
151
- overflow: "tail" | "refuse" | undefined
232
+ overflow: Overflow | undefined
152
233
  ): Effect.Effect<Capture, E> =>
153
- Stream.runFold(
154
- stream,
155
- (): Tail => ({ chunks: [], bytes: 0, dropped: 0 }),
156
- (state, chunk) => {
157
- if (chunk.byteLength === 0) return state
158
- state.chunks.push(chunk)
159
- state.bytes += chunk.byteLength
160
- while (state.bytes > maxBytes && state.chunks.length > 0) {
161
- const excess = state.bytes - maxBytes
162
- const first = state.chunks[0]!
163
- if (first.byteLength <= excess) {
164
- state.chunks.shift()
165
- state.bytes -= first.byteLength
166
- state.dropped += first.byteLength
167
- } else {
168
- state.chunks[0] = first.subarray(excess)
169
- state.bytes -= excess
170
- state.dropped += excess
171
- }
172
- }
173
- return state
174
- }
175
- ).pipe(Effect.map((state) =>
234
+ Stream.runFold(stream, emptyTail, (state, chunk) => {
235
+ keepTail(state, chunk, maxBytes)
236
+ return state
237
+ }).pipe(Effect.map((state) =>
176
238
  overflow === "refuse" && state.dropped > 0
177
239
  ? { text: "", droppedBytes: state.dropped }
178
240
  : decodeTail(state)
179
241
  ))
180
242
 
243
+ /**
244
+ * Keeps the first half of the bound and the last half. The head fills first,
245
+ * and every byte after it passes through the tail, which drops from its front:
246
+ * what falls out of the tail is the middle.
247
+ */
248
+ const boundedEnds = <E>(stream: Stream.Stream<Uint8Array, E>, maxBytes: number): Effect.Effect<Capture, E> => {
249
+ const headBytes = Math.floor(maxBytes / 2)
250
+ return Stream.runFold(stream, (): Ends => ({ head: [], headBytes: 0, tail: emptyTail() }), (state, chunk) => {
251
+ const taken = Math.min(headBytes - state.headBytes, chunk.byteLength)
252
+ if (taken > 0) {
253
+ state.head.push(chunk.subarray(0, taken))
254
+ state.headBytes += taken
255
+ }
256
+ keepTail(state.tail, chunk.subarray(taken), maxBytes - headBytes)
257
+ return state
258
+ }).pipe(Effect.map(decodeEnds))
259
+ }
260
+
181
261
  const capture = <E>(
182
262
  stream: Stream.Stream<Uint8Array, E>,
183
263
  maxBytes: number | undefined,
184
- overflow: "tail" | "refuse" | undefined
264
+ overflow: Overflow | undefined
185
265
  ): Effect.Effect<Capture, E> =>
186
266
  maxBytes === undefined
187
267
  ? Effect.map(Stream.mkString(Stream.decodeText(stream)), (text) => ({ text, droppedBytes: 0 }))
268
+ : overflow === "ends"
269
+ ? boundedEnds(stream, maxBytes)
188
270
  : boundedTail(stream, maxBytes, overflow)
189
271
 
190
272
  const unbounded = (
@@ -124,6 +124,21 @@ export const truncateBytes = (
124
124
  }
125
125
  }
126
126
 
127
+ /**
128
+ * Joins the start and the end of a stream cut in the middle around one line
129
+ * naming the bytes cut between them.
130
+ *
131
+ * The line stands alone, so a start that stops mid-line is ended before it.
132
+ * Only the line's own newlines are added; `droppedBytes` counts printed bytes.
133
+ *
134
+ * @category rendering
135
+ * @since 1.0.0-rc.5
136
+ */
137
+ export const elideMiddle = (start: string, droppedBytes: number, end: string): string =>
138
+ `${start}${start === "" || start.endsWith("\n") ? "" : "\n"}[${droppedBytes} ${
139
+ droppedBytes === 1 ? "byte" : "bytes"
140
+ } omitted]\n${end}`
141
+
127
142
  /**
128
143
  * Splits on LF without counting a terminal LF as a line.
129
144
  * Carriage returns remain part of each line, including the CR in CRLF, so