class Friends::Introvert
Constants
- ACTIVITIES_HEADER
- FRIENDS_HEADER
- LOCATIONS_HEADER
- NA_STR
- NOTES_HEADER
- PARSING_STAGES
- ParsingStage
Used internally by the parse_line! method above to associate stages with the class to create.
Attributes
Public Class Methods
@param filename [String] the name of the friends Markdown file
# File lib/friends/introvert.rb, line 25 def initialize(filename:) @user_facing_filename = filename @expanded_filename = File.expand_path(filename) @output = [] # Read in the input file. It's easier to do this now and optimize later # than try to overly be clever about what we read and write. read_file end
Public Instance Methods
Add an activity. @param serialization [String] the serialized activity
# File lib/friends/introvert.rb, line 99 def add_activity(serialization:) Activity.deserialize(serialization).tap do |activity| # If there's no description, prompt the user for one. if activity.description.nil? || activity.description.empty? activity.description = Readline.readline(activity.to_s).to_s.strip raise FriendsError, "Blank activity not added" if activity.description.empty? end activity.highlight_description(introvert: self) @output << "Activity added: \"#{activity}\"" @output << default_location_output(activity) if activity.default_location @activities.unshift(activity) end end
Add an alias to an existing location. @param name [String] the name of the location @param nickname [String] the alias to add to the location @raise [FriendsError] if 0 or 2+ locations match the given name @raise [FriendsError] if the alias is already taken
# File lib/friends/introvert.rb, line 220 def add_alias(name:, nickname:) raise FriendsError, "Expected \"[Location Name]\" \"[Alias]\"" if name.empty? raise FriendsError, "Alias cannot be blank" if nickname.empty? collision = @locations.find do |loc| loc.name.casecmp(nickname).zero? || loc.aliases.any? { |a| a.casecmp(nickname).zero? } end if collision raise FriendsError, "The location alias \"#{nickname}\" is already taken by "\ "\"#{collision}\"" end location = thing_with_name_in(:location, name) location.add_alias(nickname) @output << "Alias added: \"#{location}\"" end
Add a friend. @param name [String] the name of the friend to add @raise [FriendsError] when a friend with that name is already in the file
# File lib/friends/introvert.rb, line 85 def add_friend(name:) if @friends.any? { |friend| friend.name == name } raise FriendsError, "Friend named \"#{name}\" already exists" end friend = Friend.deserialize(name) @friends << friend @output << "Friend added: \"#{friend.name}\"" end
Add a location. @param name [String] the serialized location @raise [FriendsError] if a location with that name already exists
# File lib/friends/introvert.rb, line 140 def add_location(name:) if @locations.any? { |location| location.name == name } raise FriendsError, "Location \"#{name}\" already exists" end location = Location.deserialize(name) @locations << location @output << "Location added: \"#{location.name}\"" # Return the added location. end
Add a nickname to an existing friend. @param name [String] the name of the friend @param nickname [String] the nickname to add to the friend @raise [FriendsError] if 0 or 2+ friends match the given name
# File lib/friends/introvert.rb, line 205 def add_nickname(name:, nickname:) raise FriendsError, "Expected \"[Friend Name]\" \"[Nickname]\"" if name.empty? raise FriendsError, "Nickname cannot be blank" if nickname.empty? friend = thing_with_name_in(:friend, name) friend.add_nickname(nickname) @output << "Nickname added: \"#{friend}\"" end
Add a note. @param serialization [String] the serialized note
# File lib/friends/introvert.rb, line 120 def add_note(serialization:) Note.deserialize(serialization).tap do |note| # If there's no description, prompt the user for one. if note.description.nil? || note.description.empty? note.description = Readline.readline(note.to_s).to_s.strip raise FriendsError, "Blank note not added" if note.description.empty? end note.highlight_description(introvert: self) @notes.unshift(note) @output << "Note added: \"#{note}\"" end end
Add a tag to an existing friend. @param name [String] the name of the friend @param tag [String] the tag to add to the friend, of the form: “@tag” @raise [FriendsError] if 0 or 2+ friends match the given name
# File lib/friends/introvert.rb, line 244 def add_tag(name:, tag:) raise FriendsError, "Expected \"[Friend Name]\" \"[Tag]\"" if name.empty? raise FriendsError, "Tag cannot be blank" if tag == "@" friend = thing_with_name_in(:friend, name) friend.add_tag(tag) @output << "Tag added to friend: \"#{friend}\"" end
Write out the friends file with cleaned/sorted data. @param clean_command [Boolean] true iff the command the user
executed is `friends clean`; false if this is called as the result of another command
# File lib/friends/introvert.rb, line 41 def clean(clean_command:) friend_names = Set.new(@friends.map(&:name)) location_names = Set.new(@locations.map(&:name)) # Iterate through all events and add missing friends and # locations. (@activities + @notes).each do |event| event.friend_names.each do |name| unless friend_names.include? name add_friend(name: name) friend_names << name end end event.description_location_names.each do |name| unless location_names.include? name add_location(name: name) location_names << name end end end File.open(@expanded_filename, "w") do |file| file.puts(ACTIVITIES_HEADER) stable_sort(@activities).each { |act| file.puts(act.serialize) } file.puts # Blank line separating activities from notes. file.puts(NOTES_HEADER) stable_sort(@notes).each { |note| file.puts(note.serialize) } file.puts # Blank line separating notes from friends. file.puts(FRIENDS_HEADER) @friends.sort.each { |friend| file.puts(friend.serialize) } file.puts # Blank line separating friends from locations. file.puts(LOCATIONS_HEADER) @locations.sort.each { |location| file.puts(location.serialize) } end # This is a special-case piece of code that lets us print a message that # includes the filename when `friends clean` is called. @output << "File cleaned: \"#{@user_facing_filename}\"" if clean_command end
Graph
activities over time. Optionally filter by friend, location and tag
The graph displays all of the months (inclusive) between the first and last month in which activities have been recorded.
@param with [Array<String>] the names of friends to filter by, or empty for
unfiltered
@param location_name [String] the name of a location to filter by, or
nil for unfiltered
@param tagged [Array<String>] the names of tags to filter by, or empty for
unfiltered
@param since_date [Date] a date on or after which to find activities, or nil for unfiltered @param until_date [Date] a date before or on which to find activities, or nil for unfiltered @param unscaled [Boolean] true iff we should show the absolute size of bars in the graph
rather than a scaled version
@raise [FriendsError] if friend, location or tag cannot be found or
is ambiguous
# File lib/friends/introvert.rb, line 372 def graph(with:, location_name:, tagged:, since_date:, until_date:, unscaled:) filtered_activities_to_graph = filtered_events( events: @activities, with: with, location_name: location_name, tagged: tagged, since_date: since_date, until_date: until_date ) # If the user wants to graph in a specific date range, we explicitly # limit our output to that date range. We don't just use the date range # of the first and last `filtered_activities_to_graph` because those # activities might not include others in the full range (for instance, # if only one filtered activity matches a query, we don't want to only # show unfiltered activities that occurred on that specific day). all_activities_to_graph = filtered_events( events: @activities, with: [], location_name: nil, tagged: [], # By including all activities for the "fencepost" months in our totals, # we prevent those months from being always "full" in the graph # because all filtered events will match the criteria. since_date: (since_date.prev_day(since_date.day - 1) if since_date), until_date: (until_date.prev_day(until_date.day - 1).next_month.prev_day if until_date) ) Graph.new( filtered_activities: filtered_activities_to_graph, all_activities: all_activities_to_graph, unscaled: unscaled ).output.each { |line| @output << line } end
See `list_events` for all of the parameters we can pass.
# File lib/friends/introvert.rb, line 327 def list_activities(**args) list_events(events: @activities, **args) end
List all friend names in the friends file. @param location_name [String] the name of a location to filter by, or nil
for unfiltered
@param tagged [Array<String>] the names of tags to filter by, or empty for
unfiltered
@param verbose [Boolean] true iff we should output friend names with
nicknames, locations, and tags; false for names only
@param sort [String] one of:
["alphabetical", "n-activities", "recency"]
@param reverse [Boolean] true iff we should reverse the sorted order of
our output
# File lib/friends/introvert.rb, line 304 def list_friends(location_name:, tagged:, verbose:, sort:, reverse:) fs = @friends # Filter by location if a name is passed. if location_name location = thing_with_name_in(:location, location_name) fs = fs.select { |friend| friend.location_name == location.name } end # Filter by tag if param is passed. unless tagged.empty? fs = fs.select do |friend| tagged.all? { |tag| friend.tags.map(&:downcase).include? tag.downcase } end end list_things(type: :friend, arr: fs, verbose: verbose, sort: sort, reverse: reverse) end
List all location names in the friends file. @param verbose [Boolean] true iff we should output location names with
aliases; false for names only
@param sort [String] one of:
["alphabetical", "n-activities", "recency"]
@param reverse [Boolean] true iff we should reverse the sorted order of
our output
# File lib/friends/introvert.rb, line 343 def list_locations(verbose:, sort:, reverse:) list_things(type: :location, verbose: verbose, sort: sort, reverse: reverse) end
See `list_events` for all of the parameters we can pass.
# File lib/friends/introvert.rb, line 332 def list_notes(**args) list_events(events: @notes, **args) end
Get a regex friend map.
The returned hash uses the following format:
{ /regex/ => [list of friends matching regex] }
This hash is sorted (because Ruby's hashes are ordered) by decreasing regex key length, so the key /Jacob Evelyn/ appears before /Jacob/.
@return [Hash{Regexp => Array<Friends::Friend>}]
# File lib/friends/introvert.rb, line 463 def regex_friend_map @friends.each_with_object(Hash.new { |h, k| h[k] = [] }) do |friend, hash| friend.regexes_for_name.each do |regex| hash[regex] << friend end end.sort_by { |k, _| -k.to_s.size }.to_h end
Get a regex location map.
The returned hash uses the following format:
{ /regex/ => location }
This hash is sorted (because Ruby's hashes are ordered) by decreasing regex key length, so the key /Paris, France/ appears before /Paris/.
@return [Hash{Regexp => location}]
# File lib/friends/introvert.rb, line 482 def regex_location_map @locations.each_with_object({}) do |location, hash| location.regexes_for_name.each { |regex| hash[regex] = location } end.sort_by { |k, _| -k.to_s.size }.to_h end
Remove an alias from an existing location. @param name [String] the name of the location @param nickname [String] the alias to remove from the location @raise [FriendsError] if 0 or 2+ locations match the given name @raise [FriendsError] if the location does not have the given alias
# File lib/friends/introvert.rb, line 283 def remove_alias(name:, nickname:) raise FriendsError, "Expected \"[Location Name]\" \"[Alias]\"" if name.empty? raise FriendsError, "Alias cannot be blank" if nickname.empty? location = thing_with_name_in(:location, name) location.remove_alias(nickname) @output << "Alias removed: \"#{location}\"" end
Remove a nickname from an existing friend. @param name [String] the name of the friend @param nickname [String] the nickname to remove from the friend @raise [FriendsError] if 0 or 2+ friends match the given name @raise [FriendsError] if the friend does not have the given nickname
# File lib/friends/introvert.rb, line 271 def remove_nickname(name:, nickname:) friend = thing_with_name_in(:friend, name) friend.remove_nickname(nickname) @output << "Nickname removed: \"#{friend}\"" end
Remove a tag from an existing friend. @param name [String] the name of the friend @param tag [String] the tag to remove from the friend, of the form: “@tag” @raise [FriendsError] if 0 or 2+ friends match the given name @raise [FriendsError] if the friend does not have the given nickname
# File lib/friends/introvert.rb, line 259 def remove_tag(name:, tag:) friend = thing_with_name_in(:friend, name) friend.remove_tag(tag) @output << "Tag removed from friend: \"#{friend}\"" end
Rename an existing friend. @param old_name [String] the name of the friend @param new_name [String] the new name of the friend @raise [FriendsError] if 0 or 2+ friends match the given name
# File lib/friends/introvert.rb, line 169 def rename_friend(old_name:, new_name:) friend = thing_with_name_in(:friend, old_name) (@activities + @notes).each do |event| event.update_friend_name(old_name: friend.name, new_name: new_name) end friend.name = new_name @output << "Name changed: \"#{friend}\"" end
Rename an existing location. @param old_name [String] the name of the location @param new_name [String] the new name of the location @raise [FriendsError] if 0 or 2+ friends match the given name
# File lib/friends/introvert.rb, line 183 def rename_location(old_name:, new_name:) loc = thing_with_name_in(:location, old_name) # Update locations in activities and notes. (@activities + @notes).each do |event| event.update_location_name(old_name: loc.name, new_name: new_name) end # Update locations of friends. @friends.select { |f| f.location_name == loc.name }.each do |friend| friend.location_name = new_name end loc.name = new_name # Update location itself. @output << "Location renamed: \"#{loc.name}\"" end
Sets the likelihood_score field on each friend in `possible_matches`. This score represents how likely it is that an activity containing the friends in `matches` and containing a friend from each group in `possible_matches` contains that given friend. @param matches [Array<Friend>] the friends in a specific activity @param possible_matches [Array<Array<Friend>>] an array of groups of
possible matches, for example: [ [Friend.new(name: "John Doe"), Friend.new(name: "John Deere")], [Friend.new(name: "Aunt Mae"), Friend.new(name: "Aunt Sue")] ] These groups will all contain friends with similar names; the purpose of this method is to give us a likelihood that a "John" in an activity description, for instance, is "John Deere" vs. "John Doe"
# File lib/friends/introvert.rb, line 502 def set_likelihood_score!(matches:, possible_matches:) combinations = (matches + possible_matches.flatten). combination(2). reject do |friend1, friend2| (matches & [friend1, friend2]).size == 2 || possible_matches.any? do |group| (group & [friend1, friend2]).size == 2 end end @activities.each do |activity| names = activity.friend_names combinations.each do |group| if (names & group.map(&:name)).size == 2 group.each { |friend| friend.likelihood_score += 1 } end end end end
Set a friend's location. @param name [String] the friend's name @param location_name [String] the name of an existing location @raise [FriendsError] if 0 or 2+ friends match the given name @raise [FriendsError] if 0 or 2+ locations match the given location name
# File lib/friends/introvert.rb, line 157 def set_location(name:, location_name:) friend = thing_with_name_in(:friend, name) location = thing_with_name_in(:location, location_name) friend.location_name = location.name @output << "#{friend.name}'s location set to: \"#{location.name}\"" end
# File lib/friends/introvert.rb, line 523 def stats events = @activities + @notes elapsed_days = if events.size < 2 0 else sorted_events = events.sort (sorted_events.first.date - sorted_events.last.date).to_i end @output << "Total activities: #{@activities.size}" @output << "Total friends: #{@friends.size}" @output << "Total locations: #{@locations.size}" @output << "Total notes: #{@notes.size}" @output << "Total tags: #{tags.size}" @output << "Total time elapsed: #{elapsed_days} day#{'s' if elapsed_days != 1}" end
Suggest friends to do something with.
The returned hash uses the following format:
{ distant: ["Distant Friend 1 Name", "Distant Friend 2 Name", ...], moderate: ["Moderate Friend 1 Name", "Moderate Friend 2 Name", ...], close: ["Close Friend 1 Name", "Close Friend 2 Name", ...] }
@param location_name [String] the name of a location to filter by, or nil
for unfiltered
# File lib/friends/introvert.rb, line 419 def suggest(location_name:) # Filter our friends by location if necessary. fs = @friends fs = fs.select { |f| f.location_name == location_name } if location_name # Sort our friends, with the least favorite friend first. sorted_friends = fs.sort_by(&:n_activities) # Set initial value in case there are no friends and the while loop is # never entered. distant_friend_names = [] # First, get not-so-good friends. while !sorted_friends.empty? && sorted_friends.first.n_activities < 2 distant_friend_names << sorted_friends.shift.name end moderate_friend_names = sorted_friends.slice!(0, sorted_friends.size * 3 / 4). map!(&:name) close_friend_names = sorted_friends.map!(&:name) @output << "Distant friend: "\ "#{Paint[distant_friend_names.sample || 'None found', :bold, :magenta]}" @output << "Moderate friend: "\ "#{Paint[moderate_friend_names.sample || 'None found', :bold, :magenta]}" @output << "Close friend: "\ "#{Paint[close_friend_names.sample || 'None found', :bold, :magenta]}" end
Private Instance Methods
Raise an error that a line in the friends file is malformed. @param expected [String] the expected contents of the line @param line_num [Integer] the line number @raise [FriendsError] with a constructed message
# File lib/friends/introvert.rb, line 831 def bad_line(expected, line_num) raise FriendsError, "Expected \"#{expected}\" on line #{line_num}" end
@param [Activity] the activity that was added by the user @return [String] specifying default location and its time range
# File lib/friends/introvert.rb, line 837 def default_location_output(activity) str = "Default location" earlier_activities, later_activities = @activities.partition { |a| a.date <= activity.date } earlier_activity_with_default_location = activity earlier_activities.each do |a| next unless a.default_location break unless a.default_location == activity.default_location earlier_activity_with_default_location = a end unless later_activities.empty? str += " from #{Paint[earlier_activity_with_default_location.date, :bold]}" later_activity = later_activities.find do |a| a.default_location && a.default_location != activity.default_location end str += " to #{Paint[later_activity&.date || 'present', :bold]}" end str += " already" if earlier_activity_with_default_location != activity "#{str} set to: \"#{activity.default_location}\"" end
Filter activities by friend, location and tag @param events [Array<Event>] the base events to list, either @activities or @notes @param with [Array<String>] the names of friends to filter by, or empty for
unfiltered
@param location_name [String] the name of a location to filter by, or
nil for unfiltered
@param tagged [Array<String>] the names of tags to filter by, or empty for
unfiltered
@param since_date [Date] a date on or after which to find activities, or nil for unfiltered @param until_date [Date] a date before or on which to find activities, or nil for unfiltered @return [Array] an array of activities or notes @raise [FriendsError] if friend, location or tag cannot be found or
is ambiguous
# File lib/friends/introvert.rb, line 655 def filtered_events(events:, with:, location_name:, tagged:, since_date:, until_date:) # Filter by friend name if argument is passed. unless with.empty? friends = with.map { |name| thing_with_name_in(:friend, name) } events = events.select do |event| friends.all? { |friend| event.includes_friend?(friend) } end end # Filter by location name if argument is passed. unless location_name.nil? location = thing_with_name_in(:location, location_name) events = events.select { |event| event.includes_location?(location) } end # Filter by tag if argument is passed. unless tagged.empty? events = events.select do |event| tagged.all? { |tag| event.includes_tag?(tag) } end end # Filter by date if arguments are passed. events = events.select { |event| event.date >= since_date } unless since_date.nil? events = events.select { |event| event.date <= until_date } unless until_date.nil? events end
List all event details. @param events [Array<Event>] the base events to list, either @activities or @notes @param with [Array<String>] the names of friends to filter by, or empty for
unfiltered
@param location_name [String] the name of a location to filter by, or
nil for unfiltered
@param tagged [Array<String>] the names of tags to filter by, or empty for
unfiltered
@param since_date [Date] a date on or after which to find events, or nil for unfiltered @param until_date [Date] a date before or on which to find events, or nil for unfiltered @raise [FriendsError] if friend, location or tag cannot be found or
is ambiguous
# File lib/friends/introvert.rb, line 616 def list_events(events:, with:, location_name:, tagged:, since_date:, until_date:) events = filtered_events( events: events, with: with, location_name: location_name, tagged: tagged, since_date: since_date, until_date: until_date ) events.each { |event| @output << event.to_s } end
List either friends or activities @param arr [Array<Friend|Activity>] a filtered list to print @param verbose [Boolean] true iff we should output names with
aliases/nicknames/etc.; false for names only
@param sort [String] one of:
["alphabetical", "n-activities", "recency"]
@param reverse [Boolean] true iff we should reverse the sorted order of
our output
# File lib/friends/introvert.rb, line 551 def list_things(type:, arr: instance_variable_get("@#{type}s"), verbose:, sort:, reverse:) case sort when "alphabetical" arr = stable_sort(arr) # In case the input file was not already sorted. when "n-activities" arr = stable_sort_by(arr) { |thing| -thing.n_activities } when "recency" today = Date.today most_recent_activity_by_thing = @activities.each_with_object({}) do |activity, output| activity.send("#{type}_names").each do |thing_name| output[thing_name] = (today - activity.date).to_i unless output.key?(thing_name) end end arr = stable_sort_by(arr) do |thing| most_recent_activity_by_thing[thing.name] || -Float::INFINITY end end (reverse ? arr.reverse : arr).each do |thing| case sort when "n-activities" prefix = "#{Paint[thing.n_activities, :bold, :red]} "\ "activit#{thing.n_activities == 1 ? 'y' : 'ies'}: " when "recency" n_days = most_recent_activity_by_thing[thing.name] || NA_STR prefix = "#{Paint[n_days, :bold, :red]} "\ "day#{'s' unless n_days == 1} ago: " end @output << "#{prefix}#{verbose ? thing.to_s : thing.name}" end end
Parse the given line, adding to the various internal data structures as necessary. @param line [String] @param line_num [Integer] the 1-indexed file line number we're parsing @param state [Symbol] the state of the parsing, one of:
[:unknown, :reading_activities, :reading_friends, :reading_locations]
@return [Symbol] the updated state after parsing the given line
# File lib/friends/introvert.rb, line 759 def parse_line!(line, line_num:, state:) return :unknown if line == "" # If we're in an unknown state, look for a header to tell us what we're # parsing next. if state == :unknown PARSING_STAGES.each do |stage| if line == self.class.const_get("#{stage.id.to_s.upcase}_HEADER") return "reading_#{stage.id}".to_sym end end # If we made it here, we couldn't recognize a header. bad_line("a valid header", line_num) end # If we made it this far, we're parsing objects in a class. stage = PARSING_STAGES.find { |s| state == "reading_#{s.id}".to_sym } begin instance_variable_get("@#{stage.id}") << stage.klass.deserialize(line) rescue StandardError => e bad_line(e, line_num) end state end
Process the friends.md file and store its contents in internal data structures.
# File lib/friends/introvert.rb, line 726 def read_file @friends = [] @activities = [] @notes = [] @locations = [] return unless File.exist?(@expanded_filename) state = :unknown # Loop through all lines in the file and process them. File.foreach(@expanded_filename).with_index(1) do |line, line_num| line.chomp! # Remove trailing newline from each line. # Parse the line and update the parsing state. state = parse_line!(line, line_num: line_num, state: state) end # sort the activities from earliest to latest, in case friends.md has been corrupted @activities = stable_sort(@activities) set_implicit_locations! set_n_activities!(:friend) set_n_activities!(:location) end
# File lib/friends/introvert.rb, line 715 def set_implicit_locations! implicit_location = nil # reverse_each here moves through the activities in chronological order @activities.reverse_each do |activity| implicit_location = activity.default_location if activity.default_location activity.implicit_location = implicit_location if activity.description_location_names.empty? end end
Sets the n_activities field on each thing. @param type [Symbol] one of: [:friend, :location] @raise [ArgumentError] if `type` is not one of: [:friend, :location]
# File lib/friends/introvert.rb, line 687 def set_n_activities!(type) unless [:friend, :location].include? type raise ArgumentError, "Type must be either :friend or :location" end # Construct a hash of location name to frequency of appearance. freq_table = Hash.new { |h, k| h[k] = 0 } @activities.each do |activity| activity.send("#{type}_names").each do |thing_name| freq_table[thing_name] += 1 end end # Remove names that are not in the locations list. freq_table.each do |name, count| things = instance_variable_get("@#{type}s").select do |thing| thing.name == name end # Do nothing if no matches found. if things.size == 1 things.first.n_activities = count elsif things.size > 1 raise FriendsError, "More than one #{type} named \"#{name}\"" end end end
@param arr [Array] an unsorted array @return [Array] a stably-sorted array
# File lib/friends/introvert.rb, line 631 def stable_sort(arr) arr.sort_by.with_index { |x, idx| [x, idx] } end
@param arr [Array] an unsorted array @param &block [block] used to return a value for each element's sort position @return [Array] a stably-sorted array
# File lib/friends/introvert.rb, line 638 def stable_sort_by(arr) arr.sort_by.with_index { |x, idx| [yield(x), idx] } end
@param type [Symbol] one of: [:friend, :location] @param text [String] the name (or substring) of the friend or location to
search for
@return [Friend/Location] the friend or location that matches @raise [FriendsError] if 0 or 2+ friends match the given text
# File lib/friends/introvert.rb, line 802 def thing_with_name_in(type, text) things = instance_variable_get("@#{type}s").select do |thing| thing.regexes_for_name.any? { |regex| regex.match(text) } end # If there's more than one match with fuzzy regexes but exactly one thing # with that exact name, match it. if things.size > 1 exact_things = things.select do |thing| thing.name.casecmp(text).zero? # We ignore case for an "exact" match. end things = exact_things if exact_things.size == 1 end case things.size when 1 then things.first # If exactly one thing matches, use that thing. when 0 then raise FriendsError, "No #{type} found for \"#{text}\"" else raise FriendsError, "More than one #{type} found for \"#{text}\": "\ "#{things.map(&:name).join(', ')}" end end