You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

141 lines
4.9 KiB

2 months ago
2 months ago
2 months ago
2 months ago
2 months ago
  1. require 'dotenv'
  2. # DEFINABLE_THREAD_GROUP ||= ThreadGroup.new
  3. DEFINABLE_LISTENERS ||= [] # rubocop:disable Lint/OrAssignmentToConstant,Style/MutableConstant
  4. module Live
  5. # offers typed constant defintions with default value, by using lots of introspecting...
  6. module Definable
  7. MAIN_CSS = 'app/assets/stylesheets/application.css'.freeze # useful to trigger :reload_all from RailsLiveReload
  8. def integer(default = 0) = define_type_from_callee(caller[0], :integer, default)
  9. def boolean(default = true) = define_type_from_callee(caller[0], :boolean, default) # rubocop:disable Style/OptionalBooleanParameter
  10. def string(default = '') = define_type_from_callee(caller[0], :string, default)
  11. def reload_from_env
  12. # logger.debug('reload from env')
  13. changes = []
  14. new_env_values = env_values(cached: false)
  15. new_env_keys = new_env_values.keys
  16. new_env_values.each_pair { |constant, raw| changes << override(constant, raw) }
  17. definitions.except(*new_env_keys).each_pair { |constant, definition| changes << restore(constant, definition) }
  18. changes.compact!
  19. trigger_rolling_event(changes) if changes.any?
  20. end
  21. private
  22. def trigger_rolling_event(changes)
  23. logger.info ' ', dimensions: Semantic::FancyDimensions.new(rails: '╔═╗', before: 1)
  24. changes.each do |change|
  25. value = change[:new_value].ai
  26. logger.warn "Constant #{change[:kind]}:#{Semantic::AnsiColors::CLEAR} #{change[:constant]} = #{value}"
  27. end
  28. logger.info ' ', dimensions: Semantic::FancyDimensions.new(rails: '╚═╝')
  29. ActiveSupport::Notifications.instrument('rolling.live_constant', changes:)
  30. FileUtils.touch(MAIN_CSS) if defined?(RailsLiveReload) # triggering RailsLiveReload
  31. rescue StandardError => e
  32. logger.error(e)
  33. end
  34. def override(constant, raw)
  35. return unless definitions.include?(constant)
  36. type = definitions[constant][:type]
  37. new_value = typed_value(type, raw, definitions[constant][:default])
  38. old_value = definitions[constant][:value]
  39. return if new_value == old_value
  40. define_value(constant, new_value)
  41. { kind: :overriden, constant:, type:, old_value:, new_value: }
  42. end
  43. def restore(constant, definition)
  44. new_value = definition[:default]
  45. old_value = definition[:value]
  46. return if old_value == new_value
  47. type = definition[:type]
  48. define_value(constant, new_value)
  49. { kind: :restored, constant:, type:, old_value:, new_value: }
  50. end
  51. def env_values(cached: true)
  52. return @env_values if @env_values && cached
  53. @env_values = Dotenv.parse(*Dotenv::Rails.files)
  54. end
  55. def logger = @logger ||= SemanticLogger[to_s.underscore]
  56. def definitions = @definitions ||= {}
  57. def define_value(constant, value)
  58. definitions[constant][:value] = value
  59. remove_const(constant)
  60. const_set(constant, value)
  61. end
  62. # origin (or caller[0]) helps fetching the constant name from source code introspection
  63. def define_type_from_callee(origin, type, default)
  64. @@class_origin ||= self # rubocop:disable Style/ClassVars
  65. @listener ||= start_listener if defined?(Listen)
  66. file, line = origin.split(':')
  67. constant = introspect_constant_from_file(file, line.to_i - 1)
  68. raw_value = env_values.fetch(constant, nil)
  69. value = typed_value(type, raw_value, default)
  70. definitions[constant] = { type:, default:, value: }
  71. # logger.debug("new definition #{constant}:#{definitions[constant]}")
  72. value
  73. end
  74. def start_listener
  75. DEFINABLE_LISTENERS.each(&:stop)
  76. DEFINABLE_LISTENERS.clear
  77. listener = Listen.to(Rails.root, only: /^\.env\.?/) do
  78. @@class_origin.reload_from_env
  79. rescue StandardError => e
  80. logger.error('unable to reload from env', e)
  81. end
  82. listener.start
  83. DEFINABLE_LISTENERS << listener
  84. end
  85. def typed_value(type, raw, default)
  86. return default if raw.nil?
  87. case type
  88. when :integer then raw.to_i
  89. when :boolean then raw.upcase == 'TRUE'
  90. else raw
  91. end
  92. end
  93. # returns current directory of this source code
  94. def dir_source_location
  95. return @dir_source_location if defined?(@dir_source_location)
  96. *paths, _ = Live.const_source_location(:Definable).first.split('/')
  97. @dir_source_location = paths.join('/')
  98. end
  99. def introspect_constant_from_file(file, line)
  100. *dir_file, _ = file.split('/')
  101. dir_file = dir_file.join('/')
  102. raise "unexpected directory: #{dir_file} != #{dir_source_location}" unless dir_file == dir_source_location
  103. @lines ||= File.readlines(file) # cached source code
  104. constant = @lines[line].match(/\s*(.\w+)/)[1]
  105. unless constant.upcase == constant
  106. backtrace = ["#{file}:#{line + 1}"]
  107. raise ArgumentError, "unexpected case: a definable constant <#{constant}> must be uppercase!", backtrace
  108. end
  109. constant
  110. end
  111. end
  112. end