devex 0.3.5
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 +7 -0
- data/.obsidian/app.json +6 -0
- data/.obsidian/appearance.json +4 -0
- data/.obsidian/community-plugins.json +5 -0
- data/.obsidian/core-plugins.json +33 -0
- data/.obsidian/plugins/obsidian-minimal-settings/data.json +34 -0
- data/.obsidian/plugins/obsidian-minimal-settings/main.js +8 -0
- data/.obsidian/plugins/obsidian-minimal-settings/manifest.json +11 -0
- data/.obsidian/plugins/obsidian-style-settings/data.json +15 -0
- data/.obsidian/plugins/obsidian-style-settings/main.js +165 -0
- data/.obsidian/plugins/obsidian-style-settings/manifest.json +10 -0
- data/.obsidian/plugins/obsidian-style-settings/styles.css +243 -0
- data/.obsidian/plugins/table-editor-obsidian/data.json +6 -0
- data/.obsidian/plugins/table-editor-obsidian/main.js +236 -0
- data/.obsidian/plugins/table-editor-obsidian/manifest.json +17 -0
- data/.obsidian/plugins/table-editor-obsidian/styles.css +78 -0
- data/.obsidian/themes/AnuPpuccin/manifest.json +7 -0
- data/.obsidian/themes/AnuPpuccin/theme.css +9080 -0
- data/.obsidian/themes/Minimal/manifest.json +8 -0
- data/.obsidian/themes/Minimal/theme.css +2251 -0
- data/.rubocop.yml +231 -0
- data/CHANGELOG.md +97 -0
- data/LICENSE +21 -0
- data/README.md +314 -0
- data/Rakefile +13 -0
- data/devex-logo.jpg +0 -0
- data/docs/developing-tools.md +1000 -0
- data/docs/ref/agent-mode.md +46 -0
- data/docs/ref/cli-interface.md +60 -0
- data/docs/ref/configuration.md +46 -0
- data/docs/ref/design-philosophy.md +17 -0
- data/docs/ref/error-handling.md +38 -0
- data/docs/ref/io-handling.md +88 -0
- data/docs/ref/signals.md +141 -0
- data/docs/ref/temporal-software-theory.md +790 -0
- data/exe/dx +52 -0
- data/lib/devex/builtins/.index.rb +10 -0
- data/lib/devex/builtins/debug.rb +43 -0
- data/lib/devex/builtins/format.rb +44 -0
- data/lib/devex/builtins/gem.rb +77 -0
- data/lib/devex/builtins/lint.rb +61 -0
- data/lib/devex/builtins/test.rb +76 -0
- data/lib/devex/builtins/version.rb +156 -0
- data/lib/devex/cli.rb +340 -0
- data/lib/devex/context.rb +433 -0
- data/lib/devex/core/configuration.rb +136 -0
- data/lib/devex/core.rb +79 -0
- data/lib/devex/dirs.rb +210 -0
- data/lib/devex/dsl.rb +100 -0
- data/lib/devex/exec/controller.rb +245 -0
- data/lib/devex/exec/result.rb +229 -0
- data/lib/devex/exec.rb +662 -0
- data/lib/devex/loader.rb +136 -0
- data/lib/devex/output.rb +257 -0
- data/lib/devex/project_paths.rb +309 -0
- data/lib/devex/support/ansi.rb +437 -0
- data/lib/devex/support/core_ext.rb +560 -0
- data/lib/devex/support/global.rb +68 -0
- data/lib/devex/support/path.rb +357 -0
- data/lib/devex/support.rb +71 -0
- data/lib/devex/template_helpers.rb +136 -0
- data/lib/devex/templates/debug.erb +24 -0
- data/lib/devex/tool.rb +374 -0
- data/lib/devex/version.rb +5 -0
- data/lib/devex/working_dir.rb +99 -0
- data/lib/devex.rb +158 -0
- data/ruby-project-template/.gitignore +0 -0
- data/ruby-project-template/Gemfile +0 -0
- data/ruby-project-template/README.md +0 -0
- data/ruby-project-template/docs/README.md +0 -0
- data/sig/devex.rbs +4 -0
- metadata +122 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
7
|
+
module Devex
|
|
8
|
+
module Support
|
|
9
|
+
# Enhanced Pathname with ergonomic shortcuts for CLI development.
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# path = Path["~/src/project"]
|
|
13
|
+
# path = "~/src/project".to_p # With String refinement
|
|
14
|
+
# path = Path.pwd
|
|
15
|
+
#
|
|
16
|
+
# # Joining with / operator
|
|
17
|
+
# path / "lib" / "foo.rb"
|
|
18
|
+
#
|
|
19
|
+
# # Permission checks
|
|
20
|
+
# path.r? # readable?
|
|
21
|
+
# path.w? # writable?
|
|
22
|
+
# path.rw? # readable and writable?
|
|
23
|
+
#
|
|
24
|
+
# # Existence
|
|
25
|
+
# path.exist?
|
|
26
|
+
# path.missing?
|
|
27
|
+
# path.existence # Returns self or nil
|
|
28
|
+
#
|
|
29
|
+
# # Relative paths
|
|
30
|
+
# path.rel # Relative to pwd with ~/
|
|
31
|
+
# path.rel(from: project_root) # Relative to specific dir
|
|
32
|
+
# path.short # Shortest representation
|
|
33
|
+
#
|
|
34
|
+
# # Globbing
|
|
35
|
+
# path["**/*.rb"]
|
|
36
|
+
#
|
|
37
|
+
# # Safe directory creation
|
|
38
|
+
# path.dir! # Creates parent dirs, returns self
|
|
39
|
+
#
|
|
40
|
+
# # File I/O
|
|
41
|
+
# path.read
|
|
42
|
+
# path.write(content)
|
|
43
|
+
# path.append(content)
|
|
44
|
+
# path.atomic_write(content)
|
|
45
|
+
#
|
|
46
|
+
class Path < Pathname
|
|
47
|
+
class << self
|
|
48
|
+
# Construct from string: Path["~/src/project"]
|
|
49
|
+
def [](path) = new(expand_user(path.to_s))
|
|
50
|
+
|
|
51
|
+
# Current working directory
|
|
52
|
+
def pwd = new(Dir.pwd)
|
|
53
|
+
alias cwd pwd
|
|
54
|
+
alias getwd pwd
|
|
55
|
+
|
|
56
|
+
# Home directory
|
|
57
|
+
def home = new(Dir.home)
|
|
58
|
+
|
|
59
|
+
# Temporary directory
|
|
60
|
+
def tmp = new(Dir.tmpdir)
|
|
61
|
+
alias tmpdir tmp
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Expand ~ and ~user in path strings
|
|
66
|
+
def expand_user(path)
|
|
67
|
+
return path unless path.start_with?("~")
|
|
68
|
+
|
|
69
|
+
File.expand_path(path)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# ─────────────────────────────────────────────────────────────
|
|
74
|
+
# Path Joining
|
|
75
|
+
# ─────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
# Division operator for path joining: path / "subdir" / "file.rb"
|
|
78
|
+
def /(other) = self.class.new(join(other.to_s))
|
|
79
|
+
|
|
80
|
+
# Override join to return Path
|
|
81
|
+
def join(*args)
|
|
82
|
+
return self if args.empty?
|
|
83
|
+
|
|
84
|
+
self.class.new(super(*args.map(&:to_s)))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# ─────────────────────────────────────────────────────────────
|
|
88
|
+
# Permission Checks
|
|
89
|
+
# ─────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def r? = readable_real?
|
|
92
|
+
def w? = writable_real?
|
|
93
|
+
def x? = executable_real?
|
|
94
|
+
def rw? = r? && w?
|
|
95
|
+
def rx? = r? && x?
|
|
96
|
+
def wx? = w? && x?
|
|
97
|
+
def rwx? = r? && w? && x?
|
|
98
|
+
|
|
99
|
+
# ─────────────────────────────────────────────────────────────
|
|
100
|
+
# Type Checks
|
|
101
|
+
# ─────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
def dir? = directory?
|
|
104
|
+
def missing? = !exist?
|
|
105
|
+
alias exists? exist?
|
|
106
|
+
|
|
107
|
+
# ActiveSupport-style: returns self if exists, nil otherwise
|
|
108
|
+
def existence = exist? ? self : nil
|
|
109
|
+
|
|
110
|
+
# ─────────────────────────────────────────────────────────────
|
|
111
|
+
# Memoized Expansions
|
|
112
|
+
# ─────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
# Expanded path (memoized)
|
|
115
|
+
def exp = @exp ||= self.class.new(expand_path)
|
|
116
|
+
|
|
117
|
+
# Real path with fallback to expanded (memoized, safe)
|
|
118
|
+
def real
|
|
119
|
+
@real ||= begin
|
|
120
|
+
self.class.new(realpath)
|
|
121
|
+
rescue Errno::ENOENT
|
|
122
|
+
exp
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Clear memoization (if path might have changed)
|
|
127
|
+
def reload!
|
|
128
|
+
@exp = @real = @rc = nil
|
|
129
|
+
self
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# ─────────────────────────────────────────────────────────────
|
|
133
|
+
# Relative Paths
|
|
134
|
+
# ─────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
# Relative path with ~ substitution for home directory
|
|
137
|
+
# @param from [Path, String] Base directory (default: pwd)
|
|
138
|
+
# @param home [Boolean] Substitute ~ for home directory
|
|
139
|
+
def rel(from: nil, home: true)
|
|
140
|
+
from = self.class.new(from&.to_s || Dir.pwd)
|
|
141
|
+
result = exp.relative_path_from(from.exp)
|
|
142
|
+
result = self.class.new(result)
|
|
143
|
+
|
|
144
|
+
result = self.class.new(result.to_s.sub(Dir.home, "~")) if home && Dir.home && result.to_s.start_with?(Dir.home)
|
|
145
|
+
result
|
|
146
|
+
rescue ArgumentError
|
|
147
|
+
# Can't compute relative path (different drives on Windows, etc.)
|
|
148
|
+
if home && Dir.home
|
|
149
|
+
self.class.new(to_s.sub(Dir.home, "~"))
|
|
150
|
+
else
|
|
151
|
+
self
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Shortest representation of path
|
|
156
|
+
def short(from: nil)
|
|
157
|
+
from = self.class.new(from&.to_s || Dir.pwd)
|
|
158
|
+
candidates = []
|
|
159
|
+
|
|
160
|
+
# Try relative from base
|
|
161
|
+
begin
|
|
162
|
+
candidates << exp.relative_path_from(from.exp)
|
|
163
|
+
rescue ArgumentError
|
|
164
|
+
# Ignore - can't compute relative
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Try with ~ substitution
|
|
168
|
+
if Dir.home
|
|
169
|
+
home_sub = to_s.sub(Dir.home, "~")
|
|
170
|
+
candidates << self.class.new(home_sub) if home_sub != to_s
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Original as fallback
|
|
174
|
+
candidates << self
|
|
175
|
+
|
|
176
|
+
candidates.compact.min_by { |c| c.to_s.length }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Cache for relative path calculations (expensive operation)
|
|
180
|
+
def relative_path_from(base)
|
|
181
|
+
@rc ||= {}
|
|
182
|
+
@rc[base.to_s] ||= self.class.new(super)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# ─────────────────────────────────────────────────────────────
|
|
186
|
+
# Globbing
|
|
187
|
+
# ─────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
# Glob from this directory: path["**/*.rb"]
|
|
190
|
+
def [](pattern, include_dotfiles: true)
|
|
191
|
+
base = directory? ? self : dirname
|
|
192
|
+
flags = include_dotfiles ? File::FNM_DOTMATCH : 0
|
|
193
|
+
Pathname.glob((base / pattern).to_s, flags).map { |p| self.class.new(p) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Alias for more explicit calls
|
|
197
|
+
def glob(pattern, **) = self[pattern, **]
|
|
198
|
+
|
|
199
|
+
# ─────────────────────────────────────────────────────────────
|
|
200
|
+
# Directory Operations
|
|
201
|
+
# ─────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
# Get directory (dirname, but returns self if already a directory)
|
|
204
|
+
def dir = directory? ? exp : self.class.new(dirname)
|
|
205
|
+
|
|
206
|
+
# Create directory (and parents) if missing, return self
|
|
207
|
+
# Safe - returns nil on error instead of raising
|
|
208
|
+
def dir!
|
|
209
|
+
target = directory? ? self : dir
|
|
210
|
+
target.mkpath unless target.exist?
|
|
211
|
+
self
|
|
212
|
+
rescue SystemCallError
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Create this directory (and parents) if missing
|
|
217
|
+
def mkdir!
|
|
218
|
+
mkpath unless exist?
|
|
219
|
+
self
|
|
220
|
+
rescue SystemCallError
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# ─────────────────────────────────────────────────────────────
|
|
225
|
+
# File I/O
|
|
226
|
+
# ─────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
# Read entire file contents
|
|
229
|
+
def read(encoding: nil)
|
|
230
|
+
opts = encoding ? { encoding: encoding } : {}
|
|
231
|
+
File.read(to_s, **opts)
|
|
232
|
+
end
|
|
233
|
+
alias contents read
|
|
234
|
+
|
|
235
|
+
# Read as lines
|
|
236
|
+
def lines(chomp: true) = File.readlines(to_s, chomp: chomp)
|
|
237
|
+
|
|
238
|
+
# Write contents (creates parent directories)
|
|
239
|
+
def write(content, encoding: nil)
|
|
240
|
+
dir!
|
|
241
|
+
opts = encoding ? { encoding: encoding } : {}
|
|
242
|
+
File.write(to_s, content, **opts)
|
|
243
|
+
self
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Append to file
|
|
247
|
+
def append(content)
|
|
248
|
+
dir!
|
|
249
|
+
File.write(to_s, content, mode: "a")
|
|
250
|
+
self
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Atomic write (write to temp, then rename)
|
|
254
|
+
def atomic_write(content)
|
|
255
|
+
dir!
|
|
256
|
+
require "tempfile"
|
|
257
|
+
Tempfile.create([basename.to_s, extname], dirname.to_s) do |temp|
|
|
258
|
+
temp.binmode
|
|
259
|
+
temp.write(content)
|
|
260
|
+
temp.close
|
|
261
|
+
FileUtils.mv(temp.path, to_s)
|
|
262
|
+
end
|
|
263
|
+
self
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Delete file (no-op if doesn't exist)
|
|
267
|
+
def rm
|
|
268
|
+
File.delete(to_s) if file?
|
|
269
|
+
self
|
|
270
|
+
end
|
|
271
|
+
alias delete rm
|
|
272
|
+
alias unlink rm
|
|
273
|
+
|
|
274
|
+
# Delete directory recursively (no-op if doesn't exist)
|
|
275
|
+
def rm_rf
|
|
276
|
+
FileUtils.rm_rf(to_s) if exist?
|
|
277
|
+
self
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# ─────────────────────────────────────────────────────────────
|
|
281
|
+
# Modification Time Comparisons
|
|
282
|
+
# ─────────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
def newer_than?(other)
|
|
285
|
+
other = self.class[other] unless other.is_a?(Pathname)
|
|
286
|
+
return true unless other.exist?
|
|
287
|
+
return false unless exist?
|
|
288
|
+
|
|
289
|
+
mtime > other.mtime
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def older_than?(other)
|
|
293
|
+
other = self.class[other] unless other.is_a?(Pathname)
|
|
294
|
+
return false unless other.exist?
|
|
295
|
+
return true unless exist?
|
|
296
|
+
|
|
297
|
+
mtime < other.mtime
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# ─────────────────────────────────────────────────────────────
|
|
301
|
+
# Extension Manipulation
|
|
302
|
+
# ─────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
# Replace extension: path.with_ext(".md")
|
|
305
|
+
def with_ext(new_ext)
|
|
306
|
+
new_ext = ".#{new_ext}" unless new_ext.to_s.start_with?(".")
|
|
307
|
+
self.class.new(sub_ext(new_ext.to_s))
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Remove extension
|
|
311
|
+
def without_ext = self.class.new(to_s.sub(/#{Regexp.escape(extname)}$/, ""))
|
|
312
|
+
|
|
313
|
+
# ─────────────────────────────────────────────────────────────
|
|
314
|
+
# Siblings and Relatives
|
|
315
|
+
# ─────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
# Sibling with same directory but different name
|
|
318
|
+
def sibling(name) = dir / name.to_s
|
|
319
|
+
|
|
320
|
+
# Override parent to return Path
|
|
321
|
+
def parent = self.class.new(super)
|
|
322
|
+
|
|
323
|
+
# Override dirname to return Path
|
|
324
|
+
def dirname = self.class.new(super)
|
|
325
|
+
|
|
326
|
+
# Override basename to return Path
|
|
327
|
+
def basename(*args) = self.class.new(super)
|
|
328
|
+
|
|
329
|
+
# ─────────────────────────────────────────────────────────────
|
|
330
|
+
# Inspection
|
|
331
|
+
# ─────────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
def inspect = "#<Path:#{self}>"
|
|
334
|
+
|
|
335
|
+
# ─────────────────────────────────────────────────────────────
|
|
336
|
+
# String-like Behavior
|
|
337
|
+
# ─────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
# Delegate string methods to to_s
|
|
340
|
+
def method_missing(method, *, &)
|
|
341
|
+
if to_s.respond_to?(method)
|
|
342
|
+
result = to_s.send(method, *, &)
|
|
343
|
+
# Return Path if result looks like a path
|
|
344
|
+
if result.is_a?(String) && result.include?("/")
|
|
345
|
+
self.class.new(result)
|
|
346
|
+
else
|
|
347
|
+
result
|
|
348
|
+
end
|
|
349
|
+
else
|
|
350
|
+
super
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def respond_to_missing?(method, include_private = false) = to_s.respond_to?(method, include_private) || super
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Devex Support Library
|
|
4
|
+
#
|
|
5
|
+
# A zero-dependency collection of Ruby utilities optimized for CLI development.
|
|
6
|
+
# Provides Path manipulation, ANSI colors, and core extensions as refinements.
|
|
7
|
+
#
|
|
8
|
+
# ## Quick Start
|
|
9
|
+
#
|
|
10
|
+
# require "devex/support"
|
|
11
|
+
#
|
|
12
|
+
# # Use refinements (scoped to current file)
|
|
13
|
+
# using Devex::Support::CoreExt
|
|
14
|
+
# using Devex::Support::ANSI::StringMethods
|
|
15
|
+
#
|
|
16
|
+
# # Or for CLI tools, load globally:
|
|
17
|
+
# require "devex/support/global"
|
|
18
|
+
#
|
|
19
|
+
# ## Components
|
|
20
|
+
#
|
|
21
|
+
# ### Path - Enhanced Pathname for CLI tools
|
|
22
|
+
#
|
|
23
|
+
# path = Path["~/src/project"]
|
|
24
|
+
# path = Path.pwd / "lib" / "foo.rb"
|
|
25
|
+
#
|
|
26
|
+
# path.r? # readable?
|
|
27
|
+
# path.w? # writable?
|
|
28
|
+
# path.exist? # exists?
|
|
29
|
+
# path.dir! # ensure parent dirs exist
|
|
30
|
+
# path.rel # relative path with ~ for home
|
|
31
|
+
# path.short # shortest representation
|
|
32
|
+
#
|
|
33
|
+
# ### ANSI - Terminal colors (truecolor, zero deps)
|
|
34
|
+
#
|
|
35
|
+
# ANSI["text", :bold, :success]
|
|
36
|
+
# ANSI["text", "#5AF78E"]
|
|
37
|
+
# ANSI % ["Outer %{inner}", :yellow, inner: ["nested", :blue]]
|
|
38
|
+
#
|
|
39
|
+
# ### CoreExt - Refinements for common operations
|
|
40
|
+
#
|
|
41
|
+
# "".blank? # => true
|
|
42
|
+
# nil.present? # => false
|
|
43
|
+
# [1,2,3].average # => 2.0
|
|
44
|
+
# {a: 1}.deep_merge(b: 2) # => {a: 1, b: 2}
|
|
45
|
+
# "hello world".truncate(8) # => "hello..."
|
|
46
|
+
#
|
|
47
|
+
|
|
48
|
+
module Devex
|
|
49
|
+
module Support
|
|
50
|
+
# Autoload support modules
|
|
51
|
+
autoload :Path, "devex/support/path"
|
|
52
|
+
autoload :ANSI, "devex/support/ansi"
|
|
53
|
+
autoload :CoreExt, "devex/support/core_ext"
|
|
54
|
+
|
|
55
|
+
# Convenience: expose Path at module level
|
|
56
|
+
# Allows: Devex::Support::Path["~/foo"]
|
|
57
|
+
# Or after `include Devex::Support`: Path["~/foo"]
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
# Version of the support library
|
|
61
|
+
def version = "0.1.0"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Also expose Path at Devex level for convenience
|
|
67
|
+
# Allows: Devex::Path["~/foo"]
|
|
68
|
+
module Devex
|
|
69
|
+
Path = Support::Path
|
|
70
|
+
ANSI = Support::ANSI
|
|
71
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "support/ansi"
|
|
4
|
+
|
|
5
|
+
module Devex
|
|
6
|
+
# Helper methods available in ERB templates.
|
|
7
|
+
# Automatically respects Context.color? for all styling.
|
|
8
|
+
#
|
|
9
|
+
# Usage in templates:
|
|
10
|
+
# <%= c :green, "success" %>
|
|
11
|
+
# <%= c :bold, :white, "header" %>
|
|
12
|
+
# <%= sym :success %> All tests passed
|
|
13
|
+
# <%= hr %>
|
|
14
|
+
# <%= heading "Section" %>
|
|
15
|
+
#
|
|
16
|
+
module TemplateHelpers
|
|
17
|
+
# Color definitions (truecolor RGB) - matches Output::COLORS
|
|
18
|
+
COLORS = {
|
|
19
|
+
success: [0x5A, 0xF7, 0x8E],
|
|
20
|
+
error: [0xFF, 0x6B, 0x6B],
|
|
21
|
+
warning: [0xFF, 0xE6, 0x6D],
|
|
22
|
+
info: [0x6B, 0xC5, 0xFF],
|
|
23
|
+
header: [0xC4, 0xB5, 0xFD],
|
|
24
|
+
muted: [0x88, 0x88, 0x88],
|
|
25
|
+
emphasis: [0xFF, 0xFF, 0xFF]
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# Symbols - basic unicode that works everywhere
|
|
29
|
+
# (Not nerdfont glyphs or emoji that render as images)
|
|
30
|
+
SYMBOLS = {
|
|
31
|
+
success: "✓",
|
|
32
|
+
error: "✗",
|
|
33
|
+
warning: "⚠",
|
|
34
|
+
info: "ℹ",
|
|
35
|
+
arrow: "→",
|
|
36
|
+
bullet: "•",
|
|
37
|
+
check: "✓",
|
|
38
|
+
cross: "✗",
|
|
39
|
+
dot: "·"
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# Colorize text - the main helper
|
|
45
|
+
# Last argument is the text, preceding arguments are colors/styles
|
|
46
|
+
#
|
|
47
|
+
# Examples:
|
|
48
|
+
# c(:green, "text")
|
|
49
|
+
# c(:bold, :white, "text")
|
|
50
|
+
# c(:success, "text") # uses named color
|
|
51
|
+
# c([0x5A, 0xF7, 0x8E], "text") # RGB array
|
|
52
|
+
#
|
|
53
|
+
def c(*args)
|
|
54
|
+
text = args.pop.to_s
|
|
55
|
+
return text unless Context.color?
|
|
56
|
+
return text if args.empty?
|
|
57
|
+
|
|
58
|
+
# Expand named colors to RGB
|
|
59
|
+
colors = args.map do |color|
|
|
60
|
+
if color.is_a?(Symbol) && COLORS.key?(color)
|
|
61
|
+
COLORS[color]
|
|
62
|
+
else
|
|
63
|
+
color
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Support::ANSI[text, *colors]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Get symbol - always unicode (basic unicode works everywhere)
|
|
71
|
+
def sym(name) = SYMBOLS.fetch(name, name.to_s)
|
|
72
|
+
|
|
73
|
+
# Colored symbol - combines sym() and c()
|
|
74
|
+
def csym(name, color = nil)
|
|
75
|
+
color ||= name # Default: use symbol name as color name
|
|
76
|
+
s = sym(name)
|
|
77
|
+
c(color, s)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Horizontal rule
|
|
81
|
+
def hr(char: "─", width: 40)
|
|
82
|
+
line = char * width
|
|
83
|
+
Context.color? ? c(:muted, line) : line
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Styled heading
|
|
87
|
+
def heading(text, char: "=", width: nil)
|
|
88
|
+
width ||= text.length
|
|
89
|
+
line = char * width
|
|
90
|
+
if Context.color?
|
|
91
|
+
"#{c(:header, text)}\n#{c(:muted, line)}"
|
|
92
|
+
else
|
|
93
|
+
"#{text}\n#{line}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Muted/secondary text
|
|
98
|
+
def muted(text) = c(:muted, text)
|
|
99
|
+
|
|
100
|
+
# Bold text
|
|
101
|
+
def bold(text) = c(:bold, text)
|
|
102
|
+
|
|
103
|
+
# Create a binding with all helpers and locals available
|
|
104
|
+
def template_binding(locals = {}) = TemplateContext.new(locals).get_binding
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Context object for template rendering
|
|
108
|
+
# Includes all helper methods directly so templates can use clean syntax:
|
|
109
|
+
# <%= c :green, "text" %>
|
|
110
|
+
# <%= heading "Title" %>
|
|
111
|
+
#
|
|
112
|
+
class TemplateContext
|
|
113
|
+
include TemplateHelpers
|
|
114
|
+
|
|
115
|
+
def initialize(locals = {})
|
|
116
|
+
@locals = locals
|
|
117
|
+
# Define accessor methods for each local variable
|
|
118
|
+
locals.each do |name, value|
|
|
119
|
+
define_singleton_method(name) { value }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def get_binding = binding
|
|
124
|
+
|
|
125
|
+
# Allow accessing locals as methods or via method_missing
|
|
126
|
+
def method_missing(name, *args, &)
|
|
127
|
+
if @locals.key?(name)
|
|
128
|
+
@locals[name]
|
|
129
|
+
else
|
|
130
|
+
super
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def respond_to_missing?(name, include_private = false) = @locals.key?(name) || super
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%= heading "Context Detection" %>
|
|
2
|
+
|
|
3
|
+
<%= c :header, "TTY Detection:" %>
|
|
4
|
+
stdout.tty? = <%= c(tty[:stdout] ? :success : :muted, tty[:stdout]) %>
|
|
5
|
+
stderr.tty? = <%= c(tty[:stderr] ? :success : :muted, tty[:stderr]) %>
|
|
6
|
+
stdin.tty? = <%= c(tty[:stdin] ? :success : :muted, tty[:stdin]) %>
|
|
7
|
+
terminal? = <%= c(tty[:terminal] ? :success : :muted, tty[:terminal]) %>
|
|
8
|
+
|
|
9
|
+
<%= c :header, "Streams:" %>
|
|
10
|
+
streams_merged? = <%= c(streams[:merged] ? :warning : :muted, streams[:merged]) %>
|
|
11
|
+
piped? = <%= c(streams[:piped] ? :info : :muted, streams[:piped]) %>
|
|
12
|
+
|
|
13
|
+
<%= c :header, "Environment:" %>
|
|
14
|
+
ci? = <%= c(environment[:ci] ? :info : :muted, environment[:ci]) %>
|
|
15
|
+
env = <%= c :emphasis, environment[:env] %>
|
|
16
|
+
agent_mode_env? = <%= c(environment[:agent_mode_env] ? :warning : :muted, environment[:agent_mode_env]) %>
|
|
17
|
+
|
|
18
|
+
<%= c :header, "Detection:" %>
|
|
19
|
+
agent_mode? = <%= c(detection[:agent_mode] ? :warning : :success, detection[:agent_mode]) %>
|
|
20
|
+
interactive? = <%= c(detection[:interactive] ? :success : :muted, detection[:interactive]) %>
|
|
21
|
+
color? = <%= c(detection[:color] ? :success : :muted, detection[:color]) %>
|
|
22
|
+
|
|
23
|
+
<%= muted "Call Tree: #{call_tree.inspect}" %>
|
|
24
|
+
<%= muted "Overrides: #{overrides.inspect}" %>
|