yegor256/pdd

View on GitHub
lib/pdd/source.rb

Summary

Maintainability
A
1 hr
Test Coverage
# Copyright (c) 2014-2024 Yegor Bugayenko
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the 'Software'), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

require 'digest/md5'
require 'net/http'
require 'json'
require 'shellwords'
require_relative '../pdd'
require_relative '../pdd/puzzle'

module PDD
  MARKERS = ["\x40todo", 'TODO:?'].freeze
  # Source.
  class Source
    # Ctor.
    # +file+:: Absolute file name with source code
    # +path+:: Path to show (without full file name)
    def initialize(file, path)
      @file = file
      @path = path
    end

    def match_markers(line)
      if line.downcase.include? 'todo'
        /[^\s]\x40todo/.match(line) do |_|
          raise Error, get_no_leading_space_error("\x40todo")
        end
        /\x40todo(?!\s+#)/.match(line) do |_|
          raise Error, get_no_puzzle_marker_error("\x40todo")
        end
        /\x40todo\s+#\s/.match(line) do |_|
          raise Error, get_space_after_hash_error("\x40todo")
        end
        /[^\s]TODO:?/.match(line) do |_|
          raise Error, get_no_leading_space_error('TODO')
        end
        /TODO(?=[:\s])(?!:?\s+#)/.match(line) do |_|
          raise Error, get_no_puzzle_marker_error('TODO')
        end
        /TODO:?\s+#\s/.match(line) do |_|
          raise Error, get_space_after_hash_error('TODO')
        end
        a = [%r{(.*(?:^|\s))(?:\x40todo|TODO:|TODO)\s+#([\w\-.:/]+)\s+(.+)}.match(line)]
        a.compact
      else
        []
      end
    end

    # Fetch all puzzles.
    def puzzles
      PDD.log.info "Reading #{@path} ..."
      puzzles = []
      lines = File.readlines(@file, encoding: 'UTF-8')
      lines.each_with_index do |line, idx|
        begin
          match_markers(line).each do |m|
            puzzles << puzzle(lines.drop(idx + 1), m, idx)
          end
        rescue Error, ArgumentError => e
          message = "#{e.class} at #{@path}:#{idx + 1}: #{e.message}"
          raise Error, message unless PDD.opts && PDD.opts['skip-errors']
        end
      end
      puzzles
    end

    private

    def get_no_leading_space_error(todo)
      "#{todo} must have a leading space to become \
a puzzle, as this page explains: https://github.com/cqfn/pdd#how-to-format"
    end

    def get_no_puzzle_marker_error(todo)
      "#{todo} found, but puzzle can't be parsed, \
most probably because #{todo} is not followed by a puzzle marker, \
as this page explains: https://github.com/cqfn/pdd#how-to-format"
    end

    def get_space_after_hash_error(todo)
      "#{todo} found, but there is an unexpected space \
after the hash sign, it should not be there, \
see https://github.com/cqfn/pdd#how-to-format"
    end

    # Fetch puzzle
    def puzzle(lines, match, idx)
      col_idx = match[0].length - match[0].lstrip.length
      tail = tail(lines, match[1], col_idx)
      body = "#{match[3]} #{tail.join(' ')}".gsub(/\s+/, ' ').strip
      body = body.chomp('*/-->').strip
      marker = marker(match[2])
      Puzzle.new(
        marker.merge(
          id: "#{marker[:ticket]}-#{Digest::MD5.hexdigest(body)[0..7]}",
          lines: "#{idx + 1}-#{idx + tail.size + 1}",
          body: body,
          file: @path
        ).merge(git(idx + 1))
      )
    end

    # Parse a marker.
    def marker(text)
      re = %r{([\w\-.]+)(?::(\d+)(?:(m|h)[a-z]*)?)?(?:/([A-Z]+))?}
      match = re.match(text)
      if match.nil?
        raise "Invalid puzzle marker \"#{text}\", most probably formatted \
against the rules explained here: https://github.com/cqfn/pdd#how-to-format"
      end
      {
        ticket: match[1],
        estimate: minutes(match[2], match[3]),
        role: match[4].nil? ? 'DEV' : match[4]
      }
    end

    # Parse minutes.
    def minutes(num, units)
      min = num.nil? ? 0 : Integer(num)
      min *= 60 if !units.nil? && units.start_with?('h')
      min
    end

    # Fetch puzzle tail (all lines after the first one)
    def tail(lines, prefix, start)
      return [] if lines.empty?
      prefix = " #{' ' * start}" if prefix.empty? # fallback to space indentation
      tail_prefix = puzzle_tail_prefix(lines, prefix)
      tail = lines
             .take_while { |t| puzzle_text?(t, tail_prefix, prefix) }
             .map do |t|
               content = t[tail_prefix.length, t.length]&.lstrip
               puzzle_empty_line?(content, '') ? '' : content
             end
      tail.pop if tail[-1].eql?('')
      tail
    end

    def puzzle_tail_prefix(lines, prefix)
      return prefix if lines.empty?
      i = 0
      while i < lines.length
        unless puzzle_empty_line?(lines[i], prefix)
          return lines[i].start_with?("#{prefix} ") ? "#{prefix} " : prefix
        end
        i += 1
      end
      prefix
    end

    def puzzle_text?(line, prefix, intro_prefix)
      return false unless match_markers(line).none?
      line.start_with?(prefix) || puzzle_empty_line?(line, intro_prefix)
    end

    def puzzle_empty_line?(line, prefix)
      return true if line.nil?
      line.start_with?(prefix) && line.gsub(prefix, '').chomp.strip.eql?('\\')
    end

    # @todo #75:30min Let's make it possible to fetch Subversion data
    #  in a similar way as we are doing with Git. We should also just
    #  skip it if it's not SVN.

    # Git information at the line
    def git(pos)
      dir = Shellwords.escape(File.dirname(@file))
      name = Shellwords.escape(File.basename(@file))
      git = "cd #{dir} && git"
      if `#{git} rev-parse --is-inside-work-tree 2>/dev/null`.strip == 'true'
        cmd = "#{git} blame -L #{pos},#{pos} --porcelain #{name}"
        login = `#{cmd}`.split("\n").map do |line|
          case line
          when /^author /
            [:author, line.sub(/^author /, '')]
          when /^author-mail [^@]+@[^.]+\..+/
            [:email, line.sub(/^author-mail <(.+)>$/, '\1')]
          when /^author-time /
            [
              :time,
              Time.at(
                line.sub(/^author-time ([0-9]+)$/, '\1').to_i
              ).utc.iso8601
            ]
          end
        end.compact.to_h
        add_github_login(login)
      else
        {}
      end
    end

    def add_github_login(info)
      login = find_github_login(info)
      info[:author] = "@#{login}" unless login.empty?
      info
    end

    def get_json(query)
      uri = URI.parse(query)
      http = Net::HTTP.new(uri.hostname, uri.port)
      http.use_ssl = uri.scheme == 'https'
      req = Net::HTTP::Get.new(uri.request_uri)
      req.set_content_type('application/json')
      res = http.request(req)
      JSON.parse res.body
    end

    def find_github_user(info)
      email, author = info.values_at(:email, :author)
      # if email is not defined, changes have not been committed
      return if email.nil?

      base_uri = 'https://api.github.com/search/users?per_page=1'
      query = base_uri + "&q=#{email}+in:email"
      json = get_json query
      # find user by name instead since users can make github email private
      unless json['total_count'].positive?
        return if author.nil?

        query = base_uri + "&q=#{author}+in:fullname"
        json = get_json query
      end
      json['items'].first
    end

    def find_github_login(info)
      user = find_github_user info
      user['login']
    rescue StandardError
      ''
    end
  end

  # Verbose Source.
  class VerboseSource
    # Ctor.
    # +file+:: Absolute file name with source code
    # +source+:: Instance of source
    def initialize(file, source)
      @file = file
      @source = source
    end

    # Fetch all puzzles.
    def puzzles
      @source.puzzles
    rescue Error => e
      raise Error, "#{@file}; #{e.message}"
    end
  end
end