Class: Herb::Project

Inherits:
Object
  • Object
show all
Includes:
Colors
Defined in:
lib/herb/project.rb

Defined Under Namespace

Classes: ResultTracker

Constant Summary collapse

TEMPLATE_ERRORS =

Known error types that indicate issues in the user’s template, not bugs in the parser.

[
  "MissingOpeningTagError",
  "MissingClosingTagError",
  "TagNamesMismatchError",
  "VoidElementClosingTagError",
  "UnclosedElementError",
  "RubyParseError",
  "ERBControlFlowScopeError",
  "MissingERBEndTagError",
  "ERBMultipleBlocksInTagError",
  "ERBCaseWithConditionsError",
  "ConditionalElementMultipleTagsError",
  "ConditionalElementConditionMismatchError",
  "InvalidCommentClosingTagError",
  "OmittedClosingTagError",
  "UnclosedOpenTagError",
  "UnclosedCloseTagError",
  "UnclosedQuoteError",
  "MissingAttributeValueError",
  "UnclosedERBTagError",
  "StrayERBClosingTagError",
  "NestedERBTagError"
].freeze
ISSUE_TYPES =
[
  { key: :failed, label: "Parser crashed", symbol: "", color: :red, reportable: true,
    hint: "This could be a bug in the parser. Reporting it helps us improve Herb for everyone.",
    file_hint: ->(relative) { "Run `herb parse #{relative}` to see the parser output." } },
  { key: :template_error, label: "Template errors", symbol: "", color: :red,
    hint: "These files have issues in the template. Review the errors and update your templates to fix them." },
  { key: :unexpected_error, label: "Unexpected parse errors", symbol: "", color: :red, reportable: true,
    hint: "These errors may indicate a bug in the parser. Reporting them helps us make Herb more robust.",
    file_hint: ->(relative) { "Run `herb parse #{relative}` to see the parser output." } },
  { key: :strict_parse_error, label: "Strict mode parse errors", symbol: "", color: :yellow,
    hint: "These files use HTML patterns like omitted closing tags. Add explicit closing tags to fix." },
  { key: :analyze_parse_error, label: "Analyze parse errors", symbol: "", color: :yellow,
    hint: "These files have issues detected during analysis. Review the errors and update your templates." },
  { key: :timeout, label: "Timed out", symbol: "", color: :yellow, reportable: true,
    hint: "These files took too long to parse. This could indicate a parser issue. Reporting it helps us track down edge cases." },
  { key: :validation_error, label: "Validation errors", symbol: "", color: :yellow,
    hint: "These templates have security, nesting, or accessibility issues. The templates compile fine otherwise. Review and fix these to improve your template structure." },
  { key: :compilation_failed, label: "Compilation errors", symbol: "", color: :red, reportable: true,
    hint: "These files could not be compiled to Ruby. This could be a bug in the engine. Reporting it helps us improve Herb's compatibility.",
    file_hint: ->(relative) { "Run `herb compile #{relative}` to see the compilation error." } },
  { key: :strict_compilation_failed, label: "Strict mode compilation errors", symbol: "", color: :yellow,
    hint: "These files fail to compile only in strict mode. Add explicit closing tags to fix, or pass --no-strict to allow.",
    file_hint: ->(relative) { "Run `herb compile #{relative}` to see the compilation error." } },
  { key: :invalid_ruby, label: "Invalid Ruby output", symbol: "", color: :red, reportable: true,
    hint: "The engine produced Ruby code that doesn't parse. This is most likely a bug in the engine. Reporting it helps us fix it.",
    file_hint: ->(relative) { "Run `herb compile #{relative}` to see the compiled output." } }
].freeze

Constants included from Colors

Colors::CLEAR_SCREEN, Colors::HIDE_CURSOR, Colors::SHOW_CURSOR

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from Colors

bold, bright_magenta, cyan, dimmed, enabled?, fg, fg_bg, green, magenta, red, white, yellow

Constructor Details

#initialize(project_path, output_file: nil) ⇒ Project

Returns a new instance of Project.



122
123
124
125
126
127
128
129
# File 'lib/herb/project.rb', line 122

def initialize(project_path, output_file: nil)
  @project_path = Pathname.new(
    project_path ? File.expand_path(".", project_path) : File.expand_path("../..", __dir__)
  )

  date = Time.now.strftime("%Y-%m-%d_%H-%M-%S")
  @output_file = output_file || "#{date}_erb_parsing_result_#{@project_path.basename}.log"
end

Instance Attribute Details

#arena_statsObject

Returns the value of attribute arena_stats.



15
16
17
# File 'lib/herb/project.rb', line 15

def arena_stats
  @arena_stats
end

#file_pathsObject

Returns the value of attribute file_paths.



15
16
17
# File 'lib/herb/project.rb', line 15

def file_paths
  @file_paths
end

#isolateObject

Returns the value of attribute isolate.



15
16
17
# File 'lib/herb/project.rb', line 15

def isolate
  @isolate
end

#leak_checkObject

Returns the value of attribute leak_check.



15
16
17
# File 'lib/herb/project.rb', line 15

def leak_check
  @leak_check
end

#no_log_fileObject

Returns the value of attribute no_log_file.



15
16
17
# File 'lib/herb/project.rb', line 15

def no_log_file
  @no_log_file
end

#no_timingObject

Returns the value of attribute no_timing.



15
16
17
# File 'lib/herb/project.rb', line 15

def no_timing
  @no_timing
end

#output_fileObject

Returns the value of attribute output_file.



15
16
17
# File 'lib/herb/project.rb', line 15

def output_file
  @output_file
end

#project_pathObject

Returns the value of attribute project_path.



15
16
17
# File 'lib/herb/project.rb', line 15

def project_path
  @project_path
end

#silentObject

Returns the value of attribute silent.



15
16
17
# File 'lib/herb/project.rb', line 15

def silent
  @silent
end

#validate_rubyObject

Returns the value of attribute validate_ruby.



15
16
17
# File 'lib/herb/project.rb', line 15

def validate_ruby
  @validate_ruby
end

#verboseObject

Returns the value of attribute verbose.



15
16
17
# File 'lib/herb/project.rb', line 15

def verbose
  @verbose
end

Instance Method Details

#absolute_pathObject



143
144
145
# File 'lib/herb/project.rb', line 143

def absolute_path
  File.expand_path(@project_path, File.expand_path("../..", __dir__))
end

#analyze!Object



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/herb/project.rb', line 168

def analyze!
  start_time = Time.now unless no_timing

  log = if no_log_file
          StringIO.new
        else
          File.open(output_file, "w")
        end

  begin
    log.puts heading("METADATA")
    log.puts "Herb Version: #{Herb.version}"
    log.puts "Reported at: #{Time.now.strftime("%Y-%m-%dT%H:%M:%S")}\n\n"

    log.puts heading("PROJECT")
    log.puts "Path: #{absolute_path}"
    log.puts "Config: #{configuration.config_path || "(defaults)"}"
    log.puts "Include: #{include_patterns.join(", ")}"
    log.puts "Exclude: #{exclude_patterns.join(", ")}\n\n"

    log.puts heading("PROCESSED FILES")

    if files.empty?
      message = "No files found matching patterns: #{include_patterns.join(", ")}"
      log.puts message
      puts message
      return
    end

    @results = ResultTracker.new
    results = @results

    unless silent
      puts ""
      puts "#{bold("Herb")} 🌿 #{dimmed("v#{Herb::VERSION}")}"
      puts ""

      if configuration.config_path
        puts "#{green("")} Using Herb config file at #{dimmed(configuration.config_path)}"
      else
        puts dimmed("No .herb.yml found, using defaults")
      end

      puts dimmed("Analyzing #{files.count} #{pluralize(files.count, "file")}...")
    end

    finish_hook = lambda do |item, _index, _file_result|
      next if silent

      if verbose
        puts "  #{relative_path(item)}"
      else
        print "."
      end
    end

    ensure_parallel!

    file_results = Parallel.map(files, in_processes: Parallel.processor_count, finish: finish_hook) do |file_path|
      process_file(file_path)
    end

    unless silent
      puts "" unless verbose
      puts ""
      puts separator
    end

    file_results.each do |result|
      merge_file_result(result, results, log)
    end

    log.puts ""

    duration = no_timing ? nil : Time.now - start_time

    print_file_lists(results, log)

    if results.problem_files.any?
      puts "\n #{separator}"
      print_issue_summary(results)

      if reportable_files?(results)
        puts "\n #{separator}"
        print_reportable_files(results)
      end
    end

    log_problem_file_details(results, log)

    if arena_stats
      print_arena_summary(file_results)
    end

    if leak_check
      print_leak_check_summary(file_results)
    end

    unless no_log_file
      puts "\n #{separator}"
      puts "\n #{dimmed("Results saved to #{output_file}")}"
    end

    puts "\n #{separator}"
    print_summary(results, log, duration)

    results.problem_files.any?
  ensure
    log.close unless no_log_file
  end
end

#configurationObject



131
132
133
# File 'lib/herb/project.rb', line 131

def configuration
  @configuration ||= Configuration.load(@project_path.to_s)
end

#exclude_patternsObject



139
140
141
# File 'lib/herb/project.rb', line 139

def exclude_patterns
  configuration.file_exclude_patterns
end

#filesObject



147
148
149
# File 'lib/herb/project.rb', line 147

def files
  @files ||= file_paths || find_files
end

#include_patternsObject



135
136
137
# File 'lib/herb/project.rb', line 135

def include_patterns
  configuration.file_include_patterns
end


280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
# File 'lib/herb/project.rb', line 280

def print_file_report(file_path)
  file_path = File.expand_path(file_path)
  results = @results

  unless results
    puts "No results available. Run parse! first."
    return
  end

  relative = relative_path(file_path)
  issue_type = results.file_issue_type(file_path)

  unless issue_type
    puts "No issues found for #{relative}."
    return
  end

  diagnostics = results.file_diagnostics[file_path]
  file_content = results.file_contents[file_path]

  puts "- **Herb:** `#{Herb.version}`"
  puts "- **Ruby:** `#{RUBY_VERSION}`"
  puts "- **Platform:** `#{RUBY_PLATFORM}`"
  puts "- **Category:** `#{issue_type[:label]}`"

  if diagnostics&.any?
    puts ""
    puts "**Errors:**"
    diagnostics.each do |diagnostic|
      lines = diagnostic[:message].split("\n")
      puts "- **#{diagnostic[:name]}** #{lines.first}"
      lines.drop(1).each do |line|
        puts "  #{line}"
      end
    end
  end

  if file_content
    puts ""
    puts "**Template:**"
    puts "```erb"
    puts file_content
    puts "```"
  end

  return unless issue_type[:key] == :invalid_ruby && file_content

  begin
    engine = Herb::Engine.new(file_content, filename: file_path, escape: true, validation_mode: :none)
    puts ""
    puts "**Compiled Ruby:**"
    puts "```ruby"
    puts engine.src
    puts "```"
  rescue StandardError
    # Skip if compilation fails entirely
  end
end