createErrorMessageWithContext.ts•6.98 kB
import * as DMMF from '@prisma/dmmf'
import indentString from 'indent-string'
import { bold, dim, gray, red, underline } from 'kleur/colors'
import { CallSite, LocationInFile } from './CallSite'
import { SourceFileSlice } from './SourceFileSlice'
declare global {
  /**
   * a global variable that is injected by us via jest to make our snapshots
   * work in clients that cannot read from disk (e.g. wasm or edge clients)
   */
  let $getTemplateParameters: typeof getTemplateParameters | undefined
}
export interface ErrorArgs {
  callsite?: CallSite
  originalMethod: string
  message: string
  isPanic?: boolean
  showColors?: boolean
  callArguments?: string
}
type Colors = {
  red: (str: string) => string
  gray: (str: string) => string
  dim: (str: string) => string
  bold: (str: string) => string
  underline: (str: string) => string
  highlightSource: (source: SourceFileSlice) => SourceFileSlice
}
const colorsEnabled: Colors = {
  red,
  gray,
  dim,
  bold,
  underline,
  highlightSource: (source) => source.highlight(),
}
const colorsDisabled: Colors = {
  red: (str) => str,
  gray: (str) => str,
  dim: (str) => str,
  bold: (str) => str,
  underline: (str) => str,
  highlightSource: (source) => source,
}
type ErrorContextTemplateParameters = {
  functionName: string
  message: string
  location?: LocationInFile
  contextLines?: SourceFileSlice
  callArguments?: string
  isPanic: boolean
}
function getRawTemplateParameters({
  message,
  originalMethod,
  isPanic,
  callArguments,
}: ErrorArgs): ErrorContextTemplateParameters {
  return {
    functionName: `prisma.${originalMethod}()`,
    message,
    isPanic: isPanic ?? false,
    callArguments,
  }
}
export function getTemplateParameters(
  { callsite, message, originalMethod, isPanic, callArguments }: ErrorArgs,
  colors: Colors,
): ErrorContextTemplateParameters {
  const templateParameters = getRawTemplateParameters({ message, originalMethod, isPanic, callArguments })
  // @ts-ignore
  if (!callsite || typeof window !== 'undefined') {
    return templateParameters
  }
  if (process.env.NODE_ENV === 'production') {
    return templateParameters
  }
  const callLocation = callsite.getLocation()
  if (!callLocation || !callLocation.lineNumber || !callLocation.columnNumber) {
    return templateParameters
  }
  const contextFirstLine = Math.max(1, callLocation.lineNumber - 3)
  let source = SourceFileSlice.read(callLocation.fileName)?.slice(contextFirstLine, callLocation.lineNumber)
  const invocationLine = source?.lineAt(callLocation.lineNumber)
  if (source && invocationLine) {
    const invocationLineIndent = getIndent(invocationLine)
    const invocationCallCode = findPrismaActionCall(invocationLine)
    if (!invocationCallCode) {
      return templateParameters
    }
    templateParameters.functionName = `${invocationCallCode.code})`
    templateParameters.location = callLocation
    if (!isPanic) {
      source = source.mapLineAt(callLocation.lineNumber, (line) => line.slice(0, invocationCallCode.openingBraceIndex))
    }
    source = colors.highlightSource(source)
    const numberColumnWidth = String(source.lastLineNumber).length
    templateParameters.contextLines = source
      .mapLines((line, lineNumber) => colors.gray(String(lineNumber).padStart(numberColumnWidth)) + ' ' + line)
      .mapLines((line) => colors.dim(line))
      .prependSymbolAt(callLocation.lineNumber, colors.bold(colors.red('→')))
    if (callArguments) {
      let indentValue = invocationLineIndent + numberColumnWidth + 1 /* space between number and code */
      indentValue += 2 // arrow + space between arrow and number
      // indent all lines but first, because first line of the arguments will be printed
      // on the same line as the function call
      templateParameters.callArguments = indentString(callArguments, indentValue).slice(indentValue)
    }
  }
  return templateParameters
}
function findPrismaActionCall(str: string): { code: string; openingBraceIndex: number } | null {
  const allActions = Object.keys(DMMF.ModelAction).join('|')
  const regexp = new RegExp(String.raw`\.(${allActions})\(`)
  const match = regexp.exec(str)
  if (match) {
    const openingBraceIndex = match.index + match[0].length
    // to get the code we are slicing the string up to a found brace. We start
    // with first non-space character if space is found in the line before that or
    // 0 if it is not.
    const statementStart = str.lastIndexOf(' ', match.index) + 1
    return {
      code: str.slice(statementStart, openingBraceIndex),
      openingBraceIndex,
    }
  }
  return null
}
function getIndent(line: string): number {
  let spaceCount = 0
  for (let i = 0; i < line.length; i++) {
    if (line.charAt(i) !== ' ') {
      return spaceCount
    }
    spaceCount++
  }
  return spaceCount
}
function stringifyErrorMessage(
  { functionName, location, message, isPanic, contextLines, callArguments }: ErrorContextTemplateParameters,
  colors: Colors,
) {
  const lines: string[] = ['']
  const introSuffix = location ? ' in' : ':'
  if (isPanic) {
    lines.push(colors.red(`Oops, an unknown error occurred! This is ${colors.bold('on us')}, you did nothing wrong.`))
    lines.push(colors.red(`It occurred in the ${colors.bold(`\`${functionName}\``)} invocation${introSuffix}`))
  } else {
    lines.push(colors.red(`Invalid ${colors.bold(`\`${functionName}\``)} invocation${introSuffix}`))
  }
  if (location) {
    lines.push(colors.underline(stringifyLocationInFile(location)))
  }
  if (contextLines) {
    lines.push('')
    const contextLineParts = [contextLines.toString()]
    if (callArguments) {
      contextLineParts.push(callArguments)
      contextLineParts.push(colors.dim(')'))
    }
    lines.push(contextLineParts.join(''))
    if (callArguments) {
      lines.push('')
    }
  } else {
    lines.push('')
    if (callArguments) {
      lines.push(callArguments)
    }
    lines.push('')
  }
  lines.push(message)
  return lines.join('\n')
}
function stringifyLocationInFile(location: LocationInFile): string {
  const parts = [location.fileName]
  if (location.lineNumber) {
    parts.push(String(location.lineNumber))
  }
  if (location.columnNumber) {
    parts.push(String(location.columnNumber))
  }
  return parts.join(':')
}
export function createErrorMessageWithContext(args: ErrorArgs): string {
  const colors = args.showColors ? colorsEnabled : colorsDisabled
  let templateParameters: ErrorContextTemplateParameters
  if (
    TARGET_BUILD_TYPE === 'wasm-engine-edge' ||
    TARGET_BUILD_TYPE === 'wasm-compiler-edge' ||
    TARGET_BUILD_TYPE === 'edge'
  ) {
    if (typeof $getTemplateParameters !== 'undefined') {
      templateParameters = $getTemplateParameters(args, colors)
    } else {
      templateParameters = getRawTemplateParameters(args)
    }
  } else {
    templateParameters = getTemplateParameters(args, colors)
  }
  return stringifyErrorMessage(templateParameters, colors)
}