# frozen_string_literal: true
module Canon
# Global configuration for Canon
# Provides unified configuration across CLI, Ruby API, and RSpec interfaces
class Config
autoload :ConfigDSL, "canon/config/config_dsl"
autoload :EnvProvider, "canon/config/env_provider"
autoload :EnvSchema, "canon/config/env_schema"
autoload :OverrideResolver, "canon/config/override_resolver"
autoload :ProfileLoader, "canon/config/profile_loader"
autoload :TypeConverter, "canon/config/type_converter"
class << self
def instance
@instance ||= new
end
def configure
yield instance if block_given?
instance
end
def reset!
@instance = new
end
# Delegate to instance
def method_missing(method, ...)
if %i[xml html json yaml string profile profile= diff_mode diff_mode=
use_color use_color= xml_match_profile xml_match_profile=
html_match_profile html_match_profile= reset!].include?(method)
@instance.public_send(method, ...)
else
super
end
end
def respond_to_missing?(method, include_private = false)
%i[xml html json yaml string profile profile= diff_mode diff_mode=
use_color use_color= xml_match_profile xml_match_profile=
html_match_profile html_match_profile= reset!].include?(method) || super
end
end
attr_reader :xml, :html, :json, :yaml, :string
def initialize
@xml = FormatConfig.new(:xml)
@html = FormatConfig.new(:html)
@json = FormatConfig.new(:json)
@yaml = FormatConfig.new(:yaml)
@string = FormatConfig.new(:string)
@profile = nil
env_profile = ENV.fetch("CANON_CONFIG_PROFILE", nil)
if env_profile
# Convert to symbol if it matches a built-in profile name
self.profile = if ProfileLoader.available_profiles.include?(env_profile.to_sym)
env_profile.to_sym
else
env_profile
end
end
end
# Returns the current profile name or path.
def profile
@profile
end
# Apply a configuration profile by name (Symbol for built-in) or
# file path (String). Set to +nil+ to clear the profile layer.
def profile=(name_or_path)
clear_profile_values!
if name_or_path.nil?
@profile = nil
return
end
@profile = name_or_path.is_a?(Symbol) ? name_or_path : name_or_path.to_s
apply_profile(@profile)
end
def reset!
@xml.reset!
@html.reset!
@json.reset!
@yaml.reset!
@string.reset!
@profile = nil
end
# Backward compatibility methods for top-level diff configuration
# These delegate to XML diff config for backward compatibility
def diff_mode
@xml.diff.mode
end
def diff_mode=(value)
@xml.diff.mode = value
end
def use_color
@xml.diff.use_color
end
def use_color=(value)
@xml.diff.use_color = value
end
# Backward compatibility methods for match profile configuration
def xml_match_profile
@xml.match.profile
end
def xml_match_profile=(value)
@xml.match.profile = value
end
def html_match_profile
@html.match.profile
end
def html_match_profile=(value)
@html.match.profile = value
end
# Format-specific configuration
# Each format (XML, HTML, JSON, YAML) has its own instance
class FormatConfig
attr_reader :format, :match, :diff
def initialize(format)
@format = format
@match = MatchConfig.new(format)
@diff = DiffConfig.new(format)
@preprocessing = nil
@profile_preprocessing = nil
end
def preprocessing
@preprocessing || @profile_preprocessing
end
def preprocessing=(value)
@preprocessing = value
end
def reset!
@match.reset!
@diff.reset!
@preprocessing = nil
@profile_preprocessing = nil
end
def apply_profile_data(data)
if data.key?("preprocessing")
val = data["preprocessing"]
@profile_preprocessing = val.is_a?(String) ? val.to_sym : val
end
@match.apply_profile_data(data["match"]) if data.key?("match")
@diff.apply_profile_data(data["diff"]) if data.key?("diff")
end
def clear_profile!
@profile_preprocessing = nil
@match.clear_profile!
@diff.clear_profile!
end
end
# Match configuration for comparison behavior
class MatchConfig
attr_reader :options
def initialize(format = nil)
@format = format
@resolver = build_resolver(format)
@options = {}
end
def options=(value)
@options = value || {}
end
def reset!
@resolver = build_resolver(@format)
@options = {}
end
# Profile accessor with ENV override support
def profile
@resolver.resolve(:profile)
end
def profile=(value)
@resolver.set_programmatic(:profile, value)
end
# Return all profile-sourced values from the resolver, excluding
# the :profile key itself (which is accessed via #profile).
# These are the YAML-profile settings (e.g., preserve_whitespace_elements)
# that are stored in the resolver's profile layer but not exposed
# through the built-in MATCH_PROFILES system.
#
# @return [Hash] Profile option key-values (excluding :profile)
def profile_options
@resolver.profile.except(:profile)
end
# Element names where whitespace is PRESERVED exactly (no manipulation).
# All whitespace characters are significant in these elements.
def preserve_whitespace_elements
@resolver.resolve(:preserve_whitespace_elements) || []
end
# Element names where whitespace is COLLAPSED (HTML-style behavior).
# Multiple whitespace chars collapse to single space; boundaries preserved.
def collapse_whitespace_elements
@resolver.resolve(:collapse_whitespace_elements) || []
end
# Element names where whitespace-only text nodes are STRIPPED.
def strip_whitespace_elements
@resolver.resolve(:strip_whitespace_elements) || []
end
# Build match options from profile and options
def to_h
result = {}
result[:match_profile] = profile if profile
result[:match] = @options if @options && !@options.empty?
result
end
def apply_profile_data(data)
return unless data
data.each do |key, value|
sym_key = key.to_sym
converted = value.is_a?(String) ? value.to_sym : value
@resolver.set_profile(sym_key, converted)
end
end
def clear_profile!
@resolver.clear_profile!
end
private
def build_resolver(format)
defaults = {
profile: nil,
}
env = format ? EnvProvider.load_match_for_format(format) : {}
OverrideResolver.new(
defaults: defaults,
programmatic: {},
env: env,
)
end
end
# Pretty-printer sub-configuration for display canonicalization.
# Controls how documents are formatted by +Canon::PrettyPrinter::Xml+
# when +display_preprocessing: :pretty_print+ is active.
# The two attributes (+indent+ and +indent_type+) are backed by the
# parent +DiffConfig+'s resolver so that ENV overrides (e.g.
# +CANON_XML_DIFF_PRETTY_PRINTER_INDENT+) work automatically.
class PrettyPrinterConfig
def initialize(resolver)
@resolver = resolver
end
def indent
@resolver.resolve(:pretty_printer_indent)
end
def indent=(value)
@resolver.set_programmatic(:pretty_printer_indent, value)
end
def indent_type
@resolver.resolve(:pretty_printer_indent_type)
end
def indent_type=(value)
DiffConfig.validate_config_value!(:pretty_printer_indent_type, value)
@resolver.set_programmatic(:pretty_printer_indent_type, value)
end
end
# Diff configuration for output formatting
#
# Each user-tunable attribute is declared with +config_key+ via the
# +ConfigDSL+ module. The DSL generates the matching getter/setter
# pair and registers metadata (type, enum, default) so +EnvSchema+
# and +TypeConverter+ can discover it without re-declaring it
# (lutaml/canon TODO.improve/07 — single source of truth).
class DiffConfig
extend ConfigDSL
attr_reader :pretty_printer
def initialize(format = nil)
@format = format
@resolver = build_resolver(format)
@pretty_printer = PrettyPrinterConfig.new(@resolver)
end
def reset!
@resolver = build_resolver(@format)
@pretty_printer = PrettyPrinterConfig.new(@resolver)
end
def apply_profile_data(data)
return unless data
data.each do |key, value|
sym_key = key.to_sym
coerced = coerce_profile_value(sym_key, value)
self.class.validate_config_value!(sym_key, coerced)
@resolver.set_profile(sym_key, coerced)
end
end
def clear_profile!
@resolver.clear_profile!
end
# --- Attribute declarations --------------------------------------
config_key :mode, type: :symbol,
enum: %i[by_line by_object pretty_diff],
default: :by_line
config_key :use_color, type: :boolean,
default: -> { ColorDetector.supports_color? }
config_key :context_lines, type: :integer, default: 3
config_key :grouping_lines, type: :integer, default: 10
config_key :show_diffs, type: :symbol,
enum: %i[all normative informative],
default: :all
config_key :verbose_diff, type: :boolean, default: false
config_key :algorithm, type: :symbol, enum: %i[dom semantic],
default: :dom
config_key :parser, type: :symbol, enum: %i[sax dom], default: :sax
config_key :show_raw_inputs, type: :boolean, default: false
config_key :show_raw_expected, type: :boolean, default: false
config_key :show_raw_received, type: :boolean, default: false
config_key :show_preprocessed_inputs, type: :boolean, default: false
config_key :show_preprocessed_expected, type: :boolean, default: false
config_key :show_preprocessed_received, type: :boolean, default: false
config_key :show_prettyprint_inputs, type: :boolean, default: false
config_key :show_prettyprint_expected, type: :boolean, default: false
config_key :show_prettyprint_received, type: :boolean, default: false
config_key :show_line_numbered_inputs, type: :boolean, default: false
config_key :display_format, type: :symbol, enum: %i[raw canonical],
default: :raw
config_key :display_preprocessing, type: :symbol,
enum: %i[none pretty_print
normalize_pretty_print c14n],
default: :none
config_key :preserve_whitespace_elements,
type: :string_array,
default: [],
coerce: ->(v) { Array(v).map(&:to_s) },
getter_coerce: ->(v) { v || [] }
config_key :collapse_whitespace_elements,
type: :string_array,
default: [],
coerce: ->(v) { Array(v).map(&:to_s) },
getter_coerce: ->(v) { v || [] }
config_key :strip_whitespace_elements,
type: :string_array,
default: [],
coerce: ->(v) { Array(v).map(&:to_s) },
getter_coerce: ->(v) { v || [] }
config_key :pretty_printed_expected, type: :boolean, default: false
config_key :pretty_printed_received, type: :boolean, default: false
config_key :pretty_printer_sort_attributes, type: :boolean,
default: false
config_key :compact_semantic_report, type: :boolean, default: false
config_key :expand_difference, type: :boolean, default: false
# Accepts +true+, +false+, or +:content_only+. ENV-supplied
# +"true"/"false"+ strings arrive as +:true/:false+ (TypeConverter
# uses the +:symbol+ type) so the getter coerces them back to
# booleans.
config_key :character_visualization,
type: :symbol,
enum: [true, false, :content_only],
default: true,
getter_coerce: lambda { |val|
case val
when true, :true then true # rubocop:disable Lint/BooleanSymbol
when false, :false then false # rubocop:disable Lint/BooleanSymbol
else val
end
}
config_key :theme, type: :symbol,
enum: %i[light dark retro claude cyberpunk],
default: :dark
config_key :theme_inheritance, type: :pass_through, default: nil
config_key :custom_theme, type: :pass_through, default: nil
config_key :max_file_size, type: :integer, default: 5_242_880
config_key :max_node_count, type: :integer, default: 10_000
config_key :max_diff_lines, type: :integer, default: 10_000
# Pretty-printer keys are declared here so they participate in
# +config_keys+ (for EnvSchema discovery and validation) even
# though user code reaches them via the +PrettyPrinterConfig+
# facade through +DiffConfig#pretty_printer+.
config_key :pretty_printer_indent, type: :integer, default: 2
config_key :pretty_printer_indent_type, type: :symbol,
enum: %i[space tab],
default: :space
# Enum constraint map derived from declared config keys. Kept as a
# constant for backward compatibility with code that referenced
# +DiffConfig::VALID_ENUM_VALUES+ directly. Must be assigned AFTER
# all +config_key+ declarations above so the registry is populated.
VALID_ENUM_VALUES = enum_values.freeze
# Build diff options
def to_h
{
diff: mode,
use_color: use_color,
context_lines: context_lines,
grouping_lines: grouping_lines,
show_diffs: show_diffs,
verbose_diff: verbose_diff,
diff_algorithm: algorithm,
parser: parser,
show_raw_inputs: show_raw_inputs,
show_raw_expected: show_raw_expected,
show_raw_received: show_raw_received,
show_preprocessed_inputs: show_preprocessed_inputs,
show_preprocessed_expected: show_preprocessed_expected,
show_preprocessed_received: show_preprocessed_received,
show_prettyprint_inputs: show_prettyprint_inputs,
show_prettyprint_expected: show_prettyprint_expected,
show_prettyprint_received: show_prettyprint_received,
show_line_numbered_inputs: show_line_numbered_inputs,
character_visualization: character_visualization,
display_format: display_format,
display_preprocessing: display_preprocessing,
pretty_printer_indent: pretty_printer.indent,
pretty_printer_indent_type: pretty_printer.indent_type,
preserve_whitespace_elements: preserve_whitespace_elements,
collapse_whitespace_elements: collapse_whitespace_elements,
strip_whitespace_elements: strip_whitespace_elements,
pretty_printed_expected: pretty_printed_expected,
pretty_printed_received: pretty_printed_received,
compact_semantic_report: compact_semantic_report,
expand_difference: expand_difference,
max_file_size: max_file_size,
max_node_count: max_node_count,
max_diff_lines: max_diff_lines,
theme: theme,
}
end
private
def build_resolver(format)
defaults = self.class.config_keys.each_with_object({}) do |(k, _m), h|
h[k] = self.class.resolve_default(k)
end
env = format ? EnvProvider.load_diff_for_format(format) : {}
OverrideResolver.new(
defaults: defaults,
programmatic: {},
env: env,
)
end
# Coerce a YAML value to the appropriate Ruby type based on EnvSchema.
# YAML natively handles booleans, integers, and arrays, but symbols
# arrive as strings and need conversion.
def coerce_profile_value(key, value)
return value if value.is_a?(Array) # string_array already correct from YAML
type = EnvSchema.type_for(key)
case type
when :symbol
value.is_a?(String) ? value.to_sym : value
when :boolean
# YAML booleans are already true/false
value
when :integer
value.is_a?(String) ? Integer(value) : value
else
value
end
end
end
private
def apply_profile(name_or_path)
data = ProfileLoader.load(name_or_path)
shared = data["shared"] || {}
formats = data["formats"] || {}
format_configs.each do |fmt_key, fmt_cfg|
fmt_data = ProfileLoader.deep_merge(shared,
formats[fmt_key.to_s] || {})
fmt_cfg.apply_profile_data(fmt_data)
end
end
def clear_profile_values!
format_configs.each_value(&:clear_profile!)
end
def format_configs
{ xml: @xml, html: @html, json: @json, yaml: @yaml, string: @string }
end
end
end