├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .rubocop.yml ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── SECURITY.md ├── bin ├── console └── setup ├── config └── sus.rb ├── fixtures ├── compiler_test_helpers.rb ├── content.rb ├── standard_element.rb └── void_element.rb ├── lib └── phlex │ ├── compiler.rb │ └── compiler │ ├── elements.rb │ ├── formatter.rb │ ├── generators │ ├── content.rb │ └── element.rb │ ├── nodes │ ├── base.rb │ ├── call.rb │ ├── command.rb │ ├── fcall.rb │ ├── method_add_block.rb │ └── vcall.rb │ ├── optimizer.rb │ └── visitors │ ├── base.rb │ ├── file.rb │ ├── stable_scope.rb │ ├── statements.rb │ ├── view.rb │ └── view_method.rb ├── phlex-compiler.gemspec └── test ├── compilation ├── content.rb ├── standard_element.rb └── void_element.rb └── compiler └── formatter.rb /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | 7 | [*.yml] 8 | indent_style = space 9 | indent_size = 2 10 | 11 | [*.erb] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.md] 16 | indent_style = space 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [joeldrapper] 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: ['main'] 5 | pull_request: 6 | branches: ['main'] 7 | jobs: 8 | specs: 9 | strategy: 10 | matrix: 11 | os: ['ubuntu-latest', 'macos-latest'] 12 | ruby-version: ['2.7', '3.0', '3.1', 'head'] 13 | runs-on: ${{ matrix.os }} 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - name: Setup 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: ${{ matrix.ruby-version }} 21 | bundler-cache: false 22 | 23 | - name: Bundle Install 24 | run: bundle install 25 | 26 | - name: Tests 27 | run: bundle exec sus 28 | 29 | - name: Type-check 30 | run: bundle exec solargraph typecheck --level typed 31 | 32 | - name: Rubocop 33 | run: bundle exec rubocop 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /spec/examples.txt 10 | /spec/internal/**/log/* 11 | node_modules 12 | /docs/dist 13 | Gemfile.lock 14 | .rubocop-* 15 | .covered.db 16 | .DS_Store 17 | *.gem 18 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - "https://www.goodcop.style/rubocop.yml" 3 | - "https://www.goodcop.style/tabs.yml" 4 | 5 | AllCops: 6 | TargetRubyVersion: 2.7 7 | 8 | Style/PercentLiteralDelimiters: 9 | Enabled: false 10 | 11 | Layout/CaseIndentation: 12 | Enabled: false 13 | 14 | Style/StringConcatenation: 15 | Enabled: false 16 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | * Trolling, insulting or derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | * Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at joel@drapper.me. All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 5 | 6 | gemspec 7 | 8 | gem "phlex", github: "joeldrapper/phlex" 9 | gem "capybara" 10 | gem "rubocop" 11 | gem "solargraph" 12 | gem "sus" 13 | gem "syntax_suggest" 14 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Joel Drapper 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `phlex-compiler` 2 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | If you found a possible security vulnerability in Phlex, please email security@phlex.fun. 6 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "phlex" 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(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | bundle exec sus 3 | -------------------------------------------------------------------------------- /config/sus.rb: -------------------------------------------------------------------------------- 1 | require "phlex/compiler" 2 | -------------------------------------------------------------------------------- /fixtures/compiler_test_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CompilerTestHelpers 4 | # @return Array 5 | def compile(view) 6 | @compiler = Phlex::Compiler.new(view) 7 | output = [] 8 | 9 | mock(@compiler) do |m| 10 | m.before(:redefine) { output << _1 } 11 | end 12 | 13 | @compiler.call 14 | 15 | output.map! do |method| 16 | Phlex::Compiler::Formatter.format("", SyntaxTree.parse(method)) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /fixtures/content.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Fixtures 4 | module Content 5 | class BareString < Phlex::HTML 6 | def template 7 | h1 { "Hello" } 8 | end 9 | end 10 | 11 | class Symbol < Phlex::HTML 12 | def template 13 | h1 { :hello } 14 | end 15 | end 16 | 17 | class Float < Phlex::HTML 18 | def template 19 | h1 { 1.2 } 20 | end 21 | end 22 | 23 | class Integer < Phlex::HTML 24 | def template 25 | h1 { 1 } 26 | end 27 | end 28 | 29 | class Variable < Phlex::HTML 30 | def template 31 | greeting = "Hello" 32 | h1 { greeting } 33 | end 34 | end 35 | 36 | class InstanceVariable < Phlex::HTML 37 | def template 38 | h1 { @hello } 39 | end 40 | end 41 | 42 | class NestedTags < Phlex::HTML 43 | def template 44 | article { 45 | h1 { "Inside" } 46 | } 47 | end 48 | end 49 | 50 | class NonMutatingNestedContent < Phlex::HTML 51 | def template 52 | div { say_hello } 53 | end 54 | 55 | def say_hello 56 | "Hello" 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /fixtures/standard_element.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Fixtures 4 | module StandardElement 5 | class WithParens < Phlex::HTML 6 | def template 7 | h1() 8 | end 9 | end 10 | 11 | class WithoutParens < Phlex::HTML 12 | def template 13 | h1 14 | end 15 | end 16 | 17 | module WithAttributes 18 | class WithParens < Phlex::HTML 19 | def template 20 | h1(class: "font-bold") 21 | end 22 | end 23 | 24 | class WithoutParens < Phlex::HTML 25 | def template 26 | h1 class: "font-bold" 27 | end 28 | end 29 | end 30 | 31 | module WithBraceBlock 32 | class WithParens < Phlex::HTML 33 | def template 34 | h1() { "Hi" } 35 | end 36 | end 37 | 38 | class WithoutParens < Phlex::HTML 39 | def template 40 | h1 { "Hi" } 41 | end 42 | end 43 | 44 | class WithAttributes < Phlex::HTML 45 | def template 46 | h1(class: "font-bold") { "Hi" } 47 | end 48 | end 49 | end 50 | 51 | module WithDoBlock 52 | class WithParens < Phlex::HTML 53 | def template 54 | h1() do 55 | "Hi" 56 | end 57 | end 58 | end 59 | 60 | class WithoutParens < Phlex::HTML 61 | def template 62 | h1 do 63 | "Hi" 64 | end 65 | end 66 | end 67 | 68 | module WithAttributes 69 | class WithParens < Phlex::HTML 70 | def template 71 | h1(class: "font-bold") do 72 | "Hi" 73 | end 74 | end 75 | end 76 | 77 | class WithoutParens < Phlex::HTML 78 | def template 79 | h1 class: "font-bold" do 80 | "Hi" 81 | end 82 | end 83 | end 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /fixtures/void_element.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Fixtures 4 | module VoidElement 5 | class WithParens < Phlex::HTML 6 | def template 7 | img() 8 | end 9 | end 10 | 11 | class WithoutParens < Phlex::HTML 12 | def template 13 | img 14 | end 15 | end 16 | 17 | module WithAttributes 18 | class WithParens < Phlex::HTML 19 | def template 20 | img(class: "a b c") 21 | end 22 | end 23 | 24 | class WithoutParens < Phlex::HTML 25 | def template 26 | img class: "a b c" 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/phlex/compiler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "phlex" 4 | require "syntax_tree" 5 | 6 | module Phlex 7 | class Compiler 8 | Loader = Zeitwerk::Loader.new.tap do |loader| 9 | loader.push_dir("#{__dir__}/compiler", namespace: Phlex::Compiler) 10 | loader.inflector = Zeitwerk::GemInflector.new(__FILE__) 11 | loader.inflector.inflect "fcall" => "FCall", "vcall" => "VCall" 12 | loader.setup 13 | end 14 | 15 | def initialize(view) 16 | @view = view 17 | end 18 | 19 | attr_writer :scope 20 | 21 | def inspect 22 | "#{self.class.name} for #{@view.name} view class" 23 | end 24 | 25 | def call 26 | Visitors::File.new(self).visit(tree) 27 | end 28 | 29 | def tag_method?(method_name) 30 | (HTML::STANDARD_ELEMENTS.key?(method_name) || HTML::VOID_ELEMENTS.key?(method_name)) && !redefined?(method_name) 31 | end 32 | 33 | def redefined?(method_name) 34 | prototype = @view.allocate 35 | 36 | @view.instance_method(method_name).bind(prototype) != 37 | Phlex::HTML.instance_method(method_name).bind(prototype) 38 | end 39 | 40 | def redefine(method, line:) 41 | patch = scope + method + unscope 42 | eval(patch, Kernel.binding, file, (line - 1)) 43 | end 44 | 45 | def scope 46 | @scope.map do |scope| 47 | case scope 48 | in SyntaxTree::ModuleDeclaration then "module #{scope.constant.constant.value};" 49 | in SyntaxTree::ClassDeclaration then "class #{scope.constant.constant.value};" 50 | end 51 | end.join + "\n" 52 | end 53 | 54 | def unscope 55 | "; end" * @scope.size 56 | end 57 | 58 | def line 59 | location[1] 60 | end 61 | 62 | private 63 | 64 | def tree 65 | @tree ||= SyntaxTree.parse(source) 66 | end 67 | 68 | def source 69 | SyntaxTree.read(file) 70 | end 71 | 72 | def file 73 | location[0] 74 | end 75 | 76 | def location 77 | ::Module.const_source_location(@view.name) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/phlex/compiler/elements.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Phlex::Compiler::Elements 4 | module VCall 5 | def format(formatter) 6 | Phlex::Compiler::Generators::Element.new( 7 | Phlex::Compiler::Nodes::VCall.new(self), 8 | formatter: formatter 9 | ).call 10 | end 11 | end 12 | 13 | module FCall 14 | def format(formatter) 15 | Phlex::Compiler::Generators::Element.new( 16 | Phlex::Compiler::Nodes::FCall.new(self), 17 | formatter: formatter 18 | ).call 19 | end 20 | end 21 | 22 | module Command 23 | def format(formatter) 24 | Phlex::Compiler::Generators::Element.new( 25 | Phlex::Compiler::Nodes::Command.new(self), 26 | formatter: formatter 27 | ).call 28 | end 29 | end 30 | 31 | module MutatingMethodAddBlock 32 | def format(formatter) 33 | Phlex::Compiler::Generators::Element.new( 34 | Phlex::Compiler::Nodes::MethodAddBlock.new(self), 35 | formatter: formatter, 36 | mutating: true 37 | ).call 38 | end 39 | end 40 | 41 | module MethodAddBlock 42 | def format(formatter) 43 | Phlex::Compiler::Generators::Element.new( 44 | Phlex::Compiler::Nodes::MethodAddBlock.new(self), 45 | formatter: formatter 46 | ).call 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/phlex/compiler/formatter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Phlex 4 | class Compiler 5 | class Formatter < SyntaxTree::Formatter 6 | def genspace 7 | -> (n) { "\t" * (n / 2) } 8 | end 9 | 10 | def format(node, stackable: true) 11 | stack << node if stackable 12 | doc = node.format(self) 13 | stack.pop if stackable 14 | doc 15 | end 16 | 17 | def flush 18 | text "" if @open_string_append 19 | 20 | super 21 | end 22 | 23 | def breakable(*args, **kwargs) 24 | if !@texting && @open_string_append 25 | @broken = kwargs 26 | else 27 | super 28 | end 29 | end 30 | 31 | def chain_append(&block) 32 | @appending = true 33 | 34 | if @open_string_append 35 | text '" << ' 36 | elsif @open_chain_append 37 | text " << " 38 | else 39 | text "@_target << " 40 | end 41 | 42 | @open_string_append = false 43 | @open_chain_append = true 44 | 45 | yield(self) 46 | 47 | @appending = false 48 | end 49 | 50 | def append(&block) 51 | @appending = true 52 | 53 | unless @open_string_append 54 | if @open_chain_append 55 | text ' << "' 56 | else 57 | text '@_target << "' 58 | end 59 | 60 | @open_chain_append = false 61 | @open_string_append = true 62 | end 63 | 64 | yield(self) 65 | 66 | @appending = false 67 | end 68 | 69 | def text(value, ...) 70 | @texting = true 71 | 72 | unless @appending 73 | if @open_string_append 74 | super('"') 75 | @open_string_append = false 76 | end 77 | 78 | @open_chain_append = false 79 | 80 | breakable(**@broken) if @broken 81 | end 82 | 83 | @broken = false 84 | 85 | super 86 | 87 | @texting = false 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/phlex/compiler/generators/content.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Phlex 4 | class Compiler 5 | module Generators 6 | class Content 7 | def initialize(formatter, content:, mutating: false) 8 | @formatter = formatter 9 | @content = content 10 | @mutating = mutating 11 | end 12 | 13 | def call 14 | return if nil_value? 15 | return bare_string_value if bare_string_value? 16 | return symbol_value if symbol_value? 17 | return numeric_value if numeric_value? 18 | return variable_value if variable_value? 19 | 20 | unknown_value 21 | end 22 | 23 | private 24 | 25 | def nil_value? 26 | case @content 27 | in SyntaxTree::VarRef[value: SyntaxTree::Kw[value: "nil"]] 28 | true 29 | else 30 | false 31 | end 32 | end 33 | 34 | def bare_string_value? 35 | case @content 36 | in SyntaxTree::StringLiteral[parts: [SyntaxTree::TStringContent]] 37 | true 38 | else 39 | false 40 | end 41 | end 42 | 43 | def symbol_value? 44 | @content.is_a?(SyntaxTree::SymbolLiteral) 45 | end 46 | 47 | def numeric_value? 48 | @content.is_a?(SyntaxTree::Int) || @content.is_a?(SyntaxTree::FloatLiteral) 49 | end 50 | 51 | def variable_value? 52 | @content.is_a?(SyntaxTree::VarRef) 53 | end 54 | 55 | def bare_string_value 56 | @formatter.append do |f| 57 | f.text ERB::Util.html_escape( 58 | @content.parts.first.value 59 | ) 60 | end 61 | end 62 | 63 | def symbol_value 64 | @formatter.append do |f| 65 | f.text ERB::Util.html_escape( 66 | @content.value.value 67 | ) 68 | end 69 | end 70 | 71 | def numeric_value 72 | @formatter.append do |f| 73 | f.text ERB::Util.html_escape( 74 | @content.value 75 | ) 76 | end 77 | end 78 | 79 | def variable_value 80 | @formatter.chain_append do |f| 81 | f.text "ERB::Util.html_escape(" 82 | @content.format(f) 83 | f.text ")" 84 | end 85 | end 86 | 87 | def unknown_value 88 | @formatter.breakable(force: true) 89 | if @mutating 90 | @content.format(@formatter) 91 | else 92 | @formatter.text "yield_content {" 93 | @formatter.breakable(force: true) 94 | @content.format(@formatter) 95 | @formatter.breakable(force: true) 96 | @formatter.text "}" 97 | end 98 | @formatter.breakable(force: true) 99 | end 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/phlex/compiler/generators/element.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Phlex 4 | class Compiler 5 | module Generators 6 | class Element 7 | def initialize(node, formatter:, mutating: false) 8 | @node = node 9 | @formatter = formatter 10 | @mutating = mutating 11 | end 12 | 13 | def call 14 | @formatter.append do |f| 15 | f.text "<" 16 | f.text tag 17 | end 18 | 19 | if @node.arguments&.parts&.any? 20 | @formatter.chain_append do |f| 21 | f.text "_attributes(" 22 | @node.arguments.format(@formatter) 23 | f.text ")" 24 | end 25 | end 26 | 27 | @formatter.append do |f| 28 | f.text ">" 29 | end 30 | 31 | return if void? 32 | 33 | case @node.content 34 | in SyntaxTree::Statements[body: [c]] 35 | Content.new(@formatter, content: c, mutating: @mutating).call 36 | in nil 37 | nil 38 | else 39 | @node.content.format(@formatter) 40 | end 41 | 42 | @formatter.append do |f| 43 | f.text "" 46 | end 47 | end 48 | 49 | private 50 | 51 | def tag 52 | HTML::STANDARD_ELEMENTS[@node.name] || HTML::VOID_ELEMENTS[@node.name] 53 | end 54 | 55 | def void? 56 | HTML::VOID_ELEMENTS.key?(@node.name) 57 | end 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/phlex/compiler/nodes/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Phlex::Compiler::Nodes 4 | class Base 5 | def initialize(node) 6 | @node = node 7 | end 8 | 9 | attr_accessor :node 10 | 11 | def arguments 12 | nil 13 | end 14 | 15 | def content 16 | nil 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/phlex/compiler/nodes/call.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Phlex::Compiler::Nodes 4 | class Call < Base 5 | def name 6 | nil 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/phlex/compiler/nodes/command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Phlex::Compiler::Nodes 4 | class Command < Base 5 | def name 6 | @node.message.value.to_sym 7 | end 8 | 9 | def arguments 10 | @node.arguments 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/phlex/compiler/nodes/fcall.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Phlex::Compiler::Nodes 4 | class FCall < Base 5 | def name 6 | @node.value.value.to_sym 7 | end 8 | 9 | def arguments 10 | case @node.arguments 11 | in SyntaxTree::Args 12 | @node.arguments 13 | in SyntaxTree::ArgParen 14 | @node.arguments.arguments 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/phlex/compiler/nodes/method_add_block.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Phlex::Compiler::Nodes 4 | class MethodAddBlock < Base 5 | def name 6 | method_call.name 7 | end 8 | 9 | def arguments 10 | method_call.arguments 11 | end 12 | 13 | def method_call 14 | @method_call ||= case @node.call 15 | in SyntaxTree::FCall 16 | Phlex::Compiler::Nodes::FCall.new(@node.call) 17 | in SyntaxTree::Command 18 | Phlex::Compiler::Nodes::Command.new(@node.call) 19 | in SyntaxTree::Call 20 | Phlex::Compiler::Nodes::Call.new(@node.call) 21 | end 22 | end 23 | 24 | def content 25 | case @node.block 26 | in SyntaxTree::BraceBlock 27 | @node.block.statements 28 | in SyntaxTree::DoBlock 29 | @node.block.bodystmt.statements 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/phlex/compiler/nodes/vcall.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Phlex::Compiler::Nodes 4 | class VCall < Base 5 | def name 6 | @node.value.value.to_sym 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/phlex/compiler/optimizer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Phlex::Compiler 4 | class Optimizer 5 | def initialize(node, compiler:) 6 | @node = node 7 | @compiler = compiler 8 | end 9 | 10 | def call 11 | return optimize_element if optimize_element? 12 | 13 | false 14 | end 15 | 16 | private 17 | 18 | def optimize_element 19 | case @node 20 | in Nodes::VCall 21 | @node.node.extend(Phlex::Compiler::Elements::VCall) 22 | in Nodes::FCall 23 | @node.node.extend(Phlex::Compiler::Elements::FCall) 24 | in Nodes::Command 25 | @node.node.extend(Phlex::Compiler::Elements::Command) 26 | in Nodes::MethodAddBlock 27 | optimize_add_method_block_element 28 | end 29 | 30 | true 31 | end 32 | 33 | def optimize_add_method_block_element 34 | visitor = Phlex::Compiler::Visitors::Statements.new(@compiler) 35 | visitor.visit(@node.content) 36 | 37 | if visitor.mutating? 38 | @node.node.extend(Phlex::Compiler::Elements::MutatingMethodAddBlock) 39 | else 40 | @node.node.extend(Phlex::Compiler::Elements::MethodAddBlock) 41 | end 42 | 43 | Phlex::Compiler::Visitors::ViewMethod.new(@compiler).visit(@node.content) 44 | end 45 | 46 | def optimize_element? 47 | element? && !redefined? 48 | end 49 | 50 | def element? 51 | standard_element? || void_element? 52 | end 53 | 54 | def redefined? 55 | @compiler.redefined?(@node.name) 56 | end 57 | 58 | def standard_element? 59 | Phlex::HTML::STANDARD_ELEMENTS.key?(@node.name) 60 | end 61 | 62 | def void_element? 63 | Phlex::HTML::VOID_ELEMENTS.key?(@node.name) 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/phlex/compiler/visitors/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Phlex::Compiler::Visitors 4 | class Base < SyntaxTree::Visitor 5 | def initialize(compiler = nil) 6 | @compiler = compiler 7 | end 8 | 9 | private 10 | 11 | def format(node) 12 | Phlex::Compiler::Formatter.format("", node) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/phlex/compiler/visitors/file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Phlex::Compiler::Visitors 4 | class File < Base 5 | def initialize(compiler) 6 | @scope = [] 7 | super 8 | end 9 | 10 | visit_method def visit_class(node) 11 | @scope.push(node) 12 | 13 | if node.location.start_line == @compiler.line 14 | @compiler.scope = @scope 15 | View.new(@compiler).visit_all(node.child_nodes) 16 | else 17 | super 18 | end 19 | 20 | @scope.pop 21 | end 22 | 23 | visit_method def visit_module(node) 24 | @scope.push(node) 25 | super 26 | @scope.pop 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/phlex/compiler/visitors/stable_scope.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A mixin for visitors that stops them from visiting other scopes. 4 | 5 | module Phlex::Compiler::Visitors::StableScope 6 | def visit_class(node) 7 | nil 8 | end 9 | 10 | def visit_module(node) 11 | nil 12 | end 13 | 14 | def visit_brace_block(node) 15 | nil 16 | end 17 | 18 | def visit_do_block(node) 19 | nil 20 | end 21 | 22 | def visit_method_add_block(node) 23 | node = Phlex::Compiler::Nodes::MethodAddBlock.new(node) 24 | if node.method_call.name == :render 25 | visit(node.content) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/phlex/compiler/visitors/statements.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Phlex::Compiler::Visitors 4 | class Statements < Base 5 | MUTATING_METHODS = [:raw, :whitespace, :comment, :text, :doctype] 6 | 7 | include StableScope 8 | 9 | def mutating? 10 | !!@mutating 11 | end 12 | 13 | visit_method def visit_vcall(node) 14 | check Phlex::Compiler::Nodes::VCall.new(node) 15 | end 16 | 17 | visit_method def visit_fcall(node) 18 | check Phlex::Compiler::Nodes::FCall.new(node) 19 | end 20 | 21 | visit_method def visit_command(node) 22 | check Phlex::Compiler::Nodes::Command.new(node) 23 | end 24 | 25 | visit_method def visit_method_add_block(node) 26 | check Phlex::Compiler::Nodes::MethodAddBlock.new(node) 27 | end 28 | 29 | private 30 | 31 | def check(node) 32 | @mutating = true if @compiler.tag_method?(node.name) 33 | @mutating = true if MUTATING_METHODS.include?(node.name) && !@compiler.redefined?(node.name) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/phlex/compiler/visitors/view.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Phlex::Compiler::Visitors 4 | class View < Base 5 | include StableScope 6 | 7 | visit_method def visit_def(node) 8 | visitor = ViewMethod.new(@compiler) 9 | visitor.visit_all(node.child_nodes) 10 | 11 | if visitor.optimized_something? 12 | @compiler.redefine( 13 | format(node), 14 | line: node.location.start_line 15 | ) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/phlex/compiler/visitors/view_method.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Phlex::Compiler::Visitors 4 | class ViewMethod < Base 5 | include StableScope 6 | 7 | def optimized_something? 8 | !!@optimized_something 9 | end 10 | 11 | visit_method def visit_method_add_block(node) 12 | return super if node.call.is_a?(SyntaxTree::Call) 13 | 14 | optimizer = Phlex::Compiler::Optimizer.new( 15 | Phlex::Compiler::Nodes::MethodAddBlock.new(node), 16 | compiler: @compiler 17 | ) 18 | 19 | if optimizer.call 20 | @optimized_something = true 21 | end 22 | 23 | super 24 | end 25 | 26 | visit_method def visit_vcall(node) 27 | optimizer = Phlex::Compiler::Optimizer.new( 28 | Phlex::Compiler::Nodes::VCall.new(node), 29 | compiler: @compiler 30 | ) 31 | 32 | if optimizer.call 33 | @optimized_something = true 34 | end 35 | end 36 | 37 | visit_method def visit_fcall(node) 38 | optimizer = Phlex::Compiler::Optimizer.new( 39 | Phlex::Compiler::Nodes::FCall.new(node), 40 | compiler: @compiler 41 | ) 42 | 43 | if optimizer.call 44 | @optimized_something = true 45 | end 46 | end 47 | 48 | visit_method def visit_command(node) 49 | optimizer = Phlex::Compiler::Optimizer.new( 50 | Phlex::Compiler::Nodes::Command.new(node), 51 | compiler: @compiler 52 | ) 53 | 54 | if optimizer.call 55 | @optimized_something = true 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /phlex-compiler.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "phlex-compiler" 5 | spec.version = "0.0.1" 6 | spec.authors = ["Joel Drapper"] 7 | spec.email = ["joel@drapper.me"] 8 | 9 | spec.summary = "A compiler for Phlex" 10 | spec.description = "A compiler for Phlex" 11 | spec.homepage = "https://github.com/joeldrapper/phlex-compiler" 12 | spec.license = "MIT" 13 | spec.required_ruby_version = ">= 2.7" 14 | 15 | spec.metadata["homepage_uri"] = spec.homepage 16 | spec.metadata["source_code_uri"] = "https://github.com/joeldrapper/phlex-compiler" 17 | spec.metadata["changelog_uri"] = "https://github.com/joeldrapper/phlex-compiler/releases" 18 | spec.metadata["funding_uri"] = "https://github.com/sponsors/joeldrapper" 19 | 20 | # Specify which files should be added to the gem when it is released. 21 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 22 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 23 | `git ls-files -z`.split("\x0").reject do |f| 24 | (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) 25 | end 26 | end 27 | spec.bindir = "exe" 28 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 29 | spec.require_paths = ["lib"] 30 | 31 | spec.add_dependency "phlex", "~> 1.0" 32 | spec.add_dependency "syntax_tree", "~> 3.6" 33 | 34 | spec.metadata["rubygems_mfa_required"] = "true" 35 | end 36 | -------------------------------------------------------------------------------- /test/compilation/content.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "compiler_test_helpers" 3 | require "content" 4 | 5 | describe Phlex::Compiler do 6 | include CompilerTestHelpers 7 | 8 | it "supports string content" do 9 | output = compile(Fixtures::Content::BareString) 10 | 11 | expect(output.first).to be == <<~RUBY 12 | def template 13 | @_target << "

Hello

" 14 | end 15 | RUBY 16 | end 17 | 18 | it "supports symbol content" do 19 | output = compile(Fixtures::Content::Symbol) 20 | 21 | expect(output.first).to be == <<~RUBY 22 | def template 23 | @_target << "

hello

" 24 | end 25 | RUBY 26 | end 27 | 28 | it "supports float content" do 29 | output = compile(Fixtures::Content::Float) 30 | 31 | expect(output.first).to be == <<~RUBY 32 | def template 33 | @_target << "

1.2

" 34 | end 35 | RUBY 36 | end 37 | 38 | it "supports integer content" do 39 | output = compile(Fixtures::Content::Integer) 40 | 41 | expect(output.first).to be == <<~RUBY 42 | def template 43 | @_target << "

1

" 44 | end 45 | RUBY 46 | end 47 | 48 | it "supports nested tags" do 49 | output = compile(Fixtures::Content::NestedTags) 50 | 51 | expect(output.first).to be == <<~RUBY 52 | def template 53 | @_target << "

Inside

" 54 | end 55 | RUBY 56 | end 57 | 58 | it "supports variable content" do 59 | output = compile(Fixtures::Content::Variable) 60 | 61 | expect(output.first).to be == <<~RUBY 62 | def template 63 | greeting = "Hello" 64 | @_target << "

" << ERB::Util.html_escape(greeting) << "

" 65 | end 66 | RUBY 67 | end 68 | 69 | it "supports instance variable content" do 70 | output = compile(Fixtures::Content::InstanceVariable) 71 | 72 | expect(output.first).to be == <<~RUBY 73 | def template 74 | @_target << "

" << ERB::Util.html_escape(@hello) << "

" 75 | end 76 | RUBY 77 | end 78 | 79 | it "supports non-mutating nested content" do 80 | output = compile(Fixtures::Content::NonMutatingNestedContent) 81 | 82 | expect(output.first).to be == <<~RUBY 83 | def template 84 | @_target << "
" 85 | yield_content { say_hello } 86 | @_target << "
" 87 | end 88 | RUBY 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /test/compilation/standard_element.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "standard_element" 4 | 5 | describe Phlex::Compiler do 6 | include CompilerTestHelpers 7 | 8 | it "supports standard elements" do 9 | with_parens = compile(Fixtures::StandardElement::WithParens) 10 | without_parens = compile(Fixtures::StandardElement::WithoutParens) 11 | 12 | expected = <<~RUBY 13 | def template 14 | @_target << "

" 15 | end 16 | RUBY 17 | 18 | expect(with_parens.first).to be == expected 19 | expect(without_parens.first).to be == expected 20 | end 21 | 22 | it "supports standard elements with attributes" do 23 | with_parens = compile(Fixtures::StandardElement::WithAttributes::WithParens) 24 | without_parens = compile(Fixtures::StandardElement::WithAttributes::WithoutParens) 25 | 26 | expected = <<~RUBY 27 | def template 28 | @_target << "" 29 | end 30 | RUBY 31 | 32 | expect(with_parens.first).to be == expected 33 | expect(without_parens.first).to be == expected 34 | end 35 | 36 | it "supports standard elements with brace block" do 37 | with_parens = compile(Fixtures::StandardElement::WithBraceBlock::WithParens) 38 | without_parens = compile(Fixtures::StandardElement::WithBraceBlock::WithoutParens) 39 | 40 | expected = <<~RUBY 41 | def template 42 | @_target << "

Hi

" 43 | end 44 | RUBY 45 | 46 | expect(with_parens.first).to be == expected 47 | expect(without_parens.first).to be == expected 48 | end 49 | 50 | it "supports standard elements with brace block and attributes" do 51 | output = compile(Fixtures::StandardElement::WithBraceBlock::WithAttributes) 52 | 53 | expect(output.first).to be == <<~RUBY 54 | def template 55 | @_target << "Hi" 56 | end 57 | RUBY 58 | end 59 | 60 | it "supports standard elements with do block" do 61 | with_parens = compile(Fixtures::StandardElement::WithDoBlock::WithParens) 62 | without_parens = compile(Fixtures::StandardElement::WithDoBlock::WithoutParens) 63 | 64 | expected = <<~RUBY 65 | def template 66 | @_target << "

Hi

" 67 | end 68 | RUBY 69 | 70 | expect(with_parens.first).to be == expected 71 | expect(without_parens.first).to be == expected 72 | end 73 | 74 | it "supports standard elements with do block with attributes" do 75 | with_parens = compile(Fixtures::StandardElement::WithDoBlock::WithAttributes::WithParens) 76 | without_parens = compile(Fixtures::StandardElement::WithDoBlock::WithAttributes::WithoutParens) 77 | 78 | expected = <<~RUBY 79 | def template 80 | @_target << "Hi" 81 | end 82 | RUBY 83 | 84 | expect(with_parens.first).to be == expected 85 | expect(without_parens.first).to be == expected 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/compilation/void_element.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "void_element" 4 | 5 | describe Phlex::Compiler do 6 | include CompilerTestHelpers 7 | 8 | it "supports void elements" do 9 | with_parens = compile(Fixtures::VoidElement::WithParens) 10 | without_parens = compile(Fixtures::VoidElement::WithoutParens) 11 | 12 | expected = <<~RUBY 13 | def template 14 | @_target << "" 15 | end 16 | RUBY 17 | 18 | expect(with_parens.first).to be == expected 19 | expect(without_parens.first).to be == expected 20 | end 21 | 22 | it "supports void elements with attributes" do 23 | with_parens = compile(Fixtures::VoidElement::WithAttributes::WithParens) 24 | without_parens = compile(Fixtures::VoidElement::WithAttributes::WithoutParens) 25 | 26 | expected = <<~RUBY 27 | def template 28 | @_target << "" 29 | end 30 | RUBY 31 | 32 | expect(with_parens.first).to be == expected 33 | expect(without_parens.first).to be == expected 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/compiler/formatter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Phlex::Compiler::Formatter do 4 | let(:formatter) { Phlex::Compiler::Formatter.new("") } 5 | let(:output) { formatter.tap(&:flush).output } 6 | 7 | with "append block" do 8 | it "buffers an append string" do 9 | formatter.append { _1.text "a" } 10 | formatter.append { _1.text "b" } 11 | formatter.append { _1.text "c" } 12 | 13 | expect(output).to be == %(@_target << "abc") 14 | end 15 | 16 | it "allows for breaks" do 17 | formatter.append { _1.text "a" } 18 | 19 | formatter.breakable(force: true) 20 | formatter.text "b" 21 | formatter.breakable(force: true) 22 | 23 | formatter.append { _1.text "c" } 24 | 25 | expect(output).to be == %(@_target << "a"\nb\n@_target << "c") 26 | end 27 | end 28 | 29 | with "chain append" do 30 | it "chains the appends" do 31 | formatter.chain_append { _1.text "a" } 32 | formatter.chain_append { _1.text "b" } 33 | formatter.chain_append { _1.text "c" } 34 | 35 | expect(output).to be == %(@_target << a << b << c) 36 | end 37 | 38 | it "allows for breaks" do 39 | formatter.chain_append { _1.text "a" } 40 | 41 | formatter.breakable(force: true) 42 | formatter.text "b" 43 | formatter.breakable(force: true) 44 | 45 | formatter.chain_append { _1.text "c" } 46 | 47 | expect(output).to be == %(@_target << a\nb\n@_target << c) 48 | end 49 | end 50 | 51 | with "mixed appends" do 52 | it "works" do 53 | formatter.chain_append { _1.text "a" } 54 | formatter.chain_append { _1.text "b" } 55 | formatter.chain_append { _1.text "c" } 56 | 57 | formatter.append { _1.text "d" } 58 | formatter.append { _1.text "e" } 59 | formatter.append { _1.text "f" } 60 | 61 | formatter.chain_append { _1.text "g" } 62 | formatter.chain_append { _1.text "h" } 63 | formatter.chain_append { _1.text "i" } 64 | 65 | expect(output).to be == %(@_target << a << b << c << "def" << g << h << i) 66 | end 67 | end 68 | end 69 | --------------------------------------------------------------------------------