# Have fun. Use at your own risk. # Copyright (c) 2014 Johannes Fahrenkrug require 'nokogiri' require 'ostruct' require 'optparse' module StoryboardLint class StoryboardScanner def initialize(src_root) @src_root = src_root @scan_performed = false end def storyboard_files return @sb_files if @sb_files # find all storyboard files... @sb_files = Dir.glob(File.join(@src_root, "**/*.storyboard")) end def scan_files if !@scan_performed @segue_ids ||= [] @storyboard_ids ||= [] @reuse_ids ||= [] @custom_class_names = [] storyboard_files.each do |sb_file| sb_source = File.open(sb_file) doc = Nokogiri::XML(sb_source) doc.xpath('//segue').each do |element| seg_id = element.attr('identifier') @segue_ids << {:file => sb_file, :id => seg_id.to_s} if seg_id end @storyboard_ids += doc.xpath("//@storyboardIdentifier").to_a.map {|match| {:file => sb_file, :id => match.to_s}} @reuse_ids += doc.xpath("//@reuseIdentifier").to_a.map {|match| {:file => sb_file, :id => match.to_s}} @custom_class_names += doc.xpath("//@customClass").to_a.map {|match| {:file => sb_file, :class_name => match.to_s}} end @scan_performed = true end end def segue_ids scan_files @segue_ids end def storyboard_ids scan_files @storyboard_ids end def reuse_ids scan_files @reuse_ids end def custom_class_names scan_files @custom_class_names end end class SourceScanner def initialize(src_root, matcher) @matcher = matcher @src_root = src_root @scan_performed = false end def source_files return @source_files if @source_files # find all *.h, *.c, *.m and *.mm files @source_files = Dir.glob(File.join(@src_root, "**/*.{h,c,m,mm}")) end def scan_files if !@scan_performed @class_names ||= [] @segue_ids ||= [] @storyboard_ids ||= [] @reuse_ids ||= [] source_files.each do |source_file| File.readlines(source_file).each_with_index do |line, idx| # class names line.scan(@matcher.class_regex).each do |match| @class_names << {:file => source_file, :line => idx + 1, :class_name => match[0]} end # segue ids line.scan(@matcher.segue_id_regex_source).each do |match| @segue_ids << {:file => source_file, :line => idx + 1, :id => match[0]} end # storyboard ids line.scan(@matcher.storyboard_id_regex_source).each do |match| @storyboard_ids << {:file => source_file, :line => idx + 1, :id => match[0]} end # reuse ids line.scan(@matcher.reuse_id_regex_source).each do |match| @reuse_ids << {:file => source_file, :line => idx + 1, :id => match[0]} end end end @scan_performed = true end end def class_names scan_files @class_names end def segue_ids scan_files @segue_ids end def storyboard_ids scan_files @storyboard_ids end def reuse_ids scan_files @reuse_ids end end class Matcher DEFAULT_SEGUE_ID_PREFIX = "seg_" DEFAULT_STORYBOARD_ID_PREFIX = "sb_" DEFAULT_REUSE_ID_PREFIX = "ruid_" def initialize(options) options ||= OpenStruct.new @storyboard_id_regex_source = create_source_regex(DEFAULT_STORYBOARD_ID_PREFIX, options.storyboard_prefix, options.storyboard_suffix) @storyboard_id_regex_sb = create_storyboard_regex(DEFAULT_STORYBOARD_ID_PREFIX, options.storyboard_prefix, options.storyboard_suffix) @segue_id_regex_source = create_source_regex(DEFAULT_SEGUE_ID_PREFIX, options.segue_prefix, options.segue_suffix) @segue_id_regex_sb = create_storyboard_regex(DEFAULT_SEGUE_ID_PREFIX, options.segue_prefix, options.segue_suffix) @reuse_id_regex_source = create_source_regex(DEFAULT_REUSE_ID_PREFIX, options.reuse_prefix, options.reuse_suffix) @reuse_id_regex_sb = create_storyboard_regex(DEFAULT_REUSE_ID_PREFIX, options.reuse_prefix, options.reuse_suffix) end def create_source_regex(default_prefix, prefix, suffix) inner_regex_part = %{(?:\\"|[^"])+} if prefix.to_s.empty? and suffix.to_s.empty? return /@"(#{default_prefix}#{inner_regex_part})"/ else return /@"(#{prefix}#{inner_regex_part}#{suffix})"/ end end def create_storyboard_regex(default_prefix, prefix, suffix) inner_regex_part = %{(?:\\"|[^"])+} if prefix.to_s.empty? and suffix.to_s.empty? sb = /^#{default_prefix}/ else if !prefix.to_s.empty? if !suffix.to_s.empty? sb = /^#{prefix}[\w\s]*#{suffix}$/ else !prefix.to_s.empty? sb = /^#{prefix}/ end else sb = /#{suffix}$/ end end sb end def class_regex /@interface\s+([a-zA-Z_]+\w*)/ end [:storyboard, :segue, :reuse].each do |name| [:sb, :source].each do |kind| method_name = "#{name}_id_regex_#{kind}" define_method(method_name) { instance_variable_get("@#{method_name}") } end end end class Linter def initialize(sb_scanner, source_scanner, matcher) @matcher = matcher @sb_scanner = sb_scanner @source_scanner = source_scanner end def lint check_naming check_custom_classes check_ids end def check_naming [{:items => @sb_scanner.segue_ids, :regex => @matcher.segue_id_regex_sb, :name => 'Segue ID'}, {:items => @sb_scanner.storyboard_ids, :regex => @matcher.storyboard_id_regex_sb, :name => 'Storyboard ID'}, {:items => @sb_scanner.reuse_ids, :regex => @matcher.reuse_id_regex_sb, :name => 'Reuse ID'}].each do |data| data[:items].each do |item| if item[:id] !~ data[:regex] puts "warning: #{data[:name]} '#{item[:id]}' used in #{File.basename(item[:file])} does not match '#{data[:regex]}." end end end end def check_custom_classes @sb_scanner.custom_class_names.each do |custom_class| if !@source_scanner.class_names.map {|cn| cn[:class_name]}.include?(custom_class[:class_name]) puts "error: Custom class '#{custom_class[:class_name]}' used in #{File.basename(custom_class[:file])} could not be found in source code." end end end def check_ids [{:method_name => :segue_ids, :name => 'Segue ID'}, {:method_name => :storyboard_ids, :name => 'Storyboard ID'}, {:method_name => :reuse_ids, :name => 'Reuse ID'}].each do |data| @source_scanner.send(data[:method_name]).each do |source_item| if !@sb_scanner.send(data[:method_name]).map {|sb_item| sb_item[:id]}.include?(source_item[:id]) puts "#{source_item[:file]}:#{source_item[:line]}: warning: #{data[:name]} '#{source_item[:id]}' could not be found in any Storyboard." end end end end def self.run!(*args) options = OpenStruct.new opt_parser = OptionParser.new do |opts| opts.banner = "Usage: storyboardlint [options]" opts.separator "" opts.separator "Options" opts.on("--storyboard-prefix [PREFIX]", "Storyboard IDs have to begin with PREFIX.") do |prefix| options.storyboard_prefix = prefix end opts.on("--storyboard-suffix [SUFFIX]", "Storyboard IDs have to end with SUFFIX") do |suffix| options.storyboard_suffix = suffix end opts.on("--segue-prefix [PREFIX]", "Segue IDs have to begin with PREFIX") do |prefix| options.segue_prefix = prefix end opts.on("--segue-suffix [SUFFIX]", "Segue IDs have to end with SUFFIX") do |suffix| options.segue_suffix = suffix end opts.on("--reuse-prefix [PREFIX]", "Reuse IDs have to begin with PREFIX") do |prefix| options.reuse_prefix = prefix end opts.on("--reuse-suffix [SUFFIX]", "Reuse IDs have to end with SUFFIX") do |suffix| options.reuse_suffix = suffix end # No argument, shows at tail. This will print an options summary. # Try it and see! opts.on_tail("-h", "--help", "Show this message") do puts opts exit end # Another typical switch to print the version. opts.on_tail("--version", "Show version") do puts "StoryboardLint v0.1.1" exit end end if ARGV.length < 1 puts opt_parser exit 0 end opt_parser.parse(args) matcher = StoryboardLint::Matcher.new(options) sb_scanner = StoryboardLint::StoryboardScanner.new(ARGV[0]) source_scanner = StoryboardLint::SourceScanner.new(ARGV[0], matcher) linter = StoryboardLint::Linter.new(sb_scanner, source_scanner, matcher) linter.lint return 0 end end end