lmt 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|