/
macros.cr
208 lines (174 loc) · 8.76 KB
/
macros.cr
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
class Crystal::Program
# Temporary files which are generated by macro runs that need to be
# deleted after the compilation is finished.
getter tempfiles = [] of String
# Returns a `MacroExpander` to expand macro code into crystal code.
getter(macro_expander) { MacroExpander.new self }
# A cache of compiled "macro run" files.
# The keys are filenames that were compiled, the values are executable
# filenames ready to be run (so they don't need to be compiled twice),
# together with the time it took to compile them and whether a previous
# compilation was reused.
# The elapsed time is only needed for stats.
record CompiledMacroRun, filename : String, elapsed : Time::Span, reused : Bool
property compiled_macros_cache = {} of String => CompiledMacroRun
def expand_macro(a_macro : Macro, call : Call, scope : Type, path_lookup : Type? = nil, a_def : Def? = nil)
check_call_to_deprecated_macro a_macro, call
interpreter = MacroInterpreter.new self, scope, path_lookup || scope, a_macro, call, a_def, in_macro: true
a_macro.body.accept interpreter
{interpreter.to_s, interpreter.macro_expansion_pragmas}
end
def expand_macro(node : ASTNode, scope : Type, path_lookup : Type? = nil, free_vars = nil, a_def : Def? = nil)
interpreter = MacroInterpreter.new self, scope, path_lookup || scope, node.location, def: a_def, in_macro: false
interpreter.free_vars = free_vars
node.accept interpreter
{interpreter.to_s, interpreter.macro_expansion_pragmas}
end
def parse_macro_source(generated_source, macro_expansion_pragmas, the_macro, node, vars, current_def = nil, inside_type = false, inside_exp = false, mode : Parser::ParseMode = :normal, visibility : Visibility = :public)
parse_macro_source generated_source, macro_expansion_pragmas, the_macro, node, vars, current_def, inside_type, inside_exp, visibility, &.parse(mode)
end
def parse_macro_source(generated_source, macro_expansion_pragmas, the_macro, node, vars, current_def = nil, inside_type = false, inside_exp = false, visibility : Visibility = :public, &)
parser = @program.new_parser(generated_source, var_scopes: [vars.dup])
parser.filename = VirtualFile.new(the_macro, generated_source, node.location)
parser.macro_expansion_pragmas = macro_expansion_pragmas
parser.visibility = visibility
parser.def_nest = 1 if current_def && !current_def.is_a?(External)
parser.fun_nest = 1 if current_def && current_def.is_a?(External)
parser.type_nest = 1 if inside_type
parser.wants_doc = @program.wants_doc?
generated_node = yield parser
normalize(generated_node, inside_exp: inside_exp, current_def: current_def)
end
record MacroRunResult, stdout : String, stderr : String, status : Process::Status
def macro_run(filename, args)
compiled_macro_run = @compiled_macros_cache[filename] ||= macro_compile(filename)
compiled_file = compiled_macro_run.filename
out_io = IO::Memory.new
err_io = IO::Memory.new
Process.run(compiled_file, args: args, output: out_io, error: err_io)
MacroRunResult.new(out_io.to_s, err_io.to_s, $?)
end
record RequireWithTimestamp, filename : String, epoch : Int64 do
include JSON::Serializable
end
def macro_compile(filename)
time = Time.monotonic
source = File.read(filename)
# We store the executable relative to the cache directory for 'filename',
# that way if it's already there from a previous compilation, and no file
# that this program uses changes, we can simply avoid recompiling it again
#
# NOTE: it could happen that a macro run program runs macros that could
# change the program behaviour even if files don't change, but this is
# discouraged (and we should strongly document it) because it prevents
# incremental compiles.
program_dir = CacheDir.instance.directory_for(filename)
executable_path = File.join(program_dir, "macro_run")
recorded_requires_path = File.join(program_dir, "recorded_requires")
requires_path = File.join(program_dir, "requires")
{% unless flag?(:win32) %}
# First, update times for the program dir, so it remains in the cache longer
# (this is specially useful if a macro run program is used by multiple programs)
now = Time.utc
File.utime(now, now, program_dir)
{% end %}
if can_reuse_previous_compilation?(filename, executable_path, recorded_requires_path, requires_path)
elapsed_time = Time.monotonic - time
return CompiledMacroRun.new(executable_path, elapsed_time, true)
end
result = host_compiler.compile Compiler::Source.new(filename, source), executable_path, combine_rpath: true
# Write the new files from which 'filename' depends into the cache dir
# (here we store how to obtain these files, because a require might use
# '/*' or '/**' and we need to recompile if a file is added or removed)
File.open(recorded_requires_path, "w") do |file|
result.program.recorded_requires.to_json(file)
end
# Together with their timestamp
# (this is the list of all effective files that were required)
requires_with_timestamps = result.program.requires.map do |required_file|
epoch = File.info(required_file).modification_time.to_unix
RequireWithTimestamp.new(required_file, epoch)
end
File.open(requires_path, "w") do |file|
requires_with_timestamps.to_json(file)
end
elapsed_time = Time.monotonic - time
CompiledMacroRun.new(executable_path, elapsed_time, false)
end
@host_compiler : Compiler?
# Creates a compiler instance with the host as target used for macro_run
# compilation.
def host_compiler
@host_compiler ||= Compiler.new.tap do |host_compiler|
if compiler = self.compiler
# When cross-compiling, the host compiler shouldn't copy the config for
# the target compiler and use the system defaults instead.
# TODO: Add configuration overrides for host compiler to CLI.
unless compiler.cross_compile?
host_compiler.flags = compiler.flags
host_compiler.dump_ll = compiler.dump_ll?
host_compiler.link_flags = compiler.link_flags
host_compiler.mcpu = compiler.mcpu
host_compiler.mattr = compiler.mattr
host_compiler.mcmodel = compiler.mcmodel
host_compiler.single_module = compiler.single_module?
host_compiler.static = compiler.static?
end
# Copy default settings that are not specific to host and target context:
host_compiler.n_threads = compiler.n_threads
host_compiler.prelude = compiler.prelude
end
# Although release takes longer, once the bc is cached in .crystal
# the subsequent times will make program execution faster.
host_compiler.release!
# Don't cleanup old directories after compiling: it might happen
# that in doing so we remove the directory associated with the current
# compilation (for example if we have more than 10 macro runs, the current
# directory will be the oldest).
host_compiler.cleanup = false
# No need to generate debug info for macro run programs
host_compiler.debug = Crystal::Debug::None
end
end
private def can_reuse_previous_compilation?(filename, executable_path, recorded_requires_path, requires_path)
return false unless File.exists?(executable_path)
return false unless File.exists?(recorded_requires_path)
return false unless File.exists?(requires_path)
recorded_requires =
begin
Array(Program::RecordedRequire).from_json(File.read(recorded_requires_path))
rescue JSON::Error
return false
end
requires_with_timestamps =
begin
Array(RequireWithTimestamp).from_json(File.read(requires_path))
rescue JSON::Error
return false
end
# From the recorded requires we reconstruct the effective required files.
# We start with the target filename
required_files = Set{filename}
recorded_requires.map do |recorded_require|
begin
files = @program.find_in_path(recorded_require.filename, recorded_require.relative_to)
required_files.concat(files) if files
rescue Crystal::CrystalPath::NotFoundError
# Maybe the file is gone
next
end
end
new_requires_with_timestamps = required_files.map do |required_file|
epoch = File.info(required_file).modification_time.to_unix
RequireWithTimestamp.new(required_file, epoch)
end
# Quick check: if there are a different number of files, something changed
if requires_with_timestamps.size != new_requires_with_timestamps.size
return false
end
# Sort both requires and check if they are the same
requires_with_timestamps.sort_by! &.filename
new_requires_with_timestamps.sort_by! &.filename
requires_with_timestamps == new_requires_with_timestamps
end
end