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,607 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "paint"
5
+ require "table_tennis"
6
+
7
+ module Expressir
8
+ module Commands
9
+ # Represents a non-ASCII character with its details and replacement
10
+ class NonAsciiCharacter
11
+ attr_reader :char, :hex, :utf8, :is_math, :replacement,
12
+ :replacement_type, :occurrences
13
+
14
+ def initialize(char, hex, utf8, is_math, replacement, replacement_type)
15
+ @char = char
16
+ @hex = hex
17
+ @utf8 = utf8
18
+ @is_math = is_math
19
+ @replacement = replacement
20
+ @replacement_type = replacement_type
21
+ @occurrences = []
22
+ end
23
+
24
+ def add_occurrence(line_number, column, line)
25
+ @occurrences << {
26
+ line_number: line_number,
27
+ column: column,
28
+ line: line,
29
+ }
30
+ end
31
+
32
+ def replacement_text
33
+ @is_math ? "AsciiMath: #{@replacement}" : "ISO 10303-11: #{@replacement}"
34
+ end
35
+
36
+ def occurrence_count
37
+ @occurrences.size
38
+ end
39
+
40
+ def to_h
41
+ {
42
+ character: @char,
43
+ hex: @hex,
44
+ utf8: @utf8,
45
+ is_math: @is_math,
46
+ replacement_type: @replacement_type,
47
+ replacement: @replacement,
48
+ occurrence_count: occurrence_count,
49
+ occurrences: @occurrences,
50
+ }
51
+ end
52
+ end
53
+
54
+ # Represents all non-ASCII characters in a file
55
+ class FileViolations
56
+ attr_reader :path, :filename, :directory, :violations, :unique_characters
57
+
58
+ def initialize(file_path)
59
+ @path = file_path
60
+ @filename = File.basename(file_path)
61
+ @directory = File.dirname(file_path)
62
+ @characters = {} # Map of characters to NonAsciiCharacter objects
63
+ @violations = [] # List of violations (line, column, etc.)
64
+ end
65
+
66
+ def add_violation(line_number, column, match, char_details, line)
67
+ violation = {
68
+ line_number: line_number,
69
+ column: column,
70
+ match: match,
71
+ char_details: char_details,
72
+ line: line,
73
+ }
74
+
75
+ @violations << violation
76
+
77
+ # Register each character
78
+ char_details.each do |detail|
79
+ char = detail[:char]
80
+ unless @characters[char]
81
+ @characters[char] = NonAsciiCharacter.new(
82
+ char,
83
+ detail[:hex],
84
+ detail[:utf8],
85
+ detail[:is_math],
86
+ detail[:replacement],
87
+ detail[:replacement_type],
88
+ )
89
+ end
90
+
91
+ @characters[char].add_occurrence(line_number, column, line)
92
+ end
93
+ end
94
+
95
+ def violation_count
96
+ @violations.size
97
+ end
98
+
99
+ def unique_characters
100
+ @characters.values
101
+ end
102
+
103
+ def display_path
104
+ "#{File.basename(@directory)}/#{@filename}"
105
+ end
106
+
107
+ def full_path
108
+ File.expand_path(@path)
109
+ end
110
+
111
+ def to_h
112
+ {
113
+ file: display_path,
114
+ count: violation_count,
115
+ non_ascii_characters: unique_characters.map(&:to_h),
116
+ }
117
+ end
118
+ end
119
+
120
+ # Collection of all violations across multiple files
121
+ class NonAsciiViolationCollection
122
+ attr_reader :file_violations, :total_files
123
+
124
+ def initialize(check_remarks: false)
125
+ @file_violations = {} # Map of file paths to FileViolations objects
126
+ @total_files = 0
127
+ @unicode_to_asciimath = nil
128
+ @check_remarks = check_remarks
129
+ end
130
+
131
+ def process_file(file)
132
+ @total_files += 1
133
+
134
+ # Initialize the mapping once
135
+ @unicode_to_asciimath ||= build_unicode_to_asciimath_map
136
+
137
+ file_violations = process_file_violations(file)
138
+ return if file_violations.violations.empty?
139
+
140
+ @file_violations[file] = file_violations
141
+ end
142
+
143
+ def files_with_violations
144
+ @file_violations.size
145
+ end
146
+
147
+ def total_violations
148
+ @file_violations.values.sum(&:violation_count)
149
+ end
150
+
151
+ def unique_character_count
152
+ # Get total unique characters across all files
153
+ all_chars = Set.new
154
+ @file_violations.each_value do |file_violation|
155
+ file_violation.unique_characters.each do |char|
156
+ all_chars.add(char.char)
157
+ end
158
+ end
159
+ all_chars.size
160
+ end
161
+
162
+ def total_occurrence_count
163
+ # Sum all occurrences of all characters across all files
164
+ @file_violations.values.sum do |file_violation|
165
+ file_violation.unique_characters.sum(&:occurrence_count)
166
+ end
167
+ end
168
+
169
+ def to_yaml_data
170
+ {
171
+ summary: {
172
+ total_files: @total_files,
173
+ files_with_violations: files_with_violations,
174
+ total_violations: total_violations,
175
+ total_unique_characters: unique_character_count,
176
+ total_occurrences: total_occurrence_count,
177
+ },
178
+ violations: @file_violations.transform_keys do |k|
179
+ File.expand_path(k)
180
+ end.transform_values(&:to_h),
181
+ }
182
+ end
183
+
184
+ def print_text_output
185
+ # Print each file's violations if any
186
+ unless @file_violations.empty?
187
+ @file_violations.each_value do |file_violation|
188
+ puts "\n#{Paint[file_violation.display_path, :cyan, :bold]}:"
189
+
190
+ file_violation.violations.each do |v|
191
+ puts " #{Paint['Line',
192
+ :blue]} #{Paint[v[:line_number],
193
+ :yellow]}, #{Paint['Column',
194
+ :blue]} #{Paint[v[:column],
195
+ :yellow]}:"
196
+ puts " #{v[:line]}"
197
+ puts " #{' ' * v[:column]}#{Paint['^' * v[:match].length,
198
+ :red]} #{Paint['Non-ASCII sequence',
199
+ :red]}"
200
+
201
+ v[:char_details].each do |cd|
202
+ character = file_violation.unique_characters.find do |c|
203
+ c.char == cd[:char]
204
+ end
205
+ next unless character
206
+
207
+ puts " #{Paint["\"#{cd[:char]}\"",
208
+ :yellow]} - Hex: #{Paint[cd[:hex],
209
+ :magenta]}, UTF-8 bytes: #{Paint[cd[:utf8],
210
+ :magenta]}"
211
+ puts " #{Paint['Replacement:',
212
+ :green]} #{character.replacement_text}"
213
+ end
214
+ puts ""
215
+ end
216
+
217
+ puts " #{Paint['Found',
218
+ :green]} #{Paint[file_violation.violation_count,
219
+ :red]} #{Paint['non-ASCII sequence(s) in',
220
+ :green]} #{Paint[file_violation.filename,
221
+ :cyan]}\n"
222
+ end
223
+ end
224
+
225
+ # Always print summary
226
+ validation_scope = @check_remarks ? "code and remarks" : "code only (remarks excluded)"
227
+ puts "\n#{Paint['Summary:', :blue, :bold]}"
228
+ puts " #{Paint['Validation scope:',
229
+ :green]} #{Paint[validation_scope,
230
+ :cyan]}"
231
+ puts " #{Paint['Scanned',
232
+ :green]} #{Paint[@total_files,
233
+ :yellow]} #{Paint['EXPRESS file(s)',
234
+ :green]}"
235
+ puts " #{Paint['Found',
236
+ :green]} #{Paint[total_violations,
237
+ :red]} #{Paint['non-ASCII sequence(s) in',
238
+ :green]} #{Paint[files_with_violations,
239
+ :red]} #{Paint['file(s)',
240
+ :green]}"
241
+ end
242
+
243
+ def print_table_output
244
+ return if @file_violations.empty?
245
+
246
+ # Build rows array
247
+ rows = []
248
+ total_occurrences = 0
249
+
250
+ @file_violations.each_value do |file_violation|
251
+ file_violation.unique_characters.each do |character|
252
+ occurrence_count = character.occurrence_count
253
+ total_occurrences += occurrence_count
254
+
255
+ rows << {
256
+ file: file_violation.display_path,
257
+ symbol: "\"#{character.char}\" (#{character.hex})",
258
+ replacement: character.replacement_text,
259
+ occurrences: occurrence_count,
260
+ }
261
+ end
262
+ end
263
+
264
+ # Add total row
265
+ rows << {
266
+ file: "TOTAL",
267
+ symbol: "#{unique_character_count} unique",
268
+ replacement: "",
269
+ occurrences: total_occurrences,
270
+ }
271
+
272
+ # Use TableTennis to render
273
+ options = {
274
+ title: "Non-ASCII Characters Summary",
275
+ columns: %i[file symbol replacement occurrences],
276
+ headers: {
277
+ file: "File",
278
+ symbol: "Symbol",
279
+ replacement: "Replacement",
280
+ occurrences: "Occurrences",
281
+ },
282
+ mark: ->(row) { row[:file] == "TOTAL" },
283
+ }
284
+
285
+ puts "\n#{TableTennis.new(rows, options)}\n"
286
+ end
287
+
288
+ private
289
+
290
+ def process_file_violations(file)
291
+ file_violations = FileViolations.new(file)
292
+
293
+ if @check_remarks
294
+ # Check remarks too - validate original file content
295
+ File.readlines(file,
296
+ encoding: "UTF-8").each_with_index do |line, line_idx|
297
+ line_number = line_idx + 1
298
+
299
+ # Skip if line only contains ASCII
300
+ next unless /[^\x00-\x7F]/.match?(line)
301
+
302
+ # Find all non-ASCII sequences
303
+ line.chomp.scan(/([^\x00-\x7F]+)/) do |match|
304
+ match = match[0]
305
+ column = line.index(match)
306
+
307
+ # Process each character in the sequence
308
+ char_details = match.chars.filter_map do |c|
309
+ process_non_ascii_char(c)
310
+ end
311
+
312
+ # Skip if no non-ASCII characters found
313
+ next if char_details.empty?
314
+
315
+ file_violations.add_violation(line_number, column, match,
316
+ char_details, line.chomp)
317
+ end
318
+ end
319
+ else
320
+ # Default: exclude remarks - use model-based approach
321
+ # Parse the EXPRESS file to get the model
322
+ repository = Expressir::Express::Parser.from_file(file)
323
+
324
+ # Format each schema without remarks to get plain EXPRESS code
325
+ repository.schemas.each do |schema|
326
+ formatted_schema = schema.to_s(no_remarks: true)
327
+
328
+ # Check the formatted schema (without remarks) for non-ASCII
329
+ formatted_schema.lines.each_with_index do |line, line_idx|
330
+ line_number = line_idx + 1
331
+
332
+ # Skip if line only contains ASCII
333
+ next unless /[^\x00-\x7F]/.match?(line)
334
+
335
+ # Find all non-ASCII sequences
336
+ line.chomp.scan(/([^\x00-\x7F]+)/) do |match|
337
+ match = match[0]
338
+ column = line.index(match)
339
+
340
+ # Process each character in the sequence
341
+ char_details = match.chars.filter_map do |c|
342
+ process_non_ascii_char(c)
343
+ end
344
+
345
+ # Skip if no non-ASCII characters found
346
+ next if char_details.empty?
347
+
348
+ file_violations.add_violation(line_number, column, match,
349
+ char_details, line.chomp)
350
+ end
351
+ end
352
+ end
353
+ end
354
+
355
+ file_violations
356
+ rescue Expressir::Express::Error::SchemaParseFailure
357
+ # If file can't be parsed, fall back to checking original content
358
+ # This ensures we still catch non-ASCII even in invalid EXPRESS
359
+ File.readlines(file,
360
+ encoding: "UTF-8").each_with_index do |line, line_idx|
361
+ line_number = line_idx + 1
362
+
363
+ # Skip if line only contains ASCII
364
+ next unless /[^\x00-\x7F]/.match?(line)
365
+
366
+ # Find all non-ASCII sequences
367
+ line.chomp.scan(/([^\x00-\x7F]+)/) do |match|
368
+ match = match[0]
369
+ column = line.index(match)
370
+
371
+ # Process each character in the sequence
372
+ char_details = match.chars.filter_map do |c|
373
+ process_non_ascii_char(c)
374
+ end
375
+
376
+ # Skip if no non-ASCII characters found
377
+ next if char_details.empty?
378
+
379
+ file_violations.add_violation(line_number, column, match,
380
+ char_details, line.chomp)
381
+ end
382
+ end
383
+
384
+ file_violations
385
+ end
386
+
387
+ def process_non_ascii_char(char)
388
+ # Skip ASCII characters
389
+ return nil if char.ord <= 0x7F
390
+
391
+ code_point = char.ord
392
+ hex = "0x#{code_point.to_s(16)}"
393
+ utf8 = code_point.chr(Encoding::UTF_8).bytes.map do |b|
394
+ "0x#{b.to_s(16)}"
395
+ end.join(" ")
396
+
397
+ # Check if it's a math symbol
398
+ if asciimath = @unicode_to_asciimath[char]
399
+ return {
400
+ char: char,
401
+ hex: hex,
402
+ utf8: utf8,
403
+ is_math: true,
404
+ replacement: asciimath,
405
+ replacement_type: "asciimath",
406
+ }
407
+ end
408
+
409
+ # Not a math symbol, use ISO encoding
410
+ {
411
+ char: char,
412
+ hex: hex,
413
+ utf8: utf8,
414
+ is_math: false,
415
+ replacement: encode_iso_10303_11(char),
416
+ replacement_type: "iso-10303-11",
417
+ }
418
+ end
419
+
420
+ def encode_iso_10303_11(char)
421
+ code_point = char.ord
422
+
423
+ # Format the encoded value with double quotes
424
+ if code_point < 0x10000
425
+ "\"#{sprintf('%08X', code_point)}\"" # e.g., "00000041" for 'A'
426
+ else
427
+ # For higher code points, use all four octets
428
+ group = (code_point >> 24) & 0xFF
429
+ plane = (code_point >> 16) & 0xFF
430
+ row = (code_point >> 8) & 0xFF
431
+ cell = code_point & 0xFF
432
+
433
+ "\"#{sprintf('%02X%02X%02X%02X', group, plane, row, cell)}\""
434
+ end
435
+ end
436
+
437
+ def build_unicode_to_asciimath_map
438
+ # Pre-defined mapping of common math symbols
439
+ {
440
+ # Greek letters
441
+ "α" => "alpha",
442
+ "β" => "beta",
443
+ "γ" => "gamma",
444
+ "Γ" => "Gamma",
445
+ "δ" => "delta",
446
+ "Δ" => "Delta",
447
+ "ε" => "epsilon",
448
+ "ζ" => "zeta",
449
+ "η" => "eta",
450
+ "θ" => "theta",
451
+ "Θ" => "Theta",
452
+ "ι" => "iota",
453
+ "κ" => "kappa",
454
+ "λ" => "lambda",
455
+ "Λ" => "Lambda",
456
+ "μ" => "mu",
457
+ "ν" => "nu",
458
+ "ξ" => "xi",
459
+ "Ξ" => "Xi",
460
+ "π" => "pi",
461
+ "Π" => "Pi",
462
+ "ρ" => "rho",
463
+ "σ" => "sigma",
464
+ "Σ" => "Sigma",
465
+ "τ" => "tau",
466
+ "υ" => "upsilon",
467
+ "φ" => "phi",
468
+ "Φ" => "Phi",
469
+ "χ" => "chi",
470
+ "ψ" => "psi",
471
+ "Ψ" => "Psi",
472
+ "ω" => "omega",
473
+ "Ω" => "Omega",
474
+
475
+ # Math operators
476
+ "×" => "xx",
477
+ "÷" => "div",
478
+ "±" => "pm",
479
+ "∓" => "mp",
480
+ "∞" => "oo",
481
+ "≤" => "le",
482
+ "≥" => "ge",
483
+ "≠" => "ne",
484
+ "≈" => "~~",
485
+ "≅" => "cong",
486
+ "≡" => "equiv",
487
+ "∈" => "in",
488
+ "∉" => "notin",
489
+ "⊂" => "subset",
490
+ "⊃" => "supset",
491
+ "∩" => "cap",
492
+ "∪" => "cup",
493
+ "∧" => "and",
494
+ "∨" => "or",
495
+ "¬" => "neg",
496
+ "∀" => "forall",
497
+ "∃" => "exists",
498
+ "∄" => "nexists",
499
+ "∇" => "grad",
500
+ "∂" => "del",
501
+ "∑" => "sum",
502
+ "∏" => "prod",
503
+ "∫" => "int",
504
+ "∮" => "oint",
505
+ "√" => "sqrt",
506
+ "⊥" => "perp",
507
+ "‖" => "norm",
508
+ "→" => "rarr",
509
+ "←" => "larr",
510
+ "↔" => "harr",
511
+ "⇒" => "rArr",
512
+ "⇐" => "lArr",
513
+ "⇔" => "hArr",
514
+ }
515
+ end
516
+ end
517
+
518
+ # ValidateAscii command for checking EXPRESS files for non-ASCII characters
519
+ class ValidateAscii < Base
520
+ def run(express_file_path) # rubocop:disable Metrics/AbcSize
521
+ # Check if input is a manifest file
522
+ if File.file?(express_file_path) && File.extname(express_file_path) == ".yaml"
523
+ validate_from_manifest(express_file_path)
524
+ return
525
+ end
526
+
527
+ # Original file/directory validation logic
528
+ if File.file?(express_file_path)
529
+ unless File.exist?(express_file_path)
530
+ raise Errno::ENOENT, "Specified EXPRESS file " \
531
+ "`#{express_file_path}` not found."
532
+ end
533
+
534
+ if File.extname(express_file_path) != ".exp"
535
+ raise ArgumentError, "Specified file `#{express_file_path}` is " \
536
+ "not an EXPRESS file."
537
+ end
538
+
539
+ exp_files = [express_file_path]
540
+ elsif options[:recursive]
541
+ # Support the relative path with glob pattern
542
+ base_path = File.expand_path(express_file_path)
543
+ exp_files = Dir.glob("#{base_path}/**/*.exp")
544
+ else
545
+ # Non-recursive option
546
+ base_path = File.expand_path(express_file_path)
547
+ exp_files = Dir.glob("#{base_path}/*.exp")
548
+ end
549
+
550
+ if exp_files.empty?
551
+ raise Errno::ENOENT, "No EXPRESS files found in " \
552
+ "`#{express_file_path}`."
553
+ end
554
+
555
+ process_files(exp_files)
556
+ end
557
+
558
+ private
559
+
560
+ def validate_from_manifest(manifest_path)
561
+ unless File.exist?(manifest_path)
562
+ say "Error: Manifest file not found: #{manifest_path}", :red
563
+ exit 1
564
+ end
565
+
566
+ say "Loading manifest: #{manifest_path}..." if options[:verbose]
567
+
568
+ # Load manifest
569
+ manifest = Expressir::SchemaManifest.from_file(manifest_path)
570
+
571
+ # Extract paths from manifest
572
+ exp_files = manifest.schemas.map(&:path).reject do |p|
573
+ p.nil? || p.empty?
574
+ end
575
+
576
+ if exp_files.empty?
577
+ say "Error: No valid schema paths found in manifest", :red
578
+ exit 1
579
+ end
580
+
581
+ say "Validating #{exp_files.size} schema(s) from manifest for ASCII compliance..." if options[:verbose]
582
+
583
+ process_files(exp_files)
584
+ end
585
+
586
+ def process_files(exp_files)
587
+ # Process all files and collect violations
588
+ collection = NonAsciiViolationCollection.new(
589
+ check_remarks: options[:check_remarks],
590
+ )
591
+
592
+ exp_files.each do |exp_file|
593
+ collection.process_file(exp_file)
594
+ end
595
+
596
+ # Output results based on format
597
+ if options[:yaml]
598
+ require "yaml"
599
+ puts collection.to_yaml_data.to_yaml
600
+ else
601
+ collection.print_text_output
602
+ collection.print_table_output if collection.files_with_violations.positive?
603
+ end
604
+ end
605
+ end
606
+ end
607
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Commands
5
+ class ValidateLoad < Base
6
+ def run(paths)
7
+ # Determine if input is a manifest or direct paths
8
+ if paths.size == 1 && File.extname(paths.first) == ".yaml"
9
+ validate_from_manifest(paths.first)
10
+ else
11
+ validate_from_paths(paths)
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def validate_from_manifest(manifest_path)
18
+ unless File.exist?(manifest_path)
19
+ say "Error: Manifest file not found: #{manifest_path}", :red
20
+ exit 1
21
+ end
22
+
23
+ say "Loading manifest: #{manifest_path}..." if options[:verbose]
24
+
25
+ # Load manifest
26
+ manifest = Expressir::SchemaManifest.from_file(manifest_path)
27
+
28
+ # Extract paths from manifest
29
+ paths = manifest.schemas.map(&:path).reject { |p| p.nil? || p.empty? }
30
+
31
+ if paths.empty?
32
+ say "Error: No valid schema paths found in manifest", :red
33
+ exit 1
34
+ end
35
+
36
+ say "Validating #{paths.size} schema(s) from manifest..." if options[:verbose]
37
+
38
+ validate_from_paths(paths)
39
+ end
40
+
41
+ def validate_from_paths(paths)
42
+ no_version = []
43
+ no_valid = []
44
+
45
+ paths.each do |path|
46
+ x = Pathname.new(path).realpath.relative_path_from(Dir.pwd)
47
+ say "Validating #{x}"
48
+ ret = validate_schema(path)
49
+
50
+ if ret.nil?
51
+ no_valid << "Failed to parse: #{x}"
52
+ next
53
+ end
54
+
55
+ ret.each do |schema_id|
56
+ no_version << "Missing version string: schema `#{schema_id}` | #{x}"
57
+ end
58
+ end
59
+
60
+ print_validation_errors(:failed_to_parse, no_valid)
61
+ print_validation_errors(:missing_version_string, no_version)
62
+
63
+ exit 1 unless [no_valid, no_version].all?(&:empty?)
64
+
65
+ say "Validation passed for all EXPRESS schemas."
66
+ end
67
+
68
+ def validate_schema(path)
69
+ repository = Expressir::Express::Parser.from_file(path)
70
+ repository.schemas.inject([]) do |acc, schema|
71
+ acc << schema.id unless schema.version&.value
72
+ acc
73
+ end
74
+ rescue StandardError
75
+ nil
76
+ end
77
+
78
+ def print_validation_errors(type, array)
79
+ return if array.empty?
80
+
81
+ say "#{'*' * 20} RESULTS: #{type.to_s.upcase.tr('_', ' ')} #{'*' * 20}"
82
+ array.each do |msg|
83
+ say msg
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end