From ec4f5d75b80826602c2d615be292e339f95dab91 Mon Sep 17 00:00:00 2001 From: David Chambers Date: Sun, 5 Feb 2012 20:57:04 -0800 Subject: [PATCH] initial commit --- README.md | 144 +++++++++++++++++++++++++++++++++++++++++++ string-format.coffee | 38 ++++++++++++ string-format.js | 54 ++++++++++++++++ tests.coffee | 74 ++++++++++++++++++++++ 4 files changed, 310 insertions(+) create mode 100644 README.md create mode 100644 string-format.coffee create mode 100644 string-format.js create mode 100644 tests.coffee diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b575cf --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# String::format + +String::format is a small JavaScript utility which adds a `format` method +to strings. It's inspired by and modelled on Python's [`str.format()`][1]. + +When `format` is invoked on a string, placeholders within the string are +replaced with values determined by the arguments provided. A placeholder +is a sequence of characters beginning with `{` and ending with `}`. + +### string.format(value1, value2, ..., valueN) + +Placeholders may contain numbers which refer to positional arguments: + +```coffeescript +"{0}, you have {1} unread message{2}".format("Holly", 2, "s") +# "Holly, you have 2 unread messages" +``` + +Unmatched placeholders produce no output: + +```coffeescript +"{0}, you have {1} unread message{2}".format("Steve", 1) +# "Steve, you have 1 unread message" +``` + +A format string may reference a positional argument multiple times: + +```coffeescript +"{0} x {0} x {0} = {1}".format(3, 3*3*3) +# "3 x 3 x 3 = 27" +``` + +Positional arguments may be referenced implicitly: + +```coffeescript +"{}, you have {} unread message{}".format("Steve", 1) +# "Steve, you have 1 unread message" +``` + +A format string must not contain both implicit and explicit references: + +```coffeescript +"My name is {} {}. Do you like the name {0}?".format("Lemony", "Snicket") +# ERROR: cannot switch from implicit to explicit numbering +``` + +`{{` and `}}` in format strings produce `{` and `}`: + +```coffeescript +"{{}} creates an empty {} in {}".format("dictionary", "Python") +# "{} creates an empty dictionary in Python" +``` + +Dot notation may be used to reference object properties: + +```coffeescript +bobby = first_name: "Bobby", last_name: "Fischer" +garry = first_name: "Garry", last_name: "Kasparov" + +"{0.first_name} {0.last_name} vs. {1.first_name} {1.last_name}".format(bobby, garry) +# "Bobby Fischer vs. Garry Kasparov" +``` + +When referencing the first positional argument, `0.` may be omitted: + +```coffeescript +repo = owner: "pypy", slug: "pypy", followers: [...] + +"{owner}/{slug} has {followers.length} followers".format(repo) +# "pypy/pypy has 516 followers" +``` + +### String.prototype.format.transformers + +“Transformers” can be attached to `String.prototype.format.transformers`: + +```coffeescript +String::format.transformers.upper = -> @toUpperCase() + +"Batman's preferred onomatopoeia: {0!upper}".format("pow!") +# "Batman's preferred onomatopoeia: POW!" +``` + +Within a transformer, `this` is the string returned by the referenced object's +`toString` method, so transformers may be used in conjunction with non-string +objects: + +```coffeescript +peter_parker = + first_name: "Peter" + last_name: "Parker" + toString: -> @first_name + " " + @last_name + +"NAME: {!upper}".format(peter_parker) +# "NAME: PETER PARKER" +``` + +A transformer could sanitizing untrusted input: + +```coffeescript +String::format.transformers.escape = -> + @replace /[&<>"'`]/g, (chr) -> "&#" + chr.charCodeAt(0) + ";" + +"

{!escape}

".format("I <3 EICH") +# "

I <3 EICH

" +``` + +Or pluralize nouns, perhaps: + +```coffeescript +String::format.transformers.s = -> "s" unless +this is 1 + +"{0}, you have {1} unread message{1!s}".format("Holly", 2) +# "Holly, you have 2 unread messages" + +"{0}, you have {1} unread message{1!s}".format("Steve", 1) +# "Steve, you have 1 unread message" +``` + +String::format does not currently define any transformers. + +### string.format() + +If a format string is used in multiple places, one could assign it to +a variable to avoid repetition. The idiomatic alternative is to invoke +`String::format` with no arguments, which produces a reusable function: + +```coffeescript +greet = "{0}, you have {1} unread message{1!s}".format() + +greet("Holly", 2) +# "Holly, you have 2 unread messages" + +greet("Steve", 1) +# "Steve, you have 1 unread message" +``` + +### Running the test suite + + $ coffee tests.coffee + 16 of 16 tests passed + + +[1]: http://docs.python.org/library/stdtypes.html#str.format diff --git a/string-format.coffee b/string-format.coffee new file mode 100644 index 0000000..d9af851 --- /dev/null +++ b/string-format.coffee @@ -0,0 +1,38 @@ +format = String::format = (args...) -> + + if args.length is 0 + return (args...) => @format args... + + idx = 0 + explicit = implicit = no + error = 'cannot switch from {} to {} numbering'.format() + + @replace \ + /([{}])\1|[{](.*?)(?:!(.+?))?[}]/g, + (match, literal, key, transformer) -> + return literal if literal + + if key.length + explicit = yes + throw error('implicit', 'explicit') if implicit + value = lookup(args, key) ? '' + else + implicit = yes + throw error('explicit', 'implicit') if explicit + value = args[idx++] ? '' + + value = value.toString() + if fn = format.transformers[transformer] then fn.call(value) ? '' + else value + +lookup = (object, key) -> + unless /^(\d+)([.]|$)/.test key + key = '0.' + key + while match = /(.+?)[.](.+)/.exec key + object = object[match[1]] + key = match[2] + object[key] + +format.transformers = {} + +format.version = '0.1.0' diff --git a/string-format.js b/string-format.js new file mode 100644 index 0000000..859020d --- /dev/null +++ b/string-format.js @@ -0,0 +1,54 @@ +(function() { + var format, lookup, + __slice = Array.prototype.slice; + + format = String.prototype.format = function() { + var args, error, explicit, idx, implicit, + _this = this; + args = 1 <= arguments.length ? __slice.call(arguments, 0) : []; + if (args.length === 0) { + return function() { + var args; + args = 1 <= arguments.length ? __slice.call(arguments, 0) : []; + return _this.format.apply(_this, args); + }; + } + idx = 0; + explicit = implicit = false; + error = 'cannot switch from {} to {} numbering'.format(); + return this.replace(/([{}])\1|[{](.*?)(?:!(.+?))?[}]/g, function(match, literal, key, transformer) { + var fn, value, _ref, _ref2, _ref3; + if (literal) return literal; + if (key.length) { + explicit = true; + if (implicit) throw error('implicit', 'explicit'); + value = (_ref = lookup(args, key)) != null ? _ref : ''; + } else { + implicit = true; + if (explicit) throw error('explicit', 'implicit'); + value = (_ref2 = args[idx++]) != null ? _ref2 : ''; + } + value = value.toString(); + if (fn = format.transformers[transformer]) { + return (_ref3 = fn.call(value)) != null ? _ref3 : ''; + } else { + return value; + } + }); + }; + + lookup = function(object, key) { + var match; + if (!/^(\d+)([.]|$)/.test(key)) key = '0.' + key; + while (match = /(.+?)[.](.+)/.exec(key)) { + object = object[match[1]]; + key = match[2]; + } + return object[key]; + }; + + format.transformers = {}; + + format.version = '0.1.0'; + +}).call(this); diff --git a/tests.coffee b/tests.coffee new file mode 100644 index 0000000..3c567c7 --- /dev/null +++ b/tests.coffee @@ -0,0 +1,74 @@ +require './string-format' + + +count = passes = 0 + +ok = (actual, expected) -> + count += 1 + passes += 1 if actual is expected + +throws = (fn, expected_error) -> + count += 1 + try + do fn + catch error + passes += 1 if error is expected_error + + +ok '{0}, you have {1} unread message{2}'.format('Holly', 2, 's') + , 'Holly, you have 2 unread messages' + +ok '{0}, you have {1} unread message{2}'.format('Steve', 1) + , 'Steve, you have 1 unread message' + +ok 'the meaning of life is {0} ({1} x {2} is also {0})'.format(42, 6, 7) + , 'the meaning of life is 42 (6 x 7 is also 42)' + +ok '{}, you have {} unread message{}'.format('Steve', 1) + , 'Steve, you have 1 unread message' + +throws (-> '{} {0}'.format 'foo', 'bar') + , 'cannot switch from implicit to explicit numbering' + +throws (-> '{1} {}'.format 'foo', 'bar') + , 'cannot switch from explicit to implicit numbering' + +template = '{1} {}'.format() + +throws (-> template 'foo', 'bar') + , 'cannot switch from explicit to implicit numbering' + +ok '{{ {}: "{}" }}'.format('foo', 'bar') + , '{ foo: "bar" }' + +bobby = first_name: 'Bobby', last_name: 'Fischer' +garry = first_name: 'Garry', last_name: 'Kasparov' + +ok '{0.first_name} {0.last_name} vs. {1.first_name} {1.last_name}'.format(bobby, garry) + , 'Bobby Fischer vs. Garry Kasparov' + +ok '{first_name} {last_name}'.format(bobby) + , 'Bobby Fischer' + +String::format.transformers.s = -> 's' unless +this is 1 + +ok '{0}, you have {1} unread message{1!s}'.format('Holly', 2) + , 'Holly, you have 2 unread messages' + +ok '{0}, you have {1} unread message{1!s}'.format('Steve', 1) + , 'Steve, you have 1 unread message' + +ok 'view message{!s}'.format(2) + , 'view messages' + +ok 'view message{!s}'.format(1) + , 'view message' + +ok 'view message{length!s}'.format(['foo', 'bar']) + , 'view messages' + +ok 'view message{length!s}'.format(['baz']) + , 'view message' + + +console.log "#{passes} of #{count} tests passed"