# Copyright © 2024 Bartek thindil Jasicki
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# 3. Neither the name of the copyright holder nor the
# names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY COPYRIGHT HOLDERS AND CONTRIBUTORS ''AS IS'' AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

## The rule to check do `try` statements in the code contains or not some
## expressions. Checked things:
##
## * Except branches do they don't have specified any exception.
## * Except branches for the selected exception.
##
## The syntax in a configuration file is::
##
##   [ruleType] ?not? trystatements [checkType] [exceptionName]
##
## * ruleType is the type of rule which will be executed. Proper values are:
##   *check*, *search*, *count* and *fix*. For more information about the types of
##   rules, please refer to the program's documentation. Check type will raise
##   an error if there is a `try` statement which violates the check. Search
##   type will list all statements which violates the check or raise an
##   error if nothing found. Count type will simply list the amount of the
##   statements which violates the check. Fix type behavior depends on the
##   checkType parameter. For empty will try to remove any names of exceptions
##   from except branch but only when the try statement has only one except branch.
##   For name, it will try to add the selected exception or remove it when the
##   negation for the rule is set.
## * optional word *not* means negation for the rule. Adding word *not* will
##   change to inform only about the `try` statements which not violates the
##   rule's check.
## * trystatements is the name of the rule. It is case-insensitive, thus it can be
##   set as *trystatements*, *trystatements* or *tRyStAtEmEnTs*.
## * checkType is the type of checks to perform on the `try` statements. Proper
##   values are: *empty* and *name*. Setting it to empty will check existence of
##   except branches without specified any exception. Name value will check do
##   exist except branches with the selected exception.
## * exceptionName is required only when checkType is set to *name*. It is the
##   name of the exception to looking for. The argument is case-insensitive,
##   thus setting it to ioerror will find branches with IOError or ioError too.
##
## Disabling the rule
## ------------------
## It is possible to disable the rule for a selected part of the checked code
## by using pragma *ruleOff: "tryStatements"* in the code before it. For
## example, if the rule should be disabled for the selected statement, the full
## declaration of it should be::
##
##     {.ruleOff: "tryStatements".}
##     try:
##       someProcedure()
##     except:
##       discard
##
## To enable the rule again, the pragma *ruleOn: "tryStatements"* should be
## added in the code before it. For example, if the rule should be re-enabled
## for the statement, the full declaration should be::
##
##     {.ruleOn: "tryStatements".}
##     try:
##       someProcedure()
##     except IOError:
##       discard
##
## Examples
## --------
##
## 1. Check if all `try` statements don't have defined exceptions to catch::
##
##     check tryStatements empty
##
## 2. Remove all occurences of `Exception` exception from `try` statements::
##
##     fix not tryStatements name Exception

# Import default rules' modules
import ../rules

ruleConfig(ruleName = "trystatements",
  ruleFoundMessage = "try statements which can{negation} be upgraded",
  ruleNotFoundMessage = "try statements which can{negation} be upgraded not found.",
  rulePositiveMessage = "try statement, line: {params[0]} {params[1]}",
  ruleNegativeMessage = "try statement, line: {params[0]} {params[1]}",
  ruleOptions = @[custom, str],
  ruleOptionValues = @["empty", "name"],
  ruleMinOptions = 1)

proc checkEmpty(exceptNode: PNode; message, checkType: var string;
    checkResult: var bool; rule: var RuleOptions) {.raises: [], tags: [
    RootEffect], contractual.} =
  ## Check except branch of the try statement do it not specify an exception
  ##
  ## * nodeToCheck - the node which will be checked
  ## * message     - the message shown to the user with result of the check
  ## * checkType   - the name of the check's type
  ## * checkResult - the result of the check
  ## * rule        - the rule options set by the user
  ##
  ## Returns modified arguments checkType, checkResult and rule
  require:
    exceptNode != nil
  body:
    message = (if rule.negation: "contains" else: "doesn't contain") & " only general except statement."
    checkType = "empty"
    checkResult = true
    for child in exceptNode:
      if child.kind in {nkIdent, nkInfix}:
        checkResult = false
        break

proc checkName(exceptNode: PNode; message, checkType: var string;
    checkResult: var bool; rule: var RuleOptions) {.raises: [], tags: [
    RootEffect], contractual.} =
  ## Check except branch of the try statement do it contains the selected
  ## exception
  ##
  ## * nodeToCheck - the node which will be checked
  ## * message     - the message shown to the user with result of the check
  ## * checkType   - the name of the check's type
  ## * checkResult - the result of the check
  ## * rule        - the rule options set by the user
  ##
  ## Returns modified arguments checkType, checkResult and rule
  require:
    exceptNode != nil
  body:
    message = (if rule.negation: "contains" else: "doesn't contain") &
        " except statement with exception '" & rule.options[1] & "'."
    checkType = "name"
    checkResult = false
    type ExceptionName = string
    for child in exceptNode:
      try:
        let exceptionName: ExceptionName = (if child.kind == nkIdent: (
            $child).toLowerAscii elif child.kind == nkInfix: ($child[
            1]).toLowerAscii else: "")
        if exceptionName.len > 0 and exceptionName.toLowerAscii == rule.options[
            1].toLowerAscii:
          checkResult = true
          break
      except KeyError, Exception:
        discard

{.push ruleOff: "params".}
proc checkStatement(nodeToCheck, astNode: PNode; rule: var RuleOptions;
    messagePrefix: string) {.raises: [], tags: [RootEffect], contractual.} =
  ## Check the selected try statement's except branches do they follow the rule's
  ## settings.
  ##
  ## * nodeToCheck   - the node which will be checked
  ## * astNode       - the module node in which the try statement is
  ## * messagePrefix - the prefix added to the log message, set by the program
  ## * rule          - the rule options set by the user
  ##
  ## Returns the modified argument rule
  require:
    nodeToCheck != nil
    astNode != nil
  body:
    for child in nodeToCheck:
      if child.kind != nkExceptBranch:
        continue
      var
        checkResult: bool = false
        message, checkType: Message = ""
      # Check if the try statement contains general except statement
      if rule.options[0].toLowerAscii == "empty":
        checkEmpty(exceptNode = child, message = message,
            checkType = checkType, checkResult = checkResult, rule = rule)
      # Check if the try statement contains except with the selected exception
      if not checkResult and rule.options[0].toLowerAscii == "name":
        checkName(exceptNode = child, message = message,
            checkType = checkType, checkResult = checkResult, rule = rule)
      if rule.ruleType in {RuleTypes.count, search}:
        checkResult = not checkResult
      let oldAmount: ResultAmount = rule.amount
      setResult(checkResult = checkResult, positiveMessage = positiveMessage,
          negativeMessage = negativeMessage, ruleData = checkType,
          node = child, params = [$child.info.line, message])
      # To show the rule's explaination the rule.amount must be negative
      if rule.negation and oldAmount > rule.amount and rule.ruleType == check:
        rule.amount = -1_000
      if not checkResult:
        break
{.push ruleOn: "params".}

checkRule:
  initCheck:
    if rule.options[0] == "name" and rule.options.len < 2:
      rule.amount = errorMessage(text = "Can't check try statements' names. No name specified to check.")
      return
  startCheck:
    let negation: Message = (if rule.negation: "'t" else: "")
  checking:
    if node.kind == nkTryStmt or (node.kind == nkStmtList and node[0].kind == nkTryStmt):
      let nodeToCheck: PNode = (if node.kind == nkTryStmt: node else: node[0])
      checkStatement(nodeToCheck = nodeToCheck, astNode = astNode, rule = rule,
          messagePrefix = messagePrefix)
    else:
      for child in node:
        setRuleState(node = child, ruleName = ruleSettings.name,
            oldState = rule.enabled)
        if not rule.enabled:
          continue
        if child.kind == nkTryStmt or (child.kind == nkStmtList and child[
            0].kind == nkTryStmt):
          let nodeToCheck: PNode = (if child.kind ==
              nkTryStmt: child else: child[0])
          checkStatement(nodeToCheck = nodeToCheck, astNode = astNode,
              rule = rule, messagePrefix = messagePrefix)
  endCheck:
    discard

fixRule:
  # Don't change anything if rule is looking for non empty except branches
  if rule.negation and rule.options[0] == "empty":
    return false
  var tryNode: PNode = (if parentNode.kind ==
      nkTryStmt: parentNode else: parentNode[0])
  # Remove names of exceptions from except branch when only empty branches are allowed
  if rule.options[0] == "empty":
    # Don't remove anything if the try statement has more than one except branch
    if tryNode.len > 2:
      return false
    var exceptBranch: PNode = tryNode[^1]
    while exceptBranch.len > 1:
      delSon(father = exceptBranch, idx = 0)
      result = true
  elif rule.options[0] == "name":
    var exceptBranch: PNode = tryNode[^1]
    # Remove the selected exception from the except branch
    if rule.negation:
      for index, elem in exceptBranch:
        if elem.kind != nkIdent:
          continue
        try:
          if $elem == rule.options[1]:
            delSon(father = exceptBranch, idx = index)
            result = true
            break
        except KeyError, Exception:
          discard errorMessage(text = "Can't remove the selected exception. Reason: " &
              getCurrentExceptionMsg())
          return false
    # Add the selected exception to the except branch
    else:
      # Don't add anything if the try statement has more than one except branch
      if tryNode.len > 2:
        return false
      exceptBranch.sons.insert(item = newIdentNode(ident = getIdent(ic = rule.identsCache,
            identifier = rule.options[1]), info = exceptBranch.info), i = 0)
      return true

