diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 46a3769..40e2d1e 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -3,6 +3,7 @@ on: push: branches: - master + pull_request: workflow_dispatch: jobs: test: @@ -20,7 +21,7 @@ jobs: - uses: actions/checkout@v4 with: submodules: true - - uses: jiro4989/setup-nim-action@v1 + - uses: jiro4989/setup-nim-action@v2 with: nim-version: ${{ matrix.nimVersion }} - run: nimble test -Y --mm:${{ matrix.nimMm }} diff --git a/.gitignore b/.gitignore index 606ee08..5e1d63a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ *.bin .vscode + +htmldocs diff --git a/README.md b/README.md index fa735ae..cf27d2e 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,61 @@ This is a Nim library to encode/decode [Bencode](https://en.wikipedia.org/wiki/Bencode), the encoding used by the BitTorrent protocol to represent structured data. -## Example +## Examples ```nim import pkg/bencode +const Expected = "d8:completei20e10:incompletei0e8:intervali1800e12:min intervali900e5:peers6:\x0a\x0a\x0a\x05\x00\x80e" +``` + +### Custom types + +```nim +type + Data = object + interval: int + minInterval {.name: "min interval".}: int + peers: string + complete: int + incomplete: int + +let + data = Data( + interval: 1800, + minInterval: 900, + peers: "\x0a\x0a\x0a\x05\x00\x80", + complete: 20, + incomplete: 0, + ) + bencodedData = data.toBencode() + +doAssert bencodedData == Expected +doAssert Data.fromBencode(bencodedData) == data +``` + +### BencodeObj and container types + +```nim +let + data = be({ + "interval": be(1800), + "min interval": be(900), + "peers": be("\x0a\x0a\x0a\x05\x00\x80"), + "complete": be(20), + "incomplete": be(0), + }) + bencodedData = data.toBencode + +doAssert bencodedData == Expected +doAssert BencodeObj.fromBencode(bencodedData) == data +``` + +Also works with `std/tables`'s `Table` and `OrderedTable` and `std/json`'s `JsonNode`. + +### Old API + +```nim let data = be({ "interval": be(1800), @@ -17,6 +67,6 @@ let }) bencodedData = bEncode(data) -doAssert bencodedData == "d8:completei20e10:incompletei0e8:intervali1800e12:min intervali900e5:peers6:\x0a\x0a\x0a\x05\x00\x80e" +doAssert bencodedData == Expected doAssert bDecode(bencodedData) == data ``` diff --git a/src/bencode.nim b/bencode.nim similarity index 63% rename from src/bencode.nim rename to bencode.nim index 98fd10e..8dfb195 100644 --- a/src/bencode.nim +++ b/bencode.nim @@ -1,33 +1,34 @@ -import bencodepkg/[core, json] +import ./bencode/[ + decoding, + encoding, +] -export core, json +export decoding, encoding when isMainModule: - import os - - proc die(msg: string; code = 1) {.noReturn.} = - stderr.writeLine(msg) - quit(code) + import std/os + when NimMajor >= 2: + import std/syncio proc parseFormatArg(arg: string): BencodeFormat = - if arg.len < 2: die("Invalid argument.") + if arg.len < 2: quit("Invalid argument.") let c = arg[1] case c of 'u': Normal of 'd': Decimal of 'x': Hexadecimal - else: die("Invalid format argument '" & arg & "'.") + else: quit("Invalid format argument '" & arg & "'.") let (filename, format) = case paramCount() of 0: - die("Filename required.") + quit("Filename required.") of 1: (paramStr(1), Normal) else: block: let fnIdx = if paramStr(1)[0] == '-': 2 else: 1 (paramStr(fnIdx), paramStr(3 - fnIdx).parseFormatArg) - + let f = open(filename, fmRead) obj = bDecode(f) diff --git a/bencode.nimble b/bencode.nimble index 80f8ee3..16ae322 100644 --- a/bencode.nimble +++ b/bencode.nimble @@ -4,7 +4,6 @@ version = "0.0.7" author = "z-------------" description = "Bencode for Nim" license = "MIT" -srcDir = "src" bin = @["bencode"] installExt = @["nim"] diff --git a/bencode/decoding.nim b/bencode/decoding.nim new file mode 100644 index 0000000..c80c076 --- /dev/null +++ b/bencode/decoding.nim @@ -0,0 +1,241 @@ +import ./private/macros +import ./[ + inputstreams, + types, +] +import std/[ + parseutils, + strformat, + tables, +] +when NimMajor >= 2: + import std/[ + assertions, + syncio, + ] + +export InputStream +export types +export atEnd, readChar, peekChar, getPosition, readStr # why is this needed? + +type + BencodeDecodeErrorKind* = enum + SyntaxError + WrongLength + InvalidValue + BencodeDecodeError* = object of BencodeError + kind* {.requiresInit.}: BencodeDecodeErrorKind + pos* {.requiresInit.}: int + +proc newBencodeDecodeError(pos: int; kind: BencodeDecodeErrorKind; msg: string): ref BencodeDecodeError = + (ref BencodeDecodeError)(msg: msg, kind: kind, pos: pos) + +proc newBencodeDecodeError(s: var InputStream; kind: BencodeDecodeErrorKind; msg: string): ref BencodeDecodeError = + newBencodeDecodeError(s.getPosition, kind, msg) + +proc consume(s: var InputStream; c: char) = + ## Check that the char at the current position is `c`, then consume it. + if s.atEnd: + raise newBencodeDecodeError(s, SyntaxError, &"expected '{c}', got end of input") + let actual = s.readChar() + if actual != c: + raise newBencodeDecodeError(s, SyntaxError, &"expected '{c}', got {actual}") + +proc parseInt(str: string; pos: int): int = + result = 0 + if parseutils.parseInt(str, result) != str.len: + raise newBencodeDecodeError(pos, SyntaxError, &"invalid integer: {str}") + +proc parseHook*(s: var InputStream; v: var string) = + # : + # get the length + var lengthStr = "" + while not s.atEnd and s.peekChar() != ':': + lengthStr &= s.readChar() + consume(s, ':') + let length = parseInt(lengthStr, s.getPosition) + if length < 0: + raise newBencodeDecodeError(s, InvalidValue, &"invalid string length: {length}") + + # read the string + v = + if length >= 0: + try: + s.readStr(length) + except IOError as e: + raise newBencodeDecodeError(s, SyntaxError, e.msg) + else: + "" + +proc parseHook*(s: var InputStream; v: var int) = + # ie + consume(s, 'i') + var iStr = "" + while not s.atEnd and s.peekChar() != 'e': + iStr &= s.readChar() + consume(s, 'e') + v = parseInt(iStr, s.getPosition) + +proc parseHook*[T](s: var InputStream; v: var seq[T]) = + # l ... e + v = newSeq[T]() + consume(s, 'l') + while not s.atEnd and s.peekChar() != 'e': + var item = default T + parseHook(s, item) + v.add(item) + consume(s, 'e') + +proc parseHook*[T; C: static int](s: var InputStream; v: var array[C, T]) = + # l ... e + v = default array[C, T] + consume(s, 'l') + var i = 0 + while not s.atEnd and s.peekChar() != 'e': + if i >= C: + raise newBencodeDecodeError(s, WrongLength, &"list too long: expected {C} items, got at least {i + 1} items") + var item = default T + parseHook(s, item) + v[i] = item + inc i + consume(s, 'e') + +template parseHookDictImpl(s: var InputStream; body: untyped) = + # d ... e + var + isReadingKey = true + curKey {.inject.} = "" + consume(s, 'd') + while not s.atEnd and s.peekChar() != 'e': + if isReadingKey: + parseHook(s, curKey) + isReadingKey = false + else: + body + isReadingKey = true + if not isReadingKey: + raise newBencodeDecodeError(s, SyntaxError, "expected value, got end of dict") + consume(s, 'e') + +type SomeTable[K, V] = Table[K, V] or OrderedTable[K, V] + +proc parseHookTableImpl[T](s: var InputStream; v: var SomeTable[string, T]) = + parseHookDictImpl(s): + var value = default T + parseHook(s, value) + v[curKey] = value + +proc parseHook*[T](s: var InputStream; v: var OrderedTable[string, T]) = + # why is this needed? + parseHookTableImpl(s, v) + +proc parseHook*[T](s: var InputStream; v: var Table[string, T]) = + parseHookTableImpl(s, v) + +proc parseHook*(s: var InputStream; v: var BencodeObj) = + assert not s.atEnd + case s.peekChar() + of 'i': + v = BencodeObj(kind: Int) + parseHook(s, v.i) + of 'l': + v = BencodeObj(kind: List) + parseHook(s, v.l) + of 'd': + v = BencodeObj(kind: Dict) + parseHook(s, v.d) + else: + v = BencodeObj(kind: Str) + parseHook(s, v.s) + +import std/json + +proc parseHook*(s: var InputStream; v: var JsonNode) = + assert not s.atEnd + case s.peekChar() + of 'i': + var value = default int + parseHook(s, value) + v = newJInt(value) + of 'l': + var value = default seq[JsonNode] + parseHook(s, value) + v = newJArray() + v.elems = value + of 'd': + var value = default OrderedTable[string, JsonNode] + parseHook(s, value) + v = newJObject() + v.fields = value + else: + var value = default string + parseHook(s, value) + v = newJString(value) + +proc parseHook*[T: object](s: var InputStream; v: var T) = + parseHookDictImpl(s): + block outer: + for fieldName, fieldValue in fieldPairs(v): + if effectiveName(fieldName, fieldValue) == curKey: + parseHook(s, fieldValue) + break outer + # TODO more efficiently skip to next key? + var b = BencodeObj() + parseHook(s, b) + +proc parseHook*[T: ref object](s: var InputStream; v: var T) = + v = T() + parseHook(s, v[]) + +proc fromBencode(t: typedesc; s: var InputStream): t = + result = default t + parseHook(s, result) + +proc fromBencode*(t: typedesc; source: string): t = + ## Decode bencoded data from `source` into a value of type `t`. + runnableExamples: + import std/json + + type Foo = object + a: int + b: string + c: BencodeObj + d: JsonNode + + let data = "d1:b11:hello world1:ai42e1:c7:bencode1:dd5:works17:with JsonNode tooee" + doAssert Foo.fromBencode(data) == Foo( + a: 42, + b: "hello world", + c: Bencode("bencode"), + d: %*{"works": "with JsonNode too"}, + ) + + var s = toInputStream source + fromBencode(t, s) + +import std/streams + +proc fromBencode*(t: typedesc; s: Stream): t = + ## Decode bencoded data from `s` into a value of type `t`. + var s = toInputStream s + fromBencode(t, s) + +proc fromBencode*(t: typedesc; f: File): t = + ## Decode bencoded data from `f` into a value of type `t`. + fromBencode(t, newFileStream(f)) + +proc fromBencode*(source: string; t: typedesc): t {.deprecated: "use fromBencode(typedesc, string) instead".} = + ## Logically backwards overload to match jsony's interface. + fromBencode(t, source) + +proc bDecode*(s: Stream): BencodeObj = + ## Same as `BencodeObj.fromBencode(s)<#fromBencode,typedesc,Stream>`_. + fromBencode(BencodeObj, s) + +proc bDecode*(source: string): BencodeObj = + ## Same as `BencodeObj.fromBencode(source)<#fromBencode,typedesc,string>`_. + fromBencode(BencodeObj, source) + +proc bDecode*(f: File): BencodeObj = + ## Same as `BencodeObj.fromBencode(f)<#fromBencode,typedesc,File>`_. + fromBencode(BencodeObj, f) diff --git a/bencode/encoding.nim b/bencode/encoding.nim new file mode 100644 index 0000000..bed919a --- /dev/null +++ b/bencode/encoding.nim @@ -0,0 +1,125 @@ +import ./private/macros +import ./types +import std/[ + algorithm, + sequtils, + tables, +] + +export types + +type + BencodeEncodeError* = object of BencodeError + +proc dumpHook*(s: var string; v: string) = + s &= $v.len & ':' & v + +proc dumpHook*(s: var string; v: int) = + s &= 'i' & $v & 'e' + +proc dumpHook*[T](s: var string; v: openArray[T]) = + s &= 'l' + for el in v: + dumpHook(s, el) + s &= 'e' + +template maybeDumpDictPair(s: var string; k, v: untyped) = + # TODO skipHook for values + when v is ref: + if v != nil: + dumpHook(s, k) + dumpHook(s, v) + else: + dumpHook(s, k) + dumpHook(s, v) + +proc dumpHook*[T](s: var string; v: OrderedTable[string, T]) = + var v = v + v.sort do (x, y: tuple[key: string; value: T]) -> int: + system.cmp(x.key, y.key) + s &= 'd' + for k, v in v.pairs(): + maybeDumpDictPair(s, k, v) + s &= 'e' + +proc dumpHook*[T](s: var string; v: Table[string, T]) = + var pairs = v.pairs.toSeq + pairs.sort do (a, b: (string, T)) -> int: + system.cmp(a[0], b[0]) + s &= 'd' + for (k, v) in pairs.items: + maybeDumpDictPair(s, k, v) + s &= 'e' + +proc dumpHook*(s: var string; v: BencodeObj) = + case v.kind + of Str: + dumpHook(s, v.s) + of Int: + dumpHook(s, v.i) + of List: + dumpHook(s, v.l) + of Dict: + dumpHook(s, v.d) + +import std/json + +proc dumpHook*[T: JsonNode](s: var string; v: T) = + case v.kind + of JString: + dumpHook(s, v.getStr) + of JInt: + dumpHook(s, v.getInt) + of JArray: + dumpHook(s, v.getElems) + of JObject: + dumpHook(s, v.getFields) + of JNull: + raise (ref BencodeEncodeError)(msg: "cannot bencode a JSON null") + of JFloat: + when compiles(dumpHook(s, v.getFloat)): + dumpHook(s, v.getFloat) + else: + raise (ref BencodeEncodeError)(msg: "cannot bencode a JSON float") + of JBool: + dumpHook(s, v.getBool.int) + +proc dumpHook*[T: object](s: var string; v: T) = + s &= 'd' + for fieldName, fieldValue in sortedFieldPairs(v): + maybeDumpDictPair(s, effectiveName(fieldName, fieldValue), fieldValue) + s &= 'e' + +proc dumpHook*[T: ref object](s: var string; v: T) = + if v == nil: + raise (ref BencodeEncodeError)(msg: "cannot bencode a nil ref") + dumpHook(s, v[]) + +proc toBencode*[T](v: T): string = + ## Encode `v` as bencode. + ## + ## .. Note:: The macro that used to be called `toBencode` is now `toBencodeObj`_. Consider instead encoding directly from an object as shown below. + runnableExamples: + import std/json + + type Record = object + name: string + data: BencodeObj + json: JsonNode + + let record = Record( + name: "Steve", + data: be({ + "foo": be"bar", + "baz": be(1), + }), + json: %*{"hello": "world"}, + ) + doAssert record.toBencode == "d4:datad3:bazi1e3:foo3:bare4:jsond5:hello5:worlde4:name5:Stevee" + + result = "" + dumpHook(result, v) + +proc bEncode*(obj: BencodeObj): string = + ## Same as `obj.toBencode<#toBencode,T>`_. + toBencode(obj) diff --git a/bencode/inputstreams.nim b/bencode/inputstreams.nim new file mode 100644 index 0000000..462e194 --- /dev/null +++ b/bencode/inputstreams.nim @@ -0,0 +1,72 @@ +import std/streams + +type + InputStream* = concept v + atEnd(v) is bool + readChar(var v) is char + peekChar(v) is char + getPosition(v) is int + readStr(var v, int) is string + +# StringInputStream + +type + StringInputStream* = object + s: string + i: int + +proc toInputStream*(s: sink string): StringInputStream = + StringInputStream(s: s) + +proc atEnd*(s: StringInputStream): bool = + s.i >= s.s.len + +proc readChar*(s: var StringInputStream): char = + if s.atEnd: + raise (ref IOError)(msg: "no more chars to read") + result = s.s[s.i] + inc s.i + +proc peekChar*(s: StringInputStream): char = + if s.atEnd: + raise (ref IOError)(msg: "no more chars to peek") + result = s.s[s.i] + +proc getPosition*(s: StringInputStream): int = + s.i + +proc readStr*(s: var StringInputStream; len: int): string = + if s.i + len > s.s.len: + raise (ref IOError)(msg: "not enough data left to read a string of length " & $len) + result = s.s[s.i ..< s.i + len] + inc s.i, len + +# StreamInputStream + +type + StreamInputStream* = object + s: Stream + +proc toInputStream*(s: Stream): StreamInputStream = + StreamInputStream(s: s) + +proc atEnd*(s: StreamInputStream): bool = + s.s.atEnd + +proc readChar*(s: var StreamInputStream): char = + if s.s.atEnd: + raise (ref IOError)(msg: "no more chars to read") + result = s.s.readChar + +proc peekChar*(s: StreamInputStream): char = + if s.s.atEnd: + raise (ref IOError)(msg: "no more chars to peek") + result = s.s.peekChar + +proc getPosition*(s: StreamInputStream): int = + s.s.getPosition + +proc readStr*(s: var StreamInputStream; len: int): string = + result = s.s.readStr(len) + if result.len < len: + raise (ref IOError)(msg: "not enough data left to read a string of length " & $len) diff --git a/src/bencodepkg/json.nim b/bencode/json.nim similarity index 94% rename from src/bencodepkg/json.nim rename to bencode/json.nim index f92bebd..0ea87a3 100644 --- a/src/bencodepkg/json.nim +++ b/bencode/json.nim @@ -4,7 +4,7 @@ import std/[ sugar, tables, ] -import ./core +import ./types # to # @@ -18,15 +18,15 @@ proc newJObject(fields: OrderedTable[string, JsonNode]): JsonNode = proc toJson*(obj: BencodeObj): JsonNode = case obj.kind - of bkStr: + of Str: newJString(obj.s) - of bkInt: + of Int: newJInt(obj.i) - of bkList: + of List: newJArray( collect(newSeq, for x in obj.l: x.toJson) ) - of bkDict: + of Dict: newJObject( collect(initOrderedTable, for k, v in obj.d.pairs: {k: v.toJson} diff --git a/bencode/private/macros.nim b/bencode/private/macros.nim new file mode 100644 index 0000000..a7ca8f1 --- /dev/null +++ b/bencode/private/macros.nim @@ -0,0 +1,76 @@ +import std/[ + algorithm, + macros, +] + +macro sortedFieldPairsImpl(ty: object; nameIdent, valueIdent, body: untyped) = + body.expectKind nnkStmtList + if nameIdent.eqIdent(valueIdent): + error("names must be different", valueIdent) + + result = newStmtList() + let objectTy = ty.getTypeImpl + objectTy.expectKind nnkObjectTy + let recList = objectTy[2] + recList.expectKind nnkRecList + + var names = newSeq[NimNode]() + for son in recList: + case son.kind + of nnkIdentDefs: + names.add son[0] + of nnkRecCase: + error("object variants are not supported", son) + else: + error("unsupported node kind", son) + names = names.sortedByIt(it.strVal) + + for name in names: + let newBody = newStmtList( + newProc(nameIdent, [bindSym"untyped"], newLit(name.strVal), nnkTemplateDef, nnkPragma.newTree(ident"used")), + newProc(valueIdent, [bindSym"untyped"], newDotExpr(ty, name), nnkTemplateDef, nnkPragma.newTree(ident"used")), + ) + for node in body: + newBody.add node + result.add newBlockStmt(newBody) + +macro sortedFieldPairs*(loop: ForLoopStmt) = + if loop.len != 4: + error("wrong number of arguments") + let + nameIdent = loop[0] + valueIdent = loop[1] + call = loop[2] + body = loop[3] + call.expectKind nnkCall + let tyIdent = call[1] + result = newCall(bindSym"sortedFieldPairsImpl", tyIdent, nameIdent, valueIdent, body) + +proc removeDeprecatedImpl(body: NimNode) = + case body.kind + of nnkConstSection: + for son in body: + son.expectKind nnkConstDef + if son[0].kind == nnkPragmaExpr: + let pragma = son[0][1] + for i in countdown(pragma.len - 1, 0): + if pragma[i].kind == nnkExprColonExpr and pragma[i][0].eqIdent("deprecated"): + pragma.del(i) + else: + for son in body: + removeDeprecatedImpl(son) + +macro removeDeprecated*(body: untyped): untyped = + when NimMajor < 2: + result = body.copy + removeDeprecatedImpl(result) + else: + result = body + +import ../types + +template effectiveName*(fieldName, fieldValue: untyped): untyped = + when fieldValue.hasCustomPragma(types.name): + fieldValue.getCustomPragmaVal(types.name) + else: + fieldName diff --git a/bencode/types.nim b/bencode/types.nim new file mode 100644 index 0000000..4c4f765 --- /dev/null +++ b/bencode/types.nim @@ -0,0 +1,191 @@ +import ./private/macros +import std/[ + enumerate, + hashes, + macros, + sequtils, + strutils, + tables, +] + +template name*(name: string) {.pragma.} + +type + BencodeKind* = enum + Str = "string" + Int = "integer" + List = "list" + Dict = "dictionary" + BencodeObj* = object + case kind*: BencodeKind + of Str: + s*: string + of Int: + i*: int + of List: + l*: seq[BencodeObj] + of Dict: + d*: OrderedTable[string, BencodeObj] + BencodeFormat* = enum + Normal + Hexadecimal + Decimal + BencodeError* = object of ValueError + +removeDeprecated: + const + bkStr* {.deprecated: "use Str instead".} = BencodeKind.Str + bkInt* {.deprecated: "use Int instead".} = BencodeKind.Int + bkList* {.deprecated: "use List instead".} = BencodeKind.List + bkDict* {.deprecated: "use Dict instead".} = BencodeKind.Dict + +# $ # + +func toString*(a: BencodeObj; f = Normal): string {.raises: [].} + +func toString(str: string; f = Normal): string = + result = "" + case f + of Hexadecimal: + for c in str: + result.add "\\x" & c.ord.toHex(2) + of Decimal: + for c in str: + result.add "\\d" & ($c.ord).align(4, '0') + else: + result = str + +func toString(l: seq[BencodeObj]; f = Normal): string = + result = "@[" + for i, obj in l.pairs: + if i != 0: + result &= ", " + result &= obj.toString(f) + result &= "]" + +func toString(d: OrderedTable[string, BencodeObj]; f = Normal): string = + result = "{ " + for i, (k, v) in enumerate(d.pairs): + if i != 0: + result &= ", " + result &= k.toString(f) + result &= ": " + result &= v.toString(f) + result &= " }" + +func toString*(a: BencodeObj; f = Normal): string = + case a.kind + of Str: '"' & a.s.toString(f) & '"' + of Int: $a.i + of List: a.l.toString(f) + of Dict: a.d.toString(f) + +func `$`*(a: BencodeObj): string {.raises: [].} = + a.toString(Normal) + +# equality # + +func hash*(obj: BencodeObj): Hash = + case obj.kind + of Str: !$(hash(obj.s)) + of Int: !$(hash(obj.i)) + of List: !$(hash(obj.l)) + of Dict: + var h = default Hash + for k, v in obj.d.pairs: + h = hash(k) !& hash(v) + !$(h) + +func `==`*(a, b: BencodeObj): bool = + if a.kind != b.kind: + false + else: + case a.kind + of Str: + a.s == b.s + of Int: + a.i == b.i + of List: + a.l == b.l + of Dict: + if a.d.len != b.d.len: + return false + for key in a.d.keys: + if not b.d.hasKey(key): + return false + if a.d[key] != b.d[key]: + return false + true + +# constructors # + +proc `name=`(procDef, name: NimNode) = + procDef.expectKind nnkProcDef + name.expectKind {nnkIdent, nnkSym} + + case procDef[0].kind + of nnkPostfix: + procDef[0][1] = name + of nnkIdent: + procDef[0] = name + else: + error("unexpected node kind", procDef[0]) + +macro alias(name, procDef: untyped): untyped = + procDef.expectKind nnkProcDef + name.expectKind {nnkIdent, nnkSym} + + let aliasProcDef = procDef.copy + aliasProcDef.name = name + newStmtList(procDef, aliasProcDef) + +proc Bencode*(s: sink string): BencodeObj {.alias: be.} = + BencodeObj(kind: Str, s: s) + +proc Bencode*(i: int): BencodeObj {.alias: be.} = + BencodeObj(kind: Int, i: i) + +proc Bencode*(l: sink seq[BencodeObj]): BencodeObj {.alias: be.} = + BencodeObj(kind: List, l: l) + +proc Bencode*(l: sink openArray[BencodeObj]): BencodeObj {.alias: be.} = + BencodeObj(kind: List, l: l.toSeq) + +proc Bencode*(d: sink OrderedTable[string, BencodeObj]): BencodeObj {.alias: be.} = + BencodeObj(kind: Dict, d: d) + +proc Bencode*(d: sink openArray[(string, BencodeObj)]): BencodeObj {.alias: be.} = + Bencode(d.toOrderedTable) + +func toBencodeObjImpl(value: NimNode): NimNode = + # Adapted from std/json's `%*`: https://github.com/nim-lang/Nim/blob/0b44840299c15faa3b74cb82f48dcd56023f7d35/lib/pure/json.nim#L411 + case value.kind + of nnkBracket: # array + if value.len == 0: + quote: BencodeObj(kind: List) + else: + var bracketNode = nnkBracket.newNimNode() + for i in 0 ..< value.len: + bracketNode.add(toBencodeObjImpl(value[i])) + newCall(bindSym("Bencode", brOpen), bracketNode) + of nnkTableConstr: # object + if value.len == 0: + quote: BencodeObj(kind: Dict) + else: + var tableNode = nnkTableConstr.newNimNode() + for i in 0 ..< value.len: + value[i].expectKind nnkExprColonExpr + tableNode.add nnkExprColonExpr.newTree(value[i][0], toBencodeObjImpl(value[i][1])) + newCall(bindSym("Bencode", brOpen), tableNode) + of nnkPar: + if value.len == 1: + toBencodeObjImpl(value[0]) + else: + # what is this? + newCall(bindSym("Bencode", brOpen), value) + else: + newCall(bindSym("Bencode", brOpen), value) + +macro toBencodeObj*(value: untyped): BencodeObj = + ## .. Note:: Consider instead encoding directly from an object using `toBencode`_. + toBencodeObjImpl(value) diff --git a/src/bencodepkg/core.nim b/src/bencodepkg/core.nim deleted file mode 100644 index 181c88b..0000000 --- a/src/bencodepkg/core.nim +++ /dev/null @@ -1,119 +0,0 @@ -import std/[ - streams, - strutils, - tables, -] -import ./types - -export types - -# encode # - -proc bEncode*(obj: BencodeObj): string - -proc encodeStr(s: string): string = - $s.len & ':' & s - -proc encodeInt(i: int): string = - 'i' & $i & 'e' - -proc encodeList(l: seq[BencodeObj]): string = - result = "l" - for el in l: - result &= bEncode(el) - result &= "e" - -proc encodeDict(d: OrderedTable[string, BencodeObj]): string = - var d = d - d.sort do (x, y: tuple[key: string; value: BencodeObj]) -> int: - system.cmp(x.key, y.key) - - result = "d" - for k, v in d.pairs(): - result &= encodeStr(k) & bEncode(v) - - result &= "e" - -proc bEncode*(obj: BencodeObj): string = - result = case obj.kind - of bkStr: - encodeStr(obj.s) - of bkInt: - encodeInt(obj.i) - of bkList: - encodeList(obj.l) - of bkDict: - encodeDict(obj.d) - -# decode # - -proc bDecode*(s: Stream): BencodeObj - -proc decodeStr(s: Stream): BencodeObj = - # : - # get the length - var lengthStr = "" - while not s.atEnd and s.peekChar() != ':': - lengthStr &= s.readChar() - discard s.readChar() # advance past the ':' - let length = parseInt(lengthStr) - - # read the string - let str = - if length >= 0: - s.readStr(length) - else: - "" - BencodeObj(kind: bkStr, s: str) - -proc decodeInt(s: Stream): BencodeObj = - # ie - var iStr = "" - discard s.readChar() # 'i' - while not s.atEnd and s.peekChar() != 'e': - iStr &= s.readChar() - discard s.readChar() # 'e' - BencodeObj(kind: bkInt, i: parseInt(iStr)) - -proc decodeList(s: Stream): BencodeObj = - # l ... e - var l: seq[BencodeObj] - discard s.readChar() # advance past the 'l' - while not s.atEnd and s.peekChar() != 'e': - l.add(bDecode(s)) - discard s.readChar() # 'e' - BencodeObj(kind: bkList, l: l) - -proc decodeDict(s: Stream): BencodeObj = - # d ... e - var - d: OrderedTable[string, BencodeObj] - isReadingKey = true - curKey: string - discard s.readChar() # 'd' - while not s.atEnd and s.peekChar() != 'e': - if isReadingKey: - let keyObj = bDecode(s) - if keyObj.kind != bkStr: - raise newException(ValueError, "invalid dictionary key: expected " & $bkStr & ", got " & $keyObj.kind) - curKey = keyObj.s - isReadingKey = false - else: - d[curKey] = bDecode(s) - isReadingKey = true - discard s.readChar() # 'e' - BencodeObj(kind: bkDict, d: d) - -proc bDecode*(s: Stream): BencodeObj = - assert not s.atEnd - result = case s.peekChar() - of 'i': decodeInt(s) - of 'l': decodeList(s) - of 'd': decodeDict(s) - else: decodeStr(s) - -proc bDecode*(source: string): BencodeObj = - bDecode(newStringStream(source)) - -proc bDecode*(f: File): BencodeObj = - bDecode(newFileStream(f)) diff --git a/src/bencodepkg/types.nim b/src/bencodepkg/types.nim deleted file mode 100644 index d7f78f8..0000000 --- a/src/bencodepkg/types.nim +++ /dev/null @@ -1,157 +0,0 @@ -import std/[ - hashes, - macros, - sequtils, - strutils, - sugar, - tables, -] - -type - BencodeKind* = enum - bkStr = "string" - bkInt = "integer" - bkList = "list" - bkDict = "dictionary" - BencodeObj* = object - case kind*: BencodeKind - of bkStr: - s*: string - of bkInt: - i*: int - of bkList: - l*: seq[BencodeObj] - of bkDict: - d*: OrderedTable[string, BencodeObj] - BencodeFormat* = enum - Normal - Hexadecimal - Decimal - -# $ # - -func toString*(a: BencodeObj; f = Normal): string - -func toString(str: string; f = Normal): string = - case f - of Hexadecimal: str.map(c => "\\x" & ord(c).toHex(2)).join("") - of Decimal: str.map(c => "\\d" & ord(c).`$`.align(4, '0')).join("") - else: str - -func toString(l: seq[BencodeObj]; f = Normal): string = - "@[" & l.map(obj => obj.toString(f)).join(", ") & "]" - -func toString(d: OrderedTable[string, BencodeObj]; f = Normal): string = - "{ " & collect(newSeq, for k, v in d.pairs: k.toString(f) & ": " & v.toString(f)).join(", ") & " }" - -func toString*(a: BencodeObj; f = Normal): string = - case a.kind - of bkStr: '"' & a.s.toString(f) & '"' - of bkInt: $a.i - of bkList: a.l.toString(f) - of bkDict: a.d.toString(f) - -func `$`*(a: BencodeObj): string = - a.toString(Normal) - -# equality # - -func hash*(obj: BencodeObj): Hash = - case obj.kind - of bkStr: !$(hash(obj.s)) - of bkInt: !$(hash(obj.i)) - of bkList: !$(hash(obj.l)) - of bkDict: - var h: Hash - for k, v in obj.d.pairs: - h = hash(k) !& hash(v) - !$(h) - -func `==`*(a, b: BencodeObj): bool = - if a.kind != b.kind: - result = false - else: - case a.kind - of bkStr: - result = a.s == b.s - of bkInt: - result = a.i == b.i - of bkList: - result = a.l == b.l - of bkDict: - if a.d.len != b.d.len: - return false - for key in a.d.keys: - if not b.d.hasKey(key): - return false - if a.d[key] != b.d[key]: - return false - result = true - -# constructors # - -proc Bencode*(s: sink string): BencodeObj = - BencodeObj(kind: bkStr, s: s) - -proc Bencode*(i: int): BencodeObj = - BencodeObj(kind: bkInt, i: i) - -proc Bencode*(l: sink seq[BencodeObj]): BencodeObj = - BencodeObj(kind: bkList, l: l) - -proc Bencode*(l: sink openArray[BencodeObj]): BencodeObj = - BencodeObj(kind: bkList, l: l.toSeq) - -proc Bencode*(d: sink OrderedTable[string, BencodeObj]): BencodeObj = - BencodeObj(kind: bkDict, d: d) - -proc Bencode*(d: sink openArray[(string, BencodeObj)]): BencodeObj = - Bencode(d.toOrderedTable) - -func toBencodeImpl(value: NimNode): NimNode = - # Adapted from std/json's `%*`: https://github.com/nim-lang/Nim/blob/0b44840299c15faa3b74cb82f48dcd56023f7d35/lib/pure/json.nim#L411 - case value.kind - of nnkBracket: # array - if value.len == 0: - quote: BencodeObj(kind: bkList) - else: - var bracketNode = nnkBracket.newNimNode() - for i in 0 ..< value.len: - bracketNode.add(toBencodeImpl(value[i])) - newCall(bindSym("Bencode", brOpen), bracketNode) - of nnkTableConstr: # object - if value.len == 0: - quote: BencodeObj(kind: bkDict) - else: - var tableNode = nnkTableConstr.newNimNode() - for i in 0 ..< value.len: - value[i].expectKind nnkExprColonExpr - tableNode.add nnkExprColonExpr.newTree(value[i][0], toBencodeImpl(value[i][1])) - newCall(bindSym("Bencode", brOpen), tableNode) - of nnkPar: - if value.len == 1: - toBencodeImpl(value[0]) - else: - # what is this? - newCall(bindSym("Bencode", brOpen), value) - else: - newCall(bindSym("Bencode", brOpen), value) - -macro toBencode*(value: untyped): untyped = - toBencodeImpl(value) - -template be*(strVal: string): BencodeObj = - BencodeObj(kind: bkStr, s: strVal) - -template be*(intVal: int): BencodeObj = - BencodeObj(kind: bkInt, i: intVal) - -template be*(listVal: seq[BencodeObj]): BencodeObj = - BencodeObj(kind: bkList, l: listVal) - -template be*(dictVal: OrderedTable[string, BencodeObj]): BencodeObj = - BencodeObj(kind: bkDict, d: dictVal) - -template be*(dictVal: openArray[(string, BencodeObj)]): BencodeObj = - mixin toOrderedTable - BencodeObj(kind: bkDict, d: dictVal.toOrderedTable) diff --git a/tests/config.nims b/tests/config.nims deleted file mode 100644 index 3bb69f8..0000000 --- a/tests/config.nims +++ /dev/null @@ -1 +0,0 @@ -switch("path", "$projectDir/../src") \ No newline at end of file diff --git a/tests/nim.cfg b/tests/nim.cfg new file mode 100644 index 0000000..aa3c1b9 --- /dev/null +++ b/tests/nim.cfg @@ -0,0 +1 @@ +--path:"$projectDir/.." diff --git a/tests/tdecoding.nim b/tests/tdecoding.nim new file mode 100644 index 0000000..6ce78c7 --- /dev/null +++ b/tests/tdecoding.nim @@ -0,0 +1,85 @@ +import ./utils +import pkg/bencode +import std/[ + streams, + strutils, + unittest, +] + +test "execution terminates for invalid bencode input": + const data = "d4:name4:dmdm4:lang3:nim3:agei50e5:alistli1e2:hiee" + for i in 0 .. data.high: + if i in {0, 30, 31, 35..40}: + # input is valid even if we remove these indexes + continue + let invalidData = data[0 .. i - 1] & data[i + 1 .. ^1] + try: + discard bDecode(invalidData) + except BencodeDecodeError: + discard + +test "string too short": + let exception = + expect BencodeDecodeError: + discard bDecode("10:hello") + check exception.kind == SyntaxError + +test "invalid string length": + let exception = + expect BencodeDecodeError: + discard bDecode("-5:hello") + check exception.kind == InvalidValue + check "invalid string length" in exception.msg + +test "unexpected end of input": + var exception: ref BencodeDecodeError + exception = + expect BencodeDecodeError: + discard bDecode("l") + check exception.kind == SyntaxError + check "expected 'e'" in exception.msg + + exception = + expect BencodeDecodeError: + discard bDecode("d") + check exception.kind == SyntaxError + check "expected 'e'" in exception.msg + + exception = + expect BencodeDecodeError: + discard bDecode("d5:hello5:world3:foo3:bar") + check exception.kind == SyntaxError + check "expected 'e'" in exception.msg + + exception = + expect BencodeDecodeError: + discard bDecode("d5:hello5:world3:foo") + check exception.kind == SyntaxError + check "expected value" in exception.msg + + exception = + expect BencodeDecodeError: + discard bDecode("d5:hello5:world3:fooe") + check exception.kind == SyntaxError + check "expected value" in exception.msg + + exception = + expect BencodeDecodeError: + echo bDecode("5") + check exception.kind == SyntaxError + check "expected ':'" in exception.msg + +test "catch wrong dictionary key kind": + const data = "d4:name4:dmdmi123e3:nim3:agei50e5:alistli1e2:hiee" + let exception = + expect BencodeDecodeError: + discard bDecode(data) + check exception.kind == SyntaxError + check exception.msg == "invalid integer: i123e3" + +test "various input types": + const expected = ["hello", "world", "!!"] + checkDecode(array[3, string], "l5:hello5:world2:!!ee", expected) + +test "zero-length string": + checkDecode(string, "0:", "") diff --git a/tests/tdecoding_parsehook.nim b/tests/tdecoding_parsehook.nim new file mode 100644 index 0000000..c232ae8 --- /dev/null +++ b/tests/tdecoding_parsehook.nim @@ -0,0 +1,23 @@ +import pkg/bencode +import std/[ + times, + unittest, +] + +proc parseHook*(s: var InputStream; v: var DateTime) = + var str = "" + parseHook(s, str) + v = times.parse(str, "yyyy-MM-dd'T'HH:mm:ssz", utc()) + +test "custom parseHook": + type + Foo = object + c: int + date: DateTime + e: string + + check Foo.fromBencode("d1:ci42e4:date20:2020-01-01T10:20:30Z1:e2:hie") == Foo( + c: 42, + date: dateTime(2020, mJan, 1, 10, 20, 30, zone = utc()), + e: "hi", + ) diff --git a/tests/tencoding.nim b/tests/tencoding.nim new file mode 100644 index 0000000..7fe2813 --- /dev/null +++ b/tests/tencoding.nim @@ -0,0 +1,13 @@ +import pkg/bencode +import std/[ + json, + unittest, +] + +test "from JsonNode": + let j = %*{"foo": "bar", "baz": [1, "qux", true]} + check j.toBencode == "d3:bazli1e3:quxi1ee3:foo3:bare" + check toBencode(j) == "d3:bazli1e3:quxi1ee3:foo3:bare" + + expect BencodeEncodeError: + discard (%*{"wow": 3.14}).toBencode diff --git a/tests/tencoding_dumphook.nim b/tests/tencoding_dumphook.nim new file mode 100644 index 0000000..2396d50 --- /dev/null +++ b/tests/tencoding_dumphook.nim @@ -0,0 +1,11 @@ +import pkg/bencode +import std/[ + json, + unittest, +] + +proc dumpHook*(s: var string; v: float) = + dumpHook(s, int(v * 100)) + +test "from JsonNode with float and custom dumpHook": + check (%*{"wow": 3.14}).toBencode == "d3:wowi314ee" diff --git a/tests/test.nim b/tests/test.nim deleted file mode 100644 index 5c64144..0000000 --- a/tests/test.nim +++ /dev/null @@ -1,165 +0,0 @@ -import ./utils -import pkg/bencode -import std/unittest -import std/[ - json, - tables, -] - -test "basic encode/decode": - let - myList = @[Bencode(1), Bencode("hi")] - myDict = - { - "name": Bencode("dmdm"), - "lang": Bencode("nim"), - "age": Bencode(50), - "alist": Bencode(myList), - } - testPairs = - { - Bencode("hello"): "5:hello", - Bencode("yes"): "3:yes", - Bencode(55): "i55e", - Bencode(12345): "i12345e", - Bencode(myList): "li1e2:hie", - Bencode(myDict): "d3:agei50e5:alistli1e2:hie4:lang3:nim4:name4:dmdme", - }.toOrderedTable - - for k, v in testPairs.pairs: - check bEncode(k) == v - check bDecode(v) == k - -test "conversion to json": - let - expected = parseJson(""" - { - "foo": 69, - "bar": [ - { - "baz": 420, - "qux": 6969, - } - ] - } - """) - actual = Bencode({ - "foo": Bencode(69), - "bar": Bencode(@[ - Bencode({ - "baz": Bencode(420), - "qux": Bencode(6969), - }), - ]), - }).toJson - - check actual == expected - -test "conversion from json": - let - expected = Bencode({ - "foo": Bencode(69), - "bar": Bencode(@[ - Bencode({ - "baz": Bencode(420), - "qux": Bencode(6969), - }), - Bencode(3), # float truncation - ]), - }) - actual = parseJson(""" - { - "foo": 69, - "bar": [ - { - "baz": 420, - "qux": 6969 - }, - 3.14159 - ] - } - """).fromJson - - check actual == expected - -test "dictionary access by string key": - var b = Bencode({ - "interval": Bencode(1800), - "complete": Bencode(20), - }) - check b.d["interval"] == Bencode(1800) - b.d["complete"] = Bencode(30) - check b.d["complete"] == Bencode(30) - - check b == be({ - "interval": be(1800), - "complete": be(30), - }) - -test "execution terminates for invalid bencode input": - const data = "d4:name4:dmdm4:lang3:nim3:agei50e5:alistli1e2:hiee" - for i in 0 .. data.high: - if i in {0, 30, 31, 35..40}: - # input is valid even if we remove these indexes - continue - let invalidData = data[0 .. i - 1] & data[i + 1 .. ^1] - try: - discard bDecode(invalidData) - except ValueError: - discard - -test "string too short": - const data = "10:hello" - check bDecode(data) == Bencode("hello") - -test "invalid string length": - const data = "-5:hello" - check bDecode(data) == Bencode("") - -test "unexpected end of input": - check bDecode("l").l == newSeq[BencodeObj]() - check bDecode("d").d == initOrderedTable[string, BencodeObj]() - check bDecode("d5:hello5:world3:foo").d == { "hello": Bencode("world") }.toOrderedTable - -test "toBencode": - let world = "world" - - func getValue(): int = - 314159 - - let actual = toBencode({ - "foo": [1, 2, 3], - "bar": { - "nested": getValue(), - "nested2": [ - { - "bar": "hello " & world, - }, - ], - }, - "paren": (3 + 4), - "empty list": [], - "empty dict": {:}, - }) - let expected = Bencode({ - "foo": Bencode([Bencode(1), Bencode(2), Bencode(3)]), - "bar": Bencode({ - "nested": Bencode(314159), - "nested2": Bencode([ - Bencode({ - "bar": Bencode("hello world"), - }) - ]), - }), - "paren": Bencode(7), - "empty list": BencodeObj(kind: bkList), - "empty dict": BencodeObj(kind: bkDict), - }) - check actual == expected - -test "catch wrong dictionary key kind": - const data = "d4:name4:dmdmi123e3:nim3:agei50e5:alistli1e2:hiee" - let exception = - expect(ValueError): - discard bDecode(data) - check exception.msg == "invalid dictionary key: expected string, got integer" diff --git a/tests/tjson.nim b/tests/tjson.nim new file mode 100644 index 0000000..ead3a33 --- /dev/null +++ b/tests/tjson.nim @@ -0,0 +1,58 @@ +import pkg/bencode +import pkg/bencode/json +import std/[ + json, + unittest, +] + +test "conversion to json": + let + expected = parseJson(""" + { + "foo": 69, + "bar": [ + { + "baz": 420, + "qux": 6969, + } + ] + } + """) + actual = Bencode({ + "foo": Bencode(69), + "bar": Bencode(@[ + Bencode({ + "baz": Bencode(420), + "qux": Bencode(6969), + }), + ]), + }).toJson + + check actual == expected + +test "conversion from json": + let + expected = Bencode({ + "foo": Bencode(69), + "bar": Bencode(@[ + Bencode({ + "baz": Bencode(420), + "qux": Bencode(6969), + }), + Bencode(3), # float truncation + ]), + }) + actual = parseJson(""" + { + "foo": 69, + "bar": [ + { + "baz": 420, + "qux": 6969 + }, + 3.14159 + ] + } + """).fromJson + + check actual == expected diff --git a/tests/toldreadme.nim b/tests/toldreadme.nim new file mode 100644 index 0000000..a83c9e8 --- /dev/null +++ b/tests/toldreadme.nim @@ -0,0 +1,16 @@ +import pkg/bencode +when NimMajor >= 2: + import std/assertions + +let + data = be({ + "interval": be(1800), + "min interval": be(900), + "peers": be("\x0a\x0a\x0a\x05\x00\x80"), + "complete": be(20), + "incomplete": be(0), + }) + bencodedData = bEncode(data) + +doAssert bencodedData == "d8:completei20e10:incompletei0e8:intervali1800e12:min intervali900e5:peers6:\x0a\x0a\x0a\x05\x00\x80e" +doAssert bDecode(bencodedData) == data diff --git a/tests/treadme.nim b/tests/treadme.nim index f455694..a5ce0df 100644 --- a/tests/treadme.nim +++ b/tests/treadme.nim @@ -1,14 +1,69 @@ import pkg/bencode -let - data = be({ - "interval": be(1800), - "min interval": be(900), - "peers": be("\x0a\x0a\x0a\x05\x00\x80"), - "complete": be(20), - "incomplete": be(0), - }) - bencodedData = bEncode(data) - -doAssert bencodedData == "d8:completei20e10:incompletei0e8:intervali1800e12:min intervali900e5:peers6:\x0a\x0a\x0a\x05\x00\x80e" -doAssert bDecode(bencodedData) == data +const Expected = "d8:completei20e10:incompletei0e8:intervali1800e12:min intervali900e5:peers6:\x0a\x0a\x0a\x05\x00\x80e" + +block: + type + Data = object + interval: int + minInterval {.name: "min interval".}: int + peers: string + complete: int + incomplete: int + + let + data = Data( + interval: 1800, + minInterval: 900, + peers: "\x0a\x0a\x0a\x05\x00\x80", + complete: 20, + incomplete: 0, + ) + bencodedData = data.toBencode() + + doAssert bencodedData == Expected + doAssert Data.fromBencode(bencodedData) == data + +import std/tables + +block: + let + data = { + "interval": be(1800), + "min interval": be(900), + "peers": be("\x0a\x0a\x0a\x05\x00\x80"), + "complete": be(20), + "incomplete": be(0), + }.toTable + bencodedData = data.toBencode() + + doAssert bencodedData == Expected + doAssert Table[string, BencodeObj].fromBencode(bencodedData) == data + +block: + let + data = be({ + "interval": be(1800), + "min interval": be(900), + "peers": be("\x0a\x0a\x0a\x05\x00\x80"), + "complete": be(20), + "incomplete": be(0), + }) + bencodedData = data.toBencode + + doAssert bencodedData == Expected + doAssert BencodeObj.fromBencode(bencodedData) == data + +block: + let + data = be({ + "interval": be(1800), + "min interval": be(900), + "peers": be("\x0a\x0a\x0a\x05\x00\x80"), + "complete": be(20), + "incomplete": be(0), + }) + bencodedData = bEncode(data) + + doAssert bencodedData == Expected + doAssert bDecode(bencodedData) == data diff --git a/tests/troundtrip.nim b/tests/troundtrip.nim new file mode 100644 index 0000000..b11e1bf --- /dev/null +++ b/tests/troundtrip.nim @@ -0,0 +1,119 @@ +import ./utils +import pkg/bencode +import std/[ + streams, + tables, + unittest, +] + +test "basic encode/decode": + let + myList = @[Bencode(1), Bencode("hi")] + myDict = + { + "name": Bencode("dmdm"), + "lang": Bencode("nim"), + "age": Bencode(50), + "alist": Bencode(myList), + } + testPairs = + { + Bencode("hello"): "5:hello", + Bencode("yes"): "3:yes", + Bencode(55): "i55e", + Bencode(12345): "i12345e", + Bencode(myList): "li1e2:hie", + Bencode(myDict): "d3:agei50e5:alistli1e2:hie4:lang3:nim4:name4:dmdme", + }.toOrderedTable + + for k, v in testPairs.pairs: + check bEncode(k) == v + check bDecode(v) == k + +test "to/from (ref) object": + type + Record = object + name: string + lang {.name: "the language".}: string + age: int + alist: seq[BencodeObj] + blist: seq[int] + mydict: OrderedTable[string, string] + myarray: array[2, string] + notInBencode: string + + let expectedRecord = Record( + name: "dmdm", + lang: "nim", + age: 50, + alist: @[Bencode(1), Bencode("hi")], + blist: @[100, 200], + mydict: {"foo": "bar"}.toOrderedTable, + myarray: ["hello", "world"], + notInBencode: "", + ) + + # decode + const data = "d3:agei50e9:extra keyi123e7:myarrayl5:hello5:worlde5:alistli1e2:hie12:the language3:nim4:name4:dmdm5:blistli100ei200ee6:mydictd3:foo3:baree" + checkDecode(Record, data, expectedRecord) + let refRecord = (ref Record).fromBencode(data) + check refRecord != nil + check refRecord[] == expectedRecord + {.push warning[Deprecated]:off.} + check data.fromBencode(Record) == expectedRecord + {.pop.} + + # encode + const dataOut = "d3:agei50e5:alistli1e2:hie5:blistli100ei200ee12:the language3:nim7:myarrayl5:hello5:worlde6:mydictd3:foo3:bare4:name4:dmdm12:notInBencode0:e" + check expectedRecord.toBencode == dataOut + check refRecord.toBencode == dataOut + +test "to/from various table types": + const data = "d3:agei50e5:alistli1e2:hie4:lang3:nim4:name4:dmdme" + let expectedTablePairs = { + "age": Bencode(50), + "alist": Bencode(@[Bencode(1), Bencode("hi")]), + "lang": Bencode("nim"), + "name": Bencode("dmdm"), + } + check OrderedTable[string, BencodeObj].fromBencode(data) == expectedTablePairs.toOrderedTable + check Table[string, BencodeObj].fromBencode(data) == expectedTablePairs.toTable + check expectedTablePairs.toOrderedTable.toBencode == data + check expectedTablePairs.toTable.toBencode == data + +import std/json + +test "to JsonNode": + const data = "d3:agei50e5:alistli1e2:hie4:lang3:nim4:name4:dmdme" + let expected = %*{ + "age": 50, + "alist": [1, "hi"], + "lang": "nim", + "name": "dmdm", + } + check JsonNode.fromBencode(data) == expected + +test "to/from array": + let exception = + expect BencodeDecodeError: + discard array[2, string].fromBencode("l5:hello5:world2:!!ee") + check exception.kind == WrongLength + check exception.msg == "list too long: expected 2 items, got at least 3 items" + + check array[2, string].fromBencode("l5:hello5:worlde") == ["hello", "world"] + check ["hello", "world"].toBencode == "l5:hello5:worlde" + +test "nil ref objects": + type + Foo = object + s: string + Bar = object + e: int + f: ref Foo + g: string + + let bar = Bar(e: 321, f: nil, g: "hi") + const expected = "d1:ei321e1:g2:hie" + let actual = bar.toBencode + check actual == expected + check Bar.fromBencode(actual) == bar diff --git a/tests/ttypes.nim b/tests/ttypes.nim new file mode 100644 index 0000000..534f1c0 --- /dev/null +++ b/tests/ttypes.nim @@ -0,0 +1,81 @@ +import pkg/bencode +import std/[ + tables, + unittest, +] + +test "dictionary access by string key": + var b = Bencode({ + "interval": Bencode(1800), + "complete": Bencode(20), + }) + check b.d["interval"] == Bencode(1800) + b.d["complete"] = Bencode(30) + check b.d["complete"] == Bencode(30) + + check b == be({ + "interval": be(1800), + "complete": be(30), + }) + +test "toBencodeObj": + let world = "world" + + func getValue(): int = + 314159 + + let actual = toBencodeObj({ + "foo": [1, 2, 3], + "bar": { + "nested": getValue(), + "nested2": [ + { + "bar": "hello " & world, + }, + ], + }, + "paren": (3 + 4), + "empty list": [], + "empty dict": {:}, + }) + let expected = Bencode({ + "foo": Bencode([Bencode(1), Bencode(2), Bencode(3)]), + "bar": Bencode({ + "nested": Bencode(314159), + "nested2": Bencode([ + Bencode({ + "bar": Bencode("hello world"), + }) + ]), + }), + "paren": Bencode(7), + "empty list": BencodeObj(kind: List), + "empty dict": BencodeObj(kind: Dict), + }) + check actual == expected + +template checkEquals(a, b: BencodeObj) = + check a == b + check hash(a) == hash(b) + +template checkNotEquals(a, b: BencodeObj) = + check a != b + check hash(a) != hash(b) + +test "equality": + checkEquals be"hello", be"hello" + checkNotEquals be"hello", be"world" + checkEquals be(100), be(100) + checkNotEquals be(100), be(200) + checkEquals be([be"hello", be"world"]), Bencode([be"hello", be"world"]) + checkNotEquals be([be"hello", be"world"]), Bencode([be"world", be"hello"]) + checkEquals be({"hello": be"world", "world": be([be"foo", be(1)])}), be({"hello": be"world", "world": be([be"foo", be(1)])}) + checkNotEquals be({"hello": be"world", "world": be([be"foo", be(1)])}), be({"hello": be"world", "world": be([be"foo", be(2)])}) + +test "stringify": + check $be("hello") == """"hello"""" + check be("hello").toString(Normal) == """"hello"""" + check be("hello").toString(Hexadecimal) == """"\x68\x65\x6C\x6C\x6F"""" + check be("hello").toString(Decimal) == """"\d0104\d0101\d0108\d0108\d0111"""" + check $be([be"hello", be(2)]) == """@["hello", 2]""" + check $be({"hello": be"world", "world": be([be"foo", be(1)])}) == """{ hello: "world", world: @["foo", 1] }""" diff --git a/tests/utils.nim b/tests/utils.nim index 2148a6b..0611f11 100644 --- a/tests/utils.nim +++ b/tests/utils.nim @@ -9,3 +9,15 @@ template expect*[T: Exception](errorType: typedesc[T]; body: untyped): ref T = exception = e raise exception + +template checkDecode*(t: typedesc; data: static string; expected: untyped) = + # runtime string + block: + let d = data + check t.fromBencode(d) == expected + # compile-time string + block: + const actual = t.fromBencode(data) + check actual == expected + # stream + check t.fromBencode(newStringStream(data)) == expected diff --git a/tools/mdextractcodeblocks.nim b/tools/mdextractcodeblocks.nim new file mode 100644 index 0000000..a9b6b7c --- /dev/null +++ b/tools/mdextractcodeblocks.nim @@ -0,0 +1,27 @@ +import std/[ + os, + strformat, + strutils, +] + +if paramCount() < 1: + quit "no filename given" +let + filename = paramStr(1) + name = filename.splitFile.name +var + f = File nil + i = 0 +for line in lines(filename): + if f == nil: + if line.strip == "```nim": + let outFilename = &"{name}_{i}.nim" + f = open(outFilename, fmWrite) + inc i + echo outFilename + else: + if line.strip == "```": + f.close + f = nil + else: + f.writeLine(line)