Code View

// 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 };