opencdd 0.1.1

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 (97) hide show
  1. checksums.yaml +7 -0
  2. data/CLAUDE.md +486 -0
  3. data/README.md +304 -0
  4. data/bin/lint-no-raw-mdc +41 -0
  5. data/lib/cdd.rb +11 -0
  6. data/lib/opencdd/alias_table.rb +52 -0
  7. data/lib/opencdd/cddal/ast.rb +151 -0
  8. data/lib/opencdd/cddal/builder.rb +374 -0
  9. data/lib/opencdd/cddal/fetcher/in_memory.rb +32 -0
  10. data/lib/opencdd/cddal/fetcher/net_http.rb +84 -0
  11. data/lib/opencdd/cddal/fetcher.rb +18 -0
  12. data/lib/opencdd/cddal/generated_parser.rb +805 -0
  13. data/lib/opencdd/cddal/lexer.rb +193 -0
  14. data/lib/opencdd/cddal/parser.rb +19 -0
  15. data/lib/opencdd/cddal/resolver.rb +100 -0
  16. data/lib/opencdd/cddal/serializer.rb +210 -0
  17. data/lib/opencdd/cddal/value_serializer.rb +60 -0
  18. data/lib/opencdd/cddal.rb +63 -0
  19. data/lib/opencdd/class_tree.rb +80 -0
  20. data/lib/opencdd/class_type.rb +33 -0
  21. data/lib/opencdd/codegen/ts.rb +185 -0
  22. data/lib/opencdd/codegen.rb +7 -0
  23. data/lib/opencdd/composition_tree.rb +119 -0
  24. data/lib/opencdd/condition.rb +120 -0
  25. data/lib/opencdd/data_type.rb +143 -0
  26. data/lib/opencdd/database.rb +719 -0
  27. data/lib/opencdd/effective_properties.rb +119 -0
  28. data/lib/opencdd/entity/field_reader.rb +141 -0
  29. data/lib/opencdd/entity/field_registry.rb +99 -0
  30. data/lib/opencdd/entity/version_history.rb +62 -0
  31. data/lib/opencdd/entity.rb +255 -0
  32. data/lib/opencdd/exporters/json.rb +235 -0
  33. data/lib/opencdd/exporters/mermaid.rb +62 -0
  34. data/lib/opencdd/exporters/yaml.rb +16 -0
  35. data/lib/opencdd/exporters.rb +9 -0
  36. data/lib/opencdd/guid.rb +27 -0
  37. data/lib/opencdd/instance_rule.rb +70 -0
  38. data/lib/opencdd/irdi.rb +128 -0
  39. data/lib/opencdd/klass.rb +230 -0
  40. data/lib/opencdd/languages.rb +70 -0
  41. data/lib/opencdd/meta_class.rb +274 -0
  42. data/lib/opencdd/model/entity_store.rb +85 -0
  43. data/lib/opencdd/model/yaml_database.rb +49 -0
  44. data/lib/opencdd/model/yaml_entity.rb +196 -0
  45. data/lib/opencdd/model.rb +13 -0
  46. data/lib/opencdd/parcel/csv_reader.rb +35 -0
  47. data/lib/opencdd/parcel/csv_writer.rb +114 -0
  48. data/lib/opencdd/parcel/entity_manifest.rb +119 -0
  49. data/lib/opencdd/parcel/flat_dir_reader.rb +134 -0
  50. data/lib/opencdd/parcel/layout_detector.rb +115 -0
  51. data/lib/opencdd/parcel/metadata.rb +112 -0
  52. data/lib/opencdd/parcel/referenced_irdis.rb +91 -0
  53. data/lib/opencdd/parcel/scrape_verifier.rb +122 -0
  54. data/lib/opencdd/parcel/selector.rb +115 -0
  55. data/lib/opencdd/parcel/sharded_dir_reader.rb +151 -0
  56. data/lib/opencdd/parcel/sheet.rb +287 -0
  57. data/lib/opencdd/parcel/sheet_emitter.rb +171 -0
  58. data/lib/opencdd/parcel/sheet_schema.rb +253 -0
  59. data/lib/opencdd/parcel/workbook.rb +172 -0
  60. data/lib/opencdd/parcel/workbook_reader.rb +200 -0
  61. data/lib/opencdd/parcel/writer.rb +185 -0
  62. data/lib/opencdd/parcel.rb +175 -0
  63. data/lib/opencdd/parse_helpers.rb +73 -0
  64. data/lib/opencdd/property.rb +120 -0
  65. data/lib/opencdd/property_data_element_type.rb +44 -0
  66. data/lib/opencdd/property_ids.rb +202 -0
  67. data/lib/opencdd/reader.rb +103 -0
  68. data/lib/opencdd/relation.rb +88 -0
  69. data/lib/opencdd/relation_tree.rb +74 -0
  70. data/lib/opencdd/relation_type.rb +58 -0
  71. data/lib/opencdd/structured_values.rb +183 -0
  72. data/lib/opencdd/unit.rb +16 -0
  73. data/lib/opencdd/validator/class_reference_rule.rb +77 -0
  74. data/lib/opencdd/validator/condition_rule.rb +27 -0
  75. data/lib/opencdd/validator/data_type_rule.rb +26 -0
  76. data/lib/opencdd/validator/enum_rule.rb +27 -0
  77. data/lib/opencdd/validator/format_rule.rb +54 -0
  78. data/lib/opencdd/validator/hierarchy_rule.rb +65 -0
  79. data/lib/opencdd/validator/irdi_rule.rb +57 -0
  80. data/lib/opencdd/validator/mandatory_rule.rb +25 -0
  81. data/lib/opencdd/validator/pattern_rule.rb +26 -0
  82. data/lib/opencdd/validator/reference_rule.rb +36 -0
  83. data/lib/opencdd/validator/rule.rb +65 -0
  84. data/lib/opencdd/validator/runner.rb +101 -0
  85. data/lib/opencdd/validator/set_rule.rb +41 -0
  86. data/lib/opencdd/validator/synonym_rule.rb +30 -0
  87. data/lib/opencdd/validator/type_rule.rb +102 -0
  88. data/lib/opencdd/validator/uniqueness_rule.rb +32 -0
  89. data/lib/opencdd/validator.rb +68 -0
  90. data/lib/opencdd/value_format.rb +71 -0
  91. data/lib/opencdd/value_list.rb +42 -0
  92. data/lib/opencdd/value_term.rb +24 -0
  93. data/lib/opencdd/version.rb +5 -0
  94. data/lib/opencdd/view_control.rb +10 -0
  95. data/lib/opencdd/visitor.rb +93 -0
  96. data/lib/opencdd.rb +57 -0
  97. metadata +325 -0
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Opencdd
6
+ module Cddal
7
+ Token = Struct.new(:kind, :value, :line, :column, keyword_init: true) do
8
+ def inspect
9
+ "#{kind}#{value && !value.empty? ? "(#{value.inspect})" : ""}@#{line}:#{column}"
10
+ end
11
+ end
12
+
13
+ class Lexer
14
+ PUNCTUATION = {
15
+ "{" => :LBRACE, "}" => :RBRACE,
16
+ ":" => :COLON, "," => :COMMA,
17
+ "<" => :LANGLE, ">" => :RANGLE,
18
+ "." => :DOT,
19
+ "(" => :LPAREN, ")" => :RPAREN,
20
+ }.freeze
21
+
22
+ KEYWORD_MAP = {
23
+ "instance" => :INSTANCE,
24
+ "alias" => :ALIAS,
25
+ "import" => :IMPORT,
26
+ "true" => :TRUE,
27
+ "false" => :FALSE,
28
+ "null" => :NULL,
29
+ }.freeze
30
+
31
+ # Soft keywords. These are reserved only inside import
32
+ # declarations; elsewhere they're valid identifiers (e.g.
33
+ # `short_name.en: as` for attosecond). The lexer emits IDENT
34
+ # and the parser validates the value in import position.
35
+ SOFT_KEYWORDS = %w[as from].freeze
36
+
37
+ IRDI_RE = /[0-9]+\/[0-9]+\/\/\/[A-Za-z0-9_]+(?:_[0-9]+)?#[A-Za-z0-9_]+/.freeze
38
+ LOCAL_REF_RE = /[A-Za-z_][A-Za-z0-9_]*#[A-Za-z0-9_]+(?:##[A-Za-z0-9_]+)?/.freeze
39
+ DATE_RE = /\d{4}-\d{2}-\d{2}/.freeze
40
+ NUMBER_RE = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/.freeze
41
+ IDENT_RE = /[A-Za-z_][A-Za-z0-9_]*/.freeze
42
+ META_CLASS_RE = /(?:property-meta-class|enumeration-meta-class|term-meta-class|meta-class)(?![A-Za-z0-9_-])/.freeze
43
+ WS_RE = /[ \t\r]+/.freeze
44
+ NEWLINE_RE = /\n/.freeze
45
+ COMMENT_RE = /#[^\n]*/.freeze
46
+
47
+ attr_reader :source
48
+
49
+ def initialize(source)
50
+ @source = source.to_s
51
+ @ss = StringScanner.new(@source)
52
+ @line = 1
53
+ @col = 1
54
+ end
55
+
56
+ def tokens
57
+ return @tokens if @tokens
58
+ @tokens = []
59
+ until @ss.eos?
60
+ skip_interstitial
61
+ break if @ss.eos?
62
+ tok = scan_token
63
+ raise LexError, "unexpected character #{@ss.peek(1).inspect} at line #{@line}, column #{@col}" unless tok
64
+ @tokens << tok
65
+ end
66
+ @tokens << Token.new(kind: :EOF, value: "", line: @line, column: @col)
67
+ @tokens
68
+ end
69
+
70
+ def next_token
71
+ tok = tokens[@pos || 0]
72
+ @pos = (@pos || 0) + 1
73
+ tok ? [tok.kind, tok.value] : [false, false]
74
+ end
75
+
76
+ private
77
+
78
+ def skip_interstitial
79
+ loop do
80
+ if ws = @ss.scan(WS_RE)
81
+ @col += ws.length
82
+ elsif @ss.scan(NEWLINE_RE)
83
+ @line += 1
84
+ @col = 1
85
+ elsif comment = @ss.scan(COMMENT_RE)
86
+ @col += comment.length
87
+ else
88
+ break
89
+ end
90
+ end
91
+ end
92
+
93
+ def scan_token
94
+ line = @line
95
+ col = @col
96
+
97
+ if @ss.peek(1) == '"'
98
+ value = scan_string_literal
99
+ return Token.new(kind: :STRING, value: value, line: line, column: col)
100
+ end
101
+
102
+ if op = @ss.scan(/==|!=/)
103
+ @col += op.length
104
+ return Token.new(kind: op == "==" ? :EQEQ : :NEQ, value: op, line: line, column: col)
105
+ end
106
+
107
+ char = @ss.peek(1)
108
+ if PUNCTUATION.key?(char)
109
+ @ss.getch
110
+ @col += 1
111
+ return Token.new(kind: PUNCTUATION[char], value: char, line: line, column: col)
112
+ end
113
+
114
+ if mc = @ss.scan(META_CLASS_RE)
115
+ @col += mc.length
116
+ return Token.new(kind: :META_CLASS, value: mc, line: line, column: col)
117
+ end
118
+
119
+ if irdi = @ss.scan(IRDI_RE)
120
+ @col += irdi.length
121
+ return Token.new(kind: :IRDI, value: irdi, line: line, column: col)
122
+ end
123
+
124
+ if local = @ss.scan(LOCAL_REF_RE)
125
+ @col += local.length
126
+ return Token.new(kind: :IDENT, value: local, line: line, column: col)
127
+ end
128
+
129
+ if date = @ss.scan(DATE_RE)
130
+ unless @ss.peek(1) =~ /[A-Za-z_]/
131
+ @col += date.length
132
+ return Token.new(kind: :DATE, value: date, line: line, column: col)
133
+ end
134
+ @ss.pos -= date.length
135
+ end
136
+
137
+ if num = @ss.scan(NUMBER_RE)
138
+ @col += num.length
139
+ return Token.new(kind: :NUMBER, value: num, line: line, column: col)
140
+ end
141
+
142
+ if ident = @ss.scan(IDENT_RE)
143
+ @col += ident.length
144
+ kind = KEYWORD_MAP[ident] || :IDENT
145
+ return Token.new(kind: kind, value: ident, line: line, column: col)
146
+ end
147
+
148
+ nil
149
+ end
150
+
151
+ def scan_string_literal
152
+ @ss.getch
153
+ buffer = +""
154
+ until @ss.eos?
155
+ ch = @ss.getch
156
+ case ch
157
+ when '"'
158
+ @col += buffer.length + 2
159
+ return buffer
160
+ when "\\"
161
+ esc = @ss.getch
162
+ raise LexError, "unterminated escape at line #{@line}" unless esc
163
+ buffer << decode_escape(esc)
164
+ when "\n"
165
+ raise LexError, "unterminated string at line #{@line}"
166
+ else
167
+ buffer << ch
168
+ end
169
+ end
170
+ raise LexError, "unterminated string at line #{@line}"
171
+ end
172
+
173
+ def decode_escape(esc)
174
+ case esc
175
+ when '"' then '"'
176
+ when "\\" then "\\"
177
+ when "/" then "/"
178
+ when "b" then "\b"
179
+ when "f" then "\f"
180
+ when "n" then "\n"
181
+ when "r" then "\r"
182
+ when "t" then "\t"
183
+ when "u"
184
+ hex = @ss.scan(/[0-9a-fA-F]{4}/)
185
+ raise LexError, "invalid unicode escape at line #{@line}" unless hex
186
+ [hex.to_i(16)].pack("U")
187
+ else
188
+ raise LexError, "invalid escape \\#{esc} at line #{@line}"
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ module Cddal
5
+ class Parser
6
+ def self.parse(source)
7
+ new(Lexer.new(source).tokens).parse
8
+ end
9
+
10
+ def initialize(tokens)
11
+ @tokens = tokens
12
+ end
13
+
14
+ def parse
15
+ GeneratedParser.new(@tokens).parse
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Opencdd
6
+ module Cddal
7
+ # Resolves a CDDAL module specifier to a canonical key and source
8
+ # text. The canonical key (absolute path or normalized URL) is
9
+ # used by the Builder to deduplicate imports — the same module
10
+ # imported twice loads only once.
11
+ #
12
+ # Resolution rules, in priority order:
13
+ #
14
+ # 1. URL (http://, https://, file://) — fetched via the
15
+ # configured Fetcher. URL is the canonical key.
16
+ # 2. Absolute filesystem path — read directly. Expanded path
17
+ # is the canonical key.
18
+ # 3. Relative path (starts with ./ or ../) — resolved against
19
+ # the importing file's directory, falling back to base_path.
20
+ # 4. Bare name — searched in search_path entries, then
21
+ # base_path. First match wins.
22
+ #
23
+ # When +strict:+ is false (default), URL fetch failures and
24
+ # missing paths emit a warning and return +[nil, nil]+ so the
25
+ # Builder can skip them. When +strict:+ is true, raises
26
+ # +Opencdd::Cddal::ImportError+ on any resolution failure.
27
+ class Resolver
28
+ DEFAULT_SEARCH_PATH = [Pathname.pwd].freeze
29
+
30
+ # When +strict:+ is false (default), URL fetch failures and
31
+ # missing paths emit a warning and return +[nil, nil]+ so the
32
+ # Builder can skip them. When +strict:+ is true, raises
33
+ # +Opencdd::Cddal::ImportError+ on any resolution failure.
34
+ # Pass +quiet: true+ to suppress the non-strict warning
35
+ # (used by tests that intentionally exercise unreachable URLs).
36
+ attr_reader :base_path, :search_path, :fetcher, :strict, :quiet
37
+
38
+ def initialize(base_path: nil, search_path: nil, fetcher: nil,
39
+ strict: false, quiet: false)
40
+ @base_path = base_path ? Pathname.new(base_path) : Pathname.pwd
41
+ @search_path = Array(search_path).map { |p| Pathname.new(p.to_s) }
42
+ @fetcher = fetcher || Opencdd::Cddal::Fetcher::NetHttp.new
43
+ @strict = strict
44
+ @quiet = quiet
45
+ end
46
+
47
+ # Returns a tuple +[canonical_key, source_text]+. Returns
48
+ # +[nil, nil]+ in non-strict mode when resolution fails.
49
+ def resolve(specifier, importing_file: nil)
50
+ specifier = specifier.to_s
51
+ return resolve_url(specifier) if url?(specifier)
52
+
53
+ resolve_path(specifier, importing_file)
54
+ rescue Opencdd::Cddal::ImportError => e
55
+ raise if @strict
56
+ warn "CDDAL: #{e.message}" unless @quiet
57
+ [nil, nil]
58
+ end
59
+
60
+ private
61
+
62
+ def url?(s)
63
+ s.start_with?("http://", "https://", "file://")
64
+ end
65
+
66
+ def resolve_url(url)
67
+ text = @fetcher.fetch(url)
68
+ [url, text]
69
+ end
70
+
71
+ def resolve_path(spec, importing_file)
72
+ p = Pathname.new(spec)
73
+ candidates = path_candidates(p, importing_file)
74
+ candidates.uniq.each do |cand|
75
+ return [cand.expand_path.to_s, cand.read] if cand.exist? && cand.file?
76
+ end
77
+ raise Opencdd::Cddal::ImportError,
78
+ "cannot resolve import #{spec.inspect} " \
79
+ "(searched: #{candidates.map(&:to_s).join(', ')})"
80
+ end
81
+
82
+ def path_candidates(p, importing_file)
83
+ if p.absolute?
84
+ [p]
85
+ elsif spec_relative?(p)
86
+ base = importing_file ? Pathname.new(importing_file).dirname : @base_path
87
+ [base.join(p)]
88
+ else
89
+ # Bare name: walk search path, then base.
90
+ @search_path.map { |sp| sp.join(p) } + [@base_path.join(p)]
91
+ end
92
+ end
93
+
94
+ def spec_relative?(p)
95
+ s = p.to_s
96
+ s.start_with?("./", "../")
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ module Cddal
5
+ class Serializer
6
+ # Matches exactly the values the CDDAL lexer can tokenize as a
7
+ # single non-string token: keywords, numbers, dates, IRDIs,
8
+ # local refs, and bare identifiers. Anything not matching must
9
+ # be emitted as a quoted string literal so the lexer can reparse
10
+ # it via scan_string_literal (which is UTF-8 aware via getch).
11
+ UNQUOTED_VALUE_RE = /\A(?:
12
+ true|false|null|
13
+ -?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|
14
+ \d{4}-\d{2}-\d{2}|
15
+ [0-9]+\/[0-9]+\/\/\/[A-Za-z0-9_]+(?:_[0-9]+)?\#[A-Za-z0-9_]+|
16
+ [A-Za-z_][A-Za-z0-9_]*\#[A-Za-z0-9_]+(?:\#\#[A-Za-z0-9_]+)?|
17
+ [A-Za-z_][A-Za-z0-9_]*
18
+ )\z/x.freeze
19
+
20
+ HEADER = <<~CDDAL
21
+ # Generated by Opencdd::Cddal::Serializer
22
+ CDDAL
23
+
24
+ attr_reader :database
25
+
26
+ def initialize(database)
27
+ @database = database
28
+ end
29
+
30
+ def to_cddal
31
+ lines = [HEADER.chomp]
32
+ emit_default_aliases(lines)
33
+ emit_value_lists(lines)
34
+ emit_value_terms(lines)
35
+ emit_units(lines)
36
+ emit_class_tree(lines)
37
+ emit_rooted_properties(lines)
38
+ emit_relations(lines)
39
+ emit_view_controls(lines)
40
+ lines.join("\n") + "\n"
41
+ end
42
+
43
+ private
44
+
45
+ def emit_default_aliases(lines)
46
+ lines << ""
47
+ Opencdd::PropertyIds::REGISTRY.each_value do |entry|
48
+ entry.aliases.each do |alias_name|
49
+ lines << "alias #{alias_name}: #{entry.id}"
50
+ end
51
+ end
52
+ end
53
+
54
+ def emit_value_lists(lines)
55
+ lists = @database.value_lists.sort_by { |vl| vl.code.to_s }
56
+ return if lists.empty?
57
+ lines << ""
58
+ lists.each do |vl|
59
+ lines.concat(emit_instance(vl))
60
+ end
61
+ end
62
+
63
+ def emit_value_terms(lines)
64
+ terms = @database.value_terms.sort_by { |vt| vt.code.to_s }
65
+ return if terms.empty?
66
+ lines << ""
67
+ terms.each do |vt|
68
+ lines.concat(emit_instance(vt))
69
+ end
70
+ end
71
+
72
+ def emit_units(lines)
73
+ units = @database.units.sort_by { |u| u.code.to_s }
74
+ return if units.empty?
75
+ lines << ""
76
+ units.each do |u|
77
+ lines.concat(emit_instance(u))
78
+ end
79
+ end
80
+
81
+ def emit_class_tree(lines)
82
+ roots = @database.root_classes.sort_by { |k| k.code.to_s }
83
+ return if roots.empty?
84
+ lines << ""
85
+ roots.each do |root|
86
+ walk_class(root, lines, 0)
87
+ end
88
+ end
89
+
90
+ def walk_class(klass, lines, depth)
91
+ lines.concat(emit_instance(klass))
92
+ sorted_children = klass.children.sort_by { |c| c.code.to_s }
93
+ sorted_children.each { |c| walk_class(c, lines, depth + 1) }
94
+ end
95
+
96
+ def emit_rooted_properties(lines)
97
+ emitted = Set.new
98
+ @database.classes.sort_by { |k| k.code.to_s }.each do |klass|
99
+ props = @database.properties_of(klass).sort_by { |p| p.code.to_s }
100
+ props.each do |prop|
101
+ next if emitted.include?(prop.irdi)
102
+ emitted << prop.irdi
103
+ lines.concat(emit_instance(prop))
104
+ end
105
+ end
106
+ unrooted = @database.properties.reject { |p| emitted.include?(p.irdi) }.sort_by { |p| p.code.to_s }
107
+ unrooted.each { |p| lines.concat(emit_instance(p)) }
108
+ end
109
+
110
+ def emit_relations(lines)
111
+ rels = @database.relations.sort_by { |r| r.code.to_s }
112
+ return if rels.empty?
113
+ lines << ""
114
+ rels.each { |r| lines.concat(emit_instance(r)) }
115
+ end
116
+
117
+ def emit_view_controls(lines)
118
+ vcs = @database.view_controls.sort_by { |v| v.code.to_s }
119
+ return if vcs.empty?
120
+ lines << ""
121
+ vcs.each { |v| lines.concat(emit_instance(v)) }
122
+ end
123
+
124
+ def emit_instance(entity)
125
+ name = symbol_name_for(entity)
126
+ meta = meta_class_for(entity)
127
+ out = []
128
+ out << ""
129
+ out << "instance #{name} < #{meta} {"
130
+ emit_properties(entity).each { |line| out << " #{line}" }
131
+ out << "}"
132
+ out
133
+ end
134
+
135
+ def emit_properties(entity)
136
+ out = []
137
+ entity.properties.each do |key, value|
138
+ next if value.nil? || value.to_s.empty?
139
+ name = key.to_s
140
+ base = name.sub(/\.\w+\z/, "")
141
+ lang_match = name.match(/\.(?<lang>[a-zA-Z]{2,3}(?:-[A-Za-z0-9]+)?)\z/)
142
+ lang = lang_match && lang_match[:lang]
143
+ alias_name = alias_for_property_id(base)
144
+ display_key = lang ? "#{alias_name}.#{lang}" : alias_name
145
+ rhs = format_value(value)
146
+ out << "#{display_key}: #{rhs}"
147
+ end
148
+ out
149
+ end
150
+
151
+ def alias_for_property_id(property_id)
152
+ name = property_id.to_s
153
+ base = name.sub(/\.\w+\z/, "")
154
+ Opencdd::PropertyIds.entry(base)&.aliases&.first || name
155
+ end
156
+
157
+ def language_suffix(property_id)
158
+ m = property_id.to_s.match(/\.(?<lang>[a-zA-Z]{2,3}(-[A-Za-z0-9]+)?)\z/)
159
+ m && m[:lang]
160
+ end
161
+
162
+ def format_value(value)
163
+ s = value.to_s
164
+ return quote_string(s) if string_literal?(s)
165
+ return format_set(s) if set_like?(s)
166
+ s
167
+ end
168
+
169
+ def string_literal?(s)
170
+ !s.match?(UNQUOTED_VALUE_RE)
171
+ end
172
+
173
+ def quote_string(s)
174
+ escaped = s
175
+ .gsub("\\") { "\\\\" }
176
+ .gsub('"') { "\\\"" }
177
+ .gsub("\n") { "\\n" }
178
+ .gsub("\r") { "\\r" }
179
+ .gsub("\t") { "\\t" }
180
+ .gsub("\b") { "\\b" }
181
+ .gsub("\f") { "\\f" }
182
+ "\"#{escaped}\""
183
+ end
184
+
185
+ def set_like?(s)
186
+ s.start_with?("(") && s.end_with?(")")
187
+ end
188
+
189
+ def format_set(s)
190
+ elements = Opencdd::StructuredValues.unwrap_and_split(s)
191
+ "{ #{elements.join(', ')} }"
192
+ end
193
+
194
+ def symbol_name_for(entity)
195
+ code = entity.code&.to_s
196
+ return code if code && !code.empty?
197
+ preferred = entity.preferred_name&.to_s
198
+ return "instance" unless preferred
199
+ safe = preferred.gsub(/[^A-Za-z0-9_]/, "_").gsub(/\A(\d)/, "_\\1")
200
+ safe.empty? ? "instance" : safe
201
+ end
202
+
203
+ def meta_class_for(entity)
204
+ type = entity.type
205
+ code = Opencdd::MetaClasses.meta_class_for_type(type) if type
206
+ code || entity.meta_class_irdi&.code || Opencdd::MetaClasses::MDC_C002
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ module Cddal
5
+ # Pure-function value serializer: converts AST value nodes into
6
+ # the CDDAL/Parcel wire string form. Extracted from Builder
7
+ # (TODO.impl/28) so the builder's property-assembly pipeline
8
+ # is a consumer of this module rather than owning the logic.
9
+ #
10
+ # Stateless — safe to call from any context. The Builder mixes
11
+ # this in for backward compatibility.
12
+ module ValueSerializer
13
+ module_function
14
+
15
+ def serialize(value, property_id = nil)
16
+ case value
17
+ when AST::Literal
18
+ value.raw
19
+ when AST::IdentifierRef
20
+ value.to_s
21
+ when AST::Set
22
+ elements = value.elements.map { |e| serialize_set_element(e) }
23
+ "{#{elements.join(',')}}"
24
+ when AST::Tuple
25
+ elements = value.elements.map { |e| serialize_tuple_element(e) }
26
+ "(#{elements.join(',')})"
27
+ when AST::ClassReference
28
+ argument = case value.argument
29
+ when AST::IdentifierRef then value.argument.name
30
+ else value.argument.to_s
31
+ end
32
+ "#{value.type_name}(#{argument})"
33
+ when AST::Condition
34
+ rhs = value.right.to_cddal
35
+ "#{value.left} #{value.operator} #{rhs}"
36
+ else
37
+ value.to_s
38
+ end
39
+ end
40
+
41
+ def serialize_set_element(element)
42
+ case element
43
+ when AST::IdentifierRef then element.to_s
44
+ when AST::Literal then element.raw
45
+ when AST::Tuple then serialize(element, nil)
46
+ when AST::Set then serialize(element, nil)
47
+ else element.to_s
48
+ end
49
+ end
50
+
51
+ def serialize_tuple_element(element)
52
+ case element
53
+ when AST::IdentifierRef then element.to_s
54
+ when AST::Literal then element.raw
55
+ else element.to_s
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ module Cddal
5
+ autoload :Lexer, "opencdd/cddal/lexer"
6
+ autoload :AST, "opencdd/cddal/ast"
7
+ autoload :Parser, "opencdd/cddal/parser"
8
+ autoload :GeneratedParser, "opencdd/cddal/generated_parser"
9
+ autoload :Builder, "opencdd/cddal/builder"
10
+ autoload :Serializer, "opencdd/cddal/serializer"
11
+ autoload :Resolver, "opencdd/cddal/resolver"
12
+ autoload :Fetcher, "opencdd/cddal/fetcher"
13
+ autoload :ValueSerializer, "opencdd/cddal/value_serializer"
14
+
15
+ class Error < StandardError; end
16
+ class LexError < Error; end
17
+ class ParseError < Error; end
18
+ class ResolutionError < Error; end
19
+ class ImportError < Error; end
20
+
21
+ @default_resolver = nil
22
+
23
+ module_function
24
+
25
+ def default_resolver
26
+ @default_resolver ||= Resolver.new
27
+ end
28
+
29
+ # Parse +source+ (a CDDAL string) into a +Opencdd::Database+.
30
+ #
31
+ # Options:
32
+ # database: - existing Database to merge into (default: new)
33
+ # resolver: - Opencdd::Cddal::Resolver for module imports
34
+ # (default: a fresh Resolver with default fetcher)
35
+ # fetcher: - shortcut; if supplied, wraps in a new Resolver
36
+ # source_file: - path/URL the source came from. Used for
37
+ # diagnostics and for resolving relative imports.
38
+ def parse(source, database: nil, resolver: nil, fetcher: nil,
39
+ source_file: nil)
40
+ tokens = Lexer.new(source.to_s).tokens
41
+ declarations = Parser.new(tokens).parse
42
+ effective_resolver =
43
+ resolver || (fetcher ? Resolver.new(fetcher: fetcher) : default_resolver)
44
+ Builder.new(database, resolver: effective_resolver, source_file: source_file)
45
+ .build(declarations)
46
+ end
47
+
48
+ def parse_file(path, database: nil, resolver: nil, fetcher: nil)
49
+ source = File.read(path)
50
+ parse(source, database: database,
51
+ resolver: resolver, fetcher: fetcher, source_file: path)
52
+ end
53
+
54
+ def serialize(database)
55
+ Serializer.new(database).to_cddal
56
+ end
57
+
58
+ def serialize_to_file(database, path)
59
+ File.write(path, serialize(database))
60
+ self
61
+ end
62
+ end
63
+ end