advent_of_ruby 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []