suma 0.2.5 → 0.3.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rake.yml +3 -0
  3. data/.github/workflows/release.yml +5 -1
  4. data/.gitignore +10 -1
  5. data/.rubocop_todo.yml +237 -28
  6. data/CLAUDE.md +102 -0
  7. data/Gemfile +3 -1
  8. data/README.adoc +188 -1
  9. data/exe/suma +1 -1
  10. data/lib/suma/cli/build.rb +2 -8
  11. data/lib/suma/cli/check_svg_quality.rb +172 -0
  12. data/lib/suma/cli/compare.rb +6 -158
  13. data/lib/suma/cli/convert_jsdai.rb +0 -2
  14. data/lib/suma/cli/core.rb +119 -0
  15. data/lib/suma/cli/export.rb +1 -10
  16. data/lib/suma/cli/extract_terms.rb +10 -654
  17. data/lib/suma/cli/generate_register.rb +34 -0
  18. data/lib/suma/cli/generate_schemas.rb +8 -124
  19. data/lib/suma/cli/reformat.rb +0 -1
  20. data/lib/suma/cli/validate.rb +0 -2
  21. data/lib/suma/cli/validate_links.rb +14 -291
  22. data/lib/suma/cli.rb +12 -102
  23. data/lib/suma/collection_config.rb +0 -2
  24. data/lib/suma/collection_manifest.rb +7 -111
  25. data/lib/suma/eengine/wrapper.rb +0 -1
  26. data/lib/suma/eengine.rb +8 -0
  27. data/lib/suma/express_schema.rb +43 -31
  28. data/lib/suma/jsdai/figure.rb +0 -3
  29. data/lib/suma/jsdai/figure_xml.rb +12 -9
  30. data/lib/suma/jsdai.rb +5 -8
  31. data/lib/suma/link_validator.rb +211 -0
  32. data/lib/suma/manifest_traverser.rb +92 -0
  33. data/lib/suma/processor.rb +76 -105
  34. data/lib/suma/register_manifest_generator.rb +163 -0
  35. data/lib/suma/schema_category.rb +83 -0
  36. data/lib/suma/schema_collection.rb +28 -63
  37. data/lib/suma/schema_comparer.rb +117 -0
  38. data/lib/suma/schema_compiler.rb +86 -0
  39. data/lib/suma/schema_discovery.rb +75 -0
  40. data/lib/suma/schema_exporter.rb +7 -35
  41. data/lib/suma/schema_index.rb +53 -0
  42. data/lib/suma/schema_manifest_generator.rb +113 -0
  43. data/lib/suma/schema_naming.rb +111 -0
  44. data/lib/suma/schema_template/document.rb +141 -0
  45. data/lib/suma/schema_template/plain.rb +46 -0
  46. data/lib/suma/schema_template.rb +19 -0
  47. data/lib/suma/svg_quality/batch_report.rb +78 -0
  48. data/lib/suma/svg_quality/formatters/json_formatter.rb +30 -0
  49. data/lib/suma/svg_quality/formatters/terminal_formatter.rb +168 -0
  50. data/lib/suma/svg_quality/formatters/yaml_formatter.rb +32 -0
  51. data/lib/suma/svg_quality/formatters.rb +12 -0
  52. data/lib/suma/svg_quality/report.rb +52 -0
  53. data/lib/suma/svg_quality.rb +30 -0
  54. data/lib/suma/term_extractor.rb +466 -0
  55. data/lib/suma/urn.rb +61 -0
  56. data/lib/suma/utils.rb +10 -2
  57. data/lib/suma/version.rb +1 -1
  58. data/lib/suma.rb +34 -5
  59. data/suma.gemspec +3 -2
  60. metadata +53 -9
  61. data/lib/suma/export_standalone_schema.rb +0 -14
  62. data/lib/suma/schema_attachment.rb +0 -130
  63. data/lib/suma/schema_document.rb +0 -132
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ module Suma
7
+ class SchemaComparer
8
+ attr_reader :trial_schema, :reference_schema, :options
9
+
10
+ def initialize(trial_schema, reference_schema, options = {})
11
+ @trial_schema = trial_schema
12
+ @reference_schema = reference_schema
13
+ @options = options
14
+ end
15
+
16
+ def compare
17
+ validate_inputs
18
+
19
+ trial_stepmod = options[:trial_stepmod] || detect_repo_root(trial_schema)
20
+ reference_stepmod = options[:reference_stepmod] || detect_repo_root(reference_schema)
21
+
22
+ out_dir = Dir.mktmpdir("eengine-compare-")
23
+
24
+ result = Eengine::Wrapper.compare(
25
+ trial_schema,
26
+ reference_schema,
27
+ mode: options[:mode] || "resource",
28
+ trial_stepmod: trial_stepmod,
29
+ reference_stepmod: reference_stepmod,
30
+ out_dir: out_dir,
31
+ )
32
+
33
+ unless result[:has_changes]
34
+ FileUtils.rm_rf(out_dir) if File.directory?(out_dir)
35
+ return nil
36
+ end
37
+
38
+ unless result[:xml_path]
39
+ raise Suma::CompilationError,
40
+ "XML output not found"
41
+ end
42
+
43
+ convert_to_change_yaml(result[:xml_path], out_dir)
44
+ ensure
45
+ FileUtils.rm_rf(out_dir) if out_dir && File.directory?(out_dir)
46
+ end
47
+
48
+ private
49
+
50
+ def validate_inputs
51
+ unless File.exist?(trial_schema)
52
+ raise Suma::SchemaNotFoundError,
53
+ "Trial schema not found: #{trial_schema}"
54
+ end
55
+
56
+ unless File.exist?(reference_schema)
57
+ raise Suma::SchemaNotFoundError,
58
+ "Reference schema not found: #{reference_schema}"
59
+ end
60
+
61
+ unless Eengine::Wrapper.available?
62
+ raise Suma::EengineNotAvailableError,
63
+ "eengine not found in PATH. Install from:\n " \
64
+ "macOS: https://github.com/expresslang/homebrew-eengine\n " \
65
+ "Linux: https://github.com/expresslang/eengine-releases"
66
+ end
67
+ end
68
+
69
+ def detect_repo_root(schema_path)
70
+ current = File.expand_path(File.dirname(schema_path))
71
+
72
+ loop do
73
+ return current if File.directory?(File.join(current, ".git"))
74
+
75
+ parent = File.dirname(current)
76
+ break if parent == current
77
+
78
+ current = parent
79
+ end
80
+
81
+ File.dirname(schema_path)
82
+ end
83
+
84
+ def convert_to_change_yaml(xml_path, _out_dir)
85
+ schema_name = extract_schema_name(trial_schema)
86
+ output_path = determine_output_path
87
+
88
+ existing_schema = nil
89
+ if File.exist?(output_path)
90
+ require "expressir/changes"
91
+ existing_schema = Expressir::Changes::SchemaChange.from_file(output_path)
92
+ end
93
+
94
+ converter = EengineConverter.new(xml_path, schema_name)
95
+ change_schema = converter.convert(
96
+ version: options[:version],
97
+ existing_change_schema: existing_schema,
98
+ )
99
+
100
+ change_schema.to_file(output_path)
101
+ output_path
102
+ end
103
+
104
+ def extract_schema_name(path)
105
+ basename = File.basename(path, ".exp")
106
+ basename.sub(/_\d+$/, "")
107
+ end
108
+
109
+ def determine_output_path
110
+ options[:output] || begin
111
+ base = extract_schema_name(trial_schema)
112
+ dir = File.dirname(trial_schema)
113
+ File.join(dir, "#{base}.changes.yaml")
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Suma
7
+ # Orchestrates the compilation of a single EXPRESS schema into HTML/XML
8
+ # via Metanorma.
9
+ #
10
+ # SchemaCompiler owns all file I/O and the Metanorma::Compile invocation
11
+ # for one schema; the rendered AsciiDoc body is supplied by a SchemaTemplate
12
+ # that the caller injects. This split means templates can be tested as pure
13
+ # functions and the compiler can be tested with a real adoc fixture, without
14
+ # either depending on the other.
15
+ class SchemaCompiler
16
+ attr_reader :schema, :id, :output_path, :template
17
+
18
+ def initialize(schema:, output_path:, template:)
19
+ @schema = schema
20
+ @id = schema.id
21
+ @output_path = output_path
22
+ @template = template
23
+ end
24
+
25
+ def compile
26
+ save_config
27
+ save_adoc
28
+ invoke_metanorma
29
+ self
30
+ end
31
+
32
+ def output_xml_path
33
+ filename_adoc("xml")
34
+ end
35
+
36
+ def extensions
37
+ template.extensions
38
+ end
39
+
40
+ private
41
+
42
+ def filename_adoc(ext = "adoc")
43
+ File.join(output_path, "doc_#{id}.#{ext}")
44
+ end
45
+
46
+ def filename_config
47
+ File.join(output_path, "schema_#{id}.yaml")
48
+ end
49
+
50
+ def save_adoc
51
+ log_relative "Save EXPRESS adoc", filename_adoc
52
+
53
+ FileUtils.mkdir_p(File.dirname(filename_adoc))
54
+
55
+ config_relative = Pathname.new(filename_config)
56
+ .relative_path_from(Pathname.new(File.dirname(filename_adoc)))
57
+
58
+ File.write(filename_adoc, template.render(config_relative))
59
+ end
60
+
61
+ def save_config
62
+ log_relative "Save schema config", filename_config
63
+
64
+ FileUtils.mkdir_p(File.dirname(filename_config))
65
+ config = Expressir::SchemaManifest.new
66
+ config.schemas << Expressir::SchemaManifestEntry.new(id: id,
67
+ path: schema.path)
68
+ config.save_to_path(filename_config)
69
+ end
70
+
71
+ def invoke_metanorma
72
+ log_relative "Compiling schema (id: #{id})", filename_adoc
73
+ Metanorma::Compile.new.compile(
74
+ filename_adoc,
75
+ agree_to_terms: true,
76
+ install_fonts: false,
77
+ )
78
+ log_relative "Compiling schema (id: #{id}) ... done!", filename_adoc
79
+ end
80
+
81
+ def log_relative(prefix, path)
82
+ relative = Pathname.new(path).relative_path_from(Dir.pwd)
83
+ Utils.log "#{prefix}: #{relative}"
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ # Service for schema-config I/O on a single CollectionManifest node.
5
+ #
6
+ # SchemaDiscovery owns two concerns that previously lived on
7
+ # CollectionManifest:
8
+ #
9
+ # 1. Loading the `schemas.yaml` that sits next to a `collection.yml`
10
+ # into the manifest's `schema_config` slot.
11
+ # 2. Building the doc CollectionManifest sub-tree that hosts the
12
+ # compiled XML output for each schema in `schema_config`.
13
+ #
14
+ # It does not walk the manifest tree — that is ManifestTraverser's job.
15
+ # The split keeps schema I/O in one place and tree traversal in another,
16
+ # so each is independently testable.
17
+ class SchemaDiscovery
18
+ attr_reader :manifest
19
+
20
+ def initialize(manifest)
21
+ @manifest = manifest
22
+ end
23
+
24
+ # If the manifest's file is a `collection.yml` and a `schemas.yaml`
25
+ # sits alongside it, parse it into an Expressir::SchemaManifest and
26
+ # store it on the manifest. Otherwise leave `schema_config` untouched.
27
+ def load_config
28
+ return unless manifest.file
29
+ return unless File.basename(manifest.file) == "collection.yml"
30
+
31
+ schemas_yaml_path = File.join(File.dirname(manifest.file), "schemas.yaml")
32
+ return unless File.exist?(schemas_yaml_path)
33
+
34
+ manifest.schema_config = Expressir::SchemaManifest.from_file(schemas_yaml_path)
35
+ end
36
+
37
+ # Build a CollectionManifest sub-tree that hosts the compiled XML for
38
+ # every schema in `manifest.schema_config`. The wrapper is named with
39
+ # a trailing underscore so it does not collide with the parent id.
40
+ def build_added_manifest(schema_output_path)
41
+ doc = CollectionConfig.from_file(manifest.file)
42
+ doc_id = doc.bibdata.id
43
+
44
+ added = CollectionManifest.new(
45
+ title: "Collection",
46
+ type: "collection",
47
+ identifier: "#{manifest.identifier}_",
48
+ )
49
+
50
+ added.entry = [
51
+ CollectionManifest.new(
52
+ title: doc_id,
53
+ type: "document",
54
+ entry: build_doc_entries(schema_output_path),
55
+ ),
56
+ ]
57
+
58
+ added
59
+ end
60
+
61
+ # Build one CollectionManifest per schema in schema_config, naming
62
+ # each output file as `doc_<basename>.xml` under
63
+ # `<schema_output_path>/<schema_id>/`.
64
+ def build_doc_entries(schema_output_path)
65
+ manifest.schema_config.schemas.map do |schema|
66
+ xml_basename = "#{File.basename(schema.path, '.exp')}.xml"
67
+ CollectionManifest.new(
68
+ identifier: schema.id,
69
+ title: schema.id,
70
+ file: File.join(schema_output_path, schema.id, "doc_#{xml_basename}"),
71
+ )
72
+ end
73
+ end
74
+ end
75
+ end
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "express_schema"
4
- require_relative "utils"
5
- require_relative "export_standalone_schema"
6
3
  require "fileutils"
7
4
 
8
5
  module Suma
@@ -43,53 +40,28 @@ module Suma
43
40
  end
44
41
 
45
42
  def export_single_schema(schema)
46
- # Check if this is a standalone EXPRESS file
47
- # (not from a manifest structure)
48
- is_standalone_file = schema.is_a?(ExportStandaloneSchema)
49
- schema_output_path = determine_output_path(schema, is_standalone_file)
43
+ is_standalone = !schema.is_a?(Expressir::SchemaManifestEntry)
44
+ schema_output_path = determine_output_path(schema, is_standalone)
50
45
 
51
46
  express_schema = ExpressSchema.new(
52
47
  id: schema.id,
53
48
  path: schema.path.to_s,
54
49
  output_path: schema_output_path,
55
- is_standalone_file: is_standalone_file,
50
+ is_standalone_file: is_standalone,
56
51
  )
57
52
 
58
53
  express_schema.save_exp(with_annotations: options[:annotations])
59
54
  end
60
55
 
61
- def determine_output_path(schema, is_standalone_file)
62
- if is_standalone_file
63
- # For standalone files, output directly to the root
56
+ def determine_output_path(schema, is_standalone)
57
+ if is_standalone
64
58
  output_path.to_s
65
59
  else
66
- # For manifest schemas, preserve directory structure
67
- category = categorize_schema(schema)
68
- output_path.join(category).to_s
60
+ category = SchemaCategory.for_schema(id: schema.id, path: schema.path)
61
+ output_path.join(category.directory).to_s
69
62
  end
70
63
  end
71
64
 
72
- # rubocop:disable Metrics/MethodLength
73
- def categorize_schema(schema)
74
- path = schema.path.to_s
75
-
76
- # Check if this is from a manifest structure or a standalone EXPRESS file
77
- case path
78
- when %r{/resources/}
79
- "resources"
80
- when %r{/modules/}
81
- "modules"
82
- when %r{/business_object_models/}
83
- "business_object_models"
84
- when %r{/core_model/}
85
- "core_model"
86
- else
87
- # standalone EXPRESS file not from a manifest structure
88
- "standalone"
89
- end
90
- end
91
- # rubocop:enable Metrics/MethodLength
92
-
93
65
  # rubocop:disable Metrics/MethodLength
94
66
  def create_zip_archive
95
67
  require "zip"
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "expressir"
4
+
5
+ module Suma
6
+ # Pre-built index for O(1) schema and element lookup.
7
+ # Build once from a parsed repo, then query by name.
8
+ class SchemaIndex
9
+ def initialize(repo)
10
+ @schemas_by_name = {}
11
+ @elements_by_schema = {}
12
+
13
+ repo.schemas.each do |schema|
14
+ key = schema.id.downcase
15
+ @schemas_by_name[key] = schema
16
+ @elements_by_schema[key] = build_element_index(schema)
17
+ end
18
+ end
19
+
20
+ def find_schema(name)
21
+ @schemas_by_name[name.downcase]
22
+ end
23
+
24
+ def find_element(schema_name, element_name)
25
+ elements = @elements_by_schema[schema_name.downcase]
26
+ elements&.[](element_name.downcase)
27
+ end
28
+
29
+ private
30
+
31
+ def build_element_index(schema)
32
+ index = {}
33
+
34
+ element_collections(schema).each do |collection|
35
+ collection&.each { |e| index[e.id.downcase] = e }
36
+ end
37
+
38
+ index
39
+ end
40
+
41
+ def element_collections(schema)
42
+ [
43
+ schema.entities,
44
+ schema.types,
45
+ schema.constants,
46
+ schema.functions,
47
+ schema.rules,
48
+ schema.procedures,
49
+ schema.subtype_constraints,
50
+ ]
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+
6
+ module Suma
7
+ class SchemaManifestGenerator
8
+ YAML_FILE_EXTENSIONS = [".yaml", ".yml"].freeze
9
+
10
+ def initialize(metanorma_manifest_file, schema_manifest_file,
11
+ exclude_paths: nil)
12
+ @metanorma_manifest_file = File.expand_path(metanorma_manifest_file)
13
+ @schema_manifest_file = schema_manifest_file
14
+ @exclude_paths = exclude_paths
15
+ end
16
+
17
+ def generate
18
+ validate_inputs
19
+ metanorma_data = load_yaml(@metanorma_manifest_file)
20
+ collection_files = metanorma_data["metanorma"]["source"]["files"]
21
+ manifest_files = load_manifest_files(collection_files)
22
+ all_schemas = load_project_schemas(manifest_files)
23
+ all_schemas["schemas"] = all_schemas["schemas"].sort.to_h
24
+ write_output(all_schemas)
25
+ end
26
+
27
+ private
28
+
29
+ def validate_inputs
30
+ unless File.exist?(@metanorma_manifest_file)
31
+ raise Errno::ENOENT,
32
+ "Specified file `#{@metanorma_manifest_file}` not found."
33
+ end
34
+
35
+ unless File.file?(@metanorma_manifest_file)
36
+ raise ArgumentError,
37
+ "Specified path `#{@metanorma_manifest_file}` is not a file."
38
+ end
39
+
40
+ [@metanorma_manifest_file, @schema_manifest_file].each do |file|
41
+ unless YAML_FILE_EXTENSIONS.include?(File.extname(file))
42
+ raise ArgumentError, "Specified file `#{file}` is not a YAML file."
43
+ end
44
+ end
45
+ end
46
+
47
+ def load_yaml(file_path)
48
+ YAML.safe_load(File.read(file_path, encoding: "UTF-8"), aliases: true)
49
+ end
50
+
51
+ def load_manifest_files(collection_files)
52
+ collection_files.map do |c|
53
+ collection_data = load_yaml(c)
54
+ collection_data["manifest"]["docref"].map { |docref| docref["file"] }
55
+ end.flatten
56
+ end
57
+
58
+ def load_project_schemas(manifest_files)
59
+ all_schemas = { "schemas" => {} }
60
+
61
+ manifest_files.each do |file|
62
+ schemas_file_path = File.expand_path(file.gsub("collection.yml",
63
+ "schemas.yaml"))
64
+
65
+ unless File.exist?(schemas_file_path)
66
+ Utils.log "Schemas file not found: #{schemas_file_path}"
67
+ next
68
+ end
69
+
70
+ schemas_data = load_yaml(schemas_file_path)
71
+
72
+ if schemas_data["schemas"]
73
+ schemas_data["schemas"] = fix_path(schemas_data, schemas_file_path)
74
+ all_schemas["schemas"].merge!(schemas_data["schemas"])
75
+ end
76
+
77
+ if @exclude_paths
78
+ all_schemas["schemas"].delete_if do |_key, value|
79
+ value["path"].match?(
80
+ Regexp.new(@exclude_paths.gsub("*", "(.*){1,999}")),
81
+ )
82
+ end
83
+ end
84
+ end
85
+
86
+ all_schemas
87
+ end
88
+
89
+ def fix_path(schemas_data, schemas_file_path)
90
+ schema_manifest_path = File.expand_path(@schema_manifest_file, Dir.pwd)
91
+
92
+ schemas_data["schemas"].each do |key, value|
93
+ path_in_schema = File.expand_path(value["path"],
94
+ File.dirname(schemas_file_path))
95
+
96
+ fixed_path = Pathname.new(path_in_schema).relative_path_from(
97
+ Pathname.new(File.dirname(schema_manifest_path)),
98
+ )
99
+
100
+ { key => value.merge!("path" => fixed_path.to_s) }
101
+ end
102
+
103
+ schemas_data["schemas"]
104
+ end
105
+
106
+ def write_output(all_schemas)
107
+ output_path = File.expand_path(@schema_manifest_file)
108
+ Utils.log "Writing the Schemas YAML file to #{output_path}..."
109
+ File.write(output_path, all_schemas.to_yaml)
110
+ Utils.log "Writing the Schemas YAML file to #{output_path}...Done"
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ # Converts EXPRESS schema identifiers into human-readable display names.
5
+ #
6
+ # Naming is model-driven: the schema type (resource/module) and suffix
7
+ # determine how the identifier is formatted. Acronyms and numeric
8
+ # prefixes are preserved to match ISO 10303 conventions.
9
+ #
10
+ # @example
11
+ # SchemaNaming.display_name("topology_schema")
12
+ # # => "Topology"
13
+ # SchemaNaming.display_name("Activity_method_assignment_mim")
14
+ # # => "Activity Method Assignment (MIM)"
15
+ # SchemaNaming.display_name("aic_advanced_brep")
16
+ # # => "AIC Advanced Brep"
17
+ module SchemaNaming
18
+ # Acronyms preserved as uppercase during title-casing.
19
+ # Source: ISO 10303 naming conventions.
20
+ ACRONYMS = %w[
21
+ aic aec apu bom csg edraw id ifc pdf pld
22
+ xml xpdl 2d 3d
23
+ ].freeze
24
+
25
+ # Lowercase function words (ISO title-case convention).
26
+ LOWERCASE_WORDS = %w[a an and as for in of on or the to].freeze
27
+
28
+ # Suffixes stripped from the schema name before title-casing,
29
+ # mapped to the parenthesised label appended to the display name.
30
+ # A +nil+ label means the suffix is stripped silently.
31
+ SUFFIXES = {
32
+ "_arm" => "ARM",
33
+ "_mim" => "MIM",
34
+ "_bom" => "BOM",
35
+ "_schema" => nil,
36
+ }.freeze
37
+
38
+ class << self
39
+ # Produce a human-readable display name from a schema identifier.
40
+ #
41
+ # @param schema_id [String] the EXPRESS schema identifier
42
+ # @return [String] human-readable name
43
+ def display_name(schema_id)
44
+ base, label = decompose(schema_id)
45
+ title_cased = title_case(base)
46
+ label ? "#{title_cased} (#{label})" : title_cased
47
+ end
48
+
49
+ # Produce a prefixed display name with the schema category.
50
+ #
51
+ # @param schema_id [String] the EXPRESS schema identifier
52
+ # @param path [String, Pathname] the schema file path (for classification)
53
+ # @return [String] e.g. "Resource: Topology" or "Module: Activity (ARM)"
54
+ def prefixed_name(schema_id, path: nil)
55
+ type = ExpressSchema::Type.classify(id: schema_id, path: path)
56
+ prefix = category_prefix(type)
57
+ "#{prefix}: #{display_name(schema_id)}"
58
+ end
59
+
60
+ # Determine the category prefix for a schema type.
61
+ #
62
+ # @param type [Symbol] one of ExpressSchema::Type constants
63
+ # @return [String]
64
+ def category_prefix(type)
65
+ SchemaCategory.for_type(type).prefix
66
+ end
67
+
68
+ private
69
+
70
+ # Split a schema ID into (base_without_suffix, suffix_label_or_nil).
71
+ #
72
+ # @return [(String, String, nil)]
73
+ def decompose(schema_id)
74
+ SUFFIXES.each do |suffix, label|
75
+ if schema_id.end_with?(suffix)
76
+ return [schema_id.delete_suffix(suffix),
77
+ label]
78
+ end
79
+ end
80
+ [schema_id, nil]
81
+ end
82
+
83
+ # Title-case a snake_case identifier, preserving acronyms.
84
+ # The +tr+ collapses runs of underscores (leading, trailing, and
85
+ # consecutive) into spaces, which +split+ then treats as a single
86
+ # separator.
87
+ #
88
+ # @param name [String] snake_case identifier
89
+ # @return [String] title-cased name
90
+ def title_case(name)
91
+ words = name.tr("_", " ").split
92
+ words.each_with_index.map do |word, i|
93
+ capitalize_word(word, first: i.zero?)
94
+ end.join(" ")
95
+ end
96
+
97
+ # Capitalise a single word, preserving acronyms and
98
+ # lowercasing function words (except when first in the name).
99
+ #
100
+ # @param word [String]
101
+ # @param first [Boolean] whether this is the first word
102
+ # @return [String]
103
+ def capitalize_word(word, first: false)
104
+ return word.upcase if ACRONYMS.include?(word.downcase)
105
+ return word.downcase if LOWERCASE_WORDS.include?(word.downcase) && !first
106
+
107
+ word.capitalize
108
+ end
109
+ end
110
+ end
111
+ end