1/**
2 * Formats JavaScript values into readable strings.
3 *
4 * `format` is intended for logs, diagnostics, and error messages. It handles
5 * primitives, objects, arrays, dates, regular expressions, maps, sets, class
6 * instances, errors, circular references, and redactable values. `formatJson`
7 * wraps JSON formatting with redaction and circular-reference handling, and the
8 * module also includes helpers for property keys, paths, and dates.
9 *
10 * @since 4.0.0
11 */
12import * as Predicate from "./Predicate.ts"
13import { getRedacted, redact, symbolRedactable } from "./Redactable.ts"
14
15/**
16 * A callable interface representing a function that converts a `Value` into a `Format`, which defaults to `string`.
17 *
18 * **When to use**
19 *
20 * Use when you want to type a formatting or rendering function generically, or when you are building a pipeline that accepts pluggable formatters.
21 *
22 * **Details**
23 *
24 * This is a pure callable type and carries no runtime implementation. It is contravariant in `Value` and covariant in `Format`.
25 *
26 * **Example** (Defining a custom formatter)
27 *
28 * ```ts import.meta.vitest
29 * import type { Formatter } from "effect"
30 *
31 * const upper: Formatter.Formatter<string> = (s) => s.toUpperCase()
32 *
33 * upper("hello") // => "HELLO"
34 * ```
35 *
36 * @see {@link format}
37 * @see {@link formatJson}
38 * @category models
39 * @since 4.0.0
40 */
41export interface Formatter<in Value, out Format = string> {
42 (value: Value): Format
43}
44
45/**
46 * Converts any JavaScript value into a human-readable string.
47 *
48 * **When to use**
49 *
50 * Use when you need to format arbitrary JavaScript values for debugging,
51 * logging, or error messages.
52 *
53 * **Details**
54 *
55 * - Output is **not** valid JSON; use {@link formatJson} when you need
56 * parseable JSON.
57 * - Handles `BigInt`, `Symbol`, `Set`, `Map`, `Date`, `RegExp`, and class
58 * instances that `JSON.stringify` cannot represent.
59 * - Circular references are shown as `"[Circular]"` instead of throwing.
60 * - Failures while inspecting a value are rendered as diagnostic placeholders instead of throwing.
61 * - Primitives: stringified naturally (`null`, `undefined`, `123`, `true`).
62 * Strings are JSON-quoted.
63 * - Objects with a custom `toString` (not `Object.prototype.toString`):
64 * `toString()` is called unless `ignoreToString` is `true`.
65 * - Errors with a `cause`: formatted as `"<message> (cause: <cause>)"`.
66 * - Iterables (`Set`, `Map`, etc.): formatted as
67 * `ClassName([...elements])`.
68 * - Class instances: wrapped as `ClassName({...})`.
69 * - `Redactable` values are automatically redacted.
70 * - Arrays/objects with 0–1 entries are inline; larger ones are
71 * pretty-printed when `space` is set.
72 * - `space` — indentation unit (number of spaces, or a string like
73 * `"\t"`). Defaults to `0` (compact).
74 * - `ignoreToString` — skip calling `toString()`. Defaults to `false`.
75 *
76 * **Example** (Formatting compact output)
77 *
78 * ```ts import.meta.vitest
79 * import { Formatter } from "effect"
80 *
81 * Formatter.format({ a: 1, b: [2, 3] }) // => "{\"a\":1,\"b\":[2,3]}"
82 * ```
83 *
84 * **Example** (Pretty-printed output)
85 *
86 * ```ts import.meta.vitest
87 * import { Formatter } from "effect"
88 *
89 * const output = Formatter.format({ a: 1, b: [2, 3] }, { space: 2 })
90 * output // => "{\n \"a\": 1,\n \"b\": [\n 2,\n 3\n ]\n}"
91 * ```
92 *
93 * **Example** (Handling circular references)
94 *
95 * ```ts import.meta.vitest
96 * import { Formatter } from "effect"
97 *
98 * const obj: any = { name: "loop" }
99 * obj.self = obj
100 * Formatter.format(obj) // => "{\"name\":\"loop\",\"self\":[Circular]}"
101 * ```
102 *
103 * @see {@link formatJson}
104 * @see {@link Formatter}
105 * @category formatting
106 * @since 2.0.0
107 */
108export function format(input: unknown, options?: {
109 readonly space?: number | string | undefined
110 readonly ignoreToString?: boolean | undefined
111}): string {
112 const space = options?.space ?? 0
113 const ancestors = new WeakSet<object>()
114 const gap = !space ? "" : (typeof space === "number" ? " ".repeat(space) : space)
115 const ind = (d: number) => gap.repeat(d)
116
117 const wrap = (v: unknown, body: string): string => {
118 const ctor = (v as any)?.constructor
119 return ctor && ctor !== Object.prototype.constructor && ctor.name ? `${ctor.name}(${body})` : body
120 }
121
122 const ownKeys = (o: object): Array<PropertyKey> => {
123 try {
124 return Reflect.ownKeys(o)
125 } catch {
126 return ["[ownKeys threw]"]
127 }
128 }
129
130 function recur(v: unknown, d = 0): string {
131 try {
132 return recurUnsafe(v, d)
133 } catch {
134 if ((typeof v === "object" && v !== null) || typeof v === "function") ancestors.delete(v)
135 return "[inspection threw]"
136 }
137 }
138
139 function recurUnsafe(v: unknown, d = 0): string {
140 if (typeof v === "string") return JSON.stringify(v)
141
142 if (
143 typeof v === "number" ||
144 v == null ||
145 typeof v === "boolean" ||
146 typeof v === "symbol"
147 ) return String(v)
148
149 if (typeof v === "bigint") return String(v) + "n"
150
151 if (typeof v === "object" || typeof v === "function") {
152 if (ancestors.has(v)) return CIRCULAR
153 ancestors.add(v)
154
155 let output: string
156 if (symbolRedactable in v) {
157 output = recur(getRedacted(v as any), d)
158 } else if (Array.isArray(v)) {
159 output = !gap || v.length <= 1
160 ? `[${v.map((x) => recur(x, d)).join(",")}]`
161 : `[\n${ind(d + 1)}${v.map((x) => recur(x, d + 1)).join(",\n" + ind(d + 1))}\n${ind(d)}]`
162 } else if (v instanceof Date) {
163 output = formatDate(v)
164 } else if (
165 !options?.ignoreToString &&
166 Predicate.hasProperty(v, "toString") &&
167 typeof v["toString"] === "function" &&
168 v["toString"] !== Object.prototype.toString &&
169 v["toString"] !== Array.prototype.toString
170 ) {
171 const s = safeToString(v)
172 output = v instanceof Error && v.cause !== undefined ? `${s} (cause: ${recur(v.cause, d)})` : s
173 } else if (Symbol.iterator in v) {
174 output = `${v.constructor.name}(${recur(Array.from(v as any), d)})`
175 } else {
176 const keys = ownKeys(v)
177 if (!gap || keys.length <= 1) {
178 const body = `{${keys.map((k) => `${formatPropertyKey(k)}:${recur(safeGet(v, k), d)}`).join(",")}}`
179 output = wrap(v, body)
180 } else {
181 const body = `{\n${
182 keys.map((k) => `${ind(d + 1)}${formatPropertyKey(k)}: ${recur(safeGet(v, k), d + 1)}`).join(",\n")
183 }\n${ind(d)}}`
184 output = wrap(v, body)
185 }
186 }
187 ancestors.delete(v)
188 return output
189 }
190
191 return String(v)
192 }
193
194 return recur(input, 0)
195}
196
197const CIRCULAR = "[Circular]"
198
199/**
200 * @internal
201 */
202export function formatPropertyKey(name: PropertyKey): string {
203 return typeof name === "string" ? JSON.stringify(name) : String(name)
204}
205
206/**
207 * Formats an array of property keys as a bracket-notation path string.
208 *
209 * @internal
210 */
211export function formatPath(path: ReadonlyArray<PropertyKey>): string {
212 return path.map((key) => `[${formatPropertyKey(key)}]`).join("")
213}
214
215/**
216 * Formats a `Date` as an ISO 8601 string, returning `"Invalid Date"` for
217 * invalid dates instead of throwing.
218 *
219 * @internal
220 */
221export function formatDate(date: Date): string {
222 try {
223 return date.toISOString()
224 } catch {
225 return "Invalid Date"
226 }
227}
228
229function safeToString(input: any): string {
230 try {
231 const s = input.toString()
232 return typeof s === "string" ? s : String(s)
233 } catch {
234 return "[toString threw]"
235 }
236}
237
238function safeGet(input: object, key: PropertyKey): unknown {
239 try {
240 return (input as any)[key]
241 } catch {
242 return "[property access threw]"
243 }
244}
245
246/**
247 * Stringifies a value to JSON safely, silently dropping circular references.
248 *
249 * **When to use**
250 *
251 * Use when you need valid JSON output, unlike `format`, and the input may
252 * contain circular references that should be silently omitted rather than
253 * throwing a `TypeError`.
254 *
255 * **Details**
256 *
257 * Uses `JSON.stringify` internally with a replacer that tracks the current
258 * object ancestry. Circular references are replaced with `undefined`, which
259 * omits them from object output. `Redactable` values are automatically redacted
260 * before serialization. `BigInt` values are stringified with an `n` suffix.
261 * `Error` instances without a `toJSON` property include their enumerable
262 * properties plus `name` and `message`. Errors with `toJSON` keep their custom
263 * representation. Other values follow standard `JSON.stringify` behavior. The
264 * `space` parameter controls indentation and defaults to `0`.
265 *
266 * **Gotchas**
267 *
268 * When the root input is `undefined`, a symbol, or a function, `formatJson`
269 * returns `"null"` instead of the `undefined` returned by `JSON.stringify`.
270 * Nested values retain standard `JSON.stringify` behavior.
271 *
272 * **Example** (Formatting compact JSON)
273 *
274 * ```ts import.meta.vitest
275 * import { Formatter } from "effect"
276 *
277 * Formatter.formatJson({ name: "Alice", age: 30 }) // => "{\"name\":\"Alice\",\"age\":30}"
278 * ```
279 *
280 * **Example** (Handling circular references)
281 *
282 * ```ts import.meta.vitest
283 * import { Formatter } from "effect"
284 *
285 * const obj: any = { name: "test" }
286 * obj.self = obj
287 * Formatter.formatJson(obj) // => "{\"name\":\"test\"}"
288 * ```
289 *
290 * **Example** (Pretty-printed JSON)
291 *
292 * ```ts import.meta.vitest
293 * import { Formatter } from "effect"
294 *
295 * const output = Formatter.formatJson({ name: "Alice", age: 30 }, { space: 2 })
296 * output // => "{\n \"name\": \"Alice\",\n \"age\": 30\n}"
297 * ```
298 *
299 * @see {@link format}
300 * @see {@link Formatter}
301 * @category serialization
302 * @since 4.0.0
303 */
304export function formatJson(input: unknown, options?: {
305 readonly space?: number | string | undefined
306}): string {
307 const ancestors: Array<object> = []
308 return JSON.stringify(
309 input,
310 function(this: object, key: string, value: unknown) {
311 const original = Object.getOwnPropertyDescriptor(this, key)?.value
312 const redacted = Predicate.hasProperty(original, symbolRedactable)
313 ? redact(original)
314 : redact(value)
315 if (typeof redacted === "bigint") {
316 return format(redacted)
317 }
318 if (typeof redacted !== "object" || redacted === null) {
319 return redacted
320 }
321 const current = redacted instanceof Error && !Predicate.hasProperty(redacted, "toJSON")
322 ? { ...redacted, name: redacted.name, message: redacted.message }
323 : redacted
324 while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) {
325 ancestors.pop()
326 }
327 if (ancestors.includes(redacted)) {
328 return undefined // circular reference
329 }
330 ancestors.push(redacted)
331 if (current !== redacted) {
332 ancestors.push(current)
333 }
334 return current
335 },
336 options?.space
337 ) ?? "null"
338}