markdown_exec 3.2.0 → 3.3.0

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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/Gemfile.lock +1 -1
  4. data/Rakefile +3 -3
  5. data/bats/block-type-ux-auto.bats +1 -1
  6. data/bats/block-type-ux-default.bats +1 -1
  7. data/bats/block-type-ux-echo-hash-transform.bats +1 -1
  8. data/bats/block-type-ux-echo-hash.bats +2 -2
  9. data/bats/block-type-ux-exec-hash-transform.bats +8 -0
  10. data/bats/block-type-ux-exec-hash.bats +15 -0
  11. data/bats/block-type-ux-exec.bats +1 -1
  12. data/bats/block-type-ux-force.bats +9 -0
  13. data/bats/block-type-ux-formats.bats +8 -0
  14. data/bats/block-type-ux-readonly.bats +1 -1
  15. data/bats/block-type-ux-row-format.bats +1 -1
  16. data/bats/block-type-ux-transform.bats +1 -1
  17. data/bats/import-directive-parameter-symbols.bats +9 -0
  18. data/bats/import-duplicates.bats +4 -2
  19. data/bats/import-parameter-symbols.bats +8 -0
  20. data/bats/markup.bats +1 -1
  21. data/bats/options.bats +1 -1
  22. data/bin/tab_completion.sh +5 -1
  23. data/docs/dev/block-type-ux-echo-hash-transform.md +14 -12
  24. data/docs/dev/block-type-ux-exec-hash-transform.md +37 -0
  25. data/docs/dev/block-type-ux-exec-hash.md +93 -0
  26. data/docs/dev/block-type-ux-force.md +20 -0
  27. data/docs/dev/block-type-ux-formats.md +58 -0
  28. data/docs/dev/hexdump_format.md +267 -0
  29. data/docs/dev/import/parameter-symbols.md +6 -0
  30. data/docs/dev/import-directive-parameter-symbols.md +9 -0
  31. data/docs/dev/import-parameter-symbols-template.md +24 -0
  32. data/docs/dev/import-parameter-symbols.md +6 -0
  33. data/docs/dev/load-vars-state-demo.md +35 -0
  34. data/docs/ux-blocks-examples.md +2 -3
  35. data/examples/import_with_substitution_demo.md +130 -26
  36. data/examples/imports/organism_template.md +86 -29
  37. data/lib/cached_nested_file_reader.rb +265 -27
  38. data/lib/constants.rb +8 -1
  39. data/lib/env_interface.rb +13 -7
  40. data/lib/evaluate_shell_expressions.rb +1 -0
  41. data/lib/fcb.rb +120 -28
  42. data/lib/format_table.rb +56 -23
  43. data/lib/fout.rb +5 -0
  44. data/lib/hash_delegator.rb +1158 -347
  45. data/lib/markdown_exec/version.rb +1 -1
  46. data/lib/markdown_exec.rb +2 -0
  47. data/lib/mdoc.rb +13 -11
  48. data/lib/menu.src.yml +139 -34
  49. data/lib/menu.yml +116 -32
  50. data/lib/string_util.rb +80 -0
  51. data/lib/table_extractor.rb +170 -64
  52. data/lib/ww.rb +325 -29
  53. metadata +18 -2
@@ -9,6 +9,7 @@ require 'fileutils'
9
9
  require_relative 'constants'
10
10
  require_relative 'exceptions'
11
11
  require_relative 'find_files'
12
+ require_relative 'ww'
12
13
 
13
14
  ##
14
15
  # The CachedNestedFileReader class provides functionality to read file
@@ -22,9 +23,29 @@ require_relative 'find_files'
22
23
  class CachedNestedFileReader
23
24
  include Exceptions
24
25
 
25
- def initialize(import_pattern: /^ *#import (.+)$/)
26
+ def initialize(
27
+ import_directive_line_pattern:,
28
+ import_directive_parameter_scan:,
29
+ import_parameter_variable_assignment:,
30
+ shell:,
31
+ shell_block_name:,
32
+ symbol_command_substitution:,
33
+ symbol_evaluated_expression:,
34
+ symbol_force_quoted_literal:,
35
+ symbol_raw_literal:,
36
+ symbol_variable_reference:
37
+ )
26
38
  @file_cache = {}
27
- @import_pattern = import_pattern
39
+ @import_directive_line_pattern = import_directive_line_pattern
40
+ @import_directive_parameter_scan = import_directive_parameter_scan
41
+ @import_parameter_variable_assignment = import_parameter_variable_assignment
42
+ @shell = shell
43
+ @shell_block_name = shell_block_name
44
+ @symbol_command_substitution = symbol_command_substitution
45
+ @symbol_evaluated_expression = symbol_evaluated_expression
46
+ @symbol_force_quoted_literal = symbol_force_quoted_literal
47
+ @symbol_raw_literal = symbol_raw_literal
48
+ @symbol_variable_reference = symbol_variable_reference
28
49
  end
29
50
 
30
51
  def error_handler(name = '', opts = {})
@@ -45,25 +66,56 @@ class CachedNestedFileReader
45
66
  # return the processed lines
46
67
  def readlines(
47
68
  filename, depth = 0, context: '', import_paths: nil,
48
- indention: '', substitutions: {}, use_template_delimiters: false, &block
69
+ indention: '', substitutions: {}, use_template_delimiters: false,
70
+ clear_cache: true,
71
+ read_cache: false,
72
+ &block
49
73
  )
74
+ # clear cache if requested
75
+ @file_cache.clear if clear_cache
76
+
50
77
  cache_key = build_cache_key(filename, substitutions)
51
78
  if @file_cache.key?(cache_key)
79
+ return ["# dup #{cache_key}"] unless read_cache
80
+
52
81
  @file_cache[cache_key].each(&block) if block
53
82
  return @file_cache[cache_key]
83
+
84
+ # do not return duplicates per filename and substitutions
85
+ # return an indicator that the file was already read
86
+
54
87
  end
55
88
  raise Errno::ENOENT, filename unless filename
56
89
 
57
90
  directory_path = File.dirname(filename)
58
91
  processed_lines = []
59
92
  File.readlines(filename, chomp: true).each.with_index do |line, ind|
60
- if Regexp.new(@import_pattern) =~ line
93
+ wwt :readline, 'depth:', depth, 'filename:', filename, 'ind:', ind,
94
+ 'line:', line
95
+ if Regexp.new(@import_directive_line_pattern) =~ line
61
96
  name_strip = $~[:name].strip
62
97
  params_string = $~[:params] || ''
63
98
  import_indention = indention + $~[:indention]
64
99
 
65
100
  # Parse parameters for text substitution
66
- import_substitutions = parse_import_params(params_string)
101
+ import_substitutions, add_code = parse_import_params(params_string)
102
+ if add_code
103
+ # strings as NestedLines
104
+ add_lines = add_code.map.with_index do |line, ind2|
105
+ nested_line = NestedLine.new(
106
+ line,
107
+ depth + 1,
108
+ import_indention,
109
+ filename,
110
+ ind2
111
+ )
112
+ block&.call(nested_line)
113
+
114
+ nested_line
115
+ end
116
+ ww 'add_lines:', add_lines
117
+ processed_lines += add_lines
118
+ end
67
119
  merged_substitutions = substitutions.merge(import_substitutions)
68
120
 
69
121
  included_file_path =
@@ -78,15 +130,28 @@ class CachedNestedFileReader
78
130
 
79
131
  raise Errno::ENOENT, name_strip unless included_file_path
80
132
 
81
- imported_lines = readlines(
82
- included_file_path, depth + 1,
83
- context: "#{filename}:#{ind + 1}",
84
- import_paths: import_paths,
85
- indention: import_indention,
86
- substitutions: merged_substitutions,
87
- use_template_delimiters: use_template_delimiters,
88
- &block
89
- )
133
+ # Create a cache key for the imported file that includes both filename and parameters
134
+ imported_cache_key = build_import_cache_key(included_file_path,
135
+ name_strip, params_string, merged_substitutions)
136
+
137
+ # Check if we've already loaded this specific import
138
+ if @file_cache.key?(imported_cache_key)
139
+ imported_lines = @file_cache[imported_cache_key]
140
+ else
141
+ imported_lines = readlines(
142
+ included_file_path, depth + 1,
143
+ context: "#{filename}:#{ind + 1}",
144
+ import_paths: import_paths,
145
+ indention: import_indention,
146
+ substitutions: merged_substitutions,
147
+ use_template_delimiters: use_template_delimiters,
148
+ clear_cache: false,
149
+ &block
150
+ )
151
+
152
+ # Cache the imported lines with the specific import cache key
153
+ @file_cache[imported_cache_key] = imported_lines
154
+ end
90
155
 
91
156
  # Apply text substitutions to imported content
92
157
  processed_imported_lines = apply_substitutions(
@@ -105,6 +170,7 @@ class CachedNestedFileReader
105
170
  end
106
171
  end
107
172
 
173
+ wwt :read_document_code, 'processed_lines:', processed_lines
108
174
  @file_cache[cache_key] = processed_lines
109
175
  rescue Errno::ENOENT => err
110
176
  warn_format('readlines', "#{err} @@ #{context}",
@@ -113,21 +179,91 @@ class CachedNestedFileReader
113
179
 
114
180
  private
115
181
 
182
+ def shell_code_block_for_assignment(key, expression)
183
+ ["```#{@shell} :#{@shell_block_name}",
184
+ format(@import_parameter_variable_assignment,
185
+ { key: key, value: expression }),
186
+ '```'].tap { wwr _1 }
187
+ end
188
+
116
189
  # Parse key=value parameters from the import line
117
190
  def parse_import_params(params_string)
118
191
  return {} if params_string.nil? || params_string.strip.empty?
119
192
 
193
+ add_code = []
120
194
  params = {}
121
- # Match key=value pairs, handling quoted values
122
- params_string.scan(
123
- /([A-Za-z_]\w*)=(?:"([^"]*)"|'([^']*)'|(\S+))/
124
- ) do |key, quoted_double, quoted_single, unquoted|
195
+
196
+ # First loop: store all scanned parameters in a temporary variable
197
+ scanned_params = []
198
+ params_string.scan(@import_directive_parameter_scan) do |key, op, quoted_double, quoted_single, unquoted|
125
199
  value = quoted_double || quoted_single || unquoted
126
- # skip replacement of equal values
127
- # otherwise, the text is not available for other substitutions
128
- params[key] = value if key != value
200
+ scanned_params << [key, op, value]
129
201
  end
130
- params
202
+ wwt 'scanned_params:', scanned_params
203
+
204
+ # Preceding loop: select items where op = '='
205
+ equal_op_params = scanned_params.select { |_key, op, _value| op == '=' }
206
+ wwt 'equal_op_params:', equal_op_params
207
+ # Process remaining parameters (non-equal operators)
208
+ scanned_params.each do |key, op, value|
209
+ wwt :import, 'key:', key, 'op:', op, 'value:', value
210
+
211
+ # adjust key for current stem value
212
+ varname = key
213
+ case op
214
+ when @symbol_command_substitution,
215
+ @symbol_evaluated_expression,
216
+ @symbol_force_quoted_literal,
217
+ @symbol_variable_reference
218
+ # perform all substitutions per equal_op_params
219
+ equal_op_params.each do |equal_key, _equal_op, equal_value|
220
+ varname = varname.gsub(equal_key, equal_value)
221
+ end
222
+ wwt :import_key, 'varname:', varname
223
+ end
224
+
225
+ case op
226
+ when @symbol_raw_literal
227
+ # skip replacement of equal values otherwise,
228
+ # the text is not available for other substitutions
229
+ next unless key != value
230
+
231
+ # replace the literal below
232
+ when @symbol_command_substitution
233
+ # add code to set the variable to the value of the parameter
234
+ add_code += shell_code_block_for_assignment(
235
+ varname, %($(#{value}))
236
+ )
237
+ # replace key with expansion of the added variable
238
+ value = "${#{varname}}"
239
+ when @symbol_evaluated_expression
240
+ # add code to set the variable to the value of the parameter
241
+ add_code += shell_code_block_for_assignment(
242
+ varname, %("#{value}")
243
+ )
244
+ # replace key with expansion of the added variable
245
+ value = "${#{varname}}"
246
+ when @symbol_force_quoted_literal
247
+ # add code to set the variable to the value of the parameter
248
+ add_code += shell_code_block_for_assignment(
249
+ varname, Shellwords.escape(value)
250
+ )
251
+ # replace key with expansion of the added variable
252
+ value = "${#{varname}}"
253
+ when @symbol_variable_reference
254
+ # variable exists
255
+ value = "${#{value}}"
256
+ else
257
+ wwe "Invalid op '#{op}'"
258
+ end
259
+
260
+ params[key] = value
261
+ end
262
+ [params, add_code].tap do
263
+ wwr 'params:', _1[0], 'add_code:', _1[1]
264
+ end
265
+ rescue StandardError
266
+ wwe $!
131
267
  end
132
268
 
133
269
  # Apply text substitutions to a collection of NestedLine objects
@@ -155,8 +291,10 @@ class CachedNestedFileReader
155
291
  if use_template_delimiters
156
292
  # Replace template-style placeholders: ${KEY} or {{KEY}}
157
293
  substitutions.each do |key, value|
158
- substituted_line = substituted_line.gsub(/\$\{#{Regexp.escape(key)}\}/,
159
- value)
294
+ substituted_line = substituted_line.gsub(
295
+ /\$\{#{Regexp.escape(key)}\}/,
296
+ value
297
+ )
160
298
  substituted_line = substituted_line.gsub(
161
299
  /\{\{#{Regexp.escape(key)}\}\}/, value
162
300
  )
@@ -168,7 +306,6 @@ class CachedNestedFileReader
168
306
  # Replace each key with a unique temporary placeholder
169
307
  substitutions.each_with_index do |(key, value), index|
170
308
  temp_placeholder = "__MDE_TEMP_#{index}__"
171
- # pattern = /\b#{Regexp.escape(key)}\b/
172
309
  pattern = Regexp.new(Regexp.escape(key))
173
310
  substituted_line = substituted_line.gsub(pattern, temp_placeholder)
174
311
  temp_placeholders[temp_placeholder] = value
@@ -190,6 +327,13 @@ class CachedNestedFileReader
190
327
  substitution_hash = substitutions.sort.to_h.hash
191
328
  "#{filename}##{substitution_hash}"
192
329
  end
330
+
331
+ # Build a cache key specifically for imported files
332
+ def build_import_cache_key(filename, name_strip, params_string, substitutions)
333
+ # Sort parameters for consistent key
334
+ sorted_params = substitutions.sort.to_h.hash
335
+ "#{filename}##{name_strip}##{params_string}##{sorted_params}"
336
+ end
193
337
  end
194
338
 
195
339
  return if $PROGRAM_NAME != __FILE__
@@ -207,7 +351,18 @@ class CachedNestedFileReaderTest < Minitest::Test
207
351
  @file1.write("Line1\nLine2\n @import #{@file2.path}\nLine3")
208
352
  @file1.rewind
209
353
  @reader = CachedNestedFileReader.new(
210
- import_pattern: /^(?<indention> *)@import +(?<name>\S+)(?<params>(?: +[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S+))*) *$/
354
+ import_directive_line_pattern:
355
+ /^(?<indention> *)@import +(?<name>\S+)(?<params>(?: +[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S+))*) *$/,
356
+ import_directive_parameter_scan:
357
+ /([A-Za-z_]\w*)(:=|\?=|!=|=)(?:"([^"]*)"|'([^']*)'|(\S+))/,
358
+ import_parameter_variable_assignment: '%{key}=%{value}',
359
+ shell: 'bash',
360
+ shell_block_name: '(document_shell)',
361
+ symbol_command_substitution: ':c=',
362
+ symbol_evaluated_expression: ':e=',
363
+ symbol_raw_literal: '=',
364
+ symbol_force_quoted_literal: ':q=',
365
+ symbol_variable_reference: ':v='
211
366
  )
212
367
  end
213
368
 
@@ -278,9 +433,92 @@ class CachedNestedFileReaderTest < Minitest::Test
278
433
  @file2.reopen(@file2.path, 'w') { |f| f.write('ChangedLine') }
279
434
 
280
435
  # Second read (should read from cache, not the changed file)
281
- result2 = @reader.readlines(@file2.path).map(&:to_s)
436
+ result2 = @reader.readlines(@file2.path, clear_cache: false,
437
+ read_cache: true).map(&:to_s)
282
438
 
283
439
  assert_equal result1, result2
284
440
  assert_equal %w[ImportedLine1 ImportedLine2], result2
285
441
  end
442
+
443
+ def test_import_caching_with_same_parameters
444
+ # Create a file that will be imported multiple times
445
+ shared_file = Tempfile.new('shared.txt')
446
+ shared_file.write("Shared content line 1\nShared content line 2")
447
+ shared_file.rewind
448
+
449
+ # Create a file that imports the same file multiple times with same parameters
450
+ importing_file = Tempfile.new('importing_multiple.txt')
451
+ importing_file.write("Start\n @import #{shared_file.path} PARAM=value\nMiddle\n @import #{shared_file.path} PARAM=value\nEnd")
452
+ importing_file.rewind
453
+
454
+ # Track how many times the shared file is actually read
455
+ read_count = 0
456
+ original_readlines = File.method(:readlines)
457
+ File.define_singleton_method(:readlines) do |filename, **opts|
458
+ if filename == shared_file.path
459
+ read_count += 1
460
+ end
461
+ original_readlines.call(filename, **opts)
462
+ end
463
+
464
+ result = @reader.readlines(importing_file.path).map(&:to_s)
465
+
466
+ # The shared file should only be read once, not twice
467
+ assert_equal 1, read_count,
468
+ 'Shared file should only be read once when imported with same parameters'
469
+
470
+ # Verify the content is correct
471
+ expected = ['Start', ' Shared content line 1', ' Shared content line 2',
472
+ 'Middle', ' Shared content line 1', ' Shared content line 2', 'End']
473
+ assert_equal expected, result
474
+
475
+ # Restore original method
476
+ File.define_singleton_method(:readlines, original_readlines)
477
+
478
+ shared_file.close
479
+ shared_file.unlink
480
+ importing_file.close
481
+ importing_file.unlink
482
+ end
483
+
484
+ def test_import_caching_with_different_parameters
485
+ # Create a file that will be imported with different parameters
486
+ template_file = Tempfile.new('template.txt')
487
+ template_file.write('Hello NAME, your ID is ID')
488
+ template_file.rewind
489
+
490
+ # Create a file that imports the same file with different parameters
491
+ importing_file = Tempfile.new('importing_different.txt')
492
+ importing_file.write("Users:\n @import #{template_file.path} NAME=Alice ID=123\n @import #{template_file.path} NAME=Bob ID=456\nEnd")
493
+ importing_file.rewind
494
+
495
+ # Track how many times the template file is actually read
496
+ read_count = 0
497
+ original_readlines = File.method(:readlines)
498
+ File.define_singleton_method(:readlines) do |filename, **opts|
499
+ if filename == template_file.path
500
+ read_count += 1
501
+ end
502
+ original_readlines.call(filename, **opts)
503
+ end
504
+
505
+ result = @reader.readlines(importing_file.path).map(&:to_s)
506
+
507
+ # The template file should be read twice since parameters are different
508
+ assert_equal 2, read_count,
509
+ 'Template file should be read twice when imported with different parameters'
510
+
511
+ # Verify the content is correct
512
+ expected = ['Users:', ' Hello Alice, your 123 is 123',
513
+ ' Hello Bob, your 456 is 456', 'End']
514
+ assert_equal expected, result
515
+
516
+ # Restore original method
517
+ File.define_singleton_method(:readlines, original_readlines)
518
+
519
+ template_file.close
520
+ template_file.unlink
521
+ importing_file.close
522
+ importing_file.unlink
523
+ end
286
524
  end
data/lib/constants.rb CHANGED
@@ -27,7 +27,14 @@ BLOCK_TYPE_COLOR_OPTIONS = {
27
27
  BlockType::OPTS => :menu_opts_color,
28
28
  BlockType::SAVE => :menu_save_color,
29
29
  BlockType::SHELL => :menu_bash_color,
30
- BlockType::UX => :menu_ux_color,
30
+ BlockType::UX => {
31
+ :is_allow? => :menu_ux_color_allow,
32
+ :is_echo? => :menu_ux_color_echo,
33
+ :is_edit? => :menu_ux_color_edit,
34
+ :is_exec? => :menu_ux_color_exec,
35
+ :readonly => :menu_ux_color_readonly,
36
+ true => :menu_ux_color
37
+ },
31
38
  BlockType::VARS => :menu_vars_color
32
39
  }.freeze
33
40
 
data/lib/env_interface.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require_relative 'ww'
3
4
 
4
5
  # A class that provides an interface to ENV variables with customizable getters and setters
@@ -10,11 +11,15 @@ class EnvInterface
10
11
  # @param transform [Proc] Optional transformation to apply to the value
11
12
  # @return [Object] The environment variable value
12
13
  def get(key, default: nil, transform: nil)
13
- wwt :env, key, \
14
- value = ENV[key]
15
- return default if value.nil?
14
+ value = ENV.fetch(key, nil)
16
15
 
17
- transform ? transform.call(value) : value
16
+ if value.nil?
17
+ default
18
+ else
19
+ transform ? transform.call(value) : value
20
+ end.tap do
21
+ wwt :env, key, _1
22
+ end
18
23
  end
19
24
 
20
25
  # Set an environment variable with optional transformation
@@ -23,9 +28,10 @@ wwt :env, key, \
23
28
  # @param transform [Proc] Optional transformation to apply before setting
24
29
  # @return [String] The set value
25
30
  def set(key, value, transform: nil)
26
- wwt :env, key, caller.deref[0..3], value
27
31
  transformed_value = transform ? transform.call(value) : value
28
- ENV[key] = transformed_value.to_s
32
+ ENV[key] = transformed_value.to_s.tap do
33
+ wwt :env, key, _1
34
+ end
29
35
  end
30
36
 
31
37
  # Check if an environment variable exists
@@ -54,4 +60,4 @@ wwt :env, key, caller.deref[0..3], value
54
60
  ENV.clear
55
61
  end
56
62
  end
57
- end
63
+ end
@@ -25,6 +25,7 @@ def evaluate_shell_expressions(initial_code, expressions, shell: '/bin/bash',
25
25
  script << "\necho #{token}#{index}\n"
26
26
  script << expression << "\n"
27
27
  end
28
+ wwt :eval, 'script:', script
28
29
 
29
30
  # Execute
30
31
  stdout_str, _, status = Open3.capture3(shell, '-c', script)