Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adjust Xcodeproj::Workspace to keep the underlying REXML document and add basic <Group> support. #322

Merged
merged 6 commits into from
Oct 27, 2015
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 114 additions & 28 deletions lib/xcodeproj/workspace.rb
Original file line number Diff line number Diff line change
@@ -1,24 +1,48 @@
require 'fileutils'
require 'rexml/document'
require 'xcodeproj/workspace/file_reference'
require 'xcodeproj/workspace/group_reference'

module Xcodeproj
# Provides support for generating, reading and serializing Xcode Workspace
# documents.
#
class Workspace
# @return [Array<String>] the paths of the projects contained in the
# @return [REXML::Document] the parsed XML model for the workspace contents
attr_reader :document
attr_reader :schemes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this attr needs to be documented


# @return [Array<FileReference>] the paths of the projects contained in the
# workspace.
#
attr_reader :file_references
attr_reader :schemes
def file_references
return [] unless @document
@document.get_elements('/Workspace//FileRef').map do |node|
FileReference.from_node(node)
end
end

# @param [Array] file_references @see file_references
# @return [Array<GroupReference>] the groups contained in the workspace
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should still be param

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you saying the @return should still be @param?

#
def initialize(*file_references)
@file_references = file_references.flatten
def group_references
return [] unless @document
@document.get_elements('/Workspace//Group').map do |node|
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should cache that (@group_references ||= …)?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea of this change is to work against the underlying xml to allow the document structure to remain intact when working with workspaces.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah gotcha, makes sense to ensure it's always in sync with the XML too.

GroupReference.from_node(node)
end
end

# @param [REXML::Document] document @see document
# @param [Array<FileReference>] file_references additional projects to add
#
def initialize(document, *file_references)
@schemes = {}
if !document || document.is_a?(REXML::Document)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefer document.nil? to !document

@document = document
else
@document = REXML::Document.new(root_xml(''))
self << document
end
file_references.each { |ref| self << ref }
end

#-------------------------------------------------------------------------#
Expand All @@ -31,9 +55,10 @@ def initialize(*file_references)
# @return [Workspace] the generated workspace.
#
def self.new_from_xcworkspace(path)
from_s(File.read(File.join(path, 'contents.xcworkspacedata')), File.expand_path(File.dirname(path)))
from_s(File.read(File.join(path, 'contents.xcworkspacedata')),
File.expand_path(File.dirname(path)))
rescue Errno::ENOENT
new
new(nil)
end

#-------------------------------------------------------------------------#
Expand All @@ -48,10 +73,7 @@ def self.new_from_xcworkspace(path)
#
def self.from_s(xml, workspace_path = '')
document = REXML::Document.new(xml)
file_references = document.get_elements('/Workspace/FileRef').map do |node|
FileReference.from_node(node)
end
instance = new(file_references)
instance = new(document)
instance.load_schemes(workspace_path)
instance
end
Expand All @@ -67,9 +89,36 @@ def self.from_s(xml, workspace_path = '')
# @return [void]
#
def <<(projpath)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that the parameter to this method can be either a String or a FileReference, please update the rdoc accordingly

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies for my ignorance -- is this proper syntax for documenting a parameter with two allowed types? The yard @overload documentation seems cumbersome for something like this...

# Adds a new path to the list of the of projects contained in the
# workspace.
# @param [String, Xcodeproj::Workspace::FileReference] path_or_reference
#        A string or Xcode::Workspace::FileReference containing a path to an Xcode project
#
# @raise [ArgumentError] Raised if the input is neither a String nor a FileReference
#
# @return [void]
#
def <<(path_or_reference)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, YARD uses commas to separate the different possible types for a parameter.

For a quick cheat sheet, see http://yardoc.org/types.html

Since you're already in Xcodeproj::Workspace, you may probably only specify FileReference though, instead of the fully-qualified type name.

project_file_reference = Xcodeproj::Workspace::FileReference.new(projpath)
@file_references << project_file_reference
load_schemes_from_project File.expand_path(projpath)
return unless @document && @document.respond_to?(:root)
case
when projpath.is_a?(String)
project_file_reference = Xcodeproj::Workspace::FileReference.new(projpath)
when projpath.is_a?(Xcodeproj::Workspace::FileReference)
project_file_reference = projpath
projpath = nil
else
raise ArgumentError, 'Input to the << operator must be a file path or FileReference'
end
@document.root.add_element(project_file_reference.to_node)
load_schemes_from_project File.expand_path(projpath || project_file_reference.path)
end

#-------------------------------------------------------------------------#

# Adds a new group container to the workspace
# workspace.
#
# @param [String] name The name of the group
#
# @return [Xcodeproj::Workspace::GroupReference]
# The added group reference
#
def add_group(name)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing documentation about the optional block used by yield

return nil unless @document
group = Xcodeproj::Workspace::GroupReference.new(name)
elem = @document.root.add_element(group.to_node)
yield group, elem if block_given?
elem
end

# Checks if the workspace contains the project with the given file
Expand All @@ -81,14 +130,36 @@ def <<(projpath)
# @return [Boolean] whether the project is contained in the workspace.
#
def include?(file_reference)
@file_references.include?(file_reference)
file_references.include?(file_reference)
end

# @return [String] the XML representation of the workspace.
#
def to_s
contents = file_references.map { |reference| file_reference_xml(reference) }
root_xml(contents.join(''))
contents = ''
stack = []
@document.root.each_recursive do |elem|
until stack.empty?
last = stack.last
if last == elem.parent
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer using break if last == elem.parent then remove the else/end to avoid a useless level of indentation

break
else
contents += xcworkspace_element_end_xml(stack.length, stack.last)
stack.pop
end
end

stack << elem
contents += xcworkspace_element_start_xml(stack.length, elem)
end

until stack.empty?
contents += xcworkspace_element_end_xml(stack.length, stack.last)
stack.pop
end

# contents = file_references.map { |reference| file_reference_xml(reference) }
root_xml(contents)
end

# Saves the workspace at the given `xcworkspace` path.
Expand All @@ -115,7 +186,7 @@ def save_as(path)
# @return [void]
#
def load_schemes(workspace_dir_path)
@file_references.each do |file_reference|
file_references.each do |file_reference|
project_full_path = file_reference.absolute_path(workspace_dir_path)
load_schemes_from_project(project_full_path)
end
Expand Down Expand Up @@ -146,21 +217,36 @@ def root_xml(contents)
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
#{contents.strip}
#{contents.rstrip}
</Workspace>
DOC
end

# @return [String] The XML representation of a file reference.
# @return [String] The Xcode-specific XML formatting of an element start
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Convention is to put @return doc lines at the end after @param lines, not before

#
# @param [Integer] depth The depth of the element in the tree
# @param [REXML::Document::Element] elem The XML element to format.
#
def xcworkspace_element_start_xml(depth, elem)
attributes = case elem.name
when 'Group'
%w(location name)
when 'FileRef'
%w(location)
end
contents = "<#{elem.name}"
indent = ' ' * depth
attributes.each { | name| contents += "\n #{name} = \"#{elem.attribute(name).value}\"" }
contents.split("\n").map { |line| "#{indent}#{line}" }.join("\n") + ">\n"
end

# @return [String] The Xcode-specific XML formatting of an element end
#
# @param [FileReference] file_reference The file reference.
# @param [Integer] depth The depth of the element in the tree
# @param [REXML::Document::Element] elem The XML element to format.
#
def file_reference_xml(file_reference)
<<-DOC
<FileRef
location = "#{file_reference.type}:#{file_reference.path}">
</FileRef>
DOC
def xcworkspace_element_end_xml(depth, elem)
"#{' ' * depth}</#{elem.name}>\n"
end

#-------------------------------------------------------------------------#
Expand Down
64 changes: 64 additions & 0 deletions lib/xcodeproj/workspace/group_reference.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
module Xcodeproj
class Workspace
# Describes a group reference of a Workspace.
#
class GroupReference
# @return [String] the name of the group
#
attr_reader :name

# @return [String] the type of reference to the project
#
# This can be of the following values:
# - absolute
# - group
# - container (only supported value)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we throw in case we encounter a non-supported value, or just silently fail? (\cc @segiddins)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably throw? But it's not super important

# - developer
#
attr_reader :type

# @param [#to_s] name @see name
# @param [#to_s] type @see type
#
def initialize(name, type = 'container')
@name = name.to_s
@type = type.to_s
end

# @return [Bool] Whether a group reference is equal to another.
#
def ==(other)
name == other.name && type == other.type
end
alias_method :eql?, :==

# @return [Fixnum] A hash identical for equals objects.
#
def hash
[name, type].hash
end

# Returns a group reference given XML representation.
#
# @param [REXML::Element] xml_node
# the XML representation.
#
# @return [GroupReference] The new group reference instance.
#
def self.from_node(xml_node)
type, _ = xml_node.attribute('location').value.split(':', 2)
name = xml_node.attribute('name').value
new(name, type)
end

# @return [REXML::Element] the XML representation of the group reference.
#
def to_node
REXML::Element.new('Group').tap do |element|
element.add_attribute('location', "#{type}:")
element.add_attribute('name', "#{name}")
end
end
end
end
end
7 changes: 7 additions & 0 deletions spec/fixtures/libPusher.xcworkspace/contents.xcworkspacedata

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions spec/workspace/group_reference_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
require File.expand_path('../../spec_helper', __FILE__)

module Xcodeproj
describe Workspace do
before do
@group = Workspace::GroupReference.new('Group Name', 'container')
end

it 'properly implements equality comparison' do
@group.should == @group.dup
@group.should.eql @group.dup
@group.hash.should == @group.dup.hash
end

it 'can be initialized by the XML representation' do
node = REXML::Element.new('Group')
node.attributes['location'] = 'container:'
node.attributes['name'] = 'Group Name'
result = Workspace::GroupReference.from_node(node)
result.should == @group
end

it 'returns the XML representation' do
result = @group.to_node
result.class.should == REXML::Element
result.to_s.should == "<Group location='container:' name='Group Name'/>"
end

it 'can be converted back and forth without loss of information' do
result = Workspace::GroupReference.from_node(@group.to_node)
result.should == @group
end

it 'escapes XML entities' do
file = Workspace::GroupReference.new('"&\'><.xcodeproj', 'group')
result = file.to_node
result.class.should == REXML::Element
result.to_s.should == "<Group location='group:' name='&quot;&amp;&apos;&gt;&lt;.xcodeproj'/>"
end
end
end
Loading