suma 0.2.6 → 0.4.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +7 -1
  3. data/.rubocop_todo.yml +170 -13
  4. data/CLAUDE.md +37 -11
  5. data/Gemfile +3 -3
  6. data/README.adoc +127 -13
  7. data/exe/suma +1 -1
  8. data/lib/suma/cli/build.rb +0 -5
  9. data/lib/suma/cli/check_svg_quality.rb +82 -97
  10. data/lib/suma/cli/compare.rb +0 -1
  11. data/lib/suma/cli/convert_jsdai.rb +0 -2
  12. data/lib/suma/cli/core.rb +119 -0
  13. data/lib/suma/cli/export.rb +37 -22
  14. data/lib/suma/cli/extract_terms.rb +5 -8
  15. data/lib/suma/cli/generate_register.rb +34 -0
  16. data/lib/suma/cli/generate_schemas.rb +0 -2
  17. data/lib/suma/cli/reformat.rb +39 -64
  18. data/lib/suma/cli/validate.rb +14 -7
  19. data/lib/suma/cli/validate_links.rb +11 -157
  20. data/lib/suma/cli.rb +12 -141
  21. data/lib/suma/collection_config.rb +0 -2
  22. data/lib/suma/collection_manifest.rb +7 -110
  23. data/lib/suma/eengine/wrapper.rb +0 -1
  24. data/lib/suma/eengine.rb +8 -0
  25. data/lib/suma/express_reformatter.rb +94 -0
  26. data/lib/suma/express_schema.rb +0 -1
  27. data/lib/suma/jsdai/figure.rb +0 -3
  28. data/lib/suma/jsdai.rb +5 -2
  29. data/lib/suma/link_validation.rb +144 -0
  30. data/lib/suma/link_validator.rb +17 -8
  31. data/lib/suma/manifest_traverser.rb +92 -0
  32. data/lib/suma/processor.rb +5 -8
  33. data/lib/suma/register_manifest_generator.rb +163 -0
  34. data/lib/suma/schema_category.rb +83 -0
  35. data/lib/suma/schema_collection.rb +30 -34
  36. data/lib/suma/schema_comparer.rb +4 -3
  37. data/lib/suma/schema_compiler.rb +86 -0
  38. data/lib/suma/schema_discovery.rb +75 -0
  39. data/lib/suma/schema_exporter.rb +14 -42
  40. data/lib/suma/schema_manifest_generator.rb +14 -6
  41. data/lib/suma/schema_naming.rb +111 -0
  42. data/lib/suma/schema_template/document.rb +141 -0
  43. data/lib/suma/schema_template/plain.rb +46 -0
  44. data/lib/suma/schema_template.rb +19 -0
  45. data/lib/suma/svg_quality/batch_report.rb +0 -2
  46. data/lib/suma/svg_quality/formatters.rb +12 -0
  47. data/lib/suma/svg_quality/scanner.rb +61 -0
  48. data/lib/suma/svg_quality.rb +4 -1
  49. data/lib/suma/term_classification.rb +78 -0
  50. data/lib/suma/term_extractor.rb +125 -81
  51. data/lib/suma/urn.rb +61 -0
  52. data/lib/suma/version.rb +1 -1
  53. data/lib/suma.rb +35 -3
  54. data/suma.gemspec +1 -1
  55. metadata +28 -6
  56. data/lib/suma/schema_attachment.rb +0 -103
  57. data/lib/suma/schema_document.rb +0 -118
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ # Reformats EXPRESS source into suma's canonical form: comment blocks
5
+ # (the +(*"...\n*)+ remarks that EXPRESS uses for documentation) are
6
+ # extracted from their inline positions and appended to the end of
7
+ # the file, and excess blank lines are collapsed.
8
+ #
9
+ # Pure content transformation — no I/O, no Thor, no filesystem. The
10
+ # CLI adapter handles reading from and writing to disk.
11
+ #
12
+ # The transform is idempotent: reformatting an already-reformatted
13
+ # document returns +changed?: false+.
14
+ #
15
+ # Comment extraction uses +String#index+ rather than a single m-mode
16
+ # regex. The original +/\(\*"(.*?)\n\*\)/m+ is correct functionally
17
+ # but is polynomial-time on adversarial input (CodeQL
18
+ # +rb/polynomial-redos+). Scanning for the start marker, then for
19
+ # the next terminator, is unambiguously linear.
20
+ module ExpressReformatter
21
+ Result = Struct.new(:content, :changed?, keyword_init: true) do
22
+ def content_or_nil
23
+ changed? ? content : nil
24
+ end
25
+ end
26
+
27
+ COMMENT_START = '(*"'
28
+ COMMENT_END = "\n*)"
29
+ BLANK_RUN_PATTERN = /(\n\n+)/
30
+ NEWLINE_RUN_PATTERN = /(\n+)/
31
+
32
+ module_function
33
+
34
+ def call(content)
35
+ comments = extract_comments(content)
36
+ return Result.new(content: content, changed?: false) if comments.empty?
37
+
38
+ without_comments = strip_comments(content)
39
+ new_comments = comments.map { |c| "#{COMMENT_START}#{c}#{COMMENT_END}" }
40
+ .join("\n\n")
41
+ new_content = "#{without_comments}\n\n#{new_comments}\n"
42
+ new_content = new_content.gsub(BLANK_RUN_PATTERN, "\n\n")
43
+
44
+ changed = normalised_compare(content, new_content) != 0
45
+ Result.new(content: new_content, changed?: changed)
46
+ end
47
+
48
+ def extract_comments(content)
49
+ comments = []
50
+ pos = 0
51
+ while (start_idx = content.index(COMMENT_START, pos))
52
+ end_idx = content.index(COMMENT_END, start_idx + COMMENT_START.length)
53
+ break unless end_idx
54
+
55
+ comments << content[(start_idx + COMMENT_START.length)...end_idx]
56
+ pos = end_idx + COMMENT_END.length
57
+ end
58
+ comments
59
+ end
60
+
61
+ def strip_comments(content)
62
+ return content unless content.index(COMMENT_START, 0)
63
+
64
+ stripped = +""
65
+ last_end = 0
66
+ each_comment_range(content) do |range|
67
+ stripped << content[range[:pre_start]...range[:start]]
68
+ last_end = range[:end_exclusive]
69
+ end
70
+ stripped << content[last_end..]
71
+ stripped.force_encoding(content.encoding)
72
+ end
73
+
74
+ def each_comment_range(content)
75
+ return enum_for(:each_comment_range, content) unless block_given?
76
+
77
+ pos = 0
78
+ while (start_idx = content.index(COMMENT_START, pos))
79
+ end_idx = content.index(COMMENT_END, start_idx + COMMENT_START.length)
80
+ break unless end_idx
81
+
82
+ yield pre_start: pos, start: start_idx,
83
+ end_exclusive: end_idx + COMMENT_END.length
84
+ pos = end_idx + COMMENT_END.length
85
+ end
86
+ end
87
+
88
+ def normalised_compare(left, right)
89
+ left.gsub(NEWLINE_RUN_PATTERN, "\n") <=> right.gsub(NEWLINE_RUN_PATTERN, "\n")
90
+ end
91
+
92
+ private_class_method :normalised_compare
93
+ end
94
+ end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "utils"
4
3
  require "fileutils"
5
4
  require "expressir"
6
5
 
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "figure_xml"
4
- require_relative "figure_image"
5
-
6
3
  module Suma
7
4
  module Jsdai
8
5
  # Main class for JSDAI figure conversion
data/lib/suma/jsdai.rb CHANGED
@@ -1,6 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Suma
2
4
  module Jsdai
5
+ autoload :Figure, "suma/jsdai/figure"
6
+ autoload :FigureImage, "suma/jsdai/figure_image"
7
+ autoload :FigureXml, "suma/jsdai/figure_xml"
3
8
  end
4
9
  end
5
-
6
- require_relative "jsdai/figure"
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "expressir"
5
+
6
+ module Suma
7
+ # Deep module behind the EXPRESS cross-reference validation pipeline.
8
+ #
9
+ # Interface: paths in, +Result+ out. The CLI is a thin adapter that
10
+ # constructs this object and prints the result.
11
+ #
12
+ # Owns: loading the schemas manifest, discovering + reading .adoc and
13
+ # .exp files, extracting express cross-reference links, loading parsed
14
+ # schemas into a +SchemaIndex+, delegating to +LinkValidator+ for the
15
+ # actual resolution, and writing the summary file.
16
+ #
17
+ # Does not own: presentation (use +LinkValidation.generate_summary+),
18
+ # command-line argument parsing (the CLI adapter does that), or the
19
+ # link-resolution rules themselves (that is +LinkValidator+'s job).
20
+ class LinkValidation
21
+ EXPRESS_LINK_PATTERN = /<<express:([^,>]+)(?:,[^>]+)?>>/
22
+
23
+ attr_reader :schemas_file, :documents_path, :output_file,
24
+ :progress, :logger
25
+
26
+ def initialize(schemas_file:, documents_path:, output_file:,
27
+ progress: NullProgress.new, logger: Utils)
28
+ @schemas_file = Pathname.new(schemas_file).expand_path
29
+ @documents_path = Pathname.new(documents_path).expand_path
30
+ @output_file = output_file && Pathname.new(output_file).expand_path
31
+ @progress = progress
32
+ @logger = logger
33
+ end
34
+
35
+ def call
36
+ config = load_schemas_config
37
+ exp_files = collect_schema_paths(config)
38
+ adoc_files = find_adoc_files
39
+ links_by_file = extract_links(adoc_files + exp_files)
40
+ unresolved = validate_links(config, links_by_file)
41
+ result = Result.new(
42
+ adoc_count: adoc_files.size,
43
+ exp_count: exp_files.size,
44
+ total_links: links_by_file.values.sum(&:size),
45
+ unresolved: unresolved,
46
+ )
47
+ write_summary(result) if output_file
48
+ result
49
+ end
50
+
51
+ def self.generate_summary(result)
52
+ lines = []
53
+ lines << "Validation complete. Checked #{result.total_links} links."
54
+ if result.success?
55
+ lines << "✅ All links resolved successfully!"
56
+ else
57
+ lines << "❌ Found #{result.unresolved.size} unresolved links:"
58
+ result.unresolved.each { |issue| lines << format_issue(issue) }
59
+ end
60
+ lines.join("\n")
61
+ end
62
+
63
+ def self.format_issue(issue)
64
+ "#{issue.file}:#{issue.line} - " \
65
+ "<<express:#{issue.link}>> - #{issue.reason}"
66
+ end
67
+ private_class_method :format_issue
68
+
69
+ # Default no-op progress adapter. Callers that want a real progress
70
+ # bar pass an object responding to +#start(title, total)+ and
71
+ # +#increment+; this satisfies the same interface without forcing
72
+ # the dependency.
73
+ class NullProgress
74
+ def start(_title, _total); end
75
+
76
+ def increment; end
77
+ end
78
+
79
+ Result = Struct.new(
80
+ :adoc_count,
81
+ :exp_count,
82
+ :total_links,
83
+ :unresolved,
84
+ keyword_init: true,
85
+ ) do
86
+ def success?
87
+ unresolved.empty?
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def load_schemas_config
94
+ Expressir::SchemaManifest.from_yaml(File.read(schemas_file))
95
+ .tap { |c| c.set_initial_path(schemas_file.to_s) }
96
+ rescue StandardError => e
97
+ raise Error, "Error loading schemas file: #{e.message}"
98
+ end
99
+
100
+ def collect_schema_paths(schemas_config)
101
+ schemas_config.schemas.filter_map(&:path)
102
+ end
103
+
104
+ def find_adoc_files
105
+ Dir.glob(documents_path.join("**", "*.adoc").to_s)
106
+ end
107
+
108
+ def extract_links(files)
109
+ links_by_file = {}
110
+ progress.start("Processing files", files.size)
111
+ files.each do |file|
112
+ links = extract_links_from_file(file)
113
+ links_by_file[file] = links if links&.any?
114
+ end
115
+ links_by_file
116
+ end
117
+
118
+ def extract_links_from_file(file)
119
+ progress.increment
120
+ content = File.read(file)
121
+ content.scan(EXPRESS_LINK_PATTERN).flatten.uniq
122
+ rescue StandardError => e
123
+ logger.log "Warning: Could not read file #{file}: #{e.message}"
124
+ nil
125
+ end
126
+
127
+ def validate_links(schemas_config, links_by_file)
128
+ paths_by_id = schemas_config.schemas.to_h { |s| [s.id, s.path] }
129
+ progress.start("Loading schemas", paths_by_id.size)
130
+ repo = Expressir::Express::Parser.from_files(paths_by_id.values) do |*_args|
131
+ progress.increment
132
+ end
133
+ index = SchemaIndex.new(repo)
134
+ LinkValidator.new(index).validate(links_by_file)
135
+ end
136
+
137
+ def write_summary(result)
138
+ FileUtils.mkdir_p(output_file.dirname)
139
+ File.write(output_file, self.class.generate_summary(result))
140
+ rescue StandardError => e
141
+ logger.log "Error writing to output file: #{e.message}"
142
+ end
143
+ end
144
+ end
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "schema_index"
4
3
  require "expressir"
4
+ require "suma/link_validation"
5
5
 
6
6
  module Suma
7
- LinkValidationResult = Struct.new(:file, :line, :link, :reason, keyword_init: true)
7
+ LinkValidationResult = Struct.new(:file, :line, :link, :reason,
8
+ keyword_init: true)
8
9
 
9
10
  class LinkValidator
10
11
  def initialize(index)
@@ -28,7 +29,7 @@ module Suma
28
29
  content = File.read(file)
29
30
  index = {}
30
31
  content.lines.each_with_index do |line, idx|
31
- line.scan(/<<express:([^,>]+)(?:,[^>]+)?>>/).flatten.each do |link|
32
+ line.scan(LinkValidation::EXPRESS_LINK_PATTERN).flatten.each do |link|
32
33
  index[link] ||= idx
33
34
  end
34
35
  end
@@ -93,18 +94,22 @@ module Suma
93
94
 
94
95
  return unless parts.size > 2
95
96
 
96
- error = validate_deep_path(schema, element, parts[2..], file, line_idx, link)
97
+ error = validate_deep_path(schema, element, parts[2..], file, line_idx,
98
+ link)
97
99
  unresolved << error if error
98
100
  end
99
101
 
100
- def validate_deep_path(schema, element, path_parts, file, line_idx, full_link)
102
+ def validate_deep_path(schema, element, path_parts, file, line_idx,
103
+ full_link)
101
104
  current = element
102
105
  current_path = "#{schema.id}.#{element.id}"
103
106
 
104
107
  path_parts.each do |part|
105
108
  case current
106
109
  when Expressir::Model::Declarations::Entity
107
- attribute = current.attributes&.find { |a| a.id.downcase == part.downcase }
110
+ attribute = current.attributes&.find do |a|
111
+ a.id.downcase == part.downcase
112
+ end
108
113
 
109
114
  unless attribute
110
115
  return LinkValidationResult.new(
@@ -122,7 +127,9 @@ module Suma
122
127
  underlying = current.underlying_type
123
128
 
124
129
  if underlying.is_a?(Expressir::Model::DataTypes::Enumeration)
125
- enum_value = underlying.items.find { |e| e.id.downcase == part.downcase }
130
+ enum_value = underlying.items.find do |e|
131
+ e.id.downcase == part.downcase
132
+ end
126
133
 
127
134
  unless enum_value
128
135
  return LinkValidationResult.new(
@@ -193,7 +200,9 @@ module Suma
193
200
  schema.procedures,
194
201
  schema.subtype_constraints,
195
202
  ].each do |collection|
196
- element = collection&.find { |e| e.id.downcase == element_name.downcase }
203
+ element = collection&.find do |e|
204
+ e.id.downcase == element_name.downcase
205
+ end
197
206
  return element if element
198
207
  end
199
208
 
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "expressir"
4
+
5
+ module Suma
6
+ # Tree-walking service over a CollectionManifest.
7
+ #
8
+ # ManifestTraverser owns all imperative operations that walk or mutate a
9
+ # manifest tree: finding schemas-only entries, expanding them into
10
+ # compiled-doc sub-trees, exporting a unified Expressir::SchemaManifest,
11
+ # and removing schemas-only sources after compilation.
12
+ #
13
+ # Each method takes the manifest passed at construction as its root;
14
+ # recursion instantiates a new traverser per child node so the surface
15
+ # stays instance-based and the data model (CollectionManifest) stays pure.
16
+ #
17
+ # Schema-config loading and doc-entry construction are delegated to
18
+ # SchemaDiscovery so this class owns traversal, not schema I/O.
19
+ class ManifestTraverser
20
+ attr_reader :manifest
21
+
22
+ def initialize(manifest)
23
+ @manifest = manifest
24
+ end
25
+
26
+ # Returns every entry (anywhere in the tree) whose `schemas_only` flag
27
+ # is truthy, plus +manifest+ itself if it is schemas-only.
28
+ def find_schemas_only
29
+ results = (manifest.entry || []).select(&:schemas_only)
30
+ results << manifest if manifest.schemas_only
31
+ results
32
+ end
33
+
34
+ # Recursively concatenate every nested schema_config into a single
35
+ # Expressir::SchemaManifest. The manifest's own schema_config (if set)
36
+ # seeds the result; otherwise an empty SchemaManifest is returned.
37
+ def export_schema_config(path)
38
+ export_config = manifest.schema_config || Expressir::SchemaManifest.new
39
+ return export_config unless manifest.entry
40
+
41
+ manifest.entry.each do |child|
42
+ child_config = self.class.new(child).export_schema_config(path)
43
+ export_config.concat(child_config) if child_config
44
+ end
45
+
46
+ export_config
47
+ end
48
+
49
+ # Expand schemas-only entries into compiled-doc sub-trees. If the
50
+ # manifest has no file, walks children via process_entry. Otherwise
51
+ # loads the schema_config (SchemaDiscovery), and if this entry is
52
+ # schemas-only, hides it from the output index and appends a new
53
+ # sub-collection that hosts the compiled docs.
54
+ def expand_schemas_only(schema_output_path)
55
+ return process_entry(schema_output_path) unless manifest.file
56
+
57
+ SchemaDiscovery.new(manifest).load_config
58
+
59
+ return process_entry(schema_output_path) unless manifest.schemas_only
60
+
61
+ manifest.index = false
62
+
63
+ added = SchemaDiscovery.new(manifest).build_added_manifest(schema_output_path)
64
+ [manifest, added]
65
+ end
66
+
67
+ # Drop every direct child whose `schemas_only` is truthy. Called after
68
+ # compilation so the schemas-only manifest file no longer shows up in
69
+ # the rendered collection.
70
+ def remove_schemas_only_sources
71
+ return unless manifest.entry
72
+
73
+ kept = manifest.entry.reject(&:schemas_only)
74
+ manifest.entry = kept
75
+ end
76
+
77
+ private
78
+
79
+ # Walk each child via expand_schemas_only, flattening the results back
80
+ # into manifest.entry.
81
+ def process_entry(schema_output_path)
82
+ return [manifest] unless manifest.entry
83
+
84
+ flattened = manifest.entry.each_with_object([]) do |child, acc|
85
+ acc.concat(self.class.new(child).expand_schemas_only(schema_output_path))
86
+ end
87
+
88
+ manifest.entry = flattened
89
+ [manifest]
90
+ end
91
+ end
92
+ end
@@ -1,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "schema_collection"
4
- require_relative "utils"
5
- require_relative "collection_config"
6
- require_relative "site_config"
7
3
  require "metanorma"
8
4
 
9
5
  module Suma
@@ -41,11 +37,12 @@ module Suma
41
37
  collection_config_path = site_config.metanorma.source.files.first
42
38
  collection_config = Suma::CollectionConfig.from_file(collection_config_path)
43
39
  collection_config.path = collection_config_path
44
- collection_config.manifest.expand_schemas_only("schema_docs")
45
40
 
46
- exported_schema_config = collection_config.manifest.export_schema_config(schemas_all_path)
47
- exported_schema_config.path = schemas_all_path
41
+ traverser = ManifestTraverser.new(collection_config.manifest)
42
+ traverser.expand_schemas_only("schema_docs")
48
43
 
44
+ exported_schema_config = traverser.export_schema_config(schemas_all_path)
45
+ exported_schema_config.path = schemas_all_path
49
46
  exported_schema_config.to_file
50
47
 
51
48
  collection_config
@@ -72,7 +69,7 @@ module Suma
72
69
 
73
70
  def build_collection(collection_config, output_directory)
74
71
  new_collection_config_path = "collection-output.yaml"
75
- collection_config.manifest.remove_schemas_only_sources
72
+ ManifestTraverser.new(collection_config.manifest).remove_schemas_only_sources
76
73
  collection_config.to_file(new_collection_config_path)
77
74
 
78
75
  collection = Metanorma::Collection.parse(new_collection_config_path)
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+ require "expressir"
6
+ require "glossarist"
7
+
8
+ module Suma
9
+ # Generates a Glossarist v3 register.yaml from an EXPRESS schema manifest.
10
+ #
11
+ # The schema manifest (schemas-smrl-part-2.yml) is the single source of
12
+ # truth for schema identities and paths. This class reads it, classifies
13
+ # each schema by type (resource/module), and emits a register.yaml with
14
+ # hierarchical sections and human-readable names.
15
+ #
16
+ # Architecture:
17
+ # - Classification: delegates to ExpressSchema::Type (DRY)
18
+ # - Naming: delegates to SchemaNaming (OCP — extend naming without
19
+ # touching this class)
20
+ # - Sections: built from Glossarist::Section models (model-driven)
21
+ # - URN semantics: delegates to Suma::Urn (OCP)
22
+ #
23
+ # @example
24
+ # generator = RegisterManifestGenerator.new(
25
+ # "schemas-smrl-part-2.yml",
26
+ # urn: "urn:iso:std:iso:10303:-2:ed-2:en:tech:*",
27
+ # id: "iso10303-2-express",
28
+ # ref: "ISO 10303-2 EXPRESS Concepts",
29
+ # )
30
+ # generator.generate # writes register.yaml
31
+ class RegisterManifestGenerator
32
+ DEFAULT_OWNER = "ISO/TC 184/SC 4"
33
+ DEFAULT_STATUS = "current"
34
+ DEFAULT_ORDERING = "systematic"
35
+ DEFAULT_SCHEMA_TYPE = "glossarist"
36
+ DEFAULT_SCHEMA_VERSION = "3"
37
+
38
+ # @param schema_manifest_file [String] path to schemas-smrl-part-2.yml
39
+ # @param output_path [String] directory to write register.yaml
40
+ # @param urn [String] base URN for the dataset
41
+ # @param id [String] dataset identifier
42
+ # @param ref [String] human-readable reference label
43
+ # @param language_code [String] language for section names
44
+ # @param owner [String] dataset owner organisation
45
+ def initialize(schema_manifest_file, output_path, urn:, id:, ref:,
46
+ language_code: "eng", owner: DEFAULT_OWNER)
47
+ @schema_manifest_file = File.expand_path(schema_manifest_file)
48
+ @output_path = output_path
49
+ @urn = Suma::Urn.new(urn)
50
+ @id = id
51
+ @ref = ref
52
+ @language_code = language_code
53
+ @owner = owner
54
+ end
55
+
56
+ # Generate and write register.yaml.
57
+ #
58
+ # @return [Glossarist::DatasetRegister] the generated register
59
+ def generate
60
+ validate_inputs
61
+ schemas = load_schemas
62
+ register = build_register(schemas)
63
+
64
+ FileUtils.mkdir_p(@output_path)
65
+ output_file = File.join(@output_path, "register.yaml")
66
+ File.write(output_file, register.to_yaml)
67
+ Utils.log "Generated register.yaml: #{output_file}"
68
+ Utils.log " #{schemas.length} schemas in #{register.sections.length} categories"
69
+
70
+ register
71
+ end
72
+
73
+ private
74
+
75
+ # Verify that the manifest path exists, is a regular file, and contains
76
+ # at least one schema entry. Called from {#generate} so both the CLI
77
+ # shell and direct construction get the same validation behavior.
78
+ #
79
+ # @raise [Errno::ENOENT] when the manifest path is missing or not a file
80
+ # @raise [ArgumentError] when the manifest is empty
81
+ def validate_inputs
82
+ unless File.exist?(@schema_manifest_file)
83
+ raise Errno::ENOENT, "Specified SCHEMA_MANIFEST_FILE " \
84
+ "`#{@schema_manifest_file}` not found."
85
+ end
86
+ unless File.file?(@schema_manifest_file)
87
+ raise Errno::ENOENT, "Specified SCHEMA_MANIFEST_FILE " \
88
+ "`#{@schema_manifest_file}` is not a file."
89
+ end
90
+ end
91
+
92
+ # Load all schemas from the manifest file as SchemaManifestEntry models.
93
+ #
94
+ # @return [Array<Expressir::SchemaManifestEntry>]
95
+ def load_schemas
96
+ manifest = Expressir::SchemaManifest.from_file(@schema_manifest_file)
97
+ schemas = manifest.schemas.sort_by { |s| s.id.downcase }
98
+ if schemas.empty?
99
+ raise ArgumentError, "No schemas found in manifest " \
100
+ "`#{@schema_manifest_file}`."
101
+ end
102
+ schemas
103
+ end
104
+
105
+ # Build a fully-populated DatasetRegister model.
106
+ #
107
+ # @param schemas [Array<Expressir::SchemaManifestEntry>]
108
+ # @return [Glossarist::DatasetRegister]
109
+ def build_register(schemas)
110
+ Glossarist::DatasetRegister.new(
111
+ schema_type: DEFAULT_SCHEMA_TYPE,
112
+ schema_version: DEFAULT_SCHEMA_VERSION,
113
+ id: @id,
114
+ ref: @ref,
115
+ urn: @urn.to_s,
116
+ urn_aliases: @urn.aliases,
117
+ status: DEFAULT_STATUS,
118
+ owner: @owner,
119
+ languages: [@language_code],
120
+ ordering: DEFAULT_ORDERING,
121
+ sections: build_sections(schemas),
122
+ )
123
+ end
124
+
125
+ # Build a list of top-level sections, one per category, in
126
+ # SchemaCategory::ALL declaration order. Categories with no
127
+ # schemas are omitted.
128
+ #
129
+ # @param schemas [Array<Expressir::SchemaManifestEntry>]
130
+ # @return [Array<Glossarist::Section>]
131
+ def build_sections(schemas)
132
+ groups = schemas.group_by do |s|
133
+ SchemaCategory.for_schema(id: s.id, path: s.path)
134
+ end
135
+
136
+ SchemaCategory::ALL.filter_map do |category|
137
+ children = groups[category] || []
138
+ next if children.empty?
139
+
140
+ Glossarist::Section.new(
141
+ id: category.id,
142
+ names: { @language_code => category.label },
143
+ children: children.map { |s| build_section(s) },
144
+ )
145
+ end
146
+ end
147
+
148
+ # Build a single leaf section from a schema descriptor.
149
+ #
150
+ # @param schema [Expressir::SchemaManifestEntry]
151
+ # @return [Glossarist::Section]
152
+ def build_section(schema)
153
+ Glossarist::Section.new(
154
+ id: schema.id,
155
+ names: {
156
+ @language_code => SchemaNaming.prefixed_name(
157
+ schema.id, path: schema.path
158
+ ),
159
+ },
160
+ )
161
+ end
162
+ end
163
+ end