dr_talon 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.
Files changed (67) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +60 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +270 -0
  6. data/Rakefile +10 -0
  7. data/exe/talon +6 -0
  8. data/lib/dr_talon/cli/engine.rb +274 -0
  9. data/lib/dr_talon/cli/generate.rb +29 -0
  10. data/lib/dr_talon/cli.rb +343 -0
  11. data/lib/dr_talon/config.rb +29 -0
  12. data/lib/dr_talon/credentials.rb +94 -0
  13. data/lib/dr_talon/deps/dependency.rb +238 -0
  14. data/lib/dr_talon/deps/git.rb +69 -0
  15. data/lib/dr_talon/deps/installer.rb +207 -0
  16. data/lib/dr_talon/deps/manifest.rb +220 -0
  17. data/lib/dr_talon/deps/tree.rb +97 -0
  18. data/lib/dr_talon/deps.rb +62 -0
  19. data/lib/dr_talon/doctor.rb +161 -0
  20. data/lib/dr_talon/engine/fetchers.rb +251 -0
  21. data/lib/dr_talon/engine/git_ignore.rb +63 -0
  22. data/lib/dr_talon/engine/importer.rb +103 -0
  23. data/lib/dr_talon/engine/inspector.rb +113 -0
  24. data/lib/dr_talon/engine/installation.rb +77 -0
  25. data/lib/dr_talon/engine/materializer.rb +172 -0
  26. data/lib/dr_talon/engine/spec.rb +38 -0
  27. data/lib/dr_talon/engine/store.rb +119 -0
  28. data/lib/dr_talon/engine.rb +50 -0
  29. data/lib/dr_talon/errors.rb +5 -0
  30. data/lib/dr_talon/file_copy.rb +49 -0
  31. data/lib/dr_talon/generators/new_project.rb +289 -0
  32. data/lib/dr_talon/generators/talon_code.rb +92 -0
  33. data/lib/dr_talon/http.rb +120 -0
  34. data/lib/dr_talon/paths.rb +115 -0
  35. data/lib/dr_talon/project.rb +101 -0
  36. data/lib/dr_talon/runner.rb +188 -0
  37. data/lib/dr_talon/talon_framework.rb +103 -0
  38. data/lib/dr_talon/templates/bare/main_tests.rb.tt +7 -0
  39. data/lib/dr_talon/templates/bare/rubocop.yml.tt +8 -0
  40. data/lib/dr_talon/templates/bare/solargraph.yml.tt +24 -0
  41. data/lib/dr_talon/templates/lib/app/main.rb.tt +13 -0
  42. data/lib/dr_talon/templates/lib/lib/library.rb.tt +4 -0
  43. data/lib/dr_talon/templates/lib/lib/version.rb.tt +3 -0
  44. data/lib/dr_talon/templates/lib/rubocop.yml.tt +9 -0
  45. data/lib/dr_talon/templates/lib/solargraph.yml.tt +23 -0
  46. data/lib/dr_talon/templates/lib/test/test_helper.rb.tt +1 -0
  47. data/lib/dr_talon/templates/lib/test/tests.rb.tt +8 -0
  48. data/lib/dr_talon/templates/shared/Gemfile.tt +10 -0
  49. data/lib/dr_talon/templates/shared/README.md.tt +45 -0
  50. data/lib/dr_talon/templates/shared/editorconfig.tt +9 -0
  51. data/lib/dr_talon/templates/shared/gitignore.tt +19 -0
  52. data/lib/dr_talon/templates/talon/game/dependencies.rb.tt +3 -0
  53. data/lib/dr_talon/templates/talon/game/main.rb.tt +9 -0
  54. data/lib/dr_talon/templates/talon/game/spin.rb.tt +7 -0
  55. data/lib/dr_talon/templates/talon/game/title.rb.tt +8 -0
  56. data/lib/dr_talon/templates/talon/game/title_tests.rb.tt +16 -0
  57. data/lib/dr_talon/templates/talon/generate/component.rb.tt +19 -0
  58. data/lib/dr_talon/templates/talon/generate/component_tests.rb.tt +15 -0
  59. data/lib/dr_talon/templates/talon/generate/prefab.rb.tt +11 -0
  60. data/lib/dr_talon/templates/talon/generate/prefab_tests.rb.tt +15 -0
  61. data/lib/dr_talon/templates/talon/generate/scene.rb.tt +14 -0
  62. data/lib/dr_talon/templates/talon/generate/scene_tests.rb.tt +13 -0
  63. data/lib/dr_talon/test_report.rb +76 -0
  64. data/lib/dr_talon/version.rb +5 -0
  65. data/lib/dr_talon.rb +17 -0
  66. data/sig/dr_talon.rbs +524 -0
  67. metadata +142 -0
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module DrTalon
7
+ module Engine
8
+ # Copies a cached engine into a {Project} and records what it placed in a
9
+ # manifest, so switching engines later removes exactly those files and
10
+ # never touches the game or anything else in the project.
11
+ class Materializer
12
+ MANIFEST = File.join(".talon", "engine.yml")
13
+
14
+ # Top-level files a +:game_only+ project gets: enough to run the game
15
+ # and its tests. Windows builds may also need the DLLs next to the exe.
16
+ GAME_ONLY_FILES = %w[dragonruby dragonruby.exe font.ttf].freeze
17
+
18
+ # Files talon never copies into a project.
19
+ SKIPPED = [Installation::METADATA_FILE].freeze
20
+
21
+ # File names never copied, wherever they are (DragonRuby's zips include
22
+ # macOS Finder metadata).
23
+ SKIPPED_NAMES = %w[.DS_Store].freeze
24
+
25
+ # Project files that differ from the engine's and aren't from a previous
26
+ # install, so overwriting them could lose someone's work.
27
+ class Conflict < Error; end
28
+
29
+ # @!attribute [r] engine
30
+ # @return [String, nil] the installed {Installation#id}
31
+ # @!attribute [r] files
32
+ # @return [Array<String>] project-relative paths talon placed
33
+ Manifest = Data.define(:engine, :files)
34
+
35
+ # @!attribute [r] copied
36
+ # @return [Integer] files written
37
+ # @!attribute [r] removed
38
+ # @return [Integer] files from the previous engine that were deleted
39
+ # @!attribute [r] cloned
40
+ # @return [Integer] how many of the copies were reflinks
41
+ Result = Data.define(:copied, :removed, :cloned)
42
+
43
+ # @param project [Project]
44
+ def initialize(project)
45
+ @project = project
46
+ end
47
+
48
+ # @return [Manifest, nil]
49
+ def manifest
50
+ path = @project.path(MANIFEST)
51
+ return nil unless File.file?(path)
52
+
53
+ data = YAML.safe_load_file(path)
54
+ raise Error, "#{MANIFEST} is not a mapping" unless data.is_a?(Hash)
55
+
56
+ files = Array(data["files"]).map(&:to_s)
57
+ # Entries are deleted on the next install, so never trust one that
58
+ # points outside the project.
59
+ unsafe = files.find { |relative| relative.start_with?("/") || relative.split(%r{[/\\]}).include?("..") }
60
+ raise Error, "#{MANIFEST} lists a path outside the project: #{unsafe}" if unsafe
61
+
62
+ Manifest.new(engine: data["engine"], files: files)
63
+ rescue Psych::SyntaxError => e
64
+ raise Error, "Can't read #{MANIFEST}: #{e.message}"
65
+ end
66
+
67
+ # The engine files a project of this layout gets, relative to both the
68
+ # engine root and the project root.
69
+ #
70
+ # @param installation [Installation]
71
+ # @return [Array<String>]
72
+ def files_for(installation)
73
+ all = Dir.glob("**/*", File::FNM_DOTMATCH, base: installation.path).select do |relative|
74
+ path = File.join(installation.path, relative)
75
+ (File.file?(path) || File.symlink?(path)) && !File.basename(relative).match?(/\A\.\.?\z/)
76
+ end
77
+ all.reject! do |relative|
78
+ relative.start_with?("mygame/") || SKIPPED.include?(relative) || SKIPPED_NAMES.include?(File.basename(relative))
79
+ end
80
+
81
+ if @project.layout == :game_only
82
+ all.select! { |relative| GAME_ONLY_FILES.include?(relative) || relative.match?(%r{\A[^/]+\.dll\z}i) }
83
+ end
84
+
85
+ all.sort
86
+ end
87
+
88
+ # Project paths that would be overwritten but weren't placed by talon
89
+ # and differ from the engine's copy. Identical files are adopted
90
+ # silently, which covers projects started from an unzipped engine.
91
+ #
92
+ # @param installation [Installation]
93
+ # @return [Array<String>]
94
+ def conflicts(installation)
95
+ previous = manifest&.files || []
96
+
97
+ (files_for(installation) - previous).select do |relative|
98
+ target = @project.path(relative)
99
+ next false unless File.exist?(target) || File.symlink?(target)
100
+
101
+ source = File.join(installation.path, relative)
102
+ File.symlink?(target) || !File.file?(target) || !FileUtils.compare_file(source, target)
103
+ end
104
+ end
105
+
106
+ # Removes the previous engine's files and copies in +installation+.
107
+ #
108
+ # @param installation [Installation]
109
+ # @param force [Boolean] overwrite {#conflicts}
110
+ # @return [Result]
111
+ # @raise [Conflict]
112
+ def install(installation, force: false)
113
+ conflicting = conflicts(installation)
114
+ if conflicting.any? && !force
115
+ listed = conflicting.first(10).map { |relative| " #{relative}" }.join("\n")
116
+ more = (conflicting.size > 10) ? "\n ...and #{conflicting.size - 10} more" : ""
117
+ raise Conflict, "These project files differ from #{installation.id}'s and weren't installed by talon:\n" \
118
+ "#{listed}#{more}\nMove them aside, or pass --force to overwrite them."
119
+ end
120
+
121
+ files = files_for(installation)
122
+ removed = remove_files(manifest&.files || [])
123
+
124
+ # Record the new files before copying, so a failed copy can be cleaned
125
+ # up by the next install rather than reported as conflicts.
126
+ write_manifest(Manifest.new(engine: installation.id, files: files))
127
+
128
+ cloned = files.count do |relative|
129
+ target = @project.path(relative)
130
+ FileUtils.mkdir_p(File.dirname(target))
131
+ FileCopy.copy(File.join(installation.path, relative), target)
132
+ end
133
+
134
+ Result.new(copied: files.size, removed: removed, cloned: cloned)
135
+ end
136
+
137
+ private
138
+
139
+ def remove_files(files)
140
+ removed = files.count do |relative|
141
+ target = @project.path(relative)
142
+ next false unless File.exist?(target) || File.symlink?(target)
143
+
144
+ File.unlink(target)
145
+ true
146
+ end
147
+
148
+ prune_empty_dirs(files)
149
+ removed
150
+ end
151
+
152
+ # Deletes directories emptied by {#remove_files}, deepest first, without
153
+ # ever going above the project root.
154
+ def prune_empty_dirs(files)
155
+ dirs = files.flat_map do |relative|
156
+ parts = File.dirname(relative).split("/")
157
+ parts.each_index.map { |i| parts[0..i].join("/") }
158
+ end
159
+ dirs.uniq.reject { |dir| dir == "." }.sort_by { |dir| -dir.count("/") }.each do |dir|
160
+ path = @project.path(dir)
161
+ Dir.rmdir(path) if File.directory?(path) && Dir.empty?(path)
162
+ end
163
+ end
164
+
165
+ def write_manifest(manifest)
166
+ path = @project.path(MANIFEST)
167
+ FileUtils.mkdir_p(File.dirname(path))
168
+ File.write(path, YAML.dump({"engine" => manifest.engine, "files" => manifest.files}))
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DrTalon
4
+ module Engine
5
+ Spec = Data.define(:version, :tier, :platform)
6
+
7
+ # A possibly partial engine identifier given on the command line:
8
+ # "7.23", "7.23-pro" or "7.23-pro-linux-amd64".
9
+ class Spec
10
+ PATTERN = /\A(?<version>\d+(?:\.\d+)+)(?:-(?<tier>#{TIERS.join("|")})(?:-(?<platform>[a-z0-9-]+))?)?\z/
11
+
12
+ # @param string [String]
13
+ # @return [Spec]
14
+ # @raise [Error] if +string+ isn't a valid spec
15
+ def self.parse(string)
16
+ match = PATTERN.match(string.to_s.strip)
17
+ unless match
18
+ raise Error, "Invalid engine \"#{string}\". Expected VERSION[-TIER[-PLATFORM]], e.g. 7.23 or 7.23-pro."
19
+ end
20
+
21
+ new(version: match[:version], tier: match[:tier], platform: match[:platform])
22
+ end
23
+
24
+ # @param installation [Installation]
25
+ # @return [Boolean] whether every part this spec names matches
26
+ def match?(installation)
27
+ installation.version == version &&
28
+ (tier.nil? || installation.tier == tier) &&
29
+ (platform.nil? || installation.platform == platform)
30
+ end
31
+
32
+ # @return [String]
33
+ def to_s
34
+ [version, tier, platform].compact.join("-")
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ module DrTalon
7
+ module Engine
8
+ # The global cache of unpacked engines, one directory per {Installation#id}.
9
+ #
10
+ # DragonRuby only offers its latest release for download, so this cache is
11
+ # also the only archive of older versions.
12
+ class Store
13
+ # @return [String]
14
+ attr_reader :root
15
+
16
+ # @param root [String] usually {Paths#engines_dir}
17
+ # @param host_platform [String] preferred when a spec omits the platform
18
+ def initialize(root, host_platform: Engine.host_platform)
19
+ @root = root
20
+ @host_platform = host_platform
21
+ end
22
+
23
+ # @return [Array<Installation>] newest version first, then the most
24
+ # capable tier (see {TIERS})
25
+ def installations
26
+ return [] unless File.directory?(root)
27
+
28
+ Dir.children(root)
29
+ .reject { |name| name.start_with?(".") }
30
+ .map { |name| File.join(root, name) }
31
+ .select { |path| File.file?(File.join(path, Installation::METADATA_FILE)) }
32
+ .map { |path| Installation.load(path) }
33
+ .sort { |a, b| sort_key(b) <=> sort_key(a) }
34
+ end
35
+
36
+ # @param id [String] a full {Installation#id}
37
+ # @return [Installation, nil]
38
+ def get(id)
39
+ path = path_for(id)
40
+ Installation.load(path) if File.file?(File.join(path, Installation::METADATA_FILE))
41
+ end
42
+
43
+ # Resolves a possibly partial spec to one installation. When the spec
44
+ # omits the platform, engines for the host platform win.
45
+ #
46
+ # @param spec [String, Spec]
47
+ # @return [Installation]
48
+ # @raise [NotFound, Ambiguous]
49
+ def find(spec)
50
+ spec = Spec.parse(spec) unless spec.is_a?(Spec)
51
+ matches = installations.select { |installation| spec.match?(installation) }
52
+ raise NotFound, "No cached engine matches #{spec}. Run `talon engine list` to see what's installed." if matches.empty?
53
+
54
+ if matches.size > 1 && spec.platform.nil?
55
+ native = matches.select { |installation| installation.platform == @host_platform }
56
+ matches = native unless native.empty?
57
+ end
58
+ return matches.first if matches.one?
59
+
60
+ raise Ambiguous, "#{spec} matches more than one engine: #{matches.map(&:id).join(", ")}. Use a full id."
61
+ end
62
+
63
+ # Resolves +spec+ to an engine built for this machine, since a project
64
+ # can only run its host's build.
65
+ #
66
+ # @param spec [Spec]
67
+ # @param default_tier [String, nil] used when +spec+ omits the tier
68
+ # @return [Installation]
69
+ # @raise [Error] if +spec+ names another platform
70
+ # @raise [NotFound, Ambiguous]
71
+ def find_native(spec, default_tier: nil)
72
+ if spec.platform && spec.platform != @host_platform
73
+ raise Error, "This machine runs #{@host_platform} engines; #{spec} is for #{spec.platform}."
74
+ end
75
+
76
+ find(Spec.new(version: spec.version, tier: spec.tier || default_tier, platform: @host_platform))
77
+ end
78
+
79
+ # @return [Installation, nil] the newest engine for this machine
80
+ def latest_native
81
+ installations.find { |installation| installation.platform == @host_platform }
82
+ end
83
+
84
+ # @param id [String]
85
+ # @return [String]
86
+ def path_for(id)
87
+ File.join(root, id)
88
+ end
89
+
90
+ # Larger sorts first: newer versions, then more capable tiers.
91
+ def sort_key(installation)
92
+ [installation.gem_version, -(TIERS.index(installation.tier) || TIERS.size), installation.platform]
93
+ end
94
+ private :sort_key
95
+
96
+ # Deletes an installation from the cache.
97
+ #
98
+ # @param installation [Installation]
99
+ # @return [void]
100
+ def remove(installation)
101
+ FileUtils.rm_rf(installation.path)
102
+ end
103
+
104
+ # Yields an empty directory inside the store, on the same filesystem, so
105
+ # a finished import can be moved into place with a rename. The directory
106
+ # is removed afterwards.
107
+ #
108
+ # @yieldparam dir [String]
109
+ # @return [Object] the block's result
110
+ def stage
111
+ FileUtils.mkdir_p(root)
112
+ dir = Dir.mktmpdir(".staging-", root)
113
+ yield dir
114
+ ensure
115
+ FileUtils.rm_rf(dir) if dir
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module DrTalon
6
+ # Cached DragonRuby engines: identifying, importing and looking them up.
7
+ module Engine
8
+ # License tiers, most capable first. Jam is DragonRuby's free license:
9
+ # Standard's API, but it only exports to the web.
10
+ TIERS = %w[pro indie standard jam].freeze
11
+
12
+ # The engine path doesn't look like a DragonRuby distribution, or a detail
13
+ # such as its version can't be determined.
14
+ class InvalidEngine < Error; end
15
+
16
+ # No cached engine matches a requested spec.
17
+ class NotFound < Error; end
18
+
19
+ # More than one cached engine matches a requested spec.
20
+ class Ambiguous < Error; end
21
+
22
+ # An engine with the same id is already cached.
23
+ class AlreadyInstalled < Error; end
24
+
25
+ # The DragonRuby platform name for the machine talon is running on, as used
26
+ # in DragonRuby's zip and directory names (+dragonruby-linux-amd64+).
27
+ #
28
+ # @param host_os [String]
29
+ # @param host_cpu [String]
30
+ # @return [String]
31
+ def self.host_platform(host_os: RbConfig::CONFIG["host_os"], host_cpu: RbConfig::CONFIG["host_cpu"])
32
+ arch = /arm|aarch64/.match?(host_cpu) ? "arm64" : "amd64"
33
+
34
+ case host_os
35
+ when /darwin/ then "macos"
36
+ when /mswin|mingw|cygwin/ then "windows-#{arch}"
37
+ else "linux-#{arch}"
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ require_relative "engine/installation"
44
+ require_relative "engine/spec"
45
+ require_relative "engine/inspector"
46
+ require_relative "engine/store"
47
+ require_relative "engine/importer"
48
+ require_relative "engine/materializer"
49
+ require_relative "engine/git_ignore"
50
+ require_relative "engine/fetchers"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DrTalon
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module DrTalon
6
+ # Copies single files, cloning them (reflinks) where the filesystem allows so
7
+ # copies of the same engine into many projects share storage. Falls back to
8
+ # a byte copy across filesystems or on filesystems without cloning.
9
+ module FileCopy
10
+ # Linux +ioctl(dst, FICLONE, src)+; supported by btrfs and XFS.
11
+ FICLONE = 0x40049409
12
+ CLONE_UNSUPPORTED = [
13
+ Errno::EOPNOTSUPP, Errno::EXDEV, Errno::EINVAL, Errno::ENOTTY, Errno::EPERM, Errno::EBADF, NotImplementedError
14
+ ].freeze
15
+
16
+ module_function
17
+
18
+ # Copies +src+ to +dst+, keeping the mode and timestamps. An existing
19
+ # +dst+ (including a symlink) is replaced rather than written through.
20
+ #
21
+ # @param src [String]
22
+ # @param dst [String]
23
+ # @return [Boolean] whether the file was cloned rather than byte-copied
24
+ def copy(src, dst)
25
+ File.unlink(dst) if File.symlink?(dst) || File.exist?(dst)
26
+
27
+ cloned = File.open(src, "rb") do |input|
28
+ File.open(dst, File::WRONLY | File::CREAT | File::EXCL | File::BINARY) do |output|
29
+ try_clone(input, output).tap { |done| IO.copy_stream(input, output) unless done }
30
+ end
31
+ end
32
+
33
+ stat = File.stat(src)
34
+ File.chmod(stat.mode & 0o7777, dst)
35
+ File.utime(stat.atime, stat.mtime, dst)
36
+ cloned
37
+ end
38
+
39
+ # @return [Boolean] whether the clone succeeded
40
+ def try_clone(input, output)
41
+ return false unless RbConfig::CONFIG["host_os"].include?("linux")
42
+
43
+ output.ioctl(FICLONE, input.fileno)
44
+ true
45
+ rescue *CLONE_UNSUPPORTED
46
+ false
47
+ end
48
+ end
49
+ end