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.
- checksums.yaml +7 -0
- data/CLAUDE.md +486 -0
- data/README.md +304 -0
- data/bin/lint-no-raw-mdc +41 -0
- data/lib/cdd.rb +11 -0
- data/lib/opencdd/alias_table.rb +52 -0
- data/lib/opencdd/cddal/ast.rb +151 -0
- data/lib/opencdd/cddal/builder.rb +374 -0
- data/lib/opencdd/cddal/fetcher/in_memory.rb +32 -0
- data/lib/opencdd/cddal/fetcher/net_http.rb +84 -0
- data/lib/opencdd/cddal/fetcher.rb +18 -0
- data/lib/opencdd/cddal/generated_parser.rb +805 -0
- data/lib/opencdd/cddal/lexer.rb +193 -0
- data/lib/opencdd/cddal/parser.rb +19 -0
- data/lib/opencdd/cddal/resolver.rb +100 -0
- data/lib/opencdd/cddal/serializer.rb +210 -0
- data/lib/opencdd/cddal/value_serializer.rb +60 -0
- data/lib/opencdd/cddal.rb +63 -0
- data/lib/opencdd/class_tree.rb +80 -0
- data/lib/opencdd/class_type.rb +33 -0
- data/lib/opencdd/codegen/ts.rb +185 -0
- data/lib/opencdd/codegen.rb +7 -0
- data/lib/opencdd/composition_tree.rb +119 -0
- data/lib/opencdd/condition.rb +120 -0
- data/lib/opencdd/data_type.rb +143 -0
- data/lib/opencdd/database.rb +719 -0
- data/lib/opencdd/effective_properties.rb +119 -0
- data/lib/opencdd/entity/field_reader.rb +141 -0
- data/lib/opencdd/entity/field_registry.rb +99 -0
- data/lib/opencdd/entity/version_history.rb +62 -0
- data/lib/opencdd/entity.rb +255 -0
- data/lib/opencdd/exporters/json.rb +235 -0
- data/lib/opencdd/exporters/mermaid.rb +62 -0
- data/lib/opencdd/exporters/yaml.rb +16 -0
- data/lib/opencdd/exporters.rb +9 -0
- data/lib/opencdd/guid.rb +27 -0
- data/lib/opencdd/instance_rule.rb +70 -0
- data/lib/opencdd/irdi.rb +128 -0
- data/lib/opencdd/klass.rb +230 -0
- data/lib/opencdd/languages.rb +70 -0
- data/lib/opencdd/meta_class.rb +274 -0
- data/lib/opencdd/model/entity_store.rb +85 -0
- data/lib/opencdd/model/yaml_database.rb +49 -0
- data/lib/opencdd/model/yaml_entity.rb +196 -0
- data/lib/opencdd/model.rb +13 -0
- data/lib/opencdd/parcel/csv_reader.rb +35 -0
- data/lib/opencdd/parcel/csv_writer.rb +114 -0
- data/lib/opencdd/parcel/entity_manifest.rb +119 -0
- data/lib/opencdd/parcel/flat_dir_reader.rb +134 -0
- data/lib/opencdd/parcel/layout_detector.rb +115 -0
- data/lib/opencdd/parcel/metadata.rb +112 -0
- data/lib/opencdd/parcel/referenced_irdis.rb +91 -0
- data/lib/opencdd/parcel/scrape_verifier.rb +122 -0
- data/lib/opencdd/parcel/selector.rb +115 -0
- data/lib/opencdd/parcel/sharded_dir_reader.rb +151 -0
- data/lib/opencdd/parcel/sheet.rb +287 -0
- data/lib/opencdd/parcel/sheet_emitter.rb +171 -0
- data/lib/opencdd/parcel/sheet_schema.rb +253 -0
- data/lib/opencdd/parcel/workbook.rb +172 -0
- data/lib/opencdd/parcel/workbook_reader.rb +200 -0
- data/lib/opencdd/parcel/writer.rb +185 -0
- data/lib/opencdd/parcel.rb +175 -0
- data/lib/opencdd/parse_helpers.rb +73 -0
- data/lib/opencdd/property.rb +120 -0
- data/lib/opencdd/property_data_element_type.rb +44 -0
- data/lib/opencdd/property_ids.rb +202 -0
- data/lib/opencdd/reader.rb +103 -0
- data/lib/opencdd/relation.rb +88 -0
- data/lib/opencdd/relation_tree.rb +74 -0
- data/lib/opencdd/relation_type.rb +58 -0
- data/lib/opencdd/structured_values.rb +183 -0
- data/lib/opencdd/unit.rb +16 -0
- data/lib/opencdd/validator/class_reference_rule.rb +77 -0
- data/lib/opencdd/validator/condition_rule.rb +27 -0
- data/lib/opencdd/validator/data_type_rule.rb +26 -0
- data/lib/opencdd/validator/enum_rule.rb +27 -0
- data/lib/opencdd/validator/format_rule.rb +54 -0
- data/lib/opencdd/validator/hierarchy_rule.rb +65 -0
- data/lib/opencdd/validator/irdi_rule.rb +57 -0
- data/lib/opencdd/validator/mandatory_rule.rb +25 -0
- data/lib/opencdd/validator/pattern_rule.rb +26 -0
- data/lib/opencdd/validator/reference_rule.rb +36 -0
- data/lib/opencdd/validator/rule.rb +65 -0
- data/lib/opencdd/validator/runner.rb +101 -0
- data/lib/opencdd/validator/set_rule.rb +41 -0
- data/lib/opencdd/validator/synonym_rule.rb +30 -0
- data/lib/opencdd/validator/type_rule.rb +102 -0
- data/lib/opencdd/validator/uniqueness_rule.rb +32 -0
- data/lib/opencdd/validator.rb +68 -0
- data/lib/opencdd/value_format.rb +71 -0
- data/lib/opencdd/value_list.rb +42 -0
- data/lib/opencdd/value_term.rb +24 -0
- data/lib/opencdd/version.rb +5 -0
- data/lib/opencdd/view_control.rb +10 -0
- data/lib/opencdd/visitor.rb +93 -0
- data/lib/opencdd.rb +57 -0
- 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
|