klenod-build 0.0.13 → 0.0.15

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.
@@ -6,6 +6,7 @@ require_relative "../errors"
6
6
  require_relative "../hashing"
7
7
  require_relative "../module_id"
8
8
  require_relative "../plugin"
9
+ require_relative "../source_error"
9
10
  require_relative "../transform_result"
10
11
  require_relative "asset_javascript_metadata"
11
12
 
@@ -17,6 +18,23 @@ module Klenod
17
18
  Plugin.new(...)
18
19
  end
19
20
 
21
+ # SVG markup is scraped with a regex rather than parsed, so malformed
22
+ # markup is tolerated. A file that is not text at all is not.
23
+ class EncodingError < Klenod::Build::SourceError
24
+ def kind
25
+ "SVG encoding error"
26
+ end
27
+
28
+ private
29
+
30
+ def location(error)
31
+ Location.new(
32
+ detail: error.message,
33
+ hints: ["An SVG file must be valid UTF-8 text."]
34
+ )
35
+ end
36
+ end
37
+
20
38
  class Plugin < Klenod::Build::Plugin
21
39
  EXTENSIONS = [".svg"].freeze
22
40
  SVG_RUNTIME_SPECIFIER = "virtual:klenod/svg"
@@ -39,7 +57,7 @@ module Klenod
39
57
 
40
58
  raise UnsupportedFileError, "SVG imports do not support query options: #{module_id}" if module_id.query
41
59
 
42
- dimensions = svg_dimensions(code)
60
+ dimensions = svg_dimensions(code, module_id)
43
61
  hash = Hashing.short(code)
44
62
  output_path = "/#{asset_name(module_id)}.#{hash}.svg"
45
63
  asset =
@@ -146,8 +164,8 @@ module Klenod
146
164
  JAVASCRIPT
147
165
  end
148
166
 
149
- def svg_dimensions(code)
150
- attributes = svg_attributes(code)
167
+ def svg_dimensions(code, module_id)
168
+ attributes = svg_attributes(code, module_id)
151
169
  return Dimensions.new(nil, nil) unless attributes
152
170
 
153
171
  explicit_width = parse_length(attributes["width"])
@@ -157,13 +175,18 @@ module Klenod
157
175
  view_box_dimensions(attributes["viewBox"] || attributes["viewbox"])
158
176
  end
159
177
 
160
- def svg_attributes(code)
178
+ # Malformed markup is tolerated: no <svg> match just means unknown
179
+ # dimensions. Only a file that is not text at all fails here, and
180
+ # String#match raises without saying which file it was reading.
181
+ def svg_attributes(code, module_id)
161
182
  match = code.match(/<svg(?=[\s>])(?<attributes>[^>]*)>/im)
162
183
  return nil unless match
163
184
 
164
185
  match[:attributes].scan(/([:\w.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/).to_h do |name, double_quoted, single_quoted, unquoted|
165
186
  [name, double_quoted || single_quoted || unquoted]
166
187
  end
188
+ rescue ArgumentError => error
189
+ raise EncodingError.new(error, source: "", module_id: module_id)
167
190
  end
168
191
 
169
192
  def view_box_dimensions(view_box)
@@ -64,6 +64,7 @@ module Klenod
64
64
  def clear_cache
65
65
  @resolved_module_ids.clear
66
66
  @absolute_paths.clear
67
+ @filesystem_resolver.clear_cache
67
68
  end
68
69
 
69
70
  private
@@ -39,6 +39,18 @@ module Klenod
39
39
  IMPORT_SOURCE_PATTERN = /(?<![A-Za-z0-9_])(?:import|lazy_import|import_glob)\s*(?:\(|["'])/
40
40
  FAST_LITERAL_IMPORT_PATTERN = /(?<![A-Za-z0-9_])(import|lazy_import)\s*\(\s*("(?:\\.|[^"\\#])*")\s*\)/
41
41
 
42
+ # Ruby that does not parse, reported at its line in the enclosing
43
+ # source. Haml wraps it into its own parse error with a source excerpt.
44
+ class ParseError < Klenod::Build::Error
45
+ attr_reader :line, :column
46
+
47
+ def initialize(message, line:, column:)
48
+ @line = line
49
+ @column = column
50
+ super(message)
51
+ end
52
+ end
53
+
42
54
  def initialize(module_id:, kind:, source_dir: nil, profiler: nil, dependency_id_offset: 0, source_line_offset: 0, source_column_offset: 0)
43
55
  @module_id = module_id
44
56
  @kind = kind
@@ -54,7 +66,12 @@ module Klenod
54
66
  fast_result = rewrite_literal_import_calls(code)
55
67
  return fast_result if fast_result
56
68
 
57
- ast = measure(:ruby_import_parse) { SyntaxTree.parse(code) }
69
+ ast =
70
+ begin
71
+ measure(:ruby_import_parse) { SyntaxTree.parse(code) }
72
+ rescue SyntaxTree::Parser::ParseError => error
73
+ raise ParseError.new(error.message, line: error.lineno + @source_line_offset, column: error.column + 1 + @source_column_offset)
74
+ end
58
75
  calls = measure(:ruby_import_scan) { import_calls(ast) }
59
76
  expanded = expand_import_calls(calls)
60
77
 
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "errors"
6
+ require_relative "source_excerpt"
7
+
8
+ module Klenod
9
+ module Build
10
+ # Base class for a build failure attributable to one source file.
11
+ #
12
+ # Plugins parse very different formats with very different libraries, but the
13
+ # report a developer needs is the same every time: which file, which line and
14
+ # column, what went wrong, and what to try next. A subclass supplies `kind`
15
+ # and a `location` that unpacks its library's exception. Everything else --
16
+ # the message layout, the source excerpt, the synthesized backtrace frame --
17
+ # is shared, so every format renders identically in the terminal and in the
18
+ # browser error dialog.
19
+ #
20
+ # Being a StandardError matters: a bare Ruby SyntaxError is a ScriptError and
21
+ # escapes every `rescue => e` in the build, taking the watcher thread down
22
+ # with it.
23
+ class SourceError < Error
24
+ # `hints` is free-form advice, one entry per line, e.g.
25
+ # "Did you mean hero.jpeg?".
26
+ Location = Data.define(:line, :column, :detail, :hints) do
27
+ def initialize(line: nil, column: nil, detail: nil, hints: []) = super
28
+ end
29
+
30
+ attr_reader :module_id, :source, :line, :column, :cause, :detail, :hints
31
+
32
+ def initialize(error, source:, module_id:)
33
+ @cause = error
34
+ @module_id = module_id
35
+ @source = source
36
+
37
+ found = location_for(error)
38
+ @line = found.line
39
+ @column = found.column
40
+ @detail = found.detail || message_for(error)
41
+ @hints = Array(found.hints).map(&:to_s).reject(&:empty?).freeze
42
+
43
+ super(
44
+ SourceExcerpt.message(
45
+ module_id:,
46
+ line: @line,
47
+ column: @column,
48
+ kind:,
49
+ source:,
50
+ message: @detail,
51
+ hints: @hints
52
+ )
53
+ )
54
+
55
+ set_backtrace(backtrace_for(error))
56
+ end
57
+
58
+ # "JavaScript parse error". Used as the label in the browser error dialog,
59
+ # so it names the format rather than the plugin.
60
+ def kind
61
+ "Parse error"
62
+ end
63
+
64
+ private
65
+
66
+ # Re-wrapping is idempotent: a plugin that catches its own SourceError to
67
+ # attach the source keeps the location the first construction worked out.
68
+ def location_for(error)
69
+ return location(error) || Location.new unless error.is_a?(SourceError)
70
+
71
+ Location.new(line: error.line, column: error.column, detail: error.detail, hints: error.hints)
72
+ end
73
+
74
+ # Subclasses override to pull line, column, detail and hints out of the
75
+ # exception their parsing library raised. Returning nil keeps the wrapped
76
+ # message as the detail and renders no excerpt.
77
+ def location(_error)
78
+ nil
79
+ end
80
+
81
+ # Prism reports each failure as data rather than a message to scrape, and
82
+ # phrases the first as "what is wrong; what was expected".
83
+ def prism_location(result)
84
+ first, *rest = result.errors
85
+ detail, _, expected = first.message.partition("; ")
86
+
87
+ Location.new(
88
+ line: first.location.start_line,
89
+ column: first.location.start_column + 1,
90
+ detail: detail,
91
+ hints: [expected, *rest.map(&:message)].reject(&:empty?).map(&:capitalize)
92
+ )
93
+ end
94
+
95
+ # A native extension can raise with a bare message rather than an
96
+ # exception, so do not assume #message exists.
97
+ def message_for(error)
98
+ error.respond_to?(:message) ? error.message : error.to_s
99
+ end
100
+
101
+ # The browser dialog highlights source lines by matching backtrace frames
102
+ # against the filename. A native parser, or one that raises while
103
+ # evaluating generated source, leaves no Ruby frame pointing at the
104
+ # module, so synthesize one.
105
+ def backtrace_for(error)
106
+ frames = error.respond_to?(:backtrace) ? Array(error.backtrace) : []
107
+ return frames unless line
108
+
109
+ ["#{module_id}:#{[line, column].compact.join(":")}", *frames]
110
+ end
111
+ end
112
+
113
+ # Ruby generated from another format that does not parse.
114
+ #
115
+ # This is a bug in the plugin that generated it rather than in anything the
116
+ # developer wrote, so the excerpt shows the generated source. It is checked
117
+ # while collecting, and again as a backstop when a module is evaluated: a
118
+ # bare SyntaxError there is a ScriptError, which escapes every `rescue => e`
119
+ # in the build and takes the watcher thread down with it.
120
+ class GeneratedRubyError < SourceError
121
+ # "app:/x.rb:3: syntax error found"
122
+ HEADER = /\A(?<file>.+?):(?<line>\d+): (?<detail>.+?)$/
123
+ # " | ^~~ unexpected 'end'; expected a `)` to close the arguments"
124
+ CARET = /^ *\| (?<pad> *)\^+~* *(?<message>.*)$/
125
+
126
+ def kind
127
+ "Generated Ruby syntax error"
128
+ end
129
+
130
+ private
131
+
132
+ # Prism renders its own excerpt into the message it puts on a SyntaxError.
133
+ # We re-render it from the line and column, and keep the explanation it
134
+ # prints beside the caret.
135
+ def location(error)
136
+ return prism_location(error) if error.is_a?(Prism::ParseResult)
137
+
138
+ header = HEADER.match(error.message)
139
+ return nil unless header
140
+
141
+ caret = CARET.match(error.message)
142
+ detail, _, hint = (caret ? caret[:message] : header[:detail]).partition("; ")
143
+
144
+ Location.new(
145
+ line: header[:line].to_i,
146
+ column: caret && caret[:pad].length + 1,
147
+ detail: detail,
148
+ hints: [hint.empty? ? nil : hint.capitalize].compact
149
+ )
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Klenod
4
+ module Build
5
+ # Shared formatting for build errors that can point at a line of source.
6
+ #
7
+ # Plugins raise errors carrying the original source and a location, and the
8
+ # resulting message is shown both in the terminal and in the browser error
9
+ # dialog, so it needs to read well as plain text. The browser passes
10
+ # `ansi: false` to get the same layout without escape codes.
11
+ module SourceExcerpt
12
+ MARKED_LINE = "\e[1;31m"
13
+ RESET = "\e[0m"
14
+
15
+ module_function
16
+
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
27
+
28
+ location ? "#{location}: #{kind}" : kind
29
+ end
30
+
31
+ def message(module_id:, line:, kind:, source:, message:, column: nil, hints: [], context: 2, ansi: true)
32
+ [
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")
38
+ end
39
+
40
+ def excerpt(source:, line:, column: nil, context: 2, ansi: true)
41
+ return nil unless line
42
+
43
+ lines = source.to_s.lines
44
+ return nil if lines.empty?
45
+
46
+ index = line - 1
47
+ return nil unless index.between?(0, lines.length - 1)
48
+
49
+ first = [index - context, 0].max
50
+ last = [index + context, lines.length - 1].min
51
+ width = (last + 1).to_s.length
52
+
53
+ rows =
54
+ (first..last).flat_map do |line_index|
55
+ marked = line_index == index
56
+ 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]
61
+ end
62
+
63
+ "Source:\n#{rows.join("\n")}"
64
+ end
65
+
66
+ # " | ^", aligned under the offending column of the marked line.
67
+ def caret_row(width, column)
68
+ return nil unless column&.positive?
69
+
70
+ "#{" " * (width + 3)}| #{" " * (column - 1)}^"
71
+ end
72
+
73
+ # What to try next. Rendered like the "Source:" section so the two read as
74
+ # one report.
75
+ def hint_section(hints)
76
+ hints = Array(hints).map(&:to_s).reject(&:empty?)
77
+ return nil if hints.empty?
78
+
79
+ heading = (hints.length == 1) ? "Hint:" : "Hints:"
80
+ "#{heading}\n#{hints.map { " #{it}" }.join("\n")}"
81
+ end
82
+ end
83
+ end
84
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Klenod
4
4
  module Build
5
- VERSION = "0.0.13"
5
+ VERSION = "0.0.15"
6
6
  end
7
7
  end
@@ -129,11 +129,20 @@ module Klenod
129
129
 
130
130
  def emit_update(changed_paths, removed_paths)
131
131
  @graph_version += 1
132
- result = @context.invalidate_paths(changed_paths, removed_paths: removed_paths)
132
+ result = invalidate(changed_paths, removed_paths)
133
133
 
134
134
  @context.emit_update(UpdateEvent.new(changed_paths, removed_paths, @graph_version, result))
135
135
  end
136
136
 
137
+ # Invalidation runs on the worker thread, so anything escaping here
138
+ # would kill it and take hot reloading down for the rest of the
139
+ # process. Report the failure as a result instead.
140
+ def invalidate(changed_paths, removed_paths)
141
+ @context.invalidate_paths(changed_paths, removed_paths: removed_paths)
142
+ rescue StandardError, ScriptError => e
143
+ InvalidationResult.failed(e)
144
+ end
145
+
137
146
  def monotonic_time
138
147
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
139
148
  end
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.13
4
+ version: 0.0.15
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.13
18
+ version: 0.0.15
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.13
25
+ version: 0.0.15
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: async
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -297,6 +297,8 @@ files:
297
297
  - lib/klenod/build/resolution_error_formatter.rb
298
298
  - lib/klenod/build/resolver.rb
299
299
  - lib/klenod/build/ruby_import_rewriter.rb
300
+ - lib/klenod/build/source_error.rb
301
+ - lib/klenod/build/source_excerpt.rb
300
302
  - lib/klenod/build/source_map.rb
301
303
  - lib/klenod/build/source_map/editor.rb
302
304
  - lib/klenod/build/source_map/map.rb