Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f329d43
Implement 'wc' in Ruby
blacktoad30 Mar 30, 2025
f61c5ec
refactor: Delete formfeed character
blacktoad30 Apr 2, 2025
3f3ff39
refactor: Separate responsibility for returning exit status
blacktoad30 Apr 3, 2025
eda1b68
refactor: Use singleton method of `File` class
blacktoad30 Apr 3, 2025
83141a5
refactor: Extract option constants and simplify argument parsing
blacktoad30 Apr 3, 2025
b010690
feat: Define `WcPathname` class
blacktoad30 May 1, 2025
de45d45
fix: Use `WcPathname` class
blacktoad30 May 1, 2025
d09bfa9
feat: Define `WordCount` module
blacktoad30 May 15, 2025
313f081
fix: Use `WordCount` module
blacktoad30 Jul 4, 2025
129503d
fix: No longer require `pathname`
blacktoad30 Jul 5, 2025
fe98d84
fix: Do not use exception handling for control flow
blacktoad30 Jul 4, 2025
5a1e75f
refactor: Integrate word count method
blacktoad30 Jul 16, 2025
14cccf1
refactor: Reduce unnecessary method argument
blacktoad30 Jul 16, 2025
7b9d052
refactor: Migrate `WcPathname` to `WordCount::Pathname`
blacktoad30 Jul 12, 2025
7a74cac
fix: Count words correctly
blacktoad30 Aug 12, 2025
b813989
fix: Output when there is no file operand
blacktoad30 Aug 17, 2025
a0c5c48
fix: Handling `Errno::EPERM` (Operation not permitted)
blacktoad30 Aug 25, 2025
3f63735
refactor: Rename `:byte` to `:bytesize`
blacktoad30 Sep 11, 2025
d416cda
refactor: Replace case statement with dedicated each count methods
blacktoad30 Sep 11, 2025
5e48a33
refactor: Extract format string generation into dedicated method
blacktoad30 Sep 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions 05.wc/lib/wc_methods.rb

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GitHub上には可視化されていないですが、Page breakがところどころはいっているようですね、これは意図的ですか?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

エディタでカーソルを移動する際に、目印のように使用していました。
削除いたします。

Original file line number Diff line number Diff line change
@@ -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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

displayed_items だと何が出力されたものに見えるのですが、おそらくここではオプションをパースした結果ですよね。 enabled_options とかのほうがわかりやすいのではと思ったんですが、どうでしょうか。


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
'%<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
138 changes: 138 additions & 0 deletions 05.wc/lib/word_count.rb
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +88 to +102

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

このあたりもかなり複雑な書き方をされていますが、単に String のメソッドである lengthbytes などで対応可能なものしかプラクティスとしては設定していません。
https://docs.ruby-lang.org/ja/latest/class/String.html

まずはこれらのメソッドで対応できないか、見てみてください。

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)]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

__send__ などメタプログラミング的なメソッドも極力避けたほうがよいです。やるとしたら public_send を使ってせめてpublicなインターフェースにしかアクセスできないようにする、などの方法をとったほうがよいです。
なんとなく、こういうかっこいい書き方をしたくなる気持ちは僕もわかるのですが、結局あとから見ると読みづらく、仕事としてのコードとしては適さないことが多いです。

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
Comment on lines +132 to +138

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

オープンクラスを活用してのクラスの書き換えは実務では基本的にやらないほうがよいです。
書き捨てのコードなどとりあえず手早く動かしたいコードならよいですが、こういった変更はあとから追いづらくてメンテナンスがしづらくなりがちです。
やるとしたらRefinementを検討するなどしたほうがよいです。
https://techracho.bpsinc.jp/hachi8833/2017_03_23/37464

ここではシンプルに別メソッドとして定義したほうがよいのではと思います。

8 changes: 8 additions & 0 deletions 05.wc/wc.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require_relative './lib/wc_methods'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

あまり今回はファイルを分ける意味がないかなと思ったんですが、どうでしょうか。
たしかに実行ファイルとライブラリとして使用するファイルを分けることはよくあるパターンではあります。とはいえ今回は簡単なスクリプトですし、1ファイルで全体を見渡せたほうがいいかなと思いました。

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

分けた理由は、定義した各々のメソッドの動作を irb -r ./lib/wc_methods.rb で都度確認できるようにしたかったからです。
また、今回は作成および提出できなかったものの、テストコードで使用することも考えていました。

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

遅くなりましたが、なるほどですね。
そういう場合も1ファイルでできるようなイディオム的な書き方もあるにはあります。

if __FILE__ == $0
  errno = main(ARGV)
  exit(errno)
end

参考: https://stackoverflow.com/questions/4687680/what-does-if-file-0-mean-in-ruby

一旦ここではこのままでよしとします。


errno = main(ARGV)

exit(errno)