Skip to content

Commit

Permalink
New parser.
Browse files Browse the repository at this point in the history
  • Loading branch information
judofyr authored and defunkt committed Mar 25, 2010
1 parent ddaa83e commit 9fa9fbe
Show file tree
Hide file tree
Showing 5 changed files with 289 additions and 131 deletions.
60 changes: 60 additions & 0 deletions lib/mustache/generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
class Mustache
class Generator
def initialize(options = {})
@options = options
end

def compile(exp)
"\"#{compile!(exp)}\""
end

def compile!(exp)
case exp.first
when :multi
exp[1..-1].map { |e| compile!(e) }.join
when :static
str(exp[1])
when :mustache
send("on_#{exp[1]}", *exp[2..-1])
else
raise "Unhandled exp: #{exp.first}"
end
end

def on_section(name, content)
code = compile(content)
ev(<<-compiled)
if v = ctx[#{name.to_sym.inspect}]
if v == true
#{code}
else
v = [v] unless v.is_a?(Array) # shortcut when passed non-array
v.map { |h| ctx.push(h); r = #{code}; ctx.pop; r }.join
end
end
compiled
end

def on_partial(name)
ev("ctx.partial(#{name.to_sym.inspect})")
end

def on_utag(name)
ev("ctx[#{name.to_sym.inspect}]")
end

def on_etag(name)
ev("CGI.escapeHTML(ctx[#{name.to_sym.inspect}].to_s)")
end

# An interpolation-friendly version of a string, for use within a
# Ruby string.
def ev(s)
"#\{#{s}}"
end

def str(s)
s.inspect[1..-2]
end
end
end
165 changes: 165 additions & 0 deletions lib/mustache/parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
require 'strscan'

class Mustache
class Parser
class SyntaxError < StandardError
def initialize(message, position)
@message = message
@lineno, @column, @line = position
@stripped_line = @line.strip
@stripped_column = @column - (@line.size - @line.lstrip.size)
end

def to_s
<<-EOF
#{@message}
Line #{@lineno}
#{@stripped_line}
#{' ' * @stripped_column}^
EOF
end
end

# After these types of tags, all whitespace will be skipped.
SKIP_WHITESPACE = [ '#', '/' ]

# These types of tags allow any content,
# the rest only allow \w+.
ANY_CONTENT = [ '!', '=' ]

attr_reader :scanner, :result
attr_writer :otag, :ctag

def initialize(options = {})
@options = {}
end

def regexp(thing)
/#{Regexp.escape(thing)}/
end

def otag
@otag ||= '{{'
end

def ctag
@ctag ||= '}}'
end

def compile(data)
# Keeps information about opened sections.
@sections = []
@result = [:multi]
@scanner = StringScanner.new(data)

until @scanner.eos?
scan_tags || scan_text
end

unless @sections.empty?
# We have parsed the whole file, but there's still opened sections.
type, pos, result = @sections.pop
error "Unclosed section #{type.inspect}", pos
end

@result
end

def scan_tags
return unless @scanner.scan(regexp(otag))

# Since {{= rewrites ctag, we store the ctag which should be used
# when parsing this specific tag.
current_ctag = self.ctag
type = @scanner.scan(/#|\/|=|!|<|>|&|\{/)
@scanner.skip(/\s*/)

if ANY_CONTENT.include?(type)
r = /\s*#{regexp(type)}?#{regexp(current_ctag)}/
content = scan_until_exclusive(r)
else
content = @scanner.scan(/\w*/)
end

error "Illegal content in tag" if content.empty?

case type
when '#'
block = [:multi]
@result << [:mustache, :section, content, block]
@sections << [content, position, @result]
@result = block
when '/'
section, pos, result = @sections.pop
@result = result

if section.nil?
error "Closing unopened #{content.inspect}"
elsif section != content
error "Unclosed section #{section.inspect}", pos
end
when '!'
# ignore comments
when '='
self.otag, self.ctag = content.split(' ', 2)
when '>', '<'
@result << [:mustache, :partial, content]
when '{', '&'
type = "}" if type == "{"
@result << [:mustache, :utag, content]
else
@result << [:mustache, :etag, content]
end

@scanner.skip(/\s+/)
@scanner.skip(regexp(type)) if type

unless close = @scanner.scan(regexp(current_ctag))
error "Unclosed tag"
end

@scanner.skip(/\s+/) if SKIP_WHITESPACE.include?(type)
end

def scan_text
text = scan_until_exclusive(regexp(otag))

if text.nil?
# Couldn't find any otag, which means the rest is just static text.
text = @scanner.rest
# Mark as done.
@scanner.clear
end

@result << [:static, text]
end

# Scans the string until the pattern is matched. Returns the substring
# *excluding* the end of the match, advancing the scan pointer to that
# location. If there is no match, nil is returned.
def scan_until_exclusive(regexp)
pos = @scanner.pos
if @scanner.scan_until(regexp)
@scanner.pos -= @scanner.matched.size
@scanner.pre_match[pos..-1]
end
end

# Returns [lineno, column, line]
def position
# The rest of the current line
rest = @scanner.check_until(/\n|\Z/).to_s.chomp

# What we have parsed so far
parsed = @scanner.string[0...@scanner.pos]

lines = parsed.split("\n")

[ lines.size, lines.last.size - 1, lines.last + rest ]
end

def error(message, pos = position)
raise SyntaxError.new(message, pos)
end
end
end
134 changes: 5 additions & 129 deletions lib/mustache/template.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
require 'cgi'

require 'mustache/parser'
require 'mustache/generator'

class Mustache
# A Template is a compiled version of a Mustache template.
#
Expand All @@ -9,27 +12,6 @@ class Mustache
#
# You shouldn't use this class directly.
class Template
# An UnclosedSection error is thrown when a {{# section }} is not
# closed.
#
# For example:
# {{# open }} blah {{/ close }}
class UnclosedSection < RuntimeError
attr_reader :message

# Report the line number of the offending unclosed section.
def initialize(source, matching_line, unclosed_section)
num = 0

source.split("\n").each_with_index do |line, i|
num = i + 1
break if line.strip == matching_line.strip
end

@message = "line #{num}: ##{unclosed_section.strip} is not closed"
end
end

# Expects a Mustache template as a string along with a template
# path, which it uses to find partials.
def initialize(source, template_path = '.', template_extension = 'mustache')
Expand Down Expand Up @@ -57,115 +39,9 @@ def render(context)
# Does the dirty work of transforming a Mustache template into an
# interpolation-friendly Ruby string.
def compile(src = @source)
"\"#{compile_sections(src)}\""
exp = Parser.new.compile(src)
Generator.new.compile(exp)
end
alias_method :to_s, :compile

# {{#sections}}okay{{/sections}}
#
# Sections can return true, false, or an enumerable.
# If true, the section is displayed.
# If false, the section is not displayed.
# If enumerable, the return value is iterated over (a `for` loop).
def compile_sections(src)
res = ""
while src =~ /#{otag}\#([^\}]*)#{ctag}\s*(.+?)#{otag}\/\1#{ctag}\s*/m
# $` = The string to the left of the last successful match
res << compile_tags($`)
name = $1.strip.to_sym.inspect
code = compile($2)
res << ev(<<-compiled)
if v = ctx[#{name}]
v = [v] unless v.is_a?(Array) # shortcut when passed non-array
v.map { |h| ctx.push(h); c = #{code}; ctx.pop; c }.join
end
compiled
# $' = The string to the right of the last successful match
src = $'
end
res << compile_tags(src)
end

# Find and replace all non-section tags.
# In particular we look for four types of tags:
# 1. Escaped variable tags - {{var}}
# 2. Unescaped variable tags - {{{var}}}
# 3. Comment variable tags - {{! comment}
# 4. Partial tags - {{> partial_name }}
def compile_tags(src)
res = ""
while src =~ /#{otag}(#|=|!|<|>|&|\{)?(.+?)\1?#{ctag}+/m
res << str($`)
case $1
when '#'
# Unclosed section - raise an error and
# report the line number
raise UnclosedSection.new(@source, $&, $2)
when '!'
# ignore comments
when '='
self.otag, self.ctag = $2.strip.split(' ', 2)
when '>', '<'
res << compile_partial($2.strip)
when '{', '&'
res << utag($2.strip)
else
res << etag($2.strip)
end
src = $'
end
res << str(src)
end

# Partials are basically a way to render views from inside other views.
def compile_partial(name)
name = name.to_s.to_sym.inspect
ev("ctx.partial(#{name})")
end

# Generate a temporary id, used when compiling code.
def tmpid
@tmpid += 1
end

# Get a (hopefully) literal version of an object, sans quotes
def str(s)
s.inspect[1..-2]
end

# {{ - opening tag delimiter
def otag
@otag ||= Regexp.escape('{{')
end

def otag=(tag)
@otag = Regexp.escape(tag)
end

# }} - closing tag delimiter
def ctag
@ctag ||= Regexp.escape('}}')
end

def ctag=(tag)
@ctag = Regexp.escape(tag)
end

# {{}} - an escaped tag
def etag(s)
ev("CGI.escapeHTML(ctx[#{s.strip.to_sym.inspect}].to_s)")
end

# {{{}}} - an unescaped tag
# Aliased as & - {{&name}}
def utag(s)
ev("ctx[#{s.strip.to_sym.inspect}]")
end

# An interpolation-friendly version of a string, for use within a
# Ruby string.
def ev(s)
"#\{#{s}}"
end
end
end
Loading

0 comments on commit 9fa9fbe

Please sign in to comment.