aboutsummaryrefslogtreecommitdiffhomepage
path: root/cmake/podspec_cmake.rb
blob: 4063f351a520f91733ed2b46047aec9addcc8245 (plain)
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
#!/usr/bin/env ruby

# Copyright 2018 Google
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

require 'cocoapods'
require 'fileutils'
require 'pathname'
require 'set'

PLATFORM = :osx

def usage()
  script = File.basename($0)
  STDERR.puts <<~EOF
  USAGE: #{script} podspec cmake-file [subspecs...]
  EOF
end

def main(args)
  if args.size < 2 then
    usage()
    exit(1)
  end

  process(*args)
end

# A CMake command, like add_library. The command name is stored in the first
# argument.
class CMakeCommand
  # Create the command with its initial identifying arguments.
  def initialize(*args)
    @args = args
    @checked_count = 0
  end

  def name()
    return @args[0]
  end

  def rest()
    return @args[1..-1]
  end

  def skip?()
    return @checked_count == 0
  end

  def allow_missing_args()
    @checked_count = nil
  end

  # Adds the given arguments to the end of the command
  def add_args(*args)
    args = args.flatten

    unless @checked_count.nil?
      @checked_count += args.size
    end

    args.each do |arg|
      unless @args.include?(arg)
        @args.push(arg)
      end
    end
  end
end

# A model of a macOS or iOS Framework and the CMake commands required to build
# it.
class Framework
  def initialize(name)
    @name = name
    @add_library = CMakeCommand.new('add_library', @name, 'STATIC')
    @add_library.allow_missing_args()

    @public_headers = CMakeCommand.new(
      'set_property', 'TARGET', @name, 'PROPERTY', 'PUBLIC_HEADER')

    @properties = []

    @extras = {}
  end

  # Returns all the CMake commands required to build the framework.
  def commands()
    result = [@add_library]
    result.push(@public_headers, *@properties)
    @extras.keys.sort.each do |key|
      result.push(@extras[key])
    end
    return result
  end

  # Adds library sources to the CMake add_library command that declares the
  # library.
  def add_sources(*sources)
    @add_library.add_args(sources)
  end

  # Adds public headers to the Framework
  def add_public_headers(*headers)
    @public_headers.add_args(headers)
  end

  # Sets a target-level CMake property on the library target that declares the
  # framework.
  def set_property(property, *values)
    command = CMakeCommand.new('set_property', 'TARGET', @name, 'PROPERTY', property)
    command.add_args(values)
    @properties.push(command)
  end

  # Adds target-level preprocessor definitions.
  #
  # Args:
  # - type: PUBLIC, PRIVATE, or INTERFACE
  # - values: C preprocessor defintion arguments starting with -D
  def compile_definitions(type, *values)
    extra_command('target_compile_definitions', @name, type)
      .add_args(values)
  end

  # Adds target-level compile-time include path for the preprocessor.
  #
  # Args:
  # - type: PUBLIC, PRIVATE, or INTERFACE
  # - values: directory names, not including a leading -I flag
  def include_directories(type, *dirs)
    extra_command('target_include_directories', @name, type)
      .add_args(dirs)
  end

  # Adds target-level compile-time compiler options that aren't macro
  # definitions or include directories. Link-time options should be added via
  # lib_libraries.
  #
  # Args:
  # - type: PUBLIC, PRIVATE, or INTERFACE
  # - values: compiler flags, e.g. -fno-autolink
  def compile_options(type, *values)
    extra_command('target_compile_options', @name, type)
      .add_args(values)
  end

  # Adds target-level dependencies or link-time compiler options. CMake
  # interprets any quoted string that starts with "-" as an option and anything
  # else as a library target to depend upon.
  #
  # Args:
  # - type: PUBLIC, PRIVATE, or INTERFACE
  # - values: compiler flags, e.g. -fno-autolink
  def link_libraries(type, *dirs)
    extra_command('target_link_libraries', @name, type)
      .add_args(dirs)
  end

  private
  def extra_command(*key_args)
    key = key_args.join('|')
    command = @extras[key]
    if command.nil?
      command = CMakeCommand.new(*key_args)
      @extras[key] = command
    end
    return command
  end
end

# Generates a framework target based on podspec contents. Models the translation
# of a single podspec (and possible subspecs) to a single CMake framework
# target.
class CMakeGenerator

  # Initializes the generator with the given root Pod::Spec and the binary
  # directory for the current CMake configuration.
  #
  # Args:
  # - spec: A root specification, the name of which becomes the name of the
  #   Framework.
  # - path_list: A Pod::Sandbox::PathList used to cache file operations.
  # - cmake_binary_dir: A directory in which additional files may be written.
  def initialize(spec, path_list, cmake_binary_dir)
    @target = Framework.new(spec.name)

    headers_root = File.join(cmake_binary_dir, 'Headers')
    @headers_dir = File.join(headers_root, spec.name)

    @root = spec

    @target.set_property('FRAMEWORK', 'ON')
    @target.set_property('VERSION', spec.version)

    @target.include_directories('PRIVATE', headers_root, @headers_dir)
    @target.link_libraries('PUBLIC', "\"-framework Foundation\"")

    root_dir = Pathname.new(__FILE__).expand_path().dirname().dirname()
    @path_list = Pod::Sandbox::PathList.new(root_dir)
  end

  attr_reader :target

  # Adds information from the given Pod::Spec to the definition of the CMake
  # framework target. Subspecs are not automatically handled.
  #
  # Cocoapods subspecs are not independent libraries--they contribute sources
  # and dependencies to a final single Framework.
  #
  # Args:
  # - spec: A root or subspec that contributes to the final state of the of the
  #   Framework.
  def add_framework(spec)
    spec = spec.consumer(PLATFORM)
    files = Pod::Sandbox::FileAccessor.new(@path_list, spec)
    sources = [
      files.source_files,
      files.public_headers,
      files.private_headers,
    ].flatten
    @target.add_sources(sources)

    add_headers(files, sources)

    add_dependencies(spec)
    add_framework_dependencies(spec)

    @target.compile_options('INTERFACE', '-F${CMAKE_CURRENT_BINARY_DIR}')
    @target.compile_options('PRIVATE', '${OBJC_FLAGS}')

    add_xcconfig('PRIVATE', spec.pod_target_xcconfig)
    add_xcconfig('PUBLIC', spec.user_target_xcconfig)
  end

  private
  # Sets up the framework headers so that compilation can succeed.
  # Xcode/CocoaPods allow for several different include mechanisms to work:
  #
  #   * Unqualified headers, e.g. +#import "FIRLoggerLevel.h"+, typically
  #     resolved via the header map.
  #   * Qualified relative to some source root, e.g.
  #     +#import "Public/FIRLoggerLevel.h"+, typically resolved by an include
  #     path
  #   * Framework imports, e.g. +#import <FirebaseCore/FIRLoggerLevel.h>+,
  #     resolved by a build process that copies headers into the framework
  #     structure.
  #   * Umbrella imports e.g. +#import <FirebaseCore/FirebaseCore.h>+ (which
  #     happens to import all the public headers).
  #
  # CMake's framework support is incomplete. It has no support at all for
  # generating umbrella headers.
  #
  # CMake also does not completely support framework imports. It does work for
  # sources outside the framework that want to build against it, but until the
  # framework has been completely built the headers aren't available in this
  # form. This prevents frameworks from referring to their own code via
  # framework imports.
  #
  # This method cheats by creating a subdirectory in the build results that has
  # symbolic links of all the public headers accessible with the right path.
  # This makes it possible to use framework imports within the framework itself.
  # The parent of this path is then added as a PRIVATE include directory of the
  # target, making it possible for the framework to see itself this way.
  def add_headers(files, sources)
    # CMake-built frameworks don't have a notion of private headers, but they
    # also don't have a notion of umbrella headers, so all framework headers
    # need to be accessed by name. This means that just dumping all the private
    # and public headers into what CMake considers the public headers makes
    # everything work as we expect.
    headers = [
      files.public_headers,
      files.private_headers
    ].flatten

    @target.add_public_headers(headers)

    # Also, link the headers into a directory that looks like a framework layout
    # so that self-references via framework imports work. These *must* be
    # symbolic links, otherwise our usual sloppiness causes file contents to be
    # included multiple times, usually resulting in ambiguity errors.
    FileUtils.mkdir_p(@headers_dir)
    headers.each do |header|
      FileUtils.ln_sf(header, File.join(@headers_dir, File.basename(header)))
    end

    # Simulate header maps by adding include paths for all the directories
    # containing non-public headers.
    hmap_dirs = Set.new()
    sources.each do |source|
      next if File.extname(source) != '.h'
      next if headers.include?(source)

      hmap_dirs.add(File.dirname(source))
    end
    @target.include_directories('PRIVATE', hmap_dirs.to_a.sort)
  end

  # Adds Pod::Spec +dependencies+ as target_link_libraries. Only root-specs are
  # added as dependencies because in the CMake build there can be only one
  # target for the framework.
  def add_dependencies(spec)
    prefix = "#{@root.name}/"
    spec.dependencies.each do |dep|
      # Dependencies on subspecs of this same spec are handled elsewhere.
      next if dep.name.start_with?(prefix)

      name = dep.name.sub(/\/.*/, '')
      @target.link_libraries('PUBLIC', name)
    end
  end

  # Adds target_link_libraries entries for all the items in the Pod::Spec
  # +frameworks+ attribute.
  def add_framework_dependencies(spec)
    spec.frameworks.each do |framework|
      @target.link_libraries('PUBLIC', "\"-framework #{framework}\"")
    end
  end

  # Mirrors known entries from the xcconfig entries into their equivalents in
  # CMake. This translates OTHER_CFLAGS, GCC_PREPROCESSOR_DEFINITIONS, and
  # HEADER_SEARCH_PATHS.
  #
  # Args:
  # - type: PUBLIC for +pod_user_xcconfig+ or PRIVATE for
  #   +pod_target_xcconfig+.
  # - xcconfig: the hash of xcconfig values.
  def add_xcconfig(type, xcconfig)
    if xcconfig.empty?
      return
    end

    @target.compile_options(type, split(xcconfig['OTHER_CFLAGS']))

    defs = split(xcconfig['GCC_PREPROCESSOR_DEFINITIONS'])
    defs = defs.map { |x| '-D' + x }
    @target.compile_definitions(type, defs)

    @target.include_directories(type, split(xcconfig['HEADER_SEARCH_PATHS']))
  end

  # Splits a textual value in xcconfig. Always returns an array, but that array
  # may be empty if the value didn't exist in the podspec.
  def split(value)
    if value.nil?
      return []
    elsif value.kind_of?(String)
      return value.split
    else
      return [value]
    end
  end
end

# Processes a podspec file, translating all the specs within it into cmake file
# describing how to build it.
#
# Args:
# - podspec_file: The filename of the podspec to use as a source.
# - cmake_file: The filename of the cmake script to produce.
# - req_subspecs: Which subspecs to include. If empty, all subspecs are
#   included (which corresponds to CocoaPods behavior. The default_subspec
#   property is not handled.
def process(podspec_file, cmake_file, *req_subspecs)
  root_dir = Pathname.new(__FILE__).expand_path().dirname().dirname()
  path_list = Pod::Sandbox::PathList.new(root_dir)

  spec = Pod::Spec.from_file(podspec_file)

  writer = Writer.new()
  writer.append <<~EOF
  # This file was generated by #{File.basename(__FILE__)}
  # from #{File.basename(podspec_file)}.
  # Do not edit!
  EOF

  cmake_binary_dir = File.expand_path(File.dirname(cmake_file))

  gen = CMakeGenerator.new(spec, path_list, cmake_binary_dir)
  gen.add_framework(spec)

  req_subspecs = normalize_requested_subspecs(spec, req_subspecs)
  req_subspecs = resolve_subspec_deps(spec, req_subspecs)

  spec.subspecs.each do |subspec|
    if req_subspecs.include?(subspec.name)
      gen.add_framework(subspec)
    end
  end

  gen.target.commands.each do |command|
    writer.write(command)
  end

  File.open(cmake_file, 'w') do |fd|
    fd.write(writer.result)
  end
end

# Translates the (possibly empty) list of requested subspecs into the list of
# subspecs to actually include. If +req_subspecs+ is empty, returns all
# subspecs. If non-empty, all subspecs are returned as qualified names, e.g.
# "Logger" may become "GoogleUtilities/Logger".
def normalize_requested_subspecs(spec, req_subspecs)
  subspecs = spec.subspecs
  if req_subspecs.empty?
    return subspecs.map { |s| s.name }
  else
    return req_subspecs.map do |name|
      if name.include?(?/)
        name
      else
        "#{spec.name}/#{name}"
      end
    end
  end
end

# Expands the list of requested subspecs to include any dependencies within the
# same root subspec. For example, if +req_subspecs+ where
#
#   +["GoogleUtilties/Logger"]+,
#
# the result would be
#
#   +["GoogleUtilties/Logger", "GoogleUtilities/Environment"]+
#
# because Logger depends upon Environment within the same root spec.
def resolve_subspec_deps(spec, req_subspecs)
  prefix = spec.name + '/'

  result = Set.new()
  while !req_subspecs.empty?
    req = req_subspecs.pop
    result.add(req)

    subspec = spec.subspec_by_name(req)
    subspec.dependencies(PLATFORM).each do |dep|
      if dep.name.start_with?(prefix) && !result.include?(dep.name)
        req_subspecs.push(dep.name)
      end
    end
  end

  return result.to_a.sort
end

# Writes CMake commands out to textual form, taking care of line wrapping.
class Writer
  def initialize()
    @last_command = nil
    @result = ""
  end

  attr_reader :result

  def write(command)
    if command.skip?
      return
    end

    if command.name != @last_command
      @result << "\n"
    end
    @last_command = command.name

    single = "#{command.name}(#{command.rest.join(' ')})\n"
    if single.size < 80
      @result << single
    else
      @result << "#{command.name}(\n"
      command.rest.each do |arg|
        @result << "  #{arg}\n"
      end
      @result << ")\n"
    end
  end

  def append(text)
    @result << text
  end
end

main(ARGV)