// 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: /[{}[\],:]/ }
]
};
}