advent_of_ruby 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a387ce043139b5090d50ac18e76a0687f248fa75dc7e4ba098780f9e739b8687
4
+ data.tar.gz: e805613b4a188fafa0a0d17499893abb2edd26e5e33c2367fa717a9aa8a827c9
5
+ SHA512:
6
+ metadata.gz: a731b8c4449af8c8d89a542bc4979ed96edb8972802177709425e62ac8270c1c89fddf71f2c3239ebc430693fdc917b633f53f2006d59c5ce16e027530621203
7
+ data.tar.gz: 1c87883119e89399b0c7dc6c41a7472142c863d605bca45eafeb2d8b32a51fae11f77474655bef263d1e0696d9ad49d1e8bb84cb406dfb8cf1554390e7b99641
data/bin/arb ADDED
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "thor"
4
+ require_relative "../lib/arb/arb"
5
+
6
+ # The CLI application
7
+ class ArbApp < Thor
8
+ desc "bootstrap [YEAR] [DAY]", "Creates puzzle files for the next day, or " \
9
+ "for a given year and optionally day (defaults to Day 1)."
10
+ def bootstrap(year = nil, day = nil)
11
+ Arb::Cli.bootstrap(year:, day:)
12
+ end
13
+
14
+ # TODO add a [VARIANT] arg, so that additional versions of the same solution
15
+ # can co-exist as additional methods, e.g.:
16
+ #
17
+ # #part_1_first # for my initial attempt
18
+ # #part_1_concise # for a code-golf type solution
19
+ # #part_1_alt # for an alternative approach
20
+ desc "run [YEAR] [DAY]", "Runs the puzzle that's untracked in Git, or the " \
21
+ "puzzle of the given day and year. `-s` runs only specs, `-o` part 1, `-t` part 2."
22
+ method_option :spec, type: :boolean, aliases: "-s"
23
+ method_option :real_part_1, type: :boolean, aliases: "-o"
24
+ method_option :real_part_2, type: :boolean, aliases: "-t"
25
+ def run_day(year = nil, day = nil)
26
+ Arb::Cli.run_day(year:, day:, options:)
27
+ end
28
+
29
+ desc "progress", "Shows progress based on the number of your solutions " \
30
+ "committed in Git."
31
+ def progress
32
+ Arb::Cli.progress
33
+ end
34
+
35
+ # TODO extract/abstract run_day contents (without terminal output) to a class,
36
+ # then use it here to silently run each untracked puzzle before committing it,
37
+ # and if anything is incorrect then show confirmation prompt to user.
38
+ desc "commit", "Commits new and modified solutions to Git."
39
+ def commit
40
+ Arb::Cli.commit
41
+ end
42
+
43
+ default_task :run_day
44
+ end
45
+
46
+ ArbApp.start
@@ -0,0 +1,51 @@
1
+ module Arb
2
+ module Api
3
+ class Aoc
4
+ private attr_reader :connection
5
+
6
+ def initialize(cookie)
7
+ @connection = Faraday.new(
8
+ url: "https://adventofcode.com",
9
+ headers: {
10
+ "Cookie" => "session=#{cookie}",
11
+ "User-Agent" => "github.com/fpsvogel/advent_of_ruby by fps.vogel@gmail.com",
12
+ }
13
+ )
14
+ end
15
+
16
+ def input(year, day)
17
+ logged_in {
18
+ connection.get("/#{year}/day/#{day}/input")
19
+ }
20
+ end
21
+
22
+ def instructions(year, day)
23
+ logged_in {
24
+ connection.get("/#{year}/day/#{day}")
25
+ }
26
+ end
27
+
28
+ def submit(year, day, part, answer)
29
+ logged_in {
30
+ connection.post(
31
+ "/#{year}/day/#{day}/answer",
32
+ "level=#{part}&answer=#{answer}",
33
+ )
34
+ }
35
+ end
36
+
37
+ private
38
+
39
+ LOGGED_OUT_RESPONSE = "Puzzle inputs differ by user. Please log in to get your puzzle input.\n"
40
+
41
+ def logged_in(&block)
42
+ while (response = block.call.body) == LOGGED_OUT_RESPONSE
43
+ Config.refresh_aoc_cookie!
44
+ initialize(ENV["AOC_COOKIE"])
45
+ end
46
+
47
+ response
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,101 @@
1
+ module Arb
2
+ module Api
3
+ class OtherSolutions
4
+ UI_URI = "https://github.com"
5
+
6
+ private attr_reader :connection
7
+
8
+ PATHS = {
9
+ "eregon" => ->(year, day, part) {
10
+ if part == "1"
11
+ [
12
+ "adventofcode/tree/master/#{year}/#{day}.rb",
13
+ "adventofcode/tree/master/#{year}/#{day}a.rb",
14
+ ]
15
+ elsif part == "2"
16
+ ["adventofcode/tree/master/#{year}/#{day}b.rb"]
17
+ end
18
+ },
19
+ "gchan" => ->(year, day, part) {
20
+ ["advent-of-code-ruby/tree/main/#{year}/day-#{day.rjust(2, "0")}/day-#{day.rjust(2, "0")}-part-#{part}.rb"]
21
+ },
22
+ "ahorner" => ->(year, day, part) {
23
+ return [] if part == "1"
24
+ ["advent-of-code/tree/main/lib/#{year}/#{day.rjust(2, "0")}.rb"]
25
+ },
26
+ "ZogStriP" => ->(year, day, part) {
27
+ return [] if part == "1"
28
+
29
+ puzzle_name = File.read(Files::Instructions.path(year, day))
30
+ .match(/## --- Day \d\d?: (.+)(?= ---)/)
31
+ .captures
32
+ .first
33
+ .downcase
34
+ .gsub(" ", "_")
35
+
36
+ ["adventofcode/tree/master/#{year}/#{day.rjust(2, "0")}_#{puzzle_name}.rb"]
37
+ },
38
+ "erikw" => ->(year, day, part) {
39
+ ["advent-of-code-solutions/tree/main/#{year}/#{day.rjust(2, "0")}/part#{part}.rb"]
40
+ },
41
+ }
42
+
43
+ EDITS = {
44
+ "gchan" => ->(file_str) {
45
+ # Remove the first 5 lines (boilerplate).
46
+ file_str.lines[5..].join
47
+ },
48
+ "ZogStriP" => ->(file_str) {
49
+ # Remove input at the end of the file.
50
+ file_str.split("\n__END__").first
51
+ },
52
+ "erikw" => ->(file_str) {
53
+ # Remove the first 3 lines (boilerplate).
54
+ file_str.lines[3..].join
55
+ },
56
+ }
57
+
58
+ def initialize
59
+ @connection = Faraday.new(
60
+ url: "https://raw.githubusercontent.com",
61
+ headers: {
62
+ "User-Agent" => "github.com/fpsvogel/advent_of_ruby by fps.vogel@gmail.com",
63
+ }
64
+ )
65
+ end
66
+
67
+ def other_solutions(year, day, part)
68
+ "# #{year} Day #{day} Part #{part}\n\n" +
69
+ PATHS
70
+ .map { |username, path_builder|
71
+ actual_path = nil
72
+ solution = nil
73
+ paths = path_builder.call(year, day, part)
74
+
75
+ paths.each do |path|
76
+ next if solution
77
+ response = connection.get("/#{username}/#{path.sub("/tree/", "/")}")
78
+ next if response.status == 404
79
+
80
+ actual_path = path
81
+ solution = (EDITS[username] || :itself.to_proc).call(response.body)
82
+ end
83
+
84
+ if solution
85
+ <<~TPL
86
+ # ------------------------------------------------------------------------------
87
+ # #{username}: #{UI_URI}/#{username}/#{actual_path}
88
+ # ------------------------------------------------------------------------------
89
+
90
+ #{solution}
91
+
92
+ TPL
93
+ end
94
+ }
95
+ .compact
96
+ .join
97
+ .strip + "\n"
98
+ end
99
+ end
100
+ end
101
+ end
data/lib/arb/arb.rb ADDED
@@ -0,0 +1,23 @@
1
+ require "benchmark"
2
+ require "date"
3
+ require "debug"
4
+ require "dotenv"
5
+ require "faraday"
6
+ require "pastel"
7
+ require "reverse_markdown"
8
+ require "rspec/core"
9
+
10
+ class AppError < StandardError; end
11
+ class InputError < AppError; end
12
+ class ConfigError < AppError; end
13
+
14
+ PASTEL = Pastel.new
15
+
16
+ Dir[File.join(__dir__, "**", "*.rb")].each do |file|
17
+ require file
18
+ end
19
+
20
+ solution_files = File.join(Dir.pwd, "src", "**", "*.rb")
21
+ Dir[solution_files].each do |file|
22
+ require file
23
+ end
@@ -0,0 +1,36 @@
1
+ module Arb
2
+ module Cli
3
+ # @param year [String, Integer]
4
+ # @param day [String, Integer]
5
+ def self.bootstrap(year: nil, day: nil)
6
+ WorkingDirectory.prepare!
7
+
8
+ year, day = YearDayValidator.validate_year_and_day(year:, day:)
9
+
10
+ instructions_path = Files::Instructions.download(year, day)
11
+ others_1_path, others_2_path = Files::OtherSolutions.download(year, day)
12
+ input_path = Files::Input.download(year, day)
13
+ source_path = Files::Source.create(year, day)
14
+ spec_path = Files::Spec.create(year, day)
15
+
16
+ puts "🤘 Bootstrapped #{year}##{day}\n\n"
17
+
18
+ # Open the new files.
19
+ `#{ENV["EDITOR_COMMAND"]} #{others_1_path}`
20
+ `#{ENV["EDITOR_COMMAND"]} #{others_2_path}`
21
+ `#{ENV["EDITOR_COMMAND"]} #{input_path}`
22
+ `#{ENV["EDITOR_COMMAND"]} #{source_path}`
23
+ `#{ENV["EDITOR_COMMAND"]} #{spec_path}`
24
+ `#{ENV["EDITOR_COMMAND"]} #{instructions_path}`
25
+
26
+ if Git.commit_count <= 1
27
+ puts "Now fill in the spec for Part One with an example from the instructions, " \
28
+ "then run it with `#{PASTEL.blue.bold("arb run")}` (or just `arb`) as " \
29
+ "you implement the solution. When the spec passes, your solution will " \
30
+ "be run with the real input and you'll be prompted to submit your solution.\n"
31
+ end
32
+ rescue AppError => e
33
+ puts Pastel.new.red(e.message)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,36 @@
1
+ module Arb
2
+ module Cli
3
+ def self.commit
4
+ WorkingDirectory.prepare!
5
+
6
+ change_year, change_day = Git.new_solutions.first
7
+ unless change_year
8
+ files_modified = true
9
+ change_year, change_day = Git.modified_solutions.first
10
+ end
11
+
12
+ unless change_year
13
+ puts "Nothing to commit."
14
+
15
+ if Git.commit_count <= 2
16
+ puts "\nRun `#{PASTEL.blue.bold("arb bootstrap")}` (or `arb b`) to start the next puzzle."
17
+ end
18
+
19
+ return
20
+ end
21
+
22
+ message = "#{files_modified ? "Improve" : "Solve"} #{change_year}##{change_day}"
23
+ Git.commit_all!(message:)
24
+
25
+ # TODO less naive check: ensure prev. days are finished too
26
+ if !files_modified && change_day == "25"
27
+ puts "\n🎉 You've finished #{change_year}!\n\n"
28
+ end
29
+
30
+ if Git.commit_count <= 1
31
+ puts "Solution committed! When you're ready to start the next puzzle, run " \
32
+ "`#{PASTEL.blue.bold("arb bootstrap")}` (or `arb b`)."
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,33 @@
1
+ module Arb
2
+ module Cli
3
+ def self.progress
4
+ WorkingDirectory.prepare!
5
+
6
+ committed = Git.committed_by_year
7
+
8
+ total_count = committed.values.sum(&:count)
9
+ my_counts_by_year = committed
10
+ .transform_values { _1.values.count(&:itself) }
11
+ .reject { |k, v| v.zero? }
12
+ my_total_count = my_counts_by_year.values.sum
13
+
14
+ total_percent = (my_total_count.to_f / total_count * 100).round(1)
15
+ total_percent = total_percent == total_percent.to_i ? total_percent.to_i : total_percent
16
+
17
+ puts "You have completed:\n\n"
18
+ puts PASTEL.bold "#{PASTEL.blue("All:")}\t#{total_percent}% \t#{my_total_count}/#{total_count} puzzles\n"
19
+
20
+ my_counts_by_year.each do |year, my_count|
21
+ if year.to_i == Date.today.year
22
+ year_count = this_year_count
23
+ else
24
+ year_count = 25
25
+ end
26
+
27
+ year_percent = (my_count.to_f / year_count * 100).round
28
+
29
+ puts "#{PASTEL.blue("#{year}:")}\t#{year_percent}%\t#{my_count}/#{year_count}"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,140 @@
1
+ module Arb
2
+ module Cli
3
+ # @param year [String, Integer]
4
+ # @param day [String, Integer]
5
+ # @param options [Thor::CoreExt::HashWithIndifferentAccess] see `method_option`s
6
+ # above ArbApp#run_day.
7
+ def self.run_day(year:, day:, options:)
8
+ WorkingDirectory.prepare!
9
+
10
+ if options.spec? && (options.real_part_1? || options.real_part_2?)
11
+ raise InputError, "Don't use --spec (-s) with --real_part_1 (-o) or --real_part_2 (-t)"
12
+ end
13
+
14
+ year, day = YearDayValidator.validate_year_and_day(year:, day:, default_untracked_or_done: true)
15
+
16
+ if Git.new_solutions.none? && !Git.last_committed_solution(year:)
17
+ bootstrap(year:, day:)
18
+ return
19
+ end
20
+
21
+ solution = Runner.load_solution(year, day)
22
+ input_path = Files::Input.download(year, day, notify_exists: false)
23
+ answer_1, answer_2 = nil, nil
24
+
25
+ instructions_path = Files::Instructions.download(year, day, notify_exists: false, overwrite: false)
26
+ instructions = File.read(instructions_path)
27
+ correct_answer_1, correct_answer_2 = instructions.scan(/Your puzzle answer was `([^`]+)`./).flatten
28
+ skip_count = 0
29
+
30
+ if options.spec?
31
+ run_specs_only(year, day)
32
+ return
33
+ elsif !(options.real_part_1? || options.real_part_2?)
34
+ specs_passed, skip_count = run_specs_before_real(year, day)
35
+ return unless specs_passed
36
+ puts "👍 Specs passed!"
37
+ if skip_count > 1 || (skip_count == 1 && correct_answer_1)
38
+ puts PASTEL.yellow.bold("🤐 #{skip_count} skipped, however")
39
+ end
40
+ puts "\n"
41
+ end
42
+
43
+ if options.real_part_1? || (!options.real_part_2? && ((correct_answer_1.nil? && skip_count <= 1) || correct_answer_2))
44
+ answer_1 = Runner.run_part("#{year}##{day}.1", correct_answer_1) do
45
+ solution.part_1(File.new(input_path))
46
+ end
47
+ end
48
+ if options.real_part_2? || (!options.real_part_1? && ((correct_answer_1 && !correct_answer_2 && skip_count.zero?) || correct_answer_2))
49
+ answer_2 = Runner.run_part("#{year}##{day}.2", correct_answer_2) do
50
+ solution.part_2(File.new(input_path))
51
+ end
52
+ end
53
+
54
+ return unless answer_1 || answer_2
55
+
56
+ if correct_answer_2
57
+ puts "🙌 You've already submitted the answers to both parts.\n\n"
58
+
59
+ if Git.commit_count <= 1
60
+ puts "When you're done with this puzzle, run " \
61
+ "`#{PASTEL.blue.bold("arb commit")}` (or `arb c`) commit your solution to Git.\n"
62
+ end
63
+
64
+ return
65
+ elsif options.real_part_1? && correct_answer_1
66
+ puts "🙌 You've already submitted the answer to this part.\n\n"
67
+ return
68
+ end
69
+
70
+ puts "Submit solution? (Y/n)"
71
+ print PASTEL.green("> ")
72
+ submit = STDIN.gets.strip.downcase
73
+
74
+ if submit == "y" || submit == ""
75
+ options_part = options.real_part_1? ? "1" : (options.real_part_2? ? "2" : nil)
76
+ inferred_part = correct_answer_1.nil? ? "1" : "2"
77
+ aoc_api = Api::Aoc.new(ENV["AOC_COOKIE"])
78
+
79
+ response = aoc_api.submit(year, day, options_part || inferred_part, answer_2 || answer_1)
80
+ message = response.match(/(?<=<article>).+(?=<\/article>)/m).to_s.strip
81
+ markdown_message = ReverseMarkdown.convert(message)
82
+ short_message = markdown_message
83
+ .sub(/\n\n.+/m, "")
84
+ .sub(/ \[\[.+/, "")
85
+
86
+ if short_message.start_with?("That's the right answer!")
87
+ puts "⭐ #{short_message}\n"
88
+
89
+ # TODO don't re-download if the instructions file already contains the next part
90
+ instructions_path = Files::Instructions.download(year, day, overwrite: true)
91
+
92
+ if (options_part || inferred_part) == "1"
93
+ puts "\nDownloaded instructions for Part Two.\n\n"
94
+ `#{ENV["EDITOR_COMMAND"]} #{instructions_path}`
95
+
96
+ spec_path = Files::Spec.create(year, day, notify_exists: false)
97
+ spec = File.read(spec_path)
98
+ spec_without_skips = spec.gsub(" xit ", " it ")
99
+ File.write(spec_path, spec_without_skips)
100
+ end
101
+
102
+ if Git.commit_count <= 1
103
+ puts "\n\nNow it's time to improve your solution! Be sure to look " \
104
+ "at other people's solutions (in the \"others\" directory). When " \
105
+ "you're done, run `#{PASTEL.blue.bold("arb commit")}` (or `arb c`) " \
106
+ "to commit your solution to Git.\n"
107
+ end
108
+ else
109
+ puts "❌ #{short_message}"
110
+ end
111
+ end
112
+ rescue AppError => e
113
+ puts PASTEL.red(e.message)
114
+ end
115
+
116
+ private_class_method def self.run_specs_only(year, day)
117
+ padded_day = day.rjust(2, "0")
118
+ spec_filename = [File.join("spec", year, "#{padded_day}_spec.rb")]
119
+
120
+ RSpec::Core::Runner.run(spec_filename)
121
+ end
122
+
123
+ private_class_method def self.run_specs_before_real(year, day)
124
+ padded_day = day.rjust(2, "0")
125
+ spec_filename = [File.join("spec", year, "#{padded_day}_spec.rb")]
126
+
127
+ out = StringIO.new
128
+ RSpec::Core::Runner.run(spec_filename, $stderr, out)
129
+
130
+ if out.string.match?(/Failures:/)
131
+ RSpec.reset
132
+ RSpec::Core::Runner.run(spec_filename)
133
+
134
+ [false, nil]
135
+ else
136
+ [true, out.string.scan("skipped with xit").count]
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,82 @@
1
+ module Arb
2
+ module Cli
3
+ class Git
4
+ # Years and days of uncommitted new solutions, or an empty array.
5
+ # @return [Array<Array(String, String)>]
6
+ def self.new_solutions
7
+ output = `git status -su | grep -e "^?? src" -e "^?? spec" -e "^A src" -e "^A spec"`
8
+ output.scan(/(?<year>\d{4})\/(?<day>\d\d)(?:_spec)?.rb$/).uniq
9
+ end
10
+
11
+ # Years and days of modified existing solutions, or an empty array.
12
+ # @return [Array<Array(String, String)>]
13
+ def self.modified_solutions
14
+ output = `git status -su | grep -e "^ M src" -e "^ M spec" -e "^M src" -e "^M spec"`
15
+ output.scan(/(?<year>\d{4})\/(?<day>\d\d)(?:_spec)?.rb$/).uniq
16
+ end
17
+
18
+ # Year and day of the latest-date solution in the most recent commit, or nil.
19
+ # @return [Array(String, String), nil]
20
+ def self.last_committed_solution(year: nil, exclude_year: nil)
21
+ if exclude_year
22
+ output = `git log -n 1 --diff-filter=A --name-only --pretty=format: -- src spec ':!src/#{exclude_year}' ':!spec/#{exclude_year}'`
23
+ else
24
+ if year && !Dir.exist?(File.join("src", year))
25
+ return nil
26
+ else
27
+ output = `git log -n 1 --diff-filter=A --name-only --pretty=format: #{File.join("src", year || "")} #{File.join("spec", year || "")}`
28
+ end
29
+ end
30
+
31
+ return nil if output.empty?
32
+
33
+ output.scan(/(?<year>\d{4})\/(?<day>\d\d)(?:_spec)?.rb$/).last
34
+ end
35
+
36
+ # @return [Integer]
37
+ def self.commit_count
38
+ `git rev-list HEAD --count`.to_i
39
+ end
40
+
41
+ def self.committed_by_year
42
+ committed_solution_files = `git log --diff-filter=A --name-only --pretty=format: src`
43
+
44
+ previous_days = (2015..Date.today.year - 1).map { [_1, (1..25).to_a] }.to_h
45
+ previous_days.merge!(Date.today.year => (1..Date.today.day)) if Date.today.month == 12
46
+
47
+ committed_days = committed_solution_files
48
+ .split("\n")
49
+ .reject(&:empty?)
50
+ .map {
51
+ match = _1.match(/(?<year>\d{4})\/(?<day>\d\d).rb$/)
52
+ year, day = match[:year], match[:day]
53
+ [year.to_i, day.to_i]
54
+ }
55
+ .group_by(&:first)
56
+ .transform_values { _1.map(&:last) }
57
+
58
+ previous_days.map { |year, days|
59
+ days_hash = days.map { |day|
60
+ [day, committed_days.has_key?(year) && committed_days[year].include?(day)]
61
+ }.to_h
62
+
63
+ [year, days_hash]
64
+ }.to_h
65
+ end
66
+
67
+ # @return [Boolean]
68
+ def self.repo_exists?
69
+ `git rev-parse --is-inside-work-tree 2> /dev/null`.chomp == "true"
70
+ end
71
+
72
+ def self.init!
73
+ `git init`
74
+ end
75
+
76
+ def self.commit_all!(message:)
77
+ `git add -A`
78
+ `git commit -m "#{message}"`
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,42 @@
1
+ module Arb
2
+ module Cli
3
+ class Runner
4
+ def self.load_solution(year, day)
5
+ padded_day = day.rjust(2, "0")
6
+ Module.const_get("Year#{year}").const_get("Day#{padded_day}").new
7
+ rescue NameError
8
+ puts "There is no solution for this puzzle"
9
+ end
10
+
11
+ def self.run_part(part_name, correct_answer)
12
+ answer = nil
13
+ t = Benchmark.realtime do
14
+ answer = yield
15
+ if answer
16
+ puts "Result for #{part_name}:"
17
+
18
+ if correct_answer
19
+ if answer.to_s == correct_answer
20
+ puts PASTEL.green.bold("✅ #{answer}")
21
+ else
22
+ puts PASTEL.red.bold("❌ #{answer} -- should be #{correct_answer}")
23
+ end
24
+ else
25
+ puts PASTEL.bright_white.bold(answer)
26
+ end
27
+ else
28
+ puts "❌ No result for #{part_name}"
29
+ end
30
+ end
31
+
32
+ if answer
33
+ puts "(obtained in #{t.round(10)} seconds)"
34
+ end
35
+
36
+ puts
37
+
38
+ answer
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,157 @@
1
+ module Arb
2
+ module Cli
3
+ class WorkingDirectory
4
+ FILES = {
5
+ gitignore: <<~FILE,
6
+ input/**/*
7
+ instructions/**/*
8
+ others/**/*
9
+ .env
10
+
11
+ FILE
12
+ ruby_version: "3.3.0\n",
13
+ gemfile: <<~FILE,
14
+ source "https://rubygems.org"
15
+ ruby file: ".ruby-version"
16
+
17
+ FILE
18
+ spec_helper_addition: <<~FILE
19
+ require "debug"
20
+
21
+ Dir[File.join(__dir__, "..", "src", "**", "*.rb")].each do |file|
22
+ require file
23
+ end
24
+
25
+
26
+ FILE
27
+ }
28
+
29
+ def self.env_keys = ["EDITOR_COMMAND", "AOC_COOKIE"]
30
+ def self.default_editor_command = "code"
31
+
32
+ def self.prepare!
33
+ files_created = []
34
+
35
+ existing_dotenv = Dotenv.parse(".env")
36
+ unless env_keys.all? { existing_dotenv.has_key?(_1) }
37
+ create_dotenv!(existing_dotenv)
38
+ files_created << :dotenv
39
+ end
40
+ Dotenv.load
41
+ Dotenv.require_keys(*env_keys)
42
+
43
+ files_created += create_other_files!
44
+
45
+ if files_created.any?
46
+ puts "✅ Initial files created and committed to a new Git repository.\n\n"
47
+ end
48
+ end
49
+
50
+ def self.refresh_aoc_cookie!
51
+ print "Uh oh, your Advent of Code session cookie has expired or was " \
52
+ "incorrectly entered. "
53
+ ENV["AOC_COOKIE"] = input_aoc_cookie
54
+ File.write(".env", generate_dotenv)
55
+ end
56
+
57
+ private
58
+
59
+ def self.generate_dotenv(new_dotenv)
60
+ new_dotenv.slice(*env_keys).map { |k, v|
61
+ "#{k}=#{v}"
62
+ }.join("\n")
63
+ end
64
+
65
+ def self.create_dotenv!(existing_dotenv)
66
+ new_dotenv = existing_dotenv.dup
67
+
68
+ puts "🎄 Welcome to Advent of Code in Ruby! 🎄\n\n"
69
+ puts "Let's start with some configuration.\n\n"
70
+
71
+ unless existing_dotenv.has_key?("EDITOR_COMMAND")
72
+ puts "What's the shell command to start your editor? (default: #{default_editor_command})"
73
+ print PASTEL.green("> ")
74
+ editor_command = STDIN.gets.strip
75
+ editor_command = default_editor_command if editor_command.empty?
76
+ new_dotenv["EDITOR_COMMAND"] = editor_command
77
+ end
78
+
79
+ puts
80
+
81
+ unless existing_dotenv.has_key?("AOC_COOKIE")
82
+ new_dotenv["AOC_COOKIE"] = input_aoc_cookie
83
+ end
84
+
85
+ File.write(".env", generate_dotenv(new_dotenv))
86
+ end
87
+
88
+ def self.input_aoc_cookie
89
+ aoc_cookie = nil
90
+
91
+ loop do
92
+ puts "What's your Advent of Code session cookie?"
93
+ puts PASTEL.dark.white("To find it, log in to [Advent of Code](https://adventofcode.com/) and then:")
94
+ puts PASTEL.dark.white(" Firefox: Developer Tools ⇨ Storage tab ⇨ Cookies")
95
+ puts PASTEL.dark.white(" Chrome: Developer Tools ⇨ Application tab ⇨ Cookies")
96
+ print PASTEL.green("> ")
97
+
98
+ aoc_cookie = STDIN.gets.strip
99
+
100
+ puts
101
+
102
+ break unless aoc_cookie.strip.empty?
103
+ end
104
+
105
+ aoc_cookie
106
+ end
107
+
108
+ def self.create_other_files!
109
+ other_files_created = []
110
+
111
+ unless Dir.exist?("src")
112
+ Dir.mkdir("src")
113
+ other_files_created << :src_dir
114
+ end
115
+
116
+ unless Dir.exist?("spec")
117
+ Dir.mkdir("spec")
118
+ other_files_created << :spec_dir
119
+ end
120
+
121
+ unless File.exist?(".gitignore")
122
+ File.write(".gitignore", FILES.fetch(:gitignore))
123
+ other_files_created << :gitignore
124
+ end
125
+
126
+ unless File.exist?(".ruby-version")
127
+ File.write(".ruby-version", FILES.fetch(:ruby_version))
128
+ other_files_created << :ruby_version
129
+ end
130
+
131
+ unless File.exist?("Gemfile")
132
+ File.write("Gemfile", FILES.fetch(:gemfile))
133
+ other_files_created << :gemfile
134
+ end
135
+
136
+ spec_helper_path = File.join("spec", "spec_helper.rb")
137
+ unless File.exist?(".rspec") && File.exist?(spec_helper_path)
138
+ rspec_init_output = `rspec --init`
139
+ unless rspec_init_output.match?(/exist\s+spec.spec_helper.rb/)
140
+ original_spec_helper = File.read(spec_helper_path)
141
+ File.write(spec_helper_path, FILES.fetch(:spec_helper_addition) + original_spec_helper)
142
+ other_files_created << :spec_helper
143
+ end
144
+ other_files_created << :rspec
145
+ end
146
+
147
+ unless Git.repo_exists?
148
+ Git.init!
149
+ Git.commit_all!(message: "Initial commit")
150
+ other_files_created << :git
151
+ end
152
+
153
+ other_files_created
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,85 @@
1
+ module Arb
2
+ module Cli
3
+ class YearDayValidator
4
+ def self.validate_year_and_day(year:, day:, default_untracked_or_done: false)
5
+ year, day = year&.to_s, day&.to_s
6
+
7
+ # The first two digits of the year may be omitted.
8
+ year = "20#{year}" if year && year.length == 2
9
+
10
+ if day && !year
11
+ raise InputError, "If you specify the day, specify the year also."
12
+ elsif !day
13
+ if default_untracked_or_done
14
+ year, day = Git.new_solutions.last
15
+ end
16
+
17
+ unless day
18
+ if year && !Dir.exist?(File.join("src", year))
19
+ Dir.mkdir(File.join("src", year))
20
+ day = "1"
21
+ else
22
+ year, day = Git.last_committed_solution(year:)
23
+
24
+ if day && !default_untracked_or_done
25
+ if day == "25"
26
+ day = :end
27
+ else
28
+ day = day.next
29
+ end
30
+ end
31
+
32
+ if !day || day == :end
33
+ default_year = "2015"
34
+ default_day = "1"
35
+ bootstrap_year_prompt = nil
36
+
37
+ committed = Git.committed_by_year
38
+ total_committed = committed.values.sum { _1.values.count(&:itself) }
39
+ if total_committed.zero?
40
+ bootstrap_year_prompt = "What year's puzzles do you want to start with? (default: #{default_year})"
41
+ else
42
+ earliest_year_with_fewest_committed = committed
43
+ .transform_values { _1.values.count(&:itself) }
44
+ .sort_by(&:last).first.first
45
+ default_year = earliest_year_with_fewest_committed
46
+ default_day = committed[default_year].values.index(false)
47
+
48
+ puts "You've recently finished #{year}. Yay!"
49
+ bootstrap_year_prompt = "What year do you want to bootstrap next? (default: #{default_year} [at Day #{default_day}])"
50
+ end
51
+
52
+ loop do
53
+ puts bootstrap_year_prompt
54
+ print PASTEL.green("> ")
55
+ year_input = STDIN.gets.strip
56
+ puts
57
+ if year_input.strip.empty?
58
+ year = default_year
59
+ day = default_day
60
+ else
61
+ year = year_input.strip.match(/\A\d{4}\z/)&.to_s
62
+ day = "1"
63
+ end
64
+ break if year
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ year = Integer(year, exception: false) || (raise InputError, "Year must be a number.")
72
+ day = Integer(day, exception: false) || (raise InputError, "Day must be a number.")
73
+
74
+ unless year.between?(2015, Date.today.year)
75
+ raise InputError, "Year must be between 2015 and this year."
76
+ end
77
+ unless day.between?(1, 25) && Date.new(year, 12, day) <= Date.today
78
+ raise InputError, "Day must be between 1 and 25, and <= today."
79
+ end
80
+
81
+ [year.to_s, day.to_s]
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,25 @@
1
+ module Arb
2
+ module Files
3
+ class Input
4
+ def self.download(year, day, notify_exists: true)
5
+ year_directory = File.join("input", year)
6
+ Dir.mkdir("input") unless Dir.exist?("input")
7
+ Dir.mkdir(year_directory) unless Dir.exist?(year_directory)
8
+
9
+ padded_day = day.rjust(2, "0")
10
+ file_path = File.join(year_directory, "#{padded_day}.txt")
11
+
12
+ if File.exist?(file_path)
13
+ puts "Already exists: #{file_path}" if notify_exists
14
+ else
15
+ aoc_api = Api::Aoc.new(ENV["AOC_COOKIE"])
16
+ response = aoc_api.input(year, day)
17
+
18
+ File.write(file_path, response)
19
+ end
20
+
21
+ file_path
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,40 @@
1
+ module Arb
2
+ module Files
3
+ class Instructions
4
+ def self.path(year, day)
5
+ year_directory = File.join("instructions", year)
6
+ Dir.mkdir("instructions") unless Dir.exist?("instructions")
7
+ Dir.mkdir(year_directory) unless Dir.exist?(year_directory)
8
+
9
+ padded_day = day.rjust(2, "0")
10
+
11
+ File.join(year_directory, "#{padded_day}.md")
12
+ end
13
+
14
+ def self.download(year, day, notify_exists: true, overwrite: false)
15
+ file_path = path(year, day)
16
+
17
+ if File.exist?(file_path) && !overwrite
18
+ puts "Already exists: #{file_path}" if notify_exists
19
+ else
20
+ url = "https://adventofcode.com/#{year}/day/#{day}"
21
+
22
+ aoc_api = Api::Aoc.new(ENV["AOC_COOKIE"])
23
+ response = aoc_api.instructions(year, day)
24
+ instructions = response.match(/(?<=<main>).+(?=<\/main>)/m).to_s
25
+ markdown_instructions = ReverseMarkdown.convert(instructions).strip
26
+ markdown_instructions = markdown_instructions
27
+ .sub(/\nTo play, please identify yourself via one of these services:.+/m, "")
28
+ .sub(/\nTo begin, \[get your puzzle input\].+/m, "")
29
+ .sub(/\n\<form method="post".+/m, "")
30
+ .sub(/\nAt this point, you should \[return to your Advent calendar\].+/m, "")
31
+ .concat("\n#{url}\n")
32
+
33
+ File.write(file_path, markdown_instructions)
34
+ end
35
+
36
+ file_path
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,28 @@
1
+ module Arb
2
+ module Files
3
+ class OtherSolutions
4
+ def self.download(year, day)
5
+ year_directory = File.join("others", year)
6
+ Dir.mkdir("others") unless Dir.exist?("others")
7
+ Dir.mkdir(year_directory) unless Dir.exist?(year_directory)
8
+
9
+ padded_day = day.rjust(2, "0")
10
+
11
+ file_paths = %w[1 2].map do |part|
12
+ file_path = File.join(year_directory, "#{padded_day}_#{part}.rb")
13
+
14
+ if File.exist?(file_path)
15
+ puts "Already exists: #{file_path}"
16
+ else
17
+ other_solutions = Api::OtherSolutions.new.other_solutions(year, day, part)
18
+ File.write(file_path, other_solutions)
19
+ end
20
+
21
+ file_path
22
+ end
23
+
24
+ file_paths
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,44 @@
1
+ module Arb
2
+ module Files
3
+ class Source
4
+ def self.create(year, day)
5
+ year_directory = File.join("src", year)
6
+ Dir.mkdir(year_directory) unless Dir.exist?(year_directory)
7
+
8
+ padded_day = day.rjust(2, "0")
9
+ file_path = File.join(year_directory, "#{padded_day}.rb")
10
+
11
+ if File.exist?(file_path)
12
+ puts "Already exists: #{file_path}"
13
+ else
14
+ File.write(file_path, source(year, day))
15
+ end
16
+
17
+ file_path
18
+ end
19
+
20
+ def self.source(year, day)
21
+ padded_day = day.rjust(2, "0")
22
+
23
+ <<~TPL
24
+ # https://adventofcode.com/#{year}/day/#{day}
25
+ module Year#{year}
26
+ class Day#{padded_day}
27
+ def part_1(input_file)
28
+ lines = input_file.readlines(chomp: true)
29
+
30
+ nil
31
+ end
32
+
33
+ def part_2(input_file)
34
+ lines = input_file.readlines(chomp: true)
35
+
36
+ nil
37
+ end
38
+ end
39
+ end
40
+ TPL
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,45 @@
1
+ module Arb
2
+ module Files
3
+ class Spec
4
+ def self.create(year, day, notify_exists: true)
5
+ year_directory = File.join("spec", year)
6
+ Dir.mkdir(year_directory) unless Dir.exist?(year_directory)
7
+
8
+ padded_day = day.rjust(2, "0")
9
+ file_path = File.join(year_directory, "#{padded_day}_spec.rb")
10
+
11
+ if File.exist?(file_path)
12
+ puts "Already exists: #{file_path}" if notify_exists
13
+ else
14
+ File.write(file_path, source(year, day))
15
+ end
16
+
17
+ file_path
18
+ end
19
+
20
+ def self.source(year, day)
21
+ padded_day = day.rjust(2, "0")
22
+
23
+ <<~TPL
24
+ RSpec.describe Year#{year}::Day#{padded_day} do
25
+ let(:input) {
26
+ StringIO.new(
27
+ <<~IN
28
+ something
29
+ IN
30
+ )
31
+ }
32
+
33
+ it "solves Part One" do
34
+ expect(subject.part_1(input)).to eq(:todo)
35
+ end
36
+
37
+ xit "solves Part Two" do
38
+ expect(subject.part_2(input)).to eq(:todo)
39
+ end
40
+ end
41
+ TPL
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,3 @@
1
+ module Arb
2
+ VERSION = "0.1.0"
3
+ end
metadata ADDED
@@ -0,0 +1,177 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: advent_of_ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Felipe Vogel
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-10-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: debug
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: dotenv
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: faraday
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: pastel
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.8'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.8'
69
+ - !ruby/object:Gem::Dependency
70
+ name: reverse_markdown
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rspec
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '3.0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '3.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: thor
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: vcr
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '6.0'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '6.0'
125
+ description:
126
+ email:
127
+ - fps.vogel@gmail.com
128
+ executables:
129
+ - arb
130
+ extensions: []
131
+ extra_rdoc_files: []
132
+ files:
133
+ - bin/arb
134
+ - lib/arb/api/aoc.rb
135
+ - lib/arb/api/other_solutions.rb
136
+ - lib/arb/arb.rb
137
+ - lib/arb/cli/bootstrap.rb
138
+ - lib/arb/cli/commit.rb
139
+ - lib/arb/cli/progress.rb
140
+ - lib/arb/cli/run_day.rb
141
+ - lib/arb/cli/shared/git.rb
142
+ - lib/arb/cli/shared/runner.rb
143
+ - lib/arb/cli/shared/working_directory.rb
144
+ - lib/arb/cli/shared/year_day_validator.rb
145
+ - lib/arb/files/input.rb
146
+ - lib/arb/files/instructions.rb
147
+ - lib/arb/files/other_solutions.rb
148
+ - lib/arb/files/source.rb
149
+ - lib/arb/files/spec.rb
150
+ - lib/arb/version.rb
151
+ homepage: https://github.com/fpsvogel/advent_of_ruby
152
+ licenses:
153
+ - MIT
154
+ metadata:
155
+ allowed_push_host: https://rubygems.org
156
+ source_code_uri: https://github.com/fpsvogel/advent_of_ruby
157
+ changelog_uri: https://github.com/fpsvogel/avent_of_ruby/blob/main/CHANGELOG.md
158
+ post_install_message:
159
+ rdoc_options: []
160
+ require_paths:
161
+ - lib
162
+ required_ruby_version: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - "~>"
165
+ - !ruby/object:Gem::Version
166
+ version: 3.3.0
167
+ required_rubygems_version: !ruby/object:Gem::Requirement
168
+ requirements:
169
+ - - ">="
170
+ - !ruby/object:Gem::Version
171
+ version: '0'
172
+ requirements: []
173
+ rubygems_version: 3.5.10
174
+ signing_key:
175
+ specification_version: 4
176
+ summary: CLI for Advent of Code in Ruby, via the `arb` command.
177
+ test_files: []