Code View

// SPDX-License-Identifier: MIT
/*
Language: FSON
Description: FSON is a hand-editable configuration notation — a pragmatic
            superset of JSON5 that adds nested block comments, a narrower
            bare-key `name` grammar, `--` disabled members, `%include`
            directives, and `|`-block multi-line strings.
Author: FFS Language Tools
Category: config
Website: (FFS project)

Token contract: spec/fson/TOKENS.md (derived from spec/fson/railroad/*.rrd).
Scope decisions (root CLAUDE.md §4):
  - `--` disabled prefix/member  -> `deletion`
  - `|`-block is a single string -> no embedded Markdown highlighting
*/

/** @type LanguageFn */
export default function (hljs) {
  const regex = hljs.regex;

  // ── Escape sequences (TOKENS.md §6.1) ────────────────────────────────
  // Valid in quoted AND |-block strings. \xHH, \uHHHH, the named escapes,
  // a line continuation, and — FSON simplification — \<any-other-char>.
  const ESCAPE = {
    scope: 'char.escape',
    variants: [
      { match: /\\x[0-9A-Fa-f]{2}/ },
      { match: /\\u[0-9A-Fa-f]{4}/ },
      { match: /\\[\s\S]/ } // named, line-continuation, or bare-char fallback
    ]
  };

  // ── Comments (TOKENS.md §1) ──────────────────────────────────────────
  const LINE_COMMENT = hljs.COMMENT(/\/\//, /$/);

  // Nested block comment: contains itself so `/* a /* b */ c */` stays one
  // comment (TOKENS.md §1, §7.1).
  const BLOCK_COMMENT = {
    scope: 'comment',
    begin: /\/\*/,
    end: /\*\//,
    contains: []
  };
  BLOCK_COMMENT.contains.push(BLOCK_COMMENT); // self-nesting

  const COMMENTS = [LINE_COMMENT, BLOCK_COMMENT];

  // ── Numbers (TOKENS.md §6.2) ─────────────────────────────────────────
  // sign? ( Infinity | NaN | hex | decimal ). Distinguish Infinity/NaN as
  // number literals, hex, and the leading-dot / trailing-dot decimals.
  const NUMBER = {
    scope: 'number',
    variants: [
      { match: /[-+]?(?:Infinity|NaN)\b/ },
      { match: /[-+]?0[xX][0-9A-Fa-f]+\b/ }, // hex integer (00046)
      {
        // decimal (00047): classic, leading-dot, trailing-dot; optional exp
        match:
          /[-+]?(?:(?:0|[1-9]\d*)(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?/
      }
    ],
    relevance: 0
  };

  // ── Literals (TOKENS.md §6.3) ────────────────────────────────────────
  const LITERAL = {
    scope: 'literal',
    match: /\b(?:true|false|null)\b/
  };

  // ── Strings (TOKENS.md §6.1) ─────────────────────────────────────────
  // Double- and single-quoted string *values*. The other quote kind is a
  // literal character inside.
  const DQ_STRING = {
    scope: 'string',
    begin: /"/,
    end: /"/,
    contains: [ESCAPE]
  };
  const SQ_STRING = {
    scope: 'string',
    begin: /'/,
    end: /'/,
    contains: [ESCAPE]
  };
  const QUOTED_STRING = { variants: [DQ_STRING, SQ_STRING] };

  // ── `|`-block multi-line string (TOKENS.md §6.1, §7.4) ───────────────
  // Begins at a `|` that starts a line (after optional leading whitespace)
  // and is NOT the `\|` disabled-block prefix (that is handled by the
  // deletion rule). Continues while following lines also begin (after ws)
  // with `|`. Ends at a blank line, a `//` comment, or any non-`|` content.
  //
  // Implemented as a single per-line match that hljs re-applies greedily:
  // one match = the leading `|` + the rest of that source line. Escapes
  // inside are still highlighted. Consecutive `|`-lines therefore chain
  // into one visually-continuous string region.
  const PIPE_BLOCK_LINE = {
    scope: 'string',
    variants: [
      { begin: /^[ \t]*\|/, end: /$/ }, // a `|`-line at line start
      { begin: /(?<=:)[ \t]*\|/, end: /$/ } // first `|`-line after `key:`
    ],
    contains: [ESCAPE],
    relevance: 0
  };

  // ── Keys (TOKENS.md §4) ──────────────────────────────────────────────
  // Bare `name`: letter, then letters/digits with single interior
  // underscores; never starts/ends with `_`, never `__`. Narrower than
  // JSON5. Only a key when followed (after ws/comments) by `:`.
  const NAME = /[A-Za-z](?:_?[A-Za-z0-9])*/;

  const BARE_KEY = {
    scope: 'attr',
    // `(?<![A-Za-z0-9_])` prevents matching the tail of an invalid name
    // such as `a__b` (which is not a legal bare `name` and must be quoted).
    begin: regex.concat(
      /(?<![A-Za-z0-9_])/,
      NAME,
      /(?=\s*(?:\/\/[^\n]*\n|\/\*(?:[^*]|\*(?!\/))*\*\/|\s)*:)/
    ),
    relevance: 0
  };

  // Quoted key: a quoted string immediately (after ws/comments) before `:`.
  const QUOTED_KEY = {
    scope: 'attr',
    begin: /(?=(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*:)/,
    contains: [
      { begin: /"/, end: /"/, contains: [ESCAPE] },
      { begin: /'/, end: /'/, contains: [ESCAPE] }
    ],
    // close as soon as the string is consumed
    end: /(?=:)/,
    relevance: 0
  };

  // ── `%include` directive (TOKENS.md §5) ──────────────────────────────
  const INCLUDE = {
    begin: /%include\b/,
    beginScope: 'keyword',
    end: /(?=[,\n}])/,
    relevance: 10,
    contains: [
      QUOTED_STRING,
      { scope: 'keyword', match: /\bas\b/ },
      // alias name: a bare `name` (the quoted-string alias form is caught
      // by QUOTED_STRING above).
      { scope: 'title', match: NAME },
      ...COMMENTS
    ]
  };

  // ── `--` disabled prefix (TOKENS.md §3) ──────────────────────────────
  // Marks a member (or keyless |-block line) as disabled -> `deletion`.
  // Minimum-viable contract is to scope the `--` token; we extend the
  // deletion look across the disabled key / directive / |-line where it is
  // cheap and unambiguous.
  const DISABLED = {
    scope: 'deletion',
    variants: [
      // --|  keyless disabled |-block line: whole line struck through
      { match: /^[ \t]*--\|[^\n]*/ },
      // --%include ...  (mark the prefix + directive keyword)
      { match: /--(?=%include\b)/ },
      // --bare / --"quoted" / --'quoted' key
      {
        begin: /--(?=\s*(?:"|'|[A-Za-z]))/,
        end: /(?=:)/,
        contains: [
          { scope: 'string', begin: /"/, end: /"/, contains: [ESCAPE] },
          { scope: 'string', begin: /'/, end: /'/, contains: [ESCAPE] }
        ]
      }
    ],
    relevance: 10
  };

  return {
    name: 'fson',
    aliases: [], // do NOT alias json/json5 (TOKENS.md / prompt)
    case_insensitive: false,
    contains: [
      ...COMMENTS,
      DISABLED,
      INCLUDE,
      PIPE_BLOCK_LINE,
      QUOTED_KEY,
      BARE_KEY,
      QUOTED_STRING,
      NUMBER,
      LITERAL,
      { scope: 'punctuation', match: /[{}[\],:]/ }
    ]
  };
}