prose

YAML, TOML and .gitignore

The files that configure a project, each a list of keys and values with # comments. A YAML key is coloured as a keyword and a plain value as a string, as shiki colours them. TOML's keys and tables stay plain, as they did with shiki, and its values take their own colours.

YAML's block scalars (run: |) carry their text on the lines below, as long as they're indented further. Those lines are coloured as one string, which no single-line rule could do. So YAML is read a line at a time.

import { type Span, rules, scan } from "./scan.js";

// A `#` starts a comment only at a line's start or after a space, so `a#b` stays a value.
const HASH_COMMENT = { re: /(?<=^|\s)#.*/m, colour: "comment" } as const;
const SCALAR =
  /(?:true|false|null|yes|no|on|off|~|[-+]?(?:\d[\d_]*\.?[\d_]*(?:e[-+]?\d+)?|\.inf|\.nan))/;

const YAML_LINE = rules([
  HASH_COMMENT,
  { re: /"(?:[^"\\\n]|\\.)*"?|'[^'\n]*'?/, colour: "string" },
  // A key that reads as a YAML 1.1 boolean, like `on`, is coloured as one, as shiki does.
  {
    re: /(?<=^[ \t]*(?:- )*)(true|false|yes|no|on|off)(:)(?=\s|$)/,
    groups: ["constant", "keyword"],
  },
  {
    re: /(?<=^[ \t]*(?:- )*)([^\s#:"'-][^:#\n]*?|"[^"\n]*"|'[^'\n]*')(:)(?=\s|$)/,
    groups: ["keyword", "keyword"],
  },
  // A flow sequence, like `[main, next]`: its items are strings, and its brackets plain.
  { re: /[[\]{},]/, colour: null },
  { re: /(?<=[[,][ \t]*)[^\s,[\]{}#"']+/, colour: "string" },
  { re: /(?<=:[ \t]+)[|>][-+]?(?=[ \t]*(?:#|$))/, colour: "keyword" },
  {
    re: new RegExp(`(?<=(?::|^[ \\t]*-)[ \\t]+)${SCALAR.source}(?=[ \\t]*(?:#|$))`),
    colour: "constant",
  },
  { re: /(?<=(?::|^[ \t]*-)[ \t]+)[^\s#"'&*!|>][^\n#]*?(?=[ \t]+#|[ \t]*$)/, colour: "string" },
  { re: /^---$|^\.\.\.$/, colour: "keyword" },
]);

export function yaml(source: string): Span[] {
  const spans: Span[] = [];
  let at = 0;
  let block = -1;
  for (const line of source.split("\n")) {
    const indent = /^[ \t]*/.exec(line)![0].length;
    const end = at + line.length;
    if (block >= 0 && (line.trim() === "" || indent > block)) {
      if (line.trim()) spans.push({ colour: "string", start: at + indent, end });
    } else {
      block = /:[ \t]+[|>][-+]?[ \t]*(?:#.*)?$/.test(line) ? indent : -1;
      // Scanned on its own, so `^` and `$` are this line's ends.
      for (const s of scan(line, YAML_LINE))
        spans.push({ ...s, start: s.start + at, end: s.end + at });
    }
    at = end + 1;
  }
  return spans;
}

const TOML = rules([
  { re: /"""[\s\S]*?(?:"""|$(?![\s\S]))|'''[\s\S]*?(?:'''|$(?![\s\S]))/, colour: "string" },
  { re: /"(?:[^"\\\n]|\\.)*"?|'[^'\n]*'?/, colour: "string" },
  HASH_COMMENT,
  { re: /^[ \t]*\[\[?[^\]\n]*\]\]?/m, colour: null },
  { re: /^([ \t]*[\w.\-"' ]+?)([ \t]*=)/m, groups: [null, "keyword"] },
  { re: /\b(?:true|false)\b|[-+]?\d[\d_:.TZ+-]*(?:e[-+]?\d+)?\b/, colour: "constant" },
  { re: /,/, colour: "punctuation" },
]);

export function toml(source: string): Span[] {
  return scan(source, TOML);
}

const GITIGNORE = rules([
  { re: /^[ \t]*#.*/m, colour: "comment" },
  { re: /^!/m, colour: "keyword" },
]);

export function gitignore(source: string): Span[] {
  return scan(source, GITIGNORE);
}