::Site::::Page::

# JSLN: Meeting JSON halfway

Nobody likes writing or editing JSON. That’s why JSON5 and JSONC and TOML etc exist.

But those either add way too much complexity of their own, or they simplify the wrong things about JSON. I really just want JSON, only, slightly more convenient.

So in the spirit of xkcd.com/927, I present to you, yet another JSON alternative:

foo=123
bar.a="hello"
bar.b="world"

friends.names[]="Norm MacDonald"
friends.names[]="Old Harold Delaney"

also.nested.lists=[["work" 123] null true false 0x234f]

multiline.strings.too=
"""
hello world
with multiline strings
"""

# and comments of course

which represents:

{
  "foo": 123,
  "bar": {
    "a": "hello",
    "b": "world",
  },
  "friends": {
    "names": [
      "Norm MacDonald",
      "Old Harold Delaney"
    ]
  },
  "also": {
    "nested": {
      "lists": [["work", 123], null, true, false, 9039]
    }
  },
  "multiline": {
    "strings": {
      "too": "hello world\nwith multiline strings"
    }
  }
}

The goal is to be simple enough that it doesn’t need a spec for you to refer to; it’s just JSON, but with hex/binary numbers, no commas, and easier keys to read/type.

Probably nobody should use this. But I do and will continue to.

Anyway here’s the source code (229 loc) for parse/stringify, I’m releasing it as MIT:

class JSLNParser {

  private array
  private i = 0

  private root: Record<string, any> = {}
  private current = this.root

  constructor(str: string) {
    this.array = Array.from(str)
  }

  parse() {
    while (this.i < this.array.length) {
      this.current = this.root

      this.skipspace()

      if (this.ch() === '#') {
        while (!this.isend() && !this.isnewline()) this.i++
        continue
      }

      if (this.isnewline()) { this.i++; continue }
      if (this.isend()) break

      const key = this.buildkeys()
      const val = this.someval()

      this.current[key] = val
    }
    return this.root
  }

  private buildkeys() {
    while (true) {
      let key = this.somekey()
      this.skipspace()

      if (this.ch() === '[' && this.peek() === ']') {
        const array = this.current[key] ??= []
        key = array.length
        this.current = array
        this.i++
        this.i++
      }

      if (this.ch() === '.') {
        this.current = this.current[key] ??= {}
        this.i++
        this.skipspace()
        continue
      }
      else if (this.ch() === '=') {
        this.i++
        this.skipspace()
        return key
      }
      else {
        this.error(`Expected . or =, got ${this.ch()}`)
      }
    }
  }

  private someval() {
    if (this.isend()) this.error(`Unexpected EOS after key`)
    if (this.isnewline()) return this.multi()
    if (this.ch()!.match(/['"`]/)) return this.string(this.ch()!)
    if (this.ch() === '[') return this.inlinearray()

    const ident = this.ident()
    if (ident === 'null') return null
    if (ident === 'true') return true
    if (ident === 'false') return false

    const n = +ident
    if (ident !== 'NaN' && !isNaN(n)) return n

    this.error(`Expected value, got ${ident}`)
  }

  private inlinearray() {
    this.i++
    const vals: any[] = []
    this.skipspace()
    while (this.ch() !== ']') {
      const val = this.someval()
      vals.push(val)
      this.skipspace()
      if (this.ch() === ',') this.i++
      this.skipspace()
    }
    this.i++
    return vals
  }

  private multi() {
    this.i++
    if (this.isend()) this.error(`Expected multiline value, got EOS`)
    const delim = this.toeol()
    const lines: string[] = []
    while (!this.isend()) {
      const line = this.toeol()
      if (line === delim) break
      lines.push(line.replace(/\n$/, ''))
    }
    return lines.join('\n')
  }

  private toeol() {
    let start = this.i
    while (!this.isnewline() && !this.isend()) this.i++
    this.i++
    return this.array.slice(start, this.i).join('')
  }

  private somekey() {
    if (this.ch()!.match(/['"`]/))
      return this.string(this.ch()!)
    else
      return this.ident()
  }

  private string(term: string) {
    this.i++
    const chs = []
    while (this.ch() !== term) {
      if (this.isend()) this.error(`Unexpected EOS in string`)
      if (this.ch() === '\\') {
        this.i++
        chs.push(this.escape())
      }
      chs.push(this.ch())
      this.i++
    }
    this.i++
    return chs.join('')
  }

  private ident() {
    const chs = []
    while (this.ch()?.match(/[a-zA-Z0-9_-]/)) {
      chs.push(this.ch())
      this.i++
    }
    return chs.join('')
  }

  private escape() {
    const literals: Record<string, string> = { n: '\n', t: '\t', "'": "" }
    const ch = this.ch()!
    if (ch in literals) return literals[ch]
    this.error(`Unknown escape: ${this.ch()}`)
  }

  private error(s: string): never {
    throw new SyntaxError(s + '\n\n' + this.array.slice(0, this.i).join(''))
  }
  private skipspace() { while (this.isspace()) this.i++ }
  private ch(): string | undefined { return this.array[this.i] }
  private peek(): string | undefined { return this.array[this.i + 1] }
  private isend() { return this.ch() === undefined }
  private isnewline() { return this.ch()?.match(/[\n]/) }
  private isspace() { return this.ch()?.match(/[ \t]/) }

}

class JSLNEncoder {

  private root
  private lines: string[] = []
  private keys: string[] = []
  private stringifiers?: Record<string, (o: any) => string> | undefined

  constructor(o: Record<string, any>, stringifiers?: Record<string, (o: any) => string>) {
    this.root = o
    this.stringifiers = stringifiers
  }

  stringify() {
    this.runobj(this.root)
    return this.lines.join('\n')
  }

  private runobj(o: Record<string, any>) {
    if ('toJsln' in o && typeof o["toJsln"] === 'function') {
      o = o["toJsln"]()
    }

    const keys = [...this.keys]
    for (const [k, v] of Object.entries(o)) {
      this.keys = [...keys, this.tostr(k)]
      this.pushval(v)
    }
  }

  private pushval(o: any) {
    if (o === null) return this.finishline('null')
    if (o === true) return this.finishline('true')
    if (o === false) return this.finishline('false')
    if (typeof o === 'number') return this.finishline(o)
    if (typeof o === 'string') return this.finishline(this.toqstr(o))
    if (typeof o === 'function') return this.finishline(o())

    if (typeof o !== 'object') throw new SyntaxError(`Can't stringify object: ${o}`)

    if (o instanceof Array) {
      const lastkey = this.keys.pop() + '[]'
      this.keys.push(lastkey)
      const keys = [...this.keys]
      for (const v of o) {
        this.keys = keys
        this.pushval(v)
      }
    }
    else {
      this.runobj(o)
    }
  }

  private toqstr(o: string) {
    if (o.includes('\n')) return this.multiline(o)
    return `'${o.replace("'", "\\'")}'`
  }

  private tostr(o: string) {
    if (o.match(/^[a-zA-Z0-9_-]+$/)) return o
    return this.toqstr(o)
  }

  private multiline(o: string) {
    const lines = o.split('\n')

    const iter = maybekey()
    // ugh firefox esr
    let key: string
    while (true) {
      key = iter.next().value!
      if (!lines.includes(key)) break
    }

    lines.push(key)
    lines.unshift(key)
    lines.unshift('')

    return lines.join('\n')
  }

  private finishline(val: any) {
    const finalkey = this.keys.join('.')
    const fn = this.stringifiers?.[finalkey]
    const finalval = fn ? fn(val) : val

    this.lines.push(finalkey + '=' + finalval)
    this.keys = []
  }

}

export const JSLN = {

  parse(str: string) {
    return new JSLNParser(str).parse()
  },

  tryParse(str: string) {
    try { return new JSLNParser(str).parse() }
    catch (e) { return undefined }
  },

  stringify(o: Record<string, any>, stringifiers?: Record<string, (o: any) => string>) {
    return new JSLNEncoder(o, stringifiers).stringify()
  },

}

function* maybekey() {
  let i = 0
  do yield '='.repeat(i)
  while (++i)
}