From 7d68e865452a2fc4c74dbefee15f603528baba71 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Fri, 12 Jun 2026 16:59:06 +0200 Subject: [PATCH 1/4] Parser: Add `max_errors` to `parse_option_T` --- ext/herb/extension.c | 4 ++++ ext/herb/extension_helpers.c | 1 + java/herb_jni.c | 8 ++++++++ java/org/herb/ParserOptions.java | 10 ++++++++++ javascript/packages/core/src/parser-options.ts | 6 ++++++ javascript/packages/linter/test/parse-cache.test.ts | 4 ++++ lib/herb/parser_options.rb | 9 +++++++-- rust/src/herb.rs | 6 ++++++ sig/herb/parser_options.rbs | 4 ++++ src/herb.c | 3 +++ src/include/parser/parser.h | 12 ++++++++++++ src/parser.c | 8 ++++++-- wasm/herb-wasm.cpp | 4 ++++ 13 files changed, 75 insertions(+), 4 deletions(-) diff --git a/ext/herb/extension.c b/ext/herb/extension.c index 51a8b16cb..5dcfd2143 100644 --- a/ext/herb/extension.c +++ b/ext/herb/extension.c @@ -178,6 +178,10 @@ 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 = rb_hash_lookup(options, rb_utf8_str_new_cstr("max_errors")); + if (NIL_P(max_errors)) { max_errors = rb_hash_lookup(options, ID2SYM(rb_intern("max_errors"))); } + if (!NIL_P(max_errors)) { parser_options.max_errors = (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..038f3815e 100644 --- a/ext/herb/extension_helpers.c +++ b/ext/herb/extension_helpers.c @@ -96,6 +96,7 @@ VALUE create_parse_result(AST_DOCUMENT_NODE_T* root, VALUE source, const parser_ rb_hash_aset(kwargs, ID2SYM(rb_intern("prism_nodes_deep")), options->prism_nodes_deep ? Qtrue : Qfalse); 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")), 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..b52cd0d5c 100644 --- a/java/herb_jni.c +++ b/java/herb_jni.c @@ -142,6 +142,14 @@ 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", "()I"); + + if (getMaxErrors != NULL) { + jint maxErrors = (*env)->CallIntMethod(env, options, getMaxErrors); + parser_options.max_errors = (uint32_t) maxErrors; + } } hb_allocator_T allocator; diff --git a/java/org/herb/ParserOptions.java b/java/org/herb/ParserOptions.java index 7494ed5e8..07ce87916 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 int maxErrors = 25; public ParserOptions() {} @@ -134,6 +135,15 @@ public int getTimeout() { return timeout; } + public ParserOptions maxErrors(int value) { + this.maxErrors = value; + return this; + } + + public int 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..488ca247c 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 } 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. 0 disables the limit. */ + readonly max_errors: number + 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..35ccd26d3 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,6 +41,7 @@ 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)] @@ -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..2e2ffdf20 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: u32, } impl Default for ParserOptions { @@ -36,6 +37,7 @@ impl Default for ParserOptions { dot_notation_tags: false, html: true, timeout: 1000, + max_errors: 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..017302586 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,6 +48,8 @@ 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 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..52bd895a4 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,16 @@ 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->max_errors == 0 || options->error_count == NULL) { 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..f5f19840b 100644 --- a/src/parser.c +++ b/src/parser.c @@ -51,6 +51,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) { @@ -1779,7 +1780,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 +1788,7 @@ 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 +1828,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 +1836,8 @@ static hb_array_T* parser_build_elements_from_tags( allocator, close_tag->base.errors ); + + parser_options_increment_error_count(options); } } diff --git a/wasm/herb-wasm.cpp b/wasm/herb-wasm.cpp index b7b44860a..2d357dc85 100644 --- a/wasm/herb-wasm.cpp +++ b/wasm/herb-wasm.cpp @@ -103,6 +103,10 @@ 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")) { + parser_options.max_errors = (uint32_t) options["max_errors"].as(); + } } hb_allocator_T allocator; From 36915c940f2d614390d460d8de6b7add827b0259 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Fri, 12 Jun 2026 18:25:17 +0200 Subject: [PATCH 2/4] Drop `DEADLINE_CHECK_INTERVAL` --- src/parser.c | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/parser.c b/src/parser.c index f5f19840b..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); @@ -1615,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; } @@ -1707,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; } @@ -1788,6 +1786,7 @@ static hb_array_T* parser_build_elements_from_tags( allocator, open_tag->base.errors ); + parser_options_increment_error_count(options); } From e1315c0cae25f0e465bff80ee8baa2e73726c3ef Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Fri, 12 Jun 2026 18:47:19 +0200 Subject: [PATCH 3/4] cleanup --- ext/herb/extension.c | 13 ++++-- ext/herb/extension_helpers.c | 7 ++- java/herb_jni.c | 13 ++++-- java/org/herb/ParserOptions.java | 6 +-- .../packages/core/src/parser-options.ts | 6 +-- lib/herb/parser_options.rb | 2 +- rust/src/herb.rs | 6 +-- sig/herb/parser_options.rbs | 2 +- src/include/parser/parser.h | 3 +- test/parser/max_errors_test.rb | 46 +++++++++++++++++++ wasm/herb-wasm.cpp | 3 +- 11 files changed, 87 insertions(+), 20 deletions(-) create mode 100644 test/parser/max_errors_test.rb diff --git a/ext/herb/extension.c b/ext/herb/extension.c index 5dcfd2143..0d59d68a6 100644 --- a/ext/herb/extension.c +++ b/ext/herb/extension.c @@ -178,9 +178,16 @@ 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 = rb_hash_lookup(options, rb_utf8_str_new_cstr("max_errors")); - if (NIL_P(max_errors)) { max_errors = rb_hash_lookup(options, ID2SYM(rb_intern("max_errors"))); } - if (!NIL_P(max_errors)) { parser_options.max_errors = (uint32_t) NUM2UINT(max_errors); } + 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"))); } diff --git a/ext/herb/extension_helpers.c b/ext/herb/extension_helpers.c index 038f3815e..eb0663020 100644 --- a/ext/herb/extension_helpers.c +++ b/ext/herb/extension_helpers.c @@ -96,7 +96,12 @@ VALUE create_parse_result(AST_DOCUMENT_NODE_T* root, VALUE source, const parser_ rb_hash_aset(kwargs, ID2SYM(rb_intern("prism_nodes_deep")), options->prism_nodes_deep ? Qtrue : Qfalse); 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")), UINT2NUM(options->max_errors)); + + 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 b52cd0d5c..d81deee59 100644 --- a/java/herb_jni.c +++ b/java/herb_jni.c @@ -144,11 +144,18 @@ Java_org_herb_Herb_parse(JNIEnv* env, jclass clazz, jstring source, jobject opti } jmethodID getMaxErrors = - (*env)->GetMethodID(env, optionsClass, "getMaxErrors", "()I"); + (*env)->GetMethodID(env, optionsClass, "getMaxErrors", "()Ljava/lang/Integer;"); if (getMaxErrors != NULL) { - jint maxErrors = (*env)->CallIntMethod(env, options, getMaxErrors); - parser_options.max_errors = (uint32_t) maxErrors; + 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); + } } } diff --git a/java/org/herb/ParserOptions.java b/java/org/herb/ParserOptions.java index 07ce87916..47a3cc461 100644 --- a/java/org/herb/ParserOptions.java +++ b/java/org/herb/ParserOptions.java @@ -14,7 +14,7 @@ public class ParserOptions { private boolean dotNotationTags = false; private boolean html = true; private int timeout = 1000; - private int maxErrors = 25; + private Integer maxErrors = 25; public ParserOptions() {} @@ -135,12 +135,12 @@ public int getTimeout() { return timeout; } - public ParserOptions maxErrors(int value) { + public ParserOptions maxErrors(Integer value) { this.maxErrors = value; return this; } - public int getMaxErrors() { + public Integer getMaxErrors() { return maxErrors; } diff --git a/javascript/packages/core/src/parser-options.ts b/javascript/packages/core/src/parser-options.ts index 488ca247c..38abbd830 100644 --- a/javascript/packages/core/src/parser-options.ts +++ b/javascript/packages/core/src/parser-options.ts @@ -12,7 +12,7 @@ export interface ParseOptions { dot_notation_tags?: boolean html?: boolean timeout?: number - max_errors?: number + max_errors?: number | null } export type SerializedParserOptions = Required @@ -77,8 +77,8 @@ export class ParserOptions { /** Parse timeout in milliseconds. 0 disables the timeout. */ readonly timeout: number - /** Maximum number of errors to report. 0 disables the limit. */ - readonly max_errors: number + /** Maximum number of errors to report. null means unlimited. */ + readonly max_errors: number | null static from(options: SerializedParserOptions): ParserOptions { return new ParserOptions(options) diff --git a/lib/herb/parser_options.rb b/lib/herb/parser_options.rb index 35ccd26d3..712cece8d 100644 --- a/lib/herb/parser_options.rb +++ b/lib/herb/parser_options.rb @@ -13,7 +13,7 @@ class ParserOptions attr_reader :prism_nodes #: bool attr_reader :prism_nodes_deep #: bool attr_reader :timeout #: Numeric - attr_reader :max_errors #: Integer + attr_reader :max_errors #: Integer? DEFAULT_STRICT = true #: bool DEFAULT_TRACK_WHITESPACE = false #: bool diff --git a/rust/src/herb.rs b/rust/src/herb.rs index 2e2ffdf20..678c47d4a 100644 --- a/rust/src/herb.rs +++ b/rust/src/herb.rs @@ -18,7 +18,7 @@ pub struct ParserOptions { pub dot_notation_tags: bool, pub html: bool, pub timeout: u32, - pub max_errors: u32, + pub max_errors: Option, } impl Default for ParserOptions { @@ -37,7 +37,7 @@ impl Default for ParserOptions { dot_notation_tags: false, html: true, timeout: 1000, - max_errors: 25, + max_errors: Some(25), } } } @@ -125,7 +125,7 @@ pub fn parse_with_options(source: &str, options: &ParserOptions) -> Resultmax_errors == 0 || options->error_count == NULL) { return false; } + if (options == NULL || options->error_count == NULL) { return false; } + if (options->max_errors == 0) { return false; } return *options->error_count >= options->max_errors; } 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 2d357dc85..82613d114 100644 --- a/wasm/herb-wasm.cpp +++ b/wasm/herb-wasm.cpp @@ -105,7 +105,8 @@ val Herb_parse(const std::string& source, val options) { } if (options.hasOwnProperty("max_errors")) { - parser_options.max_errors = (uint32_t) options["max_errors"].as(); + val max_errors_val = options["max_errors"]; + parser_options.max_errors = max_errors_val.isNull() ? 0 : (uint32_t) max_errors_val.as(); } } From 66735c88afa63580e2ac73b96fd414d22ddc5f8b Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Fri, 12 Jun 2026 18:50:34 +0200 Subject: [PATCH 4/4] Steep --- lib/herb/parser_options.rb | 2 +- sig/herb/parser_options.rbs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/herb/parser_options.rb b/lib/herb/parser_options.rb index 712cece8d..ebf576256 100644 --- a/lib/herb/parser_options.rb +++ b/lib/herb/parser_options.rb @@ -44,7 +44,7 @@ def initialize(strict: DEFAULT_STRICT, track_whitespace: DEFAULT_TRACK_WHITESPAC @max_errors = max_errors end - #: () -> Hash[Symbol, (bool | Numeric)] + #: () -> Hash[Symbol, (bool | Numeric | nil)] def to_h { strict: @strict, diff --git a/sig/herb/parser_options.rbs b/sig/herb/parser_options.rbs index 8810afb18..e1b7c5064 100644 --- a/sig/herb/parser_options.rbs +++ b/sig/herb/parser_options.rbs @@ -53,8 +53,8 @@ module Herb # : (?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