lmt 0.1.2
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 +7 -0
- data/.gitignore +7 -0
- data/.markdownlint.json +7 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +35 -0
- data/README.md +73 -0
- data/Rakefile +75 -0
- data/bin/console +25 -0
- data/bin/lmt +6 -0
- data/bin/lmw +5 -0
- data/bin/setup +8 -0
- data/doc/lmt/error_reporting.md +15 -0
- data/doc/lmt/lmt.rb.md +742 -0
- data/doc/lmt/lmt_expressions.md +33 -0
- data/doc/lmt/lmt_include.md +9 -0
- data/doc/lmt/lmw.rb.md +396 -0
- data/doc/lmt/option_verification.md +20 -0
- data/lib/lmt.rb +4 -0
- data/lib/lmt/lmt.rb +277 -0
- data/lib/lmt/lmw.rb +190 -0
- data/lib/lmt/version.rb +3 -0
- data/lmt.gemspec +30 -0
- data/src/lmt/error_reporting.lmd +13 -0
- data/src/lmt/lmt.rb.lmd +652 -0
- data/src/lmt/lmt_expressions.lmd +27 -0
- data/src/lmt/lmt_include.lmd +7 -0
- data/src/lmt/lmw.rb.lmd +358 -0
- data/src/lmt/option_verification.lmd +18 -0
- metadata +156 -0
data/lib/lmt.rb
ADDED
data/lib/lmt/lmt.rb
ADDED
@@ -0,0 +1,277 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# Encoding: utf-8
|
3
|
+
|
4
|
+
require 'optparse'
|
5
|
+
require 'methadone'
|
6
|
+
require 'lmt/version'
|
7
|
+
|
8
|
+
module Lmt
|
9
|
+
|
10
|
+
class Tangle
|
11
|
+
include Methadone::Main
|
12
|
+
include Methadone::CLILogging
|
13
|
+
|
14
|
+
@dev = false
|
15
|
+
|
16
|
+
main do
|
17
|
+
check_arguments()
|
18
|
+
begin
|
19
|
+
self_test()
|
20
|
+
tangler = Tangle::Tangler.new(options[:file])
|
21
|
+
tangler.tangle()
|
22
|
+
tangler.write(options[:output])
|
23
|
+
rescue Exception => e
|
24
|
+
puts "Error: #{e.message} #{extract_causes(e)}At:"
|
25
|
+
e.backtrace.each do |trace|
|
26
|
+
puts " #{trace}"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.extract_causes(error)
|
32
|
+
if (error.cause)
|
33
|
+
" Caused by: #{error.cause.message}\n#{extract_causes(error.cause)}"
|
34
|
+
else
|
35
|
+
""
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.self_test()
|
40
|
+
|
41
|
+
block_replacement = true
|
42
|
+
replaced_block = false
|
43
|
+
block_appendment = false
|
44
|
+
|
45
|
+
# this is the replacement
|
46
|
+
replaced_block = true
|
47
|
+
# Yay appended code gets injected
|
48
|
+
block_appendment = true
|
49
|
+
insertion_works_with_spaces = false
|
50
|
+
insertion_works_with_spaces = true
|
51
|
+
escaped_string = '⦅macro_description⦆'
|
52
|
+
# These require the code in the macro to work.
|
53
|
+
report_self_test_failure("block replacement doesn't work") unless block_replacement and replaced_block
|
54
|
+
report_self_test_failure("appending to macros doesn't work") unless block_appendment
|
55
|
+
report_self_test_failure("insertion must support spaces") unless insertion_works_with_spaces
|
56
|
+
report_self_test_failure("double parentheses may be escaped") unless escaped_string[0] != '\\'
|
57
|
+
two_macros = "foo foo"
|
58
|
+
report_self_test_failure("Should be able to place two macros on the same line") unless two_macros == "foo foo"
|
59
|
+
string_with_backslash = "this string ends in \\."
|
60
|
+
report_self_test_failure("ruby escape doesn't escape backslash") unless string_with_backslash =~ /\\.?/
|
61
|
+
included_string = "I came from lmt_include.lmd"
|
62
|
+
report_self_test_failure("included replacements should replace blocks") unless included_string == "I came from lmt_include.lmd"
|
63
|
+
|
64
|
+
end
|
65
|
+
|
66
|
+
def self.report_self_test_failure(message)
|
67
|
+
if @dev
|
68
|
+
p message
|
69
|
+
else
|
70
|
+
throw message
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
class Filter
|
75
|
+
def initialize(&block)
|
76
|
+
@code = block;
|
77
|
+
end
|
78
|
+
|
79
|
+
def filter(lines)
|
80
|
+
@code.call(lines)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
class LineFilter < Filter
|
84
|
+
def filter(lines)
|
85
|
+
lines.map do |line|
|
86
|
+
@code.call(line)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
class Tangler
|
92
|
+
class << self
|
93
|
+
attr_reader :filters
|
94
|
+
end
|
95
|
+
|
96
|
+
@filters = {
|
97
|
+
'ruby_escape' => LineFilter.new do |line|
|
98
|
+
line.dump[1..-2]
|
99
|
+
end
|
100
|
+
}
|
101
|
+
|
102
|
+
def initialize(input)
|
103
|
+
@input = input
|
104
|
+
@block = ""
|
105
|
+
@blocks = {}
|
106
|
+
@tangled = false
|
107
|
+
end
|
108
|
+
|
109
|
+
def tangle()
|
110
|
+
contents = include_includes(read_file(@input))
|
111
|
+
@block, @blocks = parse_blocks(contents)
|
112
|
+
if @block
|
113
|
+
@block = expand_macros(@block)
|
114
|
+
@block = unescape_double_parens(@block)
|
115
|
+
end
|
116
|
+
@tangled = true
|
117
|
+
end
|
118
|
+
|
119
|
+
def read_file(file)
|
120
|
+
File.open(file, 'r') do |f|
|
121
|
+
f.readlines
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
def include_includes(lines, current_file = @input, depth = 0)
|
126
|
+
raise "too many includes" if depth > 1000
|
127
|
+
include_exp = /^!\s+include\s+\[.*\]\((.*)\)\s*$/
|
128
|
+
lines.map do |line|
|
129
|
+
match = include_exp.match(line)
|
130
|
+
if match
|
131
|
+
file = File.dirname(current_file) + '/' + match[1]
|
132
|
+
include_includes(read_file(file), file, depth + 1)
|
133
|
+
else
|
134
|
+
[line]
|
135
|
+
end
|
136
|
+
end.flatten(1)
|
137
|
+
end
|
138
|
+
|
139
|
+
def parse_blocks(lines)
|
140
|
+
code_block_exp = /^([s]*)``` ?([\w]*) ?(=?)([-\w]*)?/
|
141
|
+
in_block = false
|
142
|
+
blocks = lines.find_all do |line|
|
143
|
+
in_block = !in_block if line =~ code_block_exp
|
144
|
+
in_block
|
145
|
+
end.slice_before do |line|
|
146
|
+
code_block_exp =~ line
|
147
|
+
end.map do |(header, *rest)|
|
148
|
+
white_space, language, replacement_mark, name = code_block_exp.match(header)[1..-1]
|
149
|
+
[name, replacement_mark, rest]
|
150
|
+
end.group_by do |(name, _, _)|
|
151
|
+
name
|
152
|
+
end.transform_values do |bodies|
|
153
|
+
last_replacement_index = get_last_replacement_index(bodies)
|
154
|
+
bodies[last_replacement_index..-1].map { |(_, _, body)| body}
|
155
|
+
.flatten(1)
|
156
|
+
end.transform_values do |body_lines|
|
157
|
+
body_lines[-1] = body_lines[-1].chomp if body_lines[-1]
|
158
|
+
body_lines
|
159
|
+
end
|
160
|
+
throw "Missing code fence" if in_block
|
161
|
+
main = blocks[""]
|
162
|
+
blocks.delete("")
|
163
|
+
[main, blocks]
|
164
|
+
end
|
165
|
+
|
166
|
+
def expand_macros(lines, depth = 0)
|
167
|
+
throw "too deep macro expansion {depth}" if depth > 1000
|
168
|
+
lines.map do |line|
|
169
|
+
begin
|
170
|
+
expand_macro_on_line(line, depth)
|
171
|
+
rescue Exception => e
|
172
|
+
raise Exception, "Failed to process line: #{line}", e.backtrace
|
173
|
+
end
|
174
|
+
end.flatten(1)
|
175
|
+
end
|
176
|
+
|
177
|
+
def apply_filters(strings, filters)
|
178
|
+
filters.map do |filter_name|
|
179
|
+
Tangler.filters[filter_name]
|
180
|
+
end.inject(strings) do |strings, filter|
|
181
|
+
filter.filter(strings)
|
182
|
+
end
|
183
|
+
end
|
184
|
+
def unescape_double_parens(block)
|
185
|
+
block.map do |l|
|
186
|
+
l = l.gsub("\\⦅", "⦅")
|
187
|
+
l = l.gsub("\\⦆", "⦆")
|
188
|
+
l
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
def write(output)
|
193
|
+
tangle() unless @tangled
|
194
|
+
if @block
|
195
|
+
fout = File.open(output, 'w')
|
196
|
+
@block.each {|line| fout << line}
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
|
201
|
+
private
|
202
|
+
def get_last_replacement_index(bodies)
|
203
|
+
last_replacement = bodies.each_with_index
|
204
|
+
.select do |((_, replacement_mark, _), _)|
|
205
|
+
replacement_mark == '='
|
206
|
+
end[-1]
|
207
|
+
if last_replacement
|
208
|
+
last_replacement[1]
|
209
|
+
else
|
210
|
+
0
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
def expand_macro_on_line(line, depth)
|
215
|
+
white_space_exp = /^(\s*)(.*\n?)/
|
216
|
+
macro_substitution_exp = /(?<!\\)⦅ *([-\w | ]*) *⦆/
|
217
|
+
filter_extraction_exp = / *\| *([-\w]+) */
|
218
|
+
white_space, text = white_space_exp.match(line)[1..2]
|
219
|
+
section = text.split(macro_substitution_exp)
|
220
|
+
.each_slice(2)
|
221
|
+
.map do |(text_before_macro, macro_match)|
|
222
|
+
if (macro_match)
|
223
|
+
macro_name, *filters = macro_match.strip.split(filter_extraction_exp)
|
224
|
+
[text_before_macro, macro_name, filters.each_slice(2).map(&:first)]
|
225
|
+
else
|
226
|
+
[text_before_macro]
|
227
|
+
end
|
228
|
+
end.inject([white_space]) do
|
229
|
+
|(*new_lines, last_line), (text_before_macro, macro_name, filters)|
|
230
|
+
if macro_name.nil?
|
231
|
+
last_line = "" unless last_line
|
232
|
+
new_lines << last_line + text_before_macro
|
233
|
+
else
|
234
|
+
throw "Macro '#{macro_name}' unknown" unless @blocks[macro_name]
|
235
|
+
macro_lines = apply_filters(
|
236
|
+
expand_macros(@blocks[macro_name], depth + 1), filters)
|
237
|
+
unless macro_lines.empty?
|
238
|
+
new_line = last_line + text_before_macro + macro_lines[0]
|
239
|
+
macro_continued = macro_lines[1..-1].map do |macro_line|
|
240
|
+
white_space + macro_line
|
241
|
+
end
|
242
|
+
(new_lines << new_line) + macro_continued
|
243
|
+
else
|
244
|
+
new_lines
|
245
|
+
end
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
def self.required(*options)
|
252
|
+
@required_options = options
|
253
|
+
end
|
254
|
+
|
255
|
+
def self.check_arguments
|
256
|
+
missing = @required_options.select{ |p| options[p].nil?}
|
257
|
+
unless missing.empty?
|
258
|
+
message = "Missing Required Argument(s): #{missing.join(', ')}"
|
259
|
+
|
260
|
+
abort("#{message}\n\n#{opts.help()}")
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
description "A literate Markdown tangle tool written in Ruby."
|
265
|
+
on("--file FILE", "-f", "Required: input file")
|
266
|
+
on("--output FILE", "-o", "Required: output file")
|
267
|
+
on("--dev", "disables self test failure for development")
|
268
|
+
required(:file, :output)
|
269
|
+
|
270
|
+
version Lmt::VERSION
|
271
|
+
|
272
|
+
use_log_level_option :toggle_debug_on_signal => 'USR1'
|
273
|
+
|
274
|
+
go! if __FILE__ == $0
|
275
|
+
end
|
276
|
+
|
277
|
+
end
|
data/lib/lmt/lmw.rb
ADDED
@@ -0,0 +1,190 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# Encoding: utf-8
|
3
|
+
|
4
|
+
require 'optparse'
|
5
|
+
require 'methadone'
|
6
|
+
|
7
|
+
require 'pry'
|
8
|
+
|
9
|
+
module Lmt
|
10
|
+
|
11
|
+
class Lmw
|
12
|
+
include Methadone::Main
|
13
|
+
include Methadone::CLILogging
|
14
|
+
|
15
|
+
@dev = true
|
16
|
+
|
17
|
+
main do
|
18
|
+
check_arguments()
|
19
|
+
begin
|
20
|
+
self_test()
|
21
|
+
weave = Lmw::Weave.from_file(options[:file])
|
22
|
+
weave.weave()
|
23
|
+
weave.write(options[:output])
|
24
|
+
rescue Exception => e
|
25
|
+
puts "Error: #{e.message} #{extract_causes(e)}At:"
|
26
|
+
e.backtrace.each do |trace|
|
27
|
+
puts " #{trace}"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.extract_causes(error)
|
33
|
+
if (error.cause)
|
34
|
+
" Caused by: #{error.cause.message}\n#{extract_causes(error.cause)}"
|
35
|
+
else
|
36
|
+
""
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.self_test()
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.report_self_test_failure(message)
|
44
|
+
if @dev
|
45
|
+
p message
|
46
|
+
else
|
47
|
+
throw message
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
class Weave
|
52
|
+
class << self
|
53
|
+
def from_file(file)
|
54
|
+
File.open(file, 'r') do |f|
|
55
|
+
Weave.new(f.readlines, file)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
60
|
+
|
61
|
+
def initialize(lines, file_name = "")
|
62
|
+
@file_name = file_name
|
63
|
+
@lines = lines
|
64
|
+
@weaved = false
|
65
|
+
end
|
66
|
+
def weave()
|
67
|
+
@blocks = find_blocks(@lines)
|
68
|
+
@weaved_lines = substitute_directives_and_headers(
|
69
|
+
@lines.map do |line|
|
70
|
+
replace_markdown_links(line)
|
71
|
+
end)
|
72
|
+
@weaved = true
|
73
|
+
end
|
74
|
+
def write(output)
|
75
|
+
fout = File.open(output, 'w')
|
76
|
+
weave() unless @weaved
|
77
|
+
@weaved_lines.each {|line| fout << line}
|
78
|
+
end
|
79
|
+
|
80
|
+
|
81
|
+
private
|
82
|
+
def include_includes(lines, current_file = @file_name, current_path = '', depth = 0)
|
83
|
+
raise "too many includes" if depth > 1000
|
84
|
+
include_exp = /^!\s+include\s+\[.*\]\((.*)\)\s*$/
|
85
|
+
lines.map do |line|
|
86
|
+
match = include_exp.match(line)
|
87
|
+
if match
|
88
|
+
file = File.dirname(current_file) + '/' + match[1]
|
89
|
+
path = File.dirname(current_path) + '/' + match[1]
|
90
|
+
new_lines = File.open(file, 'r') {|f| f.readlines}
|
91
|
+
include_includes(new_lines, file, path, depth + 1)
|
92
|
+
else
|
93
|
+
[[line, current_path]]
|
94
|
+
end
|
95
|
+
end.flatten(1)
|
96
|
+
end
|
97
|
+
|
98
|
+
def find_blocks(lines)
|
99
|
+
lines_with_includes = include_includes(lines)
|
100
|
+
code_block_exp = /^([s]*)``` ?([\w]*) ?(=?)([-\w]*)?/
|
101
|
+
headers_and_footers = lines_with_includes.filter do |(line, source_file)|
|
102
|
+
code_block_exp =~ line
|
103
|
+
end
|
104
|
+
throw "Missing code fence" if headers_and_footers.length % 2 != 0
|
105
|
+
headers_and_footers.each_slice(2).map(&:first)
|
106
|
+
.map do |(header, source_file)|
|
107
|
+
white_space, language, replacement_mark, name = code_block_exp.match(header)[1..-1]
|
108
|
+
[name, source_file, language, replacement_mark]
|
109
|
+
end.group_by do |name, _, _, _|
|
110
|
+
name
|
111
|
+
end.transform_values do |blocks|
|
112
|
+
block_name, _, block_language, _ = blocks[0]
|
113
|
+
count, _ = blocks.inject(0) do |count, (name, source_file, language, replacement_mark)|
|
114
|
+
throw "block #{block_name} has multiple languages" unless language == block_language
|
115
|
+
count + 1
|
116
|
+
end
|
117
|
+
block_locations = blocks.each_with_index.map do |(name, source_file, language, replacement_mark), index|
|
118
|
+
[name, index, source_file]
|
119
|
+
end
|
120
|
+
{:count => count, :block_locations => block_locations}
|
121
|
+
end
|
122
|
+
end
|
123
|
+
def substitute_directives_and_headers(lines)
|
124
|
+
include_expression = /^!\s+include\s+\[.*\]\((.*)\)\s*$/
|
125
|
+
code_block_expression = /^([s]*)``` ?([\w]*) ?(=?)([-\w]*)?/
|
126
|
+
in_block = false
|
127
|
+
block_name = ""
|
128
|
+
lines.map do |line|
|
129
|
+
case line
|
130
|
+
when include_expression
|
131
|
+
include_file = $1
|
132
|
+
["**See include:** [#{include_file}](include_file)\n"]
|
133
|
+
when code_block_expression
|
134
|
+
in_block = !in_block
|
135
|
+
if in_block
|
136
|
+
white_space, language, replacement_mark, name =
|
137
|
+
code_block_expression.match(line)[1..-1]
|
138
|
+
human_name = name.gsub(/[-_]/, ' ').split(' ').map(&:capitalize).join(' ')
|
139
|
+
replacing = if replacement_mark == "="
|
140
|
+
" Replacing"
|
141
|
+
else
|
142
|
+
""
|
143
|
+
end
|
144
|
+
header = if name != ""
|
145
|
+
"#######{replacing} Code Block: #{human_name}\n\n"
|
146
|
+
else
|
147
|
+
"#######{replacing} Output Block\n\n"
|
148
|
+
end
|
149
|
+
[header,
|
150
|
+
"#{white_space}``` #{language}\n"]
|
151
|
+
else
|
152
|
+
[line]
|
153
|
+
end
|
154
|
+
else
|
155
|
+
[line]
|
156
|
+
end
|
157
|
+
end.flatten(1)
|
158
|
+
end
|
159
|
+
def replace_markdown_links(line)
|
160
|
+
line
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def self.required(*options)
|
165
|
+
@required_options = options
|
166
|
+
end
|
167
|
+
|
168
|
+
def self.check_arguments
|
169
|
+
missing = @required_options.select{ |p| options[p].nil?}
|
170
|
+
unless missing.empty?
|
171
|
+
message = "Missing Required Argument(s): #{missing.join(', ')}"
|
172
|
+
|
173
|
+
abort("#{message}\n\n#{opts.help()}")
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
description "A literate Markdown weave tool written in Ruby."
|
178
|
+
on("--file FILE", "-f", "Required: input file")
|
179
|
+
on("--output FILE", "-o", "Required: output file")
|
180
|
+
on("--dev", "disables self test failure for development")
|
181
|
+
required(:file, :output)
|
182
|
+
|
183
|
+
version Lmt::VERSION
|
184
|
+
|
185
|
+
use_log_level_option :toggle_debug_on_signal => 'USR1'
|
186
|
+
|
187
|
+
go! if __FILE__ == $0
|
188
|
+
end
|
189
|
+
|
190
|
+
end
|