Ctrl K
ComunidadGuías…

scripts/typescript-lex.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { isOneOf } from "#fp";
import { SYNTAX_WORDS } from "#scripts/syntax-words.ts";
import {
  endsAValue,
  WORD_PART,
  WORD_START,
} from "#scripts/typescript-lex/slash.ts";

/** The character at `index` as one whole letter, so a wider one is one item
 * to the scanner rather than two surrogate halves. */
const charAt = (content: string, index: number): string =>
  String.fromCodePoint(content.codePointAt(index)!);

/** Whether `character` belongs to the word an apostrophe may close. A
 * surrogate half belongs, so a wider letter joins its word too. */
const isWordUnit = (character: string): boolean =>
  WORD_PART.test(character) || (character >= "\uD800" && character <= "\uDFFF");

/** In TSX text, a word's apostrophe is punctuation, not a string
 * delimiter. The one exception is a keyword a literal can start after:
 * `return'…'` is a tightly-spelled string, `It's` is text. */
const isQuoteAt = (content: string, index: number): boolean => {
  const quote = content[index];
  if (quote === '"' || quote === "`") return true;
  if (quote !== "'") return false;
  let start = index;
  while (start > 0 && isWordUnit(content[start - 1]!)) start--;
  const word = content.slice(start, index);
  return word === "" || SYNTAX_WORDS.has(word);
};

const isLineTerminator = isOneOf(["\n", "\r", "\u2028", "\u2029"]);

/** A character that doubles into `++` or `--`. */
const STEP_CHANGE = isOneOf(["+", "-"]);

/** Skip a comment at `start`, or return `start` when none starts there. */
export const skipComment = (content: string, start: number): number => {
  if (content.startsWith("//", start)) {
    for (let index = start + 2; index < content.length; index++) {
      const character = content[index] as string;
      if (character === "\r" && content[index + 1] === "\n") return index + 1;
      if (isLineTerminator(character)) return index;
    }
    return content.length;
  }
  if (content.startsWith("/*", start)) {
    const close = content.indexOf("*/", start + 2);
    return close === -1 ? content.length : close + 2;
  }
  return start;
};

type RunMove = "close" | number | null;

/** Walk one escaped run until its reader closes it or jumps over nested code. */
const runEnd = (
  content: string,
  start: number,
  moveAt: (index: number) => RunMove,
): number => {
  let index = start + 1;
  while (index < content.length) {
    if (content[index] === "\\") {
      index += 2;
    } else {
      const move = moveAt(index);
      if (move === "close") return index + 1;
      index = move ?? index + 1;
    }
  }
  return content.length;
};

const quotedEnd = (content: string, start: number, quote: string): number =>
  runEnd(content, start, (index) =>
    content[index] === quote ? "close" : null,
  );

type RegexBodyStep = "close" | "escape" | "reject" | "stay";

const regexBodyStep = (
  character: string | undefined,
  inClass: boolean,
): RegexBodyStep => {
  if (isLineTerminator(character)) return "reject";
  if (character === "\\") return "escape";
  if (character === "/" && !inClass) return "close";
  return "stay";
};

const classAfter = (
  character: string | undefined,
  inClass: boolean,
): boolean => (character === "[" ? true : character === "]" ? false : inClass);

/** Just past a regex body's closing slash. The for-of drives the walk, so
 * no mutant can freeze an index into an endless loop; the offset it follows
 * is bookkeeping for the return values alone. */
const regexBodyEnd = (content: string, start: number): number => {
  let inClass = false;
  let escaped = false;
  let offset = start + 1;
  for (const character of content.slice(start + 1)) {
    const wasEscaped: boolean = escaped;
    const step: RegexBodyStep = wasEscaped
      ? "stay"
      : regexBodyStep(character, inClass);
    escaped = step === "escape";
    if (step === "reject") return start + 1;
    if (step === "close") return offset + 1;
    if (!wasEscaped) inClass = classAfter(character, inClass);
    offset += character.length;
  }
  return content.length;
};

const LOWERCASE_FLAGS = /[a-z]*/y;

/** Just past a regex and its flags, or null when the slash divides. */
const regexEnd = (content: string, start: number): number | null => {
  const bodyEnd = regexBodyEnd(content, start);
  if (bodyEnd === start + 1) return null;
  LOWERCASE_FLAGS.lastIndex = bodyEnd;
  const flags = LOWERCASE_FLAGS.exec(content);
  return bodyEnd + flags![0].length;
};

/** One stretch of source text that executable code does not read directly. */
export interface LexicalSpan {
  end: number;
  kind: "comment" | "regex" | "string";
  start: number;
}

interface Step {
  context?: readonly string[];
  next: number;
  span?: LexicalSpan;
  tokens: readonly string[];
}

type StepReader<Result extends Step | null> = (
  content: string,
  index: number,
  context: readonly string[],
) => Result;

interface ScanResult {
  end: number;
  spans: LexicalSpan[];
  tokens: string[];
}

const NUMBER_PART = /[eE][+-]|[0-9a-fA-FoOxXbBnE._]/y;

const startsNumber = (content: string, index: number): boolean =>
  /[0-9]/.test(content[index] as string) ||
  (content[index] === "." && /[0-9]/.test(content[index + 1] ?? ""));

const numberStep = (content: string, start: number): Step => {
  let index = start;
  let dots = 0;
  while (index < content.length) {
    const read = content.slice(start, index);
    const takesDecimal =
      dots === 0 &&
      !/[eE]/.test(read) &&
      !read.endsWith("n") &&
      !/^0[xXoObB]/.test(read);
    if (content[index] === ".") {
      if (!takesDecimal) break;
      dots++;
    }
    NUMBER_PART.lastIndex = index;
    const part = NUMBER_PART.exec(content);
    if (part === null) break;
    index += part[0].length;
  }
  return { next: index, tokens: ["NUM"] };
};

/** Read one source word as a keyword or a chosen name. */
const wordStep = (
  content: string,
  start: number,
  context: readonly string[],
): Step => {
  let index = start;
  while (index < content.length) {
    const character = charAt(content, index);
    if (index > start && !WORD_PART.test(character)) break;
    index += character.length;
  }
  const word = content.slice(start, index);
  return {
    next: index,
    tokens: [SYNTAX_WORDS.has(word) && context.at(-1) !== "." ? word : "ID"],
  };
};

/** Read a template and the executable code in each interpolation. */
function templateStep(content: string, start: number): Step {
  const tokens: string[] = ["STR"];
  const end = runEnd(content, start, (index) => {
    if (content[index] === "`") return "close";
    if (content[index] === "$" && content[index + 1] === "{") {
      const group = scanCode(content, index + 2, true, ["="]);
      tokens.push(...group.tokens);
      return group.end;
    }
    return null;
  });
  return {
    context: ["STR"],
    next: end,
    span: { end, kind: "string", start },
    tokens,
  };
}

const commentStep = (content: string, index: number): Step | null => {
  const end = skipComment(content, index);
  return end === index
    ? null
    : {
        next: end,
        span: { end, kind: "comment", start: index },
        tokens: [],
      };
};

/** A JSX closing tag, or a comparison's pattern: `</name>` is a tag only
 * when no slash follows the `>`, because that slash would be the pattern's
 * own closing one. */
const startsJsxClose = (content: string, index: number): boolean =>
  /^\/(?:[$_.:\-\p{ID_Continue}]+\s*)?>(?!\/)/u.test(content.slice(index));

const regexStep: StepReader<Step | null> = (content, index, context) => {
  if (
    content[index] !== "/" ||
    (content[index - 1] === "<" && startsJsxClose(content, index)) ||
    endsAValue(context)
  ) {
    return null;
  }
  const end = regexEnd(content, index);
  return end === null
    ? { context: ["ID"], next: index + 1, tokens: ["/"] }
    : {
        next: end,
        span: { end, kind: "regex", start: index },
        tokens: ["RE"],
      };
};

const stringStep = (content: string, index: number): Step | null => {
  if (!isQuoteAt(content, index)) return null;
  const character = content[index] as string;
  if (character === "`") return templateStep(content, index);
  const end = quotedEnd(content, index, character);
  return {
    next: end,
    span: { end, kind: "string", start: index },
    tokens: ["STR"],
  };
};

const stepAt: StepReader<Step> = (content, index, context) => {
  const character = charAt(content, index);
  if (/\s/.test(character)) {
    return { next: index + character.length, tokens: [] };
  }
  const lexical =
    commentStep(content, index) ??
    regexStep(content, index, context) ??
    stringStep(content, index);
  if (lexical !== null) return lexical;
  if (startsNumber(content, index)) return numberStep(content, index);
  if (WORD_START.test(character)) return wordStep(content, index, context);
  if (character === "=" && content[index + 1] === ">") {
    return { next: index + 2, tokens: ["=>"] };
  }
  if (STEP_CHANGE(character) && content[index + 1] === character) {
    return { next: index + 2, tokens: [character + character] };
  }
  return { next: index + character.length, tokens: [character] };
};

const nextDepth = (
  stopsAtBrace: boolean,
  depth: number,
  character: string,
  oneCharacterStep: boolean,
): number => {
  if (!stopsAtBrace || !oneCharacterStep) return depth;
  if (character === "{") return depth + 1;
  if (character === "}") return depth - 1;
  return depth;
};

/** Scan executable code, optionally up to one interpolation's closing brace. */
function scanCode(
  content: string,
  start: number,
  stopsAtBrace: boolean,
  initialContext: readonly string[] = [],
): ScanResult {
  const context = [...initialContext];
  const spans: LexicalSpan[] = [];
  const tokens: string[] = [];
  let depth = 0;
  let index = start;
  while (index < content.length) {
    const character = content[index] as string;
    if (stopsAtBrace && character === "}" && depth === 0) {
      return { end: index + 1, spans, tokens };
    }
    const step = stepAt(content, index, context);
    tokens.push(...step.tokens);
    context.push(...(step.context ?? step.tokens));
    if (step.span !== undefined) spans.push(step.span);
    depth = nextDepth(stopsAtBrace, depth, character, step.next === index + 1);
    index = step.next;
  }
  return { end: content.length, spans, tokens };
}

/** Skip a string or template, or return `start` when no string starts there. */
export const skipString = (content: string, start: number): number => {
  const quote = content[start];
  if (!isQuoteAt(content, start) || quote === undefined) return start;
  return quote === "`"
    ? templateStep(content, start).next
    : quotedEnd(content, start, quote);
};

/** Skip a comment or string, or return `start` for executable code. */
export const skipCommentOrString = (content: string, start: number): number => {
  const pastComment = skipComment(content, start);
  return pastComment === start ? skipString(content, start) : pastComment;
};

/** The index of the `}` that closes the interpolation opening at `start`,
 * or the last index when the braces never close. The walk is the scan
 * `templateStep` runs over the same text, so a regex literal, a comment, or
 * a nested template inside counts as one stretch and keeps its braces out
 * of the count. */
export const interpolationEnd = (content: string, start: number): number =>
  scanCode(content, start, true, ["="]).end - 1;

/** Every comment, string, template, and regular expression in source order. */
export function* lexicalSpans(content: string): Generator<LexicalSpan> {
  yield* scanCode(content, 0, false).spans;
}

/** Just the comments, after the same complete lexical scan. */
export function* commentSpans(content: string): Generator<LexicalSpan> {
  for (const span of lexicalSpans(content)) {
    if (span.kind === "comment") yield span;
  }
}

/** The source text of every string and template literal, in order. */
export const stringSpanTexts = (content: string): string[] =>
  [...lexicalSpans(content)]
    .filter((span) => span.kind === "string")
    .map((span) => content.slice(span.start, span.end));

/** Replace lexical spans with spaces while every line offset stays fixed. */
export const blankSpans = (content: string, blankStrings: boolean): string => {
  const characters = content.split("");
  for (const span of lexicalSpans(content)) {
    if (span.kind === "string" && !blankStrings) continue;
    for (let index = span.start; index < span.end; index++) {
      const character = content[index] as string;
      characters[index] = isLineTerminator(character) ? character : " ";
    }
  }
  return characters.join("");
};

/** One body with every chosen word and literal reduced to its shape token. */
export const shapeOf = (content: string): string[] =>
  scanCode(content, 0, false).tokens;