mendix-ruby-bridge 0.1.3 → 0.1.4

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.
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
3
4
  require "open3"
4
5
  require "pathname"
5
6
  require "shellwords"
7
+ require "timeout"
6
8
 
7
9
  module MendixBridge
8
10
  class GitWorkflowError < StandardError; end
@@ -28,13 +30,13 @@ module MendixBridge
28
30
 
29
31
  def status
30
32
  {
31
- "branch" => current_branch,
32
- "clean" => clean?,
33
+ "branch" => current_branch,
34
+ "clean" => clean?,
33
35
  "operation_in_progress" => operation_in_progress,
34
- "project" => @project_file,
35
- "project_tracked" => tracked?(@project_file),
36
- "mprcontents_tracked" => mprcontents_tracked?,
37
- "ready_to_switch" => clean? && operation_in_progress.nil?
36
+ "project" => @project_file,
37
+ "project_tracked" => tracked?(@project_file),
38
+ "mprcontents_tracked" => mprcontents_tracked?,
39
+ "ready_to_switch" => clean? && operation_in_progress.nil?
38
40
  }
39
41
  end
40
42
 
@@ -43,10 +45,44 @@ module MendixBridge
43
45
  .lines.map(&:strip).reject { |name| name.end_with?("/HEAD") }
44
46
  end
45
47
 
48
+ def tags
49
+ git!("tag", "--list", "--sort=-creatordate").lines.map(&:strip).reject(&:empty?)
50
+ end
51
+
52
+ def worktrees
53
+ records = []
54
+ current = {}
55
+ git!("worktree", "list", "--porcelain").each_line do |line|
56
+ line = line.chomp
57
+ if line.empty?
58
+ records << current unless current.empty?
59
+ current = {}
60
+ elsif line.start_with?("worktree ")
61
+ current["path"] = line.delete_prefix("worktree ")
62
+ elsif line.start_with?("HEAD ")
63
+ current["sha"] = line.delete_prefix("HEAD ")
64
+ elsif line.start_with?("branch ")
65
+ current["branch"] = line.delete_prefix("branch refs/heads/")
66
+ elsif line == "detached"
67
+ current["detached"] = true
68
+ elsif line == "prunable"
69
+ current["prunable"] = true
70
+ elsif line.start_with?("locked")
71
+ current["locked"] = true
72
+ end
73
+ end
74
+ records << current unless current.empty?
75
+ records
76
+ end
77
+
46
78
  def fetch
47
79
  git!("fetch", "--prune", "origin")
48
80
  end
49
81
 
82
+ def remote_names
83
+ git!("remote").lines.map(&:strip).reject(&:empty?)
84
+ end
85
+
50
86
  def switch(branch, studio_closed:)
51
87
  ensure_switch_ready!(branch, studio_closed:)
52
88
  previous = current_branch
@@ -63,25 +99,96 @@ module MendixBridge
63
99
  validate_and_refresh!
64
100
  status
65
101
  rescue StandardError => error
66
- rollback(previous) if switched
102
+ git!("switch", previous) if switched && current_branch != previous
67
103
  raise error
68
104
  end
69
105
 
70
- def create(branch, studio_closed:)
71
- ensure_switch_ready!(branch, studio_closed:)
106
+ def create(branch, studio_closed:, start_point: nil, carry_changes: false)
107
+ ensure_studio_closed!(studio_closed)
108
+ ensure_no_operation!
109
+ validate_branch_name!(branch)
72
110
  raise GitWorkflowError, "branch already exists: #{branch}" if local_branch?(branch) || remote_branch?(branch)
73
111
 
74
112
  previous = current_branch
75
113
  switched = false
76
- git!("switch", "-c", branch)
114
+ stashed = false
115
+ if !clean? && !carry_changes
116
+ git!("stash", "push", "--include-untracked", "-m", "Before creating #{branch}")
117
+ stashed = true
118
+ end
119
+ arguments = ["switch", "-c", branch]
120
+ arguments << start_point if start_point
121
+ git!(*arguments)
77
122
  switched = true
78
- validate_and_refresh!
79
- status
123
+ # Creating a ref does not change the project contents. Existing Mendix
124
+ # consistency errors must not make Git undo an otherwise valid branch.
125
+ { **status, "carried_changes" => !clean?, "stashed_changes" => stashed }
80
126
  rescue StandardError => error
81
- rollback(previous) if switched
127
+ begin
128
+ git!("switch", previous) if switched && current_branch != previous
129
+ git!("stash", "pop", "--index") if stashed
130
+ rescue GitWorkflowError
131
+ # Keep the original error; the named automatic stash remains recoverable.
132
+ end
82
133
  raise error
83
134
  end
84
135
 
136
+ TERMINAL_SUBCOMMANDS = %w[
137
+ status diff log show blame branch switch checkout add restore reset commit
138
+ fetch pull push merge rebase cherry-pick revert stash tag remote worktree
139
+ rev-parse reflog clean mv rm grep shortlog describe bisect
140
+ ].freeze
141
+
142
+ # Non-interactive Git CLI used by the embedded terminal. It deliberately
143
+ # accepts Git commands only: a browser must never become an arbitrary shell
144
+ # on the machine hosting the bridge.
145
+ def terminal_command(command, studio_closed:)
146
+ arguments = Shellwords.split(command.to_s.strip)
147
+ arguments.shift if arguments.first == "git"
148
+ raise GitWorkflowError, "enter a git command" if arguments.empty?
149
+ raise GitWorkflowError, "git global options are not allowed in the embedded terminal" if
150
+ arguments.first.start_with?("-")
151
+
152
+ subcommand = arguments.first
153
+ unless TERMINAL_SUBCOMMANDS.include?(subcommand)
154
+ raise GitWorkflowError, "unsupported git command: #{subcommand}"
155
+ end
156
+
157
+ read_only = %w[status diff log show blame rev-parse reflog grep shortlog describe].include?(subcommand)
158
+ read_only ||= subcommand == "remote" &&
159
+ (arguments.length == 1 || arguments[1] == "-v" || arguments[1] == "show")
160
+ mutating = !read_only
161
+ ensure_studio_closed!(studio_closed) if mutating
162
+ ensure_no_operation! if mutating && !%w[rebase merge cherry-pick revert bisect].include?(subcommand)
163
+
164
+ output = error = nil
165
+ result = nil
166
+ Timeout.timeout(120) do
167
+ output, error, result = Open3.capture3(
168
+ {
169
+ "GIT_TERMINAL_PROMPT" => "0",
170
+ "GIT_EDITOR" => "true",
171
+ "GIT_SEQUENCE_EDITOR" => "true"
172
+ },
173
+ "git", "-C", @root, *arguments
174
+ )
175
+ end
176
+ combined = [output, error].compact.reject(&:empty?).join
177
+ raise GitWorkflowError, combined.strip.empty? ? "git command failed" : combined.strip unless result.success?
178
+
179
+ {
180
+ "ok" => true,
181
+ "command" => "git #{Shellwords.join(arguments)}",
182
+ "output" => combined.strip,
183
+ "exit_code" => result.exitstatus,
184
+ **status
185
+ }
186
+ rescue ArgumentError => error
187
+ raise GitWorkflowError, "invalid command line: #{error.message}"
188
+ rescue Timeout::Error
189
+ raise GitWorkflowError, "git command timed out after 120 seconds"
190
+ end
191
+
85
192
  def stash_push(studio_closed:, message: nil, include_untracked: false)
86
193
  ensure_studio_closed!(studio_closed)
87
194
  ensure_no_operation!
@@ -168,9 +275,8 @@ module MendixBridge
168
275
  status
169
276
  end
170
277
 
171
- # Stages the Mendix project directory and commits it. Deliberately skips the
172
- # `mx check` consistency validation so work-in-progress (even inconsistent)
173
- # state can be committed; branch switches still guard against a dirty tree.
278
+ # Stages the whole project directory then commits. Skips mx check so
279
+ # work-in-progress can be committed; branch switches still guard a dirty tree.
174
280
  def commit(message, studio_closed:)
175
281
  ensure_studio_closed!(studio_closed)
176
282
  ensure_no_operation!
@@ -186,8 +292,206 @@ module MendixBridge
186
292
  status
187
293
  end
188
294
 
295
+ # Commits only what is already staged — no implicit git add. Enables
296
+ # fine-grained staging via the git panel before committing.
297
+ def commit_staged(message, studio_closed:)
298
+ ensure_studio_closed!(studio_closed)
299
+ ensure_no_operation!
300
+ message = message.to_s.strip
301
+ raise GitWorkflowError, "commit message cannot be empty" if message.empty?
302
+ raise GitWorkflowError, "nothing staged to commit" if
303
+ git!("diff", "--cached", "--name-only").strip.empty?
304
+
305
+ git!("commit", "-m", message)
306
+ status
307
+ end
308
+
309
+ # Commit log across all branches in topo order (newest first).
310
+ # Returns an array of hashes with sha, short_sha, author, email, date,
311
+ # subject, refs (typed array), parents (SHA array).
312
+ def log(max: 200)
313
+ fmt = "%H%x1f%h%x1f%an%x1f%ae%x1f%ai%x1f%P%x1f%D%x1f%s"
314
+ raw = git!("log", "--all", "--topo-order",
315
+ "--pretty=format:#{fmt}",
316
+ "--max-count=#{max.to_i}")
317
+ raw.each_line.filter_map do |line|
318
+ fields = line.chomp.split("\x1f", 8)
319
+ next if fields.length < 8
320
+
321
+ sha, short_sha, author, email, date, parents_str, refs_str, subject = fields
322
+ next if sha.to_s.strip.empty?
323
+
324
+ {
325
+ "sha" => sha.strip,
326
+ "short_sha" => short_sha.strip,
327
+ "author" => author.strip,
328
+ "email" => email.strip,
329
+ "date" => date.strip,
330
+ "subject" => subject.to_s.strip,
331
+ "refs" => parse_refs(refs_str.to_s.strip),
332
+ "parents" => parents_str.to_s.strip.split.reject(&:empty?)
333
+ }
334
+ end
335
+ end
336
+
337
+ # Per-file working-tree and index status (porcelain v1, NUL-terminated).
338
+ def file_status
339
+ raw = git!("status", "--porcelain=v1", "-z")
340
+ files = []
341
+ entries = raw.split("\x00")
342
+ i = 0
343
+ while i < entries.length
344
+ entry = entries[i]
345
+ unless entry.length >= 4
346
+ i += 1
347
+ next
348
+ end
349
+ xy = entry[0..1]
350
+ path = entry[3..]
351
+ renamed_from = nil
352
+ if xy[0] == "R" || xy[0] == "C"
353
+ renamed_from = entries[i + 1].to_s
354
+ i += 1
355
+ end
356
+ files << {
357
+ "path" => path.to_s,
358
+ "xy" => xy,
359
+ "index_status" => xy[0].to_s,
360
+ "worktree_status" => xy[1].to_s,
361
+ "renamed_from" => renamed_from
362
+ }
363
+ i += 1
364
+ end
365
+ files
366
+ end
367
+
368
+ def stage(path)
369
+ git!("add", "--", path)
370
+ { "ok" => true }
371
+ end
372
+
373
+ def unstage(path)
374
+ begin
375
+ git!("reset", "HEAD", "--", path)
376
+ rescue GitWorkflowError
377
+ # HEAD doesn't exist yet (empty repo) — use rm --cached instead
378
+ git!("rm", "--cached", "--", path)
379
+ end
380
+ { "ok" => true }
381
+ end
382
+
383
+ def discard(path)
384
+ target = File.expand_path(path, @root)
385
+ unless target.start_with?("#{@root}/")
386
+ raise GitWorkflowError, "path is outside the Git repository: #{path}"
387
+ end
388
+
389
+ raw = git!("status", "--porcelain", "--", path).strip
390
+ if raw.start_with?("??")
391
+ FileUtils.rm_rf(target)
392
+ else
393
+ git!("checkout", "--", path)
394
+ end
395
+ { "ok" => true }
396
+ end
397
+
398
+ def push(remote: "origin", branch: nil)
399
+ branch ||= current_branch
400
+ output = git!("push", "--set-upstream", remote, branch)
401
+ { "ok" => true, "output" => output.strip }
402
+ end
403
+
404
+ def add_remote(name, url)
405
+ name = name.to_s.strip
406
+ url = url.to_s.strip
407
+ raise GitWorkflowError, "remote name cannot be empty" if name.empty?
408
+ raise GitWorkflowError, "remote URL cannot be empty" if url.empty?
409
+ raise GitWorkflowError, "invalid remote name: #{name}" unless name.match?(/\A[A-Za-z0-9._-]+\z/)
410
+
411
+ git!("remote", "add", name, url)
412
+ { "ok" => true, "name" => name, "url" => url }
413
+ end
414
+
415
+ def pull(studio_closed:)
416
+ ensure_studio_closed!(studio_closed)
417
+ ensure_no_operation!
418
+ output = git!("pull", "--rebase")
419
+ refresh_inventory! if @inventory_dir
420
+ { "ok" => true, "output" => output.strip, **status }
421
+ end
422
+
423
+ def cherry_pick(sha, studio_closed:)
424
+ ensure_studio_closed!(studio_closed)
425
+ ensure_no_operation!
426
+ output, error, result = Open3.capture3("git", "-C", @root, "cherry-pick", sha)
427
+ raise GitWorkflowError, "cherry-pick failed:\n#{error}#{output}" unless result.success?
428
+
429
+ refresh_inventory! if @inventory_dir
430
+ { "ok" => true, "output" => output.strip, **status }
431
+ end
432
+
433
+ def revert_commit(sha, studio_closed:)
434
+ ensure_studio_closed!(studio_closed)
435
+ ensure_no_operation!
436
+ output, error, result = Open3.capture3("git", "-C", @root, "revert", "--no-edit", sha)
437
+ raise GitWorkflowError, "revert failed:\n#{error}#{output}" unless result.success?
438
+
439
+ refresh_inventory! if @inventory_dir
440
+ { "ok" => true, "output" => output.strip, **status }
441
+ end
442
+
443
+ def reset_to(sha, mode: "mixed", studio_closed:)
444
+ ensure_studio_closed!(studio_closed)
445
+ ensure_no_operation!
446
+ raise GitWorkflowError, "invalid reset mode: #{mode}" unless
447
+ %w[soft mixed hard].include?(mode.to_s)
448
+
449
+ git!("reset", "--#{mode}", sha)
450
+ refresh_inventory! if @inventory_dir && mode == "hard"
451
+ { "ok" => true, **status }
452
+ end
453
+
454
+ def create_tag(name, sha: nil, message: nil)
455
+ args = ["tag"]
456
+ args.concat(["-a", name, "-m", message]) if message
457
+ args << name unless message
458
+ args << sha if sha
459
+ git!(*args)
460
+ { "ok" => true }
461
+ end
462
+
463
+ def delete_tag(name)
464
+ git!("tag", "-d", name)
465
+ { "ok" => true }
466
+ end
467
+
468
+ def delete_branch(name, force: false)
469
+ raise GitWorkflowError, "cannot delete the current branch" if name == current_branch
470
+
471
+ git!("branch", force ? "-D" : "-d", name)
472
+ { "ok" => true }
473
+ end
474
+
189
475
  private
190
476
 
477
+ def parse_refs(refs_str)
478
+ return [] if refs_str.to_s.strip.empty?
479
+
480
+ refs_str.split(",").map(&:strip).reject(&:empty?).filter_map do |ref|
481
+ if ref.start_with?("HEAD -> ")
482
+ { "name" => ref.sub("HEAD -> ", ""), "type" => "head" }
483
+ elsif ref == "HEAD"
484
+ { "name" => "HEAD", "type" => "detached" }
485
+ elsif ref.start_with?("tag: ")
486
+ { "name" => ref.sub("tag: ", ""), "type" => "tag" }
487
+ elsif ref.include?("/")
488
+ { "name" => ref, "type" => "remote" }
489
+ else
490
+ { "name" => ref, "type" => "local" }
491
+ end
492
+ end
493
+ end
494
+
191
495
  def project_pathspec
192
496
  relative = Pathname.new(@project_dir).relative_path_from(Pathname.new(@root)).to_s
193
497
  relative == "." ? "." : relative
@@ -197,7 +501,10 @@ module MendixBridge
197
501
  ensure_studio_closed!(studio_closed)
198
502
  raise GitWorkflowError, "working tree is dirty; commit or stash changes first" unless clean?
199
503
  ensure_no_operation!
504
+ validate_branch_name!(branch)
505
+ end
200
506
 
507
+ def validate_branch_name!(branch)
201
508
  _output, error, status = Open3.capture3("git", "check-ref-format", "--branch", branch)
202
509
  raise GitWorkflowError, "invalid branch name: #{branch} (#{error.strip})" unless status.success?
203
510
  end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module MendixBridge
6
+ # Parses the widget tree out of a Mendix page MDL string.
7
+ #
8
+ # Each widget node is a Hash:
9
+ # {
10
+ # "type" => "dataview", # MDL keyword (lowercase)
11
+ # "name" => "dv1", # widget identifier (nil if anonymous)
12
+ # "properties" => { "DataSource" => "$Customer", "Class" => "..." },
13
+ # "children" => [ ...widget nodes... ]
14
+ # }
15
+ #
16
+ # Usage:
17
+ # tree = MdlParser.parse_page_widgets(full_mdl_string)
18
+ # names = MdlParser.flat_widget_names(tree)
19
+ module MdlParser
20
+ # Words that open MDL statements and are never widget type keywords.
21
+ STATEMENT_KEYWORDS = %w[
22
+ create or modify alter grant revoke drop select
23
+ ].to_set.freeze
24
+
25
+ # Value qualifiers: first word of a multi-word property value.
26
+ MULTI_WORD_QUALIFIERS = %w[
27
+ microflow nanoflow database association xpath show_page open_page
28
+ ].to_set.freeze
29
+
30
+ # Parse the full MDL of a page (including its CREATE / MODIFY header) and
31
+ # return the top-level widget tree extracted from the page body { }.
32
+ def self.parse_page_widgets(full_mdl)
33
+ sc = StringScanner.new(full_mdl.to_s)
34
+ # Advance past 'page Module.Name ('
35
+ unless sc.skip_until(/\bpage\s+[\w.]+\s*\(/i)
36
+ return []
37
+ end
38
+ # Consume balanced settings (...) — opening ( already consumed by skip_until
39
+ collect_balanced(sc, "(", ")")
40
+ sc.skip(/\s*/)
41
+ return [] unless sc.scan(/\{/)
42
+ body = collect_balanced(sc, "{", "}")
43
+ parse_widget_tree(body)
44
+ end
45
+
46
+ # Parse a raw MDL body (content between outer braces) into a widget tree.
47
+ def self.parse_widget_tree(source)
48
+ sc = StringScanner.new(source.to_s)
49
+ nodes = []
50
+ until sc.eos?
51
+ skip_ws_and_comments(sc)
52
+ break if sc.eos?
53
+ node = parse_widget_node(sc)
54
+ if node
55
+ nodes << node
56
+ else
57
+ sc.getch # skip unknown char and keep going
58
+ end
59
+ end
60
+ nodes
61
+ end
62
+
63
+ # Return a flat array of all named widget identifiers in the tree (DFS).
64
+ def self.flat_widget_names(tree)
65
+ tree.flat_map do |node|
66
+ named = node["name"] ? [node["name"]] : []
67
+ named + flat_widget_names(node["children"] || [])
68
+ end
69
+ end
70
+
71
+ # Return a flat array of all widget nodes (named and anonymous), DFS.
72
+ def self.flat_widgets(tree)
73
+ tree.flat_map do |node|
74
+ [node] + flat_widgets(node["children"] || [])
75
+ end
76
+ end
77
+
78
+ # -------------------------------------------------------------------------
79
+ private_class_method def self.skip_ws_and_comments(sc)
80
+ loop do
81
+ sc.skip(/\s+/)
82
+ sc.scan(/--[^\n]*/) ? next : break
83
+ end
84
+ end
85
+
86
+ private_class_method def self.parse_widget_node(sc)
87
+ start = sc.pos
88
+
89
+ # Widget type must be a lowercase word
90
+ type = sc.scan(/[a-z][a-z0-9_]*/)
91
+ return nil unless type
92
+ if STATEMENT_KEYWORDS.include?(type)
93
+ sc.pos = start
94
+ return nil
95
+ end
96
+
97
+ skip_ws_and_comments(sc)
98
+
99
+ # Optional widget name: quoted or plain identifier (not a paren or brace)
100
+ name = sc.scan(/"[^"]*"/) || sc.scan(/[A-Za-z_]\w*/)
101
+ if name
102
+ # Roll back if we consumed a keyword that isn't a name
103
+ if STATEMENT_KEYWORDS.include?(name.downcase)
104
+ sc.pos -= name.bytesize
105
+ name = nil
106
+ else
107
+ name = name.delete_prefix('"').delete_suffix('"')
108
+ end
109
+ end
110
+
111
+ skip_ws_and_comments(sc)
112
+
113
+ # Must be followed by '(' to be a widget declaration
114
+ unless sc.scan(/\(/)
115
+ sc.pos = start
116
+ return nil
117
+ end
118
+
119
+ props_raw = collect_balanced(sc, "(", ")")
120
+ properties = parse_properties(props_raw)
121
+
122
+ skip_ws_and_comments(sc)
123
+
124
+ children = []
125
+ if sc.scan(/\{/)
126
+ body = collect_balanced(sc, "{", "}")
127
+ children = parse_widget_tree(body)
128
+ end
129
+
130
+ { "type" => type, "name" => name, "properties" => properties, "children" => children }.compact
131
+ end
132
+
133
+ # Collect everything between a matched pair of delimiters. The opening
134
+ # delimiter has already been consumed; depth starts at 1.
135
+ private_class_method def self.collect_balanced(sc, open, close)
136
+ depth = 1
137
+ buf = +""
138
+ until sc.eos? || depth.zero?
139
+ ch = sc.getch
140
+ case ch
141
+ when open then depth += 1; buf << ch
142
+ when close
143
+ depth -= 1
144
+ buf << ch unless depth.zero?
145
+ when "'"
146
+ # Single-quoted string: '' is an escaped quote inside the string.
147
+ buf << ch
148
+ loop do
149
+ c = sc.getch
150
+ break if c.nil?
151
+ buf << c
152
+ next unless c == "'"
153
+ if sc.peek(1) == "'"
154
+ buf << sc.getch # consume the escaped-quote twin
155
+ else
156
+ break
157
+ end
158
+ end
159
+ else
160
+ buf << ch
161
+ end
162
+ end
163
+ buf
164
+ end
165
+
166
+ private_class_method def self.parse_properties(raw)
167
+ sc = StringScanner.new(raw.strip)
168
+ out = {}
169
+ until sc.eos?
170
+ skip_ws_and_comments(sc)
171
+ sc.skip(/,/)
172
+ skip_ws_and_comments(sc)
173
+ break if sc.eos?
174
+
175
+ key = sc.scan(/[A-Za-z_]\w*/)
176
+ break unless key
177
+
178
+ skip_ws_and_comments(sc)
179
+ next unless sc.scan(/:/)
180
+ skip_ws_and_comments(sc)
181
+
182
+ value = parse_value(sc)
183
+ out[key] = value unless value.nil?
184
+ end
185
+ out
186
+ end
187
+
188
+ private_class_method def self.parse_value(sc)
189
+ sc.skip(/\s*/)
190
+ return nil if sc.eos?
191
+
192
+ # Single-quoted string
193
+ if sc.scan(/'/)
194
+ str = +""
195
+ loop do
196
+ c = sc.getch
197
+ break if c.nil?
198
+ if c == "'" && sc.peek(1) == "'"
199
+ str << "'"
200
+ sc.getch # consume the escape twin, discard it
201
+ elsif c == "'"
202
+ break
203
+ else
204
+ str << c
205
+ end
206
+ end
207
+ return str
208
+ end
209
+
210
+ # Expression in square brackets [...]
211
+ if sc.scan(/\[/)
212
+ return "[#{collect_balanced(sc, "[", "]")}]"
213
+ end
214
+
215
+ # Nested block {...}
216
+ if sc.scan(/\{/)
217
+ return "{#{collect_balanced(sc, "{", "}")}}"
218
+ end
219
+
220
+ # Number (integer or float, optionally negative)
221
+ if (num = sc.scan(/-?\d+(?:\.\d+)?/))
222
+ return num.include?(".") ? num.to_f : num.to_i
223
+ end
224
+
225
+ # Identifier-based value (includes $variables and dotted paths like Module.Attr)
226
+ if (first = sc.scan(/\$?[A-Za-z_]\w*(?:[.$\/]\w+)*/))
227
+ skip_ws_and_comments(sc)
228
+
229
+ # Function call: ident(args) → "ident(args)"
230
+ if sc.scan(/\(/)
231
+ args = collect_balanced(sc, "(", ")")
232
+ return "#{first}(#{args})"
233
+ end
234
+
235
+ # Multi-word qualifiers: microflow Module.Name, database Module.Entity, etc.
236
+ if MULTI_WORD_QUALIFIERS.include?(first)
237
+ saved = sc.pos
238
+ sc.skip(/\s+/)
239
+ second = sc.scan(/\$?[A-Za-z_]\w*(?:[.$\/]\w+)*/)
240
+ if second
241
+ skip_ws_and_comments(sc)
242
+ if sc.scan(/\(/)
243
+ args = collect_balanced(sc, "(", ")")
244
+ return "#{first} #{second}(#{args})"
245
+ end
246
+ if first == "xpath" && sc.scan(/\[/)
247
+ constraint = "[#{collect_balanced(sc, "[", "]")}]"
248
+ return "#{first} #{second}#{constraint}"
249
+ end
250
+ return "#{first} #{second}"
251
+ else
252
+ sc.pos = saved
253
+ end
254
+ end
255
+
256
+ return true if first == "true"
257
+ return false if first == "false"
258
+ return first
259
+ end
260
+
261
+ nil
262
+ end
263
+ end
264
+ end