diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d2f8d2..73b8784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ All notable changes to this project will be documented in this file. ### Added - Add support for Xcode 16 synchronized folders +- Add support for Swift Packages + - Add new optional variable nammed "project_type" which accepts 2 values: "xcode" and "spm". When missing ccios use "xcode" by default. + - When "project_type" is set to "spm" ccios will not try to update a pbxproj and will only generate files. + - When "project_type" is set to "spm" multi target definition is no longer supported for generated files, as this is not supported by SPM. + - When generating files for an spm project, the target name in the header is either: the target defined in the template variables, the target defined in `.ccios.yml`, or it will guess the name of the target when your package uses the standard naming scheme of: "Sources//". ## [5.1.0] diff --git a/README.md b/README.md index 1ce8a45..97dfe38 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ templates_collection: ccios/templates # Global overrides of variables [Optional] variables: + project_type: xcode project: Project.xcodeproj target: SomeDefaultTarget @@ -221,6 +222,8 @@ parameters: # List of templates variables that is used to generate files in an xcode project. [Optional] # Those variables can be overridden in config file, see section "Variable hierarchy" for more informations. variables: + # Type of project "spm" or "xcode", will be considered as "xcode" if not specified. [Optional] + project_type: spm # The name of the xcode project. "*.xcodeproj" will use the first it finds. [required] project: "*.xcodeproj" # The base path used to generate an element. This variable must be defined once here, or on each elements below. diff --git a/lib/ccios/file_creator.rb b/lib/ccios/file_creator.rb index 44d397d..5c94ec1 100644 --- a/lib/ccios/file_creator.rb +++ b/lib/ccios/file_creator.rb @@ -25,10 +25,18 @@ def templater_options(targets, project) full_username: git_username, date: DateTime.now.strftime("%d/%m/%Y"), } - if targets.count == 1 - defaults["project_name"] = targets[0].display_name + if project.nil? + if targets.count == 1 + defaults["project_name"] = targets[0] + else + raise "A file outside an xcode project cannot require multiple targets" + end else - defaults["project_name"] = project.project_name_from_path + if targets.count == 1 + defaults["project_name"] = targets[0].display_name + else + defaults["project_name"] = project.project_name_from_path + end end defaults end diff --git a/lib/ccios/file_template_definition.rb b/lib/ccios/file_template_definition.rb index 156eddf..66ff972 100644 --- a/lib/ccios/file_template_definition.rb +++ b/lib/ccios/file_template_definition.rb @@ -33,20 +33,34 @@ def validate(parser, project, context, template_definition, config) base_path = merged_variables["base_path"] raise "Missing base_path variable" if base_path.nil? - base_group = XcodeGroupRepresentation.findGroup(base_path, project) - raise "Base path \"#{base_path}\" is missing" if base_group.nil? - target_name = merged_variables["target"] - if target_name.is_a?(String) || target_name.nil? - target = parser.target_for(project, target_name) - raise "Unable to find target \"#{target_name}\"" if target.nil? - elsif target_name.is_a?(Array) - target_name.each do |target_name| + if project.nil? + if target_name.is_a?(String) + target = target_name + elsif target_name.nil? + guessed_name = guess_spm_target_name(base_path) + raise "Unable to guess the target from the base path \"#{base_path}\", please specify the target in your config file" if guessed_name.nil? + target = guessed_name + elsif target_name.is_a?(Array) + raise "A template generating files in an spm project cannot specify multiple targets" + else + raise "Invalid target in template #{@name}" + end + else + base_group = XcodeGroupRepresentation.findGroup(base_path, project) + raise "Base path \"#{base_path}\" is missing" if base_group.nil? + + if target_name.is_a?(String) || target_name.nil? target = parser.target_for(project, target_name) raise "Unable to find target \"#{target_name}\"" if target.nil? + elsif target_name.is_a?(Array) + target_name.each do |target_name| + target = parser.target_for(project, target_name) + raise "Unable to find target \"#{target_name}\"" if target.nil? + end + else + raise "Invalid target in template #{@name}" end - else - raise "Invalid target in template #{@name}" end end @@ -66,10 +80,19 @@ def generate(parser, project, context, template_definition, config) target_name = merged_variables["target"] targets = [] - if target_name.is_a?(String) || target_name.nil? - targets = [parser.target_for(project, target_name)] - elsif target_name.is_a?(Array) - targets = target_name.map { |name| parser.target_for(project, name) } + if project.nil? + if target_name.is_a?(String) + targets = [target_name] + elsif target_name.nil? + guessed_name = guess_spm_target_name(base_path) + targets = [guessed_name] + end + else + if target_name.is_a?(String) || target_name.nil? + targets = [parser.target_for(project, target_name)] + elsif target_name.is_a?(Array) + targets = target_name.map { |name| parser.target_for(project, name) } + end end FileCreator.new.create_file_using_template_path( @@ -81,4 +104,14 @@ def generate(parser, project, context, template_definition, config) context ) end -end \ No newline at end of file + + private def guess_spm_target_name(path) + parts = path.split(File::SEPARATOR) + sources_index = parts.index("Sources") + if sources_index && sources_index + 1 < parts.length + return parts[sources_index + 1] + else + return nil + end + end +end diff --git a/lib/ccios/group_template_definition.rb b/lib/ccios/group_template_definition.rb index 910cf14..5f7449d 100644 --- a/lib/ccios/group_template_definition.rb +++ b/lib/ccios/group_template_definition.rb @@ -38,4 +38,4 @@ def generate(parser, project, context, template_definition, config) file_creator = FileCreator.new file_creator.create_empty_directory_for_group(group) end -end \ No newline at end of file +end diff --git a/lib/ccios/pbxproj_parser.rb b/lib/ccios/pbxproj_parser.rb index 887fc5f..897878c 100644 --- a/lib/ccios/pbxproj_parser.rb +++ b/lib/ccios/pbxproj_parser.rb @@ -33,4 +33,4 @@ def target_for(project, target_name) project.targets.find { |t| t.name == target_name } end end -end \ No newline at end of file +end diff --git a/lib/ccios/template_definition.rb b/lib/ccios/template_definition.rb index a0fc83f..b89778d 100644 --- a/lib/ccios/template_definition.rb +++ b/lib/ccios/template_definition.rb @@ -59,10 +59,17 @@ def validate(parser, options, config) raise "Error: invalid template name" unless @name.is_a? String merged_variables = config.variables_for_template(self) - project_path = merged_variables["project"] - - project = parser.project_for(project_path) - raise "Error: Unable to find project \"#{project_path}\"" if project.nil? + project_type = merged_variables["project_type"] || "xcode" + case project_type + when "xcode" + project_path = merged_variables["project"] + project = parser.project_for(project_path) + raise "Error: Unable to find project \"#{project_path}\"" if project.nil? + when "spm" + project = nil + else + raise "Invalid project_type given \"#{project_type}\"" + end @template_file_source.each do |file_template_name, path| raise "Missing template source file for \"#{file_template_name}\"" unless File.exist?(self.template_source_file(file_template_name)) @@ -93,9 +100,16 @@ def generate(parser, options, config) options = agrument_transformed_options(options) merged_variables = config.variables_for_template(self) - project_path = merged_variables["project"] - project = parser.project_for project_path + project_type = merged_variables["project_type"] || "xcode" + case project_type + when "xcode" + project_path = merged_variables["project"] + project = parser.project_for project_path + when "spm" + project = nil + + end @generated_elements.each do |element| element.generate(parser, project, options, self, config) diff --git a/lib/ccios/xcode_group_representation.rb b/lib/ccios/xcode_group_representation.rb index 9999e70..b5ea0bb 100644 --- a/lib/ccios/xcode_group_representation.rb +++ b/lib/ccios/xcode_group_representation.rb @@ -2,9 +2,13 @@ # This object handles Xcode groups, folder reference and synchronized folder reference class XcodeGroupRepresentation - def self.findGroup(path, project) intermediates_groups = path.split("/") + + if project.nil? + return self.new nil, nil, intermediates_groups + end + deepest_group = project.main_group additional_path = [] @@ -22,10 +26,15 @@ def self.findGroup(path, project) end def initialize(project, xcode_group, additional_path = []) - throw "Unsupported group type" unless xcode_group.is_a?(Xcodeproj::Project::Object::PBXFileSystemSynchronizedRootGroup) || xcode_group.is_a?(Xcodeproj::Project::Object::PBXGroup) - if !additional_path.empty? && !xcode_group.is_a?(Xcodeproj::Project::Object::PBXFileSystemSynchronizedRootGroup) - throw "additional_path can only be specified for a synchronized file system group" + if project.nil? + throw "Unexpected xcode_group when project is nil, we should be in an spm context" unless xcode_group.nil? + else + throw "Unsupported group type" unless xcode_group.is_a?(Xcodeproj::Project::Object::PBXFileSystemSynchronizedRootGroup) || xcode_group.is_a?(Xcodeproj::Project::Object::PBXGroup) + if !additional_path.empty? && !xcode_group.is_a?(Xcodeproj::Project::Object::PBXFileSystemSynchronizedRootGroup) + throw "additional_path can only be specified for a synchronized file system group" + end end + # This represent the xcode project if present, if nil we should be generating files in an Swift package @project = project # This represents the deepest group or folder reference in the project @xcode_deepest_group = xcode_group @@ -34,11 +43,18 @@ def initialize(project, xcode_group, additional_path = []) end def real_path - Xcodeproj::Project::Object::GroupableHelper.real_path(@xcode_deepest_group) + @additional_path.join("/") + if @xcode_deepest_group.nil? + @additional_path.join("/") + else + Xcodeproj::Project::Object::GroupableHelper.real_path(@xcode_deepest_group) + @additional_path.join("/") + end end def register_file_to_targets(file_path, targets) + if @xcode_deepest_group.nil? + return + end if @xcode_deepest_group.is_a?(Xcodeproj::Project::Object::PBXGroup) file_ref = @xcode_deepest_group.new_reference(file_path) targets.each do |target| @@ -61,7 +77,7 @@ def create_groups_if_needed_for_intermediate_groups(intermediates_groups) new_additional_path = @additional_path intermediates_groups.each do |group_name| - if new_deepest_group.is_a?(Xcodeproj::Project::Object::PBXFileSystemSynchronizedRootGroup) + if new_deepest_group.nil? || new_deepest_group.is_a?(Xcodeproj::Project::Object::PBXFileSystemSynchronizedRootGroup) new_additional_path.append(group_name) elsif new_deepest_group.is_a?(Xcodeproj::Project::Object::PBXGroup) existing_child = new_deepest_group.find_subpath(group_name) @@ -88,4 +104,4 @@ def pf_new_group(associate_path_to_group:, name:, path:) associate_path_to_group ? path : nil ) end -end \ No newline at end of file +end diff --git a/test/swift_package/.gitignore b/test/swift_package/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/test/swift_package/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/test/swift_package/Package.swift b/test/swift_package/Package.swift new file mode 100644 index 0000000..145684f --- /dev/null +++ b/test/swift_package/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 6.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "swift_package", + platforms: [ + .iOS(.v18) + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "swift_package", + targets: ["App"]), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "App", + dependencies: ["Core", "Data"] + ), + .target(name: "Core"), + .target( + name: "Data", + dependencies: ["Core"] + ) + ] +) diff --git a/test/swift_package/Sources/App/App.swift b/test/swift_package/Sources/App/App.swift new file mode 100644 index 0000000..08b22b8 --- /dev/null +++ b/test/swift_package/Sources/App/App.swift @@ -0,0 +1,2 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book diff --git a/test/swift_package/Sources/App/Coordinator/.gitkeep b/test/swift_package/Sources/App/Coordinator/.gitkeep new file mode 100644 index 0000000..08b22b8 --- /dev/null +++ b/test/swift_package/Sources/App/Coordinator/.gitkeep @@ -0,0 +1,2 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book diff --git a/test/swift_package/Sources/App/Screen/.gitkeep b/test/swift_package/Sources/App/Screen/.gitkeep new file mode 100644 index 0000000..08b22b8 --- /dev/null +++ b/test/swift_package/Sources/App/Screen/.gitkeep @@ -0,0 +1,2 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book diff --git a/test/swift_package/Sources/Core/Core.swift b/test/swift_package/Sources/Core/Core.swift new file mode 100644 index 0000000..08b22b8 --- /dev/null +++ b/test/swift_package/Sources/Core/Core.swift @@ -0,0 +1,2 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book diff --git a/test/swift_package/Sources/Core/Interactor/.gitkeep b/test/swift_package/Sources/Core/Interactor/.gitkeep new file mode 100644 index 0000000..08b22b8 --- /dev/null +++ b/test/swift_package/Sources/Core/Interactor/.gitkeep @@ -0,0 +1,2 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book diff --git a/test/swift_package/Sources/Core/Repository/.gitkeep b/test/swift_package/Sources/Core/Repository/.gitkeep new file mode 100644 index 0000000..08b22b8 --- /dev/null +++ b/test/swift_package/Sources/Core/Repository/.gitkeep @@ -0,0 +1,2 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book diff --git a/test/swift_package/Sources/Data/Data.swift b/test/swift_package/Sources/Data/Data.swift new file mode 100644 index 0000000..08b22b8 --- /dev/null +++ b/test/swift_package/Sources/Data/Data.swift @@ -0,0 +1,2 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book diff --git a/test/test_swift_package.rb b/test/test_swift_package.rb new file mode 100644 index 0000000..26fb8a3 --- /dev/null +++ b/test/test_swift_package.rb @@ -0,0 +1,132 @@ +require 'minitest/autorun' +require_relative '../lib/ccios/templates_loader' +require_relative '../lib/ccios/file_creator' +require 'tempfile' +require 'tmpdir' + +class SwiftPackageTest < Minitest::Test + + def setup + FileCreator.logger.level = Logger::ERROR + @test_dir = set_up_project_in_temporary_directory + end + + def teardown + FileUtils.remove_entry(@test_dir) + end + + def test_coordinator_without_folders + generate_and_assert( + source_path: "Sources", + template_name: "coordinator", + name: "Cotest", + expected_files: ["App/Coordinator/CotestCoordinator.swift"] + ) + end + + def test_interactor_without_folders + generate_and_assert( + source_path: "Sources", + template_name: "interactor", + name: "Intest", + expected_files: [ + "Core/Interactor/IntestInteractor/IntestInteractor.swift", + "Core/Interactor/IntestInteractor/IntestInteractorImplementation.swift" + ] + ) + end + + def test_presenter_without_folders + generate_and_assert( + source_path: "Sources", + template_name: "presenter", + name: "Pretest", + expected_files: [ + "App/Screen/Pretest/UI/View", + "App/Screen/Pretest/UI/ViewController/PretestViewController.swift", + "App/Screen/Pretest/UI/PretestViewContract.swift", + "App/Screen/Pretest/Presenter/PretestPresenter.swift", + "App/Screen/Pretest/Presenter/PretestPresenterImplementation.swift", + "App/Screen/Pretest/Model", + ] + ) + end + + def test_repository_data_folders + generate_and_assert( + source_path: "Sources", + template_name: "repository", + name: "Retest", + expected_files: ["Data/Repository/Retest/RetestRepositoryImplementation.swift"] + ) + end + + def test_repository_core_folders + generate_and_assert( + source_path: "Sources", + template_name: "repository", + name: "Retest", + expected_files: ["Core/Repository/Retest/RetestRepository.swift"] + ) + end + + private + + def setup_parser + yml_path = File.join(@test_dir, ".ccios.yml") + File.open(yml_path, "w") do |io| + io.puts ccios_yml_swift_package_content + end + + config = Config.parse yml_path + yield(config) + end + + def generate_and_assert(source_path:, template_name:, name:, expected_files:) + setup_parser do |config| + template = TemplatesLoader.new.get_templates(config)[template_name] + raise "Template not found #{template_name}" if template.nil? + + Dir.chdir(@test_dir) do + template.generate(nil, {"name" => name}, config) + + expected_files.each do |file_name| + expected_path = File.join(@test_dir, source_path, file_name) + assert File.exist?(expected_path), "File #{expected_path} does not exist" + end + end + end + end + + def set_up_project_in_temporary_directory + dir = Dir.mktmpdir("ccios-tests", "/tmp") + project_path = File.join(Dir.pwd, "test", "project") + FileUtils.copy_entry project_path, dir + dir + end + + def ccios_yml_swift_package_content + <<-eos +variables: + project_type: spm + +templates_config: + repository: + variables: {} + elements_variables: + repository: + base_path: Sources/Core/Repository + repository_implementation: + base_path: Sources/Data/Repository + presenter: + variables: + base_path: Sources/App/Screen + coordinator: + variables: + base_path: Sources/App/Coordinator + interactor: + variables: + base_path: Sources/Core/Interactor +eos + end +end