diff --git a/ext/herb/extension.c b/ext/herb/extension.c index 51a8b16cb..0d59d68a6 100644 --- a/ext/herb/extension.c +++ b/ext/herb/extension.c @@ -178,6 +178,17 @@ static VALUE Herb_parse(int argc, VALUE* argv, VALUE self) { if (NIL_P(timeout)) { timeout = rb_hash_lookup(options, ID2SYM(rb_intern("timeout"))); } if (!NIL_P(timeout)) { parser_options.timeout_ms = (uint32_t) (NUM2DBL(timeout) * 1000); } + VALUE max_errors_sentinel = ID2SYM(rb_intern("__not_set__")); + VALUE max_errors = rb_hash_lookup2(options, rb_utf8_str_new_cstr("max_errors"), max_errors_sentinel); + + if (max_errors == max_errors_sentinel) { + max_errors = rb_hash_lookup2(options, ID2SYM(rb_intern("max_errors")), max_errors_sentinel); + } + + if (max_errors != max_errors_sentinel) { + parser_options.max_errors = NIL_P(max_errors) ? 0 : (uint32_t) NUM2UINT(max_errors); + } + VALUE arena_stats = rb_hash_lookup(options, rb_utf8_str_new_cstr("arena_stats")); if (NIL_P(arena_stats)) { arena_stats = rb_hash_lookup(options, ID2SYM(rb_intern("arena_stats"))); } if (!NIL_P(arena_stats) && RTEST(arena_stats)) { print_arena_stats = true; } diff --git a/ext/herb/extension_helpers.c b/ext/herb/extension_helpers.c index 8d741e7af..eb0663020 100644 --- a/ext/herb/extension_helpers.c +++ b/ext/herb/extension_helpers.c @@ -97,6 +97,12 @@ VALUE create_parse_result(AST_DOCUMENT_NODE_T* root, VALUE source, const parser_ rb_hash_aset(kwargs, ID2SYM(rb_intern("prism_program")), options->prism_program ? Qtrue : Qfalse); rb_hash_aset(kwargs, ID2SYM(rb_intern("timeout")), DBL2NUM((double) options->timeout_ms / 1000.0)); + rb_hash_aset( + kwargs, + ID2SYM(rb_intern("max_errors")), + options->max_errors == 0 ? Qnil : UINT2NUM(options->max_errors) + ); + VALUE parser_options_args[1] = { kwargs }; VALUE parser_options = rb_class_new_instance_kw(1, parser_options_args, cParserOptions, RB_PASS_KEYWORDS); diff --git a/java/herb_jni.c b/java/herb_jni.c index e73ad410f..d81deee59 100644 --- a/java/herb_jni.c +++ b/java/herb_jni.c @@ -142,6 +142,21 @@ Java_org_herb_Herb_parse(JNIEnv* env, jclass clazz, jstring source, jobject opti jint timeout = (*env)->CallIntMethod(env, options, getTimeout); parser_options.timeout_ms = (uint32_t) timeout; } + + jmethodID getMaxErrors = + (*env)->GetMethodID(env, optionsClass, "getMaxErrors", "()Ljava/lang/Integer;"); + + if (getMaxErrors != NULL) { + jobject maxErrorsObj = (*env)->CallObjectMethod(env, options, getMaxErrors); + + if (maxErrorsObj == NULL) { + parser_options.max_errors = 0; + } else { + jclass integerClass = (*env)->FindClass(env, "java/lang/Integer"); + jmethodID intValue = (*env)->GetMethodID(env, integerClass, "intValue", "()I"); + parser_options.max_errors = (uint32_t) (*env)->CallIntMethod(env, maxErrorsObj, intValue); + } + } } hb_allocator_T allocator; diff --git a/java/org/herb/ParserOptions.java b/java/org/herb/ParserOptions.java index 7494ed5e8..47a3cc461 100644 --- a/java/org/herb/ParserOptions.java +++ b/java/org/herb/ParserOptions.java @@ -14,6 +14,7 @@ public class ParserOptions { private boolean dotNotationTags = false; private boolean html = true; private int timeout = 1000; + private Integer maxErrors = 25; public ParserOptions() {} @@ -134,6 +135,15 @@ public int getTimeout() { return timeout; } + public ParserOptions maxErrors(Integer value) { + this.maxErrors = value; + return this; + } + + public Integer getMaxErrors() { + return maxErrors; + } + public static ParserOptions create() { return new ParserOptions(); } diff --git a/javascript/packages/core/src/parser-options.ts b/javascript/packages/core/src/parser-options.ts index 5eab21b8a..38abbd830 100644 --- a/javascript/packages/core/src/parser-options.ts +++ b/javascript/packages/core/src/parser-options.ts @@ -12,6 +12,7 @@ export interface ParseOptions { dot_notation_tags?: boolean html?: boolean timeout?: number + max_errors?: number | null } export type SerializedParserOptions = Required @@ -30,6 +31,7 @@ export const DEFAULT_PARSER_OPTIONS: SerializedParserOptions = { dot_notation_tags: false, html: true, timeout: 1000, + max_errors: 25, } /** @@ -75,6 +77,9 @@ export class ParserOptions { /** Parse timeout in milliseconds. 0 disables the timeout. */ readonly timeout: number + /** Maximum number of errors to report. null means unlimited. */ + readonly max_errors: number | null + static from(options: SerializedParserOptions): ParserOptions { return new ParserOptions(options) } @@ -93,5 +98,6 @@ export class ParserOptions { this.dot_notation_tags = options.dot_notation_tags ?? DEFAULT_PARSER_OPTIONS.dot_notation_tags this.html = options.html ?? DEFAULT_PARSER_OPTIONS.html this.timeout = options.timeout ?? DEFAULT_PARSER_OPTIONS.timeout + this.max_errors = options.max_errors ?? DEFAULT_PARSER_OPTIONS.max_errors } } diff --git a/javascript/packages/linter/test/parse-cache.test.ts b/javascript/packages/linter/test/parse-cache.test.ts index 4bcad34b5..ef46f4eb3 100644 --- a/javascript/packages/linter/test/parse-cache.test.ts +++ b/javascript/packages/linter/test/parse-cache.test.ts @@ -79,6 +79,7 @@ describe("ParseCache", () => { transform_conditionals: false, html: true, timeout: 1000, + max_errors: 25, }) }) @@ -100,6 +101,7 @@ describe("ParseCache", () => { transform_conditionals: false, html: true, timeout: 1000, + max_errors: 25, }) }) @@ -121,6 +123,7 @@ describe("ParseCache", () => { transform_conditionals: false, html: true, timeout: 1000, + max_errors: 25, }) }) @@ -142,6 +145,7 @@ describe("ParseCache", () => { transform_conditionals: false, html: true, timeout: 1000, + max_errors: 25, }) }) }) diff --git a/lib/herb/parser_options.rb b/lib/herb/parser_options.rb index aeda32bb6..ebf576256 100644 --- a/lib/herb/parser_options.rb +++ b/lib/herb/parser_options.rb @@ -13,6 +13,7 @@ class ParserOptions attr_reader :prism_nodes #: bool attr_reader :prism_nodes_deep #: bool attr_reader :timeout #: Numeric + attr_reader :max_errors #: Integer? DEFAULT_STRICT = true #: bool DEFAULT_TRACK_WHITESPACE = false #: bool @@ -25,9 +26,10 @@ class ParserOptions DEFAULT_PRISM_NODES = false #: bool DEFAULT_PRISM_NODES_DEEP = false #: bool DEFAULT_TIMEOUT = 1 #: Numeric + DEFAULT_MAX_ERRORS = 25 #: Integer #: (?strict: bool, ?track_whitespace: bool, ?analyze: bool, ?action_view_helpers: bool, ?transform_conditionals: bool, ?render_nodes: bool, ?strict_locals: bool, ?prism_nodes: bool, ?prism_nodes_deep: bool, ?prism_program: bool, ?timeout: Numeric) -> void - def initialize(strict: DEFAULT_STRICT, track_whitespace: DEFAULT_TRACK_WHITESPACE, analyze: DEFAULT_ANALYZE, action_view_helpers: DEFAULT_ACTION_VIEW_HELPERS, transform_conditionals: DEFAULT_TRANSFORM_CONDITIONALS, render_nodes: DEFAULT_RENDER_NODES, strict_locals: DEFAULT_STRICT_LOCALS, prism_nodes: DEFAULT_PRISM_NODES, prism_nodes_deep: DEFAULT_PRISM_NODES_DEEP, prism_program: DEFAULT_PRISM_PROGRAM, timeout: DEFAULT_TIMEOUT) + def initialize(strict: DEFAULT_STRICT, track_whitespace: DEFAULT_TRACK_WHITESPACE, analyze: DEFAULT_ANALYZE, action_view_helpers: DEFAULT_ACTION_VIEW_HELPERS, transform_conditionals: DEFAULT_TRANSFORM_CONDITIONALS, render_nodes: DEFAULT_RENDER_NODES, strict_locals: DEFAULT_STRICT_LOCALS, prism_nodes: DEFAULT_PRISM_NODES, prism_nodes_deep: DEFAULT_PRISM_NODES_DEEP, prism_program: DEFAULT_PRISM_PROGRAM, timeout: DEFAULT_TIMEOUT, max_errors: DEFAULT_MAX_ERRORS) @strict = strict @track_whitespace = track_whitespace @analyze = analyze @@ -39,9 +41,10 @@ def initialize(strict: DEFAULT_STRICT, track_whitespace: DEFAULT_TRACK_WHITESPAC @prism_nodes_deep = prism_nodes_deep @prism_program = prism_program @timeout = timeout + @max_errors = max_errors end - #: () -> Hash[Symbol, (bool | Numeric)] + #: () -> Hash[Symbol, (bool | Numeric | nil)] def to_h { strict: @strict, @@ -55,6 +58,7 @@ def to_h prism_nodes_deep: @prism_nodes_deep, prism_program: @prism_program, timeout: @timeout, + max_errors: @max_errors, } end @@ -71,7 +75,8 @@ def inspect "prism_nodes=#{@prism_nodes}\n " \ "prism_nodes_deep=#{@prism_nodes_deep}\n " \ "prism_program=#{@prism_program}\n " \ - "timeout=#{@timeout}>" + "timeout=#{@timeout}\n " \ + "max_errors=#{@max_errors}>" end end end diff --git a/rust/src/herb.rs b/rust/src/herb.rs index 1eba5197d..678c47d4a 100644 --- a/rust/src/herb.rs +++ b/rust/src/herb.rs @@ -18,6 +18,7 @@ pub struct ParserOptions { pub dot_notation_tags: bool, pub html: bool, pub timeout: u32, + pub max_errors: Option, } impl Default for ParserOptions { @@ -36,6 +37,7 @@ impl Default for ParserOptions { dot_notation_tags: false, html: true, timeout: 1000, + max_errors: Some(25), } } } @@ -123,6 +125,8 @@ pub fn parse_with_options(source: &str, options: &ParserOptions) -> Result Result { start_line: 0, start_column: 0, timeout_ms: 1000, + max_errors: 25, + error_count: std::ptr::null_mut(), deadline_ms: 0, }; diff --git a/sig/herb/parser_options.rbs b/sig/herb/parser_options.rbs index 0726497d7..e1b7c5064 100644 --- a/sig/herb/parser_options.rbs +++ b/sig/herb/parser_options.rbs @@ -24,6 +24,8 @@ module Herb attr_reader timeout: Numeric + attr_reader max_errors: Integer? + DEFAULT_STRICT: bool DEFAULT_TRACK_WHITESPACE: bool @@ -46,11 +48,13 @@ module Herb DEFAULT_TIMEOUT: Numeric + DEFAULT_MAX_ERRORS: Integer + # : (?strict: bool, ?track_whitespace: bool, ?analyze: bool, ?action_view_helpers: bool, ?transform_conditionals: bool, ?render_nodes: bool, ?strict_locals: bool, ?prism_nodes: bool, ?prism_nodes_deep: bool, ?prism_program: bool, ?timeout: Numeric) -> void def initialize: (?strict: bool, ?track_whitespace: bool, ?analyze: bool, ?action_view_helpers: bool, ?transform_conditionals: bool, ?render_nodes: bool, ?strict_locals: bool, ?prism_nodes: bool, ?prism_nodes_deep: bool, ?prism_program: bool, ?timeout: Numeric) -> void - # : () -> Hash[Symbol, (bool | Numeric)] - def to_h: () -> Hash[Symbol, bool | Numeric] + # : () -> Hash[Symbol, (bool | Numeric | nil)] + def to_h: () -> Hash[Symbol, bool | Numeric | nil] # : () -> String def inspect: () -> String diff --git a/src/herb.c b/src/herb.c index 4b68d8efd..17cd5db43 100644 --- a/src/herb.c +++ b/src/herb.c @@ -45,6 +45,9 @@ HERB_EXPORTED_FUNCTION AST_DOCUMENT_NODE_T* herb_parse( parser_options_T parser_options = HERB_DEFAULT_PARSER_OPTIONS; if (options != NULL) { parser_options = *options; } + uint32_t error_count = 0; + parser_options.error_count = &error_count; + parser_options_set_deadline(&parser_options); if (parser_options.start_line > 0) { diff --git a/src/include/parser/parser.h b/src/include/parser/parser.h index a58a585b2..b0c7c7d0a 100644 --- a/src/include/parser/parser.h +++ b/src/include/parser/parser.h @@ -35,6 +35,8 @@ typedef struct PARSER_OPTIONS_STRUCT { uint32_t start_line; uint32_t start_column; uint32_t timeout_ms; + uint32_t max_errors; + uint32_t* error_count; uint64_t deadline_ms; } parser_options_T; @@ -52,6 +54,17 @@ static inline bool parser_options_past_deadline(const parser_options_T* options) return hb_monotonic_ms() >= options->deadline_ms; } +static inline bool parser_options_errors_exceeded(const parser_options_T* options) { + if (options == NULL || options->error_count == NULL) { return false; } + if (options->max_errors == 0) { return false; } + + return *options->error_count >= options->max_errors; +} + +static inline void parser_options_increment_error_count(const parser_options_T* options) { + if (options != NULL && options->error_count != NULL) { (*options->error_count)++; } +} + static inline void parser_options_set_deadline(parser_options_T* options) { if (options->timeout_ms == 0) { return; } diff --git a/src/parser.c b/src/parser.c index d081eed13..bf8b6bf4c 100644 --- a/src/parser.c +++ b/src/parser.c @@ -23,8 +23,6 @@ #include #define MAX_CONSECUTIVE_ERRORS 10 -#define DEADLINE_CHECK_INTERVAL 1024 - static void parser_parse_in_data_state(parser_T* parser, hb_array_T* children, hb_array_T* errors); static void parser_parse_foreign_content(parser_T* parser, hb_array_T* children, hb_array_T* errors); static AST_ERB_CONTENT_NODE_T* parser_parse_erb_tag(parser_T* parser); @@ -51,6 +49,7 @@ const parser_options_T HERB_DEFAULT_PARSER_OPTIONS = { .track_whitespace = false .start_line = 0, .start_column = 0, .timeout_ms = 1000, + .max_errors = 25, .deadline_ms = 0 }; size_t parser_sizeof(void) { @@ -1614,7 +1613,7 @@ static size_t find_matching_close_tag( int depth = 0; for (size_t index = start_index + 1; index < hb_array_size(nodes); index++) { - if ((index & (DEADLINE_CHECK_INTERVAL - 1)) == 0 && parser_options_past_deadline(options)) { return (size_t) -1; } + if (parser_options_past_deadline(options)) { return (size_t) -1; } AST_NODE_T* node = (AST_NODE_T*) hb_array_get(nodes, index); if (node == NULL) { continue; } @@ -1706,7 +1705,7 @@ static hb_array_T* parser_build_elements_from_tags( hb_array_T* close_tag_names = collect_close_tag_names(nodes, allocator); for (size_t index = 0; index < hb_array_size(nodes); index++) { - if ((index & (DEADLINE_CHECK_INTERVAL - 1)) == 0 && parser_options_past_deadline(options)) { break; } + if (parser_options_past_deadline(options)) { break; } AST_NODE_T* node = (AST_NODE_T*) hb_array_get(nodes, index); if (node == NULL) { continue; } @@ -1779,7 +1778,7 @@ static hb_array_T* parser_build_elements_from_tags( index = implicit_close_index - 1; } else { - if (hb_array_size(open_tag->base.errors) == 0) { + if (hb_array_size(open_tag->base.errors) == 0 && !parser_options_errors_exceeded(options)) { append_missing_closing_tag_error( open_tag->tag_name, open_tag->base.location.start, @@ -1787,6 +1786,8 @@ static hb_array_T* parser_build_elements_from_tags( allocator, open_tag->base.errors ); + + parser_options_increment_error_count(options); } hb_array_append(result, node); @@ -1826,7 +1827,7 @@ static hb_array_T* parser_build_elements_from_tags( AST_HTML_CLOSE_TAG_NODE_T* close_tag = (AST_HTML_CLOSE_TAG_NODE_T*) node; if (!is_void_element(close_tag->tag_name->value)) { - if (hb_array_size(close_tag->base.errors) == 0) { + if (hb_array_size(close_tag->base.errors) == 0 && !parser_options_errors_exceeded(options)) { append_missing_opening_tag_error( close_tag->tag_name, close_tag->base.location.start, @@ -1834,6 +1835,8 @@ static hb_array_T* parser_build_elements_from_tags( allocator, close_tag->base.errors ); + + parser_options_increment_error_count(options); } } diff --git a/test/parser/max_errors_test.rb b/test/parser/max_errors_test.rb new file mode 100644 index 000000000..ffb350fdd --- /dev/null +++ b/test/parser/max_errors_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +module Parser + class MaxErrorsTest < Minitest::Spec + test "default max_errors caps errors at 25" do + source = "
" * 1000 + result = Herb.parse(source) + + missing_tag_errors = result.errors.select { |e| e.is_a?(Herb::Errors::MissingClosingTagError) } + assert_equal 25, missing_tag_errors.size + end + + test "custom max_errors caps errors at specified limit" do + source = "
" * 1000 + result = Herb.parse(source, max_errors: 5) + + missing_tag_errors = result.errors.select { |e| e.is_a?(Herb::Errors::MissingClosingTagError) } + assert_equal 5, missing_tag_errors.size + end + + test "max_errors nil means unlimited" do + source = "
" * 100 + result = Herb.parse(source, max_errors: nil) + + missing_tag_errors = result.errors.select { |e| e.is_a?(Herb::Errors::MissingClosingTagError) } + assert_equal 100, missing_tag_errors.size + end + + test "max_errors applies to missing opening tag errors" do + source = "
" * 1000 + result = Herb.parse(source, max_errors: 10) + + missing_tag_errors = result.errors.select { |e| e.is_a?(Herb::Errors::MissingOpeningTagError) } + assert_equal 10, missing_tag_errors.size + end + + test "normal templates are not affected by max_errors" do + source = "
hello
" + result = Herb.parse(source) + + assert result.errors.empty? + end + end +end diff --git a/wasm/herb-wasm.cpp b/wasm/herb-wasm.cpp index b7b44860a..82613d114 100644 --- a/wasm/herb-wasm.cpp +++ b/wasm/herb-wasm.cpp @@ -103,6 +103,11 @@ val Herb_parse(const std::string& source, val options) { if (options.hasOwnProperty("timeout")) { parser_options.timeout_ms = (uint32_t) options["timeout"].as(); } + + if (options.hasOwnProperty("max_errors")) { + val max_errors_val = options["max_errors"]; + parser_options.max_errors = max_errors_val.isNull() ? 0 : (uint32_t) max_errors_val.as(); + } } hb_allocator_T allocator;