├── spec ├── preserve_spec.rb ├── fixtures │ └── sample.haml ├── plain_spec.rb ├── doctype_spec.rb ├── filter_spec.rb ├── whitespace_spec.rb ├── haml_comment_spec.rb ├── silent_script_spec.rb ├── spec_helper.rb ├── indent_spec.rb ├── comment_spec.rb ├── multiline_spec.rb ├── html_style_attribute_spec.rb ├── element_spec.rb ├── formatter_spec.rb ├── script_spec.rb └── attribute_spec.rb ├── .rspec ├── lib ├── haml_parser │ ├── version.rb │ ├── error.rb │ ├── utils.rb │ ├── ruby_multiline.rb │ ├── cli.rb │ ├── filter_parser.rb │ ├── line_parser.rb │ ├── script_parser.rb │ ├── indent_tracker.rb │ ├── ast.rb │ ├── element_parser.rb │ └── parser.rb └── haml_parser.rb ├── exe └── haml_parser ├── Gemfile ├── bin ├── setup └── console ├── .gitignore ├── .travis.yml ├── benchmark └── parse.rb ├── .rubocop_todo.yml ├── CHANGELOG.md ├── Rakefile ├── .rubocop.yml ├── LICENSE.txt ├── haml_parser.gemspec └── README.md /spec/preserve_spec.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /lib/haml_parser/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module HamlParser 3 | VERSION = '0.4.1' 4 | end 5 | -------------------------------------------------------------------------------- /exe/haml_parser: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | require 'haml_parser/cli' 4 | 5 | HamlParser::CLI.start(ARGV) 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source 'https://rubygems.org' 3 | 4 | # Specify your gem's dependencies in haml_parser.gemspec 5 | gemspec 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /lib/haml_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative 'haml_parser/version' 3 | 4 | module HamlParser 5 | # Your code goes here... 6 | end 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | 11 | spec/examples.txt 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | rvm: 4 | - 2.1.10 5 | - 2.2.5 6 | - 2.3.1 7 | - ruby-head 8 | after_script: 9 | - bundle exec rake benchmark 10 | matrix: 11 | allow_failures: 12 | - rvm: ruby-head 13 | -------------------------------------------------------------------------------- /lib/haml_parser/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module HamlParser 3 | class Error < StandardError 4 | attr_accessor :lineno 5 | 6 | def initialize(message, lineno) 7 | super(message) 8 | @lineno = lineno 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/fixtures/sample.haml: -------------------------------------------------------------------------------- 1 | !!! 5 2 | %div{hello: 'world'}< 3 | hoge 4 | %div.foo#bar> fuga 5 | :javascript 6 | (function() { 7 | alert('hello'); 8 | })(); 9 | - if 1.even? 10 | 11 | = 'even' 12 | - else 13 | -# odd 14 | odd 15 | / 16 | %this 17 | is comment 18 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'haml_parser' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start 16 | -------------------------------------------------------------------------------- /spec/plain_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'spec_helper' 3 | 4 | RSpec.describe 'Plain parser' do 5 | it 'parses literally when prefixed with backslash' do 6 | ast = expect_single_ast('\= @title') 7 | expect(ast.text).to eq('= @title') 8 | end 9 | 10 | it 'raises error when text has children' do 11 | expect { parse(< 0 && scanner.scan_until(re) 9 | if scanner.matched == start 10 | depth += 1 11 | else 12 | depth -= 1 13 | end 14 | end 15 | depth 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | Metrics/AbcSize: 2 | Enabled: false 3 | 4 | Metrics/ClassLength: 5 | Enabled: false 6 | 7 | Metrics/CyclomaticComplexity: 8 | Enabled: false 9 | 10 | Metrics/LineLength: 11 | Enabled: false 12 | 13 | Metrics/MethodLength: 14 | Enabled: false 15 | 16 | Metrics/ModuleLength: 17 | Enabled: false 18 | 19 | Metrics/PerceivedComplexity: 20 | Enabled: false 21 | 22 | Style/Documentation: 23 | Enabled: false 24 | 25 | Style/PredicateName: 26 | Exclude: 27 | - 'lib/haml_parser/line_parser.rb' 28 | - 'lib/haml_parser/ruby_multiline.rb' 29 | -------------------------------------------------------------------------------- /spec/doctype_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'spec_helper' 3 | 4 | RSpec.describe 'doctype parser' do 5 | it 'parses doctype' do 6 | ast = expect_single_ast('!!!') 7 | expect(ast.doctype).to eq('') 8 | end 9 | 10 | it 'parses XML doctype' do 11 | ast = expect_single_ast('!!! xml') 12 | expect(ast.doctype).to eq('xml') 13 | end 14 | 15 | it 'raises error when doctype has children' do 16 | expect { parse(< 1 && text[-1] == ',' && 20 | !((text[-3, 2] =~ /\W\?/) || text[-3, 2] == '?\\') 21 | end 22 | private_class_method :is_ruby_multiline? 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'bundler/gem_tasks' 3 | 4 | task :default => [:spec, :rubocop] 5 | 6 | require 'rspec/core/rake_task' 7 | RSpec::Core::RakeTask.new(:spec) 8 | 9 | require 'rubocop/rake_task' 10 | RuboCop::RakeTask.new(:rubocop) 11 | 12 | desc 'Run all benchmarks' 13 | task :benchmark => ['benchmark:sample', 'benchmark:haml'] 14 | 15 | namespace :benchmark do 16 | desc "Run benchmark with haml_parser's sample" 17 | task :sample do 18 | sample_haml_path = File.join(__dir__, 'spec', 'fixtures', 'sample.haml') 19 | sh 'ruby', 'benchmark/parse.rb', sample_haml_path 20 | end 21 | 22 | desc "Run benchmark with haml's sample" 23 | task :haml do 24 | haml_gem = Gem::Specification.find_by_name('haml') 25 | standard_haml_path = File.join(haml_gem.gem_dir, 'test', 'templates', 'standard.haml') 26 | sh 'ruby', 'benchmark/parse.rb', standard_haml_path 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'spec_helper' 3 | 4 | RSpec.describe 'Filter parser' do 5 | it 'parses filters' do 6 | root = parse(< or <' do 5 | it 'parses nuke-outer-whitespace (>)' do 6 | ast = expect_single_ast('%span> hello') 7 | aggregate_failures do 8 | expect(ast.tag_name).to eq('span') 9 | expect(ast.nuke_outer_whitespace).to eq(true) 10 | expect(ast.nuke_inner_whitespace).to eq(false) 11 | expect(ast.oneline_child.text).to eq('hello') 12 | end 13 | end 14 | 15 | it 'parses nuke-inner-whitespace (>)' do 16 | ast = expect_single_ast(<<' do 30 | ast = expect_single_ast(<< 32 | hello 33 | HAML 34 | aggregate_failures do 35 | expect(ast.tag_name).to eq('pre') 36 | expect(ast.nuke_outer_whitespace).to eq(true) 37 | expect(ast.nuke_inner_whitespace).to eq(true) 38 | expect(ast.children[0].text).to eq('hello') 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/haml_comment_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'spec_helper' 3 | 4 | RSpec.describe 'Haml comment parser' do 5 | it 'parses Haml comment' do 6 | ast = expect_single_ast(< @indent_tracker.current_level 40 | # Start filter 41 | @indent_level = indent_level 42 | else 43 | # Empty filter 44 | @ast = nil 45 | return nil 46 | end 47 | 48 | text = line[@indent_level..-1] 49 | @ast.texts << text 50 | nil 51 | end 52 | 53 | def finish 54 | @ast 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /haml_parser.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # frozen_string_literal: true 3 | lib = File.expand_path('../lib', __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'haml_parser/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'haml_parser' 9 | spec.version = HamlParser::VERSION 10 | spec.authors = ['Kohei Suzuki'] 11 | spec.email = ['eagletmt@gmail.com'] 12 | 13 | spec.summary = 'Parser of Haml template language' 14 | spec.description = 'Parser of Haml template language' 15 | spec.homepage = 'https://github.com/eagletmt/haml_parser' 16 | spec.license = 'MIT' 17 | 18 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|benchmark)/}) } 19 | spec.bindir = 'exe' 20 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 21 | spec.require_paths = ['lib'] 22 | spec.required_ruby_version = '>= 2.0.0' 23 | 24 | spec.add_development_dependency 'benchmark-ips' 25 | spec.add_development_dependency 'bundler' 26 | spec.add_development_dependency 'coveralls' 27 | spec.add_development_dependency 'haml' 28 | spec.add_development_dependency 'pry' 29 | spec.add_development_dependency 'rake' 30 | spec.add_development_dependency 'rspec', '>= 3.3.0' 31 | spec.add_development_dependency 'rubocop' 32 | spec.add_development_dependency 'simplecov' 33 | end 34 | -------------------------------------------------------------------------------- /spec/silent_script_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'spec_helper' 3 | 4 | RSpec.describe 'Silent script parser' do 5 | it 'parses silent script' do 6 | ast = expect_single_ast(<, 53 | self_closing=false, 54 | nuke_inner_whitespace=false, 55 | nuke_outer_whitespace=false, 56 | object_ref=nil, 57 | filename="input.haml", 58 | lineno=1>]> 59 | ``` 60 | 61 | ## Development 62 | 63 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake false` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 64 | 65 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 66 | 67 | ## Contributing 68 | 69 | Bug reports and pull requests are welcome on GitHub at https://github.com/eagletmt/haml_parser. 70 | 71 | 72 | ## License 73 | 74 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 75 | -------------------------------------------------------------------------------- /lib/haml_parser/script_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative 'ast' 3 | require_relative 'error' 4 | require_relative 'ruby_multiline' 5 | 6 | module HamlParser 7 | class ScriptParser 8 | def initialize(line_parser) 9 | @line_parser = line_parser 10 | end 11 | 12 | def parse(text) 13 | case text[0] 14 | when '=', '~' 15 | parse_script(text) 16 | when '&' 17 | parse_sanitized(text) 18 | when '!' 19 | parse_unescape(text) 20 | else 21 | parse_text(text) 22 | end 23 | end 24 | 25 | private 26 | 27 | def parse_script(text) 28 | if text[1] == '=' 29 | create_node(Ast::Text) { |t| t.text = text[2..-1].strip } 30 | else 31 | node = create_node(Ast::Script) 32 | script = text[1..-1].lstrip 33 | if script.empty? 34 | syntax_error!('No Ruby code to evaluate') 35 | end 36 | node.script = [script, *RubyMultiline.read(@line_parser, script)].join("\n") 37 | node.preserve = text[0] == '~' 38 | node 39 | end 40 | end 41 | 42 | def parse_sanitized(text) 43 | if text.start_with?('&==') 44 | create_node(Ast::Text) { |t| t.text = text[3..-1].lstrip } 45 | elsif text[1] == '=' || text[1] == '~' 46 | node = create_node(Ast::Script) 47 | script = text[2..-1].lstrip 48 | if script.empty? 49 | syntax_error!('No Ruby code to evaluate') 50 | end 51 | node.script = [script, *RubyMultiline.read(@line_parser, script)].join("\n") 52 | node.preserve = text[1] == '~' 53 | node 54 | else 55 | create_node(Ast::Text) { |t| t.text = text[1..-1].strip } 56 | end 57 | end 58 | 59 | def parse_unescape(text) 60 | if text.start_with?('!==') 61 | create_node(Ast::Text) do |t| 62 | t.text = text[3..-1].lstrip 63 | t.escape_html = false 64 | end 65 | elsif text[1] == '=' || text[1] == '~' 66 | node = create_node(Ast::Script) 67 | node.escape_html = false 68 | script = text[2..-1].lstrip 69 | if script.empty? 70 | syntax_error!('No Ruby code to evaluate') 71 | end 72 | node.script = [script, *RubyMultiline.read(@line_parser, script)].join("\n") 73 | node.preserve = text[1] == '~' 74 | node 75 | else 76 | create_node(Ast::Text) do |t| 77 | t.text = text[1..-1].lstrip 78 | t.escape_html = false 79 | end 80 | end 81 | end 82 | 83 | def parse_text(text) 84 | text = text.lstrip 85 | if text.empty? 86 | nil 87 | else 88 | create_node(Ast::Text) { |t| t.text = text } 89 | end 90 | end 91 | 92 | def syntax_error!(message) 93 | raise Error.new(message, @line_parser.lineno) 94 | end 95 | 96 | def create_node(klass, &block) 97 | klass.new.tap do |node| 98 | node.filename = @line_parser.filename 99 | node.lineno = @line_parser.lineno 100 | if block 101 | block.call(node) 102 | end 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/haml_parser/indent_tracker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative 'error' 3 | 4 | module HamlParser 5 | class IndentTracker 6 | class IndentMismatch < Error 7 | attr_reader :current_level, :indent_levels 8 | 9 | def initialize(current_level, indent_levels, lineno) 10 | super("Unexpected indent level: #{current_level}: indent_level=#{indent_levels}", lineno) 11 | @current_level = current_level 12 | @indent_levels = indent_levels 13 | end 14 | end 15 | 16 | class InconsistentIndent < Error 17 | attr_reader :previous_size, :current_size 18 | 19 | def initialize(previous_size, current_size, lineno) 20 | super("Inconsistent indentation: #{current_size} spaces used for indentation, but the rest of the document was indented using #{previous_size} spaces.", lineno) 21 | @previous_size = previous_size 22 | @current_size = current_size 23 | end 24 | end 25 | 26 | class HardTabNotAllowed < Error 27 | def initialize(lineno) 28 | super('Indentation with hard tabs are not allowed :-p', lineno) 29 | end 30 | end 31 | 32 | def initialize(on_enter: nil, on_leave: nil) 33 | @indent_levels = [0] 34 | @on_enter = on_enter || lambda { |_level, _text| } 35 | @on_leave = on_leave || lambda { |_level, _text| } 36 | @comment_level = nil 37 | end 38 | 39 | def process(line, lineno) 40 | if line.start_with?("\t") 41 | raise HardTabNotAllowed.new(lineno) 42 | end 43 | indent, text = split(line) 44 | indent_level = indent.size 45 | 46 | unless text.empty? 47 | track(indent_level, text, lineno) 48 | end 49 | [text, indent] 50 | end 51 | 52 | def split(line) 53 | m = line.match(/\A( *)(.*)\z/) 54 | [m[1], m[2]] 55 | end 56 | 57 | def finish 58 | indent_leave(0, '', -1) 59 | end 60 | 61 | def current_level 62 | @indent_levels.last 63 | end 64 | 65 | def enter_comment! 66 | @comment_level = @indent_levels[-2] 67 | end 68 | 69 | def check_indent_level!(lineno) 70 | if @indent_levels.size >= 3 71 | previous_size = @indent_levels[-2] - @indent_levels[-3] 72 | current_size = @indent_levels[-1] - @indent_levels[-2] 73 | if previous_size != current_size 74 | raise InconsistentIndent.new(previous_size, current_size, lineno) 75 | end 76 | end 77 | end 78 | 79 | private 80 | 81 | def track(indent_level, text, lineno) 82 | if indent_level > @indent_levels.last 83 | indent_enter(indent_level, text, lineno) 84 | elsif indent_level < @indent_levels.last 85 | indent_leave(indent_level, text, lineno) 86 | end 87 | end 88 | 89 | def indent_enter(indent_level, text, _lineno) 90 | unless @comment_level 91 | @indent_levels.push(indent_level) 92 | @on_enter.call(indent_level, text) 93 | end 94 | end 95 | 96 | def indent_leave(indent_level, text, lineno) 97 | if @comment_level 98 | if indent_level <= @comment_level 99 | # finish comment mode 100 | @comment_level = nil 101 | else 102 | # still in comment 103 | return 104 | end 105 | end 106 | 107 | while indent_level < @indent_levels.last 108 | @indent_levels.pop 109 | @on_leave.call(indent_level, text) 110 | end 111 | 112 | if indent_level != @indent_levels.last 113 | raise IndentMismatch.new(indent_level, @indent_levels.dup, lineno) 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/haml_parser/ast.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module HamlParser 3 | module Ast 4 | module HasChildren 5 | def initialize(*) 6 | super 7 | self.children ||= [] 8 | end 9 | 10 | def <<(ast) 11 | self.children << ast 12 | end 13 | 14 | def to_h 15 | super.merge(children: children.map(&:to_h)) 16 | end 17 | end 18 | 19 | Root = Struct.new(:children) do 20 | include HasChildren 21 | 22 | def to_h 23 | super.merge(type: 'root') 24 | end 25 | end 26 | 27 | Doctype = Struct.new(:doctype, :filename, :lineno) do 28 | def to_h 29 | super.merge(type: 'doctype') 30 | end 31 | end 32 | 33 | Element = Struct.new( 34 | :children, 35 | :tag_name, 36 | :static_class, 37 | :static_id, 38 | :old_attributes, 39 | :new_attributes, 40 | :oneline_child, 41 | :self_closing, 42 | :nuke_inner_whitespace, 43 | :nuke_outer_whitespace, 44 | :object_ref, 45 | :filename, 46 | :lineno, 47 | ) do 48 | include HasChildren 49 | 50 | def initialize(*) 51 | super 52 | self.static_class ||= '' 53 | self.static_id ||= '' 54 | self.self_closing ||= false 55 | self.nuke_inner_whitespace ||= false 56 | self.nuke_outer_whitespace ||= false 57 | end 58 | 59 | def to_h 60 | super.merge( 61 | type: 'element', 62 | oneline_child: oneline_child && oneline_child.to_h, 63 | ) 64 | end 65 | 66 | # XXX: For compatibility 67 | def attributes 68 | attrs = old_attributes || '' 69 | if new_attributes 70 | if attrs.empty? 71 | attrs = new_attributes 72 | else 73 | attrs += ", #{new_attributes}" 74 | end 75 | end 76 | attrs 77 | end 78 | end 79 | 80 | Script = Struct.new( 81 | :children, 82 | :script, 83 | :keyword, 84 | :escape_html, 85 | :preserve, 86 | :filename, 87 | :lineno, 88 | ) do 89 | include HasChildren 90 | 91 | def initialize(*) 92 | super 93 | if escape_html.nil? 94 | self.escape_html = true 95 | end 96 | if preserve.nil? 97 | self.preserve = false 98 | end 99 | end 100 | 101 | def to_h 102 | super.merge(type: 'script') 103 | end 104 | end 105 | 106 | SilentScript = Struct.new(:children, :script, :keyword, :filename, :lineno) do 107 | include HasChildren 108 | 109 | def to_h 110 | super.merge(type: 'silent_script') 111 | end 112 | end 113 | 114 | HtmlComment = Struct.new(:children, :comment, :conditional, :filename, :lineno) do 115 | include HasChildren 116 | 117 | def initialize(*) 118 | super 119 | self.comment ||= '' 120 | self.conditional ||= '' 121 | end 122 | 123 | def to_h 124 | super.merge(type: 'html_comment') 125 | end 126 | end 127 | 128 | HamlComment = Struct.new(:children, :filename, :lineno) do 129 | include HasChildren 130 | 131 | def to_h 132 | super.merge(type: 'haml_comment') 133 | end 134 | end 135 | 136 | Text = Struct.new(:text, :escape_html, :filename, :lineno) do 137 | def initialize(*) 138 | super 139 | if escape_html.nil? 140 | self.escape_html = true 141 | end 142 | end 143 | 144 | def to_h 145 | super.merge(type: 'text') 146 | end 147 | end 148 | 149 | Filter = Struct.new(:name, :texts, :filename, :lineno) do 150 | def initialize(*) 151 | super 152 | self.texts ||= [] 153 | end 154 | 155 | def to_h 156 | super.merge(type: 'filter') 157 | end 158 | end 159 | 160 | Empty = Struct.new(:filename, :lineno) do 161 | def to_h 162 | super.merge(type: 'empty') 163 | end 164 | end 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /spec/html_style_attribute_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'spec_helper' 3 | 4 | RSpec.describe 'HTML-style attribute parser' do 5 | it 'parses simple values' do 6 | ast = expect_single_ast('%span(foo=1 bar=3) hello') 7 | aggregate_failures do 8 | expect(ast.tag_name).to eq('span') 9 | expect(ast.new_attributes).to eq('"foo" => 1,"bar" => 3,') 10 | expect(ast.oneline_child.text).to eq('hello') 11 | end 12 | end 13 | 14 | it 'parses variables' do 15 | ast = expect_single_ast('%span(foo=foo bar=3) hello') 16 | aggregate_failures do 17 | expect(ast.new_attributes).to eq('"foo" => foo,"bar" => 3,') 18 | end 19 | end 20 | 21 | it 'parses attributes with old syntax' do 22 | ast = expect_single_ast('%span(foo=foo){bar: 3} hello') 23 | aggregate_failures do 24 | expect(ast.new_attributes).to eq('"foo" => foo,') 25 | expect(ast.old_attributes).to eq('bar: 3') 26 | expect(ast.oneline_child.text).to eq('hello') 27 | end 28 | end 29 | 30 | it 'parses HTML-style multiline attribute list' do 31 | ast = expect_single_ast(< 1,\n\n"bar" => 3,}) 38 | end 39 | end 40 | 41 | it "doesn't parse extra parens" do 42 | ast = expect_single_ast('%span(foo=1)(bar=3) hello') 43 | aggregate_failures do 44 | expect(ast.new_attributes).to eq('"foo" => 1,') 45 | expect(ast.oneline_child.text).to eq('(bar=3) hello') 46 | end 47 | end 48 | 49 | it 'parses empty parens' do 50 | ast = expect_single_ast('%span()(bar=3) hello') 51 | aggregate_failures do 52 | expect(ast.new_attributes).to eq('') 53 | expect(ast.oneline_child.text).to eq('(bar=3) hello') 54 | end 55 | end 56 | 57 | it "doesn't skip spaces before attribute list" do 58 | ast = expect_single_ast('%span (hello)') 59 | aggregate_failures do 60 | expect(ast.tag_name).to eq('span') 61 | expect(ast.new_attributes).to be_nil 62 | expect(ast.oneline_child.text).to eq('(hello)') 63 | end 64 | end 65 | 66 | it 'parses single-quoted value' do 67 | ast = expect_single_ast('%span(foo=1 bar="baz") hello') 68 | aggregate_failures do 69 | expect(ast.new_attributes).to eq('"foo" => 1,"bar" => "baz",') 70 | end 71 | end 72 | 73 | it 'parses double-quoted value' do 74 | ast = expect_single_ast("%span(foo=1 bar='baz') hello") 75 | aggregate_failures do 76 | expect(ast.new_attributes).to eq('"foo" => 1,"bar" => "baz",') 77 | end 78 | end 79 | 80 | it 'parses key-only attribute' do 81 | ast = expect_single_ast('%span(foo bar=1) hello') 82 | aggregate_failures do 83 | expect(ast.new_attributes).to eq('"foo" => true,"bar" => 1,') 84 | end 85 | end 86 | 87 | it 'parses string interpolation in single-quote' do 88 | ast = expect_single_ast('%span(foo=1 bar="baz#{1 + 2}") hello') 89 | aggregate_failures do 90 | expect(ast.new_attributes).to eq('"foo" => 1,"bar" => "baz#{1 + 2}",') 91 | end 92 | end 93 | 94 | it 'parses string interpolation in double-quote' do 95 | ast = expect_single_ast('%span(foo=1 bar="baz#{1 + 2}") hello') 96 | aggregate_failures do 97 | expect(ast.new_attributes).to eq('"foo" => 1,"bar" => "baz#{1 + 2}",') 98 | end 99 | end 100 | 101 | it 'parses escaped single-quote' do 102 | ast = expect_single_ast(%q|%span(foo=1 bar='ba\'z') hello|) 103 | aggregate_failures do 104 | expect(ast.new_attributes).to eq(%q|"foo" => 1,"bar" => "ba\'z",|) 105 | end 106 | end 107 | 108 | it 'parses escaped double-quote' do 109 | ast = expect_single_ast('%span(foo=1 bar="ba\"z") hello') 110 | aggregate_failures do 111 | expect(ast.new_attributes).to eq('"foo" => 1,"bar" => "ba\"z",') 112 | end 113 | end 114 | 115 | it 'raises error when attributes list is unterminated' do 116 | expect { parse('%span(foo=1 bar=2') }.to raise_error(HamlParser::Error) 117 | end 118 | 119 | it 'raises error when key is not alnum' do 120 | expect { parse('%span(foo=1 3.14=3) hello') }.to raise_error(HamlParser::Error) 121 | end 122 | 123 | it 'raises error when value is missing' do 124 | expect { parse('%span(foo=1 bar=) hello') }.to raise_error(HamlParser::Error) 125 | end 126 | 127 | it 'raises error when quote is unterminated' do 128 | expect { parse('%span(foo=1 bar="baz) hello') }.to raise_error(HamlParser::Error) 129 | end 130 | 131 | it 'raises error when string interpolation is unterminated' do 132 | expect { parse('%span(foo=1 bar="ba#{1") hello') }.to raise_error(HamlParser::Error) 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /spec/element_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'spec_helper' 3 | 4 | RSpec.describe 'Element parser' do 5 | it 'parses one-line element' do 6 | ast = expect_single_ast('%span hello') 7 | aggregate_failures do 8 | expect(ast.tag_name).to eq('span') 9 | expect(ast.oneline_child.text).to eq('hello') 10 | expect(ast.children).to be_empty 11 | end 12 | end 13 | 14 | it 'parses multi-line element' do 15 | ast = expect_single_ast(< #{value},#{"\n" * line_count}" 147 | end 148 | attributes.join 149 | end 150 | 151 | def scan_key(scanner) 152 | scanner.scan(/[-:\w]+/).tap do |name| 153 | unless name 154 | syntax_error!('Invalid attribute list (missing attribute name)') 155 | end 156 | end 157 | end 158 | 159 | def scan_operator(scanner) 160 | scanner.skip(/=/) 161 | end 162 | 163 | def scan_value(scanner) 164 | quote = scanner.scan(/["']/) 165 | if quote 166 | scan_quoted_value(scanner, quote) 167 | else 168 | scan_variable_value(scanner) 169 | end 170 | end 171 | 172 | def scan_quoted_value(scanner, quote) 173 | re = /((?:\\.|\#(?!\{)|[^#{quote}\\#])*)(#{quote}|#\{)/ 174 | pos = scanner.pos 175 | loop do 176 | unless scanner.scan(re) 177 | syntax_error!('Invalid attribute list (mismatched quotation)') 178 | end 179 | if scanner[2] == quote 180 | break 181 | end 182 | depth = Utils.balance(scanner, '{', '}') 183 | if depth != 0 184 | syntax_error!('Invalid attribute list (mismatched interpolation)') 185 | end 186 | end 187 | str = scanner.string.byteslice(pos - 1..scanner.pos - 1) 188 | 189 | # Even if the quote is single, string interpolation is performed in Haml. 190 | str[0] = '"' 191 | str[-1] = '"' 192 | str 193 | end 194 | 195 | def scan_variable_value(scanner) 196 | scanner.scan(/(@@?|\$)?\w+/).tap do |var| 197 | unless var 198 | syntax_error!('Invalid attribute list (invalid variable name)') 199 | end 200 | end 201 | end 202 | 203 | def parse_object_ref(text) 204 | s = StringScanner.new(text) 205 | s.pos = 1 206 | depth = Utils.balance(s, '[', ']') 207 | if depth == 0 208 | [s.pre_match[1, s.pre_match.size - 1], s.rest] 209 | else 210 | syntax_error!('Unmatched brackets for object reference') 211 | end 212 | end 213 | 214 | def parse_nuke_whitespace(rest) 215 | m = rest.match(/\A(><|<>|[><])(.*)\z/) 216 | if m 217 | nuke_whitespace = m[1] 218 | [ 219 | nuke_whitespace.include?('<'), 220 | nuke_whitespace.include?('>'), 221 | m[2], 222 | ] 223 | else 224 | [false, false, rest] 225 | end 226 | end 227 | 228 | def parse_self_closing(rest) 229 | if rest[0] == '/' 230 | if rest.size > 1 231 | syntax_error!("Self-closing tags can't have content") 232 | end 233 | [true, ''] 234 | else 235 | [false, rest] 236 | end 237 | end 238 | 239 | def syntax_error!(message) 240 | raise Error.new(message, @line_parser.lineno) 241 | end 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /lib/haml_parser/parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative 'ast' 3 | require_relative 'element_parser' 4 | require_relative 'error' 5 | require_relative 'filter_parser' 6 | require_relative 'indent_tracker' 7 | require_relative 'line_parser' 8 | require_relative 'ruby_multiline' 9 | require_relative 'script_parser' 10 | require_relative 'utils' 11 | 12 | module HamlParser 13 | class Parser 14 | def initialize(options = {}) 15 | @filename = options[:filename] 16 | end 17 | 18 | def call(template_str) 19 | @ast = Ast::Root.new 20 | @stack = [] 21 | @line_parser = LineParser.new(@filename, template_str) 22 | @indent_tracker = IndentTracker.new(on_enter: method(:indent_enter), on_leave: method(:indent_leave)) 23 | @filter_parser = FilterParser.new(@indent_tracker) 24 | 25 | while @line_parser.has_next? 26 | in_filter = !@ast.is_a?(Ast::HamlComment) && @filter_parser.enabled? 27 | line = @line_parser.next_line(in_filter: in_filter) 28 | if in_filter 29 | ast = @filter_parser.append(line) 30 | if ast 31 | @ast << ast 32 | end 33 | end 34 | unless @filter_parser.enabled? 35 | line_count = line.count("\n") 36 | line.delete!("\n") 37 | parse_line(line) 38 | line_count.times do 39 | @ast << create_node(Ast::Empty) 40 | end 41 | end 42 | end 43 | 44 | ast = @filter_parser.finish 45 | if ast 46 | @ast << ast 47 | end 48 | @indent_tracker.finish 49 | @ast 50 | rescue Error => e 51 | if @filename && e.lineno 52 | e.backtrace.unshift "#{@filename}:#{e.lineno}" 53 | end 54 | raise e 55 | end 56 | 57 | private 58 | 59 | DOCTYPE_PREFIX = '!' 60 | ELEMENT_PREFIX = '%' 61 | COMMENT_PREFIX = '/' 62 | SILENT_SCRIPT_PREFIX = '-' 63 | DIV_ID_PREFIX = '#' 64 | DIV_CLASS_PREFIX = '.' 65 | FILTER_PREFIX = ':' 66 | ESCAPE_PREFIX = '\\' 67 | 68 | def parse_line(line) 69 | text, indent = @indent_tracker.process(line, @line_parser.lineno) 70 | 71 | if text.empty? 72 | @ast << create_node(Ast::Empty) 73 | return 74 | end 75 | 76 | if @ast.is_a?(Ast::HamlComment) 77 | @ast << create_node(Ast::Text) { |t| t.text = text } 78 | return 79 | end 80 | 81 | case text[0] 82 | when ESCAPE_PREFIX 83 | parse_plain(text[1..-1]) 84 | when ELEMENT_PREFIX 85 | parse_element(text) 86 | when DOCTYPE_PREFIX 87 | if text.start_with?('!!!') 88 | parse_doctype(text) 89 | else 90 | parse_script(text) 91 | end 92 | when COMMENT_PREFIX 93 | parse_comment(text) 94 | when SILENT_SCRIPT_PREFIX 95 | parse_silent_script(text) 96 | when DIV_ID_PREFIX, DIV_CLASS_PREFIX 97 | if text.start_with?('#{') 98 | parse_script(text) 99 | else 100 | parse_line("#{indent}%div#{text}") 101 | end 102 | when FILTER_PREFIX 103 | parse_filter(text) 104 | else 105 | parse_script(text) 106 | end 107 | end 108 | 109 | def parse_doctype(text) 110 | @ast << create_node(Ast::Doctype) { |d| d.doctype = text[3..-1].strip } 111 | end 112 | 113 | def parse_comment(text) 114 | text = text[1, text.size - 1].strip 115 | comment = create_node(Ast::HtmlComment) 116 | comment.comment = text 117 | if text[0] == '[' 118 | comment.conditional, rest = parse_conditional_comment(text) 119 | text.replace(rest) 120 | end 121 | @ast << comment 122 | end 123 | 124 | CONDITIONAL_COMMENT_REGEX = /[\[\]]/o 125 | 126 | def parse_conditional_comment(text) 127 | s = StringScanner.new(text[1..-1]) 128 | depth = Utils.balance(s, '[', ']') 129 | if depth == 0 130 | [s.pre_match, s.rest.lstrip] 131 | else 132 | syntax_error!('Unmatched brackets in conditional comment') 133 | end 134 | end 135 | 136 | def parse_plain(text) 137 | @ast << create_node(Ast::Text) { |t| t.text = text } 138 | end 139 | 140 | def parse_element(text) 141 | @ast << ElementParser.new(@line_parser).parse(text) 142 | end 143 | 144 | def parse_script(text) 145 | node = ScriptParser.new(@line_parser).parse(text) 146 | if node.is_a?(Ast::Script) 147 | node.keyword = block_keyword(node.script) 148 | end 149 | @ast << node 150 | end 151 | 152 | def parse_silent_script(text) 153 | if text.start_with?('-#') 154 | @ast << create_node(Ast::HamlComment) 155 | return 156 | end 157 | node = create_node(Ast::SilentScript) 158 | script = text[/\A- *(.*)\z/, 1] 159 | node.script = [script, *RubyMultiline.read(@line_parser, script)].join("\n") 160 | node.keyword = block_keyword(node.script) 161 | @ast << node 162 | end 163 | 164 | def parse_filter(text) 165 | filter_name = text[/\A#{FILTER_PREFIX}(\w+)\z/, 1] 166 | unless filter_name 167 | syntax_error!("Invalid filter name: #{text}") 168 | end 169 | @filter_parser.start(filter_name, @line_parser.filename, @line_parser.lineno) 170 | end 171 | 172 | def indent_enter(_, _text) 173 | empty_lines = [] 174 | while @ast.children.last.is_a?(Ast::Empty) 175 | empty_lines << @ast.children.pop 176 | end 177 | @stack.push(@ast) 178 | @ast = @ast.children.last 179 | case @ast 180 | when Ast::Text 181 | syntax_error!('nesting within plain text is illegal') 182 | when Ast::Doctype 183 | syntax_error!('nesting within a header command is illegal') 184 | when nil 185 | syntax_error!('Indenting at the beginning of the document is illegal') 186 | end 187 | @ast.children = empty_lines 188 | if @ast.is_a?(Ast::Element) && @ast.self_closing 189 | syntax_error!('Illegal nesting: nesting within a self-closing tag is illegal') 190 | end 191 | if @ast.is_a?(Ast::HtmlComment) && !@ast.comment.empty? 192 | syntax_error!('Illegal nesting: nesting within a html comment that already has content is illegal.') 193 | end 194 | if @ast.is_a?(Ast::HamlComment) 195 | @indent_tracker.enter_comment! 196 | else 197 | @indent_tracker.check_indent_level!(@line_parser.lineno) 198 | end 199 | nil 200 | end 201 | 202 | def indent_leave(_indent_level, _text) 203 | parent_ast = @stack.pop 204 | @ast = parent_ast 205 | nil 206 | end 207 | 208 | MID_BLOCK_KEYWORDS = %w[else elsif rescue ensure end when].freeze 209 | START_BLOCK_KEYWORDS = %w[if begin case unless].freeze 210 | # Try to parse assignments to block starters as best as possible 211 | START_BLOCK_KEYWORD_REGEX = /(?:\w+(?:,\s*\w+)*\s*=\s*)?(#{Regexp.union(START_BLOCK_KEYWORDS)})/ 212 | BLOCK_KEYWORD_REGEX = /^-?\s*(?:(#{Regexp.union(MID_BLOCK_KEYWORDS)})|#{START_BLOCK_KEYWORD_REGEX.source})\b/ 213 | 214 | def block_keyword(text) 215 | m = text.match(BLOCK_KEYWORD_REGEX) 216 | if m 217 | m[1] || m[2] 218 | end 219 | end 220 | 221 | def syntax_error!(message) 222 | raise Error.new(message, @line_parser.lineno) 223 | end 224 | 225 | def create_node(klass, &block) 226 | klass.new.tap do |node| 227 | node.filename = @line_parser.filename 228 | node.lineno = @line_parser.lineno 229 | if block 230 | yield(node) 231 | end 232 | end 233 | end 234 | end 235 | end 236 | --------------------------------------------------------------------------------