Skip to content

Commit

Permalink
Initial work on the error handling proposal
Browse files Browse the repository at this point in the history
The commit also adds a facility for writing the generated macro
output to a file. It also introduces a new module named `results`
that should eventually replace all usages of `import result`.
  • Loading branch information
zah committed Apr 6, 2020
1 parent 9414202 commit 5b15152
Show file tree
Hide file tree
Showing 5 changed files with 385 additions and 1 deletion.
277 changes: 277 additions & 0 deletions stew/errorhandling.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
import
typetraits, strutils,
shims/macros, results

const
enforce_error_handling {.strdefine.}: string = "yes"
errorHandlingEnforced = parseBool(enforce_error_handling)

type
VoidResult = object
Raising*[ErrorList: tuple, ResultType] = distinct ResultType

let
raisesPragmaId {.compileTime.} = ident"raises"

proc mergeTupleTypeSets(lhs, rhs: NimNode): NimNode =
result = newTree(nnkPar)

for i in 1 ..< lhs.len:
result.add lhs[i]

for i in 1 ..< rhs.len:
block findMatch:
for j in 1 ..< lhs.len:
if sameType(rhs[i], lhs[i]):
break findMatch

result.add rhs[i]

macro `++`*(lhs: type[tuple], rhs: type[tuple]): type =
result = mergeTupleTypeSets(getType(lhs)[1], getType(rhs)[1])

proc genForwardingCall(procDef: NimNode): NimNode =
result = newCall(procDef.name)
for param, _ in procDef.typedParams:
result.add param

macro noerrors*(procDef: untyped) =
let raisesPragma = procDef.pragma.findPragma(raisesPragmaId)
if raisesPragma != nil:
error "You should not specify `noerrors` and `raises` at the same time",
raisesPragma
var raisesList = newTree(nnkBracket, bindSym"Defect")
procDef.addPragma newColonExpr(ident"raises", raisesList)
return procDef

macro errors*(ErrorsTuple: typed, procDef: untyped) =
let raisesPragma = procDef.pragma.findPragma(raisesPragmaId)
if raisesPragma != nil:
error "You should not specify `errors` and `raises` at the same time",
raisesPragma

var raisesList = newTree(nnkBracket, bindSym"Defect")

for i in 1 ..< ErrorsTuple.len:
raisesList.add ErrorsTuple[i]

procDef.addPragma newColonExpr(ident"raises", raisesList)

when errorHandlingEnforced:
# We are going to create a wrapper proc or a template
# that calls the original one and wraps the returned
# value in a Raising type. To achieve this, we must
# generate a new name for the original proc:

let
generateTemplate = true
OrigResultType = procDef.params[0]

# Create the wrapper
var
wrapperDef: NimNode
RaisingType: NimNode

if generateTemplate:
wrapperDef = newNimNode(nnkTemplateDef, procDef)
procDef.copyChildrenTo wrapperDef
# We must remove the raises list from the original proc
wrapperDef.pragma = newEmptyNode()
else:
wrapperDef = copy procDef

# Change the original proc name
procDef.name = genSym(nskProc, $procDef.name)

var wrapperBody = newNimNode(nnkStmtList, procDef.body)
if OrigResultType.kind == nnkEmpty or eqIdent(OrigResultType, "void"):
RaisingType = newTree(nnkBracketExpr, ident"Raising",
ErrorsTuple, bindSym"VoidResult")
wrapperBody.add(
genForwardingCall(procDef),
newCall(RaisingType, newTree(nnkObjConstr, bindSym"VoidResult")))
else:
RaisingType = newTree(nnkBracketExpr, ident"Raising",
ErrorsTuple, OrigResultType)
wrapperBody.add newCall(RaisingType, genForwardingCall(procDef))

wrapperDef.params[0] = if generateTemplate: ident"untyped"
else: RaisingType
wrapperDef.body = wrapperBody

result = newStmtList(procDef, wrapperDef)
else:
result = procDef

storeMacroResult result

macro checkForUnhandledErrors(origHandledErrors, raisedErrors: typed) =
# This macro is executed with two tuples:
#
# 1. The list of errors handled at the call-site which will
# have a line info matching the call-site.
# 2. The list of errors that the called function is raising.
# The lineinfo here points to the definition of the function.

# For accidental reasons, the first tuple will be recognized as a
# typedesc, while the second won't be (beware because this can be
# considered a bug in Nim):
var handledErrors = getTypeInst(origHandledErrors)
if handledErrors.kind == nnkBracketExpr:
handledErrors = handledErrors[1]

assert handledErrors.kind == nnkTupleConstr and
raisedErrors.kind == nnkTupleConstr

# Here, we'll store the list of errors that the user missed:
var unhandledErrors = newTree(nnkPar)

# We loop through the raised errors and check whether they have
# an appropriate handler:
for raised in raisedErrors:
block findHandler:
template tryFindingHandler(raisedType) =
for handled in handledErrors:
if sameType(raisedType, handled):
break findHandler

tryFindingHandler raised
# A base type of the raised exception may be handled instead
for baseType in raised.baseTypes:
tryFindingHandler baseType

unhandledErrors.add raised

if unhandledErrors.len > 0:
let errMsg = "The following errors are not handled: $1" % [unhandledErrors.repr]
error errMsg, origHandledErrors

template raising*[E, R](x: Raising[E, R]): R =
## `raising` is used to mark locations in the code that might
## raise exceptions. It disarms the type-safety checks imposed
## by the `errors` pragma.
distinctBase(x)

macro chk*[R, E](x: Raising[R, E], handlers: untyped): untyped =
## The `chk` macro can be used in 2 different ways
##
## 1) Try to get the result of an expression. In case of any
## errors, substitute the result with a default value:
##
## ```
## let x = chk(foo(), defaultValue)
## ```
##
## We'll handle this case with a simple rewrite to
##
## ```
## let x = try: distinctBase(foo())
## except CatchableError: defaultValue
## ```
##
## 2) Try to get the result of an expression while providing exception
## handlers that must cover all possible recoverable errors.
##
## ```
## let x = chk foo():
## KeyError as err: defaultValue
## ValueError: return
## _: raise
## ```
##
## The above example will be rewritten to:
##
## ```
## let x = try:
## foo()
## except KeyError as err:
## defaultValue
## except ValueError:
## return
## except CatchableError:
## raise
## ```
##
## Please note that the special case `_` is considered equivalent to
## `CatchableError`.
##
## If the `chk` block lacks a default handler and there are unlisted
## recoverable errors, the compiler will fail to compile the code with
## a message indicating the missing ones.
let
RaisingType = getTypeInst(x)
ErrorsSetTuple = RaisingType[1]
ResultType = RaisingType[2]

# The `try` branch is the same in all scenarios. We generate it here.
# The target AST looks roughly like this:
#
# TryStmt
# StmtList
# Call
# Ident "distinctBase"
# Call
# Ident "foo"
# ExceptBranch
# Ident "CatchableError"
# StmtList
# Ident "defaultValue"
result = newTree(nnkTryStmt, newStmtList(
newCall(bindSym"distinctBase", x)))

# Check how the API was used:
if handlers.kind != nnkStmtList:
# This is usage type 1: chk(foo(), defaultValue)
result.add newTree(nnkExceptBranch,
bindSym("CatchableError"),
newStmtList(handlers))
else:
var
# This will be a tuple of all the errors handled by the `chk` block.
# In the end, we'll compare it to the Raising list.
HandledErrorsTuple = newNimNode(nnkPar, x)
# Has the user provided a default `_: value` handler?
defaultCatchProvided = false

for handler in handlers:
template err(msg: string) = error msg, handler
template unexpectedSyntax = err(
"The `chk` handlers block should consist of `ExceptionType: Value/Block` pairs")

case handler.kind
of nnkCommentStmt:
continue
of nnkInfix:
if eqIdent(handler[0], "as"):
if handler.len != 4:
err "The expected syntax is `ExceptionType as exceptionVar: Value/Block`"
let
ExceptionType = handler[1]
exceptionVar = handler[2]
valueBlock = handler[3]

HandledErrorsTuple.add ExceptionType
result.add newTree(nnkExceptBranch, infix(ExceptionType, "as", exceptionVar),
valueBlock)
else:
err "The '$1' operator is not expected in a `chk` block" % [$handler[0]]
of nnkCall:
if handler.len != 2:
unexpectedSyntax
let ExceptionType = handler[0]
if eqIdent(ExceptionType, "_"):
if defaultCatchProvided:
err "Only a single default handler is expected"
handler[0] = bindSym"CatchableError"
defaultCatchProvided = true

result.add newTree(nnkExceptBranch, handler[0], handler[1])
HandledErrorsTuple.add handler[0]
else:
unexpectedSyntax

result = newTree(nnkStmtListExpr,
newCall(bindSym"checkForUnhandledErrors", HandledErrorsTuple, ErrorsSetTuple),
result)

storeMacroResult result
3 changes: 3 additions & 0 deletions stew/results.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import result
export result

52 changes: 51 additions & 1 deletion stew/shims/macros.nim
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import
std/macros
std/[macros, tables, hashes]

export
macros
Expand All @@ -17,6 +17,42 @@ type
const
nnkPragmaCallKinds = {nnkExprColonExpr, nnkCall, nnkCallStrLit}

proc hash*(x: LineInfo): Hash =
!$(hash(x.filename) !& hash(x.line) !& hash(x.column))

var
# Please note that we are storing NimNode here in order to
# incur the code rendering cost only on a successful compilation.
macroLocations {.compileTime.} = newSeq[LineInfo]()
macroOutputs {.compileTime.} = newSeq[NimNode]()

proc storeMacroResult*(callSite: LineInfo, macroResult: NimNode) =
macroLocations.add callSite
macroOutputs.add macroResult

proc storeMacroResult*(macroResult: NimNode) =
let usageSite = callsite().lineInfoObj
storeMacroResult(usageSite, macroResult)

macro dumpMacroResults*: untyped =
var files = initTable[string, NimNode]()

proc addToFile(file: var NimNode, location: LineInfo, macroOutput: NimNode) =
if file == nil:
file = newNimNode(nnkStmtList, macroOutput)

file.add newCommentStmtNode($location)
file.add macroOutput

for i in 0..< macroLocations.len:
addToFile files.mgetOrPut(macroLocations[i].filename, nil),
macroLocations[i], macroOutputs[i]

for name, contents in files:
let targetFile = name & ".generated.nim"
writeFile(targetFile, repr(contents))
hint "Wrote macro output to " & targetFile, contents

proc findPragma*(pragmas: NimNode, pragmaSym: NimNode): NimNode =
for p in pragmas:
if p.kind in {nnkSym, nnkIdent} and eqIdent(p, pragmaSym):
Expand Down Expand Up @@ -316,6 +352,20 @@ iterator typedParams*(n: NimNode, skip = 0): (NimNode, NimNode) =
for j in 0 ..< paramNodes.len - 2:
yield (paramNodes[j], paramType)

iterator baseTypes*(exceptionType: NimNode): NimNode =
var typ = exceptionType
while typ != nil:
let impl = getImpl(typ)
if impl.len != 3 or impl[2].kind != nnkObjectTy:
break

let objType = impl[2]
if objType[1].kind != nnkOfInherit:
break

typ = objType[1][0]
yield typ

macro unpackArgs*(callee: typed, args: untyped): untyped =
result = newCall(callee)
for arg in args:
Expand Down
37 changes: 37 additions & 0 deletions tests/test_errorhandling.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import
../stew/[shims/macros, errorhandling]

proc bar(x: int): int {.noerrors.} =
100

proc toString(x: int): string {.errors: (ValueError, KeyError, OSError).} =
$x

proc main =
let
a = bar(10)
b = raising toString(20)
c = chk toString(30):
ValueError: "got ValueError"
KeyError as err: err.msg
OSError: raise

echo a
echo b
echo c

main()

dumpMacroResults()

when false:
type
ExtraErrors = KeyError|OSError

#[
proc map[A, E, R](a: A, f: proc (a: A): Raising[E, R])): string {.
errors: E|ValueError|ExtraErrors
.} =
$chk(f(a))
]#

Loading

0 comments on commit 5b15152

Please sign in to comment.