├── .gitignore ├── LICENSE ├── benchmark.nim ├── build_continually.sh ├── readme.md ├── stringinterpolation.nim ├── stringinterpolation.nimble └── tests ├── test1.nim └── test2.nim /.gitignore: -------------------------------------------------------------------------------- 1 | nimcache 2 | bin 3 | .changes 4 | build.sh 5 | monitor.sh 6 | 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /benchmark.nim: -------------------------------------------------------------------------------- 1 | import strfmt 2 | import strutils 3 | import stringinterpolation as si 4 | import times 5 | 6 | template runTimed*(benchmark: string, code: stmt): stmt {.immediate.} = 7 | block: 8 | let s = cpuTime() 9 | let s2 = epochTime() 10 | block: 11 | code 12 | let e = cpuTime() 13 | let e2 = epochTime() 14 | echo "\nBenchmark: ", benchmark 15 | #echo interp" Epoch: ${e2-s2}%8.6f sec" 16 | #echo interp" CPU: ${e-s}%8.6f sec" 17 | #echo ifmt" Epoch: ${e2-s2}%8.6f sec" 18 | #echo ifmt" CPU: ${e-s}%8.6f sec" 19 | echo format(" Epoch: %.3f sec", e2-s2) 20 | echo format(" CPU: %.3f sec", e-s) 21 | 22 | # TODO: find out why this works: 23 | #echo si.format("%f", e2) 24 | # but not this 25 | #echo ifmt"$e2" 26 | 27 | 28 | let 29 | i = 1 30 | x = 1.0 31 | s = "hello world" 32 | 33 | let s1 = ifmt"i = $i%5d x = $x%5.2f s = | $s%-20s |" 34 | let s2 = interp"i = ${i:5d} x = ${x:5.2f} s = | ${s:<20s} |" 35 | let s3 = "i = $1 x = $2 s = | $3 |" % [align($i, 5), x.formatFloat(ffDecimal,2).align(5), s.align(20)] 36 | 37 | echo s1 38 | echo s2 39 | echo s3 40 | 41 | const 42 | iterations = 1000000 43 | 44 | runTimed("ifmt"): 45 | var arr = newSeq[string]() 46 | for iter in 1 .. iterations: 47 | let s = ifmt"i = $i%5d x = $x%5.2f s = | $s%-20s |" 48 | arr.add(s) 49 | 50 | runTimed("interp"): 51 | var arr = newSeq[string]() 52 | for iter in 1 .. iterations: 53 | let s = interp"i = ${i:5d} x = ${x:5.2f} s = | ${s:<20s} |" 54 | arr.add(s) 55 | 56 | runTimed("strutils"): 57 | var arr = newSeq[string]() 58 | for iter in 1 .. iterations: 59 | let s = "i = $1 x = $2 s = | $3 |" % [align($i, 5), x.formatFloat(ffDecimal,2).align(5), s.align(20)] 60 | arr.add(s) 61 | -------------------------------------------------------------------------------- /build_continually.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | clear 4 | 5 | while true; do 6 | ./build.sh 7 | 8 | change=$(inotifywait -r -e close_write,moved_to,create,modify . \ 9 | --exclude 'src/main$|nimchange|#.*|./git/' \ 10 | 2> /dev/null) 11 | 12 | # very short sleep to avoid "text file busy" 13 | sleep 0.01 14 | 15 | clear 16 | echo "changed: $change `date +%T`" 17 | echo "changed: $change" >> .changes 18 | done 19 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## String Interpolation 2 | 3 | This is a small string interpolation library for Nim providing a prinftf-like syntax. 4 | The library was inspired by Scala's string interpolation and provides a similar interface. 5 | 6 | Basically there are three formatting templates/macros: 7 | 8 | - `ifmt*(formatString: string): expr` 9 | 10 | Internally, this is a wrapper for `format`. 11 | The format string can contain indentifiers `ifmt"x = $x"` or expressions `ifmt"x = ${x+1}"`. 12 | Both can have a printf format suffix e.g. `ifmt"iteration = $i%5d, error = ${error*100}%6.2f %%"`. 13 | If there are no formatters, a `%s` formatter will be used, which will lead to a call to `$` (see `format`). 14 | In order to escape `$` or `%` in the format string, use `$$` and `%%`. 15 | A type mismatch or an ill-formated format string produces a compile-time error. 16 | 17 | - `format*(formatString: string{lit}, args: varargs[expr]): expr` 18 | 19 | This is a wrapper for `formatUnsafe` and performs additional type checking of the arguments. 20 | There is a special rule for `%s`: 21 | If the correponding argument is not a `string`, the macro will issue a call to `$` to convert it. 22 | If all arguments have proper type (or can be converted to string), a call to `formatUnsafe` is generated. 23 | Due to the compile-time type checking `formatString` must be a static string literal. 24 | 25 | - `formatUnsafe*(formatString: string, args: varargs[expr]): string` 26 | 27 | This template provides a simple wrapper for `snprintf`. 28 | No type checking is performed, allowing to use dynamic format strings. 29 | Internally, the template takes a two-step approach: 30 | In a first call to `snprintf` a fixed size (256) buffer is provided. 31 | If `snprintf` reports that the buffer was too small, a second call is performed with exactly the size required. 32 | As a result, there is no limitation on the maximum string size of the arguments. 33 | 34 | ### TODO: 35 | 36 | - The validation of printf formatters is still very basic. Lots of room for improvement. 37 | - Much more testing is required, especially regarding various type checks. 38 | -------------------------------------------------------------------------------- /stringinterpolation.nim: -------------------------------------------------------------------------------- 1 | 2 | import macros 3 | 4 | # helper types to represent printf format string matches 5 | type 6 | FormatStringMatchEnum = enum 7 | fsInvalid, fsPct, fsMatch 8 | 9 | FormatStringMatch = object 10 | case kind: FormatStringMatchEnum 11 | of fsMatch: 12 | s: string 13 | else: 14 | discard 15 | 16 | proc len(m: FormatStringMatch): int = 17 | case m.kind: 18 | of fsInvalid: 0 19 | of fsPct: 1 20 | of fsMatch: m.s.len 21 | 22 | proc typeSpecifier(m: FormatStringMatch): char = 23 | if m.kind == fsMatch: 24 | m.s[^1] 25 | else: 26 | '\0' 27 | 28 | 29 | proc parseFormatString(s: string): FormatStringMatch = 30 | ## helper function tries to parse a printf format string 31 | ## from the beginning of ``s``. 32 | ## 33 | ## This is still completely stupid (only tests for the terminating character) 34 | ## In the long term I plan to add a more thorough validation of 35 | ## the format strings... 36 | if s[0] == '%': 37 | return FormatStringMatch(kind: fsPct) 38 | 39 | let terminating = {'d', 'i', 'u', 'f', 'F', 'e', 'E', 'g', 'G', 'x', 'X', 'o', 's', 'c', 'p', 'p', 'a', 'A', 'n'} 40 | 41 | for i, c in s: 42 | if c in terminating: 43 | return FormatStringMatch(kind: fsMatch, s: s[0..i]) 44 | 45 | return FormatStringMatch(kind: fsInvalid) 46 | 47 | 48 | proc extractFormatStrings(s: string): seq[FormatStringMatch] = 49 | ## extracts all printf format strings that are contained 50 | ## in ``s``. 51 | 52 | result = newSeq[FormatStringMatch]() 53 | var i = 0 54 | 55 | while i < s.len: 56 | let c = s[i] 57 | #echo s, i, c, result 58 | if c == '%': 59 | let m = parseFormatString(s[i+1..^1]) 60 | if m.kind == fsMatch: 61 | result.add(m) 62 | i += m.len 63 | inc i 64 | 65 | 66 | 67 | 68 | 69 | macro appendVarargs(c: expr, e: expr): expr = 70 | ## helper macro to wrap varargs of C calls. 71 | result = c 72 | for a in e.children: 73 | result.add(a) 74 | 75 | 76 | proc snprintf(buffer: ptr cchar, n: csize, formatstr: cstring): cint 77 | {.importc: "snprintf", varargs, header: "".} 78 | 79 | 80 | template formatUnsafe*(formatString: string, args: varargs[expr]): string = 81 | ## performs a string formatting _without_ type checking. On the other hand, 82 | ## this allows to pass a formatString which is not a static literal. 83 | ## This means that it is possible to use dynamic string format strings, e.g. 84 | ## formatUnsafe("%" & $dynamicNumberOfDigits & "d", 1_000_000) 85 | 86 | # first call 87 | const initSize = 256 88 | var result = newStringOfCap(initSize) 89 | var written = appendVarargs(snprintf(cast[ptr cchar](result.cstring), initSize, formatString), args) 90 | let requiredSize = written + 1 # required size is +1 since the '\0' is not considered in 'written' 91 | 92 | if written < 0: 93 | raise newException(ValueError, "illegal format string \"" & formatString & "\"") 94 | elif requiredSize <= initSize: 95 | result.setlen(written) 96 | else: 97 | # second call required 98 | result = newStringOfCap(requiredSize) 99 | 100 | # call snprintf again (we can discard the size this time) 101 | written = appendVarargs(snprintf(cast[ptr cchar](result.cstring), requiredSize, formatString), args) 102 | result.setlen(written) 103 | 104 | result 105 | 106 | 107 | macro format*(formatString: string{lit}, args: varargs[expr]): expr = 108 | ## formats a static format string, with arguments of variable type. 109 | ## This is a typesafe version of ``formatUnsafe``. Internally, it performs 110 | ## type checking and generates a call to ``formatUnsafe``. 111 | 112 | #echo formatString.strval 113 | let formatStrings = extractFormatStrings(formatString.strval) 114 | #echo formatStrings 115 | #echo formatStrings.len, " == ", args.len 116 | if formatStrings.len != args.len: 117 | error "number of varargs (" & $args.len & ") does not match number of string formatters (" & $formatStrings.len & ")" 118 | 119 | result = newCall(bindSym"formatUnsafe", formatString) 120 | 121 | var i = 0 122 | for fs in formatStrings: 123 | #echo i 124 | #echo args[i].treerepr 125 | let actualType = args[i].getType 126 | let atk = actualType.typeKind 127 | #echo "Actual Type: ", actualType.treerepr 128 | #echo "Actual Type Kind: ", atk 129 | 130 | let typeSpecifier = fs.typeSpecifier 131 | #echo "Type specifier: ", typeSpecifier 132 | 133 | type TypeMatchEnum = enum tmMatch, tmConvertToString, tmNoMatch 134 | 135 | proc checkIn(s: set[NimTypeKind]): TypeMatchEnum = 136 | if atk in s: 137 | tmMatch 138 | else: 139 | tmNoMatch 140 | 141 | let typeMatch = case typeSpecifier 142 | of 'c': 143 | checkIn({ntyChar}) 144 | of 'd', 'i', 'x', 'X': 145 | # TODO: is this okay, we could type check if formatter has unsigned flag... 146 | checkIn({ntyInt, ntyInt8, ntyInt16, ntyInt32, ntyInt64, ntyUInt, ntyUInt8, ntyUInt16, ntyUInt32, ntyUInt64}) 147 | of 'f', 'e', 'E', 'g', 'G': 148 | checkIn({ntyFloat, ntyFloat32, ntyFloat64, ntyFloat128}) 149 | of 'p': 150 | # TODO: is this okay 151 | checkIn({ntyPtr, ntyRef}) 152 | of 's': 153 | if atk == ntyString: 154 | tmMatch 155 | else: 156 | tmConvertToString 157 | else: 158 | tmNoMatch 159 | 160 | if typeMatch == tmNoMatch: 161 | error "string formatter '" & typeSpecifier & "' does not match type " & $atk # $symbol(actualType) 162 | elif typeMatch == tmConvertToString: 163 | add(result, prefix(args[i], "$")) 164 | else: 165 | add(result, args[i]) 166 | 167 | inc i 168 | 169 | #echo " *** generated by 'format': ", result.treerepr 170 | 171 | 172 | 173 | 174 | 175 | 176 | const 177 | IdentChars = {'a'..'z', 'A'..'Z', '0'..'9', '_'} 178 | IdentStartChars = {'a'..'z', 'A'..'Z', '_'} 179 | ## copied from parseutils, which copied from strutils 180 | ## maybe these characters should be exported in a single place? 181 | 182 | 183 | proc isValidExpr(s: string): bool {.compileTime.} = 184 | ## static helper function to check if an expression is valid 185 | try: 186 | discard parseExpr(s) 187 | return true 188 | except ValueError: 189 | return false 190 | 191 | 192 | macro ifmt*(formatStringNode: string): expr = 193 | 194 | let formatString = formatStringNode.strVal 195 | 196 | type 197 | ParseState = enum 198 | psNeutral, psOneDollar, psIdent, psExpr 199 | 200 | var state: ParseState = psNeutral 201 | var buffer = "" 202 | var i = 0 203 | 204 | var outFmtStr = "" 205 | var outArgs = newSeq[NimNode]() 206 | 207 | while i < formatString.len: 208 | let c = formatString[i] 209 | #echo c, " state: ", state 210 | case state 211 | 212 | of psNeutral: 213 | if c == '$': 214 | state = psOneDollar 215 | buffer.setlen(0) # clear buffer for ident/expr accumulation 216 | inc i 217 | elif c == '%': 218 | inc i 219 | if i < formatString.len and formatString[i] == '%': 220 | outFmtStr.add("%%") 221 | inc i 222 | else: 223 | error "format string contains an unescaped '%' character (use \"%%\" to escape)" 224 | else: 225 | outFmtStr.add(c) 226 | inc i 227 | 228 | of psOneDollar: 229 | if c == '$': # second dollar -> yield "$", return to neutral 230 | outFmtStr.add("$") 231 | state = psNeutral 232 | elif c == '{': 233 | state = psExpr 234 | elif c in IdentStartChars: 235 | state = psIdent 236 | buffer.add(c) 237 | else: 238 | error "a '$' character must either be followed by '$', an identifier, or a {} expression" 239 | inc i 240 | 241 | of psIdent: 242 | if c in IdentChars: 243 | buffer.add(c) 244 | inc i 245 | elif c == '%': 246 | outArgs.add(newIdentNode(buffer)) 247 | let substr = formatString[i+1..^1] 248 | #echo "substr: ", substr 249 | let formatter = parseFormatString(substr) 250 | #echo "formatter: ", formatter 251 | 252 | case formatter.kind: 253 | of fsMatch: # if we have a valid format string: append it and inc by '%' + formatter.len 254 | outFmtStr.add('%' & formatter.s) 255 | i += 1 + formatter.len 256 | of fsPct: # if the '%' char was actually an escaped double '%': provide default formatter + insert '%%' 257 | outFmtStr.add("%s%%") 258 | i += 1 + formatter.len # == 2, but written consistently 259 | of fsInvalid: 260 | error "could not parse format string '" & substr & "'" 261 | state = psNeutral 262 | else: 263 | outArgs.add(newIdentNode(buffer)) 264 | outFmtStr.add("%s") 265 | state = psNeutral 266 | # note: we no _not_ increase i here 267 | # in order to parse the same character 268 | # again in neutral state, allowing to 269 | # check for '$'. 270 | 271 | of psExpr: 272 | if c == '}' and buffer.isValidExpr: 273 | outArgs.add(parseExpr(buffer)) 274 | state = psNeutral 275 | inc i 276 | # peek into next char to see if we have a format string 277 | if i < formatString.len: 278 | let c = formatString[i] 279 | if c == '%': 280 | let substr = formatString[i+1..^1] 281 | #echo "substr: ", substr 282 | let formatter = parseFormatString(substr) 283 | #echo "formatter: ", formatter 284 | 285 | case formatter.kind: 286 | of fsMatch: # if we have a valid format string: append it and inc by '%' + formatter.len 287 | outFmtStr.add('%' & formatter.s) 288 | i += 1 + formatter.len 289 | of fsPct: # if the '%' char was actually an escaped double '%': provide default formatter + insert '%%' 290 | outFmtStr.add("%s%%") 291 | i += 1 + formatter.len # == 2, but written consistently 292 | of fsInvalid: 293 | error "could not parse format string '" & substr & "'" 294 | else: 295 | # no explicit formatter: insert the default formatter 296 | outFmtStr.add("%s") 297 | else: 298 | # at end of string we have to insert the default formatter 299 | outFmtStr.add("%s") 300 | else: 301 | buffer.add(c) 302 | inc i 303 | 304 | 305 | # handle termination 306 | if state == psIdent: 307 | outArgs.add(newIdentNode(buffer)) 308 | outFmtStr.add("%s") 309 | elif state == psOneDollar: 310 | error "format string is not properly terminated (trailing '$')" 311 | elif state == psExpr: 312 | error "format string contains an invalid or incomplete expression \"{" & buffer & "\"" 313 | 314 | # generate call to "format" template and add arguments 315 | result = newCall(bindSym"format", newStrLitNode(outFmtStr)) 316 | for arg in outArgs: 317 | result.add(arg) 318 | 319 | #echo " *** outFmtStr: ", outFmtStr 320 | #echo " *** outArgs: ", outArgs.repr 321 | #echo " *** generated by 'ifmt': ", result.treeRepr 322 | 323 | 324 | 325 | 326 | 327 | when isMainModule: 328 | 329 | import unittest 330 | 331 | suite "stringinterpolation": 332 | 333 | test "parseFormatString": 334 | # since I had to drop regular expressions from 335 | # parseFormatString, not all tests work. 336 | # In fact, the currect implementation only checks 337 | # for the terminating type specifier. TODO... 338 | let tests = [ 339 | ("%asdf", fsPct, 1), 340 | ("ddd", fsMatch, 1), 341 | (" d", fsMatch, 2), 342 | (" +-0#d", fsMatch, 6), 343 | ("f", fsMatch, 1), 344 | ("d", fsMatch, 1), 345 | ("s", fsMatch, 1), 346 | ("5f", fsMatch, 2), 347 | ("5.1f", fsMatch, 4), 348 | (".2f", fsMatch, 3), 349 | ("50.20f", fsMatch, 6), 350 | #("5.2.2f", fsInvalid, 0), # only one decimal separator allows 351 | #("5.f", fsInvalid, 0), # the decimal separator must be followed by digits 352 | ("zu", fsMatch, 2), 353 | #("zzu", fsMatch, 1), # length specifier must not be repeated 354 | ("hhd", fsMatch, 3), # "hh" is a valid length specified 355 | #("hhhhd", fsInvalid, 0), # but must not be repeated 356 | ] 357 | for s, expectedKind, expectedLen in tests.items: 358 | let m = parseFormatString(s) 359 | #echo "Expression: '", s, "' => ", m, " match len: ", m.len 360 | check m.kind == expectedKind 361 | check m.len == expectedLen 362 | 363 | 364 | test "formatUnsafe": 365 | check formatUnsafe("") == "" 366 | check formatUnsafe("", 1) == "" 367 | check formatUnsafe("%%") == "%" 368 | 369 | let s = formatUnsafe("%3d %8.3f%%", 42+1, 3.14 * 2) 370 | check s == " 43 6.280%" 371 | check s.len == 13 372 | 373 | var digits = 15 374 | check formatUnsafe("%" & $digits & "d", 1_000_000) == " 1000000" 375 | 376 | expect ValueError: 377 | discard formatUnsafe("%") 378 | 379 | # can I check for SIGSEGV? 380 | # check formatUnsafe("%s") == "" 381 | 382 | 383 | test "format": 384 | 385 | # everything must convert to string 386 | check format("%s", 1) == "1" 387 | check format("%s", 1.0) == "1.0" 388 | check format("%s", @[1,2,3]) == "@[1, 2, 3]" 389 | 390 | let s = format("%12d %s %s %5.3e", 42.int16, 3.14*1.0, @[1,2,3], 1.234) 391 | check s == " 42 3.14 @[1, 2, 3] 1.234e+00" 392 | 393 | # these must be compliation errors due to type errors 394 | when compiles(format("hello %d", "a string")): 395 | check false 396 | when compiles(format("hello %f", "a string")): 397 | check false 398 | when compiles(format("hello %f", 1)): 399 | check false 400 | when compiles(format("hello %d", 1.0)): 401 | check false 402 | 403 | test "ifmt": 404 | 405 | let x = 1 406 | let s = "test" 407 | 408 | check ifmt" %% test x = $x%d$x%5s$x%%$x%%" == " % test x = 1 11%1%" 409 | check ifmt"%%${x+1}%5d${x+2}%s${3+x}%%$$$x$$" == "% 234%$1$" 410 | check ifmt"""${"test"}""" == "test" 411 | check ifmt"""${s}""" == "test" 412 | 413 | check ifmt"""${s & "{}"}""" == "test{}" 414 | 415 | # these must be compliation errors 416 | when compiles(ifmt"a single %"): 417 | check false 418 | when compiles(ifmt"trailing $"): 419 | check false 420 | when compiles(ifmt"an open ${expr"): 421 | check false 422 | when compiles(ifmt"a wrong identifier $doesNotExist"): 423 | check false 424 | when compiles(ifmt"a bad expression ${or}"): 425 | check false 426 | 427 | -------------------------------------------------------------------------------- /stringinterpolation.nimble: -------------------------------------------------------------------------------- 1 | [Package] 2 | name = "stringinterpolation" 3 | version = "0.1.0" 4 | author = "Fabian Keller" 5 | description = "String interpolation with printf syntax" 6 | license = "MIT" 7 | 8 | [Deps] 9 | Requires: "nim >= 0.11.0" -------------------------------------------------------------------------------- /tests/test1.nim: -------------------------------------------------------------------------------- 1 | import ../stringinterpolation 2 | 3 | proc test*(format: string) = 4 | let s: string = ifmt"nothing to do" 5 | echo s 6 | echo format 7 | 8 | test("test") 9 | 10 | -------------------------------------------------------------------------------- /tests/test2.nim: -------------------------------------------------------------------------------- 1 | import ../stringinterpolation 2 | 3 | template test1*(code: stmt): stmt {.immediate.} = 4 | var x = 0 5 | echo format("%d", x) 6 | #echo ifmt"$x%d" 7 | code 8 | 9 | template test2*(code: stmt): stmt = 10 | var x = 0 11 | echo format("%d", x) 12 | #echo ifmt"$x%d" 13 | code 14 | 15 | template test3*(): stmt = 16 | var x = 0 17 | echo format("%d", x) 18 | #echo ifmt"$x%d" 19 | 20 | template test4*(): expr = 21 | var x = 0 22 | echo format("%d", x) 23 | #echo ifmt"$x%d" 24 | "expr" 25 | 26 | 27 | test1: 28 | echo "Hello World" 29 | echo ifmt"${1}" 30 | 31 | test2: 32 | echo "Hello World" 33 | echo ifmt"${1}" 34 | 35 | test3() 36 | 37 | let x = test4() 38 | --------------------------------------------------------------------------------