diff --git a/05.wc/lib/wc_methods.rb b/05.wc/lib/wc_methods.rb new file mode 100644 index 0000000000..b506ddbf6d --- /dev/null +++ b/05.wc/lib/wc_methods.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'optparse' +require_relative './word_count' + +OPTION_STRING = 'lwc' + +OPTION_NAME_TO_WORD_COUNT_TYPE = OPTION_STRING.chars.zip(WordCount::TYPES).to_h.freeze + +def main(args) + displayed_items = parse_args(args) + + wc_paths = args.empty? ? [WordCount::Pathname.new] : args.map { WordCount::Pathname.new(_1) } + + word_count_types = WordCount.extract_types(displayed_items) + + output_format = displayed_output_format(displayed_items, wc_paths) + + results = + wc_paths.map { _1.word_count_result(word_count_types) } + .each { print_word_count_result(output_format, _1) } + + if wc_paths.size >= 2 + count_total = + word_count_types.to_h { [_1, 0] } + .merge!(*results.filter_map { _1[:count] }) { |_, total, count| total + count } + + print_word_count_result(output_format, { path: 'total', count: count_total, message: nil }) + end + + results.any? { _1[:message] } ? 1 : 0 +end + +def parse_args(args) + parsed_options = OptionParser.new.getopts(args, OPTION_STRING).transform_keys(OPTION_NAME_TO_WORD_COUNT_TYPE) + + # `wc [file ...]` == `wc -lwc [file ...]` + parsed_options.transform_values! { |_| true } unless parsed_options.value?(true) + + parsed_options[:path] = !args.empty? + + parsed_options.select { |_, val| val }.keys +end + +def displayed_output_format(displayed_items, wc_paths) + one_type_one_operand = WordCount.extract_types(displayed_items).size == 1 && wc_paths.size == 1 + + digit = one_type_one_operand ? 1 : adjust_digit(wc_paths) + + displayed_items.map { output_format_string(_1, digit) }.join(' ') +end + +def adjust_digit(wc_paths) + default_digit = wc_paths.any?(&:exist_non_regular_file?) ? 7 : 1 + + total_bytes_digit = wc_paths.sum(&:regular_file_size).to_s.size + + [default_digit, total_bytes_digit].max +end + +def output_format_string(displayed_item, digit) + case displayed_item + when *WordCount::TYPES + "%<#{displayed_item}>#{digit}d" + when :path + '%s' + else + raise ArgumentError, "displayed_item: allow only #{[*WordCount::TYPES, :path].map(&:inspect).join(', ')}" + end +end + +def print_word_count_result(output_format, result) + warn "wc: #{result[:path]}: #{result[:message]}" if result[:message] + + puts format(output_format, **result[:count], path: result[:path]) unless result[:count].nil? +end diff --git a/05.wc/lib/word_count.rb b/05.wc/lib/word_count.rb new file mode 100644 index 0000000000..0d4dc99534 --- /dev/null +++ b/05.wc/lib/word_count.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +module WordCount + TYPES = %i[newline word bytesize].freeze + + def extract_types(types) + TYPES & types + end + + module_function :extract_types +end + +class WordCount::Pathname + USE_FILETEST_MODULE_FUNCTIONS = %i[directory? file? readable? size].freeze + + private_constant :USE_FILETEST_MODULE_FUNCTIONS + + def initialize(path = nil) + @path = path.to_s + end + + def to_path + return '-' if @path.empty? + + @path + end + + def stdin? + to_path == '-' + end + + def inspect + "#<#{self.class}:#{to_path}>" + end + + def open(mode = 'r', perm = 0o0666, &block) + return block&.call($stdin) || $stdin if stdin? + + File.open(to_path, mode, perm, &block) + end + + def exist? + stdin? || FileTest.exist?(to_path) + end + + USE_FILETEST_MODULE_FUNCTIONS.each do |method| + define_method(method) { stdin? ? $stdin.stat.public_send(method) : FileTest.public_send(method, to_path) } + end + + def regular_file_size + file? ? size : 0 + end + + def exist_non_regular_file? + exist? && !file? + end + + def word_count_result(word_count_types = WordCount::TYPES) + path = @path.empty? ? 'standard input' : @path + + return { path:, count: nil, message: exist? ? 'Permission denied' : 'No such file or directory' } unless readable? + + return { path:, count: word_count_types.to_h { [_1, 0] }, message: 'Is a directory' } if directory? + + { path:, count: word_count(word_count_types), message: nil } + rescue Errno::EPERM => e + { path:, count: nil, message: e.message.partition(' @ ').first } + end + + private + + def word_count(word_count_types) + return open { _1.set_encoding('ASCII-8BIT').word_count(word_count_types) } unless file? && word_count_types.include?(:bytesize) + + return { bytesize: size } if word_count_types == %i[bytesize] + + counts = open { _1.set_encoding('ASCII-8BIT').word_count(word_count_types - %i[bytesize]) } + + { **counts, bytesize: size } + end +end + +module WordCount::IO + BUFFER_SIZE = 16 * 1024 + + private_constant :BUFFER_SIZE + + def word_count(word_count_types = WordCount::TYPES, bufsize: BUFFER_SIZE) + each_buffer(bufsize).inject(:<<).to_s.word_count(word_count_types) + end + + def each_buffer(limit = BUFFER_SIZE) + return to_enum(__callee__, limit) unless block_given? + + loop do + yield readpartial(limit) + rescue EOFError + break + end + + self + end +end + +module WordCount::String + def word_count(word_count_types = WordCount::TYPES) + word_count_types.to_h do |type| + case type + when *WordCount::TYPES + [type, __send__(type)] + else + raise ArgumentError, "word_count_type: allow only #{WordCount::TYPES.map(&:inspect).join(', ')}" + end + end + end + + private + + def newline + count("\n") + end + + def word + num = 0 + + split { num += 1 if _1.match?(/[[:graph:]]/) } + + num + end +end + +class IO + include WordCount::IO +end + +class String + include WordCount::String +end diff --git a/05.wc/wc.rb b/05.wc/wc.rb new file mode 100755 index 0000000000..e272b0508a --- /dev/null +++ b/05.wc/wc.rb @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative './lib/wc_methods' + +errno = main(ARGV) + +exit(errno)