// SPDX-License-Identifier: MIT
/*
* FSON writer — a faithful JavaScript port of FFS's FsonWriter.cpp
* (spec/fson/writer/FsonWriter.cpp), Fson dialect only.
*
* Format decisions (from the writer's class comment):
* - 2-space indentation per level
* - "key: value" with one space after ':'; one member/element per line
* - empty containers compact ({} / []) unless dangling comments force
* the expanded form
* - disabled members written adjacent: "--key: value"
* - strings escape only mandatory characters; raw UTF-8 passes through;
* always double-quoted (quote style not remembered)
* - numbers written by verbatim lexeme (hex stays hex, Infinity stays
* Infinity)
* - prefer-bare-keys: quoted keys are rewritten bare when the name
* satisfies the bare-name grammar (opt-in; default off, matching the
* C++ default)
* - the file ends with a single newline
*/
const { isValidName } = require('./parser');
class Indent {
constructor(step = 2) {
this.step = step;
this.n = 0;
}
right() {
this.n += this.step;
}
left() {
this.n -= this.step;
}
toString() {
return ' '.repeat(this.n);
}
}
function splitLines(text) {
// mirror C++ splitLines: "a\nb" -> ["a","b"]; "a\n" -> ["a",""]
return text.split('\n');
}
function encodeString(text) {
let out = '';
for (const ch of text) {
switch (ch) {
case '"': out += '\\"'; break;
case '\\': out += '\\\\'; break;
case '\b': out += '\\b'; break;
case '\f': out += '\\f'; break;
case '\n': out += '\\n'; break;
case '\r': out += '\\r'; break;
case '\t': out += '\\t'; break;
default: {
const code = ch.codePointAt(0);
if (code < 0x20) {
out += '\\u' + code.toString(16).padStart(4, '0');
} else {
out += ch; // raw UTF-8 passthrough (incl. U+2028/U+2029)
}
}
}
}
return out;
}
function encodeMultilineLine(line) {
let out = '';
for (const ch of line) {
switch (ch) {
case '\\': out += '\\\\'; break;
case '\b': out += '\\b'; break;
case '\f': out += '\\f'; break;
case '\r': out += '\\r'; break;
case '\t': out += '\\t'; break;
default: {
const code = ch.codePointAt(0);
if (code < 0x20) {
out += '\\u' + code.toString(16).padStart(4, '0');
} else {
out += ch;
}
}
}
}
return out;
}
class Writer {
constructor(options = {}) {
this.indent = new Indent(2);
this.preferBare = options.preferBareKeys === true;
}
write(doc) {
this.indent = new Indent(2);
let out = '';
out += this.commentLines(doc.header);
out += this.value(doc.root);
out += '\n';
out += this.commentLines(doc.footer);
return out;
}
value(v) {
switch (v.type) {
case 'null':
return 'null';
case 'boolean':
return v.value ? 'true' : 'false';
case 'number':
return v.lexeme; // Fson: verbatim lexeme
case 'string':
if (this.canWriteMultiline(v)) return this.multilineString(v);
return '"' + encodeString(v.value) + '"';
case 'array':
return this.array(v);
case 'object':
return this.object(v);
default:
return 'null';
}
}
canWriteMultiline(str) {
return (
str.type === 'string' &&
str.form === 'multiline' &&
str.value.length > 0 &&
str.value.endsWith('\n')
);
}
multilineString(str) {
// one |-line per '\n'-segment; trailing '\n' closes the last line and
// produces no extra empty line. First line inline (caller wrote "key: ").
const value = str.value;
let out = '';
let start = 0;
let first = true;
while (start < value.length) {
let nl = value.indexOf('\n', start);
const end = nl === -1 ? value.length : nl;
const line = value.slice(start, end);
if (!first) out += '\n' + this.indent.toString();
first = false;
out += '|' + encodeMultilineLine(line);
if (nl === -1) break;
start = nl + 1;
}
return out;
}
key(k) {
let bare = k.form === 'bare';
if (this.preferBare && !bare) bare = isValidName(k.name);
if (bare) return k.name;
return '"' + encodeString(k.name) + '"';
}
object(obj) {
const entries = obj.entries;
// First pass: find the last comma-bearing entry (ordinary member or
// emitted include) and whether anything is visible at all.
let lastCommaPos = -1;
let anyOutput = false;
for (let pos = 0; pos < entries.length; pos++) {
const e = entries[pos];
if (e.kind === 'member') {
// (merged-in members from %include are query-view only; the parser
// never creates them, so every member here is real)
lastCommaPos = pos;
anyOutput = true;
} else {
// include: emitted in Fson unless disabled (disabled ones are only
// kept under Keep policy, which the formatter does not use → they
// ARE emitted verbatim in the Fson dialect, disabled directives
// included, matching writeObject's Fson path)
lastCommaPos = pos;
anyOutput = true;
}
}
const hasDangling = obj.dangling.length > 0;
if (!anyOutput && !hasDangling) return '{}';
let out = '{\n';
this.indent.right();
for (let pos = 0; pos < entries.length; pos++) {
const e = entries[pos];
if (e.kind === 'include') {
const inc = e.ref;
out += this.commentLines(inc.leading);
out += this.indent.toString();
if (inc.disabled) out += '--';
out += '%include ';
out += '"' + encodeString(inc.path) + '"';
if (inc.alias != null) out += ' as ' + inc.alias;
if (pos !== lastCommaPos) out += ',';
out += this.trailingComment(inc.trailing);
continue;
}
const mem = e.ref;
out += this.commentLines(mem.leading);
const keyless = mem.disabled && mem.key.name === '';
out += this.indent.toString();
if (mem.disabled) out += '--';
if (!keyless) {
out += this.key(mem.key);
out += ': ';
}
out += this.value(mem.value);
const wroteBlock =
mem.value.type === 'string' && this.canWriteMultiline(mem.value);
if (pos !== lastCommaPos && !wroteBlock) out += ',';
out += this.trailingComment(mem.trailing);
}
out += this.commentLines(obj.dangling);
this.indent.left();
out += this.indent.toString() + '}';
return out;
}
array(arr) {
const count = arr.elements.length;
const hasDangling = arr.dangling.length > 0;
if (count === 0 && !hasDangling) return '[]';
let out = '[\n';
this.indent.right();
for (let index = 0; index < count; index++) {
const el = arr.elements[index];
out += this.commentLines(el.leading);
out += this.indent.toString();
out += this.value(el.value);
const wroteBlock =
el.value.type === 'string' && this.canWriteMultiline(el.value);
if (index + 1 < count && !wroteBlock) out += ',';
out += this.trailingComment(el.trailing);
}
out += this.commentLines(arr.dangling);
this.indent.left();
out += this.indent.toString() + ']';
return out;
}
comment(c) {
if (c.kind === 'line') return '//' + c.text;
return '/*' + c.text + '*/';
}
commentLines(comments) {
let out = '';
for (const c of comments) {
out += this.indent.toString() + this.comment(c) + '\n';
}
return out;
}
trailingComment(c) {
if (!c) return '\n';
// Fson: inline (any kind)
return ' ' + this.comment(c) + '\n';
}
}
function write(doc, options) {
return new Writer(options).write(doc);
}
module.exports = { write, encodeString };