expressir 2.1.30 → 2.1.31

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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/docs.yml +98 -0
  3. data/.github/workflows/links.yml +100 -0
  4. data/.github/workflows/rake.yml +4 -0
  5. data/.github/workflows/release.yml +5 -0
  6. data/.github/workflows/validate_schemas.yml +1 -1
  7. data/.gitignore +3 -0
  8. data/.rubocop.yml +1 -1
  9. data/.rubocop_todo.yml +244 -39
  10. data/Gemfile +2 -1
  11. data/README.adoc +621 -54
  12. data/docs/Gemfile +12 -0
  13. data/docs/_config.yml +141 -0
  14. data/docs/_guides/changes/changes-format.adoc +778 -0
  15. data/docs/_guides/changes/importing-eengine.adoc +898 -0
  16. data/docs/_guides/changes/index.adoc +396 -0
  17. data/docs/_guides/changes/programmatic-usage.adoc +1038 -0
  18. data/docs/_guides/changes/validating-changes.adoc +681 -0
  19. data/docs/_guides/cli/benchmark-performance.adoc +834 -0
  20. data/docs/_guides/cli/coverage-analysis.adoc +921 -0
  21. data/docs/_guides/cli/format-schemas.adoc +547 -0
  22. data/docs/_guides/cli/index.adoc +8 -0
  23. data/docs/_guides/cli/managing-changes.adoc +927 -0
  24. data/docs/_guides/cli/validate-ascii.adoc +645 -0
  25. data/docs/_guides/cli/validate-schemas.adoc +534 -0
  26. data/docs/_guides/index.adoc +165 -0
  27. data/docs/_guides/ler/creating-packages.adoc +664 -0
  28. data/docs/_guides/ler/index.adoc +305 -0
  29. data/docs/_guides/ler/loading-packages.adoc +707 -0
  30. data/docs/_guides/ler/package-formats.adoc +748 -0
  31. data/docs/_guides/ler/querying-packages.adoc +826 -0
  32. data/docs/_guides/ler/validating-packages.adoc +750 -0
  33. data/docs/_guides/liquid/basic-templates.adoc +813 -0
  34. data/docs/_guides/liquid/documentation-generation.adoc +1042 -0
  35. data/docs/_guides/liquid/drops-reference.adoc +829 -0
  36. data/docs/_guides/liquid/filters-and-tags.adoc +912 -0
  37. data/docs/_guides/liquid/index.adoc +468 -0
  38. data/docs/_guides/manifests/creating-manifests.adoc +483 -0
  39. data/docs/_guides/manifests/index.adoc +307 -0
  40. data/docs/_guides/manifests/resolving-manifests.adoc +557 -0
  41. data/docs/_guides/manifests/validating-manifests.adoc +713 -0
  42. data/docs/_guides/ruby-api/formatting-schemas.adoc +605 -0
  43. data/docs/_guides/ruby-api/index.adoc +257 -0
  44. data/docs/_guides/ruby-api/parsing-files.adoc +421 -0
  45. data/docs/_guides/ruby-api/search-engine.adoc +609 -0
  46. data/docs/_guides/ruby-api/working-with-repository.adoc +577 -0
  47. data/docs/_pages/data-model.adoc +665 -0
  48. data/docs/_pages/express-language.adoc +506 -0
  49. data/docs/_pages/getting-started.adoc +414 -0
  50. data/docs/_pages/index.adoc +116 -0
  51. data/docs/_pages/introduction.adoc +256 -0
  52. data/docs/_pages/ler-packages.adoc +837 -0
  53. data/docs/_pages/parsers.adoc +683 -0
  54. data/docs/_pages/schema-manifests.adoc +431 -0
  55. data/docs/_references/index.adoc +228 -0
  56. data/docs/_tutorials/creating-ler-package.adoc +735 -0
  57. data/docs/_tutorials/documentation-coverage.adoc +795 -0
  58. data/docs/_tutorials/index.adoc +221 -0
  59. data/docs/_tutorials/liquid-templates.adoc +806 -0
  60. data/docs/_tutorials/parsing-your-first-schema.adoc +522 -0
  61. data/docs/_tutorials/querying-schemas.adoc +751 -0
  62. data/docs/_tutorials/working-with-multiple-schemas.adoc +676 -0
  63. data/docs/index.adoc +242 -0
  64. data/docs/lychee.toml +84 -0
  65. data/examples/demo_ler_usage.sh +86 -0
  66. data/examples/ler/README.md +111 -0
  67. data/examples/ler/simple_example.ler +0 -0
  68. data/examples/ler/simple_schema.exp +33 -0
  69. data/examples/ler_build.rb +75 -0
  70. data/examples/ler_cli.rb +79 -0
  71. data/examples/ler_demo_complete.rb +276 -0
  72. data/examples/ler_query.rb +91 -0
  73. data/examples/ler_query_examples.rb +305 -0
  74. data/examples/ler_stats.rb +81 -0
  75. data/examples/phase3_demo.rb +159 -0
  76. data/examples/query_demo_simple.rb +131 -0
  77. data/expressir.gemspec +2 -0
  78. data/lib/expressir/cli.rb +12 -4
  79. data/lib/expressir/commands/manifest.rb +427 -0
  80. data/lib/expressir/commands/package.rb +1274 -0
  81. data/lib/expressir/commands/validate.rb +70 -37
  82. data/lib/expressir/commands/validate_ascii.rb +607 -0
  83. data/lib/expressir/commands/validate_load.rb +88 -0
  84. data/lib/expressir/express/formatter.rb +5 -1
  85. data/lib/expressir/express/formatters/remark_item_formatter.rb +25 -0
  86. data/lib/expressir/express/parser.rb +33 -0
  87. data/lib/expressir/manifest/resolver.rb +213 -0
  88. data/lib/expressir/manifest/validator.rb +195 -0
  89. data/lib/expressir/model/declarations/entity.rb +6 -0
  90. data/lib/expressir/model/dependency_resolver.rb +270 -0
  91. data/lib/expressir/model/indexes/entity_index.rb +103 -0
  92. data/lib/expressir/model/indexes/reference_index.rb +148 -0
  93. data/lib/expressir/model/indexes/type_index.rb +149 -0
  94. data/lib/expressir/model/interface_validator.rb +384 -0
  95. data/lib/expressir/model/repository.rb +400 -5
  96. data/lib/expressir/model/repository_validator.rb +295 -0
  97. data/lib/expressir/model/search_engine.rb +525 -0
  98. data/lib/expressir/model.rb +4 -94
  99. data/lib/expressir/package/builder.rb +200 -0
  100. data/lib/expressir/package/metadata.rb +81 -0
  101. data/lib/expressir/package/reader.rb +165 -0
  102. data/lib/expressir/schema_manifest.rb +11 -1
  103. data/lib/expressir/version.rb +1 -1
  104. data/lib/expressir.rb +15 -2
  105. metadata +114 -4
  106. data/docs/benchmarking.adoc +0 -107
  107. data/docs/liquid_drops.adoc +0 -1547
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Model
5
+ # Resolves dependencies between EXPRESS schemas
6
+ # Handles USE FROM and REFERENCE FROM interfaces
7
+ # Detects circular dependencies
8
+ class DependencyResolver
9
+ # Exception for circular dependencies
10
+ class CircularDependencyError < StandardError
11
+ attr_reader :dependency_chain
12
+
13
+ def initialize(message, chain)
14
+ super(message)
15
+ @dependency_chain = chain
16
+ end
17
+ end
18
+
19
+ attr_reader :base_dirs, :schema_registry, :verbose, :unresolved
20
+
21
+ # Initialize resolver
22
+ # @param base_dirs [String, Array<String>] Base directories to search
23
+ # @param schema_registry [Hash] Manual schema name => path mappings
24
+ # @param verbose [Boolean] Enable verbose logging
25
+ def initialize(base_dirs: Dir.pwd, schema_registry: {}, verbose: false)
26
+ @base_dirs = Array(base_dirs).map { |d| File.expand_path(d) }
27
+ @schema_registry = schema_registry || {}
28
+ @verbose = verbose
29
+ @resolved_schemas = Set.new
30
+ @resolution_stack = []
31
+ @unresolved = []
32
+ @last_found_base_index = nil
33
+ @last_found_base_dir = nil
34
+ @multiple_matches = []
35
+ end
36
+
37
+ # Resolve all dependencies starting from root schema
38
+ # @param root_schema_path [String] Path to root schema file
39
+ # @return [Array<String>] Ordered list of schema file paths
40
+ def resolve_dependencies(root_schema_path)
41
+ @resolved_schemas.clear
42
+ @resolution_stack.clear
43
+ @unresolved.clear
44
+
45
+ resolve_recursive(File.expand_path(root_schema_path))
46
+ @resolved_schemas.to_a
47
+ end
48
+
49
+ # Extract interfaces from schema file using proper parsing
50
+ # @param schema_path [String] Path to schema file
51
+ # @return [Array<Hash>] List of interfaces with :kind, :schema_name
52
+ def extract_interfaces(schema_path)
53
+ require_relative "../express/parser"
54
+
55
+ interfaces = []
56
+
57
+ # Parse the schema file properly
58
+ repo = Expressir::Express::Parser.from_file(schema_path)
59
+
60
+ # Extract interfaces from first schema (file should contain one schema)
61
+ schema = repo.schemas.first
62
+ return interfaces unless schema&.interfaces
63
+
64
+ schema.interfaces.each do |interface|
65
+ kind = case interface.kind
66
+ when Expressir::Model::Declarations::Interface::USE
67
+ "USE"
68
+ when Expressir::Model::Declarations::Interface::REFERENCE
69
+ "REFERENCE"
70
+ else
71
+ interface.kind.first
72
+ end
73
+
74
+ interfaces << {
75
+ kind: kind,
76
+ schema_name: interface.schema.id,
77
+ }
78
+ end
79
+
80
+ interfaces
81
+ end
82
+
83
+ # Resolve schema location
84
+ # @param schema_name [String] Schema name to find
85
+ # @param kind [String] Interface kind ("USE" or "REFERENCE")
86
+ # @param current_path [String] Path of schema requesting the reference
87
+ # @return [String, nil] Path to schema file, or nil if not found
88
+ def resolve_schema_location(schema_name, _kind, _current_path)
89
+ # Check schema_registry first
90
+ if @schema_registry[schema_name]
91
+ registry_path = File.expand_path(@schema_registry[schema_name])
92
+ return registry_path if File.exist?(registry_path)
93
+ end
94
+
95
+ # Search ONLY in base_dirs recursively and collect ALL matches
96
+ all_matches = []
97
+ @base_dirs.each_with_index do |base_dir, index|
98
+ candidate = find_schema_in_directory(schema_name, base_dir)
99
+ if candidate
100
+ all_matches << { path: candidate, base_dir: base_dir, index: index }
101
+ end
102
+ end
103
+
104
+ # If no matches found, return nil
105
+ return nil if all_matches.empty?
106
+
107
+ # If multiple matches found, record for warning
108
+ if all_matches.size > 1
109
+ @multiple_matches << {
110
+ schema_name: schema_name,
111
+ matches: all_matches,
112
+ }
113
+ end
114
+
115
+ # Use the first match
116
+ first_match = all_matches.first
117
+ @last_found_base_index = first_match[:index]
118
+ @last_found_base_dir = first_match[:base_dir]
119
+
120
+ first_match[:path]
121
+ end
122
+
123
+ # Get resolution statistics
124
+ # @return [Hash] Statistics about resolved schemas
125
+ def statistics
126
+ {
127
+ total_schemas: @resolved_schemas.size,
128
+ schemas: @resolved_schemas.to_a,
129
+ base_dirs: @base_dirs,
130
+ multiple_matches: @multiple_matches,
131
+ }
132
+ end
133
+
134
+ private
135
+
136
+ # Resolve schema and its dependencies recursively
137
+ # @param schema_path [String] Path to schema file
138
+ # @return [void]
139
+ def resolve_recursive(schema_path)
140
+ return if @resolved_schemas.include?(schema_path)
141
+
142
+ # Check for circular dependency - schema-level circular references are valid in EXPRESS
143
+ if @resolution_stack.include?(schema_path)
144
+ # Schema is already being processed - this is a back-reference
145
+ # Skip re-processing; the schema will be added to resolved_schemas when its original pass completes
146
+ return
147
+ end
148
+
149
+ @resolution_stack.push(schema_path)
150
+
151
+ # Extract and resolve interfaces
152
+ interfaces = extract_interfaces(schema_path)
153
+ interfaces.each do |interface|
154
+ if @verbose
155
+ schema_name = File.basename(schema_path, ".*")
156
+ print " #{interface[:kind]} FROM #{interface[:schema_name]} (in #{schema_name}): "
157
+ end
158
+
159
+ dep_path = resolve_schema_location(
160
+ interface[:schema_name],
161
+ interface[:kind],
162
+ schema_path,
163
+ )
164
+
165
+ if dep_path
166
+ if @verbose
167
+ # Show which source and relative path
168
+ if @last_found_base_dir && @last_found_base_index
169
+ relative_path = dep_path.sub("#{@last_found_base_dir}/", "")
170
+ source_label = "[source #{@last_found_base_index + 1}]"
171
+ puts "\e[32m✓\e[0m #{source_label} #{relative_path}"
172
+ else
173
+ relative_path = File.basename(dep_path)
174
+ puts "\e[32m✓\e[0m #{relative_path}"
175
+ end
176
+ end
177
+ resolve_recursive(dep_path)
178
+ else
179
+ # Track unresolved reference
180
+ @unresolved << {
181
+ from: schema_path,
182
+ kind: interface[:kind],
183
+ schema_name: interface[:schema_name],
184
+ }
185
+
186
+ if @verbose
187
+ puts "\e[31m✗ not found\e[0m"
188
+ end
189
+ end
190
+ end
191
+
192
+ @resolution_stack.pop
193
+ @resolved_schemas.add(schema_path)
194
+ end
195
+
196
+ # Find schema file in directory
197
+ # @param schema_name [String] Schema name to find
198
+ # @param directory [String] Directory to search
199
+ # @param recursive [Boolean] Whether to search subdirectories
200
+ # @return [String, nil] Path to schema file or nil
201
+ def find_schema_in_directory(schema_name, directory)
202
+ return nil unless File.directory?(directory)
203
+
204
+ # Try direct match first (resource pattern: schema_name.exp)
205
+ candidate = File.join(directory, "#{schema_name}.exp")
206
+ return File.expand_path(candidate) if File.exist?(candidate)
207
+
208
+ # Try module pattern: {lower-case-prefix-without-_schema}/{arm|mim}.exp
209
+ # Examples:
210
+ # Activity_method_mim -> activity_method/mim.exp
211
+ # Activity_method_arm -> activity_method/arm.exp
212
+ module_path = try_module_pattern(schema_name, directory)
213
+ return module_path if module_path
214
+
215
+ # Search recursively in subdirectories
216
+ # Only in base_dirs to avoid permission errors in system directories
217
+ begin
218
+ # First try direct name match
219
+ Dir.glob(File.join(directory, "**",
220
+ "#{schema_name}.exp")).each do |file|
221
+ return File.expand_path(file)
222
+ end
223
+
224
+ # Also try module pattern recursively for _arm/_mim schemas
225
+ if schema_name =~ /^(.+)_(arm|mim)$/i
226
+ prefix = Regexp.last_match(1)
227
+ suffix = Regexp.last_match(2).downcase
228
+ prefix = prefix.sub(/_schema$/i, "")
229
+ module_dir = prefix.downcase
230
+
231
+ # Search for **/module_dir/suffix.exp pattern
232
+ Dir.glob(File.join(directory, "**", module_dir,
233
+ "#{suffix}.exp")).each do |file|
234
+ return File.expand_path(file)
235
+ end
236
+ end
237
+ rescue Errno::EACCES, Errno::EPERM
238
+ # Ignore permission errors when searching directories
239
+ nil
240
+ end
241
+
242
+ nil
243
+ end
244
+
245
+ # Try module naming pattern
246
+ # @param schema_name [String] Schema name (e.g., "Activity_method_mim")
247
+ # @param directory [String] Base directory
248
+ # @return [String, nil] Path if found, nil otherwise
249
+ def try_module_pattern(schema_name, directory)
250
+ # Check if schema name ends with _arm or _mim
251
+ if schema_name =~ /^(.+)_(arm|mim)$/i
252
+ prefix = Regexp.last_match(1)
253
+ suffix = Regexp.last_match(2).downcase
254
+
255
+ # Remove trailing _schema if present
256
+ prefix = prefix.sub(/_schema$/i, "")
257
+
258
+ # Convert to lowercase and replace underscores
259
+ module_dir = prefix.downcase
260
+
261
+ # Try: directory/module_dir/suffix.exp
262
+ candidate = File.join(directory, module_dir, "#{suffix}.exp")
263
+ return File.expand_path(candidate) if File.exist?(candidate)
264
+ end
265
+
266
+ nil
267
+ end
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Model
5
+ module Indexes
6
+ # Index for fast entity lookup across schemas
7
+ # Maintains indexes by qualified name and by schema for efficient querying
8
+ class EntityIndex
9
+ attr_reader :by_schema, :by_qualified_name
10
+
11
+ # Initialize a new entity index
12
+ # @param schemas [Array<Declarations::Schema>] Schemas to index
13
+ def initialize(schemas = [])
14
+ @by_schema = {}
15
+ @by_qualified_name = {}
16
+ build(schemas) unless schemas.empty?
17
+ end
18
+
19
+ # Build indexes from schemas
20
+ # @param schemas [Array<Declarations::Schema>] Schemas to index
21
+ # @return [void]
22
+ def build(schemas)
23
+ @by_schema = {}
24
+ @by_qualified_name = {}
25
+
26
+ schemas.each do |schema|
27
+ schema_id = schema.id.safe_downcase
28
+ @by_schema[schema_id] = {}
29
+
30
+ next unless schema.entities
31
+
32
+ schema.entities.each do |entity|
33
+ entity_id = entity.id.safe_downcase
34
+ qualified_name = "#{schema_id}.#{entity_id}"
35
+
36
+ @by_schema[schema_id][entity_id] = entity
37
+ @by_qualified_name[qualified_name] = entity
38
+ end
39
+ end
40
+ end
41
+
42
+ # Find entity by qualified name
43
+ # @param qualified_name [String] Entity qualified name (e.g., "schema.entity")
44
+ # @return [Declarations::Entity, nil] Found entity or nil
45
+ def find(qualified_name)
46
+ return nil if qualified_name.nil? || qualified_name.empty?
47
+
48
+ normalized_name = qualified_name.safe_downcase
49
+
50
+ # Try qualified name first
51
+ entity = @by_qualified_name[normalized_name]
52
+ return entity if entity
53
+
54
+ # Try as simple name across all schemas
55
+ schema_name, entity_name = normalized_name.split(".", 2)
56
+ if entity_name.nil?
57
+ # Search all schemas for simple name
58
+ @by_schema.each_value do |entities|
59
+ entity = entities[schema_name]
60
+ return entity if entity
61
+ end
62
+ else
63
+ # Look in specific schema
64
+ schema_entities = @by_schema[schema_name]
65
+ return schema_entities[entity_name] if schema_entities
66
+ end
67
+
68
+ nil
69
+ end
70
+
71
+ # List all entities
72
+ # @param schema [String, nil] Filter by schema name
73
+ # @return [Array<Declarations::Entity>] List of entities
74
+ def list(schema: nil)
75
+ if schema
76
+ @by_schema[schema.safe_downcase]&.values || []
77
+ else
78
+ @by_qualified_name.values
79
+ end
80
+ end
81
+
82
+ # Check if index is empty
83
+ # @return [Boolean] True if no entities indexed
84
+ def empty?
85
+ @by_qualified_name.empty?
86
+ end
87
+
88
+ # Count total entities
89
+ # @return [Integer] Total number of entities
90
+ def count
91
+ @by_qualified_name.size
92
+ end
93
+
94
+ # Get entities for a specific schema
95
+ # @param schema [String] Schema name
96
+ # @return [Hash<String, Declarations::Entity>] Entities in schema
97
+ def schema_entities(schema)
98
+ @by_schema[schema.safe_downcase] || {}
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Model
5
+ module Indexes
6
+ # Index for cross-schema reference tracking
7
+ # Maintains USE FROM, REFERENCE FROM relationships and dependency graphs
8
+ class ReferenceIndex
9
+ attr_reader :use_from, :reference_from, :dependencies
10
+
11
+ # Initialize a new reference index
12
+ # @param schemas [Array<Declarations::Schema>] Schemas to index
13
+ def initialize(schemas = [])
14
+ @use_from = {}
15
+ @reference_from = {}
16
+ @dependencies = {}
17
+ build(schemas) unless schemas.empty?
18
+ end
19
+
20
+ # Build indexes from schemas
21
+ # @param schemas [Array<Declarations::Schema>] Schemas to index
22
+ # @return [void]
23
+ def build(schemas)
24
+ @use_from = {}
25
+ @reference_from = {}
26
+ @dependencies = {}
27
+
28
+ schemas.each do |schema|
29
+ schema_id = schema.id.safe_downcase
30
+
31
+ # Initialize schema entries even if there are no interfaces
32
+ @use_from[schema_id] = []
33
+ @reference_from[schema_id] = []
34
+
35
+ next unless schema.interfaces
36
+
37
+ schema.interfaces.each do |interface|
38
+ referenced_schema = interface.schema.id.safe_downcase
39
+ items = interface.items.map { |item| item.ref.id }
40
+
41
+ if interface.kind == Declarations::Interface::USE
42
+ @use_from[schema_id] << {
43
+ schema: referenced_schema,
44
+ items: items,
45
+ }
46
+ elsif interface.kind == Declarations::Interface::REFERENCE
47
+ @reference_from[schema_id] << {
48
+ schema: referenced_schema,
49
+ items: items,
50
+ }
51
+ end
52
+
53
+ @dependencies[schema_id] ||= Set.new
54
+ @dependencies[schema_id] << referenced_schema
55
+ end
56
+ end
57
+ end
58
+
59
+ # Get USE FROM references for a schema
60
+ # @param schema [String] Schema name
61
+ # @return [Array<Hash>] List of USE FROM references
62
+ def use_references(schema)
63
+ @use_from[schema.safe_downcase] || []
64
+ end
65
+
66
+ # Get REFERENCE FROM references for a schema
67
+ # @param schema [String] Schema name
68
+ # @return [Array<Hash>] List of REFERENCE FROM references
69
+ def reference_references(schema)
70
+ @reference_from[schema.safe_downcase] || []
71
+ end
72
+
73
+ # Get all dependencies for a schema
74
+ # @param schema [String] Schema name
75
+ # @return [Set<String>] Set of dependent schema names
76
+ def schema_dependencies(schema)
77
+ @dependencies[schema.safe_downcase] || Set.new
78
+ end
79
+
80
+ # Detect circular dependencies in schema references
81
+ # @return [Array<Array<String>>] List of circular dependency cycles
82
+ def detect_circular_dependencies
83
+ cycles = []
84
+ visited = Set.new
85
+ rec_stack = Set.new
86
+
87
+ @dependencies.each_key do |schema_id|
88
+ next if visited.include?(schema_id)
89
+
90
+ path = []
91
+ detect_cycle(schema_id, visited, rec_stack, path, cycles)
92
+ end
93
+
94
+ cycles
95
+ end
96
+
97
+ # Check if index is empty
98
+ # @return [Boolean] True if no references indexed
99
+ def empty?
100
+ @dependencies.empty?
101
+ end
102
+
103
+ # Count total USE FROM references
104
+ # @return [Integer] Total USE FROM references
105
+ def use_from_count
106
+ @use_from.values.sum(&:size)
107
+ end
108
+
109
+ # Count total REFERENCE FROM references
110
+ # @return [Integer] Total REFERENCE FROM references
111
+ def reference_from_count
112
+ @reference_from.values.sum(&:size)
113
+ end
114
+
115
+ private
116
+
117
+ # Helper method for cycle detection using DFS
118
+ # @param node [String] Current schema node
119
+ # @param visited [Set] Set of visited nodes
120
+ # @param rec_stack [Set] Recursion stack for current path
121
+ # @param path [Array] Current path
122
+ # @param cycles [Array] Accumulated cycles
123
+ # @return [Boolean] True if cycle detected
124
+ def detect_cycle(node, visited, rec_stack, path, cycles)
125
+ visited << node
126
+ rec_stack << node
127
+ path << node
128
+
129
+ deps = @dependencies[node] || Set.new
130
+ deps.each do |dep|
131
+ if !visited.include?(dep)
132
+ return true if detect_cycle(dep, visited, rec_stack, path, cycles)
133
+ elsif rec_stack.include?(dep)
134
+ # Found a cycle
135
+ cycle_start = path.index(dep)
136
+ cycles << (path[cycle_start..] + [dep])
137
+ return true
138
+ end
139
+ end
140
+
141
+ path.pop
142
+ rec_stack.delete(node)
143
+ false
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Model
5
+ module Indexes
6
+ # Index for fast type lookup across schemas
7
+ # Maintains indexes by qualified name, by schema, and by category
8
+ class TypeIndex
9
+ attr_reader :by_schema, :by_qualified_name, :by_category
10
+
11
+ # Initialize a new type index
12
+ # @param schemas [Array<Declarations::Schema>] Schemas to index
13
+ def initialize(schemas = [])
14
+ @by_schema = {}
15
+ @by_qualified_name = {}
16
+ @by_category = {}
17
+ build(schemas) unless schemas.empty?
18
+ end
19
+
20
+ # Build indexes from schemas
21
+ # @param schemas [Array<Declarations::Schema>] Schemas to index
22
+ # @return [void]
23
+ def build(schemas)
24
+ @by_schema = {}
25
+ @by_qualified_name = {}
26
+ @by_category = {}
27
+
28
+ schemas.each do |schema|
29
+ schema_id = schema.id.safe_downcase
30
+ @by_schema[schema_id] = {}
31
+
32
+ next unless schema.types
33
+
34
+ schema.types.each do |type|
35
+ type_id = type.id.safe_downcase
36
+ qualified_name = "#{schema_id}.#{type_id}"
37
+ category = categorize(type)
38
+
39
+ @by_schema[schema_id][type_id] = type
40
+ @by_qualified_name[qualified_name] = type
41
+ @by_category[category] ||= []
42
+ @by_category[category] << type
43
+ end
44
+ end
45
+ end
46
+
47
+ # Find type by qualified name
48
+ # @param qualified_name [String] Type qualified name (e.g., "schema.type")
49
+ # @return [Declarations::Type, nil] Found type or nil
50
+ def find(qualified_name)
51
+ return nil if qualified_name.nil? || qualified_name.empty?
52
+
53
+ normalized_name = qualified_name.safe_downcase
54
+
55
+ # Try qualified name first
56
+ type = @by_qualified_name[normalized_name]
57
+ return type if type
58
+
59
+ # Try as simple name across all schemas
60
+ schema_name, type_name = normalized_name.split(".", 2)
61
+ if type_name.nil?
62
+ # Search all schemas for simple name
63
+ @by_schema.each_value do |types|
64
+ type = types[schema_name]
65
+ return type if type
66
+ end
67
+ else
68
+ # Look in specific schema
69
+ schema_types = @by_schema[schema_name]
70
+ return schema_types[type_name] if schema_types
71
+ end
72
+
73
+ nil
74
+ end
75
+
76
+ # List all types
77
+ # @param schema [String, nil] Filter by schema name
78
+ # @param category [String, nil] Filter by category
79
+ # @return [Array<Declarations::Type>] List of types
80
+ def list(schema: nil, category: nil)
81
+ types = if schema
82
+ @by_schema[schema.safe_downcase]&.values || []
83
+ else
84
+ @by_qualified_name.values
85
+ end
86
+
87
+ types = filter_by_category(types, category) if category
88
+
89
+ types
90
+ end
91
+
92
+ # Check if index is empty
93
+ # @return [Boolean] True if no types indexed
94
+ def empty?
95
+ @by_qualified_name.empty?
96
+ end
97
+
98
+ # Count total types
99
+ # @return [Integer] Total number of types
100
+ def count
101
+ @by_qualified_name.size
102
+ end
103
+
104
+ # Get types for a specific schema
105
+ # @param schema [String] Schema name
106
+ # @return [Hash<String, Declarations::Type>] Types in schema
107
+ def schema_types(schema)
108
+ @by_schema[schema.safe_downcase] || {}
109
+ end
110
+
111
+ # Get types for a specific category
112
+ # @param category [String] Category name
113
+ # @return [Array<Declarations::Type>] Types in category
114
+ def category_types(category)
115
+ @by_category[category] || []
116
+ end
117
+
118
+ # Categorize type by its underlying type
119
+ # @param type [Declarations::Type] Type to categorize
120
+ # @return [String] Type category
121
+ def categorize(type)
122
+ return "defined" unless type.underlying_type
123
+
124
+ underlying = type.underlying_type
125
+
126
+ # Use explicit is_a? checks for test double compatibility
127
+ return "select" if underlying.is_a?(DataTypes::Select)
128
+ return "enumeration" if underlying.is_a?(DataTypes::Enumeration)
129
+ return "aggregate" if underlying.is_a?(DataTypes::Array) ||
130
+ underlying.is_a?(DataTypes::Bag) ||
131
+ underlying.is_a?(DataTypes::List) ||
132
+ underlying.is_a?(DataTypes::Set)
133
+
134
+ "defined"
135
+ end
136
+
137
+ private
138
+
139
+ # Filter types by category
140
+ # @param types [Array<Declarations::Type>] List of types
141
+ # @param category [String] Category to filter by
142
+ # @return [Array<Declarations::Type>] Filtered types
143
+ def filter_by_category(types, category)
144
+ types.select { |type| categorize(type) == category }
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end