Module: Ea::Shapescript::Parser

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

Overview

Tokenizer + parser for ShapeScript. Returns a list of Shape primitives extracted from a single shape body.

Supported grammar (extended):

script        := statement*
statement     := shape | var_decl | primitive | if_stmt | label
shape         := "shape" identifier "{" statement* "}"
var_decl      := "var" identifier "=" expr ";"
primitive     := call "(" args ")" ";"
call          := "rectangle" | "ellipse" | "polygon" |
               "line" | "path" | "label"
args          := expr ("," expr)*
if_stmt       := "if" "(" expr ")" "{" statement* "}"
               ("else" "{" statement* "}")?
expr          := identifier | number | string | binop

Variables are resolved at parse time when possible; otherwise the identifier is left as a string for late binding.

Constant Summary collapse

PRIMITIVES =
{
  "rectangle" => :rectangle,
  "ellipse" => :ellipse,
  "polygon" => :polygon,
  "line" => :line,
  "path" => :path
}.freeze

Class Method Summary collapse

Class Method Details

.apply_binop(op, left, right) ⇒ Object



196
197
198
199
200
201
202
203
204
205
# File 'lib/ea/shapescript/parser.rb', line 196

def apply_binop(op, left, right)
  return nil unless left.is_a?(Numeric) && right.is_a?(Numeric)

  case op
  when "+" then left + right
  when "-" then left - right
  when "*" then left * right
  when "/" then left / right
  end
end

.consume_if(tokens, env, then_shapes) ⇒ Object



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/ea/shapescript/parser.rb', line 120

def consume_if(tokens, env, then_shapes)
  tokens.shift # "("
  condition = resolve_expr(tokens.shift, tokens, env)
  tokens.shift until tokens.empty? || tokens.first == ")"
  tokens.shift # ")"
  tokens.shift # "{"

  # Evaluate condition; if true, consume then-block into shapes.
  # If false, skip then-block and consume optional else.
  truthy = condition == true || (condition.is_a?(Numeric) && condition.nonzero?)
  if truthy
    consume_statements(tokens, env, then_shapes, top_level: false)
  else
    skip_block(tokens)
    if tokens.first == "else"
      tokens.shift
      tokens.shift # "{"
      consume_statements(tokens, env, then_shapes, top_level: false)
    end
  end
end

.consume_label(tokens, env) ⇒ Object



102
103
104
105
106
107
108
109
110
# File 'lib/ea/shapescript/parser.rb', line 102

def consume_label(tokens, env)
  tokens.shift # "("
  text = tokens.shift
  text = text.to_s.gsub(/\A["']|["']\z/, "")
  tokens.shift until tokens.empty? || tokens.first == ")"
  tokens.shift # ")"
  consume_semicolon(tokens)
  Shape.new(kind: :label, params: [text])
end

.consume_primitive(name, tokens, env) ⇒ Object



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/ea/shapescript/parser.rb', line 85

def consume_primitive(name, tokens, env)
  kind = PRIMITIVES[name]
  open_paren = tokens.shift # "("
  params = []
  if open_paren == "("
    until tokens.empty?
      tok = tokens.shift
      break if tok == ")"

      value = resolve_expr(tok, tokens, env)
      params << value if value.is_a?(Numeric)
    end
  end
  consume_semicolon(tokens)
  Shape.new(kind: kind, params: params)
end

.consume_semicolon(tokens) ⇒ Object



152
153
154
# File 'lib/ea/shapescript/parser.rb', line 152

def consume_semicolon(tokens)
  tokens.shift if tokens.first == ";"
end

.consume_statements(tokens, env, shapes, top_level: true) ⇒ Object

Consume statements until matching close brace or end.



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
# File 'lib/ea/shapescript/parser.rb', line 51

def consume_statements(tokens, env, shapes, top_level: true)
  until tokens.empty?
    tok = tokens.first
    if tok == "}"
      tokens.shift if top_level == false
      break
    end

    tokens.shift # consume tok
    case tok
    when "shape"
      _name = tokens.shift
      _open = tokens.shift # "{"
      consume_statements(tokens, env.dup, shapes, top_level: false)
    when "var"
      consume_var_decl(tokens, env)
    when "if"
      consume_if(tokens, env, shapes)
    when "label"
      shapes << consume_label(tokens, env)
    else
      shapes << consume_primitive(tok, tokens, env) if PRIMITIVES.key?(tok)
    end
  end
end

.consume_var_decl(tokens, env) ⇒ Object



112
113
114
115
116
117
118
# File 'lib/ea/shapescript/parser.rb', line 112

def consume_var_decl(tokens, env)
  name = tokens.shift
  eq = tokens.shift # "="
  value = resolve_expr(tokens.shift, tokens, env)
  consume_semicolon(tokens)
  env[name] = value if value.is_a?(Numeric) || value.is_a?(String)
end

.parse(source) ⇒ Array<Shape>

Returns flat list of all primitives across all shapes in the source. Shape nesting is flattened. Variables, conditionals, and labels are resolved/inlined where possible; unresolvable expressions are skipped.

Parameters:

  • source (String)

    ShapeScript source

Returns:

  • (Array<Shape>)

    flat list of all primitives across all shapes in the source. Shape nesting is flattened. Variables, conditionals, and labels are resolved/inlined where possible; unresolvable expressions are skipped.



32
33
34
35
36
37
38
39
40
# File 'lib/ea/shapescript/parser.rb', line 32

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

  tokens = tokenize(source)
  env = {}
  shapes = []
  consume_statements(tokens, env, shapes)
  shapes
end

.resolve_expr(tok, tokens, env) ⇒ Object

Resolve an expression to a value. Returns Numeric/String/Boolean or nil when unresolvable. Handles simple binary ops (+, -, *, /).



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/ea/shapescript/parser.rb', line 158

def resolve_expr(tok, tokens, env)
  return nil unless tok

  # Parenthesized sub-expression
  if tok == "("
    value = resolve_expr(tokens.shift, tokens, env)
    tokens.shift if tokens.first == ")"
    return value
  end

  # Numeric literal
  return tok.to_i if tok.match?(/\A-?\d+\z/)
  return tok.to_f if tok.match?(/\A-?\d+\.\d+\z/)

  # String literal
  return tok[1..-2] if tok.match?(/\A["'].*["']\z/)

  # Boolean literals
  return true if tok == "true"
  return false if tok == "false"

  # Variable reference
  if env.key?(tok)
    value = env[tok]
    # Look ahead for binary op
    if %w[+ - * /].include?(tokens.first)
      op = tokens.shift
      rhs = resolve_expr(tokens.shift, tokens, env)
      return apply_binop(op, value, rhs)
    end
    return value
  end

  # Bare identifier (function call? hasproperty? — leave as nil
  # for now; treat as false in conditionals).
  nil
end

.skip_block(tokens) ⇒ Object



142
143
144
145
146
147
148
149
150
# File 'lib/ea/shapescript/parser.rb', line 142

def skip_block(tokens)
  tokens.shift # "{"
  depth = 1
  until tokens.empty? || depth.zero?
    t = tokens.shift
    depth += 1 if t == "{"
    depth -= 1 if t == "}"
  end
end

.tokenize(source) ⇒ Object

-- Tokenizer ---------------------------------------------------



44
45
46
47
48
# File 'lib/ea/shapescript/parser.rb', line 44

def tokenize(source)
  clean = source.gsub(%r{//[^\n]*}, "")
                .gsub(/\/\*.*?\*\//m, "")
  clean.scan(/[A-Za-z_][A-Za-z0-9_]*|-?\d+\.?\d*|"[^"]*"|'[^']*'|[{}(),;=+\-*\/]/)
end