diff --git a/README.md b/README.md index 5ece875..7adba32 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,13 @@ nimble install https://github.com/Patitotective/kdl-nim ``` ## Features +- Streams support +- Compile-time parsing support +- [Decoder/Desializer](https://patitotective.github.io/kdl-nim/kdl/decoder.html) +- [Encoder/Serializer](https://patitotective.github.io/kdl-nim/kdl/encoder.html) - [JSON-in-KDL](https://github.com/kdl-org/kdl/blob/main/JSON-IN-KDL.md) ([JiK](https://patitotective.github.io/kdl-nim/kdl/jik.html)) - [XML-in-KDL](https://github.com/kdl-org/kdl/blob/main/XML-IN-KDL.md) ([Xik](https://patitotective.github.io/kdl-nim/kdl/xik.html)) +- [Prefs](https://patitotective.github.io/kdl-nim/kdl/prefs.html) ## Overview ```nim @@ -83,7 +88,6 @@ doc.writeFile("doc.kdl") Documentation is live at https://patitotective.github.io/kdl-nim/. ## TODO -- Make the lexer and parser work with `openArray[char]` or `cstring` - Implement [KDL schema language](https://github.com/kdl-org/kdl/blob/main/SCHEMA-SPEC.md). - Implement [KDL query language](https://github.com/kdl-org/kdl/blob/main/QUERY-SPEC.md). diff --git a/kdl.nimble b/kdl.nimble index 238c377..636b176 100644 --- a/kdl.nimble +++ b/kdl.nimble @@ -1,15 +1,15 @@ # Package -version = "0.2.2" +version = "1.0.0" author = "Patitotective" description = "KDL document language Nim implementation" license = "MIT" srcDir = "src" - +skipFiles = @["src/kdl/query.nim", "src/kdl/schema.nim"] # Dependencies -requires "nim >= 1.6.6" +requires "nim >= 1.6.8" task docs, "Generate documentation": exec "nim doc --git.url:https://github.com/Patitotective/kdl-nim --git.commit:main --outdir:docs --project src/kdl.nim" diff --git a/src/kdl.nim b/src/kdl.nim index 4808d7a..08ec717 100644 --- a/src/kdl.nim +++ b/src/kdl.nim @@ -16,10 +16,10 @@ runnableExamples: let doc = parseKdl("node 1 null {child \"abc\" true}") # You can also read files using parseKdlFile("file.kdl") - assert doc[0][0].isInt() # 1 - assert doc[0][1].isNull() # null - assert doc[0].children[0][0].isString() # "abc" - assert doc[0].children[0][1].isBool() # true + assert doc[0].args[0].isInt() # 1 + assert doc[0].args[1].isNull() # null + assert doc[0].children[0].args[0].isString() # "abc" + assert doc[0].children[0].args[1].isBool() # true ## ### Reading nodes runnableExamples: @@ -28,7 +28,7 @@ runnableExamples: assert doc[0].name == "node" assert doc[0].tag.isSome and doc[0].tag.get == "tag" # Tags are Option[string] assert doc[0]["key"] == "val" # Same as doc[0].props["key"] - assert doc[0].children[0][0] == "abc" # Same as doc[0].children[0].args[0] + assert doc[0].children[0].args[0] == "abc" # Same as doc[0].children[0].args[0] ## ### Reading values ## Accessing to the inner value of any `KdlVal` can be achieved by using any of the following procedures: @@ -39,36 +39,37 @@ runnableExamples: runnableExamples: let doc = parseKdl("node 1 3.14 {child \"abc\" true}") - assert doc[0][0].getInt() == 1 - assert doc[0][1].getFloat() == 3.14 - assert doc[0].children[0][0].getString() == "abc" - assert doc[0].children[0][1].getBool() == true + assert doc[0].args[0].getInt() == 1 + assert doc[0].args[1].getFloat() == 3.14 + assert doc[0].children[0].args[0].getString() == "abc" + assert doc[0].children[0].args[1].getBool() == true ## There's also a generic procedure that converts `KdlValue` to the given type, consider this example: runnableExamples: let doc = parseKdl("node 1 3.14 255") - assert doc[0][0].get(float32) == 1f - assert doc[0][1].get(int) == 3 - assert doc[0][2].get(uint8) == 255u8 + assert doc[0].args[0].get(float32) == 1f + assert doc[0].args[1].get(int) == 3 + assert doc[0].args[2].get(uint8) == 255u8 ## It only converts between numbers, you can't `val.get(string)` if `val.isBool()`. +## ## ### Setting values runnableExamples: var doc = parseKdl("node 1 3.14 {child \"abc\" true}") - doc[0][0].setInt(10) - assert doc[0][0] == 10 + doc[0].args[0].setInt(10) + assert doc[0].args[0] == 10 - doc[0].children[0][1].setBool(false) - assert doc[0].children[0][1] == false + doc[0].children[0].args[1].setBool(false) + assert doc[0].children[0].args[1] == false # You can also use the generic procedure `setTo` - doc[0][0].setTo(3.14) - assert doc[0][0] == 3 + doc[0].args[0].setTo(3.14) + assert doc[0].args[0] == 3 - doc[0].children[0][0].setTo("def") - assert doc[0].children[0][0] == "def" + doc[0].children[0].args[0].setTo("def") + assert doc[0].children[0].args[0] == "def" ## ### Creating KDL ## To create KDL documents, nodes or values without parsing you can also use the `toKdl`, `toKdlNode` and `toKdlVal` macros which have a similar syntax to KDL: @@ -84,13 +85,21 @@ runnableExamples: let node = toKdlNode: numbers(1, 2.13, 3.1e-10) assert node == parseKdl("numbers 1 2.13 3.1e-10")[0] - assert toKdlVal("abc") == parseKdl("node \"abc\"")[0][0] + assert toKdlVal("abc") == parseKdl("node \"abc\"")[0].args[0] + +## ## More +## Checkout these other useful modules as well: +## - [kdl/decoder](kdl/decoder.html) for KDL deserializing +## - [kdl/encoder](kdl/encoder.html) for KDL serializing +## - [kdl/xix](kdl/xik.html) for [XML-in-KDL](https://github.com/kdl-org/kdl/blob/main/XML-IN-KDL.md) +## - [kdl/jix](kdl/jix.html) for [JSON-in-KDL](https://github.com/kdl-org/kdl/blob/main/JSON-IN-KDL.md) +## - [kdl/prefs](kdl/prefs.html) for simple preferences sytem. import std/[algorithm, enumerate, strformat, strutils, sequtils, options, tables] -import kdl/[parser, lexer, nodes, utils, xik, jik] -export parser, nodes -export utils except quoted +import kdl/[decoder, encoder, parser, lexer, nodes, types, utils, prefs, xik, jik] + +export decoder, encoder, parser, nodes, types export scanKdl, scanKdlFile, lexer.`$` # lexer func indent(s: string, count: Natural, padding = " ", newLine = "\n"): string = @@ -168,7 +177,7 @@ proc pretty*(doc: KdlDoc, newLine = true): string = if newLine: result.add "\p" -proc writeFile*(doc: KdlDoc, path: string, pretty = false) = +proc writeFile*(path: string, doc: KdlDoc, pretty = false) = ## Writes `doc` to path. Set `pretty` to true to use `pretty` instead of `$`. if pretty: writeFile(path, doc.pretty()) diff --git a/src/kdl/decoder.nim b/src/kdl/decoder.nim new file mode 100644 index 0000000..d6c15a5 --- /dev/null +++ b/src/kdl/decoder.nim @@ -0,0 +1,685 @@ +## ## Decoder +## This module implements a deserializer for KDL documents, nodes and values into different types and objects: +## - `char` +## - `bool` +## - `Option[T]` +## - `SomeNumber` +## - `StringTableRef` +## - `enum` and `HoleyEnum` +## - `string` and `cstring` +## - `KdlVal` (object variant) +## - `seq[T]` and `array[I, T]` +## - `HashSet[A]` and `OrderedSet[A]` +## - `Table[string, T]` and `OrderedTable[string, T]` +## - `object`, `ref` and `tuple` (including object variants) +## - Plus any type you implement. +runnableExamples: + import kdl + + type + Package = object + name*, version*: string + authors*: Option[seq[string]] + description*, licenseFile*, edition*: Option[string] + + Deps = Table[string, string] + + const doc = parseKdl(""" +package { + name "kdl" + version "0.0.0" + description "kat's document language" + authors "Kat Marchán " + license-file "LICENSE.md" + edition "2018" +} +dependencies { + nom "6.0.1" + thiserror "1.0.22" +}""") + + const package = doc.decode(Package, "package") + const dependencies = doc.decode(Deps, "dependencies") + + assert package == Package( + name: "kdl", + version: "0.0.0", + authors: @["Kat Marchán "].some, + description: "kat's document language".some, + licenseFile: "LICENSE.md".some, + edition: "2018".some + ) + assert dependencies == {"nom": "6.0.1", "thiserror": "1.0.22"}.toTable + +## ### Custom Hooks +## #### Decode hook +## Use custom decode hooks to decode your types, your way. +## +## To do it you have to overload the `decodeHook` procedure with the following signature: +## ```nim +## proc decodeHook*(a: KdlSome, v: var MyType) +## ``` +## Where `KdlSome` is one of `KdlDoc`, `KdlNode` or `KdlVal`: +## - `KdlDoc` is called when `doc.decode()`. +## - `KdlNode` is called when `doc.decode("node-name")`, or when parsing a field like `MyObj(a: MyType)` in `myobj-node {a "some representation of MyType"}`. +## - `KdlVal` is called when decoding arguments (`seq[MyType]`) or properties like `MyObj(a: MyType)` in `myobj-node a="another representation of MyType"`. +runnableExamples: + import std/times + import kdl + import kdl/utils # kdl/utils define some useful internal procedures such as `eqIdent`, which checks the equality of two strings ignore case, underscores and dashes in an efficient way. + + proc decodeHook*(a: KdlVal, v: var DateTime) = + assert a.isString + v = a.getString.parse("yyyy-MM-dd") + + proc decodeHook*(a: KdlNode, v: var DateTime) = + case a.args.len + of 6: # year month day hour minute second + v = dateTime( + a.args[0].decode(int), + a.args[1].decode(Month), + a.args[2].decode(MonthdayRange), + a.args[3].decode(HourRange), + a.args[4].decode(MinuteRange), + a.args[5].decode(SecondRange) + ) + of 3: # year month day + v = dateTime( + a.args[0].decode(int), + a.args[1].decode(Month), + a.args[2].decode(MonthdayRange), + ) + of 1: # yyyy-MM-dd + a.args[0].decode(v) + else: + doAssert a.args.len in {1, 3, 6} + + if "hour" in a.props: + v.hour = a.props["hour"].getInt + if "minute" in a.props: + v.minute = a.props["minute"].getInt + if "second" in a.props: + v.second = a.props["second"].getInt + if "nanosecond" in a.props: + v.nanosecond = a.props["nanosecond"].getInt + if "offset" in a.props: + v.utcOffset = a.props["offset"].get(int) + + proc decodeHook*(a: KdlDoc, v: var DateTime) = + if a.len == 0: return + + var + year: int + month: Month + day: MonthdayRange = 1 + hour: HourRange + minute: MinuteRange + second: SecondRange + nanosecond: NanosecondRange + + for node in a: + if node.name.eqIdent "year": + node.decode(year) + elif node.name.eqIdent "month": + node.decode(month) + elif node.name.eqIdent "day": + node.decode(day) + elif node.name.eqIdent "hour": + node.decode(hour) + elif node.name.eqIdent "minute": + node.decode(minute) + elif node.name.eqIdent "second": + node.decode(second) + elif node.name.eqIdent "nanosecond": + node.decode(nanosecond) + + v = dateTime(year, month, day, hour, minute, second, nanosecond) + + assert parseKdl(""" + year 2022 + month 10 // or "October" + day 15 + hour 12 + minute 10 + """).decode(DateTime) == dateTime(2022, mOct, 15, 12, 10) + + assert parseKdl("date 2022 \"October\" 15 12 04 00").decode(DateTime, "date") == dateTime(2022, mOct, 15, 12, 04) + + assert parseKdl("author birthday=\"2000-10-15\" name=\"Nobody\"")[0]["birthday"].decode(DateTime) == dateTime(2000, mOct, 15) + +## #### New hook +## With new hooks you can initialize types with default values before decoding. +## Use the following signature when overloading `newHook`: +## ```nim +## proc newHook*(v: var MyType) +## ``` +## *Note: by default for object variants modifying a discriminator field will end in a compilation error, if you are sure about it, disable this behavior by compiling with the following flag -d:kdlDecoderNoCaseTransitionError.* +runnableExamples: + import kdl + + type Foo = object + x*: int + + proc newHook*(v: var Foo) = + v.x = 5 # You may also do `v = Foo(x: 5)` + + assert parseKdl("").decode(Foo) == Foo(x: 5) + +## #### Post hook +## Post hooks are called after decoding any (default, for custom decode hooks you have to call `postHookable(v)` explicitly) type. +## +## Overloads of `postHook` must use the following signature: +## ```nim +## proc postHook*(v: var MyType) +## ``` +runnableExamples: + import kdl + + type Foo = object + x*: int + + proc postHook*(v: var Foo) = + inc v.x + + assert parseKdl("x 1").decode(Foo) == Foo(x: 2) # 2 because x after postHook got incremented by one + +## #### Enum hook +## Enum hooks are useful for parsing enums in a custom manner. +## +## You can overload `enumHook` with two different signatures: +## ```nim +## proc enumHook*(a: string, v: var MyEnum) +## ``` +## ```nim +## proc enumHook*(a: int, v: var MyEnum) +## ``` +## *Note: by default decoding an integer into a holey enum raises an error, to override this behaviour compile with -d:kdlDecoderAllowHoleyEnums.* +runnableExamples: + import std/[strformat, strutils] + import kdl + + type MyEnum = enum + meNorth, meSouth, meWest, meEast + + proc enumHook*(a: string, v: var MyEnum) = + case a.toLowerAscii + of "north": + v = meNorth + of "south": + v = meSouth + of "west": + v = meWest + of "east": + v = meEast + else: + raise newException(ValueError, &"invalid enum value {a} for {$typeof(v)}") + + proc enumHook*(a: int, v: var MyEnum) = + case a + of 0xbeef: + v = meNorth + of 0xcafe: + v = meSouth + of 0xface: + v = meWest + of 0xdead: + v = meEast + else: + raise newException(ValueError, &"invalid enum value {a} for {$typeof(v)}") + + assert parseKdl(""" + node "north" "south" "west" "east" + """).decode(seq[MyEnum], "node") == @[meNorth, meSouth, meWest, meEast] + + assert parseKdl(""" + node 0xbeef 0xcafe 0xface 0xdead + """).decode(seq[MyEnum], "node") == @[meNorth, meSouth, meWest, meEast] + +## #### Rename hook +## As its name suggests, a rename hook renames the fields of an object in any way you want. +## +## Follow this signature when overloading `renameHook`: +## ```nim +## proc renameHook*(_: typedesc[MyType], fieldName: var string) +## ``` +runnableExamples: + import kdl + + type Foo = object + kind*: string + list*: seq[int] + + proc renameHook*(_: typedesc[Foo], fieldName: var string) = + fieldName = + case fieldName + of "type": + "kind" + of "array": + "list" + else: + fieldName + + # Here we rename "type" to "kind" and "array" to "list". + assert parseKdl(""" + type "string" + array 1 2 3 + """).decode(Foo) == Foo(kind: "string", list: @[1, 2, 3]) + +## +## ---------- +## +## As you may have noticed if you looked through the API, there is `newHook` and `newHookable`, `enumHook` and `enumHookable`. +## Any hook suffixed -able, actually calls the hook itself after making sure there is an overload that matches it. +## You should not overload these as they are meant for internal use, the reason they are exported is because when implementing your custom decode hooks you may also want to use them. +## +## So remember: for custom behaviour, overload -hook suffixed procedures; to make use of these hooks call the -hookable suffixed procedures, you don't call these unless you want their behavior within your custom decode hooks. +## +## ---------- +## +## All of these examples were taken out from the [tests](https://github.com/Patitotective/kdl-nim/blob/main/tests/test_serializer.nim), so if you need more, check them out. + +import std/[typetraits, strformat, strutils, strtabs, tables, sets] +import nodes, utils, types + +proc rfind(a: KdlDoc, s: string): Option[KdlNode] = + for i in countdown(a.high, 0): + if a[i].name.eqIdent s: + return a[i].some + +proc find(a: KdlNode, s: string): Option[KdlVal] = + for key, val in a.props: + if key.eqIdent s: + return val.some + +proc rfindRename(a: KdlDoc, s: string, T: typedesc): Option[KdlNode] = + for i in countdown(a.high, 0): + if a[i].name.renameHookable(T).eqIdent s: + return a[i].some + +proc findRename(a: KdlNode, s: string, T: typedesc): Option[KdlVal] = + for key, val in a.props: + if key.renameHookable(T).eqIdent s: + return val.some + +# ----- Index ----- + +proc decode*(a: KdlSome, v: var auto) +proc decode*[T](a: KdlSome, _: typedesc[T]): T +proc decodeHook*[T: KdlSome](a: T, v: var T) +proc decodeHook*(a: KdlSome, v: var proc) + +proc decode*(a: KdlDoc, v: var auto, name: string) +proc decode*[T](a: KdlDoc, _: typedesc[T], name: string): T +proc decodeHook*(a: KdlDoc, v: var Object) +proc decodeHook*(a: KdlDoc, v: var List) +proc decodeHook*(a: KdlDoc, v: var ref) + +proc decodeHook*(a: KdlNode, v: var Object) +proc decodeHook*(a: KdlNode, v: var List) +proc decodeHook*(a: KdlNode, v: var auto) +proc decodeHook*(a: KdlNode, v: var ref) + +proc decodeHook*[T: Value](a: KdlVal, v: var T) +proc decodeHook*[T: enum](a: KdlVal, v: var T) +proc decodeHook*(a: KdlVal, v: var char) +proc decodeHook*(a: KdlVal, v: var cstring) +proc decodeHook*[T: array](a: KdlVal, v: var T) +proc decodeHook*(a: KdlVal, v: var seq) +proc decodeHook*(a: KdlVal, v: var Object) +proc decodeHook*(a: KdlVal, v: var ref) + +# ----- Hooks ----- + +proc newHook*[T](v: var T) = + when v is range: + if v notin T.low..T.high: + v = T.low + +proc postHook*(v: var auto) = + discard + +proc enumHook*[T: enum](a: int, v: var T) = + when T is HoleyEnum and not defined(kdlDecoderAllowHoleyEnums): + fail &"forbidden int-to-HoleyEnum conversion ({a} -> {$T}); compile with -d:kdlDecoderAllowHoleyEnums" + else: + v = T(a) + +proc enumHook*[T: enum](a: string, v: var T) = + v = parseEnum[T](a) + +proc renameHook*(_: typedesc, fieldName: var string) = + discard + +proc newHookable*(v: var auto) = + when not defined(kdlDecoderNoCaseTransitionError): + {.push warningAsError[CaseTransition]: on.} + mixin newHook + newHook(v) + +proc postHookable*(v: var auto) = + mixin postHook + postHook(v) + +proc enumHookable*[T: enum](a: string or int, v: var T) = + mixin enumHook + enumHook(a, v) + +proc enumHookable*[T: enum](_: typedesc[T], a: string or int): T = + mixin enumHook + enumHook(a, result) + +proc renameHookable*(fieldName: string, a: typedesc): string = + mixin renameHook + result = fieldName + renameHook(a, result) + +# ----- KdlSome ----- + +proc decode*(a: KdlSome, v: var auto) = + mixin decodeHook + + # Don't initialize object variants yet + when not isObjVariant(typeof v): + newHookable(v) + + decodeHook(a, v) + +proc decode*[T](a: KdlSome, _: typedesc[T]): T = + decode(a, result) + +proc decodeHook*[T: KdlSome](a: T, v: var T) = + v = a + +proc decodeHook*(a: KdlSome, v: var proc) = + fail &"{$typeof(v)} not implemented for {$typeof(a)}" + +# ----- KdlDoc ----- + +proc decode*(a: KdlDoc, v: var auto, name: string) = + var found = -1 + for e in countdown(a.high, 0): + if a[e].name.eqIdent name: + found = e + break + + if found < 0: + fail "Could not find a any node for " & name.quoted + + decode(a[found], v) + +proc decode*[T](a: KdlDoc, _: typedesc[T], name: string): T = + decode(a, result, name) + +proc decodeHook*(a: KdlDoc, v: var Object) = + type T = typeof(v) + when T is tuple and not isNamedTuple(T): # Unnamed tuple + var count = 0 + for fieldName, field in v.fieldPairs: + if count > a.high: + fail &"Expected an argument at index {count+1} in {a}" + + decode(a[count], field) + inc count + else: + const discKeys = getDiscriminants(T) # Object variant discriminator keys + + when discKeys.len > 0: + template discriminatorSetter(key, typ): untyped = + let discFieldNode = a.rfindRename(key, T) + + if discFieldNode.isSome: + decode(discFieldNode.get, typ) + else: + var x: typeofdesc typ + newHookable(x) + x + + v = initCaseObject(T, discriminatorSetter) + newHookable(v) + + for fieldName, field in v.fieldPairs: + when fieldName notin discKeys: # Ignore discriminant field name + var found = false + + for node in a: + if node.name.renameHookable(T).eqIdent fieldName: + decode(node, field) + found = true + + if not found: + newHookable(field) + + postHookable(v) + +proc decodeHook*(a: KdlDoc, v: var List) = + when v is seq: + v.setLen a.len + + for e, node in a: + decode(node, v[e]) + + postHookable(v) + +proc decodeHook*(a: KdlDoc, v: var ref) = + if v.isNil: new v + decode(a, v[]) + +# ----- KdlNode ----- + +proc decodeHook*(a: KdlNode, v: var Object) = + type T = typeof(v) + when T is tuple and not isNamedTuple(T): # Unnamed tuple + var count = 0 + for fieldName, field in v.fieldPairs: + if count > a.args.high: + fail &"Expected an argument at index {count+1} in {a}" + + decode(a.args[count], field) + inc count + else: + const discKeys = getDiscriminants(T) # Object variant discriminator keys + when discKeys.len > 0: + template discriminatorSetter(key, typ): untyped = + # let key1 = key.renameHookable(T) + let discFieldNode = a.children.rfindRename(key, T) # Find a children + let discFieldProp = a.findRename(key, T) # Find a property + + if discFieldNode.isSome: + decode(discFieldNode.get, typ) + elif discFieldProp.isSome: + decode(discFieldProp.get, typ) + else: + var x: typeofdesc typ + newHookable(x) + x + + v = initCaseObject(T, discriminatorSetter) + newHookable(v) + + for fieldName, field in v.fieldPairs: + when fieldName notin discKeys: # Ignore discriminant field name + var found = false + for key, _ in a.props: + if key.renameHookable(T).eqIdent fieldName: + decode(a.props[key], field) + found = true + + for node in a.children: + if node.name.renameHookable(T).eqIdent fieldName: + decode(node, field) + found = true + + if not found: + newHookable(field) + + postHookable(v) + +proc decodeHook*(a: KdlNode, v: var List) = + when v is seq: + v.setLen a.args.len + a.children.len + + var count = 0 + + for arg in a.args: + if count >= v.len: break + decode(arg, v[count]) + + inc count + + for child in a.children: + if count >= v.len: break + decode(child, v[count]) + inc count + + postHookable(v) + +proc decodeHook*(a: KdlNode, v: var ref) = + if v.isNil: new v + decode(a, v[]) + +proc decodeHook*(a: KdlNode, v: var auto) = + check a.args.len == 1, &"expected exactly one argument in {a}" + decode(a.args[0], v) + +# ----- KdlVal ----- + +proc decodeHook*[T: Value](a: KdlVal, v: var T) = + v = a.get(T) + postHookable(v) + +proc decodeHook*[T: enum](a: KdlVal, v: var T) = + case a.kind + of KString: + enumHookable(a.getString, v) + of KInt: + enumHookable(a.get(int), v) + + else: + fail &"expected string or int in {a}" + + postHookable(v) + +proc decodeHook*(a: KdlVal, v: var char) = + check a.isString and a.getString.len == 1, &"expected one-character-long string in a" + v = a.getString[0] + postHookable(v) + +proc decodeHook*(a: KdlVal, v: var cstring) = + case a.kind + of KNull: + v = nil + of KString: + v = cstring a.getString + else: + fail &"expected string or null in {a}" + postHookable(v) + +proc decodeHook*[T: array](a: KdlVal, v: var T) = + when v.len == 1: + decode(a, v[0]) + +proc decodeHook*(a: KdlVal, v: var seq) = + v.setLen 1 + decode(a, v[0]) + +proc decodeHook*(a: KdlVal, v: var Object) = + fail &"{$typeof(v)} not implemented for {$typeof(a)}" + +proc decodeHook*(a: KdlVal, v: var ref) = + if v.isNil: new v + decode(a, v[]) + +# ----- Non-primitive stdlib hooks ----- + +# ----- Index ----- + +proc decodeHook*[T](a: KdlDoc, v: var SomeTable[string, T]) +proc decodeHook*[T](a: KdlDoc, v: var SomeSet[T]) + +proc decodeHook*[T](a: KdlNode, v: var SomeTable[string, T]) +proc decodeHook*[T](a: KdlNode, v: var SomeSet[T]) +proc decodeHook*(a: KdlNode, v: var StringTableRef) +proc decodeHook*[T](a: KdlNode, v: var Option[T]) + +proc decodeHook*[T](a: KdlVal, v: var SomeSet[T]) +proc decodeHook*[T](a: KdlVal, v: var Option[T]) +proc decodeHook*(a: KdlVal, v: var (SomeTable[string, auto] or StringTableRef)) + +# ----- KdlDoc ----- + +proc decodeHook*[T](a: KdlDoc, v: var SomeTable[string, T]) = + v.clear() + + for node in a: + v[node.name] = decode(node, T) + + postHookable(v) + +proc decodeHook*[T](a: KdlDoc, v: var SomeSet[T]) = + v.clear() + + for node in a: + v.incl decode(KdlDoc, T) + + postHookable(v) + +# ----- KdlNode ----- + +proc decodeHook*[T](a: KdlNode, v: var SomeTable[string, T]) = + v.clear() + + for key, val in a.props: + v[key] = decode(val, T) + + for node in a.children: + v[node.name] = decode(node, T) + + postHookable(v) + +proc decodeHook*[T](a: KdlNode, v: var SomeSet[T]) = + v.clear() + + for arg in a.args: + v.incl decode(arg, T) + + postHookable(v) + +proc decodeHook*(a: KdlNode, v: var StringTableRef) = + v = newStringTable() + + for key, val in a.props: + v[key] = decode(val, string) + + for node in a.children: + v[node.name] = decode(node, string) + + postHookable(v) + +proc decodeHook*[T](a: KdlNode, v: var Option[T]) = + v = + try: + decode(a, T).some + except KdlError: + none[T]() + + postHookable(v) + +# ----- KdlVal ----- + +proc decodeHook*[T](a: KdlVal, v: var SomeSet[T]) = + v.clear() + + v.incl decode(a, T) + + postHookable(v) + +proc decodeHook*[T](a: KdlVal, v: var Option[T]) = + if a.isNull: + v = none[T]() + else: + v = decode(a, T).some + + postHookable(v) + +proc decodeHook*(a: KdlVal, v: var (SomeTable[string, auto] or StringTableRef)) = + fail &"{$typeof(v)} not implemented for {$typeof(a)}" diff --git a/src/kdl/encoder.nim b/src/kdl/encoder.nim new file mode 100644 index 0000000..e7b545c --- /dev/null +++ b/src/kdl/encoder.nim @@ -0,0 +1,248 @@ +## ## Encoder +## This module implements a serializer for different types and objects into KDL documents, nodes and values: +## - `char` +## - `bool` +## - `Option[T]` +## - `SomeNumber` +## - `StringTableRef` +## - `enum` and `HoleyEnum` +## - `string` and `cstring` +## - `KdlVal` (object variant) +## - `seq[T]` and `array[I, T]` +## - `HashSet[A]` and `OrderedSet[A]` +## - `Table[string, T]` and `OrderedTable[string, T]` +## - `object`, `ref` and `tuple` (including object variants) +## - Plus any type you implement. +runnableExamples: + import std/options + import kdl + + type + Package = object + name*, version*: string + authors*: Option[seq[string]] + description*, licenseFile*, edition*: Option[string] + + const doc = parseKdl(""" +"name" "kdl" +"version" "0.0.0" +"authors" { + "-" "Kat Marchán " +} +"description" "kat's document language" +"licenseFile" "LICENSE.md" +"edition" "2018" + """) + + assert Package(name: "kdl", version: "0.0.0", authors: @["Kat Marchán "].some, description: "kat's document language".some, licenseFile: "LICENSE.md".some, edition: "2018".some).encode() == doc + +## ### Custom Encode Hooks +## If you need to encode a specific type in a specific way you may use custom encode hooks. +## +## To do so, you'll have to overload the `encodeHook` procedure with the following signatura: +## ```nim +## proc encodeHook*(a: MyType, v: var KdlSome) +## ``` +## Where `KdlSome` is one of `KdlDoc`, `KdlNode` or `KdlVal`. +runnableExamples: + import std/times + import kdl + + proc encodeHook*(a: DateTime, v: var KdlDoc) = + v = @[ + initKNode("year", args = @[encode(a.year, KdlVal)]), + initKNode("month", args = @[encode(a.month, KdlVal)]), + initKNode("day", args = @[encode(a.monthday, KdlVal)]), + initKNode("hour", args = @[encode(a.hour, KdlVal)]), + initKNode("minute", args = @[encode(a.minute, KdlVal)]), + initKNode("second", args = @[encode(a.second, KdlVal)]), + initKNode("nanosecond", args = @[encode(a.nanosecond, KdlVal)]), + ] + + const doc = parseKdl(""" +"year" 2022 +"month" "October" +"day" 15 +"hour" 12 +"minute" 4 +"second" 0 +"nanosecond" 0 + """) + + assert dateTime(2022, mOct, 15, 12, 04).encode() == doc + +import std/[typetraits, strformat, enumerate, options, strtabs, tables, sets] +import nodes, utils, types + +# ----- Index ----- + +proc encode*(a: auto, v: var KdlDoc) +proc encode*(a: auto, v: var KdlVal) +proc encode*(a: auto, v: var KdlNode, name: string) +proc encode*(a: auto): KdlDoc +proc encode*(a: auto, name: string): KdlNode +proc encode*[T: KdlSome](a: auto, _: typedesc[T]): T + +proc encodeHook*(a: List, v: var KdlDoc) +proc encodeHook*(a: Object, v: var KdlDoc) +proc encodeHook*(a: ref, v: var KdlDoc) + +proc encodeHook*(a: KdlVal, v: var KdlNode, name: string) +proc encodeHook*(a: List, v: var KdlNode, name: string) +proc encodeHook*(a: Object, v: var KdlNode, name: string) +proc encodeHook*(a: ref, v: var KdlNode, name: string) +proc encodeHook*(a: auto, v: var KdlNode, name: string) + +proc encodeHook*(a: Value, v: var KdlVal) +proc encodeHook*(a: cstring, v: var KdlVal) +proc encodeHook*(a: char, v: var KdlVal) +proc encodeHook*(a: KdlVal, v: var KdlVal) +proc encodeHook*(a: enum, v: var KdlVal) +proc encodeHook*(a: List, v: var KdlVal) + +# ----- KdlSome ----- + +proc encode*(a: auto, v: var KdlDoc) = + mixin encodeHook + encodeHook(a, v) + +proc encode*(a: auto, v: var KdlVal) = + mixin encodeHook + encodeHook(a, v) + +proc encode*(a: auto, v: var KdlNode, name: string) = + mixin encodeHook + encodeHook(a, v, name) + +proc encode*(a: auto): KdlDoc = + encode(a, result) + +proc encode*(a: auto, name: string): KdlNode = + encode(a, result, name) + +proc encode*[T: KdlSome](a: auto, _: typedesc[T]): T = + when T is KdlDoc: + encode(a, result) + elif T is KdlNode: + encode(a, result, "node") + elif T is KdlVal: + encode(a, result) + +# ----- KdlDoc ----- + +proc encodeHook*(a: List, v: var KdlDoc) = + v.setLen(a.len) + for e, i in a: + encode(i, v[e], "-") + +proc encodeHook*(a: Object, v: var KdlDoc) = + type T = typeof(a) + + when T is tuple and not isNamedTuple(T): # Unnamed tuple + for _, field in a.fieldPairs: + v.add encode(field, "-") + else: + for fieldName, field in a.fieldPairs: + v.add encode(field, fieldName) + +proc encodeHook*(a: ref, v: var KdlDoc) = + encode(a[], v) + +# ----- KdlNode ----- + +proc encodeHook*(a: KdlVal, v: var KdlNode, name: string) = + v = initKNode(name, args = @[a]) + +proc encodeHook*(a: List, v: var KdlNode, name: string) = + v = initKNode(name) + v.children.setLen(a.len) + for e, i in a: + encode(i, v.children[e], "-") + +proc encodeHook*(a: Object, v: var KdlNode, name: string) = + v = initKNode(name) + type T = typeof(a) + + when T is tuple and not isNamedTuple(T): # Unnamed tuple + for _, field in a.fieldPairs: + v.args.add encode(field, KdlVal) + else: + encode(a, v.children) + +proc encodeHook*(a: ref, v: var KdlNode, name: string) = + encode(a[], v, name) + +proc encodeHook*(a: auto, v: var KdlNode, name: string) = + v = initKNode(name) + v.args.setLen(1) + encode(a, v.args[0]) + +# ----- KdlVal ----- + +proc encodeHook*(a: Value, v: var KdlVal) = + v = a.initKVal + +proc encodeHook*(a: cstring, v: var KdlVal) = + if a.isNil: + v = initKNull() + else: + v = initKString($a) + +proc encodeHook*(a: char, v: var KdlVal) = + v = initKString($a) + +proc encodeHook*(a: KdlVal, v: var KdlVal) = + v = a + +proc encodeHook*(a: enum, v: var KdlVal) = + v = initKString($a) + +proc encodeHook*(a: List, v: var KdlVal) = + check a.len == 1, &"cannot encode {$typeof(a)} to {$typeof(v)}" + encode(a[0], v) + +# ----- Non-primitive stdlib hooks ----- + +# ----- Index ----- + +proc encodeHook*(a: SomeTable[string, auto] or StringTableRef, v: var KdlDoc) +proc encodeHook*(a: SomeSet[auto], v: var KdlDoc) + +proc encodeHook*(a: SomeTable[string, auto] or SomeSet[auto] or StringTableRef, v: var KdlNode, name: string) +proc encodeHook*(a: Option[auto], v: var KdlNode, name: string) + +proc encodeHook*(a: Option[auto], v: var KdlVal) + +# ----- KdlDoc ----- + +proc encodeHook*(a: SomeTable[string, auto] or StringTableRef, v: var KdlDoc) = + v.setLen(a.len) + + for e, (key, val) in enumerate(a.pairs): + encode(val, v[e], key) + +proc encodeHook*(a: SomeSet[auto], v: var KdlDoc) = + v.setLen(a.len) + + for e, i in enumerate(a): + encode(i, v[e], "-") + +# ----- KdlNode ----- + +proc encodeHook*(a: SomeTable[string, auto] or SomeSet[auto] or StringTableRef, v: var KdlNode, name: string) = + v = initKNode(name) + encode(a, v.children) + +proc encodeHook*(a: Option[auto], v: var KdlNode, name: string) = + if a.isNone: + v = initKNode(name) + else: + encode(a.get, v, name) + +# ----- KdlVal ----- + +proc encodeHook*(a: Option[auto], v: var KdlVal) = + if a.isNone: + v = initKNull() + else: + encode(a.get, v) diff --git a/src/kdl/jik.nim b/src/kdl/jik.nim index 5c0d1f1..8667571 100644 --- a/src/kdl/jik.nim +++ b/src/kdl/jik.nim @@ -65,8 +65,10 @@ runnableExamples: assert data.parseJson() == data.parseJson().toKdl().toJson() +{.used.} + import std/json -import nodes +import nodes, types proc toKVal(node: JsonNode): KdlVal = case node.kind @@ -140,7 +142,7 @@ proc jsonKind(node: KdlNode): JsonNodeKind = assert node.props.len == 0, "arrays cannot have properties in " & $node result = JArray elif tag == "object" or node.props.len > 0: - assert node.len == 0, "objects cannot have arguments in " & $node + assert node.args.len == 0, "objects cannot have arguments in " & $node result = JObject for child in node.children: @@ -152,14 +154,14 @@ proc jsonKind(node: KdlNode): JsonNodeKind = result = JObject if result == JObject and child.jsonKind notin {JObject, JArray}: - assert child.len in 0..1, "fields cannot have more than one argument in " & $child + assert child.args.len in 0..1, "fields cannot have more than one argument in " & $child proc toJson*(node: KdlNode): JsonNode proc toJObject(node: KdlNode): JsonNode = result = newJObject() - for key, val in node: + for key, val in node.props: result[key] = val.toJson for child in node.children: @@ -168,7 +170,7 @@ proc toJObject(node: KdlNode): JsonNode = proc toJArray(node: KdlNode): JsonNode = result = newJArray() - for arg in node: + for arg in node.args: result.add arg.toJson for child in node.children: @@ -183,5 +185,5 @@ proc toJson*(node: KdlNode): JsonNode = of JObject: node.toJObject else: - assert node.len == 1, "unkown value in " & $node - node[0].toJson + assert node.args.len == 1, "unkown value in " & $node + node.args[0].toJson diff --git a/src/kdl/lexer.nim b/src/kdl/lexer.nim index 3f44c6a..8ca5387 100644 --- a/src/kdl/lexer.nim +++ b/src/kdl/lexer.nim @@ -1,5 +1,5 @@ -import std/[strformat, strutils, unicode, tables, macros] -import utils +import std/[strformat, strutils, unicode, streams, tables, macros] +import utils, types type TokenKind* = enum @@ -32,9 +32,14 @@ type kind*: TokenKind Lexer* = object - source*: string + case isStream*: bool + of true: + stream*: Stream + else: + source*: string + current*: int + multilineStringsNewLines*: seq[tuple[idx, length: int]] # Indexes and length of new lines in multiline strings that have to be converted to a single \n stack*: seq[Token] - current*: int const nonIdenChars = {'\\', '/', '(', ')', '{', '}', '<', '>', ';', '[', ']', '=', ',', '"'} @@ -74,11 +79,34 @@ const } proc `$`*(lexer: Lexer): string = - result = &"{(if lexer.current == lexer.source.len: \"SUCCESS\" else: \"FAIL\")} {lexer.current}/{lexer.source.len}\n\t" + result = + if lexer.isStream: + &"{(if lexer.stream.atEnd: \"SUCCESS\" else: \"FAIL\")}\n\t" + else: + &"{(if lexer.current == lexer.source.len: \"SUCCESS\" else: \"FAIL\")} {lexer.current}/{lexer.source.len}\n\t" + for token in lexer.stack: result.addQuoted(token.lexeme) result.add(&"({token.kind}) ") +proc getPos*(lexer: Lexer): int = + if lexer.isStream: + lexer.stream.getPosition() + else: + lexer.current + +proc setPos(lexer: var Lexer, x: int) = + if lexer.isStream: + lexer.stream.setPosition(x) + else: + lexer.current = x + +proc inc(lexer: var Lexer, x = 1) = + lexer.setPos(lexer.getPos() + x) + +proc dec(lexer: var Lexer, x = 1) = + lexer.setPos(lexer.getPos() - x) + macro lexing(token: TokenKind, body: untyped) = ## Converts a procedure definition like: ## ```nim @@ -88,11 +116,11 @@ macro lexing(token: TokenKind, body: untyped) = ## Into ## ```nim ## proc foo(lexer: var Lexer, consume: bool = true, addToStack: bool = true): bool {.discardable.} = - ## let before = lexer.current + ## let before = getPos(lexer) ## echo "hi" - ## result = before != lexer.current + ## result = before != getPos(lexer) ## if not consume: - ## lexer.current = before + ## setPos(lexer, before) ## if result and addToStack: # Only when token is not tkEmpty ## lexer.add(token, before) ## ``` @@ -107,69 +135,117 @@ macro lexing(token: TokenKind, body: untyped) = body.addPragma(ident"discardable") # Modify the procedure statements list (body) - let before = genSym(nskLet, "before") body[^1].insert(0, quote do: - let `before` = lexer.current + let before {.inject.} = getPos(lexer) ) body[^1].add(quote do: - result = `before` != lexer.current + result = before != getPos(lexer) ) body[^1].add(quote do: if not consume: - lexer.current = `before` + setPos(lexer, before) ) if token != bindSym"tkEmpty": body[^1].add(quote do: if result and addToStack: - lexer.add(`token`, `before`) + lexer.add(`token`, before) ) result = body -proc add(lexer: var Lexer, kind: TokenKind, start: int, until = lexer.current) = - lexer.stack.add(Token(kind: kind, lexeme: lexer.source[start..= lexer.source.len -proc eof(lexer: Lexer, extra = 0): bool = - lexer.current + extra >= lexer.source.len + lexer.setPos before proc peek(lexer: var Lexer, next = 0): char = if not lexer.eof(next): - result = lexer.source[lexer.current + next] + let before = lexer.getPos + inc lexer, next + + result = + if lexer.isStream: + lexer.stream.peekChar() + else: + lexer.source[lexer.current] + + lexer.setPos before + +proc peekStr(lexer: var Lexer, until: int): string = + if lexer.eof(until-1): return + + if lexer.isStream: + lexer.stream.peekStr(until) + else: + lexer.source[lexer.current.. hashes: lexer.error &"Expected {hashes} hashes but found {endHashes}" - elif lexer.literal("\r\n", consume = false): # Replace CRLF with LF - lexer.source[lexer.current..lexer.current + 1] = "\n" - lexer.consume() else: - lexer.consume() + inc lexer if not terminated: lexer.error "Unterminated string" @@ -305,29 +382,29 @@ proc tokenRawString*() {.lexing: tkRawString.} = lexer.tokenStringBody(raw = true) proc tokenMultiLineComment*() {.lexing: tkEmpty.} = - if lexer.until(2) != "/*": + if not lexer.peek("/*"): return - lexer.consume 2 + lexer.inc 2 var nested = 1 while not lexer.eof() and nested > 0: - if lexer.until(2) == "*/": + if lexer.peek("*/"): dec nested - lexer.consume 2 - elif lexer.until(2) == "/*": + lexer.inc 2 + elif lexer.peek("/*"): inc nested - lexer.consume 2 + lexer.inc 2 else: - lexer.consume() + inc lexer if nested > 0: lexer.error "Expected end of multi-line comment" proc tokenWhitespace*() {.lexing: tkWhitespace.} = - if not lexer.eof() and (let rune = lexer.source.runeAt(lexer.current); rune.int in whitespaces): - lexer.consume rune.size + if not lexer.eof() and (let rune = lexer.peekRune(); rune.int in whitespaces): + lexer.inc rune.size else: lexer.tokenMultiLineComment() @@ -335,55 +412,48 @@ proc skipWhitespaces*() {.lexing: tkEmpty.} = while lexer.tokenWhitespace(): discard -proc tokenNewLine*() {.lexing: tkNewLine.} = - for nl in newLines: - # if lexer.current > 0: - # echo nl.escape(), " == ", lexer.until(nl.len).escape(), " ", nl == lexer.until(nl.len), " ", escape $lexer.peek() - if lexer.until(nl.len) == nl: - lexer.consume nl.len - break - proc tokenIdent*() {.lexing: tkIdent.} = if lexer.eof() or lexer.peek() in nonInitialChars: return - let before = lexer.current # Check the identifier is similar to a boolean, null or number, and if it is it should follow the EOF, a whitespace, a new line or any non-ident char in order to be discarded. if ( lexer.literal("true") or lexer.literal("false") or lexer.literal("null") or lexer.tokenNumHex(addToStack = false) or lexer.tokenNumBin(addToStack = false) or lexer.tokenNumOct(addToStack = false) or lexer.tokenNumFloat(addToStack = false) or lexer.tokenNumInt(addToStack = false) ): if (lexer.eof() or lexer.tokenWhitespace(addToStack = false) or lexer.tokenNewLine(addToStack = false) or lexer.peek() in nonIdenChars): - lexer.current = before + lexer.setPos before return block outer: - for rune in lexer.source[lexer.current..^1].runes: # FIXME: slicing copies string, unnecessary, better copy unicode and replace string with openArray[char] - if rune.int <= 0x20 or rune.int > 0x10FFFF or lexer.eof() or lexer.tokenWhitespace(consume = false) or lexer.tokenNewLine(consume = false): - break outer + while not lexer.eof() or not lexer.tokenWhitespace(consume = false) or not lexer.tokenNewLine(consume = false): + let rune = lexer.peekRune() + if rune.int <= 0x20 or rune.int > 0x10FFFF: + break for c in nonIdenChars: if rune == Rune(c): break outer - lexer.consume rune.size + lexer.inc rune.size proc tokenSingleLineComment*() {.lexing: tkEmpty.} = - if lexer.until(2) != "//": + if not lexer.peek("//"): return - lexer.consume 2 + lexer.inc 2 while not lexer.eof(): # Consume until a new line or EOF if lexer.tokenNewLine(addToStack = addToStack): break - lexer.consume() + + inc lexer proc tokenLineCont*() {.lexing: tkLineCont.} = if lexer.peek() != '\\': return - lexer.consume() + inc lexer lexer.skipwhitespaces() if not lexer.tokenSingleLineComment(addToStack = false) and not lexer.tokenNewLine(addToStack = false): @@ -391,15 +461,13 @@ proc tokenLineCont*() {.lexing: tkLineCont.} = proc tokenLitMatches() {.lexing: tkEmpty.} = ## Tries to match any of the litMatches literals. - let before = lexer.current - for (lit, kind) in litMatches: if lexer.literal(lit): lexer.add(kind, before) break -proc validToken*(input: string, token: proc(lexer: var Lexer, consume = true, addToStack = true): bool): bool = - var lexer = Lexer(source: input, current: 0) +proc validToken*(source: sink string, token: proc(lexer: var Lexer, consume = true, addToStack = true): bool): bool = + var lexer = Lexer(isStream: true, stream: newStringStream(source)) try: result = lexer.token() and lexer.eof() @@ -435,8 +503,19 @@ proc scanKdl*(lexer: var Lexer) = lexer.error "Could not match any pattern" proc scanKdl*(source: string, start = 0): Lexer = - result = Lexer(source: source, current: start) + result = Lexer(isStream: false, source: source, current: start) result.scanKdl() proc scanKdlFile*(path: string): Lexer = scanKdl(readFile(path)) + +proc scanKdl*(stream: sink Stream): Lexer = + result = Lexer(isStream: true, stream: stream) + defer: result.stream.close() + result.scanKdl() + +proc scanKdlStream*(source: sink string): Lexer = + scanKdl(newStringStream(source)) + +proc scanKdlFileStream*(path: string): Lexer = + scanKdl(openFileStream(path)) diff --git a/src/kdl/nodes.nim b/src/kdl/nodes.nim index 470f709..5080263 100644 --- a/src/kdl/nodes.nim +++ b/src/kdl/nodes.nim @@ -1,60 +1,28 @@ import std/[strformat, strutils, options, tables, macros] -import utils +import types, utils export options, tables -type - KValKind* = enum - KEmpty, - KString, - KFloat, - KBool, - KNull - KInt, - - KdlVal* = object - tag*: Option[string] # Type annotation - - case kind*: KValKind - of KString: - str*: string - of KFloat: - fnum*: float - of KBool: - boolean*: bool - of KNull, KEmpty: - discard - of KInt: - num*: int64 - - KdlProp* = tuple[key: string, val: KdlVal] - - KdlNode* = object - tag*: Option[string] - name*: string - args*: seq[KdlVal] - props*: Table[string, KdlVal] - children*: seq[KdlNode] - - KdlDoc* = seq[KdlNode] - # ----- Initializers ----- -proc initKNode*(name: string, tag = string.none, args: openArray[KdlVal] = newSeq[KdlVal](), props = initTable[string, KdlVal](), children: openArray[KdlNode] = newSeq[KdlNode]()): KdlNode = +proc initKNode*(name: string, tag = string.none, args: openarray[KdlVal] = newSeq[KdlVal](), props = initTable[string, KdlVal](), children: openarray[KdlNode] = newSeq[KdlNode]()): KdlNode = KdlNode(tag: tag, name: name, args: @args, props: props, children: @children) proc initKVal*(val: string, tag = string.none): KdlVal = KdlVal(tag: tag, kind: KString, str: val) proc initKVal*(val: SomeFloat, tag = string.none): KdlVal = - KdlVal(tag: tag, kind: KFloat, fnum: val) + KdlVal(tag: tag, kind: KFloat, fnum: val.float) proc initKVal*(val: bool, tag = string.none): KdlVal = KdlVal(tag: tag, kind: KBool, boolean: val) proc initKVal*(val: SomeInteger, tag = string.none): KdlVal = - KdlVal(tag: tag, kind: KInt, num: val) + KdlVal(tag: tag, kind: KInt, num: val.int64) + +proc initKVal*(val: typeof(nil), tag = string.none): KdlVal = + KdlVal(tag: tag, kind: KNull) proc initKVal*(val: KdlVal): KdlVal = val @@ -62,16 +30,16 @@ proc initKString*(val = string.default, tag = string.none): KdlVal = initKVal(val, tag) proc initKFloat*(val: SomeFloat = float.default, tag = string.none): KdlVal = - initKVal(val, tag) + initKVal(val.float, tag) proc initKBool*(val = bool.default, tag = string.none): KdlVal = initKVal(val, tag) proc initKNull*(tag = string.none): KdlVal = - KdlVal(tag: tag, kind: KNUll) + KdlVal(tag: tag, kind: KNull) proc initKInt*(val: SomeInteger = int64.default, tag = string.none): KdlVal = - initKVal(val, tag) + initKVal(val.int64, tag) # ----- Comparisions ----- @@ -96,19 +64,19 @@ proc isEmpty*(val: KdlVal): bool = # ----- Getters ----- proc getString*(val: KdlVal): string = - assert val.isString() + check val.isString() val.str proc getFloat*(val: KdlVal): float = - assert val.isFloat() + check val.isFloat() val.fnum proc getBool*(val: KdlVal): bool = - assert val.isBool() + check val.isBool() val.boolean proc getInt*(val: KdlVal): int64 = - assert val.isInt() + check val.isInt() val.num proc get*[T: SomeNumber or string or bool](val: KdlVal, x: typedesc[T]): T = @@ -122,10 +90,22 @@ proc get*[T: SomeNumber or string or bool](val: KdlVal, x: typedesc[T]): T = assert val.get(float32) == 3.14f when T is string: - assert val.isString - result = val.getString + result = + case val.kind + of KFloat: + $val.getFloat() + of KString: + val.getString() + of KBool: + $val.getBool() + of KNull: + "null" + of KInt: + $val.getInt() + of KEmpty: + "empty" elif T is SomeNumber: - assert val.isFloat or val.isInt + check val.isFloat or val.isInt result = if val.isInt: @@ -133,26 +113,26 @@ proc get*[T: SomeNumber or string or bool](val: KdlVal, x: typedesc[T]): T = else: T(val.getFloat) elif T is bool: - assert val.isBool + check val.isBool result = val.getBool # ----- Setters ----- proc setString*(val: var KdlVal, x: string) = - assert val.isString() + check val.isString() val.str = x proc setFloat*(val: var KdlVal, x: SomeFloat) = - assert val.isFloat() + check val.isFloat() val.fnum = x proc setBool*(val: var KdlVal, x: bool) = - assert val.isBool() + check val.isBool() val.boolean = x proc setInt*(val: var KdlVal, x: SomeInteger) = - assert val.isInt() + check val.isInt() val.num = x proc setTo*[T: SomeNumber or string or bool](val: var KdlVal, x: T) = @@ -178,7 +158,7 @@ proc setTo*[T: SomeNumber or string or bool](val: var KdlVal, x: T) = elif T is bool: val.setBool(x) -# ----- Stringifier ----- +# ----- Operators ----- proc `$`*(val: KdlVal): string = if val.tag.isSome: @@ -237,13 +217,10 @@ proc `$`*(doc: KdlDoc): string = if e < doc.high: result.add "\n" - -# ----- Operators ----- - proc `==`*(val1, val2: KdlVal): bool = ## Checks if val1 and val2 have the same value. They must be of the same kind. - assert val1.kind == val2.kind + check val1.kind == val2.kind case val1.kind of KString: @@ -261,10 +238,10 @@ proc `==`*[T: SomeNumber or string or bool](val: KdlVal, x: T): bool = ## Checks if val is x, raises an error when they are not comparable. when T is string: - assert val.isString + check val.isString result = val.getString() == x elif T is SomeNumber: - assert val.isFloat or val.isInt + check val.isFloat or val.isInt result = if val.isInt: @@ -272,23 +249,15 @@ proc `==`*[T: SomeNumber or string or bool](val: KdlVal, x: T): bool = else: val.getFloat() == x.float elif T is bool: - assert val.isBool + check val.isBool result = val.getBool() == x -proc `[]`*(node: KdlNode, idx: int | BackwardsIndex): KdlVal = - ## Gets the argument at idx. - node.args[idx] - proc `[]`*(node: KdlNode, key: string): KdlVal = ## Gets the value of the key property. node.props[key] -proc `[]`*(node: var KdlNode, idx: int | BackwardsIndex): var KdlVal = - ## Gets the argument at idx. - node.args[idx] - -proc `[]`*(node: var KdlNode, key: string): var KdlVal = +proc `[]`*(node: var KdlNode, key: string): var KdlVal = # TODO test ## Gets the value of the key property. node.props[key] @@ -308,26 +277,19 @@ proc contains*(node: KdlNode, val: KdlVal): bool = ## Checks if node has the val argument. node.args.contains(val) -proc len*(node: KdlNode): int = - ## Node's arguments length. - node.args.len +proc contains*(node: KdlNode, child: KdlNode): bool = + ## Checks if node has the child children. + node.children.contains(child) proc add*(node: var KdlNode, val: KdlVal) = ## Adds val to node's arguments. node.args.add(val) -# ----- Iterators ----- - -iterator items*(node: KdlNode): KdlVal = - ## Yields arguments. - for arg in node.args: - yield arg +proc add*(node: var KdlNode, child: KdlNode) = + ## Adds child to node's children. -iterator pairs*(node: KdlNode): (string, KdlVal) = - ## Yields properties. - for key, val in node.props: - yield (key, val) + node.children.add(child) # ----- Macros ----- @@ -351,9 +313,6 @@ proc withTag(body: NimNode): tuple[body, tag: NimNode] = proc toKdlValImpl(body: NimNode): NimNode = let (value, tag) = body.withTag() - if value.kind == nnkNilLit: - return newCall("initKNull", tag) - newCall("initKVal", value, tag) proc toKdlNodeImpl(body: NimNode): NimNode = @@ -395,13 +354,13 @@ proc toKdlNodeImpl(body: NimNode): NimNode = body[i].expectKind(nnkStmtList) result.add newTree(nnkExprEqExpr, ident"children", newCall("toKdl", body[i])) -macro toKdlVal*(body: untyped): untyped = +macro toKdlVal*(body: untyped): KdlVal = ## Generate a KdlVal from Nim's AST that is somehat similar to KDL's syntax. ## - For type annotations use a bracket expresion: `node[tag]` instead of `(tag)node`. toKdlValImpl(body) -macro toKdlNode*(body: untyped): untyped = +macro toKdlNode*(body: untyped): KdlNode = ## Generate a KdlNode from Nim's AST that is somewhat similar to KDL's syntax. ## - For nodes use call syntax: `node(args, props)`. ## - For properties use an equal expression: `key=val`. @@ -419,7 +378,7 @@ macro toKdlNode*(body: untyped): untyped = toKdlNodeImpl(body) -macro toKdl*(body: untyped): untyped = +macro toKdl*(body: untyped): KdlDoc = ## Generate a KdlDoc from Nim's AST that is somewhat similar to KDL's syntax. ## ## See also [toKdlNode](#toKdlNode.m,untyped). @@ -433,3 +392,29 @@ macro toKdl*(body: untyped): untyped = result = prefix(doc, "@") else: result = toKdlValImpl(body) + +macro toKdlArgs*(args: varargs[typed]): untyped = + ## Creates an array of `KdlVal`s by calling `initKVal` through `args`. + runnableExamples: + assert toKdlArgs(1, 2, "a") == [1.initKVal, 2.initKVal, "a".initKVal] + assert initKNode("name", args = toKdlArgs(nil, true, "b")) == initKNode("name", args = [initKNull(), true.initKVal, "b".initKVal]) + + args.expectKind nnkBracket + result = newNimNode(nnkBracket) + for arg in args: + result.add newCall("initKVal", arg) + +macro toKdlProps*(props: untyped): Table[string, KdlVal] = + ## Creates a `Table[string, KdlVal]` from a array-of-tuples/table-constructor by calling `initKVal` through the values. + runnableExamples: + assert toKdlProps({"a": 1, "b": 2}) == {"a": 1.initKVal, "b": 2.initKVal}.toTable + assert initKNode("name", props = toKdlProps({"c": nil, "d": true})) == initKNode("name", props = {"c": initKNull(), "d": true.initKVal}.toTable) + + props.expectKind nnkTableConstr + + result = newNimNode(nnkTableConstr) + for i in props: + i.expectKind nnkExprColonExpr + result.add newTree(nnkExprColonExpr, i[0], newCall("initKVal", i[1])) + + result = newCall("toTable", result) diff --git a/src/kdl/parser.nim b/src/kdl/parser.nim index b093eb9..2f35993 100644 --- a/src/kdl/parser.nim +++ b/src/kdl/parser.nim @@ -1,19 +1,25 @@ -import std/[parseutils, strformat, strutils, unicode, options, tables, macros] -import lexer, nodes, utils +import std/[parseutils, strformat, strutils, unicode, options, streams, tables, macros] +import lexer, nodes, types, utils type None = object Parser* = object - source*: string + case isStream*: bool + of true: + stream*: Stream + else: + source*: string + + multilineStringsNewLines*: seq[tuple[idx, length: int]] # Indexes and length of new lines in multiline strings that have to be converted to a single \n stack*: seq[Token] current*: int Match[T] = tuple[ok, ignore: bool, val: T] const - numbers = {tkNumFloat, tkNumInt, tkNumHex, tkNumBin, tkNumOct} - intNumbers = numbers - {tkNumFloat} + integers = {tkNumInt, tkNumHex, tkNumBin, tkNumOct} + numbers = integers + {tkNumFloat} strings = {tkString, tkRawString} macro parsing(x: typedesc, body: untyped): untyped = @@ -25,7 +31,7 @@ macro parsing(x: typedesc, body: untyped): untyped = ## Into ## ```nim ## proc foo(parser: var Parser, required: bool = true): Match[T] {.discardable.} = - ## let before = parser.current + ## let before = getPos(parser) ## echo "hi" ## ``` @@ -55,8 +61,19 @@ proc peek(parser: Parser, next = 0): Token = result = Token(start: token.start + token.lexeme.len) proc error(parser: Parser, msg: string) = - let coord = parser.source.getCoord(parser.peek().start) - raise newException(KdlParserError, &"{msg} at {coord.line + 1}:{coord.col + 1}\n{parser.source.errorAt(coord).indent(2)}") + let coord = + if parser.isStream: + parser.stream.getCoord(parser.peek().start) + else: + parser.source.getCoord(parser.peek().start) + + let errorMsg = + if parser.isStream: + parser.stream.errorAt(coord) + else: + parser.source.errorAt(coord) + + raise newException(KdlParserError, &"{msg} at {coord.line + 1}:{coord.col + 1}\n{errorMsg.indent(2)}\n") proc consume(parser: var Parser, amount = 1) = parser.current += amount @@ -124,7 +141,7 @@ proc more(kind: TokenKind) {.parsing: None.} = proc parseNumber(token: Token): KdlVal = assert token.kind in numbers - if token.kind in intNumbers: + if token.kind in integers: result = initKInt() result.num = @@ -137,7 +154,8 @@ proc parseNumber(token: Token): KdlVal = token.lexeme.parseHexInt() of tkNumOct: token.lexeme.parseOctInt() - else: 0 + else: + 0 else: result = initKFloat() @@ -160,17 +178,32 @@ proc escapeString(str: string, x = 0..str.high): string = inc i -proc parseString(token: Token): KdlVal = +proc parseString(token: Token, multilineStringsNewLines: seq[(int, int)]): KdlVal = assert token.kind in strings result = initKString() + var varToken = token + varToken.lexeme = newStringOfCap(token.lexeme.len) + + var i = 0 + while i < token.lexeme.len: + let before = i + for (idx, length) in multilineStringsNewLines: + if i + token.start == idx: + varToken.lexeme.add '\n' + i += length + + if i == before: + varToken.lexeme.add token.lexeme[i] + inc i + if token.kind == tkString: - result.str = escapeString(token.lexeme, 1.. `prefs.content.field` + prefs.content.field + +template `[]=`*(prefs: KdlPrefs[auto], field, val): untyped = + ## `prefs[field] = val` -> `prefs.content.field = val` + prefs.content.field = val + +template `{}`*(prefs: KdlPrefs[auto], field): untyped = + ## `prefs{field}` -> `prefs.default.field` + prefs.default.field diff --git a/src/kdl/query.nim b/src/kdl/query.nim index 6bc7720..e693b3a 100644 --- a/src/kdl/query.nim +++ b/src/kdl/query.nim @@ -1,2 +1,45 @@ -# TODO: implement the KDL query language specification https://github.com/kdl-org/kdl/blob/main/QUERY-SPEC.md - \ No newline at end of file +import types + +type + Query* = seq[Selector] + + Selector* = seq[NodeFilter] + + Operator* = enum + opEqual # = + opNoEqual # != + opDescend # >> + opGreater # > + opLess # < + opGreaterEq # >= + opLessEq # <= + opStarts # ^ + opEnds # $ + opContains # * + + NodeFilter* = object + matchers*: seq[Matcher] + operator*: Operator + + Matcher* = object + accessor*: Accessor + operator*: Operator + value*: KdlVal # Comparision value + + AccessorKind* = enum + Name + Prop + Val + Props + Values + + Accessor* = object + case kind*: AccessorKind + of Prop: + prop*: string + of Val: + index*: Natural + else: discard + + Mapping*[T: Accessor or seq[Accessor]] = T + diff --git a/src/kdl/schema.nim b/src/kdl/schema.nim index 444bbb2..859a134 100644 --- a/src/kdl/schema.nim +++ b/src/kdl/schema.nim @@ -1 +1,154 @@ # TODO: implement the KDL schema language specification https://github.com/kdl-org/kdl/blob/main/SCHEMA-SPEC.md + +import std/options +import nodes, types + +type + Document* = object + info*: Info + node*: seq[Node] # Zero or more + defs*: Option[Defs] + nodeNames*: Option[Validations] + otherNodesAllowed*: bool + tag*: seq[Tag] + tagNames*: Option[Validations] + otherTagsAllowed*: bool + + Info* = object + title*: seq[Title] # Zero or more + desc*: seq[Description] # Zero or more + author*: seq[Author] # Zero or more + contributor*: seq[Author] # Zero or more + link*: seq[Link] # Zero or more + license*: seq[License] # Zero or more + published*: Option[Published] + modified*: Option[Published] + version*: Option[Version] + + Title* = object + title*: string + lang*: Option[string] # An IETF BCP 47 language tag + + Description* = object + desc*: string + lang*: Option[string] # An IETF BCP 47 language tag + + Author* = object + name*: string + orcid*: Option[string] + links*: seq[Link] # Zero or more + + Link* = object + uri*: string # URI/IRI + rel*: string # "self" or "documentation" + lang*: Option[string] # An IETF BCP 47 language tag + + License* = object + name*: string + spdx*: Option[string] # An SPDX license identifier + links*: seq[Link] # One or more + + Published* = object + date*: string # As a ISO8601 date + time*: Option[string] # An ISO8601 Time to accompany the date + + + Version* = string # SemVer https://github.com/euantorano/semver.nim + + Node* = object + name*: Option[string] + desc*: Option[string] + id*: Option[string] # Unique + refQuery*: Option[string] # KDL Query + + min*, max*: Option[int] + propNames*: Option[Validations] + otherPropsAllowed*: bool + tag*: Validations + prop*: seq[Prop] # Zero or more + value*: seq[Value] # Zero or more + children*: seq[Children] # Zero or more + + Tag* = object + name*: Option[string] + desc*: Option[string] + id*: Option[string] # Unique + refQuery*: Option[string] # KDL Query + + node*: seq[Node] # Zero or more + nodeNames*: Option[Validations] + otherNodesAllowed*: bool + + Prop* = object + key*: Option[string] + desc*: Option[string] + id*: Option[string] # Unique + refQuery*: Option[string] # KDL Query + + required*: Option[bool] + # Any validation node + + Value* = object + desc*: Option[string] + id*: Option[string] # Unique + refQuery*: Option[string] # KDL Query + + min*, max*: Option[int] + + # Any validation node + + Children* = object + desc*: Option[string] + id*: Option[string] # Unique + refQuery*: Option[string] # KDL Query + + node*: seq[Node] # Zero or more + nodeNames*: Option[Validations] + otherNodesAllowed*: bool + + Format = enum + DateTime # ISO8601 date/time format + Time # Time section of ISO8601 + Date # Date section of ISO8601 + Duration # ISO8601 duration format + Decimal # IEEE 754-2008 decimal string format + Currency # ISO 4217 currency code + Country2 # ISO 3166-1 alpha-2 country code + Country3 # ISO 3166-1 alpha-3 country code + CountrySubdivision # ISO 3166-2 country subdivison code + Email # RFC5302 email address + IdnEmail # RFC6531 internationalized email adress + HostName # RFC1132 internet hostname + IdnHostName # RFC5890 internationalized internet hostname + Ipv4 # RFC2673 dotted-quad IPv4 address + Ipv6 # RFC2373 IPv6 address + Url # RFC3986 URI + UrlReference # RFC3986 URI Reference + Irl # RFC3987 Internationalized Resource Identifier + IrlReference # RFC3987 Internationalized Resource Identifier Reference + UrlTemplate # RFC6570 URI Template + Uuid # RFC4122 UUID + Regex # Regular expression. Specific patterns may be implementation-dependent + Base64 # A Base64-encoded string, denoting arbitrary binary data + KdlQuery # A KDL Query string + + Validations* = ref object + tag*: Validations + `enum`*: seq[KdlVal] # List of allowed values for this property + case kind*: KValKind # Type of the property value + of KString: + pattern*: string # Regex + minLength*, maxLength*: int + format*: Format + of KFloat, KInt: + `%`*: string + + else: discard + + Defs* = object + node*: seq[Node] # Zero or more + tag*: seq[Tag] # Zero or more + prop*: seq[Prop] # Zero or more + value*: seq[Value] # Zero or more + children*: seq[Children] # Zero or more + diff --git a/src/kdl/types.nim b/src/kdl/types.nim new file mode 100644 index 0000000..0b918fb --- /dev/null +++ b/src/kdl/types.nim @@ -0,0 +1,45 @@ +import std/[options, tables] + +type + KdlError* = object of CatchableError + KdlLexerError* = object of KdlError + KdlParserError* = object of KdlError + + KValKind* = enum + KEmpty, + KString, + KFloat, + KBool, + KNull + KInt, + + KdlVal* = object + tag*: Option[string] # Type annotation + + case kind*: KValKind + of KString: + str*: string + of KFloat: + fnum*: float + of KBool: + boolean*: bool + of KNull, KEmpty: + discard + of KInt: + num*: int64 + + KdlProp* = tuple[key: string, val: KdlVal] + + KdlNode* = object + tag*: Option[string] + name*: string + args*: seq[KdlVal] + props*: Table[string, KdlVal] + children*: seq[KdlNode] + + KdlDoc* = seq[KdlNode] + + KdlPrefs*[T] = object + path*: string + default*: T + content*: T diff --git a/src/kdl/utils.nim b/src/kdl/utils.nim index 232ef2c..9590137 100644 --- a/src/kdl/utils.nim +++ b/src/kdl/utils.nim @@ -1,22 +1,216 @@ -import std/[strformat, strutils] +import std/[strformat, strutils, unicode, streams, tables, macros] + +import types type - KdlError* = object of ValueError - KdlLexerError* = object of KdlError - KdlParserError* = object of KdlError + Coord* = object + line*, col*, idx*: int + + Object* = ((object or tuple) and not KdlSome) + List* = (array or seq) + Value* = (SomeNumber or string or bool) + KdlSome* = (KdlDoc or KdlNode or KdlVal) + SomeTable*[K, V] = (Table[K, V] or OrderedTable[K, V]) + +template fail*(msg: string) = + raise newException(KdlError, msg) - Coord* = tuple[line: int, col: int] +template check*(cond: untyped, msg = "") = + if not cond: + let txt = msg + fail astToStr(cond) & " failed" & (if txt.len > 0: ": " & txt else: "") proc quoted*(x: string): string = result.addQuoted(x) - -proc getCoord*(str: string, idx: int): Coord = - let lines = str[0..= aLen: + # both cursors at the end: + if j >= bLen: return 0 + # not yet at the end of 'b': + return -1 + elif j >= bLen: + return 1 + inc i + inc j + +proc eqIdent*(v, a: openarray[char], ignoreChars = {'_', '-'}): bool = cmpIgnoreStyle(v, a, ignoreChars) == 0 + +# ----- Streams ----- + +proc peekRune*(s: Stream): Rune = + let str = s.peekStr(4) + if str.len > 0: + result = str.runeAt(0) + +proc peekLineFromStart*(s: Stream): string = + let before = s.getPosition() + while s.getPosition() > 0: + s.setPosition(s.getPosition() - 1) + if s.peekChar() in Newlines: + s.setPosition(s.getPosition() + 1) + if s.atEnd: + s.setPosition(s.getPosition() - 1) + + break + + result = s.peekLine() + s.setPosition before + +proc peekLineFromStart*(s: string, at: int): string = + if at >= s.len: + return + + var idx = 0 + for i in countdown(at-1, 0): + if s[i] in Newlines: + idx = i + 1 + if idx == s.high: + dec idx + break + + for i in idx..s.high: + if s[i] in Newlines: + return s[idx.. 0: + quote do: + @`result` + else: + quote do: + newSeq[string]() + +macro initCaseObject*(T: typedesc, discriminatorSetter): untyped = + ## Does the minimum to construct a valid case object `T`. + ## - `discriminatorSetter`: called passing two arguments `(key, typ)` (`key` being the field name and `typ` the field type), last expression should be the value for the field + # maybe candidate for std/typetraits + + var a = T.getTypeImpl + + doAssert a.kind == nnkBracketExpr + + let sym = a[1] + let t = sym.getTypeImpl + var t2: NimNode + + case t.kind + of nnkObjectTy: t2 = t[2] + of nnkRefTy: t2 = t[0].getTypeImpl[2] + else: doAssert false, $t.kind # xxx `nnkPtrTy` could be handled too + + doAssert t2.kind == nnkRecList + + result = newTree(nnkObjConstr) + result.add sym + + for ti in t2: + if ti.kind == nnkRecCase: + let key = ti[0][0] + let typ = ti[0][1] + let key2 = key.strVal + let val = quote do: + `discriminatorSetter`(`key2`, typedesc[`typ`]) + + result.add newTree(nnkExprColonExpr, key, val) + +template typeofdesc*[T](b: typedesc[T]): untyped = T diff --git a/src/kdl/xik.nim b/src/kdl/xik.nim index f663a30..705b61a 100644 --- a/src/kdl/xik.nim +++ b/src/kdl/xik.nim @@ -43,8 +43,10 @@ breakfast_menu { assert $data.parseXml() == $data.parseXml().toKdl().toXml() +{.used.} + import std/[strtabs, xmltree] -import nodes +import nodes, types proc toKdl*(node: XmlNode, comments = false): KdlNode = ## Converts node into its KDL representation. @@ -60,10 +62,10 @@ proc toKdl*(node: XmlNode, comments = false): KdlNode = result = initKNode(node.tag) if node.attrsLen > 0: for key, val in node.attrs: - result[key] = initKVal(val) + result.props[key] = initKVal(val) if node.len == 1 and node[0].kind in {xnText, xnEntity, xnVerbatimText}: - result.add initKVal(node[0].text) + result.args.add initKVal(node[0].text) else: for child in node: if comments or child.kind != xnComment: @@ -77,7 +79,7 @@ proc toXml*(node: KdlNode, comments = false): XmlNode = result = newElement(node.name) - assert (node.len > 0 and node.children.len == 0) or (node.len == 0 and node.children.len > 0) or (node.len == 0 and node.children.len == 0), "nodes have to have either one argument and zero children, zero arguments and zero or more children" + assert (node.args.len > 0 and node.children.len == 0) or (node.args.len == 0 and node.children.len > 0) or (node.args.len == 0 and node.children.len == 0), "nodes have to have either one argument and zero children, zero arguments and zero or more children" if node.props.len > 0: result.attrs = newStringTable() @@ -85,18 +87,18 @@ proc toXml*(node: KdlNode, comments = false): XmlNode = assert val.isString, "properties' values have to be of type string" result.attrs[key] = val.getString - if node.len > 0: - assert node.len == 1 and node[0].isString, "first argument has to be a string and there must be only one argument" - result.add newText(node[0].getString) + if node.args.len > 0: + assert node.args.len == 1 and node.args[0].isString, "first argument has to be a string and there must be only one argument" + result.add newText(node.args[0].getString) else: for child in node.children: case child.name of "-": - assert child.len == 1 and child[0].isString, "first argument has to be a string and there must be only one argument" - result.add newText(child[0].getString) + assert child.args.len == 1 and child.args[0].isString, "first argument has to be a string and there must be only one argument" + result.add newText(child.args[0].getString) of "!": - assert child.len == 1 and child[0].isString, "first argument has to be a string and there must be only one argument" + assert child.args.len == 1 and child.args[0].isString, "first argument has to be a string and there must be only one argument" if comments: - result.add newComment(child[0].getString) + result.add newComment(child.args[0].getString) else: result.add child.toXml(comments) diff --git a/tests/test_serializer.nim b/tests/test_serializer.nim new file mode 100644 index 0000000..d9e4939 --- /dev/null +++ b/tests/test_serializer.nim @@ -0,0 +1,535 @@ +import std/[strformat, strutils, unittest, options, tables, times] +import kdl +import kdl/utils except check + +type + MyObjKind = enum + moInt, moString + + MyObj = object + case kind*: MyObjKind + of moInt: + intV*: int + of moString: + stringV*: string + + case kind2*: MyObjKind + of moInt: + intV2*: int + of moString: + stringV2*: string + + MyObj2 = object + id*: int + name*: string + + MyObj3 = object + id*: int + name*: string + + MyObj4 = object + kind*: string + list*: seq[int] + + MyEnum = enum + meNorth, meSouth, meWest, meEast + +proc `==`(a, b: MyObj): bool = + assert a.kind == b.kind + assert a.kind2 == b.kind2 + + result = + case a.kind + of moInt: + a.intV == b.intV + of moString: + a.stringV == b.stringV + + result = + case a.kind2 + of moInt: + result and a.intV2 == b.intV2 + of moString: + result and a.stringV2 == b.stringV2 + +proc newHook*(v: var DateTime) = + v = dateTime(2000, mMar, 30) + +proc newHook*(v: var MyObj2) = + v.id = 5 + +proc postHook*(v: var MyObj3) = + inc v.id + +proc enumHook*(a: string, v: var MyEnum) = + case a.toLowerAscii + of "north": + v = meNorth + of "south": + v = meSouth + of "west": + v = meWest + of "east": + v = meEast + else: + raise newException(ValueError, &"invalid enum value {a} for {$typeof(v)}") + +proc enumHook*(a: int, v: var MyEnum) = + case a + of 0xbeef: + v = meNorth + of 0xcafe: + v = meSouth + of 0xface: + v = meWest + of 0xdead: + v = meEast + else: + raise newException(ValueError, &"invalid enum value {a} for {$typeof(v)}") + +proc renameHook*(_: typedesc[MyObj4 or MyObj], fieldName: var string) = + fieldName = + case fieldName + of "type": + "kind" + of "type2": + "kind2" + of "array": + "list" + else: + fieldName + +proc decodeHook*(a: KdlVal, v: var DateTime) = + assert a.isString + v = a.getString.parse("yyyy-MM-dd") + +proc decodeHook*(a: KdlNode, v: var DateTime) = + case a.args.len + of 6: # year month day hour minute second + v = dateTime( + a.args[0].decode(int), + a.args[1].decode(Month), + a.args[2].decode(MonthdayRange), + a.args[3].decode(HourRange), + a.args[4].decode(MinuteRange), + a.args[5].decode(SecondRange) + ) + of 3: # year month day + v = dateTime( + a.args[0].decode(int), + a.args[1].decode(Month), + a.args[2].decode(MonthdayRange), + ) + of 1: # yyyy-MM-dd + a.args[0].decode(v) + else: + doAssert a.args.len in {1, 3, 6} + + if "hour" in a.props: + v.hour = a.props["hour"].getInt + if "minute" in a.props: + v.minute = a.props["minute"].getInt + if "second" in a.props: + v.second = a.props["second"].getInt + if "nanosecond" in a.props: + v.nanosecond = a.props["nanosecond"].getInt + if "offset" in a.props: + v.utcOffset = a.props["offset"].get(int) + +proc decodeHook*(a: KdlDoc, v: var DateTime) = + if a.len == 0: return + + var + year: int + month: Month + day: MonthdayRange = 1 + hour: HourRange + minute: MinuteRange + second: SecondRange + nanosecond: NanosecondRange + + for node in a: + if node.name.eqIdent "year": + node.decode(year) + elif node.name.eqIdent "month": + node.decode(month) + elif node.name.eqIdent "day": + node.decode(day) + elif node.name.eqIdent "hour": + node.decode(hour) + elif node.name.eqIdent "minute": + node.decode(minute) + elif node.name.eqIdent "second": + node.decode(second) + elif node.name.eqIdent "nanosecond": + node.decode(nanosecond) + + v = dateTime(year, month, day, hour, minute, second, nanosecond) + +proc encodeHook*(a: DateTime, v: var KdlDoc) = + v = @[ + initKNode("year", args = @[encode(a.year, KdlVal)]), + initKNode("month", args = @[encode(a.month, KdlVal)]), + initKNode("day", args = @[encode(a.monthday, KdlVal)]), + initKNode("hour", args = @[encode(a.hour, KdlVal)]), + initKNode("minute", args = @[encode(a.minute, KdlVal)]), + initKNode("second", args = @[encode(a.second, KdlVal)]), + initKNode("nanosecond", args = @[encode(a.nanosecond, KdlVal)]), + ] + +template encodeDecodes(x): untyped = + let a = x + when x is ref: + a.encode().decode(typeof a)[] == a[] + else: + a.encode().decode(typeof a) == a + +template encodeDecodes(x: untyped, name: string): untyped = + let a = x + when a is ref: + a.encode(name).decode(typeof a)[] == a[] + else: + a.encode(name).decode(typeof a) == a + +suite "Decoder": + test "Crate": + type + Package = object + name*, version*: string + authors*: Option[seq[string]] + description*, licenseFile*, edition*: Option[string] + + Deps = Table[string, string] + + const + doc = parseKdl(""" + package { + name "kdl" + version "0.0.0" + description "kat's document language" + authors "Kat Marchán " + license-file "LICENSE.md" + edition "2018" + } + + dependencies { + nom "6.0.1" + thiserror "1.0.22" + }""") + + package = doc.decode(Package, "package") + dependencies = doc.decode(Deps, "dependencies") + + check package == Package(name: "kdl", version: "0.0.0", authors: @["Kat Marchán "].some, description: "kat's document language".some, licenseFile: "LICENSE.md".some, edition: "2018".some) + check dependencies == {"nom": "6.0.1", "thiserror": "1.0.22"}.toTable + + test "Nimble": + type + Package = object + version*, author*, description*, license*: string + requires*: seq[string] + obj*: tuple[num: Option[float32]] + + const + doc = parseKdl(""" + version "0.0.0" + author "Kat Marchán " + description "kat's document language" + license "CC BY-SA 4.0" + obj num=3.14 + + requires "nim >= 0.10.0" "foobar >= 0.1.0" "fizzbuzz >= 1.0"""") + package = doc.decode(Package) + + check package == Package(version: "0.0.0", author: "Kat Marchán ", description: "kat's document language", license: "CC BY-SA 4.0", requires: @["nim >= 0.10.0", "foobar >= 0.1.0", "fizzbuzz >= 1.0"], obj: (num: 3.14f.some)) + + test "Seqs and arrays": + type Foo = object + a*, b*: int + + check parseKdl("node 1 2 3").decode(seq[seq[int]], "node") == @[@[1], @[2], @[3]] + check parseKdl("node 1 2 3").decode(seq[int], "node") == @[1, 2, 3] + + check parseKdl("node {a 1; b 2}; node {a 3; b 3}").decode(seq[Foo]) == @[Foo(a: 1, b: 2), Foo(a: 3, b: 3)] + check parseKdl("node 1; node 2").decode(seq[int]) == @[1, 2] + + check parseKdl("node 1 2 3").decode(array[4, int], "node") == [1, 2, 3, 0] + check parseKdl("node 1 2 3").decode(array[3, int], "node") == [1, 2, 3] + check parseKdl("node 1 2 3").decode(array[2, int], "node") == [1, 2] + check parseKdl("node 1 2 3").decode(array[0, int], "node") == [] + + test "Options": + type Person = object + name*: string + surname*: Option[string] + + check parseKdl("node \"Nah\"; node \"Pat\"; node").decode(seq[Option[string]]) == @["Nah".some, "Pat".some, string.none] + check parseKdl("node name=\"Beef\"; node name=\"Pat\" surname=\"ito\"").decode(seq[Person]) == @[Person(name: "Beef", surname: none(string)), Person(name: "Pat", surname: some("ito"))] + + test "Tables": + check parseKdl("key \"value\"; alive true").decode(Table[string, KdlVal]) == { + "key": "value".initKVal, + "alive": true.initKVal + }.toTable + check parseKdl("person age=10 name=\"Phil\" {other-name \"Isofruit\"}").decode(Table[string, KdlVal], "person") == { + "age": 10.initKVal, + "name": "Phil".initKVal, + "other-name": "Isofruit".initKVal + }.toTable + + check parseKdl("key \"value\"; alive true").decode(OrderedTable[string, KdlVal]) == { + "key": "value".initKVal, + "alive": true.initKVal + }.toOrderedTable + check parseKdl("person age=10 name=\"Phil\" {other-name \"Isofruit\"}").decode(OrderedTable[string, KdlVal], "person") == { + "age": 10.initKVal, + "name": "Phil".initKVal, + "other-name": "Isofruit".initKVal + }.toOrderedTable + + test "Objects": + type + Person = object + name*: string + age*: int + + Game = object + name*, version*, author*, license*: string + + check parseKdl("person age=20 {name \"Rika\"}").decode(Person, "person") == Person(age: 20, name: "Rika") + check parseKdl("person age=20 {name \"Rika\"}").decode(tuple[age: int, name: string], "person") == (age: 20, name: "Rika") + check parseKdl("name \"Mindustry\"; version \"126.2\"; author \"Anuken\"; license \"GNU General Public License v3.0\"").decode(Game) == Game(name: "Mindustry", version: "126.2", author: "Anuken", license: "GNU General Public License v3.0") + + test "Refs": + type + Person = ref object + name*: string + age*: int + + Game = ref object + name*, version*, author*, license*: string + + check parseKdl("person age=20 {name \"Rika\"}").decode(Person, "person")[] == Person(age: 20, name: "Rika")[] + check parseKdl("name \"Mindustry\"; version \"126.2\"; author \"Anuken\"; license \"GNU General Public License v3.0\"").decode(Game)[] == Game(name: "Mindustry", version: "126.2", author: "Anuken", license: "GNU General Public License v3.0")[] + + test "Object variants": + check parseKdl(""" + node kind="moString" stringV="Hello" + node kind="moInt" intV=12 + node kind="moString" stringV="Beef" kind2="moInt" intV2=0xbeef + """).decode(seq[MyObj]) == @[MyObj(kind: moString, stringV: "Hello"), MyObj(kind: moInt, intV: 12), MyObj(kind: moString, stringV: "Beef", kind2: moInt, intV2: 0xbeef)] + + check parseKdl(""" + kind "moString" + stringV "World" + """).decode(MyObj) == MyObj(kind: moString, stringV: "World") + + test "Enums": + type + Dir = enum + north, south, west, east + HoleyDir = enum + hNorth = 1, hSouth = 3, hWest = 6, hEast = 12 + + check parseKdl("dir \"north\" 1").decode(seq[Dir], "dir") == @[north, south] + + when defined(kdlDecoderAllowHoleyEnums): + check parseKdl("dir 2 3").decode(seq[HoleyDir], "dir") == @[HoleyDir(2), hSouth] + else: + expect KdlError: + discard parseKdl("dir 2 3").decode(seq[HoleyDir], "dir") + + test "Chars": + check parseKdl("rows \"a\" \"b\" \"c\"").decode(seq[char], "rows") == @['a', 'b', 'c'] + check parseKdl("char \"#\"").decode(char, "char") == '#' + + test "Extra": + check parseKdl("node 1").decode(int, "node") == 1 + + var result: int + parseKdl("node 1").decode(result, "node") + check result == 1 + + check parseKdl("node true").decode(bool, "node") == true + + check parseKdl("node null \"not null\"").decode(seq[cstring], "node") == @[cstring nil, cstring "not null"] + + test "Custom": + check parseKdl(""" + year 2022 + month 10 // or "October" + day 15 + hour 12 + minute 10 + """).decode(DateTime) == dateTime(2022, mOct, 15, 12, 10) + + check parseKdl("date 2022 \"October\" 15 12 04 00").decode(DateTime, "date") == dateTime(2022, mOct, 15, 12, 04) + + check parseKdl("author birthday=\"2000-10-15\" name=\"Nobody\"")[0]["birthday"].decode(DateTime) == dateTime(2000, mOct, 15) + + test "newHook": + check parseKdl("").decode(DateTime) == dateTime(2000, mMar, 30) + check parseKdl("name \"otoboke\"").decode(MyObj2) == MyObj2(id: 5, name: "otoboke") + + test "postHook": + check parseKdl("id 4").decode(MyObj3) == MyObj3(id: 5) + + test "enumHook": + check parseKdl(""" + node "north" "south" "west" "east" + """).decode(seq[MyEnum], "node") == @[meNorth, meSouth, meWest, meEast] + + check parseKdl(""" + node 0xbeef 0xcafe 0xface 0xdead + """).decode(seq[MyEnum], "node") == @[meNorth, meSouth, meWest, meEast] + + test "renameHook": + check parseKdl(""" + type "string" + array 1 2 3 + """).decode(MyObj4) == MyObj4(kind: "string", list: @[1, 2, 3]) + + check parseKdl(""" + node type="string" { + array 1 2 3 + } + """).decode(MyObj4, "node") == MyObj4(kind: "string", list: @[1, 2, 3]) + + check parseKdl(""" + type "moString" + stringV "hello" + type2 "moInt" + intV2 0xbeef + """).decode(MyObj) == MyObj(kind: moString, stringV: "hello", kind2: moInt, intV2: 0xbeef) + + check parseKdl(""" + node type="moString" type2="moInt" { + stringV "bye" + intV2 0xdead + } + """).decode(MyObj, "node") == MyObj(kind: moString, stringV: "bye", kind2: moInt, intV2: 0xdead) + +suite "Encoder": + test "Crate": + type + Package = object + name*, version*: string + authors*: Option[seq[string]] + description*, licenseFile*, edition*: Option[string] + + check encodeDecodes Package(name: "kdl", version: "0.0.0", authors: @["Kat Marchán "].some, description: "kat's document language".some, licenseFile: "LICENSE.md".some, edition: "2018".some) + check encodeDecodes {"nom": "6.0.1", "thiserror": "1.0.22"}.toTable + + test "Nimble": + type + Package = object + version*, author*, description*, license*: string + requires*: seq[string] + obj*: tuple[num: Option[float32]] + + check encodeDecodes Package(version: "0.0.0", author: "Kat Marchán ", description: "kat's document language", license: "CC BY-SA 4.0", requires: @["nim >= 0.10.0", "foobar >= 0.1.0", "fizzbuzz >= 1.0"], obj: (num: 3.14f.some)) + + test "Seqs and arrays": + type Foo = object + a*, b*: int + + check encodeDecodes @[@[1], @[2], @[3]] + check encodeDecodes @[1, 2, 3] + + check encodeDecodes @[Foo(a: 1, b: 2), Foo(a: 3, b: 3)] + check encodeDecodes @[1, 2] + + check encodeDecodes [1, 2, 3, 0] + check encodeDecodes [1, 2, 3] + check encodeDecodes [1, 2] + check encodeDecodes array[0, int].default + + test "Options": + type Person = object + name*: string + surname*: Option[string] + + check encodeDecodes @["Nah".some, "Pat".some, string.none] + check encodeDecodes @[Person(name: "Beef", surname: none(string)), Person(name: "Pat", surname: some("ito"))] + + test "Tables": + check encodeDecodes { + "key": "value".initKVal, + "alive": true.initKVal + }.toTable + check encodeDecodes { + "age": 10.initKVal, + "name": "Phil".initKVal, + "other-name": "Isofruit".initKVal + }.toTable + + check encodeDecodes { + "key": "value".initKVal, + "alive": true.initKVal + }.toOrderedTable + check encodeDecodes { + "age": 10.initKVal, + "name": "Phil".initKVal, + "other-name": "Isofruit".initKVal + }.toOrderedTable + + test "Objects": + type + Person = object + name*: string + age*: int + + Game = object + name*, version*, author*, license*: string + + check encodeDecodes Person(age: 20, name: "Rika") + check encodeDecodes (age: 20, name: "Rika") + check encodeDecodes Game(name: "Mindustry", version: "126.2", author: "Anuken", license: "GNU General Public License v3.0") + + test "Refs": + type + Person = ref object + name*: string + age*: int + + Game = ref object + name*, version*, author*, license*: string + + check encodeDecodes Person(age: 20, name: "Rika") + check encodeDecodes (age: 20, name: "Rika") + check encodeDecodes Game(name: "Mindustry", version: "126.2", author: "Anuken", license: "GNU General Public License v3.0") + + test "Object variants": + check encodeDecodes @[MyObj(kind: moString, stringV: "Hello"), MyObj(kind: moInt, intV: 12), MyObj(kind: moString, stringV: "Beef", kind2: moInt, intV2: 0xbeef)] + + check encodeDecodes MyObj(kind: moString, stringV: "World") + + test "Enums": + type + Dir = enum + north, south, west, east + HoleyDir = enum + hNorth = 1, hSouth = 3, hWest = 6, hEast = 12 + + check encodeDecodes @[north, south] + + when defined(kdlDecoderAllowHoleyEnums): + check encodeDecodes @[HoleyDir(2), hSouth] + + test "Chars": + check encodeDecodes @['a', 'b', 'c'] + check encodeDecodes('#', "node") + + test "Extra": + check encodeDecodes(1, "node") + + check encodeDecodes(true, "node") + + check encodeDecodes @[cstring nil, cstring "not null"] + + test "Custom": + check encodeDecodes dateTime(2022, mOct, 15, 12, 10) + + check encodeDecodes dateTime(2022, mOct, 15, 12, 04) + + check encodeDecodes dateTime(2000, mOct, 15) diff --git a/tests/tests.nim b/tests/tests.nim index 717f935..f6851a4 100644 --- a/tests/tests.nim +++ b/tests/tests.nim @@ -1,11 +1,10 @@ -import std/[unittest, xmlparser, xmltree, json, os] +import std/[xmlparser, unittest, xmltree, json, os] -import kdl, kdl/[schema, query, jik, xik] +import kdl +import kdl/[schema, query, jik, xik] let testsDir = getAppDir() / "test_cases" -proc quoted*(x: string): string = result.addQuoted(x) - suite "spec": for kind, path in walkDir(testsDir / "input"): if kind != pcFile: continue @@ -19,10 +18,15 @@ suite "spec": elif fileExists(expectedPath): test "Valid: " & filename: check readFile(expectedPath) == parseKdlFile(path).pretty() + test "Valid: " & filename & " [Stream]": + check readFile(expectedPath) == parseKdlFileStream(path).pretty() else: test "Invalid: " & filename: - expect(KDLError): + expect(KdlError): discard parseKdlFile(path) + test "Invalid: " & filename & " [Stream]": + expect(KdlError): + discard parseKdlFileStream(path) suite "examples": # Check that kdl-nim can parse all the documents in the examples folder for kind, path in walkDir(testsDir / "examples"): @@ -52,3 +56,8 @@ suite "JiK": # Check that kdl-nim can convert JSON into KDL forth and back test "File: " & filename: let data = parseFile(path) check data == data.toKdl().toJson() + +suite "Other": + test "Nodes": + check toKdlArgs("abc", 123, 3.14, true, nil) == ["abc".initKVal, 123.initKVal, 3.14.initKVal, true.initKVal, initKNull()] + check toKdlProps({"a": "abc", "b": 123, "c": 3.14, "d": false, "e": nil}) == {"a": "abc".initKVal, "b": 123.initKVal, "c": 3.14.initKVal, "d": false.initKVal, "e": initKNull()}.toTable