11
\$\begingroup\$

I was bored over the last couple of days and wrote up a string interpolation library for JavaScript.

I'm very pleased with its functionality, it passes it 79 tests cross browser and the comments and README seem fine too.

My main concern here are the regular expressions. I'm not a real pro in that area so I suspect there could be some enhancement to them.

Another thing is readability of the code as well as how good the comments are. I'd like to have "unclear" sections of the code pointed out so I can improve the naming / comments.

The library brings with it the Formatter factory and the FormatError.

Basic usage

> format = Formatter()
> format('Good {} Sir {}.', 'morning', 'Lancelot')
'Good morning Sir Lancelot.'

> format('Good {time} Sir {name}.', 'morning', 'Lancelot')
'Good morning Sir Lancelot.'

> format('Good {0} Sir {1}.', ['morning', 'Lancelot'])
'Good morning Sir Lancelot.'

Source

(function(undefined) {
    'use strict';

    // Formatter factory
    function Formatter(formats) {
        function f() {
            return format(f.formats, arguments);
        }
        f.formats = formats || {};
        return f;
    }

    // Default formatters
    Formatter.formats = {
        repeat: repeat,

        join: function(value, str) {
            return value.join(str || ', ');
        },

        upper: function(value) {
            return value.toUpperCase();
        },

        lower: function(value) {
            return value.toLowerCase();
        },

        lpad: function(value, length, str) {
            return pad(value, length, str, 'l');
        },

        rpad: function(value, length, str) {
            return pad(value, length, str, 'r');
        },

        pad: function(value, length, str) {
            return pad(value, length, str);
        },

        surround: function(value, left, right) {
            return left + value + (right || left);
        },

        hex: function(value, lead) {
            return (lead ? '0x' : '') + value.toString(16);
        },

        bin: function(value, lead) {
            return (lead ? '0b' : '') + value.toString(2);
        }
    };

    function repeat(value, count) {
        return new Array((count || 0) + 1).join(value || ' ');
    }

    function pad(value, length, str, mode) {
        value = '' + value;
        str = str || ' ';

        var len = length - value.length;
        if (len < 0) {
            return value;

        } else if (mode === 'l') {
            return repeat(str, len) + value;

        } else if (mode === 'r') {
            return value + repeat(str, len);

        } else {
            return repeat(str, len - ~~(len / 2))
                   + value
                   + repeat(str, ~~(len / 2));
        }
    }

    // match {} placholders like {0}, {name}, {} and the inner "{{foo}}"
    // {} can be escaped with \
    var replaceExp = /([^\\]|^)\{([^\{\}]*[^\\^\}]|)\}/g,

        // match things like: foo[0].test["test"]['test]
        accessExp = /^\.?([^\.\[]+)|\[((-?\d+)|('|")(.*?[^\\])\4)\]/,

        // match :foo and :foo(.*?)
        formatExp = /\:([a-zA-Z]+)(\((.*?)\))?(\:|$)/,

        // match arguments: "test", 12, -12, 'test', true, false
        // strings can contain escaped characters like \"
        argumentsExp = /^(,|^)\s*?((true|false|(-?\d+))|('|")(.*?([^\\]|\5))\5)/;

    // Main formatting function
    function format(formatters, args) {

        // Setup magic!
        var string = args[0],
            first = args[1],
            argsLength = args.length - 2,
            type = first != null ? {}.toString.call(first).slice(8, -1) : '',
            arrayLength = first ? first.length - 1 : 0,
            autoIndex = 0;

        function replace(value, pre, form) {

            // Extract formatters
            var formats = [], format = null, id = form;
            while (format = form.match(formatExp)) {
                if (formats.length === 0) {
                    id = form.substring(0, format.index);
                }
                form = form.substring(format[0].length - 1);
                formats.push(format);
            }

            // In case of a valid number use it for indexing
            var num = (isNaN(+id) || id === '') ? null : +id;

            // Handle objects
            if (type === 'Object' && id !== '') {

                // Handle plain keys
                if (id.indexOf('.') === -1 && id.indexOf('[') === -1) {
                    if (first[id] !== undefined) {
                        value = first[id];

                    // fall back to obj.toString()
                    } else {
                        value = args[1 + autoIndex];
                    }

                // Access properties
                } else {
                    value = getProperty(first, id);
                }

            // Handle given array indexes
            } else if (type === 'Array' && num !== null) {
                value = first[num >= 0 ? num : arrayLength + num];

            // Handle given arguments indexes
            } else if (num !== null) {
                value = args[1 + (num >= 0 ? num : argsLength + num)];

            // Handle automatic arguments indexes
            } else {
                value = args[1 + autoIndex];
            }
            autoIndex++;

            // Apply formats
            while (format = formats.shift()) {
                var method = (formatters[format[1]] ? formatters : Formatter.formats)[format[1]];
                if (method) {
                    value = method.apply(undefined,
                                         getArguments(value, format[3] || ''));

                } else {
                    throw new FormatError(
                        replace, 'Undefined formatter "{}".', format[1]
                    );
                }
            }
            return pre + value;
        }
        return string.replace(replaceExp, replace);
    }

    // Get a specific peoperty of an object based on a accessor string
    function getProperty(obj, id) {
        var m, pos = 0;
        while (m = id.substring(pos).match(accessExp)) {
            // .name  / [0] / ["test"]
            var prop = m[1] || (m[3] ? +m[3] : m[5].replace('\\' + m[4], m[4]));
            if (obj === undefined) {
                throw new FormatError(
                    getProperty,
                    'Cannot access property "{}" of undefined.', prop
                );

            } else {
                obj = obj[prop];
            }
            pos += m[0].length;
        }
        return obj;
    }

    // Convert a string like:
    //   true, false, -1, 34, 'foo', "bla\" foo"
    //
    // Into a  list of arguments:
    //   [true, false, -1, 34, 'foo', 'bla" foo']
    function getArguments(value, string) {
        var m, pos = 0, args = [value];
        while (m = string.substring(pos).match(argumentsExp)) {
            // number
            args.push(m[4] ? +m[4]
                           // boolean
                           : (m[3] ? m[3] === 'true'
                                   // string
                                   : m[6].replace('\\' + m[5], m[5])));

            pos += m[0].length;
        }
        return args;
    }

    // Formatting error type
    function FormatError(func, msg, value) {
        this.name = 'FormatError';
        this.message = format(Formatter.formats, [msg, value]);
        if (Error.captureStackTrace) {
            Error.captureStackTrace(this, func);
        }
    }
    FormatError.prototype = new Error();

    // Exports
    var exp = typeof window === 'undefined' ? exports : window;
    exp.Formatter = Formatter;
    exp.FormatError = FormatError;
})();
\$\endgroup\$
0
2
\$\begingroup\$

I have not looked at your regexes in detail. Could you please explain them? The rest of the code could use better documentation as well.

I did find two serious code correctness issues:

  1. Your :join() formatter will not work with the empty string as the delimiter.

    return value.join(str || ', ');    // Boolean('') === false
    
  2. Your :pad() formatter, when used for center padding, will not correctly add an odd number of padding characters, as the padding on either side is rounded down.

    } else {
        return repeat(str, len - ~~(len / 2))
               + value
               + repeat(str, ~~(len / 2));
    }
    

    In general, using tricks such as ~~ tends to reduce the code's clarity, and this is an excellent example of that. You should use the Math.floor() and Math.ceil() functions instead if that is what you intend.

Fix these issues, and of course add corresponding tests. Also document the fact that :pad() and the other padding functions are only intended to work with a single padding character.

\$\endgroup\$
3
  • \$\begingroup\$ Good catches. :pad() actually works with odd numbers, maybe I fixed that after I last updated the post. The :join() problem uncovered some bugs with the parsing of empty strings in arguments, that's also fixed and tested for now. I also replaced the ~~ floor shortcut. I'll add the note and try to document the regular expressions, JS unfortunately doesn't allow them to be split over multiple lines :( \$\endgroup\$ – Ivo Wetzel Feb 23 '11 at 12:48
  • \$\begingroup\$ @IvoWetzel You can have large comment blocks explaining how they work though: github.com/derobins/wmd/blob/master/showdown.js#L161 \$\endgroup\$ – Yi Jiang Feb 25 '11 at 9:01
  • \$\begingroup\$ Added the fixed / changed code to the question. There are also 7 new tests accompanying the changes. @YiJiang I gave the regex commenting a try, could you tell me whether the format is useful? \$\endgroup\$ – Ivo Wetzel Feb 25 '11 at 11:29

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.