#
#
#            Nim Tester
#        (c) Copyright 2015 Andreas Rumpf
#
#    See the file "copying.txt", included in this
#    distribution, for details about the copyright.
#

import sequtils, parseutils, strutils, os, streams, parsecfg,
  tables, hashes, sets
import compiler/platform

type TestamentData* = ref object
  # better to group globals under 1 object; could group the other ones here too
  batchArg*: string
  testamentNumBatch*: int
  testamentBatch*: int

let testamentData0* = TestamentData()

var compilerPrefix* = findExe("nim")

let isTravis* = existsEnv("TRAVIS")
let isAppVeyor* = existsEnv("APPVEYOR")
let isAzure* = existsEnv("TF_BUILD")

var skips*: seq[string]

type
  TTestAction* = enum
    actionRun = "run"
    actionCompile = "compile"
    actionReject = "reject"

  TOutputCheck* = enum
    ocIgnore = "ignore"
    ocEqual = "equal"
    ocSubstr = "substr"

  TResultEnum* = enum
    reNimcCrash,       # nim compiler seems to have crashed
    reMsgsDiffer,      # error messages differ
    reFilesDiffer,     # expected and given filenames differ
    reLinesDiffer,     # expected and given line numbers differ
    reOutputsDiffer,
    reExitcodesDiffer, # exit codes of program or of valgrind differ
    reTimeout,
    reInvalidPeg,
    reCodegenFailure,
    reCodeNotFound,
    reExeNotFound,
    reInstallFailed    # package installation failed
    reBuildFailed      # package building failed
    reDisabled,        # test is disabled
    reJoined,          # test is disabled because it was joined into the megatest
    reSuccess          # test was successful
    reInvalidSpec      # test had problems to parse the spec

  TTarget* = enum
    targetC = "c"
    targetCpp = "cpp"
    targetObjC = "objc"
    targetJS = "js"

  InlineError* = object
    kind*: string
    msg*: string
    line*, col*: int

  ValgrindSpec* = enum
    disabled, enabled, leaking

  TSpec* = object
    # xxx make sure `isJoinableSpec` takes into account each field here.
    action*: TTestAction
    file*, cmd*: string
    filename*: string ## Test filename (without path).
    input*: string
    outputCheck*: TOutputCheck
    sortoutput*: bool
    output*: string
    line*, column*: int
    exitCode*: int
    msg*: string
    ccodeCheck*: seq[string]
    maxCodeSize*: int
    err*: TResultEnum
    inCurrentBatch*: bool
    targets*: set[TTarget]
    matrix*: seq[string]
    nimout*: string
    nimoutFull*: bool # whether nimout is all compiler output or a subset
    parseErrors*: string            # when the spec definition is invalid, this is not empty.
    unjoinable*: bool
    unbatchable*: bool
      # whether this test can be batchable via `NIM_TESTAMENT_BATCH`; only very
      # few tests are not batchable; the ones that are not could be turned batchable
      # by making the dependencies explicit
    useValgrind*: ValgrindSpec
    timeout*: float # in seconds, fractions possible,
                      # but don't rely on much precision
    inlineErrors*: seq[InlineError] # line information to error message
    debugInfo*: string # debug info to give more context

proc getCmd*(s: TSpec): string =
  if s.cmd.len == 0:
    result = compilerPrefix & " $target --hints:on -d:testing --nimblePath:build/deps/pkgs2 $options $file"
  else:
    result = s.cmd

const
  targetToExt*: array[TTarget, string] = ["nim.c", "nim.cpp", "nim.m", "js"]
  targetToCmd*: array[TTarget, string] = ["c", "cpp", "objc", "js"]

proc defaultOptions*(a: TTarget): string =
  case a
  of targetJS: "-d:nodejs"
    # once we start testing for `nim js -d:nimbrowser` (eg selenium or similar),
    # we can adapt this logic; or a given js test can override with `-u:nodejs`.
  else: ""

when not declared(parseCfgBool):
  # candidate for the stdlib:
  proc parseCfgBool(s: string): bool =
    case normalize(s)
    of "y", "yes", "true", "1", "on": result = true
    of "n", "no", "false", "0", "off": result = false
    else: raise newException(ValueError, "cannot interpret as a bool: " & s)

proc addLine*(self: var string; pieces: varargs[string]) =
  for piece in pieces:
    self.add piece
  self.add "\n"


const
  inlineErrorKindMarker = "tt."
  inlineErrorMarker = "#[" & inlineErrorKindMarker

proc extractErrorMsg(s: string; i: int; line: var int; col: var int; spec: var TSpec): int =
  ## Extract inline error messages.
  ##
  ## Can parse a single message for a line:
  ##
  ## .. code-block:: nim
  ##
  ##   proc generic_proc*[T] {.no_destroy, userPragma.} = #[tt.Error
  ##        ^ 'generic_proc' should be: 'genericProc' [Name] ]#
  ##
  ## Can parse multiple messages for a line when they are separated by ';':
  ##
  ## .. code-block:: nim
  ##
  ##   proc generic_proc*[T] {.no_destroy, userPragma.} = #[tt.Error
  ##        ^ 'generic_proc' should be: 'genericProc' [Name]; tt.Error
  ##                           ^ 'no_destroy' should be: 'nodestroy' [Name]; tt.Error
  ##                                       ^ 'userPragma' should be: 'user_pragma' [template declared in mstyleCheck.nim(10, 9)] [Name] ]#
  ##
  ## .. code-block:: nim
  ##
  ##   proc generic_proc*[T] {.no_destroy, userPragma.} = #[tt.Error
  ##        ^ 'generic_proc' should be: 'genericProc' [Name];
  ##     tt.Error              ^ 'no_destroy' should be: 'nodestroy' [Name];
  ##     tt.Error                          ^ 'userPragma' should be: 'user_pragma' [template declared in mstyleCheck.nim(10, 9)] [Name] ]#
  ##
  result = i + len(inlineErrorMarker)
  inc col, len(inlineErrorMarker)
  let msgLine = line
  var msgCol = -1
  var msg = ""
  var kind = ""

  template parseKind =
    while result < s.len and s[result] in IdentChars:
      kind.add s[result]
      inc result
      inc col
    if kind notin ["Hint", "Warning", "Error"]:
      spec.parseErrors.addLine "expected inline message kind: Hint, Warning, Error"

  template skipWhitespace =
    while result < s.len and s[result] in Whitespace:
      if s[result] == '\n':
        col = 1
        inc line
      else:
        inc col
      inc result

  template parseCaret =
    if result < s.len and s[result] == '^':
      msgCol = col
      inc result
      inc col
      skipWhitespace()
    else:
      spec.parseErrors.addLine "expected column marker ('^') for inline message"

  template isMsgDelimiter: bool =
    s[result] == ';' and
    (block:
      let nextTokenIdx = result + 1 + parseutils.skipWhitespace(s, result + 1)
      if s.len > nextTokenIdx + len(inlineErrorKindMarker) and
         s[nextTokenIdx..(nextTokenIdx + len(inlineErrorKindMarker) - 1)] == inlineErrorKindMarker:
        true
      else:
        false)

  template trimTrailingMsgWhitespace =
    while msg.len > 0 and msg[^1] in Whitespace:
      setLen msg, msg.len - 1

  template addInlineError =
    doAssert msg[^1] notin Whitespace
    if kind == "Error": spec.action = actionReject
    spec.inlineErrors.add InlineError(kind: kind, msg: msg, line: msgLine, col: msgCol)

  parseKind()
  skipWhitespace()
  parseCaret()

  while result < s.len-1:
    if s[result] == '\n':
      if result > 0 and s[result - 1] == '\r':
        msg[^1] = '\n'
      else:
        msg.add '\n'
      inc result
      inc line
      col = 1
    elif isMsgDelimiter():
      trimTrailingMsgWhitespace()
      inc result
      skipWhitespace()
      addInlineError()
      inc result, len(inlineErrorKindMarker)
      inc col, 1 + len(inlineErrorKindMarker)
      kind.setLen 0
      msg.setLen 0
      parseKind()
      skipWhitespace()
      parseCaret()
    elif s[result] == ']' and s[result+1] == '#':
      trimTrailingMsgWhitespace()
      inc result, 2
      inc col, 2
      addInlineError()
      break
    else:
      msg.add s[result]
      inc result
      inc col

  if spec.inlineErrors.len > 0:
    spec.unjoinable = true

proc extractSpec(filename: string; spec: var TSpec): string =
  const
    tripleQuote = "\"\"\""
    specStart = "discard " & tripleQuote
  var s = readFile(filename)

  var i = 0
  var a = -1
  var b = -1
  var line = 1
  var col = 1
  while i < s.len:
    if (i == 0 or s[i-1] != ' ') and s.continuesWith(specStart, i):
      # `s[i-1] == '\n'` would not work because of `tests/stdlib/tbase64.nim` which contains BOM (https://en.wikipedia.org/wiki/Byte_order_mark)
      const lineMax = 10
      if a != -1:
        raise newException(ValueError, "testament spec violation: duplicate `specStart` found: " & $(filename, a, b, line))
      elif line > lineMax:
        # not overly restrictive, but prevents mistaking some `specStart` as spec if deeep inside a test file
        raise newException(ValueError, "testament spec violation: `specStart` should be before line $1, or be indented; info: $2" % [$lineMax, $(filename, a, b, line)])
      i += specStart.len
      a = i
    elif a > -1 and b == -1 and s.continuesWith(tripleQuote, i):
      b = i
      i += tripleQuote.len
    elif s[i] == '\n':
      inc line
      inc i
      col = 1
    elif s.continuesWith(inlineErrorMarker, i):
      i = extractErrorMsg(s, i, line, col, spec)
    else:
      inc col
      inc i

  if a >= 0 and b > a:
    result = s.substr(a, b-1).multiReplace({"'''": tripleQuote, "\\31": "\31"})
  elif a >= 0:
    raise newException(ValueError, "testament spec violation: `specStart` found but not trailing `tripleQuote`: $1" % $(filename, a, b, line))
  else:
    result = ""

proc parseTargets*(value: string): set[TTarget] =
  for v in value.normalize.splitWhitespace:
    case v
    of "c": result.incl(targetC)
    of "cpp", "c++": result.incl(targetCpp)
    of "objc": result.incl(targetObjC)
    of "js": result.incl(targetJS)
    else: raise newException(ValueError, "invalid target: '$#'" % v)

proc initSpec*(filename: string): TSpec =
  result.file = filename

proc isCurrentBatch*(testamentData: TestamentData; filename: string): bool =
  if testamentData.testamentNumBatch != 0:
    hash(filename) mod testamentData.testamentNumBatch == testamentData.testamentBatch
  else:
    true

proc parseSpec*(filename: string): TSpec =
  result.file = filename
  result.filename = extractFilename(filename)
  let specStr = extractSpec(filename, result)
  var ss = newStringStream(specStr)
  var p: CfgParser
  open(p, ss, filename, 1)
  var flags: HashSet[string]
  var nimoutFound = false
  while true:
    var e = next(p)
    case e.kind
    of cfgKeyValuePair:
      let key = e.key.normalize
      const whiteListMulti = ["disabled", "ccodecheck"]
        ## list of flags that are correctly handled when passed multiple times
        ## (instead of being overwritten)
      if key notin whiteListMulti:
        doAssert key notin flags, $(key, filename)
      flags.incl key
      case key
      of "action":
        case e.value.normalize
        of "compile":
          result.action = actionCompile
        of "run":
          result.action = actionRun
        of "reject":
          result.action = actionReject
        else:
          result.parseErrors.addLine "cannot interpret as action: ", e.value
      of "file":
        if result.msg.len == 0 and result.nimout.len == 0:
          result.parseErrors.addLine "errormsg or msg needs to be specified before file"
        result.file = e.value
      of "line":
        if result.msg.len == 0 and result.nimout.len == 0:
          result.parseErrors.addLine "errormsg, msg or nimout needs to be specified before line"
        discard parseInt(e.value, result.line)
      of "column":
        if result.msg.len == 0 and result.nimout.len == 0:
          result.parseErrors.addLine "errormsg or msg needs to be specified before column"
        discard parseInt(e.value, result.column)
      of "output":
        if result.outputCheck != ocSubstr:
          result.outputCheck = ocEqual
        result.output = e.value
      of "input":
        result.input = e.value
      of "outputsub":
        result.outputCheck = ocSubstr
        result.output = strip(e.value)
      of "sortoutput":
        try:
          result.sortoutput = parseCfgBool(e.value)
        except:
          result.parseErrors.addLine getCurrentExceptionMsg()
      of "exitcode":
        discard parseInt(e.value, result.exitCode)
        result.action = actionRun
      of "errormsg":
        result.msg = e.value
        result.action = actionReject
      of "nimout":
        result.nimout = e.value
        nimoutFound = true
      of "nimoutfull":
        result.nimoutFull = parseCfgBool(e.value)
      of "batchable":
        result.unbatchable = not parseCfgBool(e.value)
      of "joinable":
        result.unjoinable = not parseCfgBool(e.value)
      of "valgrind":
        when defined(linux) and sizeof(int) == 8:
          result.useValgrind = if e.value.normalize == "leaks": leaking
                               else: ValgrindSpec(parseCfgBool(e.value))
          result.unjoinable = true
          if result.useValgrind != disabled:
            result.outputCheck = ocSubstr
        else:
          # Windows lacks valgrind. Silly OS.
          # Valgrind only supports OSX <= 17.x
          result.useValgrind = disabled
      of "disabled":
        let value = e.value.normalize
        case value
        of "y", "yes", "true", "1", "on": result.err = reDisabled
        of "n", "no", "false", "0", "off": discard
        # These values are defined in `compiler/options.isDefined`
        of "win":
          when defined(windows): result.err = reDisabled
        of "linux":
          when defined(linux): result.err = reDisabled
        of "bsd":
          when defined(bsd): result.err = reDisabled
        of "osx":
          when defined(osx): result.err = reDisabled
        of "unix", "posix":
          when defined(posix): result.err = reDisabled
        of "freebsd":
          when defined(freebsd): result.err = reDisabled
        of "littleendian":
          when defined(littleendian): result.err = reDisabled
        of "bigendian":
          when defined(bigendian): result.err = reDisabled
        of "cpu8", "8bit":
          when defined(cpu8): result.err = reDisabled
        of "cpu16", "16bit":
          when defined(cpu16): result.err = reDisabled
        of "cpu32", "32bit":
          when defined(cpu32): result.err = reDisabled
        of "cpu64", "64bit":
          when defined(cpu64): result.err = reDisabled
        # These values are for CI environments
        of "travis": # deprecated
          if isTravis: result.err = reDisabled
        of "appveyor": # deprecated
          if isAppVeyor: result.err = reDisabled
        of "azure":
          if isAzure: result.err = reDisabled
        else:
          # Check whether the value exists as an OS or CPU that is
          # defined in `compiler/platform`.
          block checkHost:
            for os in platform.OS:
              # Check if the value exists as OS.
              if value == os.name.normalize:
                # The value exists; is it the same as the current host?
                if value == hostOS.normalize:
                  # The value exists and is the same as the current host,
                  # so disable the test.
                  result.err = reDisabled
                # The value was defined, so there is no need to check further
                # values or raise an error.
                break checkHost
            for cpu in platform.CPU:
              # Check if the value exists as CPU.
              if value == cpu.name.normalize:
                # The value exists; is it the same as the current host?
                if value == hostCPU.normalize:
                  # The value exists and is the same as the current host,
                  # so disable the test.
                  result.err = reDisabled
                # The value was defined, so there is no need to check further
                # values or raise an error.
                break checkHost
            # The value doesn't exist as an OS, CPU, or any previous value
            # defined in this case statement, so raise an error.
            result.parseErrors.addLine "cannot interpret as a bool: ", e.value
      of "cmd":
        if e.value.startsWith("nim "):
          result.cmd = compilerPrefix & e.value[3..^1]
        else:
          result.cmd = e.value
      of "ccodecheck":
        result.ccodeCheck.add e.value
      of "maxcodesize":
        discard parseInt(e.value, result.maxCodeSize)
      of "timeout":
        try:
          result.timeout = parseFloat(e.value)
        except ValueError:
          result.parseErrors.addLine "cannot interpret as a float: ", e.value
      of "targets", "target":
        try:
          result.targets.incl parseTargets(e.value)
        except ValueError as e:
          result.parseErrors.addLine e.msg
      of "matrix":
        for v in e.value.split(';'):
          result.matrix.add(v.strip)
      else:
        result.parseErrors.addLine "invalid key for test spec: ", e.key

    of cfgSectionStart:
      result.parseErrors.addLine "section ignored: ", e.section
    of cfgOption:
      result.parseErrors.addLine "command ignored: ", e.key & ": " & e.value
    of cfgError:
      result.parseErrors.addLine e.msg
    of cfgEof:
      break
  close(p)

  if skips.anyIt(it in result.file):
    result.err = reDisabled

  if nimoutFound and result.nimout.len == 0 and not result.nimoutFull:
    result.parseErrors.addLine "empty `nimout` is vacuously true, use `nimoutFull:true` if intentional"

  result.inCurrentBatch = isCurrentBatch(testamentData0, filename) or result.unbatchable
  if not result.inCurrentBatch:
    result.err = reDisabled

  # Interpolate variables in msgs:
  template varSub(msg: string): string =
    try:
      msg % ["/", $DirSep, "file", result.filename]
    except ValueError:
      result.parseErrors.addLine "invalid variable interpolation (see 'https://nim-lang.github.io/Nim/testament.html#writing-unitests-output-message-variable-interpolation')"
      msg
  result.nimout = result.nimout.varSub
  result.msg = result.msg.varSub
  for inlineError in result.inlineErrors.mitems:
    inlineError.msg = inlineError.msg.varSub
