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
data/lib/suma/cli.rb CHANGED
@@ -1,108 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "thor"
4
- require_relative "thor_ext"
5
- require_relative "cli/validate"
6
- require "expressir"
7
- require "expressir/cli"
8
-
9
3
  module Suma
10
4
  module Cli
11
- # Core command class for handling CLI entrypoints
12
- class Core < Thor
13
- extend ThorExt::Start
14
-
15
- desc "build METANORMA_SITE_MANIFEST",
16
- "Build collection specified in site manifest (`metanorma*.yml`)"
17
- option :compile, type: :boolean, default: true,
18
- desc: "Compile or skip compile of collection"
19
- option :schemas_all_path, type: :string, aliases: "-s",
20
- desc: "Generate file that contains all " \
21
- "schemas in the collection."
22
- def build(_site_manifest)
23
- require_relative "cli/build"
24
- Cli::Build.start
25
- end
26
-
27
- desc "generate-schemas METANORMA_MANIFEST_FILE SCHEMA_MANIFEST_FILE",
28
- "Generate EXPRESS schema manifest file from Metanorma site manifest"
29
- option :exclude_paths, type: :string, default: nil, aliases: "-e",
30
- desc: "Exclude schemas paths by pattern " \
31
- "(e.g. `*_lf.exp`)"
32
- def generate_schemas(_metanorma_manifest_file, _schema_manifest_file)
33
- require_relative "cli/generate_schemas"
34
- Cli::GenerateSchemas.start
35
- end
36
-
37
- desc "reformat EXPRESS_FILE_PATH",
38
- "Reformat EXPRESS files"
39
- option :recursive, type: :boolean, default: false, aliases: "-r",
40
- desc: "Reformat EXPRESS files under the specified " \
41
- "path recursively"
42
- def reformat(_express_file_path)
43
- require_relative "cli/reformat"
44
- Cli::Reformat.start
45
- end
46
-
47
- desc "extract-terms SCHEMA_MANIFEST_FILE GLOSSARIST_OUTPUT_PATH",
48
- "Extract terms from SCHEMA_MANIFEST_FILE into " \
49
- "Glossarist v2 format"
50
- option :language_code, type: :string, default: "eng", aliases: "-l",
51
- desc: "Language code for the Glossarist"
52
- def extract_terms(_schema_manifest_file, _glossarist_output_path)
53
- require_relative "cli/extract_terms"
54
- Cli::ExtractTerms.start
55
- end
56
-
57
- desc "convert-jsdai XML_FILE IMAGE_FILE OUTPUT_DIR",
58
- "Convert JSDAI XML and image files to SVG and EXP files"
59
- def convert_jsdai(_xml_file, _image_file, _output_dir)
60
- require_relative "cli/convert_jsdai"
61
- Cli::ConvertJsdai.start
62
- end
63
-
64
- desc "export *FILES",
65
- "Export EXPRESS schemas from manifest files or " \
66
- "standalone EXPRESS files"
67
- option :output, type: :string, aliases: "-o", required: true,
68
- desc: "Output directory path"
69
- option :annotations, type: :boolean, default: false,
70
- desc: "Include annotations (remarks/comments)"
71
- option :zip, type: :boolean, default: false,
72
- desc: "Create ZIP archive of exported schemas"
73
- def export(*_files)
74
- require_relative "cli/export"
75
- Cli::Export.start
76
- end
77
-
78
- desc "compare TRIAL_SCHEMA REFERENCE_SCHEMA",
79
- "Compare EXPRESS schemas using eengine and generate Change YAML"
80
- option :output, type: :string, aliases: "-o",
81
- desc: "Output Change YAML file path"
82
- option :version, type: :string, aliases: "-v", required: true,
83
- desc: "Version number for this change version"
84
- option :mode, type: :string, default: "resource",
85
- desc: "Schema comparison mode (resource/module)"
86
- option :trial_stepmod, type: :string,
87
- desc: "Override auto-detected trial repo root"
88
- option :reference_stepmod, type: :string,
89
- desc: "Override auto-detected reference repo root"
90
- option :verbose, type: :boolean, default: false,
91
- desc: "Enable verbose output"
92
- def compare(_trial_schema, _reference_schema)
93
- require_relative "cli/compare"
94
- Cli::Compare.start
95
- end
96
-
97
- desc "validate SUBCOMMAND ...ARGS", "Validate express documents"
98
- subcommand "validate", Cli::Validate
99
-
100
- desc "expressir SUBCOMMAND ...ARGS", "Expressir commands"
101
- subcommand "expressir", Expressir::Cli
102
-
103
- def self.exit_on_failure?
104
- true
105
- end
106
- end
5
+ autoload :Core, "suma/cli/core"
6
+ autoload :Build, "suma/cli/build"
7
+ autoload :CheckSvgQuality, "suma/cli/check_svg_quality"
8
+ autoload :Compare, "suma/cli/compare"
9
+ autoload :ConvertJsdai, "suma/cli/convert_jsdai"
10
+ autoload :Export, "suma/cli/export"
11
+ autoload :ExtractTerms, "suma/cli/extract_terms"
12
+ autoload :GenerateRegister, "suma/cli/generate_register"
13
+ autoload :GenerateSchemas, "suma/cli/generate_schemas"
14
+ autoload :Reformat, "suma/cli/reformat"
15
+ autoload :Validate, "suma/cli/validate"
16
+ autoload :ValidateLinks, "suma/cli/validate_links"
107
17
  end
108
18
  end
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "utils"
4
3
  require "lutaml/model"
5
- require_relative "collection_manifest"
6
4
  require "metanorma"
7
5
 
8
6
  module Suma
@@ -4,11 +4,17 @@ require "metanorma"
4
4
  require "expressir"
5
5
 
6
6
  module Suma
7
+ # Pure data model for one node of a Metanorma collection manifest.
8
+ #
9
+ # CollectionManifest extends the Metanorma config manifest to add the
10
+ # `schemas_only` flag and an `entry` sub-collection. It owns only state:
11
+ # attributes, YAML mappings, and the `schema_config` slot populated by
12
+ # SchemaDiscovery. Tree-walking logic lives in ManifestTraverser; schema
13
+ # I/O lives in SchemaDiscovery.
7
14
  class CollectionManifest < Metanorma::Collection::Config::Manifest
8
15
  attribute :schemas_only, Lutaml::Model::Type::Boolean
9
16
  attribute :entry, CollectionManifest, collection: true,
10
17
  initialize_empty: true
11
- # attribute :schema_source, Lutaml::Model::Type::String
12
18
  attr_accessor :schema_config
13
19
 
14
20
  yaml do
@@ -33,115 +39,5 @@ module Suma
33
39
  def docref_from_yaml(model, value)
34
40
  model.entry = CollectionManifest.from_yaml(value.to_yaml)
35
41
  end
36
-
37
- # Recursively exports schema configuration by traversing collection manifests.
38
- #
39
- # This method builds an EXPRESS Schema Manifest (Expressir::SchemaManifest) by:
40
- # 1. Starting with an empty or existing Expressir::SchemaManifest
41
- # 2. Recursively traversing child entries to collect schemas
42
- # 3. Using Expressir::SchemaManifest#concat to combine manifests
43
- #
44
- # The actual schema manifest operations (creation, concatenation, serialization)
45
- # are handled by Expressir's SchemaManifest class, keeping the logic DRY.
46
- #
47
- # @param path [String] Base path for resolving relative schema paths
48
- # @return [Expressir::SchemaManifest] Combined schema manifest
49
- def export_schema_config(path)
50
- export_config = @schema_config || Expressir::SchemaManifest.new
51
- return export_config unless entry
52
-
53
- entry.each do |x|
54
- child_config = x.export_schema_config(path)
55
- # Use Expressir's concat method to combine schema manifests
56
- export_config.concat(child_config) if child_config
57
- end
58
-
59
- export_config
60
- end
61
-
62
- def lookup(attr_sym, match)
63
- results = entry.select { |e| e.send(attr_sym) == match }
64
- results << self if send(attr_sym) == match
65
- results
66
- end
67
-
68
- def process_entry(schema_output_path)
69
- return [self] unless entry
70
-
71
- ret = entry.each_with_object([]) do |e, m|
72
- add = e.expand_schemas_only(schema_output_path)
73
- m.concat(add)
74
- end
75
-
76
- self.entry = ret
77
- [self]
78
- end
79
-
80
- def expand_schemas_only(schema_output_path)
81
- return process_entry(schema_output_path) unless file
82
-
83
- update_schema_config
84
-
85
- return process_entry(schema_output_path) unless schemas_only
86
-
87
- # If we are going to keep the schemas-only file and compile it, we can't
88
- # have it showing up in output.
89
- self.index = false
90
-
91
- [self, added_collection_manifest(schema_output_path)]
92
- end
93
-
94
- def remove_schemas_only_sources
95
- ret = entry.each_with_object([]) do |e, m|
96
- e.schemas_only or m << e
97
- end
98
- self.entry = ret
99
- end
100
-
101
- def entries(schema_output_path)
102
- @schema_config.schemas.map do |schema|
103
- fname = [File.basename(schema.path, ".exp"), ".xml"].join
104
-
105
- CollectionManifest.new(
106
- identifier: schema.id,
107
- title: schema.id,
108
- file: File.join(schema_output_path, schema.id, "doc_#{fname}"),
109
- # schema_source: schema.path
110
- )
111
- end
112
- end
113
-
114
- def added_collection_manifest(schema_output_path)
115
- doc = CollectionConfig.from_file(file)
116
- doc_id = doc.bibdata.id
117
-
118
- # we need to separate this file from the following new entries
119
- added = CollectionManifest.new(
120
- title: "Collection",
121
- type: "collection",
122
- identifier: "#{identifier}_",
123
- )
124
-
125
- added.entry = [
126
- CollectionManifest.new(
127
- title: doc_id,
128
- type: "document",
129
- entry: entries(schema_output_path),
130
- ),
131
- ]
132
-
133
- added
134
- end
135
-
136
- def update_schema_config
137
- # If there is collection.yml, this is a document collection, we process
138
- # schemas.yaml.
139
- if File.basename(file) == "collection.yml"
140
- schemas_yaml_path = File.join(File.dirname(file), "schemas.yaml")
141
- if schemas_yaml_path && File.exist?(schemas_yaml_path)
142
- @schema_config = Expressir::SchemaManifest.from_file(schemas_yaml_path)
143
- end
144
- end
145
- end
146
42
  end
147
43
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
- require_relative "errors"
5
4
 
6
5
  module Suma
7
6
  module Eengine
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ module Eengine
5
+ autoload :Errors, "suma/eengine/errors"
6
+ autoload :Wrapper, "suma/eengine/wrapper"
7
+ end
8
+ end
@@ -1,11 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "utils"
4
3
  require "fileutils"
5
4
  require "expressir"
6
5
 
7
6
  module Suma
8
7
  class ExpressSchema
8
+ module Type
9
+ RESOURCE = :resource
10
+ MODULE_ARM = :module_arm
11
+ MODULE_MIM = :module_mim
12
+ BUSINESS_OBJECT_MODEL = :business_object_model
13
+ CORE_MODEL = :core_model
14
+ STANDALONE = :standalone
15
+
16
+ ID_SUFFIXES = {
17
+ "_arm" => :MODULE_ARM,
18
+ "_mim" => :MODULE_MIM,
19
+ "_bom" => :BUSINESS_OBJECT_MODEL,
20
+ }.freeze
21
+
22
+ PATH_SEGMENTS = {
23
+ "/resources/" => :RESOURCE,
24
+ "/modules/" => :MODULE_ARM,
25
+ "/core_model/" => :CORE_MODEL,
26
+ }.freeze
27
+
28
+ def self.classify(id:, path:)
29
+ name = id&.downcase || ""
30
+
31
+ ID_SUFFIXES.each do |suffix, type|
32
+ return const_get(type) if name.end_with?(suffix)
33
+ end
34
+
35
+ path_str = path.to_s
36
+ PATH_SEGMENTS.each do |segment, type|
37
+ return const_get(type) if path_str.include?(segment)
38
+ end
39
+
40
+ STANDALONE
41
+ end
42
+ end
43
+
9
44
  attr_accessor :path, :id, :parsed, :output_path, :is_standalone_file
10
45
 
11
46
  def initialize(id:, path:, output_path:, is_standalone_file: false)
@@ -16,14 +51,7 @@ module Suma
16
51
  end
17
52
 
18
53
  def type
19
- path_str = @path.to_s
20
- if path_str.include?("/resources/")
21
- "resources"
22
- elsif path_str.include?("/modules/")
23
- "modules"
24
- else
25
- "unknown_type"
26
- end
54
+ @type ||= classify
27
55
  end
28
56
 
29
57
  def parsed
@@ -50,11 +78,8 @@ module Suma
50
78
 
51
79
  def build_output_filename
52
80
  if @is_standalone_file
53
- # For standalone files, output directly to output_path
54
81
  File.join(@output_path, "#{@id}.exp")
55
82
  else
56
- # For manifest schemas, preserve directory structure
57
- # Note: @output_path already contains the category (resources/modules)
58
83
  parent_dir = File.basename(File.dirname(@path))
59
84
  File.join(@output_path, parent_dir, File.basename(@path))
60
85
  end
@@ -65,29 +90,16 @@ module Suma
65
90
  schema_type = with_annotations ? "annotated" : "plain"
66
91
  Utils.log "Save #{schema_type} schema: #{relative_path}"
67
92
 
68
- # return if File.exist?(filename_plain)
69
93
  FileUtils.mkdir_p(File.dirname(filename_plain))
70
94
 
71
95
  content = with_annotations ? parsed.to_s(no_remarks: false) : to_plain
72
96
  File.write(filename_plain, content)
73
97
  end
74
- end
75
- end
76
-
77
- # col = Suma::SchemaCollection.new(
78
- # config_yaml: 'suma-schemas.yaml',
79
- # output_path_docs: 'schema_docs',
80
- # output_path_schemas: 'plain_schemas'
81
- # )
82
-
83
- # docs = col.compile
84
98
 
85
- # paths = col.schemas.map do |schema|
86
- # {
87
- # plain_schema_path: schema.filename_plain,
88
- # schema_doc_path: col.doc_from_schema_name(schema.id).output_xml_path
89
- # }
90
- # end
99
+ private
91
100
 
92
- # Utils.log "COMPILED FILES ARE AT:"
93
- # pp paths
101
+ def classify
102
+ Type.classify(id: @id, path: @path)
103
+ end
104
+ end
105
+ end
@@ -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
@@ -11,10 +11,11 @@ module Suma
11
11
  attribute :href, :string
12
12
 
13
13
  xml do
14
- root "img.area"
15
- map_attribute "shape", to: :shape
16
- map_attribute "coords", to: :coords
17
- map_attribute "href", to: :href
14
+ element "img.area"
15
+ ordered
16
+ map_attribute "shape", to: :shape, render_empty: true
17
+ map_attribute "coords", to: :coords, render_empty: true
18
+ map_attribute "href", to: :href, render_empty: true
18
19
  end
19
20
  end
20
21
 
@@ -24,8 +25,9 @@ module Suma
24
25
  attribute :areas, FigureXmlImageArea, collection: true
25
26
 
26
27
  xml do
27
- root "img"
28
- map_attribute "src", to: :src
28
+ element "img"
29
+ ordered
30
+ map_attribute "src", to: :src, render_empty: true
29
31
  map_element "img.area", to: :areas
30
32
  end
31
33
  end
@@ -37,9 +39,10 @@ module Suma
37
39
  attribute :img, FigureXmlImage
38
40
 
39
41
  xml do
40
- root "imgfile.content"
41
- map_attribute "module", to: :module
42
- map_attribute "file", to: :file
42
+ element "imgfile.content"
43
+ ordered
44
+ map_attribute "module", to: :module, render_empty: true
45
+ map_attribute "file", to: :file, render_empty: true
43
46
  map_element "img", to: :img
44
47
  end
45
48
  end
data/lib/suma/jsdai.rb CHANGED
@@ -1,12 +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"
7
-
8
- # Configure XML adapter to Nokogiri because Ox goes into a "stack level too
9
- # deep" error, for unknown reasons
10
- Lutaml::Model::Config.configure do |config|
11
- config.xml_adapter_type = :nokogiri
12
- end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "expressir"
4
+
5
+ module Suma
6
+ LinkValidationResult = Struct.new(:file, :line, :link, :reason,
7
+ keyword_init: true)
8
+
9
+ class LinkValidator
10
+ def initialize(index)
11
+ @index = index
12
+ end
13
+
14
+ def validate(links_by_file)
15
+ unresolved = []
16
+
17
+ links_by_file.each do |file, links|
18
+ line_index = build_link_line_index(file)
19
+ validate_file(file, links, line_index, unresolved)
20
+ end
21
+
22
+ unresolved
23
+ end
24
+
25
+ private
26
+
27
+ def build_link_line_index(file)
28
+ content = File.read(file)
29
+ index = {}
30
+ content.lines.each_with_index do |line, idx|
31
+ line.scan(/<<express:([^,>]+)(?:,[^>]+)?>>/).flatten.each do |link|
32
+ index[link] ||= idx
33
+ end
34
+ end
35
+ index
36
+ end
37
+
38
+ def validate_file(file, links, line_index, unresolved)
39
+ links.each do |link|
40
+ line_idx = line_index[link]
41
+ next unless line_idx
42
+
43
+ parts = link.split(".")
44
+
45
+ if parts.size == 1
46
+ validate_schema_only(parts[0], file, line_idx, link, unresolved)
47
+ else
48
+ validate_element(parts, file, line_idx, link, unresolved)
49
+ end
50
+ end
51
+ end
52
+
53
+ def validate_schema_only(schema_name, file, line_idx, link, unresolved)
54
+ schema = @index.find_schema(schema_name)
55
+
56
+ return if schema
57
+
58
+ unresolved << LinkValidationResult.new(
59
+ file: file,
60
+ line: line_idx + 1,
61
+ link: link,
62
+ reason: "Schema '#{schema_name}' not found",
63
+ )
64
+ end
65
+
66
+ def validate_element(parts, file, line_idx, link, unresolved)
67
+ schema_name = parts[0]
68
+ element_name = parts[1]
69
+
70
+ schema = @index.find_schema(schema_name)
71
+
72
+ unless schema
73
+ unresolved << LinkValidationResult.new(
74
+ file: file,
75
+ line: line_idx + 1,
76
+ link: link,
77
+ reason: "Schema '#{schema_name}' not found",
78
+ )
79
+ return
80
+ end
81
+
82
+ element = @index.find_element(schema_name, element_name)
83
+
84
+ unless element
85
+ unresolved << LinkValidationResult.new(
86
+ file: file,
87
+ line: line_idx + 1,
88
+ link: link,
89
+ reason: "Element '#{element_name}' not found in schema '#{schema_name}'",
90
+ )
91
+ return
92
+ end
93
+
94
+ return unless parts.size > 2
95
+
96
+ error = validate_deep_path(schema, element, parts[2..], file, line_idx,
97
+ link)
98
+ unresolved << error if error
99
+ end
100
+
101
+ def validate_deep_path(schema, element, path_parts, file, line_idx,
102
+ full_link)
103
+ current = element
104
+ current_path = "#{schema.id}.#{element.id}"
105
+
106
+ path_parts.each do |part|
107
+ case current
108
+ when Expressir::Model::Declarations::Entity
109
+ attribute = current.attributes&.find do |a|
110
+ a.id.downcase == part.downcase
111
+ end
112
+
113
+ unless attribute
114
+ return LinkValidationResult.new(
115
+ file: file,
116
+ line: line_idx + 1,
117
+ link: full_link,
118
+ reason: "Attribute '#{part}' not found in entity '#{current_path}'",
119
+ )
120
+ end
121
+
122
+ current = attribute
123
+ current_path += ".#{part}"
124
+
125
+ when Expressir::Model::Declarations::Type
126
+ underlying = current.underlying_type
127
+
128
+ if underlying.is_a?(Expressir::Model::DataTypes::Enumeration)
129
+ enum_value = underlying.items.find do |e|
130
+ e.id.downcase == part.downcase
131
+ end
132
+
133
+ unless enum_value
134
+ return LinkValidationResult.new(
135
+ file: file,
136
+ line: line_idx + 1,
137
+ link: full_link,
138
+ reason: "Enumeration value '#{part}' not found in type '#{current_path}'",
139
+ )
140
+ end
141
+
142
+ current = enum_value
143
+ current_path += ".#{part}"
144
+
145
+ elsif underlying
146
+ base_type = find_base_type(schema, underlying)
147
+
148
+ unless base_type
149
+ return LinkValidationResult.new(
150
+ file: file,
151
+ line: line_idx + 1,
152
+ link: full_link,
153
+ reason: "Base type not found for '#{current_path}'",
154
+ )
155
+ end
156
+
157
+ current = base_type
158
+
159
+ else
160
+ return LinkValidationResult.new(
161
+ file: file,
162
+ line: line_idx + 1,
163
+ link: full_link,
164
+ reason: "Cannot navigate deeper from type '#{current_path}'",
165
+ )
166
+ end
167
+
168
+ else
169
+ return LinkValidationResult.new(
170
+ file: file,
171
+ line: line_idx + 1,
172
+ link: full_link,
173
+ reason: "Cannot navigate deeper from '#{current_path}'",
174
+ )
175
+ end
176
+ end
177
+
178
+ nil
179
+ end
180
+
181
+ def find_base_type(schema, type_ref)
182
+ return nil if %w[INTEGER REAL STRING BOOLEAN NUMBER BINARY
183
+ LOGICAL].include?(type_ref.to_s.upcase)
184
+
185
+ if type_ref.is_a?(String)
186
+ find_schema_element(schema, type_ref)
187
+ elsif type_ref.is_a?(Expressir::Model::ModelElement)
188
+ type_ref
189
+ end
190
+ end
191
+
192
+ def find_schema_element(schema, element_name)
193
+ [
194
+ schema.entities,
195
+ schema.types,
196
+ schema.constants,
197
+ schema.functions,
198
+ schema.rules,
199
+ schema.procedures,
200
+ schema.subtype_constraints,
201
+ ].each do |collection|
202
+ element = collection&.find do |e|
203
+ e.id.downcase == element_name.downcase
204
+ end
205
+ return element if element
206
+ end
207
+
208
+ nil
209
+ end
210
+ end
211
+ end