klenod-build 0.0.24 → 0.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f22c9acfae008123fd3b68c5ab499c7bc8df3da4d5e135dea331f0fe6563596
4
- data.tar.gz: da87ac500d4d0f26bdeb4ff3249afacd1e34d594bb2f4e18bff1c1c4dd818e64
3
+ metadata.gz: 2b1c963370596392fc4e48311db021c836c182e27665d8b62652bda1a55744e2
4
+ data.tar.gz: 454ee9440b137797885ee3b45d5fecc789fb64cc7adcf43e9a61eead08602299
5
5
  SHA512:
6
- metadata.gz: 0dd4bce3f8c250ed256e4362e6337f8ddee38c2865b9aba9ae7153066b1fb4d34f0fb324714626b4787011fde4118b1bc3f80a487865e2ee409248aebb44375f
7
- data.tar.gz: a65805c594f8d0823ce2df36a1b9d2f5c00e7b5ff71d3a178c1b8d7cc5f24a0f69d09d5d254d8bd16479d9c476b3ac14f67d097ea28efb2ccc7d89fd34f6dcec
6
+ metadata.gz: fc351535e38a0599165dc4794cecfdc84064b5cf8b0273f7e3f763643a152d43391f413a2ab2d97845f0d3774c41989c925da95c4f2d12e32261569e963ec34c
7
+ data.tar.gz: 0e8b90fbaef71ccb01e419ac58e4f394cafe69c61d2926bac649802404a075cf89c1ee8df77b9cf9cc4acf6e958b3547bd9e338cc4ef6ba85e4d158d0ef2993d
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "klenod/runtime/backtrace_rewriter"
4
+
5
+ require_relative "source_excerpt"
6
+
7
+ module Klenod
8
+ module Build
9
+ # A runtime exception as a development report, in the same shape as a
10
+ # SourceError: the class and message, an excerpt of the first frame that
11
+ # has source, then the frames.
12
+ #
13
+ # × NameError: undefined local variable or method 'x'
14
+ #
15
+ # ╭─[app:/routes/page.haml:4]
16
+ # 3 │ %h1 Hello
17
+ # > 4 │ %p= x
18
+ # ╰────
19
+ # at app:/routes/page.haml:4:in 'render'
20
+ # at /gems/rack/lib/rack.rb:12:in 'call'
21
+ #
22
+ # Rewriting lives in Klenod::Runtime::BacktraceRewriter, so a production
23
+ # application keeps corrected backtraces without loading any of this.
24
+ module ExceptionFormatter
25
+ Entry = Runtime::BacktraceRewriter::ParsedBacktraceEntry
26
+
27
+ module_function
28
+
29
+ # `mods` maps module IDs to evaluated modules, as Graph#mods does. The
30
+ # error is rewritten in place.
31
+ def format(error, mods: {}, ansi: true, links: ansi && SourceExcerpt.hyperlinks?)
32
+ rewriter = Runtime::BacktraceRewriter.new(mods)
33
+ rewriter.rewrite_exception(error)
34
+ module_ids = mods.values.select { it.respond_to?(:eval_path) }.to_h { [it.eval_path.to_s, it.path.to_s] }
35
+ frames = Array(error.backtrace).map { Entry.parse(it) || it.to_s }
36
+
37
+ [
38
+ SourceExcerpt.header(error.class.name, error.message, ansi:),
39
+ [
40
+ excerpt(frames, rewriter, module_ids, ansi:, links:),
41
+ *frames.map { " #{frame(it, module_ids, ansi:, links:)}" }
42
+ ].compact.join("\n")
43
+ ].reject(&:empty?).join("\n\n")
44
+ end
45
+
46
+ # The first frame the rewriter has original source for, boxed.
47
+ def excerpt(frames, rewriter, module_ids, ansi:, links:)
48
+ frames.grep(Entry).each do |entry|
49
+ source = rewriter.source_for(entry.file)
50
+ next unless source
51
+
52
+ location = location(entry, module_ids, ansi:, links:)
53
+ excerpt = SourceExcerpt.excerpt(source:, line: entry.line, ansi:, location:)
54
+ return excerpt if excerpt
55
+ end
56
+
57
+ nil
58
+ end
59
+
60
+ # "at app:/routes/page.haml:4:in 'render'". Frames outside the graph are
61
+ # dimmed so the application's own frames stand out.
62
+ def frame(entry, module_ids, ansi:, links:)
63
+ return SourceExcerpt.dim("at #{entry}", ansi:) unless entry.is_a?(Entry) && module_ids.key?(entry.file)
64
+
65
+ "#{SourceExcerpt.dim("at", ansi:)} #{location(entry, module_ids, ansi:, links:)}#{SourceExcerpt.dim(":in '#{entry.description}'", ansi:)}"
66
+ end
67
+
68
+ # Graph modules are named by module ID and linked to the file they were
69
+ # evaluated from.
70
+ def location(entry, module_ids, ansi:, links:)
71
+ SourceExcerpt.styled_location(
72
+ module_id: module_ids.fetch(entry.file, entry.file),
73
+ line: entry.line,
74
+ path: entry.file,
75
+ ansi:,
76
+ links:
77
+ )
78
+ end
79
+ end
80
+ end
81
+ end
@@ -16,7 +16,8 @@ module Klenod
16
16
  evaluated_module_ids = mods.keys
17
17
  changed_module_ids = module_ids_for_paths(changed_paths)
18
18
  removed_module_ids = module_ids_for_paths(removed_paths)
19
- failed_retry_ids = failed_module_ids_for_created_paths(changed_paths)
19
+ failed_retry_ids = failed_module_ids - removed_module_ids
20
+ imported_module_ids = records.each_key.select { |module_id| graph.dependents(module_id).any? }
20
21
  pattern_owner_ids = module_ids_for_watched_paths(changed_paths + removed_paths)
21
22
  plugin_owner_ids = plugin_invalidated_module_ids(changed_paths + removed_paths)
22
23
  reload_module_ids = (changed_module_ids + failed_retry_ids + pattern_owner_ids + plugin_owner_ids).uniq
@@ -31,6 +32,12 @@ module Klenod
31
32
  failed_reload_ids = []
32
33
  reloaded_module_ids =
33
34
  reload_module_ids.filter_map do |module_id|
35
+ if orphaned_failure?(module_id, imported_module_ids)
36
+ graph.remove_record(module_id)
37
+ mods.delete(module_id)
38
+ next
39
+ end
40
+
34
41
  if evaluated_module_ids.include?(module_id)
35
42
  graph.load_module(module_id, force: true)
36
43
  else
@@ -124,28 +131,22 @@ module Klenod
124
131
  end
125
132
  end
126
133
 
127
- def failed_module_ids_for_created_paths(paths)
128
- relative_paths = relative_paths_for(paths)
129
- return [] if relative_paths.empty?
130
-
131
- records.filter_map do |module_id, record|
132
- next unless record.status == :failed
133
-
134
- unresolved_path = record.metadata.fetch(:error).unresolved_path if record.metadata.fetch(:error).respond_to?(:unresolved_path)
135
- module_id if unresolved_path && relative_paths.any? { |path| path_satisfies_unresolved_path?(path, unresolved_path) }
136
- end
137
- end
138
-
139
- def relative_paths_for(paths)
140
- paths.filter_map do |path|
141
- Pathname.new(path).expand_path.relative_path_from(resolver.source_dir).to_s
142
- rescue ArgumentError
143
- nil
144
- end
134
+ # A failed record keeps no dependency links, so nothing ties it to the file
135
+ # that broke it: a missing import, or a new dependency that fails to parse.
136
+ # Retry every failed module on each change. Failures are rare, and a retry
137
+ # that fails again reports its error again.
138
+ def failed_module_ids
139
+ records.filter_map { |module_id, record| module_id if record.status == :failed }
145
140
  end
146
141
 
147
- def path_satisfies_unresolved_path?(path, unresolved_path)
148
- path == unresolved_path || path.start_with?("#{unresolved_path}.")
142
+ # A failed module whose last importer dropped it earlier in this update.
143
+ # Nothing can demand it any more, so retrying it would report its error
144
+ # on every change. Its record goes; importing it again collects it anew.
145
+ # A failed entry never had an importer and keeps being retried.
146
+ def orphaned_failure?(module_id, imported_module_ids)
147
+ records[module_id]&.status == :failed &&
148
+ imported_module_ids.include?(module_id) &&
149
+ graph.dependents(module_id).empty?
149
150
  end
150
151
 
151
152
  def plugin_invalidated_module_ids(paths)
@@ -386,6 +386,18 @@ module Klenod
386
386
  @mods[module_id] = mod
387
387
  record
388
388
  end
389
+ rescue SourceError => error
390
+ error.path ||= source_path_for(error.module_id)
391
+ raise
392
+ end
393
+
394
+ # The file behind an app module, so error reports can link to it.
395
+ def source_path_for(module_id)
396
+ return nil unless module_id.is_a?(ModuleId) && module_id.scheme == :app
397
+
398
+ absolute_path(module_id).to_s
399
+ rescue ResolveError
400
+ nil
389
401
  end
390
402
 
391
403
  def collect_module(module_id, force: false)
@@ -443,6 +455,9 @@ module Klenod
443
455
  @profiler.progress(:collect_module, module_id: record.id.to_s, total_records: @records.length)
444
456
  record
445
457
  end
458
+ rescue SourceError => error
459
+ error.path ||= source_path_for(error.module_id)
460
+ raise
446
461
  end
447
462
 
448
463
  def evaluate_module(module_id)
@@ -1106,6 +1106,15 @@
1106
1106
  "unitsPerEm": 1000,
1107
1107
  "xWidthAvg": 362
1108
1108
  },
1109
+ "Asap Sharp": {
1110
+ "familyName": "Asap Sharp",
1111
+ "category": "sans-serif",
1112
+ "ascent": 934,
1113
+ "descent": -212,
1114
+ "lineGap": 0,
1115
+ "unitsPerEm": 1000,
1116
+ "xWidthAvg": 442
1117
+ },
1109
1118
  "Asar": {
1110
1119
  "familyName": "Asar",
1111
1120
  "category": "serif",
@@ -2537,6 +2546,15 @@
2537
2546
  "unitsPerEm": 16,
2538
2547
  "xWidthAvg": 8
2539
2548
  },
2549
+ "Caacupe One": {
2550
+ "familyName": "Caacupe One",
2551
+ "category": "display",
2552
+ "ascent": 960,
2553
+ "descent": -340,
2554
+ "lineGap": 0,
2555
+ "unitsPerEm": 1000,
2556
+ "xWidthAvg": 309
2557
+ },
2540
2558
  "Cabin": {
2541
2559
  "familyName": "Cabin",
2542
2560
  "category": "sans-serif",
@@ -8187,7 +8205,7 @@
8187
8205
  "descent": -120,
8188
8206
  "lineGap": 0,
8189
8207
  "unitsPerEm": 1000,
8190
- "xWidthAvg": 311
8208
+ "xWidthAvg": 313
8191
8209
  },
8192
8210
  "Macondo": {
8193
8211
  "familyName": "Macondo",
@@ -14993,6 +15011,15 @@
14993
15011
  "unitsPerEm": 1000,
14994
15012
  "xWidthAvg": 457
14995
15013
  },
15014
+ "Scoutie Sans": {
15015
+ "familyName": "Scoutie Sans",
15016
+ "category": "sans-serif",
15017
+ "ascent": 2000,
15018
+ "descent": -600,
15019
+ "lineGap": 0,
15020
+ "unitsPerEm": 2000,
15021
+ "xWidthAvg": 920
15022
+ },
14996
15023
  "Seaweed Script": {
14997
15024
  "familyName": "Seaweed Script",
14998
15025
  "category": "display",
@@ -16847,6 +16874,15 @@
16847
16874
  "unitsPerEm": 1000,
16848
16875
  "xWidthAvg": 400
16849
16876
  },
16877
+ "Valley Sans": {
16878
+ "familyName": "Valley Sans",
16879
+ "category": "sans-serif",
16880
+ "ascent": 960,
16881
+ "descent": -240,
16882
+ "lineGap": 0,
16883
+ "unitsPerEm": 1000,
16884
+ "xWidthAvg": 487
16885
+ },
16850
16886
  "Vampiro One": {
16851
16887
  "familyName": "Vampiro One",
16852
16888
  "category": "display",
@@ -5,7 +5,7 @@ Update them to the latest Capsize revision from the Klenod repository root:
5
5
  bundle exec rake google_fonts:metrics:update
6
6
 
7
7
  Project: https://github.com/seek-oss/capsize
8
- Commit: b0eb891c39e2ecd8b680f550bcda6f5fe9e38326
8
+ Commit: 8df97f098adc19d8a6d33df301757d7e9670bae6
9
9
  Source: packages/metrics/src/entireMetricsCollection.json
10
10
 
11
11
  MIT License
@@ -46,6 +46,7 @@ module Klenod
46
46
  # invalid attribute list. Join the lines of an unclosed list first so
47
47
  # both forms work across lines.
48
48
  def parse_new_attributes(text)
49
+ tag_prefix = @line.text.strip[/\A[^(]*/]
49
50
  joined_lines = 0
50
51
  until (close_index = attribute_list_close_index(text)) || @next_line.eod?
51
52
  text = "#{text} #{@next_line.text}"
@@ -56,7 +57,18 @@ module Klenod
56
57
  pairs = close_index && extended_attribute_pairs(text[1...close_index])
57
58
  rest = close_index && text[(close_index + 1)..]
58
59
  unless pairs&.any? { |_name, value| value.match?(/[.\[]/) }
59
- attributes, rest, last_line = super
60
+ begin
61
+ attributes, rest, last_line = super
62
+ rescue ::Haml::SyntaxError => error
63
+ raise unless error.message.start_with?("Invalid attribute list")
64
+
65
+ # Haml quotes the list with String#inspect, which escapes every
66
+ # `"` in it. Show the source as written instead.
67
+ attribute_list = ::Haml::Util.balance(text, "(", ")")&.first || text
68
+ message = "Invalid attribute list: `#{attribute_list}`."
69
+ message = "#{message}\n\n#{hash_attributes_hint(tag_prefix, pairs)}" if pairs
70
+ raise ::Haml::SyntaxError.new(message, error.line)
71
+ end
60
72
  return [attributes, rest, last_line + joined_lines]
61
73
  end
62
74
 
@@ -147,6 +159,19 @@ module Klenod
147
159
  pairs
148
160
  end
149
161
 
162
+ # Every value is valid Ruby, but some are neither Haml's bare variables
163
+ # nor the member and index expressions handled above. Suggest the
164
+ # equivalent hash attributes, which accept any Ruby expression.
165
+ def hash_attributes_hint(tag_prefix, pairs)
166
+ entries = pairs.map do |name, value|
167
+ key = name.match?(/\A[a-z_]\w*\z/i) ? name : name.inspect
168
+ "#{key}: #{value}"
169
+ end
170
+ tag_prefix = "" unless tag_prefix.match?(/\A[%.#][-:\w.#]*\z/)
171
+
172
+ "Hint:\n Use {} attributes for Ruby expressions: #{tag_prefix}{#{entries.join(", ")}}"
173
+ end
174
+
150
175
  # Returns the index of the `)` that closes the list opened at index 0,
151
176
  # ignoring brackets inside quoted values.
152
177
  def attribute_list_close_index(source)
@@ -1,34 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "errors"
4
+ require_relative "source_excerpt"
4
5
 
5
6
  module Klenod
6
7
  module Build
7
8
  module ResolutionErrorFormatter
8
- ANSI_BACKGROUND = "\e[0;48;5;52m"
9
-
10
9
  module_function
11
10
 
12
11
  def format(error, source_root: nil, source_context: nil, ansi: false)
13
12
  return error.message unless error.resolution_failure?
14
13
 
15
- lines = [error.title, "", " Import: #{error.requested_specifier}"]
14
+ lines = [SourceExcerpt.header(error.title, "", ansi:), "", " Import: #{error.requested_specifier}"]
16
15
  lines << " Imported by: #{error.imported_by}" if error.imported_by
17
16
  lines << " Source root: #{source_root}" if source_root
18
17
  append_suggestions(lines, error)
19
18
  lines.concat(["", source_context]) if source_context
20
- text = lines.join("\n")
21
- ansi ? format_ansi(text) : text
22
- end
23
-
24
- def format_ansi(text)
25
- title, separator, body = text.partition("\n")
26
- body = body.gsub("\e[0m", ANSI_BACKGROUND)
27
- body = "\n#{body}" unless separator.empty?
28
-
29
- "\e[1;31;47m ERROR \e[1;37;41m #{title} #{ANSI_BACKGROUND}#{body}\e[0m"
19
+ lines.join("\n")
30
20
  end
31
- private_class_method :format_ansi
32
21
 
33
22
  def append_suggestions(lines, error)
34
23
  return if error.suggestions.empty?
@@ -27,7 +27,7 @@ module Klenod
27
27
  def initialize(line: nil, column: nil, detail: nil, hints: []) = super
28
28
  end
29
29
 
30
- attr_reader :module_id, :source, :line, :column, :cause, :detail, :hints
30
+ attr_reader :module_id, :source, :line, :column, :cause, :detail, :hints, :path
31
31
 
32
32
  def initialize(error, source:, module_id:)
33
33
  @cause = error
@@ -40,19 +40,31 @@ module Klenod
40
40
  @detail = found.detail || message_for(error)
41
41
  @hints = Array(found.hints).map(&:to_s).reject(&:empty?).freeze
42
42
 
43
- super(
43
+ super(@detail)
44
+
45
+ set_backtrace(backtrace_for(error))
46
+ end
47
+
48
+ # The file the module was read from, when it has one. The graph sets it
49
+ # as the error leaves the module, since plugins only know the module ID,
50
+ # and the message links the location to it.
51
+ def path=(path)
52
+ @path = path
53
+ @message = nil
54
+ end
55
+
56
+ def to_s
57
+ @message ||=
44
58
  SourceExcerpt.message(
45
59
  module_id:,
46
- line: @line,
47
- column: @column,
60
+ line:,
61
+ column:,
48
62
  kind:,
49
63
  source:,
50
- message: @detail,
51
- hints: @hints
64
+ message: detail,
65
+ hints:,
66
+ path:
52
67
  )
53
- )
54
-
55
- set_backtrace(backtrace_for(error))
56
68
  end
57
69
 
58
70
  # "JavaScript parse error". Used as the label in the browser error dialog,
@@ -10,34 +10,120 @@ module Klenod
10
10
  # `ansi: false` to get the same layout without escape codes.
11
11
  module SourceExcerpt
12
12
  MARKED_LINE = "\e[1;31m"
13
+ KIND = "\e[1;31m"
14
+ DETAIL = "\e[31m"
15
+ HINT_LABEL = "\e[1;36m"
16
+ HINT = "\e[36m"
17
+ LOCATION = "\e[34m"
18
+ BOLD = "\e[1m"
19
+ NOT_BOLD = "\e[22m"
20
+ DIM = "\e[2m"
13
21
  RESET = "\e[0m"
14
22
 
15
23
  module_function
16
24
 
17
- # "app:/pages/thing.tsx:33:9: JavaScript parse error"
18
- def title(module_id:, line:, kind:, column: nil)
19
- location =
20
- if module_id && line
21
- "#{module_id}:#{[line, column].compact.join(":")}"
22
- elsif module_id
23
- module_id.to_s
24
- elsif line
25
- column ? "line #{line} column #{column}" : "line #{line}"
26
- end
25
+ # "app:/pages/thing.tsx:33:9"
26
+ def location(module_id:, line:, column: nil)
27
+ if module_id && line
28
+ "#{module_id}:#{[line, column].compact.join(":")}"
29
+ elsif module_id
30
+ module_id.to_s
31
+ elsif line
32
+ column ? "line #{line} column #{column}" : "line #{line}"
33
+ end
34
+ end
27
35
 
28
- location ? "#{location}: #{kind}" : kind
36
+ # The kind and detail share a header line. The location goes in the
37
+ # excerpt's frame, or on an "at" line when there is no excerpt, and the
38
+ # hints follow:
39
+ #
40
+ # × Haml parse error: Could not parse Ruby filter
41
+ #
42
+ # ╭─[app:/pages/page.haml:4]
43
+ # 3 │ @count = 0
44
+ # > 4 │
45
+ # ╰────
46
+ # hint: Unmatched keyword, missing `end' ?
47
+ #
48
+ # With `path`, the file the module was read from, the location links to
49
+ # that file when `links` is on.
50
+ def message(module_id:, line:, kind:, source:, message:, column: nil, hints: [], context: 2, ansi: true, path: nil, links: ansi && hyperlinks?)
51
+ location = styled_location(module_id:, line:, column:, path:, ansi:, links:)
52
+ excerpt = excerpt(source:, line:, column:, context:, ansi:, location:)
53
+ hints = hint_section(hints, ansi:)&.gsub(/^/, " ")
54
+ header = header(kind, message, ansi:)
55
+
56
+ unless excerpt
57
+ header = "#{header}\n #{dim("at", ansi:)} #{location}" if location
58
+ return [header, hints].compact.join("\n\n")
59
+ end
60
+
61
+ [header, [excerpt, hints].compact.join("\n")].join("\n\n")
29
62
  end
30
63
 
31
- def message(module_id:, line:, kind:, source:, message:, column: nil, hints: [], context: 2, ansi: true)
64
+ # "× JSON parse error: expected object key". Later lines of a multi-line
65
+ # detail are indented under the first.
66
+ def header(kind, message, ansi: true)
67
+ first, *rest = message.to_s.lines(chomp: true)
68
+ label = first ? "× #{kind}:" : "× #{kind}"
69
+ label = "#{KIND}#{label}#{RESET}" if ansi
70
+ return label unless first
71
+
32
72
  [
33
- title(module_id:, line:, column:, kind:),
34
- message,
35
- excerpt(source:, line:, column:, context:, ansi:),
36
- hint_section(hints)
37
- ].compact.join("\n\n")
73
+ "#{label} #{detail(first, ansi:)}",
74
+ *rest.map { it.empty? ? it : " #{detail(it, ansi:)}" }
75
+ ].join("\n")
76
+ end
77
+
78
+ # The location in blue with the file name in bold, linked to `path` when
79
+ # `links` is on and the file exists.
80
+ def styled_location(module_id:, line: nil, column: nil, path: nil, ansi: true, links: ansi && hyperlinks?)
81
+ location = location(module_id:, line:, column:)
82
+ return location unless ansi && location
83
+
84
+ location = bold_file_name(location, module_id) if module_id
85
+ location = file_link(location, path:, line:, column:) if links && path && File.file?(path.to_s)
86
+ "#{LOCATION}#{location}#{RESET}"
87
+ end
88
+
89
+ # "app:\e[1m/pages/thing.tsx\e[22m:33:9": the file name stands out from its
90
+ # scheme and position.
91
+ def bold_file_name(location, module_id)
92
+ scheme, name = module_id.to_s.match(/\A([a-z][a-z0-9+.-]*:)?(.*)\z/i).captures
93
+ location.sub(module_id.to_s) { "#{scheme}#{BOLD}#{name}#{NOT_BOLD}" }
94
+ end
95
+
96
+ # Whether the terminal turns OSC 8 sequences into clickable links. There
97
+ # is no way to ask, so this recognises the terminals known to do it.
98
+ def hyperlinks?(env = ENV)
99
+ return false if env.key?("NO_COLOR")
100
+ return true if %w[iTerm.app WezTerm vscode ghostty Hyper Tabby rio].include?(env["TERM_PROGRAM"])
101
+ return true if %w[xterm-kitty foot contour].include?(env["TERM"])
102
+ return true if env.key?("KITTY_WINDOW_ID") || env.key?("WT_SESSION") || env.key?("ALACRITTY_WINDOW_ID")
103
+ return true if env["VTE_VERSION"].to_i >= 5000
104
+ return true if env["KONSOLE_VERSION"].to_i >= 201200
105
+
106
+ false
107
+ end
108
+
109
+ # Wraps `text` in an OSC 8 hyperlink to `path`, with the line and column
110
+ # as the fragment: "file:///app/pages/thing.tsx#33:9".
111
+ def file_link(text, path:, line: nil, column: nil)
112
+ encoded = path.to_s.b.gsub(%r{[^A-Za-z0-9\-._~/]}) { format("%%%02X", it.ord) }
113
+ fragment = line && "##{[line, column].compact.join(":")}"
114
+
115
+ "\e]8;;file://#{encoded}#{fragment}\e\\#{text}\e]8;;\e\\"
116
+ end
117
+
118
+ # Colored per line so loggers that prefix each line do not carry the
119
+ # color into their gutter.
120
+ def detail(message, ansi: true)
121
+ return message unless ansi && message
122
+
123
+ message.to_s.lines(chomp: true).map { it.empty? ? it : "#{DETAIL}#{it}#{RESET}" }.join("\n")
38
124
  end
39
125
 
40
- def excerpt(source:, line:, column: nil, context: 2, ansi: true)
126
+ def excerpt(source:, line:, column: nil, context: 2, ansi: true, location: nil)
41
127
  return nil unless line
42
128
 
43
129
  lines = source.to_s.lines
@@ -49,35 +135,53 @@ module Klenod
49
135
  first = [index - context, 0].max
50
136
  last = [index + context, lines.length - 1].min
51
137
  width = (last + 1).to_s.length
138
+ gutter = " " * (width + 3)
52
139
 
53
140
  rows =
54
141
  (first..last).flat_map do |line_index|
55
142
  marked = line_index == index
56
143
  number = (line_index + 1).to_s.rjust(width)
57
- formatted = "#{marked ? ">" : " "} #{number} | #{lines.fetch(line_index).chomp}"
58
- formatted = "#{MARKED_LINE}#{formatted}#{RESET}" if marked && ansi
59
-
60
- marked ? [formatted, caret_row(width, column)].compact : [formatted]
144
+ code = lines.fetch(line_index).chomp
145
+ formatted =
146
+ if marked
147
+ row = "> #{number} │ #{code}".rstrip
148
+ ansi ? "#{MARKED_LINE}#{row}#{RESET}" : row
149
+ else
150
+ "#{dim(" #{number} │", ansi:)} #{code}".rstrip
151
+ end
152
+
153
+ marked ? [formatted, caret_row(width, column, ansi:)].compact : [formatted]
61
154
  end
62
155
 
63
- "Source:\n#{rows.join("\n")}"
156
+ [
157
+ location ? "#{gutter}#{dim("╭─[", ansi:)}#{location}#{dim("]", ansi:)}" : "#{gutter}#{dim("╭────", ansi:)}",
158
+ *rows,
159
+ "#{gutter}#{dim("╰────", ansi:)}"
160
+ ].join("\n")
161
+ end
162
+
163
+ # `text` without color codes or OSC 8 links, as a terminal would show it.
164
+ def strip(text)
165
+ text.to_s.gsub(/\e\[[0-9;]*m|\e\]8;[^\e\a]*(?:\e\\|\a)/, "")
166
+ end
167
+
168
+ def dim(text, ansi: true)
169
+ ansi ? "#{DIM}#{text}#{RESET}" : text
64
170
  end
65
171
 
66
- # " | ^", aligned under the offending column of the marked line.
67
- def caret_row(width, column)
172
+ # " · ^", aligned under the offending column of the marked line.
173
+ def caret_row(width, column, ansi: true)
68
174
  return nil unless column&.positive?
69
175
 
70
- "#{" " * (width + 3)}| #{" " * (column - 1)}^"
176
+ "#{" " * (width + 3)}#{dim("·", ansi:)} #{" " * (column - 1)}^"
71
177
  end
72
178
 
73
- # What to try next. Rendered like the "Source:" section so the two read as
74
- # one report.
75
- def hint_section(hints)
179
+ # What to try next, one "hint: ..." line per hint.
180
+ def hint_section(hints, ansi: true)
76
181
  hints = Array(hints).map(&:to_s).reject(&:empty?)
77
182
  return nil if hints.empty?
78
183
 
79
- heading = (hints.length == 1) ? "Hint:" : "Hints:"
80
- "#{heading}\n#{hints.map { " #{it}" }.join("\n")}"
184
+ hints.map { ansi ? "#{HINT_LABEL}hint:#{RESET} #{HINT}#{it}#{RESET}" : "hint: #{it}" }.join("\n")
81
185
  end
82
186
  end
83
187
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Klenod
4
4
  module Build
5
- VERSION = "0.0.24"
5
+ VERSION = "0.0.26"
6
6
  end
7
7
  end
data/lib/klenod/build.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  require_relative "build/version"
4
4
  require "klenod/runtime"
5
5
  require_relative "build/context"
6
+ require_relative "build/exception_formatter"
7
+ require_relative "build/resolution_error_formatter"
6
8
 
7
9
  module Klenod
8
10
  class Error < StandardError; end unless const_defined?(:Error, false)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: klenod-build
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.24
4
+ version: 0.0.26
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrés Alin
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.0.24
18
+ version: 0.0.26
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.0.24
25
+ version: 0.0.26
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: async
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -257,6 +257,7 @@ files:
257
257
  - lib/klenod/build/context.rb
258
258
  - lib/klenod/build/dependency.rb
259
259
  - lib/klenod/build/errors.rb
260
+ - lib/klenod/build/exception_formatter.rb
260
261
  - lib/klenod/build/filesystem_resolver.rb
261
262
  - lib/klenod/build/graph.rb
262
263
  - lib/klenod/build/graph/invalidator.rb