-
Notifications
You must be signed in to change notification settings - Fork 34
/
Copy pathcpp_library.rb
542 lines (463 loc) · 20.5 KB
/
cpp_library.rb
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
require 'find'
require "arduino_ci/host"
require 'pathname'
require 'shellwords'
HPP_EXTENSIONS = [".hpp", ".hh", ".h", ".hxx", ".h++"].freeze
CPP_EXTENSIONS = [".cpp", ".cc", ".c", ".cxx", ".c++"].freeze
CI_CPP_DIR = Pathname.new(__dir__).parent.parent + "cpp"
ARDUINO_HEADER_DIR = CI_CPP_DIR + "arduino"
UNITTEST_HEADER_DIR = CI_CPP_DIR + "unittest"
module ArduinoCI
# Information about an Arduino CPP library, specifically for compilation purposes
class CppLibrary
# @return [String] The official library properties file name
LIBRARY_PROPERTIES_FILE = "library.properties".freeze
# @return [String] The "official" name of the library, which can include spaces (in a way that the lib dir won't)
attr_reader :name
# @return [ArduinoBackend] The backend support for this library
attr_reader :backend
# @return [Array<Pathname>] The set of artifacts created by this class (note: incomplete!)
attr_reader :artifacts
# @return [Array<Pathname>] The set of directories that should be excluded from compilation
attr_reader :exclude_dirs
# @return [String] STDERR from the last command
attr_reader :last_err
# @return [String] STDOUT from the last command
attr_reader :last_out
# @return [String] the last command
attr_reader :last_cmd
# @return [Array<Pathname>] Directories suspected of being vendor-bundle
attr_reader :vendor_bundle_cache
# @param friendly_name [String] The "official" name of the library, which can contain spaces
# @param backend [ArduinoBackend] The support backend
def initialize(friendly_name, backend)
raise ArgumentError, "friendly_name is not a String (got #{friendly_name.class})" unless friendly_name.is_a? String
raise ArgumentError, 'backend is not a ArduinoBackend' unless backend.is_a? ArduinoBackend
@name = friendly_name
@backend = backend
@info_cache = nil
@artifacts = []
@last_err = ""
@last_out = ""
@last_msg = ""
@has_libasan_cache = {}
@vendor_bundle_cache = nil
@exclude_dirs = []
end
# Generate a guess as to the on-disk (coerced character) name of this library
#
# @TODO: delegate this to the backend in some way? It uses "official" names for install, but dir names in lists :(
# @param friendly_name [String] The library name as it might appear in library manager
# @return [String] How the path will be stored on disk -- spaces are coerced to underscores
def self.library_directory_name(friendly_name)
friendly_name.tr(" ", "_")
end
# Generate a guess as to the on-disk (coerced character) name of this library
#
# @TODO: delegate this to the backend in some way? It uses "official" names for install, but dir names in lists :(
# @return [String] How the path will be stored on disk -- spaces are coerced to underscores
def name_on_disk
self.class.library_directory_name(@name)
end
# Get the path to this library, whether or not it exists
# @return [Pathname] The fully qualified library path
def path
@backend.lib_dir + name_on_disk
end
# Determine whether a library is present in the lib dir
#
# Note that `true` doesn't guarantee that the library is valid/installed
# and `false` doesn't guarantee that the library isn't built-in
#
# @return [bool]
def installed?
path.exist?
end
# install a library by name
# @param version [String] the version to install
# @param recursive [bool] whether to also install its dependencies
# @return [bool] whether the command succeeded
def install(version = nil, recursive = false)
return true if installed? && !recursive
fqln = version.nil? ? @name : "#{@name}@#{version}"
result = if recursive
@backend.run_and_capture("lib", "install", fqln)
else
@backend.run_and_capture("lib", "install", "--no-deps", fqln)
end
result[:success]
end
# information about the library as reported by the backend
# @return [Hash] the metadata object
def info
return nil unless installed?
# note that if the library isn't found, we're going to do a lot of cache attempts...
if @info_cache.nil?
@info_cache = @backend.installed_libraries.find do |l|
lib_info = l["library"]
Pathname.new(lib_info["install_dir"]).realpath == path.realpath
end
end
@info_cache
end
# @param installed_library_path [String] The library to query
# @return [Array<String>] Example sketch files
def example_sketches
reported_dirs = info["library"]["examples"].map(&Pathname::method(:new))
reported_dirs.map { |e| e + e.basename.sub_ext(".ino") }.select(&:exist?).sort_by(&:to_s)
end
# The expected path to the library.properties file (i.e. even if it does not exist)
# @return [Pathname]
def library_properties_path
path + LIBRARY_PROPERTIES_FILE
end
# Whether library.properties definitions for this library exist
# @return [bool]
def library_properties?
lib_props = library_properties_path
lib_props.exist? && lib_props.file?
end
# Library properties
# @return [LibraryProperties] The library.properties metadata wrapper for this library
def library_properties
return nil unless library_properties?
LibraryProperties.new(library_properties_path)
end
# Set directories that should be excluded from compilation
# @param rval [Array] Array of strings or pathnames that will be coerced to pathnames
def exclude_dirs=(rval)
@exclude_dirs = rval.map { |d| d.is_a?(Pathname) ? d : Pathname.new(d) }
end
# Decide whether this is a 1.5-compatible library
#
# This should be according to https://arduino.github.io/arduino-cli/latest/library-specification
# but we rely on the cli to decide for us
# @return [bool]
def one_point_five?
!info["library"]["is_legacy"]
end
# Guess whether a file is part of the vendor bundle (indicating we should ignore it).
#
# A safe way to do this seems to be to check whether any of the installed gems
# appear to be a subdirectory of (but not equal to) the working directory.
# That gets us the vendor directory (or multiple directories). We can check
# if the given path is contained by any of those.
#
# @param some_path [Pathname] The path to check
# @return [bool]
def vendor_bundle?(some_path)
# Cache bundle information, as it is (1) time consuming to fetch and (2) not going to change while we run
if @vendor_bundle_cache.nil?
bundle_info = Host.run_and_capture("bundle show --paths")
if !bundle_info[:success]
# if the bundle show command fails, assume there isn't a bundle
@vendor_bundle_cache = false
else
# Get all the places where gems are stored. We combine a few things here:
# by preemptively switching to the parent directory, we can both ensure that
# we skip any gems that are equal to the working directory AND exploit some
# commonality in the paths to cut down our search locations
#
# NOT CONFUSING THE WORKING DIRECTORY WITH VENDOR BUNDLE IS SUPER IMPORTANT
# because if we do, we won't be able to run CI on this library itself.
bundle_paths = bundle_info[:out].lines
.map { |l| Pathname.new(l.chomp) }
.select(&:exist?)
.map(&:realpath)
.map(&:parent)
.uniq
wd = Pathname.new(".").realpath
@vendor_bundle_cache = bundle_paths.select do |gem_path|
gem_path.ascend do |part|
break true if wd == part
end
end
end
end
# no bundle existed
return false if @vendor_bundle_cache == false
# With vendor bundles located, check this file against those
@vendor_bundle_cache.any? do |gem_path|
some_path.ascend do |part|
break true if gem_path == part
end
end
end
# Guess whether a file is part of the tests/ dir (indicating library compilation should ignore it).
#
# @param path [Pathname] The path to check
# @return [bool]
def in_tests_dir?(sourcefile_path)
return false unless tests_dir.exist?
tests_dir_aliases = [tests_dir, tests_dir.realpath]
# we could do this but some rubies don't return an enumerator for ascend
# path.ascend.any? { |part| tests_dir_aliases.include?(part) }
sourcefile_path.ascend do |part|
return true if tests_dir_aliases.include?(part)
end
false
end
# Guess whether a file is part of any @excludes_dir dir (indicating library compilation should ignore it).
#
# @param path [Pathname] The path to check
# @return [bool]
def in_exclude_dir?(sourcefile_path)
# we could do this but some rubies don't return an enumerator for ascend
# path.ascend.any? { |part| tests_dir_aliases.include?(part) }
sourcefile_path.ascend do |part|
return true if exclude_dir.any? { |p| p.realpath == part.realpath }
end
false
end
# Check whether libasan (and by extension -fsanitizer=address) is supported
#
# This requires compilation of a sample program, and will be cached
# @param gcc_binary [String]
def libasan?(gcc_binary)
unless @has_libasan_cache.key?(gcc_binary)
Tempfile.create(["arduino_ci_libasan_check", ".cpp"]) do |file|
file.write "int main(){}"
file.close
@has_libasan_cache[gcc_binary] = run_gcc(gcc_binary, "-o", "/dev/null", "-fsanitize=address", file.path)
end
end
@has_libasan_cache[gcc_binary]
end
# Get a list of all CPP source files in a directory and its subdirectories
# @param some_dir [Pathname] The directory in which to begin the search
# @param extensions [Array<Sring>] The set of allowable file extensions
# @return [Array<Pathname>] The paths of the found files
def code_files_in(some_dir, extensions)
raise ArgumentError, 'some_dir is not a Pathname' unless some_dir.is_a? Pathname
full_dir = path + some_dir
return [] unless full_dir.exist? && full_dir.directory?
files = full_dir.children.reject(&:directory?)
cpp = files.select { |path| extensions.include?(path.extname.downcase) }
not_hidden = cpp.reject { |path| path.basename.to_s.start_with?(".") }
not_hidden.sort_by(&:to_s)
end
# Get a list of all CPP source files in a directory and its subdirectories
# @param some_dir [Pathname] The directory in which to begin the search
# @param extensions [Array<Sring>] The set of allowable file extensions
# @return [Array<Pathname>] The paths of the found files
def code_files_in_recursive(some_dir, extensions)
raise ArgumentError, 'some_dir is not a Pathname' unless some_dir.is_a? Pathname
return [] unless some_dir.exist? && some_dir.directory?
Find.find(some_dir).map { |p| Pathname.new(p) }.select(&:directory?).map { |d| code_files_in(d, extensions) }.flatten
end
# Source files that are part of the library under test
# @param extensions [Array<String>] the allowed extensions (or, the ones we're looking for)
# @return [Array<Pathname>]
def source_files(extensions)
source_dir = Pathname.new(info["library"]["source_dir"])
ret = if one_point_five?
code_files_in_recursive(source_dir, extensions)
else
[source_dir, source_dir + "utility"].map { |d| code_files_in(d, extensions) }.flatten
end
# note to future troubleshooter: some of these tests may not be relevant, but at the moment at
# least some of them are tied to existing features
ret.reject { |p| vendor_bundle?(p) || in_tests_dir?(p) || in_exclude_dir?(p) }
end
# Header files that are part of the project library under test
# @return [Array<Pathname>]
def header_files
source_files(HPP_EXTENSIONS)
end
# CPP files that are part of the project library under test
# @return [Array<Pathname>]
def cpp_files
source_files(CPP_EXTENSIONS)
end
# CPP files that are part of the arduino mock library we're providing
# @return [Array<Pathname>]
def cpp_files_arduino
code_files_in(ARDUINO_HEADER_DIR, CPP_EXTENSIONS)
end
# CPP files that are part of the unit test library we're providing
# @return [Array<Pathname>]
def cpp_files_unittest
code_files_in(UNITTEST_HEADER_DIR, CPP_EXTENSIONS)
end
# CPP files that are part of the 3rd-party libraries we're including
# @param [Array<String>] aux_libraries
# @return [Array<Pathname>]
def cpp_files_libraries(aux_libraries)
arduino_library_src_dirs(aux_libraries).map { |d| code_files_in(d, CPP_EXTENSIONS) }.flatten.uniq
end
# Returns the Pathnames for all paths to exclude from testing and compilation
# @return [Array<Pathname>]
def exclude_dir
@exclude_dirs.map { |p| Pathname.new(path) + p }.select(&:exist?)
end
# The directory where we expect to find unit test defintions provided by the user
# @return [Pathname]
def tests_dir
Pathname.new(path) + "test"
end
# The files provided by the user that contain unit tests
# @return [Array<Pathname>]
def test_files
code_files_in(tests_dir, CPP_EXTENSIONS)
end
# Find all directories in the project library that include C++ header files
# @return [Array<Pathname>]
def header_dirs
unbundled = header_files.reject { |path| vendor_bundle?(path) }
unexcluded = unbundled.reject { |path| in_exclude_dir?(path) }
files = unexcluded.select { |path| HPP_EXTENSIONS.include?(path.extname.downcase) }
files.map(&:dirname).uniq
end
# wrapper for the GCC command
def run_gcc(gcc_binary, *args, **kwargs)
full_args = [gcc_binary] + args
@last_cmd = " $ #{full_args.join(' ')}"
ret = Host.run_and_capture(*full_args, **kwargs)
@last_err = ret[:err]
@last_out = ret[:out]
ret[:success]
end
# Return the GCC version
# @return [String] the version reported by `gcc -v`
def gcc_version(gcc_binary)
return nil unless run_gcc(gcc_binary, "-v")
@last_err
end
# Get a list of all dependencies as defined in library.properties
# @return [Array<String>] The library names of the dependencies (not the paths)
def arduino_library_dependencies
return [] unless library_properties?
return [] if library_properties.depends.nil?
library_properties.depends
end
# Arduino library dependencies all the way down, installing if they are not present
# @return [Array<String>] The library names of the dependencies (not the paths)
def all_arduino_library_dependencies!(additional_libraries = [])
# Pull in all possible places that headers could live, according to the spec:
# https://github.com/arduino/Arduino/wiki/Arduino-IDE-1.5:-Library-specification
recursive = (additional_libraries + arduino_library_dependencies).map do |n|
other_lib = self.class.new(n, @backend)
other_lib.install unless other_lib.installed?
other_lib.all_arduino_library_dependencies!
end.flatten
ret = (additional_libraries + recursive).uniq
ret
end
# Arduino library directories containing sources -- only those of the dependencies
# @return [Array<Pathname>]
def arduino_library_src_dirs(aux_libraries)
all_arduino_library_dependencies!(aux_libraries).map { |l| self.class.new(l, @backend).header_dirs }.flatten.uniq
end
# GCC command line arguments for including aux libraries
#
# This function recursively collects the library directores of the dependencies
#
# @param aux_libraries [Array<Pathname>] The external Arduino libraries required by this project
# @return [Array<String>] The GCC command-line flags necessary to include those libraries
def include_args(aux_libraries)
all_aux_include_dirs = arduino_library_src_dirs(aux_libraries)
places = [ARDUINO_HEADER_DIR, UNITTEST_HEADER_DIR] + header_dirs + all_aux_include_dirs
places.map { |d| "-I#{d}" }
end
# GCC command line arguments for features (e.g. -fno-weak)
# @param ci_gcc_config [Hash] The GCC config object
# @return [Array<String>] GCC command-line flags
def feature_args(ci_gcc_config)
return [] if ci_gcc_config[:features].nil?
ci_gcc_config[:features].map { |f| "-f#{f}" }
end
# GCC command line arguments for warning (e.g. -Wall)
# @param ci_gcc_config [Hash] The GCC config object
# @return [Array<String>] GCC command-line flags
def warning_args(ci_gcc_config)
return [] if ci_gcc_config[:warnings].nil?
ci_gcc_config[:features].map { |w| "-W#{w}" }
end
# GCC command line arguments for defines (e.g. -Dhave_something)
# @param ci_gcc_config [Hash] The GCC config object
# @return [Array<String>] GCC command-line flags
def define_args(ci_gcc_config)
return [] if ci_gcc_config[:defines].nil?
ci_gcc_config[:defines].map { |d| "-D#{d}" }
end
# GCC command line arguments as-is
# @param ci_gcc_config [Hash] The GCC config object
# @return [Array<String>] GCC command-line flags
def flag_args(ci_gcc_config)
return [] if ci_gcc_config[:flags].nil?
ci_gcc_config[:flags]
end
# All GCC command line args for building any unit test
# @param aux_libraries [Array<Pathname>] The external Arduino libraries required by this project
# @param ci_gcc_config [Hash] The GCC config object
# @return [Array<String>] GCC command-line flags
def test_args(aux_libraries, ci_gcc_config)
# TODO: something with libraries?
ret = include_args(aux_libraries)
ret += cpp_files_arduino.map(&:to_s)
ret += cpp_files_unittest.map(&:to_s)
ret += cpp_files.map(&:to_s)
unless ci_gcc_config.nil?
cgc = ci_gcc_config
ret = feature_args(cgc) + warning_args(cgc) + define_args(cgc) + flag_args(cgc) + ret
end
ret
end
# build a file for running a test of the given unit test file
#
# The dependent libraries configuration is appended with data from library.properties internal to the library under test
#
# @param test_file [Pathname] The path to the file containing the unit tests
# @param aux_libraries [Array<Pathname>] The external Arduino libraries required by this project
# @param ci_gcc_config [Hash] The GCC config object
# @return [Pathname] path to the compiled test executable
def build_for_test_with_configuration(test_file, aux_libraries, gcc_binary, ci_gcc_config)
base = test_file.basename
executable = Pathname.new("unittest_#{base}.bin").expand_path
File.delete(executable) if File.exist?(executable)
arg_sets = []
arg_sets << ["-std=c++0x", "-o", executable.to_s, "-DARDUINO=100"]
if libasan?(gcc_binary)
arg_sets << [ # Stuff to help with dynamic memory mishandling
"-g", "-O1",
"-fno-omit-frame-pointer",
"-fno-optimize-sibling-calls",
"-fsanitize=address"
]
end
# combine library.properties defs (if existing) with config file.
# TODO: as much as I'd like to rely only on the properties file(s), I think that would prevent testing 1.0-spec libs
full_dependencies = all_arduino_library_dependencies!(aux_libraries)
arg_sets << test_args(full_dependencies, ci_gcc_config)
arg_sets << cpp_files_libraries(full_dependencies).map(&:to_s)
arg_sets << [test_file.to_s]
args = arg_sets.flatten(1)
return nil unless run_gcc(gcc_binary, *args)
artifacts << executable
executable
end
# print any found stack dumps
# @param executable [Pathname] the path to the test file
def print_stack_dump(executable)
possible_dumpfiles = [
executable.sub_ext(executable.extname + ".stackdump")
]
possible_dumpfiles.select(&:exist?).each do |dump|
puts "========== Stack dump from #{dump}:"
File.foreach(dump) { |line| print " #{line}" }
end
end
# run a test file
# @param executable [Pathname] the path to the test file
# @return [bool] whether all tests were successful
def run_test_file(executable)
@last_cmd = executable
@last_out = ""
@last_err = ""
ret = Host.run_and_output(executable.to_s.shellescape)
# print any stack traces found during a failure
print_stack_dump(executable) unless ret
ret
end
end
end