Module: Tempest::REPL::Formatter

Defined in:
lib/tempest/repl/formatter.rb

Overview

Renders posts and Jetstream events as terminal lines, earthquake-style:

[$AA] [HH:MM] @handle: text

The leading [$AA] is only emitted when a Registry is supplied (and the event is something that can be replied to — a post, not a delete or a like/repost record). URLs found in the body are annotated inline with their own ($LA) ids when a registry is supplied. ANSI colors are emitted only when Formatter.color is true (set by the CLI when stdout is a TTY); tests run with color disabled.

Constant Summary collapse

RESET =
"\e[0m".freeze
CYAN =
"\e[36m".freeze
GREEN =
"\e[32m".freeze
DIM =
"\e[2m".freeze
HASHTAG_BLUE =
"\e[38;5;110m".freeze
HASHTAG_PATTERN =
/#[[:alnum:]_]+/.freeze
URL_PATTERN =
%r{https?://[^\s]+}.freeze
DECORATE_PATTERN =
Regexp.union(URL_PATTERN, HASHTAG_PATTERN).freeze

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.colorObject

Returns the value of attribute color.



28
29
30
# File 'lib/tempest/repl/formatter.rb', line 28

def color
  @color
end

Class Method Details

.annotate_urls(text, registry, facets: nil) ⇒ Object



122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/tempest/repl/formatter.rb', line 122

def annotate_urls(text, registry, facets: nil)
  return text unless registry
  text = text.to_s
  if facets && !facets.empty?
    return annotate_urls_with_facets(text, registry, facets)
  end

  urls = URI.extract(text, ["http", "https"]).uniq
  urls.each do |url|
    var = registry.assign_url(url)
    text = text.sub(url, "#{url} (#{var})")
  end
  text
end

.annotate_urls_with_facets(text, registry, facets) ⇒ Object



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/tempest/repl/formatter.rb', line 137

def annotate_urls_with_facets(text, registry, facets)
  text = text.dup.force_encoding(Encoding::UTF_8)
  bytesize = text.bytesize
  valid = facets
    .select { |f| f.byte_start.is_a?(Integer) && f.byte_end.is_a?(Integer) }
    .select { |f| f.byte_start >= 0 && f.byte_end <= bytesize && f.byte_start < f.byte_end }
    .sort_by(&:byte_start)

  # Assign vars in reading order so earlier facets get earlier ids.
  replacements = valid.map do |facet|
    var = registry.assign_url(facet.uri)
    domain = host_of(facet.uri) || facet.uri
    [facet, "[#{domain} #{var}]"]
  end

  # Apply substitutions in reverse byte order so earlier ranges remain valid.
  replacements.reverse_each do |facet, replacement|
    head = text.byteslice(0, facet.byte_start) || ""
    tail = text.byteslice(facet.byte_end, text.bytesize - facet.byte_end) || ""
    text = (head + replacement + tail).force_encoding(Encoding::UTF_8)
  end
  text
end

.bracket(time) ⇒ Object



188
189
190
# File 'lib/tempest/repl/formatter.rb', line 188

def bracket(time)
  Formatter.color ? "#{CYAN}[#{time}]#{RESET} " : "[#{time}] "
end

.compose(var, time, handle, did, text) ⇒ Object



180
181
182
183
184
185
186
# File 'lib/tempest/repl/formatter.rb', line 180

def compose(var, time, handle, did, text)
  prefix = ""
  prefix += id_label(var) if var
  prefix += bracket(time) if time
  identity = handle ? handle_label(handle) : did_label(did)
  "#{prefix}#{identity}: #{text}"
end

.decorate_body(text) ⇒ Object



43
44
45
46
47
48
49
50
51
# File 'lib/tempest/repl/formatter.rb', line 43

def decorate_body(text)
  return text unless Formatter.color
  return text if text.nil? || text.empty?

  text.gsub(DECORATE_PATTERN) do |match|
    color = match.start_with?("http") ? DIM : HASHTAG_BLUE
    "#{color}#{match}#{RESET}"
  end
end

.did_label(did) ⇒ Object



200
201
202
# File 'lib/tempest/repl/formatter.rb', line 200

def did_label(did)
  Formatter.color ? "#{DIM}<#{did}>#{RESET}" : "<#{did}>"
end

.event_line(event, registry: nil, resolver: nil) ⇒ Object



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/tempest/repl/formatter.rb', line 73

def event_line(event, registry: nil, resolver: nil)
  handle = resolver&.resolve(event.did)
  if event.operation == :delete
    body = "(deleted #{event.collection}/#{event.rkey})"
    var = nil
  elsif event.respond_to?(:like?) && event.like?
    body = "liked #{subject_owner_label(event.subject_uri, resolver)}"
    var = nil
  elsif event.respond_to?(:repost?) && event.repost?
    body = "reposted #{subject_owner_label(event.subject_uri, resolver)}"
    var = nil
  else
    facets = event.respond_to?(:facets) ? event.facets : nil
    body = annotate_urls(squeeze(event.text), registry, facets: facets)
    body = decorate_body(body)
    body = prepend_reply_marker(body, reply_parent_uri_of(event), registry)
    var = registry&.assign_post(event)
  end
  compose(var, format_time(event.created_at), handle, event.did, body)
end

.format_status_time(time) ⇒ Object



69
70
71
# File 'lib/tempest/repl/formatter.rb', line 69

def format_status_time(time)
  time.respond_to?(:localtime) ? time.localtime.strftime("%H:%M") : time.to_s
end

.format_time(iso) ⇒ Object



173
174
175
176
177
178
# File 'lib/tempest/repl/formatter.rb', line 173

def format_time(iso)
  return nil if iso.nil? || iso.empty?
  Time.iso8601(iso).localtime.strftime("%H:%M")
rescue ArgumentError
  nil
end

.handle_label(handle) ⇒ Object



196
197
198
# File 'lib/tempest/repl/formatter.rb', line 196

def handle_label(handle)
  Formatter.color ? "#{GREEN}@#{handle}#{RESET}" : "@#{handle}"
end

.host_of(uri) ⇒ Object



161
162
163
164
165
166
167
# File 'lib/tempest/repl/formatter.rb', line 161

def host_of(uri)
  parsed = URI.parse(uri)
  host = parsed.host
  host && !host.empty? ? host : nil
rescue URI::InvalidURIError
  nil
end

.id_label(var) ⇒ Object



192
193
194
# File 'lib/tempest/repl/formatter.rb', line 192

def id_label(var)
  Formatter.color ? "#{DIM}[#{var}]#{RESET} " : "[#{var}] "
end

.post_line(post, registry: nil) ⇒ Object



34
35
36
37
38
39
40
41
# File 'lib/tempest/repl/formatter.rb', line 34

def post_line(post, registry: nil)
  var = registry&.assign_post(post)
  facets = post.respond_to?(:facets) ? post.facets : nil
  body = annotate_urls(squeeze(post.text), registry, facets: facets)
  body = decorate_body(body)
  body = prepend_reply_marker(body, reply_parent_uri_of(post), registry)
  compose(var, format_time(post.created_at), post.handle, nil, body)
end

.prepend_reply_marker(body, reply_parent_uri, registry) ⇒ Object



113
114
115
116
117
118
119
120
# File 'lib/tempest/repl/formatter.rb', line 113

def prepend_reply_marker(body, reply_parent_uri, registry)
  return body if reply_parent_uri.nil? || reply_parent_uri.empty?
  return body unless registry

  parent_var = registry.var_for_uri(reply_parent_uri)
  marker = parent_var ? "#{parent_var} " : ""
  "#{marker}#{body}"
end

.reply_parent_uri_of(record) ⇒ Object



109
110
111
# File 'lib/tempest/repl/formatter.rb', line 109

def reply_parent_uri_of(record)
  record.respond_to?(:reply_parent_uri) ? record.reply_parent_uri : nil
end

.squeeze(text) ⇒ Object



169
170
171
# File 'lib/tempest/repl/formatter.rb', line 169

def squeeze(text)
  text.to_s.gsub(/\s*\n\s*/, " ")
end

.status_line(status) ⇒ Object



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/tempest/repl/formatter.rb', line 53

def status_line(status)
  body = case status.state
  when :disconnected
    status.reason == :error && status.error ? "disconnected: #{status.error.message}" : "disconnected"
  when :reconnecting
    "reconnecting..."
  when :live
    "live"
  when :gapped
    "fetching timeline (offline since #{format_status_time(status.since)})"
  else
    status.state.to_s
  end
  Formatter.color ? "#{DIM}-- #{body}#{RESET}" : "-- #{body}"
end

.subject_did(subject_uri) ⇒ Object



103
104
105
106
107
# File 'lib/tempest/repl/formatter.rb', line 103

def subject_did(subject_uri)
  return nil if subject_uri.nil? || subject_uri.empty?
  match = subject_uri.match(%r{\Aat://([^/]+)/})
  match && match[1]
end

.subject_owner_label(subject_uri, resolver) ⇒ Object



94
95
96
97
98
99
100
101
# File 'lib/tempest/repl/formatter.rb', line 94

def subject_owner_label(subject_uri, resolver)
  did = subject_did(subject_uri)
  return "a post" unless did

  handle = resolver&.resolve(did)
  owner = handle ? handle_label(handle) : did_label(did)
  "#{owner}'s post"
end