smalruby/smalruby-editor

View on GitHub
app/models/source_code.rb

Summary

Maintainability
A
2 hrs
Test Coverage
# -*- coding: utf-8 -*-

require 'tempfile'
require 'open3'
require 'nkf'
require 'digest/sha2'
require 'bundler'
require 'smalruby_editor'
silence_warnings do
  require_relative 'concerns/ruby_to_block'
end

# ソースコードを表現するモデル
class SourceCode < ActiveRecord::Base
  include RubyToBlock

  validates :filename, presence: true
  validate :validate_filename
  validates :data, presence: true, allow_blank: true

  MAX_REMIX_COUNT = 1000

  # リミックス用のファイル名を生成する
  def self.make_remix_filename(home_dir, filename)
    home_dir = Pathname(home_dir).expand_path
    filename = filename.dup
    ext = filename.slice!(/\.rb(\.xml)?$/)
    filename.slice!(/(_remix(\d+)?)+$/)
    basename = "#{filename}_remix"
    MAX_REMIX_COUNT.times do |i|
      suffix = (i == 0 ? '' : sprintf('%02d', i + 1))
      remix_name = "#{basename}#{suffix}"
      if !home_dir.join("#{remix_name}.rb").exist? &&
          !home_dir.join("#{remix_name}.rb.xml").exist?
        return "#{remix_name}#{ext}"
      end
    end
    fail "reach max remix count...: #{filename}"
  end

  # シンタックスをチェックする
  def check_syntax
    _, stderr_str, status = *open3_capture3_ruby_c
    return [] if status.success?

    stderr_str.lines.each.with_object([]) { |line, res|
      if (md = /^.*:(\d+): (.*)$/.match(line))
        res << { row: md[1].to_i, column: 0, message: md[2] }
      elsif (md = /( +)\^$/.match(line))
        res[-1][:column] = md[1].length
      end
    }
  end

  # プログラムを実行する
  def run(path, env = {})
    _, stderr_str, status = *open3_capture3_run_program(path, env)
    return [] if status.success?

    parse_ruby_error_messages(stderr_str)
  end

  # ハッシュ値を計算する
  def digest
    Digest::SHA256.hexdigest(data)
  end

  # ソースコードの概要を取得する
  def summary
    res = {}
    if xml?
      res[:filename] = filename.sub(/\.xml\z/, '')

      doc = Nokogiri::HTML.parse(data)
      if (attr = doc.xpath('//character[1]/@costumes').first)
        costumes = attr.value.split(",").map { |s|
          s.sub(/^[^:]*:/, "")
        }
        if (attr = doc.xpath('//character[1]/@costume_index').first)
          costume_index = attr.value.to_i
        else
          costume_index = 0
        end
        res[:imageUrl] = "/smalruby/assets/#{costumes[costume_index]}"
      end
      text = doc.xpath('//block[@type="ruby_comment"][1]' +
                       '/field[@name="COMMENT"]/text()').first
      res[:title] = text.to_s if text
    else
      res[:filename] = filename
    end
    res[:title] = res[:filename] unless res[:title]
    res
  end

  # 指定した命令ブロックを含んでいるかどうかを返す
  #
  # @param [String|Regexp] type 命令ブロックの種類
  # @return [Boolean] 命令ブロックを含む場合はtrue、そうでない場合はfalse
  def include_block?(type)
    if xml?
      doc = Nokogiri::HTML.parse(data)
      if type.is_a?(Regexp)
        doc.xpath(%(//block[matches(@type, '#{type.source}')]),
                  MatchesXPathFunction.new).length > 0
      else
        doc.xpath(%(//block[@type="#{type}"])).length > 0
      end
    else
      false
    end
  end

  # ソースコードの種別がXML形式かどうかを返す
  def xml?
    !!(/\.xml\z/ =~ filename)
  end

  private

  class MatchesXPathFunction
    def matches(node_set, regex)
      node_set.select { |node|
        node.value =~ /#{regex}/
      }
    end
  end

  private_constant :MatchesXPathFunction

  def validate_filename
    if File.basename(filename) != filename
      errors.add(:filename, 'includes directory separator(s)')
    end
  end

  def ruby_cmd
    if SmalrubyEditor.osx?
      Pathname(Gem.bin_path('rsdl', 'rsdl'))
    else
      Pathname(RbConfig::CONFIG['RUBY_INSTALL_NAME'])
        .expand_path(RbConfig::CONFIG['bindir'])
    end
  end

  def open3_capture3_ruby_c
    tempfile = Tempfile.new('smalruby-editor')
    tempfile.write(data)
    path = tempfile.path
    tempfile.close

    Bundler.with_clean_env do
      Open3.capture3(*[ruby_cmd, '-c', path].map(&:to_s))
    end
  end

  def open3_capture3_run_program(path, env)
    Bundler.with_clean_env do
      Open3.capture3(env, *[ruby_cmd, path].map(&:to_s))
    end
  end

  def parse_ruby_error_messages(stderr_str)
    if SmalrubyEditor.windows?
      stderr_str = NKF.nkf("-w", stderr_str)
    end
    stderr_str.lines.each.with_object([]) { |line, res|
      if (md = /^\tfrom .*:(\d+):(in .*)$/.match(line))
        res << { row: md[1].to_i, column: 0, message: md[2] }
      elsif (md = /^.*:(\d+):(in .*)$/.match(line))
        res << { row: md[1].to_i, column: 0, message: md[2] }
      elsif (md = /( +)\^$/.match(line))
        res[-1][:column] = md[1].length
      else
        row = res[-1] ? res[-1][:row] : 0
        res << { row: row, column: 0, message: line.chomp }
      end
    }
  end
end