Ctrl K
ComunidadGuías…

packages/sql/tokenizer.ts

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

Archivo original ↗Preguntar a KiBot
import type { DialectSpec, NameCase } from './dialect'
import { OPERATORS } from './vocabulary'

type TokenKind =
  | 'comment'
  | 'function'
  | 'identifier'
  | 'keyword'
  | 'number'
  | 'operator'
  | 'punctuation'
  | 'string'
  | 'type'
  | 'variable'

export interface Token {
  kind: TokenKind
  text: string
  start: number
  end: number
  unclosed?: true
  quoted?: true
  name?: string
}

interface OpenState {
  kind: 'comment' | 'string' | 'identifier'
  close: string
  backslash: boolean
  doubled: boolean
}

export type TokenizerState = OpenState | { kind: 'none' }

export const INITIAL_STATE: TokenizerState = { kind: 'none' }
const NO_ESCAPES = { backslash: false, doubled: false }

const WORD_START = /[\p{L}_]/u
const WORD = /[\p{L}\p{N}_$]/u
const DIGIT = /\d/u
const NON_ASCII = /[^\0-\u007F]/u
const DASH_COMMENT_GAP = /[\s\p{Cc}]/u
const DOLLAR_TAG = /\$(?<tag>[A-Za-z_]\w*)?\$/uy
const STRING_OPENER = /[BENXbenx]?'|"/uy
const ALTERNATIVE_QUOTE_OPENER = /[Nn]?[Qq]'\S/uy
const NUMBER =
  /(?:0x[\dA-Fa-f]+|\d+(?:\.\d*)?(?:[Ee][+-]?\d+)?|\.\d+(?:[Ee][+-]?\d+)?)/uy
const CALL_PAREN = /\s*\(/uy
const PUNCTUATION = new Set(['(', ')', ',', ';', '.', '[', ']', '{', '}', ':'])
const CLOSING_BRACKET: Record<string, string> = {
  '(': ')',
  '<': '>',
  '[': ']',
  '{': '}',
}

const stickyMatch = (regex: RegExp, text: string, at: number) => {
  regex.lastIndex = at
  return regex.exec(text)?.[0]
}

const nonAsciiName = (char: string, dialect: DialectSpec) =>
  dialect.nonAsciiNames && NON_ASCII.test(char)

const wordEnd = (text: string, from: number, dialect: DialectSpec) => {
  let end = from
  while (
    end < text.length &&
    (WORD.test(text[end] ?? '') || nonAsciiName(text[end] ?? '', dialect))
  ) {
    end += 1
  }
  return end
}

const scanUntil = (
  text: string,
  from: number,
  { backslash, close, doubled }: OpenState
): { end: number; unclosed: boolean } => {
  let index = from
  while (index < text.length) {
    if (backslash && text[index] === '\\') {
      index += 2
      continue
    }
    if (text.startsWith(close, index)) {
      if (doubled && text.startsWith(close, index + close.length)) {
        index += close.length * 2
        continue
      }
      return { end: index + close.length, unclosed: false }
    }
    index += 1
  }
  return { end: text.length, unclosed: true }
}

const openingAt = (
  text: string,
  index: number,
  dialect: DialectSpec
): { pending: OpenState; contentStart: number } | null => {
  const char = text[index] ?? ''
  if (text.startsWith('/*', index)) {
    return {
      contentStart: index + 2,
      pending: { ...NO_ESCAPES, close: '*/', kind: 'comment' },
    }
  }
  if (dialect.dollarQuotes && char === '$') {
    const tag = stickyMatch(DOLLAR_TAG, text, index)
    if (tag) {
      return {
        contentStart: index + tag.length,
        pending: { ...NO_ESCAPES, close: tag, kind: 'string' },
      }
    }
  }
  if (dialect.identifierQuotes.includes(char)) {
    return {
      contentStart: index + 1,
      pending: {
        ...NO_ESCAPES,
        close: CLOSING_BRACKET[char] ?? char,
        doubled: true,
        kind: 'identifier',
      },
    }
  }
  const alternativeOpener =
    dialect.alternativeQuotes &&
    stickyMatch(ALTERNATIVE_QUOTE_OPENER, text, index)
  if (alternativeOpener) {
    const delimiter = alternativeOpener.at(-1) ?? ''
    return {
      contentStart: index + alternativeOpener.length,
      pending: {
        ...NO_ESCAPES,
        close: `${CLOSING_BRACKET[delimiter] ?? delimiter}'`,
        kind: 'string',
      },
    }
  }
  const stringOpener = stickyMatch(STRING_OPENER, text, index)
  if (stringOpener) {
    return {
      contentStart: index + stringOpener.length,
      pending: {
        backslash: dialect.backslashEscapes || char.toUpperCase() === 'E',
        close: char === '"' ? '"' : "'",
        doubled: true,
        kind: 'string',
      },
    }
  }
  return null
}

const wordKind = (
  text: string,
  start: number,
  end: number,
  dialect: DialectSpec
): TokenKind => {
  const upper = text.slice(start, end).toUpperCase()
  if (dialect.keywords.has(upper)) {
    return 'keyword'
  }
  if (dialect.types.has(upper)) {
    return 'type'
  }
  if (dialect.functions.has(upper) && stickyMatch(CALL_PAREN, text, end)) {
    return 'function'
  }
  return 'identifier'
}

const startsLineComment = (text: string, index: number, dialect: DialectSpec) =>
  (text.startsWith('--', index) &&
    (!dialect.spacedDashComments ||
      DASH_COMMENT_GAP.test(text[index + 2] ?? ' '))) ||
  (dialect.hashComments && text[index] === '#' && text[index + 1] !== '>')

const tokenAt = (
  text: string,
  index: number,
  dialect: DialectSpec
): Pick<Token, 'kind' | 'end'> | null => {
  const char = text[index] ?? ''
  const next = text[index + 1] ?? ''

  if (startsLineComment(text, index, dialect)) {
    const newline = text.indexOf('\n', index)
    return { end: newline === -1 ? text.length : newline, kind: 'comment' }
  }
  if (
    (dialect.atVariables && char === '@' && WORD.test(next)) ||
    (char === '$' && DIGIT.test(next))
  ) {
    return { end: wordEnd(text, index + 1, dialect), kind: 'variable' }
  }
  if (DIGIT.test(char) || (char === '.' && DIGIT.test(next))) {
    const match = stickyMatch(NUMBER, text, index)
    if (match) {
      return { end: index + match.length, kind: 'number' }
    }
  }
  if (WORD_START.test(char) || nonAsciiName(char, dialect)) {
    const end = wordEnd(text, index + 1, dialect)
    return { end, kind: wordKind(text, index, end, dialect) }
  }
  const operator = OPERATORS.find((candidate) =>
    text.startsWith(candidate, index)
  )
  if (operator) {
    return { end: index + operator.length, kind: 'operator' }
  }
  if (PUNCTUATION.has(char)) {
    return { end: index + 1, kind: 'punctuation' }
  }
  return null
}

const foldName = (name: string, nameCase: NameCase) =>
  nameCase === 'upper' ? name.toUpperCase() : name.toLowerCase()

export const tokenize = (
  text: string,
  dialect: DialectSpec,
  initialState: TokenizerState = INITIAL_STATE
): { tokens: Token[]; state: TokenizerState } => {
  const tokens: Token[] = []
  let state = initialState
  let index = 0

  const push = (
    kind: TokenKind,
    start: number,
    end: number,
    extra?: Pick<Token, 'name' | 'quoted' | 'unclosed'>
  ) => {
    tokens.push({ end, kind, start, text: text.slice(start, end), ...extra })
    index = end
  }

  const pushEnclosed = (
    pending: OpenState,
    start: number,
    contentStart: number
  ) => {
    const { end, unclosed } = scanUntil(text, contentStart, pending)
    const { close } = pending
    push(pending.kind, start, end, {
      ...(pending.kind === 'identifier' && {
        name: text
          .slice(contentStart, unclosed ? end : end - close.length)
          .replaceAll(close + close, close),
        quoted: true,
      }),
      ...(unclosed && { unclosed }),
    })
    state = unclosed ? pending : INITIAL_STATE
  }

  while (index < text.length) {
    if (state.kind !== 'none') {
      pushEnclosed(state, index, index)
      continue
    }
    const char = text[index] ?? ''
    if (/\s/u.test(char) && !nonAsciiName(char, dialect)) {
      index += 1
      continue
    }
    const opening = openingAt(text, index, dialect)
    if (opening) {
      pushEnclosed(opening.pending, index, opening.contentStart)
      continue
    }
    const token = tokenAt(text, index, dialect)
    if (token) {
      push(
        token.kind,
        index,
        token.end,
        token.kind === 'identifier' && dialect.nameCase
          ? { name: foldName(text.slice(index, token.end), dialect.nameCase) }
          : undefined
      )
    } else {
      index += 1
    }
  }

  return { state, tokens }
}

export const identifierName = (token: Token) => token.name ?? token.text

export const isKeyword = (token: Token | undefined, ...words: string[]) =>
  token?.kind === 'keyword' && words.includes(token.text.toUpperCase())

export const isPunctuation = (token: Token | undefined, char: string) =>
  token?.kind === 'punctuation' && token.text === char