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.
- checksums.yaml +4 -4
- data/bin/setup-tools +5 -20
- data/lib/mendix_bridge/alter_page_builder.rb +176 -0
- data/lib/mendix_bridge/backend_server.rb +363 -11
- data/lib/mendix_bridge/git_workflow.rb +323 -16
- data/lib/mendix_bridge/mdl_parser.rb +264 -0
- data/lib/mendix_bridge/page_parser.rb +31 -37
- data/lib/mendix_bridge/version.rb +1 -1
- data/lib/mendix_bridge.rb +2 -0
- data/web/dist/assets/index-DdcSfFYq.css +1 -0
- data/web/dist/assets/index-FGJN6neJ.js +36 -0
- data/web/dist/favicon.png +0 -0
- data/web/dist/index.html +4 -4
- metadata +5 -3
- data/web/dist/assets/index-BO6iPT1P.css +0 -1
- data/web/dist/assets/index-JGODw81W.js +0 -27
|
@@ -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"
|
|
32
|
-
"clean"
|
|
33
|
+
"branch" => current_branch,
|
|
34
|
+
"clean" => clean?,
|
|
33
35
|
"operation_in_progress" => operation_in_progress,
|
|
34
|
-
"project"
|
|
35
|
-
"project_tracked"
|
|
36
|
-
"mprcontents_tracked"
|
|
37
|
-
"ready_to_switch"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
172
|
-
#
|
|
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
|