syntheticore/declaire

View on GitHub
src/parser.js

Summary

Maintainability
D
1 day
Test Coverage
var _ = require('./utils.js');


// Breadth-first walk the tree in reverse order
var inverseBreadth = function(node, cb) {
  if(!node.children) return;
  for(var i = node.children.length - 1; i >= 0; i--) {
    cb(node.children[i]);
  };
  _.each(node.children, function(child) {
    inverseBreadth(child, cb);
  });
};

// Clean tree from alternatives and temporary data
var cleanTree = function(node) {
  if(node.children) {
    for(var i = node.children.length - 1; i >= 0; i--) {
      var child = node.children[i];
      if(child.type == 'Alternative') {
        node.children.splice(i, 1);
      }
    }
  }
  var children = _.flatten(_.union(node.children, node.alternatives));
  _.each(children, function(child) {
    cleanTree(child);
  })
  delete node.slurpy;
  if(node.attributes && !_.keys(node.attributes).length) delete node.attributes;
  if(node.statements && !node.statements.length) delete node.statements;
  if(node.children && !node.children.length) delete node.children;
  if(node.classes && !node.classes.length) delete node.classes;
  return node;
};


var Parser = {
  indentSpaces: 2,

  // Parse the given template source and spit out a traversable tree
  parseTemplate: function(tmpl) {
    var self = this;
    var lines = tmpl.split('\n');
    var lastIndent = 0;
    var lineNum = 0;
    var top = {
      type: 'TOP',
      children: []
    };
    var lastNode = top;
    var stack = [top];
    var slurpyMode = false;
    _.each(lines, function(line) {
      lineNum++;
      var indent = Math.max(0, line.search(/\S/)) / self.indentSpaces;
      // Regard anything below a slurpy tag as text
      if(slurpyMode && (indent > lastIndent || !line.replace(/\s/g, '').length)) {
        var parent = lastNode;
        parent.children.push({
          type: 'Text',
          content: line.slice(lastIndent * self.indentSpaces + self.indentSpaces) + '\n'
        });
        return;
      }
      slurpyMode = false;
      // Remove indentation
      line = line.slice(indent * self.indentSpaces);
      // Ignore comments
      var ci = line.indexOf('//');
      if(ci == 0) return;
      // Ignore empty lines
      if(!line.replace(/\s/g, '').length) return;
      // Push and pop the current parent from the stack
      // as dictated by indentation
      if(indent > lastIndent) {
        if(indent - lastIndent > 1) {
          throw({message: 'Too much indentation', lineNum: lineNum});
        }
        if(lastNode.content) throw({message: 'Elements cannot have both text and children elements', lineNum: lineNum});
        stack.push(lastNode);
      } else if(indent < lastIndent) {
        _.times(lastIndent - indent, function() {
          stack.pop();
        });
      }
      var node = self.parseLine(line);
      // Add the generated node to its parent
      var parent = _.last(stack);
      parent.children.push(node);
      // If we have a multiTags hierarchy -> use rightmost child as lastNode
      var child = node;
      while(child.children && child.children[0]) {
        child = child.children[0];
      };
      lastIndent = indent;
      lastNode = child;
      if(node.slurpy) {
        slurpyMode = true;
      }
    });
    // Squash alternatives into their respective statements
    var previous;
    var alternativeStack = [];
    inverseBreadth(top, function(node) {
      // Collect alternatives from bottom to top
      if(node.type == 'Alternative') {
        alternativeStack.push(node);
      // Attach to next statement we encounter
      } else {
        if(alternativeStack.length) {
          node.alternatives = alternativeStack.reverse();
          alternativeStack = [];
        }
      }
      previous = node;
    });
    // Remove alternatives
    return cleanTree(top);
  },

  // Convert line into tree node
  parseLine: function(line) {
    // Statement
    if(line.indexOf('{{') == 0) {
      return this.parseStatement(line);
    // Text
    } else if(line.indexOf('|') == 0) {
      return {
        type: 'Text',
        content: line.slice(2) + '\n'
      };
    // Tag definition
    } else {
      return this.parseTag(line);
    }
  },

  // Parse all major components from a tag, including inline content
  parseTag: function(line) {
    var self = this;
    var m = line.match(/(([\w-#\.]+\s*>\s*)*)([\w-]+)?(#([\w-]+))?((\.[\w-]+)*)(\((.*)\))?(\+)?( (.*))?/);
    var multiTags = m[1];
    var tag = m[3] || 'div';
    var id = m[5];
    var classes = m[6] ? m[6].slice(1).split('.') : [];
    var inParens = m[9];
    var plus = m[10];
    var content = m[12];
    var attributes = {};
    var statements = [];
    // Parse attributes
    if(inParens) {
      var attrDefinitions;
      var i = inParens.indexOf('{{');
      if(i > -1) {
        attrDefinitions = inParens.slice(0, i);
        var statementDefinitions = inParens.slice(i, inParens.length);
        statements = self.parseMicroStatements(statementDefinitions);
      } else {
        attrDefinitions = inParens;
      }
      _.each(_.scan(attrDefinitions, /([\w-]+?)\s*=\s*(['"])(.+?)\2\s?/g), function(m) { // '
        var key = m[1];
        var value = m[3];
        attributes[key] = self.parseAttribute(value);
      });
      // _.each(_.scan(statementDefinitions, /{{(\w+)\s+(\w+)\s+(\w+)}}/g), function(m) {
      //   statements[m[2]] = m[3];
      // });
    }
    // Make AST node
    var tag = {
      type: 'HTMLTag',
      tag: tag,
      id: id,
      classes: classes,
      attributes: attributes,
      content: content,
      statements: statements,
      slurpy: !!plus,
      children: []
    };
    // Build a hierarchy from multi tags
    if(multiTags) {
      // Remove trailing '>' before splitting
      multiTags = multiTags.trim().slice(0, -1);
      var tags = _.map(multiTags.split('>'), function(t) {
        return self.parseTag(t.trim());
      });
      var top = tags.shift();
      var prev = top;
      _.each(tags, function(t) {
        prev.children.push(t);
        prev = t;
      });
      prev.children.push(tag);
      return top;
    } else {
      return tag;
    }
  },

  parseAttribute: function(str) {
    var attr = {type: 'static', value: str};
    // Dynamic attribute
    if(str[0] == '{') {
      // Trim off curlies
      var expr = str.slice(1, -1);
      // One time only binding?
      var oneTime = (expr[0] == ':');
      if(oneTime) expr = expr.slice(1);
      // CSS class chooser?
      var m;
      if(m = expr.match(/((\w+):\s*([^,]+))+/g)) {
        var classes = {};
        _.each(m, function(match) {
          var parts = match.split(':');
          var className = parts[0];
          var expr = parts[1].trim();
          // Trim off parens from compound expressions
          if(expr[0] == '(') expr = expr.slice(1, -1);
          classes[className] = expr;
        });
        //XXX oneTimeOnly should exist for every expression
        return {type: 'CSS', classes: classes, oneTimeOnly: oneTime};
      } else {
        return {type: 'dynamic', expression: expr, oneTimeOnly: oneTime};
      }
    // Static attribute
    } else {
      return {type: 'static', value: str};
    }
  },

  parseMicroStatements: function(string) {
    var statements = [];
    _.each(_.scan(string, /{{(\w+)\s+(.*?)}}/g), function(m) {
      var statement = m[1];
      var rest = m[2];
      // as
      if(statement == 'as') {
        statements.push({statement: 'as', varName: rest});
      
      // on
      } else if(statement == 'on') {
        var m = rest.match(/(\w+)\s+(\$?[\w\.]+)(\((.+)\))?/);
        var args = m[4] && _.map(m[4].split(','), function(arg) { return arg.trim() });
        statements.push({statement: 'on', event: m[1], method: m[2], args: args});
      
      } else {
        throw('Unknown statement: ' + statement);
      }
    });
    return statements;
  },

  // Takes an statement line and creates the approriate node
  parseStatement: function(line) {
    var m;
    var out;
    // if
    if(m = line.match(/{{if\s+(.+)}}/)) {
      out = {
        type: 'Statement',
        keyword: 'if',
        expr: m[1]
      };
    // for
    } else if(m = line.match(/{{for\s+((\w+)\s+in\s+)?(.+)}}/)) {
      out = {
        type: 'Statement',
        keyword: 'for',
        itemPath: m[2],
        itemsPath: m[3]
      };
    // view
    } else if(m = line.match(/{{view\s+(\w+)?(\(.*\))?}}/)) {
      var parens = m[2];
      var args = parens ? _.map(parens.slice(1, -1).split(','), function(argument) {
        return argument.replace(/\s/g, '');
      }) : [];
      out = {
        type: 'Statement',
        keyword: 'view',
        viewModel: m[1],
        arguments: args
      };
    // import
    } else if(m = line.match(/{{import\s+([\w\/]+)(\(.*\))?}}/)) {
      var parens = m[2];
      var args = {};
      if(parens) {
        _.each(parens.slice(1, -1).split(','), function(argument) {
          var pair = argument.replace(/\s/g, '').split(':');
          var vari = pair[0];
          var expr = pair[1];
          args[vari] = expr;
        });
      }
      out = {
        type: 'Statement',
        keyword: 'import',
        templateName: m[1] + '.dcl',
        arguments: args
      };
    // content
    } else if(m = line.match(/{{content}}/)) {
      out = {
        type: 'Statement',
        keyword: 'content'
      };
    // client
    } else if(m = line.match(/{{client}}/)) {
      out = {
        type: 'Statement',
        keyword: 'client'
      };
    // route
    } else if(m = line.match(/{{route (.*)}}/)) {
      out = {
        type: 'Statement',
        keyword: 'route',
        expr: m[1]
      };
    // =>
    } else if(m = line.match(/{{=>\s*(.*)}}/)) {
      out = {
        type: 'Alternative',
        expr: m[1]
      };
    } else {
      throw('Unknown statement: ' + line);
    }
    out.children = [];
    return out;
  }
};

module.exports = Parser;