Module: Ea::Ocl::Parser

Defined in:
lib/ea/ocl/parser.rb

Overview

Tokenizer + parser for an OCL invariant subset. Returns an AST built from Nodes::* structs.

Supported forms:

inv: <expr>
self.<attr>
"<literal>"
<collection>->exists(x | <pred>)
<collection>->forAll(x | <pred>)
<string>.matches('<regex>')
<expr> and <expr>
<expr> or <expr>
not <expr>

Class Method Summary collapse

Class Method Details

.collection_node(kind, collection_src, var, pred_src) ⇒ Object



109
110
111
112
113
114
# File 'lib/ea/ocl/parser.rb', line 109

def collection_node(kind, collection_src, var, pred_src)
  Nodes.const_get(kind == :exists ? :CollectionExists : :CollectionForAll)
       .new(collection: parse_expression(collection_src),
            var: var,
            predicate: parse_expression(pred_src))
end

.find_top_level(source, substring) ⇒ Object

Find substring at depth 0 (not inside parens).



172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/ea/ocl/parser.rb', line 172

def find_top_level(source, substring)
  depth = 0
  i = 0
  while i < source.length
    c = source[i]
    depth += 1 if c == "("
    depth -= 1 if c == ")"
    return i if depth.zero? && source[i, substring.length] == substring

    i += 1
  end
  nil
end

.find_top_level_op(source, op) ⇒ Object

rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/ea/ocl/parser.rb', line 147

def find_top_level_op(source, op) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
  depth = 0
  i = 0
  while i < source.length
    c = source[i]
    depth += 1 if c == "("
    depth -= 1 if c == ")"
    if depth.zero? && source[i, op.length] == op
      # Each `next` clause below MUST fall through to `i += 1`
      # at the loop's tail — using `next` here would skip the
      # increment and infinite-loop on the same matching char.
      should_skip =
        (op == ">" && source[i + 1, 1] == "=") || # >= matches as >
        (op == "<" && source[i + 1, 1] == "=") || # <= matches as <
        (op == "=" && source[i + 1, 1] == "=") || # == matches as =
        (op == ">" && source[i - 1, 1] == "-") || # -> arrow
        (op == "<" && source[i - 1, 1] == "-") # <- arrow
      return i unless should_skip
    end
    i += 1
  end
  nil
end

.parse(source) ⇒ Array<Nodes::*>

Returns AST nodes (one per top-level expression).

Parameters:

  • source (String)

    OCL expression with optional inv: prefix

Returns:

  • (Array<Nodes::*>)

    AST nodes (one per top-level expression)



23
24
25
26
27
28
# File 'lib/ea/ocl/parser.rb', line 23

def parse(source)
  return [] if source.nil? || source.empty?

  body = strip_inv_prefix(source)
  parse_expression(body)
end

.parse_expression(source) ⇒ Object

Parse one expression. Returns a single AST node.

Raises:



36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/ea/ocl/parser.rb', line 36

def parse_expression(source) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
  s = source.strip

  # Strip outer parens
  s = s[1..-2].strip while s.start_with?("(") && s.end_with?(")")

  # 'not <expr>'
  if s.start_with?("not ")
    return Nodes::UnaryOp.new(op: :not,
                              operand: parse_expression(s[4..]))
  end

  # 'and' / 'or' / comparison ops — comparison has higher
  # precedence than logical, so check it first.
  if (split = split_on_comparison_op(s))
    op, left, right = split
    return Nodes::Comparison.new(op: op,
                                 left: parse_expression(left),
                                 right: parse_expression(right))
  end
  if (split = split_on_logical_op(s))
    op, left, right = split
    return Nodes::BinaryOp.new(op: op,
                               left: parse_expression(left),
                               right: parse_expression(right))
  end

  # Collection ->exists(...) or ->forAll(...)
  if (m = s.match(/^(.+?)->(exists|forAll)\s*\(\s*(\w+)\s*\|\s*(.+?)\s*\)\z/))
    return collection_node(m[2].to_sym, m[1], m[3], m[4])
  end

  # Collection ->size() — integer cardinality
  if (m = s.match(/^(.+?)->size\(\)\z/))
    return Nodes::CollectionSize.new(collection: parse_expression(m[1]))
  end

  # Collection ->isEmpty() — boolean
  if (m = s.match(/^(.+?)->isEmpty\(\)\z/))
    return Nodes::CollectionIsEmpty.new(collection: parse_expression(m[1]))
  end

  # String.matches('regex')
  if (m = s.match(/^(.+?)\.matches\(\s*['"](.+?)['"]\s*\)\z/))
    return Nodes::StringMatches.new(string: parse_expression(m[1]),
                                    pattern: m[2])
  end

  # Boolean literal — check BEFORE bare identifier
  return Nodes::Literal.new(value: true) if s == "true"
  return Nodes::Literal.new(value: false) if s == "false"

  # Numeric literal
  return Nodes::Literal.new(value: s.to_i) if s.match?(/\A-?\d+\z/)
  return Nodes::Literal.new(value: s.to_f) if s.match?(/\A-?\d+\.\d+\z/)

  # String literal
  return Nodes::Literal.new(value: s[1..-2]) if s.match?(/\A['"].*['"]\z/)

  # 'self.<attr>' or '<var>.<attr>' — both produce AttributeAccess.
  if (m = s.match(/\A(?:self\.|[a-z_]\w*\.)([a-z_]\w*)\z/i))
    return Nodes::AttributeAccess.new(name: m[1])
  end

  # Bare identifier — variable reference.
  if (m = s.match(/\A([a-z_]\w*)\z/))
    return Nodes::AttributeAccess.new(name: m[1])
  end

  raise UnsupportedError,
        "Unsupported OCL expression: #{source.inspect}"
end

.split_on_comparison_op(source) ⇒ Object

Split on top-level comparison operator (>, <, >=, <=, =, ==, !=). Comparison has higher precedence than and/or, so we split here BEFORE calling split_on_logical_op.



135
136
137
138
139
140
141
142
143
144
145
# File 'lib/ea/ocl/parser.rb', line 135

def split_on_comparison_op(source)
  %w[>= <= == != = > <].each do |op|
    # Need to be careful with `>=` vs `>`: scan in order so
    # multi-char operators are detected first.
    idx = find_top_level_op(source, op)
    next unless idx

    return [op, source[0...idx], source[(idx + op.length)..]]
  end
  nil
end

.split_on_logical_op(source) ⇒ Object

Find the topmost and or or outside of parens. Returns [op, left_str, right_str] or nil.



118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/ea/ocl/parser.rb', line 118

def split_on_logical_op(source)
  # Look for ' or ' first (lower precedence than 'and' in OCL?)
  # Actually OCL: 'or' < 'xor' < 'and' < 'implies'. We pick the
  # rightmost top-level operator (left-associative).
  %i[and or].each do |op|
    keyword = " #{op} "
    idx = find_top_level(source, keyword)
    next unless idx

    return [op, source[0...idx], source[(idx + keyword.length)..]]
  end
  nil
end

.strip_inv_prefix(source) ⇒ Object



30
31
32
33
# File 'lib/ea/ocl/parser.rb', line 30

def strip_inv_prefix(source)
  s = source.strip
  s.sub(/\Ainv\s*:\s*/, "")
end