-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathutil.lua
1439 lines (1322 loc) · 58.9 KB
/
util.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
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
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
--[[
This file implements all "platform-specific" logic, which is generally provided via either directly by Lua's `os` and `io` standard library modules,
or by invoking external programs through `io.popen` specifically (comparable to C's `system` function in `stddef.h`, i.e. invokes programs as-if through the system shell).
Used by benmet itself, but also provided to step scripts written in Lua.
The main tested target platform is Linux, and although some efforts were also invested towards Windows support, when testing a real use case once it didn't quite work yet.
]]
-- imports from other libraries
local md5
local json_encode, json_decode
if not _G.benmet_util_skip_library_imports then
local main_script_dir_path = _G.benmet_get_main_script_dir_path and _G.benmet_get_main_script_dir_path()
local clone_dir_hint = main_script_dir_path and "into "..main_script_dir_path.."/.."
or "next to 'benmet' (this file's parent directory)"
do
local found, sha2 = pcall(require, "pure_lua_SHA.sha2")
if not found then
error("Could not find Lua module `pure_lua_SHA.sha2`. Please run the command 'auto-setup', or manually clone https://github.com/Egor-Skriptunoff/pure_lua_SHA.git "..clone_dir_hint)
end
md5 = sha2.md5
end
do
local found, lunajson = pcall(require, "lunajson")
if not found then
error("Could not find Lua module `lunajson`. Please run the command 'auto-setup', or manually clone https://github.com/grafi-tt/lunajson.git "..clone_dir_hint)
end
--[=[lunajson.encode(value, [nullv]):
Encode value into a JSON string and return it. If nullv is specified, values equal to nullv will be encoded as null.
This function encodes a table t as a JSON array if a value t[1] is present or a number t[0] is present. If t[0] is present, its value is considered as the length of the array. Then the array may contain nil and those will be encoded as null. Otherwise, this function scans non nil values starting from index 1, up to the first nil it finds. When the table t is not an array, it is an object and all of its keys must be strings.
--]=]
json_encode = lunajson.encode
--[=[lunajson.decode(jsonstr, [pos, [nullv, [arraylen]]]):
Decode jsonstr. If pos is specified, it starts decoding from pos until the JSON definition ends, otherwise the entire input is parsed as JSON. null inside jsonstr will be decoded as the optional sentinel value nullv if specified, and discarded otherwise. If arraylen is true, the length of an array ary will be stored in ary[0]. This behavior is useful when empty arrays should not be confused with empty objects.
This function returns the decoded value if jsonstr contains valid JSON, otherwise an error will be raised. If pos is specified it also returns the position immediately after the end of decoded JSON.
--]=]
json_decode = lunajson.decode
end
end
local util = {
json_encode = json_encode,
json_decode = json_decode,
}
util.debug_detail_level = 0
local detail_level = 0
local incdl = function() detail_level = detail_level+1 end
local decdl = function() detail_level = detail_level-1 assert(detail_level >= 0) end
local assertdecdl = function(condition, error_message) if not condition then decdl() error(error_message) end return condition end
util.incdl, util.decdl, util.assertdecdl = incdl, decdl, assertdecdl
-- setup features
local actual_debug_detail_level = util.debug_detail_level
util.debug_detail_level = 0
-- debugging, logging
local debug_write = _G.benmet_debug_output_to_file
if debug_write then
local debug_output_file = assert(io.open(debug_write, 'w+'), "failed to open file '"..tostring(debug_write).."' to write debug output to")
util.debug_output_file = debug_output_file
debug_write = function(x) debug_output_file:write(tostring(x).."\n") end
debug_write"=={debug output start}=="
else
debug_write = print
end
function util.debugprint(x, indent, level, already_visited)
if (level or util.debug_detail_level) <= detail_level then return end --skip if we're too deep already
indent = indent or string.rep(" ", detail_level)
if type(x) ~= 'table' then
debug_write(indent..(--[[type(x) == 'string' and string.format("%q", x) or]] tostring(x)))
return
end
already_visited = already_visited or {0}
if already_visited[x] then
debug_write(indent .. already_visited[x])
return
end
already_visited[x] = "(table #"..already_visited[1]..")"
already_visited[1] = already_visited[1]+1
debug_write(indent .. already_visited[x] .. " = {")
for k,v in pairs(x) do
util.debugprint(k, indent .. " ", level, already_visited)
debug_write(indent .. "=>")
util.debugprint(v, indent .. " ", level, already_visited)
debug_write(indent .. ",")
end
debug_write(indent .. "}")
end
function util.logprint(x, indent)
return util.debugprint(x, indent--[[, util.log_detail_level]])
end
-- basic functions
function util.string_ends_with(s, suffix)
return (string.sub(s, -#suffix) == suffix) and string.sub(s, 1, -(#suffix+1))
end
function util.string_starts_with(s, prefix)
return (string.sub(s, 1, #prefix) == prefix) and string.sub(s, #prefix+1)
end
-- returns list of substrings that do not contain delimiter_char
function util.string_split(s, delimiter_char)
local segments = {}
for segment in string.gmatch(s, "[^%"..delimiter_char.."]*") do
segments[#segments+1] = segment
end
return segments
end
-- (UNUSED) returns list of non-empty substrings that do not contain delimiter_char
function util.string_tokenize(s, delimiter_char)
local tokens = {}
for token in string.gmatch(s, "[^%"..delimiter_char.."]+") do
tokens[#tokens+1] = token
end
return tokens
end
util.cut_trailing_space = function(s)
return string.match(s, ".*%S") or ""
end
function util.in_quotes(s)
if util.string_starts_with(s, '"') and util.string_ends_with(s, '"') then
error("string already has quotes: "..tostring(s)) -- I _think this helps code correctness - runtime crashes are better than bugs - but feel free to comment it out if your judgement differs.
return s
end
s = string.format("%q", s) -- FIXME: probably wrong, a manual string.gsub with replacement table might be better?
s = string.gsub(s, "\\\\", "\\") -- undo doubling by string.format "%q"
return s
end
function util.in_quotes_with_backslashes(s)
return string.gsub(util.in_quotes(s), "/", "\\")
end
function util.remove_quotes(s)
if #s > 1 then
local first_char = string.sub(s, 1, 1)
local last_char = string.sub(s, -1)
if first_char == last_char and (first_char == "\"" or first_char == "'") then
return string.sub(s, 2, -2), true
end
end
return s
end
function util.line_contents_from_string(s)
local lines = {}
for line in string.gmatch(s, "[^\n]*") do
lines[#lines+1] = line
end
if lines[#lines] == "" then
lines[#lines] = nil
end
return lines
end
local table_copy_shallow = function(t)
local result
if t then
result = {}
for k,v in pairs(t) do
result[k] = v
end
end
return result
end
util.table_copy_shallow = table_copy_shallow
local function table_copy_deep_impl(t, copy, copy_lookup, type_f)
for k,v in pairs(t) do
local copy_v = copy_lookup[v]
if copy_v == nil then
if type_f(v) ~= 'table' then
copy_v = v
copy_lookup[v] = copy_v
else
copy_v = {}
copy_lookup[v] = copy_v
table_copy_deep_impl(v, copy_v, copy_lookup, type_f)
end
end
copy[k] = copy_v
end
return copy
end
local table_copy_deep_into = function(t, result)
if not t then return result end
return table_copy_deep_impl(t, result, {[t] = result}, type)
end
local table_copy_deep = function(t)
if not t then return nil end
return table_copy_deep_into(t, {})
end
util.table_copy_deep = table_copy_deep
local function table_patch_in_place(instance_to_patch, patch, --[[patches]] ...)
if not patch then return instance_to_patch end
for k,v in pairs(patch) do
instance_to_patch[k] = v
end
return table_patch_in_place(instance_to_patch, --[[patches]] ...)
end
util.table_patch_in_place = table_patch_in_place
function util.table_patch(original, --[[patches]] ...)
return table_patch_in_place(table_copy_deep(original), ...)
end
function util.tables_shallow_equal(a, b)
for k,v in pairs(a) do
if b[k] ~= v then
util.debugprint("not equal in '"..tostring(k).."': "..tostring(b[k])..", "..tostring(v))
return false
end
end
for k,v in pairs(b) do
if a[k] ~= v then
util.debugprint("not equal in '"..tostring(k).."': "..tostring(a[k])..", "..tostring(v))
return false
end
end
return true
end
local list_copy_shallow = function(list)
local result = {}
for i = 1, #list do
result[i] = list[i]
end
return result
end
util.list_copy_shallow = list_copy_shallow
local list_append_in_place = function(to, from)
local n = #to
for i = 1, #from do
n = n+1
to[n] = from[i]
end
return to
end
util.list_append_in_place = list_append_in_place
function util.list_append_to_new(first, second)
return list_append_in_place(list_copy_shallow(first), second)
end
function util.list_split_in_place_at_return_tail(list, at)
local tail = {}
local tail_i = 1
for i = at, #list do
tail[tail_i] = list[i]
list[i] = nil
tail_i = tail_i+1
end
return tail
end
function util.list_contains(list, element)
for i = 1, #list do
if list[i] == element then
return true
end
end
end
function util.table_keys_list(t)
local keys = {}
for k in pairs(t) do
keys[#keys+1] = k
end
return keys
end
function util.list_to_index_lookup_table(list)
local index_lookup = {}
for i = 1, #list do
index_lookup[list[i]] = i
end
return index_lookup
end
util.list_to_lookup_table = util.list_to_index_lookup_table
function util.tables_intersect(values_from, keys_from)
local result = {}
for k,_ in pairs(keys_from) do
result[k] = values_from[k]
end
return result
end
function util.tables_intersect_assert(values_from, keys_from)
local result = {}
for k,_ in pairs(keys_from) do
result[k] = values_from[k]
assert(result[k] ~= nil)
end
return result
end
function util.list_remove_same_ordered_from_in_place(to_remove, from)
local to_remove_n = #to_remove
if to_remove_n == 0 then return end
local next_to_remove_i = 1
local next_to_remove = to_remove[next_to_remove_i]
local to_index = 1
for element_i = 1, #from do
local element = from[element_i]
if next_to_remove ~= element then
from[to_index] = element
to_index = to_index+1
else
next_to_remove_i = next_to_remove_i+1
if next_to_remove_i > to_remove_n then break end
next_to_remove = to_remove[next_to_remove_i]
end
end
for i = to_index, #from do
from[i] = nil
end
assert(next_to_remove_i > to_remove_n, "error: inconsistent order of elements to remove and list to remove them from")
end
local array_element_iterator__next = function(state)
local next_index = state[1] + 1
state[1] = next_index
return state[2][next_index]
end
function util.array_element_iterator(array)
return array_element_iterator__next, {0, array} --[[, first_index=nil]]
end
local weakly_keyed_table_mt = {__mode = 'k'}
local new_weakly_keyed_table = function(t)
t = t or {}
return setmetatable(t, weakly_keyed_table_mt)
end
util.new_weakly_keyed_table = new_weakly_keyed_table
-- This function constructs and assigns to t[index]
-- a proxy function that selects the actual implementation from a list
-- and then assigns and calls the selected implementation.
-- Used so we can f.e. implement removing files based on whether 'rm' exists,
-- but only look up whether 'rm' exists if that functionality is actually required.
local install_delayed_impl_selector = function(t, index, condition_implementation_pair_list)
local selected_impl
local impl_selector_proxy = function(--[[impl_args]]...)
if not selected_impl then -- otherwise the selection was already evaluated, maybe this function was copied out to somewhere else in the meantime
for i = 1, #condition_implementation_pair_list, 2 do
local condition_f = condition_implementation_pair_list[i]
if condition_f == true or condition_f() then -- if the condition is true, choose the corresponding implementation
selected_impl = condition_implementation_pair_list[i+1]
break
end
assert(i + 1 < #condition_implementation_pair_list, "exhausted all implementations, no condition satisfied")
end
t[index] = selected_impl -- replace ourselves with the actual implementation
end
return selected_impl(--[[impl_args]]...) -- work as a proxy
end
t[index] = impl_selector_proxy
end
local env_override_table = {}
local env_override_string = ""
function util.getenv(varname)
return env_override_table[varname]
or os.getenv(varname)
end
local env_override_string_from_table = function(env_override_table)
local s = ""
local suffix = util.system_type == 'unix' and "\n"
or " && "
for k,v in pairs(env_override_table) do
s = s..util.export_command.." "..util.in_quotes(k.."="..v)..suffix
end
return s
end
util.env_override_string_from_table = env_override_string_from_table
function util.setenv(varname, value)
assert(string.find(varname, "[\"`'=%-%s]") == nil, "invalid environment variable name")
env_override_table[varname] = value
env_override_string = env_override_string_from_table(env_override_table)
end
function util.prependenv(varname, value_prefix)
local prev_value = util.getenv(varname)
return util.setenv(varname, value_prefix..(prev_value ~= nil and prev_value or ""))
end
function util.appendenv(varname, value_suffix)
local prev_value = util.getenv(varname)
return util.setenv(varname, (prev_value ~= nil and prev_value or "")..value_suffix)
end
local execute_command_with_env_override_string = function(cmd, env_override_string, ...)
assert(select('#', ...) == 0, "superfluous argument passed to execute_command, did you mean execute_command_at?")
util.logprint("executing: "..cmd.."\nenv-override: "..env_override_string)
incdl()
local read_pipe = assertdecdl(io.popen(env_override_string..cmd))
local program_output = read_pipe:read('*a')
util.debugprint("the program wrote: "..program_output)
local program_success, exit_type, return_status = read_pipe:close()
util.debugprint("it "..(program_success and "succeeded" or "failed").." by '"..exit_type.."' with status "..return_status)
decdl()
return program_success, exit_type, return_status, program_output
end
function util.execute_command_with_env_override(cmd, env_override_table, ...)
return execute_command_with_env_override_string(cmd, env_override_string_from_table(env_override_table), ...)
end
function util.execute_command(cmd, ...)
return execute_command_with_env_override_string(cmd, env_override_string, ...)
end
function util.execute_command_with_env_override_at(cmd, env_override_table, path)
return util.execute_command_with_env_override("cd "..util.in_quotes(path).." && "..cmd, env_override_table)
end
function util.execute_command_at(cmd, path)
return util.execute_command("cd "..util.in_quotes(path).." && "..cmd)
end
local find_program_cache = {}
do -- find our find program; we cannot delay this because we determine util.system_type (-> util.export_command) based on this (though there's probably better ways)
-- try 'which'
local found, exit_type, return_code, program_output = util.execute_command("which which")
if found then
find_program_cache['which'] = util.cut_trailing_space(program_output) -- fill the cache as a call to util.find_program would have
util.find_program_program = 'which'
else
-- try 'where'
found, exit_type, return_code, program_output = util.execute_command("where /Q where")
if found then
find_program_cache['where'] = util.cut_trailing_space(program_output) -- fill the cache as a call to util.find_program would have
util.find_program_program = 'where'
end
end
end
function util.find_program(program_name)
assert(program_name)
assert(util.find_program_program)
util.logprint("looking up program '"..program_name.."'")
incdl()
local found, exit_type, return_code, program_output = util.execute_command(util.find_program_program.." "..util.in_quotes(program_name))
decdl()
local result = found and (util.cut_trailing_space(program_output)
or found)
or false
find_program_cache[program_name] = result
return result
end
function util.find_program_cached(program_name)
local result = find_program_cache[program_name]
if result ~= nil then
util.logprint("looked up program '"..program_name.."' (from cache): "..tostring(result))
else
result = util.find_program(program_name)
end
return result
end
util.system_type = util.find_program_program == 'which' and 'unix'
or 'windows'
util.export_command = util.system_type == 'unix' and 'export'
or 'SET'
function util.append_line(file_path, line)
line = util.string_ends_with(line, "\n") and line
or line.."\n"
local file = assert(io.open(file_path, 'a+'))
assert(file:write(line))
assert(file:close())
end
function util.new_compat_serialize(t)
if t == nil then return "" end
assert(type(t) == 'table')
local lines = {}
for k, v in pairs(t) do
k = tostring(k)
v = tostring(v)
-- TODO: warn about unescaped newlines
lines[#lines+1] = k.."="..v
end
table.sort(lines)
return table.concat(lines, "\n")
end
-- helper function that checks the type of the given value,
-- asserting that it is a non-identity primitive,
-- then returns its string representation (via tostring)
local strict_tostring = function(x, --[[error_prefixes]]...)
local x_type = type(x)
if x_type ~= 'string' and x_type ~= 'number' and x_type ~= 'boolean' then
local error_message_segments = {--[[error_prefixes]]...}
error_message_segments[#error_message_segments+1] = "type '"
error_message_segments[#error_message_segments+1] = x_type
error_message_segments[#error_message_segments+1] = "'"
error(table.concat(error_message_segments))
end
return tostring(x)
end
local check_json_param_key_validity = function(key)
local key_type = type(key)
if key_type ~= 'string' then
error("non-string param name from parsed JSON: type '"..key_type.."'")
end
assert(key ~= "", "invalid param name \"\" (empty string) encountered in parsed JSON")
end
util.check_json_param_key_validity = check_json_param_key_validity
-- helper function asserting that all keys are strings and
-- all entries are non-identity primitives (strings, numbers or booleans);
-- returns a table with values converted to strings
local ensure_strings_in_json_param_entry = function(entry)
local t = {}
for k,v in pairs(entry) do -- translate keys and values to strings, as we would expect from our line-based format
check_json_param_key_validity(k)
t[k] = strict_tostring(v, "encountered unexpected type as value of parameter '", k, "' from parsed JSON: ")
end
return t
end
-- deserialize one coordinate of parameters from either a JSON object (starting with "{") or our line-based format
function util.new_compat_deserialize(s, failure_message)
local failure_prefix = failure_message and failure_message.."\n" or ""
if string.match(s, "^%s*{") then -- looks like a JSON object
failure_prefix = failure_prefix ~= "" and failure_prefix.."(trying to parse params as JSON object)\n"
or "error trying to parse params as JSON object: "
-- decode, then ensure it's all strings as it would be coming from our line-based format
local successful, parsed = pcall(json_decode, s)
if not successful then
error(failure_prefix..tostring(parsed))
end
return ensure_strings_in_json_param_entry(parsed)
elseif string.match(s, "^%s*%[") then -- looks like a JSON array, which is an error (we only expected a single coordinate of parameters)
failure_prefix = failure_prefix ~= "" and failure_prefix
or "error trying to parse params: "
error(failure_prefix.."expected single JSON object, found JSON array")
else -- parse as our line-based format
local t = {}
for k, v in string.gmatch(s, "([^%s=]*)=([^\n]*)") do
if k ~= "" then
if t[k] ~= nil then
error(failure_prefix.."encountered duplicate key assignment: ["..k.."] = "..t[k]..", then = "..v)
end
t[k] = v
end
end
return t
end
end
-- deserialize multiple parameters from either
-- a JSON array (starting with "[") containing objects holding parameter coordinates,
-- or our line-based format, where entries are separated by lines starting with "="
function util.new_compat_deserialize_multientry(s)
if string.match(s, "^%s*%[") then -- looks like a JSON array
-- decode the array
local successful, parsed = pcall(json_decode, s)
if not successful then
error("error trying to parse multi-entry params as JSON array: "..tostring(parsed))
end
-- ensure every entry is all strings, as it would be coming from our line-based format
local entries = {}
for i = 1, #parsed do
entry[i] = ensure_strings_in_json_param_entry(parsed[i])
end
return entries
elseif string.match(s, "^%s*{") then -- looks like a JSON object
failure_prefix = failure_prefix ~= "" and failure_prefix
or "error trying to parse multi-entry params: "
error(failure_prefix.."expected JSON array, found single JSON object")
else -- parse our line-based format
local entries = {}
local t
-- inline string tokenization
local s_length_1 = #s + 1
local prev_end_pos = 0
repeat
local next_start_pos, next_end_pos = string.find(s, "\n", prev_end_pos+1, true)
local line = (next_start_pos or s_length_1) > prev_end_pos+1 and string.sub(s, prev_end_pos+1, next_start_pos and next_start_pos-1)
prev_end_pos = next_end_pos
if line then
local k, v = string.match(line, "([^%s=]*)=(.*)")
if k == "" then -- entry separator
t = nil
else -- property line within entry
if not t then
t = {}
entries[#entries+1] = t
end
if t[k] ~= nil then
error("encountered duplicate key assignment: ["..k.."] = "..t[k]..", then = "..v)
end
t[k] = v
end
end
until not next_start_pos
return entries
end
end
-- deserialize multi-valued parameters from either
-- a JSON object (starting with "{") containing either single values or arrays of values,
-- or our line-based format allowed to contain multiple assignments to the same property
function util.new_compat_deserialize_multivalue(s)
local key_index_lookup = {}
local entries = {}
if string.match(s, "^%s*{") then -- looks like a JSON object
local successful, parsed = pcall(json_decode, s)
if not successful then
error("error trying to parse multivalue params from JSON object: "..tostring(parsed))
end
for k, parsed_entry in pairs(parsed) do
check_json_param_key_validity(k)
local key_index = #entries+1
key_index_lookup[k] = key_index
local entry_type = type(parsed_entry)
if entry_type == 'table' then -- table elements must be arrays holding permissible individual values
local entry_values = {}
entries[key_index] = {k, entry_values}
-- iterate over all numbered entries (assuming it is an array) and move them over to entry_values
for i = 1, #parsed_entry do
entry_values[i] = strict_tostring(parsed_entry[i], "encountered unexpected type as element in array value of parameter '", k, "' from parsed JSON: ")
parsed_entry[i] = nil
end
-- if there are any entries left, the array contained null entries, or it was an object (with string keys)
for k in pairs(parsed_entry) do
if type(k) == 'number' then
error("error trying to parse multivalue params from JSON object: did not reach index "..k.." of array value; note that 'null' in array values is not permitted")
else
error("error trying to parse multivalue params from JSON object: found non-number index '"..tostring(k).."', values may only be arrays, not objects")
end
end
-- we additionally require that the array cannot be empty
assert(#entry_values > 0, "error trying to parse multivalue params from JSON object: value array cannot be empty")
else -- non-table elements are individual values, which we wrap in a table for consistency
entries[key_index] = {k, {strict_tostring(parsed_entry, "encountered unexpected type as value of parameter '",k,"' from parsed JSON: ")}}
end
end
return entries, key_index_lookup
elseif string.match(s, "^%s*%[") then -- looks like a JSON array, which is an error (we only expected a single coordinate of parameters)
failure_prefix = failure_prefix ~= "" and failure_prefix
or "error trying to parse params: "
error(failure_prefix.."expected single JSON object, found JSON array")
else -- parse our line-based format
for k, v in string.gmatch(s, "([^%s=]*)=([^\n]*)") do
if k ~= "" then
local key_index = key_index_lookup[k]
if not key_index then
key_index = #entries+1
key_index_lookup[k] = key_index
end
local entry = entries[key_index]
if not entry then
entry = {k, {v}}
entries[key_index] = entry
else
local values = entry[2]
values[#values+1] = v
end
end
end
return entries, key_index_lookup
end
end
local order_multivalue_entries_by_value_count_asc = function(a, b)
return #a[2] < #b[2]
end
function util.sort_multivalue(multivalue_entries)
table.sort(multivalue_entries, order_multivalue_entries_by_value_count_asc)
return multivalue_entries
end
-- checks that all sublists are non-empty arrays and
-- converts values to strings
function util.coerce_json_multivalue_array_in_place(multivalue_array)
for i = 1, #multivalue_array do
local next_element = multivalue_array[i]
for k,v in pairs(next_element) do
check_json_param_key_validity(k)
if type(v) == 'table' then
if #v == 0 then
error("sublists must be non-empty arrays without null entries")
end
for vi = 1, #v do
v[vi] = strict_tostring(v[vi], "unsupported value type at input_array[", i, "].", k, "[", vi, "]: ")
end
else
next_element[k] = strict_tostring(v, "encountered unexpected value type at input_array[", i, "].", k, ": ")
end
end
end
end
-- constructs all combinations, going in reverse order
local combinatorial_iterator_impl = function(state, combination)
local multivalue_entries = state[1]
local index_stack = state[2]
local keys_length = state[3]
if combination ~= nil then -- on all but the first call
for i = keys_length, 1, -1 do -- check all keys, starting at the last
if index_stack[i] > 1 then -- if there are more options left for key i, select the next one (decrementing)
local index_i = index_stack[i] - 1
index_stack[i] = index_i
local entry = multivalue_entries[i]
combination[entry[1]] = entry[2][index_i]
for j = i+1, keys_length do -- reset all later keys back to the initial choice (the last option of each multivalue)
entry = multivalue_entries[j]
local values = entry[2]
local values_count = #values
combination[entry[1]] = values[values_count]
index_stack[j] = values_count
end
return combination
end
end
return
else -- on the first call, set up combination as the last option of each multivalue
index_stack = {}
state[2] = index_stack
local combination = {}
for i = 1, keys_length do
local entry = multivalue_entries[i]
local values = entry[2]
local values_count = #values
combination[entry[1]] = values[values_count]
index_stack[i] = values_count
end
return combination
end
end
function util.all_combinations_of_multivalues(multivalue_entries) -- note: faster iteration over sorted multivalue
return combinatorial_iterator_impl, {multivalue_entries, nil, #multivalue_entries}, nil
end
function util.combinatorial_iterator_one_from_each_sublist(table_of_sublists)
-- convert to our multivalue_entries format
local multivalue_entries = {}
for k,v in pairs(table_of_sublists) do
if type(v) ~= 'table' then -- auto-wrap non-list values
v = {v}
end
multivalue_entries[#multivalue_entries+1] = {k, v}
end
-- sort the entries for asymptotically faster iteration
util.sort_multivalue(multivalue_entries)
-- return the iterator
return util.all_combinations_of_multivalues(multivalue_entries)
end
local all_combinations_of_multivalues_in_list_impl_passthrough_or_recur -- forward declaration to allow mutual recursion
local all_combinations_of_multivalues_in_list_impl = function(state, prev_nested_iterator_index)
local current_nested_iterator, current_nested_iterator_state = state[3], state[4]
if current_nested_iterator == nil then -- handle the next element in the input array
-- get the next array entry
local next_index = state[2]+1
state[2] = next_index
local input_array = state[1]
if next_index > #input_array then -- we reached the end
return nil
end
local next_input = input_array[next_index]
-- determine what iterator to use for this element
-- check if one of the elements is a list
local has_list_element
for k,v in pairs(next_input) do
if type(v) == 'table' then
has_list_element = true
end
end
-- if there was no list element, the next element is just this element verbatim
if not has_list_element then
return next_input
end
-- otherwise, we set up the combinatorial iterator over this element as nested iterator
current_nested_iterator, current_nested_iterator_state, prev_nested_iterator_index = util.combinatorial_iterator_one_from_each_sublist(next_input)
state[3], state[4] = current_nested_iterator, current_nested_iterator_state
-- fallthrough
end
return all_combinations_of_multivalues_in_list_impl_passthrough_or_recur(state, current_nested_iterator(current_nested_iterator_state, prev_nested_iterator_index))
end
-- helper function, either passes through the new index and varargs,
-- or clears the nested iterator from the state and calls back to all_combinations_of_multivalues_in_list_impl
all_combinations_of_multivalues_in_list_impl_passthrough_or_recur = function(state, new_nested_iterator_index, ...)
if new_nested_iterator_index == nil then
state[3], state[4] = nil, nil
return all_combinations_of_multivalues_in_list_impl(state)
else
return new_nested_iterator_index, ...
end
end
-- iterates over every entry in multivalue_entries_array,
-- returns elements without list values directly,
-- returns all combinations of elements with list values (via util.combinatorial_iterator_one_from_each_sublist)
function util.all_combinations_of_multivalues_in_list(multivalue_entries_array)
local state = {multivalue_entries_array, 0}
return all_combinations_of_multivalues_in_list_impl, state
end
function util.hash_params(params)
return md5(util.new_compat_serialize(params))
end
-- path operations
-- note: this is a conservative approach that reports both
-- paths that would be UNIX-absolute and paths that would be Windows-absolute,
-- regardless of what system you're on, which may help with stuff like Wine and MSYS-shells
function util.path_is_absolute(path)
return util.string_starts_with(path, "/") -- absolute UNIX path
or string.match(path, "^%w:[/\\]") -- absolute Windows path
end
function util.get_last_path_segment(path)
return string.match(path, "([^/%\\]+)[/%\\]*$")
end
-- Lua stuff (for launching subprocesses):
-- splits a path template string (used in package.path and package.searchpath) into its elements
-- and returns a list of indices of elements that are relative paths;
-- caches its results
local split_path_template_string__cache = new_weakly_keyed_table()
local relative_path_template_element_indices__cache = new_weakly_keyed_table()
local split_path_template_string_and_collect_relative_path_element_indices = function(path_template_string)
local split_path_template_string = split_path_template_string__cache[path_template_string]
if not split_path_template_string then
split_path_template_string = util.string_split(path_template_string, ";")
split_path_template_string__cache[path_template_string] = split_path_template_string
end
local relative_path_template_element_indices = relative_path_template_element_indices__cache[path_template_string]
if not relative_path_template_element_indices then
relative_path_template_element_indices = {}
for i = 1, #split_path_template_string do
local e = split_path_template_string[i]
if #e > 0 and not util.path_is_absolute(e) then
relative_path_template_element_indices[#relative_path_template_element_indices+1] = i
end
end
relative_path_template_element_indices__cache[path_template_string] = relative_path_template_element_indices
end
return split_path_template_string, relative_path_template_element_indices
end
-- return the given path template string, with all relative paths prefixed by the given prefix
function util.prefix_relative_path_templates_in_string(path_template_string, relative_path_prefix)
local elements, relative_element_indices = split_path_template_string_and_collect_relative_path_element_indices(path_template_string)
elements = util.list_copy_shallow(elements)
for i = 1, #relative_element_indices do
local element_index = relative_element_indices[i]
elements[element_index] = relative_path_prefix..elements[element_index]
end
return table.concat(elements, ";")
end
-- return only the relative paths from the given path template string, prefixed by the given prefix
function util.prefixed_only_relative_path_templates_in_string(path_template_string, relative_path_prefix)
local elements, relative_element_indices = split_path_template_string_and_collect_relative_path_element_indices(path_template_string)
local relative_elements = {}
for i = 1, #relative_element_indices do
local element_index = relative_element_indices[i]
relative_elements[#relative_elements+1] = relative_path_prefix..elements[element_index]
end
return table.concat(relative_elements, ";")
end
local cached_lua_program
install_delayed_impl_selector(util, 'get_lua_program', {
function() return _G.benmet_get_lua_program_command end, function() return _G.benmet_get_lua_program_command() end,
function()
cached_lua_program = util.find_program_cached("lua53")
or util.find_program_cached("lua5.3")
or util.find_program_cached("lua")
return cached_lua_program
end, function() return cached_lua_program end,
})
-- scripts loaded from files via loadfile, additionally wrapped in select(2, assert(xpcall(_, debug_traceback, ...))),
-- associated by keys from indirection_layer.path_to_cache_key of the script file path
local loadfile_cache = {}
-- execute the lua script located at path as if it were launched as a subprocess
-- with args_list as unquoted arguments, at at_relative_path relative to the script's path,
-- with environment variables overrides provided in new_os_env_override_table;
-- returns the same values as the util.execute_command-family of functions:
-- success (boolean), exit type (always 'exit'), return status (0 if successful, 1 otherwise), program output (string),
-- and as an additional fifth value (not available from util.execute_command) an error trace (or loading error message) in case of failure
function util.execute_lua_script_as_if_program(path, args_list, at_relative_path, new_os_env_override_table)
assert(path, "no lua script path given to util.execute_lua_script_as_if_program")
assert(not (at_relative_path and _G.benmet_disable_indirection_layer))
at_relative_path = at_relative_path and (
(string.sub(at_relative_path, -1) == "/" or string.sub(at_relative_path, -1) == "\\") and at_relative_path
or at_relative_path.."/")
util.logprint("executing Lua script \""..path.."\" as program "..(at_relative_path and "at relative path \""..at_relative_path.."\" " or "").."with args: '"..table.concat(args_list, "' '").."'")
incdl()
-- look for the loaded script in cache
local indirection_layer = require "benmet.indirection_layer"
local cache_key = indirection_layer.path_to_cache_key(path)
local loaded_script = loadfile_cache[cache_key]
if not loaded_script then
-- load the script in the global environmnent
local loading_error
loaded_script, loading_error = loadfile(path)
if not loaded_script then
util.debugprint("failed to load the script: "..loading_error)
decdl()
return false, 'exit', 1, "", loading_error
end
local unprotected = loaded_script
local select, assert, xpcall, debug_traceback = select, assert, xpcall, debug.traceback
loaded_script = function(...) return select(2, assert(xpcall(unprotected, debug_traceback, ...))) end
loadfile_cache[cache_key] = loaded_script
end
-- simulate changing to the requested working directory, back up the global environment and replace the arg table
indirection_stack_key = indirection_layer.increase_stack(at_relative_path, args_list, new_os_env_override_table)
-- temporarily clear our env override table in util
local prev_env_override_table = env_override_table
env_override_table = {}
env_override_string = ""
-- copy find_program cache
local prev_find_program_cache = table_copy_shallow(find_program_cache)
-- replace os.exit with coroutine.yield, the script is run as a coroutine so we can stop execution upon this being called
_G.benmet_ensure_package_path_entries_are_absolute()
os.exit = coroutine.yield
-- disable debug details for nested execution
local prev_ddl = util.debug_detail_level
local prev_dl = detail_level
util.debug_detail_level = 0
detail_level = 0
-- run the script as a coroutine, so we can stop its execution upon call to its os.exit (-> coroutine.yield)
local script_coroutine = coroutine.create(loaded_script)
local successful, return_code_or_run_error = coroutine.resume(script_coroutine, (table.unpack or unpack)(args_list))
-- re-enable debug details
util.debug_detail_level = prev_ddl
detail_level = prev_dl
-- restore find_program cache, in case the script did something with PATH
find_program_cache = prev_find_program_cache
-- restore our previous env override table in util
env_override_table = prev_env_override_table
env_override_string = env_override_string_from_table(prev_env_override_table)
-- revert working directory, io and global state
-- read script output
local script_output = indirection_layer.decrease_stack(indirection_stack_key)
util.debugprint("the script wrote: "..script_output)
if not successful then
util.debugprint("the script errored: "..return_code_or_run_error)
-- authentic behaviour would be to also call
-- io.stderr:write(return_code_or_run_error)
-- but additionally installing debug.traceback as error handler beforehand (in a wrapper function, or however else you do that on a coroutine)
decdl()
return false, 'exit', 1, script_output, return_code_or_run_error
end
if coroutine.status(script_coroutine) ~= 'suspended' then -- the script's body finished, report success (we explicitly ignore any value it returned)
util.debugprint("the script finished execution")
util.debugprint("it succeeded by 'exit' with status 0")
decdl()
return script_success, 'exit', return_status, script_output
end
-- it called its os.exit (-> coroutine.yield), report the return code
util.debugprint("the script called os.exit")
local script_success, return_status
if type(return_code_or_run_error) == 'boolean' then
script_success = return_code_or_run_error
return_status = script_success and 0 or 1
elseif type(return_code_or_run_error) == 'number' then
return_status = return_code_or_run_error
script_success = return_status == 0
else
script_success = true
return_status = 0
end
util.debugprint("it "..(script_success and "succeeded" or "failed").." by 'exit' with status "..return_status)
decdl()
return script_success, 'exit', return_status, script_output
end
-- file system
install_delayed_impl_selector(util, 'get_current_directory', {
function() return util.find_program_cached("pwd") end, function()
incdl()
local program_success, exit_type, return_status, program_output = assert(util.execute_command("pwd"))
decdl()
return util.cut_trailing_space(program_output)
end,
--[[CD is not a program]] true, function()
incdl()
local program_success, exit_type, return_status, program_output = assert(util.execute_command("CD"))
decdl()
return util.cut_trailing_space(program_output)