charming 0.1.3 → 0.1.4

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/lib/charming/application.rb +19 -2
  3. data/lib/charming/cli.rb +3 -3
  4. data/lib/charming/controller/component_dispatching.rb +47 -3
  5. data/lib/charming/controller/focus.rb +123 -0
  6. data/lib/charming/controller/focus_management.rb +1 -1
  7. data/lib/charming/controller/rendering.rb +4 -15
  8. data/lib/charming/controller/session_state.rb +11 -0
  9. data/lib/charming/controller.rb +11 -2
  10. data/lib/charming/database/commands.rb +106 -0
  11. data/lib/charming/generators/database_installer.rb +154 -0
  12. data/lib/charming/generators/model_generator.rb +2 -10
  13. data/lib/charming/generators/name.rb +1 -1
  14. data/lib/charming/generators/view_generator.rb +1 -1
  15. data/lib/charming/presentation/components/form/field.rb +1 -1
  16. data/lib/charming/presentation/components/markdown.rb +7 -7
  17. data/lib/charming/presentation/layout/pane.rb +7 -0
  18. data/lib/charming/presentation/layout/rect.rb +5 -0
  19. data/lib/charming/presentation/layout/screen_layout.rb +7 -0
  20. data/lib/charming/presentation/layout/split.rb +7 -0
  21. data/lib/charming/presentation/markdown/render_context.rb +28 -10
  22. data/lib/charming/presentation/markdown/renderer.rb +264 -39
  23. data/lib/charming/presentation/markdown/style_config.rb +215 -0
  24. data/lib/charming/presentation/markdown/syntax_highlighter.rb +3 -2
  25. data/lib/charming/presentation/markdown.rb +2 -2
  26. data/lib/charming/presentation/view.rb +7 -0
  27. data/lib/charming/router.rb +3 -8
  28. data/lib/charming/runtime.rb +2 -0
  29. data/lib/charming/version.rb +1 -1
  30. data/lib/charming.rb +2 -2
  31. metadata +42 -9
  32. data/lib/charming/database_commands.rb +0 -103
  33. data/lib/charming/database_installer.rb +0 -152
  34. data/lib/charming/focus.rb +0 -121
  35. data/lib/charming/presentation/markdown/block_renderers.rb +0 -118
  36. data/lib/charming/presentation/markdown/inline_renderers.rb +0 -66
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc7f17fae2c6012942e00a20986ee7928b3f1a71e9d6fcb4a9855439e1281fd3
4
- data.tar.gz: 63d288c1cbecc5830b2c1d3cfece4d0a15a58241ee9b6e130d6ea13c9256767f
3
+ metadata.gz: 63a0e6137fddcdcd90ebeb7f64a4a5d8b7d24bce082a87f27273eadc3d20a3e7
4
+ data.tar.gz: 925a673a508c582bb259da12794653b65ac30d6184e9b48f586db0ae3d0de7ba
5
5
  SHA512:
6
- metadata.gz: 374717171efb3639c11cd96748fb41e02a9bff74ec3c7e2325770d90f5b5db193df8c7ebaca1e73dffcf597f1e14fc3af6971d8cf9780026dbe5b3db6513cebd
7
- data.tar.gz: 1870fb231844e57394d0412de0a752d1927a8bf6c4347daff86d26bed96c4226744f9cc26a9fe8ce0992b7bb604e87a4763b3747ad0ea58a56876cf59f2692bf
6
+ metadata.gz: 18cc22164930e56efe5c768fd947c7dd23009b49d2266b5d849b71dedae30202a4b32540a9c595ec5490ae4b5ee7a6f0bf167796fcbd5fb41e674db312696840
7
+ data.tar.gz: 4b2ed08745f24cc99082d604247e1cf1e501f2188f217ed70ced49065c22587e59c335ce11aede01a47ba2626de78b9cccb2fafa50abea8273b86eda779f0736
@@ -5,6 +5,7 @@ module Charming
5
5
  # terminal-based apps. It provides routing (via a DSL), session storage, and
6
6
  # task execution for managing async operations.
7
7
  class Application
8
+ LOGGER_READER = Object.new.freeze
8
9
  THEME_READER = Object.new.freeze
9
10
 
10
11
  class << self
@@ -19,7 +20,15 @@ module Charming
19
20
  # Derives the module namespace from the class name — e.g., Admin::HomeController
20
21
  # yields "Admin". Mirrors Rails' engine-style namespacing.
21
22
  def namespace
22
- name&.split("::")&.then { |parts| parts[0...-1].join("::") }
23
+ ActiveSupport::Inflector.deconstantize(name.to_s)
24
+ end
25
+
26
+ # Returns or sets the app logger. Defaults to a null-device logger so app and framework code
27
+ # can safely call logging methods without writing into the terminal UI.
28
+ def logger(value = LOGGER_READER)
29
+ return configured_logger if value == LOGGER_READER
30
+
31
+ @logger = value
23
32
  end
24
33
 
25
34
  # Returns the app's filesystem root, used to resolve relative theme and template paths.
@@ -67,6 +76,13 @@ module Charming
67
76
 
68
77
  private
69
78
 
79
+ def configured_logger
80
+ return @logger if instance_variable_defined?(:@logger)
81
+ return superclass.logger if superclass.respond_to?(:logger)
82
+
83
+ @logger = Logger.new(File::NULL)
84
+ end
85
+
70
86
  # Expands a relative theme path against the app root (or the current working directory
71
87
  # when no root is configured). Returns *path* unchanged when it is already absolute.
72
88
  def resolve_theme_path(path)
@@ -76,11 +92,12 @@ module Charming
76
92
  end
77
93
  end
78
94
 
79
- attr_accessor :task_executor
95
+ attr_accessor :logger, :task_executor
80
96
  attr_reader :session
81
97
 
82
98
  # Initializes an empty session hash for per-request state storage.
83
99
  def initialize
100
+ @logger = self.class.logger
84
101
  @session = {}
85
102
  end
86
103
 
data/lib/charming/cli.rb CHANGED
@@ -75,17 +75,17 @@ module Charming
75
75
  end
76
76
 
77
77
  # Routes `db:*` commands to either the install path (db:install) or the generic
78
- # DatabaseCommands dispatcher.
78
+ # Database::Commands dispatcher.
79
79
  def database(command, args)
80
80
  if command == "db:install"
81
81
  database = args.shift || raise(Generators::Error, "Usage: charming db:install sqlite3")
82
82
  raise Generators::Error, "Usage: charming db:install sqlite3" if args.any?
83
83
 
84
- DatabaseInstaller.new(database, out: out, destination: pwd).install
84
+ Generators::DatabaseInstaller.new(database, out: out, destination: pwd).install
85
85
  else
86
86
  raise Generators::Error, "Usage: charming #{command}" if args.any?
87
87
 
88
- DatabaseCommands.new(command, out: out, destination: pwd).run
88
+ Database::Commands.new(command, out: out, destination: pwd).run
89
89
  end
90
90
  0
91
91
  end
@@ -71,10 +71,54 @@ module Charming
71
71
  :handled
72
72
  end
73
73
 
74
- # Default mouse dispatch hook: subclasses/components may override by including their own
75
- # mouse logic via the controller's `dispatch_component_mouse` override.
74
+ # Hit-tests the current mouse event against named layout panes from the latest render.
75
+ # Clicks move focus to matching slots; components in clicked panes receive local coordinates.
76
76
  def dispatch_component_mouse
77
- nil
77
+ target = mouse_target_for_event
78
+ return nil unless target
79
+
80
+ slot = target.fetch(:name)
81
+ previous_focus = focus.current
82
+ focus.focus(slot) if focusable_click?(slot)
83
+
84
+ result = dispatch_mouse_to_target_component(slot, target)
85
+ return response if result.nil? && previous_focus == focus.current
86
+
87
+ result ? dispatch_component_result(slot, result) : render_default_action
88
+ response
89
+ end
90
+
91
+ def mouse_target_for_event
92
+ mouse_targets.rfind { |target| target.fetch(:rect).cover?(event.x, event.y) }
93
+ end
94
+
95
+ def focusable_click?(slot)
96
+ event.respond_to?(:click?) && event.click? && focus.ring.include?(slot)
97
+ end
98
+
99
+ def dispatch_mouse_to_target_component(slot, target)
100
+ return nil unless respond_to?(slot, true)
101
+
102
+ component = send(slot)
103
+ return nil unless component.respond_to?(:handle_mouse)
104
+
105
+ local_event = local_mouse_event(target.fetch(:inner_rect))
106
+ return nil unless local_event
107
+
108
+ component.handle_mouse(local_event)
109
+ end
110
+
111
+ def local_mouse_event(rect)
112
+ return nil unless rect.cover?(event.x, event.y)
113
+
114
+ Events::MouseEvent.new(
115
+ button: event.button,
116
+ x: event.x - rect.x,
117
+ y: event.y - rect.y,
118
+ ctrl: event.ctrl,
119
+ alt: event.alt,
120
+ shift: event.shift
121
+ )
78
122
  end
79
123
  end
80
124
  end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ class Controller
5
+ # Focus manages a stack of focus scopes (rings) for a single controller class. Each scope has
6
+ # a slot ring (a fixed list of named slots) and a current slot within that ring. Multiple
7
+ # scopes can be stacked so the command palette, modals, and layouts can each have their own
8
+ # focus contexts without interfering with one another.
9
+ #
10
+ # State lives under `session[:focus_state][controller_class_name]` so focus persists across
11
+ # controller dispatches within the same session.
12
+ class Focus
13
+ # Returns the Focus object for *controller_class* under the given *session*, creating the
14
+ # underlying session hash if absent.
15
+ def self.for(session, controller_class)
16
+ session[:focus_state] ||= {}
17
+ key = controller_class.name
18
+ session[:focus_state][key] ||= {scopes: []}
19
+ new(session[:focus_state][key])
20
+ end
21
+
22
+ def initialize(state)
23
+ @state = state
24
+ end
25
+
26
+ # Defines the primary focus ring for the controller with the given *slots*. Only effective
27
+ # the first time it is called; subsequent calls are no-ops.
28
+ def define(slots)
29
+ return if @state[:scopes].any? { |scope| scope[:origin] == :ring }
30
+
31
+ @state[:scopes] << build_scope(slots, :ring)
32
+ end
33
+
34
+ # Defines a layout scope (inserted after the primary ring and before modal scopes). *slots*
35
+ # is the list of pane names; the previously-focused layout slot is preserved when it is still
36
+ # part of the new ring.
37
+ def define_layout(slots)
38
+ current = current_layout_slot(slots)
39
+ remove_scope(:layout)
40
+ return if slots.empty?
41
+
42
+ @state[:scopes].insert(layout_scope_index, build_scope(slots, :layout, current))
43
+ end
44
+
45
+ # Pushes a new focus scope with the given *slots* onto the stack. Used by modals, palettes,
46
+ # and other overlays. *origin* is a label for the scope kind.
47
+ def push_scope(slots, origin: :modal)
48
+ @state[:scopes] << build_scope(slots, origin)
49
+ end
50
+
51
+ # Pops the topmost focus scope from the stack.
52
+ def pop_scope
53
+ @state[:scopes].pop
54
+ end
55
+
56
+ # Returns the currently focused slot, or nil when no scope is active.
57
+ def current
58
+ top && top[:current]
59
+ end
60
+
61
+ # Returns the slot ring of the topmost scope (an array of slot names). Empty when no scope.
62
+ def ring
63
+ top ? top[:ring] : []
64
+ end
65
+
66
+ # Sets the current slot within the topmost scope to *slot*. No-op when *slot* is not in the ring.
67
+ def focus(slot)
68
+ return unless ring.include?(slot)
69
+
70
+ top[:current] = slot
71
+ end
72
+
73
+ # Cycles focus by *direction* (default +1 forward) within the topmost ring. No-op on an empty ring.
74
+ def cycle(direction = +1)
75
+ return if ring.empty?
76
+
77
+ index = ring.index(current) || 0
78
+ top[:current] = ring[(index + direction) % ring.length]
79
+ end
80
+
81
+ # True when *slot* is the current focus slot.
82
+ def focused?(slot)
83
+ current == slot
84
+ end
85
+
86
+ private
87
+
88
+ # Returns the topmost scope hash (the last entry pushed onto `@state[:scopes]`).
89
+ def top
90
+ @state[:scopes].last
91
+ end
92
+
93
+ # Removes every scope whose origin equals *origin* (in place).
94
+ def remove_scope(origin)
95
+ @state[:scopes].reject! { |scope| scope[:origin] == origin }
96
+ end
97
+
98
+ # Returns the index in the scope stack where a layout scope belongs: just before the first
99
+ # non-ring, non-layout scope (i.e., at the end of the "structural" stack).
100
+ def layout_scope_index
101
+ index = @state[:scopes].index { |scope| !%i[ring layout].include?(scope[:origin]) }
102
+ index || @state[:scopes].length
103
+ end
104
+
105
+ # Returns the current layout scope's current slot, but only when it is still part of *slots*.
106
+ # Otherwise returns the first slot in *slots* (so a new layout reverts to its first pane).
107
+ def current_layout_slot(slots)
108
+ current_slot = current_layout_scope&.fetch(:current)
109
+ slots.include?(current_slot) ? current_slot : slots.first
110
+ end
111
+
112
+ # Returns the layout scope, or nil when no layout scope is present.
113
+ def current_layout_scope
114
+ @state[:scopes].find { |scope| scope[:origin] == :layout }
115
+ end
116
+
117
+ # Builds an immutable scope hash with the given *slots*, *origin*, and starting *current* slot.
118
+ def build_scope(slots, origin, current = slots.first)
119
+ {ring: slots.dup.freeze, current: current, origin: origin}
120
+ end
121
+ end
122
+ end
123
+ end
@@ -9,7 +9,7 @@ module Charming
9
9
  # Returns the per-controller Focus object, defining the focus ring from class-level DSL
10
10
  # declarations on first access.
11
11
  def focus
12
- @focus ||= Focus.for(session, self.class).tap do |f|
12
+ @focus ||= Controller::Focus.for(session, self.class).tap do |f|
13
13
  f.define(self.class.focus_ring_slots) unless self.class.focus_ring_slots.empty?
14
14
  end
15
15
  end
@@ -96,12 +96,9 @@ module Charming
96
96
  def conventional_view_constant_path(name)
97
97
  parts = name.to_s.split("/")
98
98
  action = parts.pop
99
- parts.map { |part| camelize(part) } + ["#{camelize(action)}View"]
100
- end
99
+ view_name = "#{ActiveSupport::Inflector.camelize(action.to_s)}View"
101
100
 
102
- # Converts a snake_case string to CamelCase. Used to build conventional view constant names.
103
- def camelize(value)
104
- value.to_s.split("_").map(&:capitalize).join
101
+ parts.map { |part| ActiveSupport::Inflector.camelize(part) } + [view_name]
105
102
  end
106
103
 
107
104
  # Returns the default template path for a given *action* (e.g., "home/show" for HomeController#show).
@@ -111,16 +108,8 @@ module Charming
111
108
 
112
109
  # Returns the underscored controller path (e.g., "home" for HomeController) used for view lookup.
113
110
  def controller_template_path
114
- underscore(self.class.name.split("::").last.delete_suffix("Controller"))
115
- end
116
-
117
- # Converts CamelCase to snake_case.
118
- def underscore(value)
119
- value
120
- .gsub(/([A-Z]+)([A-Z][a-z])/, "\\1_\\2")
121
- .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
122
- .tr("-", "_")
123
- .downcase
111
+ controller_name = ActiveSupport::Inflector.demodulize(self.class.name).delete_suffix("Controller")
112
+ ActiveSupport::Inflector.underscore(controller_name)
124
113
  end
125
114
  end
126
115
  end
@@ -11,6 +11,17 @@ module Charming
11
11
  application.session
12
12
  end
13
13
 
14
+ # Stores the named layout panes from the latest render so mouse events can be hit-tested
15
+ # against the same focus slots used by Tab traversal.
16
+ def register_mouse_targets(targets)
17
+ session[:mouse_targets] = targets
18
+ end
19
+
20
+ # Returns the named layout panes from the latest render.
21
+ def mouse_targets
22
+ session.fetch(:mouse_targets, [])
23
+ end
24
+
14
25
  # Returns the named session-backed state object, creating it on first access. *name* is a
15
26
  # symbol key under `session[:states]`. *state_class* is an ApplicationState subclass whose
16
27
  # constructor receives *attributes* on first creation. Subsequent calls return the same object.
@@ -67,8 +67,11 @@ module Charming
67
67
  # Mouse event dispatcher: checks command palette (if open), sidebar (if focused).
68
68
  def dispatch_mouse
69
69
  return dispatch_command_palette_mouse if command_palette_open?
70
- return dispatch_sidebar_mouse if sidebar_focused?
71
- dispatch_component_mouse
70
+
71
+ mouse_response = dispatch_component_mouse
72
+ return mouse_response if mouse_response
73
+
74
+ dispatch_sidebar_mouse if sidebar_focused?
72
75
  end
73
76
 
74
77
  # Renders a body or template wrapped in the controller's layout.
@@ -97,6 +100,12 @@ module Charming
97
100
  application.use_theme(name)
98
101
  end
99
102
 
103
+ # Returns the application logger. The default logger writes to File::NULL, so logging calls are
104
+ # safe in TUI code unless the app explicitly configures a file or custom logger.
105
+ def logger
106
+ application.logger
107
+ end
108
+
100
109
  # Opens the theme picker (a CommandPalette populated with the registered themes) and renders.
101
110
  def open_theme_palette
102
111
  session[:command_palette] = command_palette_state(:themes)
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Charming
6
+ module Database
7
+ # Commands implements the runtime side of `charming db:COMMAND` (other than
8
+ # `db:install`, which lives in Generators::DatabaseInstaller). It loads the app's
9
+ # `config/database.rb`, delegates the actual work to ActiveRecord, and prints a short
10
+ # status line on success.
11
+ class Commands
12
+ # *command* is the subcommand string (e.g., "db:create"). *out* is the status-output
13
+ # stream. *destination* is the app root for resolving `config/database.rb` and `db/`.
14
+ def initialize(command, out:, destination:)
15
+ @command = command
16
+ @out = out
17
+ @destination = destination
18
+ end
19
+
20
+ # Dispatches the configured command. Raises Generators::Error for unknown commands.
21
+ def run
22
+ case command
23
+ when "db:create" then create
24
+ when "db:migrate" then migrate
25
+ when "db:rollback" then rollback
26
+ when "db:drop" then drop
27
+ when "db:seed" then seed
28
+ else raise Generators::Error, "Unknown database command: #{command}"
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ # The subcommand, output stream, and app destination.
35
+ attr_reader :command, :out, :destination
36
+
37
+ # Creates the SQLite database file (touch) and establishes the connection.
38
+ def create
39
+ load_database
40
+ FileUtils.mkdir_p(File.dirname(database_path)) if database_path
41
+ FileUtils.touch(database_path) if database_path
42
+ ActiveRecord::Base.connection
43
+ out.puts "create #{relative_database_path}"
44
+ end
45
+
46
+ # Runs all pending migrations from `db/migrate`.
47
+ def migrate
48
+ load_database
49
+ migration_context.migrate
50
+ out.puts "migrate db/migrate"
51
+ end
52
+
53
+ # Rolls back the most recent migration.
54
+ def rollback
55
+ load_database
56
+ migration_context.rollback(1)
57
+ out.puts "rollback db/migrate"
58
+ end
59
+
60
+ # Disconnects ActiveRecord, then deletes the database file.
61
+ def drop
62
+ load_database
63
+ ActiveRecord::Base.connection.disconnect!
64
+ File.delete(database_path) if database_path && File.exist?(database_path)
65
+ out.puts "drop #{relative_database_path}"
66
+ end
67
+
68
+ # Loads `db/seeds.rb` (raises if missing).
69
+ def seed
70
+ load_database
71
+ seed_path = File.join(destination, "db", "seeds.rb")
72
+ raise Generators::Error, "Missing file: db/seeds.rb" unless File.exist?(seed_path)
73
+
74
+ load seed_path
75
+ out.puts "seed db/seeds.rb"
76
+ end
77
+
78
+ # Loads the app's `config/database.rb` (raises if missing) which establishes the connection.
79
+ def load_database
80
+ database_config = File.join(destination, "config", "database.rb")
81
+ raise Generators::Error, "Database support is not configured. Missing config/database.rb." unless File.exist?(database_config)
82
+
83
+ require database_config
84
+ end
85
+
86
+ # The ActiveRecord migration context rooted at `db/migrate` inside the app.
87
+ def migration_context
88
+ ActiveRecord::MigrationContext.new(File.join(destination, "db", "migrate"))
89
+ end
90
+
91
+ # The configured database file path (nil when ActiveRecord isn't connected to a file).
92
+ def database_path
93
+ ActiveRecord::Base.connection_db_config.database
94
+ end
95
+
96
+ # The database path relative to the app root, used for human-friendly status output.
97
+ def relative_database_path
98
+ return "database" unless database_path
99
+
100
+ base = File.realpath(destination)
101
+ path = File.expand_path(database_path)
102
+ path.start_with?("#{base}/") ? path.delete_prefix("#{base}/") : path
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Charming
6
+ module Generators
7
+ # DatabaseInstaller implements `charming db:install sqlite3`. It adds database support
8
+ # to an existing Charming app by creating `config/database.rb`, `app/models/application_record.rb`,
9
+ # `db/migrate/`, and `db/seeds.rb`, and patching the gemspec and root loader to include
10
+ # the new dependencies and the `app/models` autoload directory.
11
+ class DatabaseInstaller
12
+ # *database* is the adapter name (only "sqlite3" is currently supported). *out* is the
13
+ # status-output stream. *destination* is the app root.
14
+ def initialize(database, out:, destination:)
15
+ @database = database
16
+ @out = out
17
+ @destination = destination
18
+ @app_name = Name.new(app_name_from_gemspec)
19
+ end
20
+
21
+ # Performs the install: writes the database config, application record, migrate directory,
22
+ # seeds file, and patches the gemspec + root loader. Idempotent: existing files are
23
+ # reported with "exist <path>" instead of being overwritten.
24
+ def install
25
+ raise Error, "Unsupported database: #{database.inspect}" unless database == "sqlite3"
26
+
27
+ create_file("config/database.rb", database_config)
28
+ create_file("app/models/application_record.rb", application_record)
29
+ create_file("db/migrate/.keep", "")
30
+ create_file("db/seeds.rb", %(# frozen_string_literal: true
31
+ ))
32
+ update_gemspec
33
+ update_root_file
34
+ end
35
+
36
+ private
37
+
38
+ # The database adapter, status stream, app destination, and derived app name.
39
+ attr_reader :database, :out, :destination, :app_name
40
+
41
+ # Writes *content* to *path* (relative to the app root), creating intermediate directories.
42
+ # Reports "exist <path>" without overwriting when the file already exists.
43
+ def create_file(path, content)
44
+ absolute_path = File.join(destination, path)
45
+ if File.exist?(absolute_path)
46
+ out.puts "exist #{path}"
47
+ return
48
+ end
49
+
50
+ FileUtils.mkdir_p(File.dirname(absolute_path))
51
+ File.write(absolute_path, content)
52
+ out.puts "create #{path}"
53
+ end
54
+
55
+ # Patches the gemspec to include the `db` directory in the gem files glob and to add
56
+ # activerecord + sqlite3 dependencies.
57
+ def update_gemspec
58
+ update_file(gemspec_path) do |current|
59
+ updated = current.sub('Dir.glob("{app,config,exe,lib}/**/*")', 'Dir.glob("{app,config,db,exe,lib}/**/*")')
60
+ updated = insert_dependency(updated, "activerecord", "~> 8.1")
61
+ insert_dependency(updated, "sqlite3", "~> 2.0")
62
+ end
63
+ end
64
+
65
+ # Patches the root loader file (`lib/<app>.rb`) to require `config/database` and to push
66
+ # the `app/models` autoload directory. Both edits are no-ops when already applied.
67
+ def update_root_file
68
+ update_file(root_file_path) do |current|
69
+ updated = current
70
+ updated = updated.sub(%(require "zeitwerk"\n), %(require "zeitwerk"\nrequire_relative "../config/database"\n)) unless updated.include?(%(require_relative "../config/database"))
71
+ unless updated.include?(%[loader.push_dir(File.expand_path("../app/models", __dir__), namespace: #{app_name.class_name})])
72
+ updated = updated.sub(
73
+ %[loader.push_dir(File.expand_path("../app/state", __dir__), namespace: #{app_name.class_name})\n],
74
+ %[loader.push_dir(File.expand_path("../app/models", __dir__), namespace: #{app_name.class_name})\nloader.push_dir(File.expand_path("../app/state", __dir__), namespace: #{app_name.class_name})\n]
75
+ )
76
+ end
77
+ updated
78
+ end
79
+ end
80
+
81
+ # Reads *path*, yields its contents to the block, and writes the result back when it
82
+ # differs. Raises Error when the file is missing.
83
+ def update_file(path)
84
+ raise Error, "Missing file: #{relative_path(path)}" unless File.exist?(path)
85
+
86
+ current = File.read(path)
87
+ updated = yield current
88
+ return if updated == current
89
+
90
+ File.write(path, updated)
91
+ out.puts "update #{relative_path(path)}"
92
+ end
93
+
94
+ # Inserts a `spec.add_dependency "name", "version"` line after the `charming` dependency
95
+ # when it's not already present.
96
+ def insert_dependency(content, gem_name, version)
97
+ return content if content.include?(%(spec.add_dependency "#{gem_name}"))
98
+
99
+ dependency = %( spec.add_dependency "#{gem_name}", "#{version}")
100
+ content.sub(%( spec.add_dependency "charming"\n), %( spec.add_dependency "charming"\n#{dependency}\n))
101
+ end
102
+
103
+ # The contents of the new `config/database.rb` (establishes an SQLite connection to
104
+ # `db/development.sqlite3`).
105
+ def database_config
106
+ %(# frozen_string_literal: true
107
+
108
+ require "active_record"
109
+ require "fileutils"
110
+
111
+ database_path = File.expand_path("../db/development.sqlite3", __dir__)
112
+ FileUtils.mkdir_p(File.dirname(database_path))
113
+
114
+ ActiveRecord::Base.establish_connection(
115
+ adapter: "sqlite3",
116
+ database: database_path
117
+ )
118
+ )
119
+ end
120
+
121
+ # The contents of the new `app/models/application_record.rb` (abstract ActiveRecord base).
122
+ def application_record
123
+ %(# frozen_string_literal: true
124
+
125
+ module #{app_name.class_name}
126
+ class ApplicationRecord < ActiveRecord::Base
127
+ self.abstract_class = true
128
+ end
129
+ end
130
+ )
131
+ end
132
+
133
+ # Reads the app's gemspec filename to derive the app name.
134
+ def app_name_from_gemspec
135
+ File.basename(gemspec_path, ".gemspec")
136
+ end
137
+
138
+ # The path to the app's gemspec (raises when not found).
139
+ def gemspec_path
140
+ @gemspec_path ||= Dir.glob(File.join(destination, "*.gemspec")).first || raise(Error, "Run this command from a Charming app root")
141
+ end
142
+
143
+ # The path to the app's root loader file (`lib/<app_name>.rb`).
144
+ def root_file_path
145
+ File.join(destination, "lib", "#{app_name.snake_name}.rb")
146
+ end
147
+
148
+ # Strips the app destination prefix from *path* for human-friendly status output.
149
+ def relative_path(path)
150
+ path.delete_prefix("#{destination}/")
151
+ end
152
+ end
153
+ end
154
+ end
@@ -103,20 +103,12 @@ module Charming
103
103
 
104
104
  # The pluralized table name (e.g., "user" → "users", "category" → "categories").
105
105
  def table_name
106
- pluralize(name.snake_name)
106
+ ActiveSupport::Inflector.pluralize(name.snake_name)
107
107
  end
108
108
 
109
109
  # The CamelCase migration class name (e.g., "users" → "Users").
110
110
  def table_class_name
111
- table_name.split("_").map(&:capitalize).join
112
- end
113
-
114
- # Minimal English pluralization for the model name (covers the common -y, -s/x/z/ch/sh cases).
115
- def pluralize(value)
116
- return value.sub(/y\z/, "ies") if value.end_with?("y")
117
- return "#{value}es" if value.match?(/(?:s|x|z|ch|sh)\z/)
118
-
119
- "#{value}s"
111
+ ActiveSupport::Inflector.camelize(table_name)
120
112
  end
121
113
 
122
114
  # The current UTC timestamp in the format ActiveRecord uses for migration filenames.
@@ -21,7 +21,7 @@ module Charming
21
21
 
22
22
  # The CamelCase class name (e.g., "user" → "User").
23
23
  def class_name
24
- snake_name.split("_").map(&:capitalize).join
24
+ ActiveSupport::Inflector.camelize(snake_name)
25
25
  end
26
26
 
27
27
  # The controller class name (e.g., "user" → "UserController").
@@ -40,7 +40,7 @@ module Charming
40
40
 
41
41
  # CamelCase rendering of the action name (e.g., "user_settings" → "UserSettings").
42
42
  def action_class_name
43
- action.split("_").map(&:capitalize).join
43
+ ActiveSupport::Inflector.camelize(action)
44
44
  end
45
45
  end
46
46
  end
@@ -111,7 +111,7 @@ module Charming
111
111
 
112
112
  # Converts a snake_case symbol/string to a humanized "Capitalized" string.
113
113
  def humanize(value)
114
- value.to_s.tr("_", " ").capitalize
114
+ ActiveSupport::Inflector.humanize(value)
115
115
  end
116
116
  end
117
117
  end