leptris 1.9.370.1 → 1.9.371.0

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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +1 -1
  3. data/ext/leptris/native/native.c +20 -0
  4. data/lib/leptris/vendor/aarch64-linux/libleptris.so +0 -0
  5. data/lib/leptris/vendor/aarch64-linux-musl/libleptris.so +0 -0
  6. data/lib/leptris/vendor/arm-linux/libleptris.so +0 -0
  7. data/lib/leptris/vendor/arm-linux-musl/libleptris.so +0 -0
  8. data/lib/leptris/vendor/arm64-darwin/libleptris.dylib +0 -0
  9. data/lib/leptris/vendor/ppc64le-linux/libleptris.so +0 -0
  10. data/lib/leptris/vendor/s390x-linux-musl/libleptris.so +0 -0
  11. data/lib/leptris/vendor/x86_64-darwin/libleptris.dylib +0 -0
  12. data/lib/leptris/vendor/x86_64-linux/libleptris.so +0 -0
  13. data/lib/leptris/vendor/x86_64-linux-musl/libleptris.so +0 -0
  14. data/lib/leptris/version.rb +1 -1
  15. data/lib/leptris/xml/document.rb +13 -1
  16. data/lib/leptris/xml/ffi.rb +10 -0
  17. data/lib/leptris/xml.rb +27 -12
  18. data/vendor-src/README.md +1 -1
  19. data/vendor-src/libleptris/CHANGELOG.md +29 -0
  20. data/vendor-src/libleptris/CMakeLists.txt +1 -1
  21. data/vendor-src/libleptris/src/include/leptris/error.h +22 -0
  22. data/vendor-src/libleptris/src/include/leptris/html.h +15 -0
  23. data/vendor-src/libleptris/src/include/leptris/types.h +6 -1
  24. data/vendor-src/libleptris/src/leptris/dom/diag.c +32 -1
  25. data/vendor-src/libleptris/src/leptris/dom/diag.h +16 -0
  26. data/vendor-src/libleptris/src/leptris/error.c +16 -1
  27. data/vendor-src/libleptris/src/leptris/html/html_parse.c +103 -4
  28. data/vendor-src/libleptris/test/CMakeLists.txt +1 -0
  29. data/vendor-src/libleptris/test/html/test_html_parse_errors.cpp +102 -0
  30. data/vendor-src/libleptris/vcpkg.json +1 -1
  31. metadata +3 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0da354b1a07e2345161752a5b7ca7902696e6072ea64918b0daa658188e3baa2
4
- data.tar.gz: 5752801d285e7eb4da1fb597f866ccf317fea25032ee79e8f75e336398a8be2d
3
+ metadata.gz: 7d4aadf1e16972c36b3c8668694e03a5cbb5e1af83f5ffd5886386b672271be9
4
+ data.tar.gz: 3f6d316bf97d593866b9a93a6fe39389bece411605c20f0b812fd1e823f898ea
5
5
  SHA512:
6
- metadata.gz: 794d1477836616595314bfcd20529e55a17739357575d5d51f45aeee589f9e57e855053590eebf6e717cb592936d7c5998042e7706cee0325616d835cc925edc
7
- data.tar.gz: 793321d127eaece55428d75deb14bfe9496f207480bac4f0ed10aa54f868722d5a69478e5949b72deb224788f0b51af20b86d73c37899ca99c212f33ef52ed9f
6
+ metadata.gz: 5f22ba905b799eabfe7988d5eb584ee6b1751672e79db8fac3d96146a0be437dde212f00046d44e108d70b698b89cfff6f701abf18ed29ff72090bb521fe414e
7
+ data.tar.gz: f5bcb476c7e447ccd09b730bf8be20dcaa7cbc00f4cc704f9d0a7c329410da460ae1fb4b924cf2c0099783de22cc3a78f14e8fca6e19dcb58fe6bf150c99a2bd
data/Rakefile CHANGED
@@ -8,7 +8,7 @@ RSpec::Core::RakeTask.new(:spec)
8
8
  # Pin for `rake compile` and the platform-gem builds. Keep in lockstep
9
9
  # with .github/workflows/build.yml (which calls `rake compile`) and the
10
10
  # CHANGELOG when libleptris releases.
11
- LIBLEPTRIS_VERSION = "1.9.370"
11
+ LIBLEPTRIS_VERSION = "1.9.371"
12
12
  # Vendored alongside libleptris for fn:normalize-unicode (TODO
13
13
  # .restructure/20): built per platform with a RELOCATABLE @rpath
14
14
  # install name, loaded by ffi.rb before libleptris so the
@@ -1445,6 +1445,25 @@ static VALUE nn_create_child(VALUE self, VALUE name)
1445
1445
  return out;
1446
1446
  }
1447
1447
 
1448
+ /* The #204 write row: gates + version bump + engine set in ONE C
1449
+ * frame on the TypedData self — the caller skips the binding
1450
+ * wrapper's Ruby arg plumbing (nf_set_binding_attribute's
1451
+ * discipline, parameterized by subclass). */
1452
+ static VALUE nn_set_attribute(VALUE self, VALUE name, VALUE value)
1453
+ {
1454
+ struct native_node *n;
1455
+ int st;
1456
+
1457
+ TypedData_Get_Struct(self, struct native_node, &nn_type, n);
1458
+ document_advance_version(n->document);
1459
+ st = f_set_attr(n->ptr, RSTRING_PTR(StringValue(name)),
1460
+ StringValueCStr(value));
1461
+ if (st != 0)
1462
+ rb_raise(c_runtime_error,
1463
+ "leptris_element_set_attribute failed (%d)", st);
1464
+ return value;
1465
+ }
1466
+
1448
1467
  static VALUE nn_append_child(VALUE self, VALUE child)
1449
1468
  {
1450
1469
  struct native_node *n, *c;
@@ -3537,6 +3556,7 @@ LEPTRIS_INIT_EXPORT void Init_native(void)
3537
3556
  rb_define_method(c_native_node, "name", nn_name, 0);
3538
3557
  rb_define_method(c_native_node, "content", nn_content, 0);
3539
3558
  rb_define_method(c_native_node, "content=", nn_content_set, 1);
3559
+ rb_define_method(c_native_node, "set_attribute", nn_set_attribute, 2);
3540
3560
  rb_define_method(c_native_node, "attribute", nn_attribute, 1);
3541
3561
  rb_define_alias(c_native_node, "[]", "attribute");
3542
3562
  /* Unshadowed aliases for consumers whose contract modules shadow
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.370.1"
4
+ VERSION = "1.9.371.0"
5
5
  end
@@ -888,9 +888,21 @@ class Leptris::XML::Document
888
888
  ok = Leptris::XML::FFI.leptris_document_parse_diag(
889
889
  c_ptr, i, kind_buf, msg_buf, 512)
890
890
  next nil if ok.zero?
891
+ # leptris-ruby#1771: the machine-readable code + position
892
+ # (the §13.2.5 token name for HTML parse lints).
893
+ code_buf = ::FFI::MemoryPointer.new(:char, 128)
894
+ line_buf = ::FFI::MemoryPointer.new(:int)
895
+ col_buf = ::FFI::MemoryPointer.new(:int)
896
+ Leptris::XML::FFI.leptris_document_parse_diag_code(
897
+ c_ptr, i, code_buf, 128, line_buf, col_buf)
898
+ code = code_buf.read_string
899
+ code = nil if code.empty?
891
900
  Leptris::XML::SyntaxError.new(
892
901
  msg_buf.read_string.force_encoding(Encoding::UTF_8),
893
- severity: DIAG_SEVERITY.fetch(kind_buf.read_int, :recoverable))
902
+ severity: DIAG_SEVERITY.fetch(kind_buf.read_int, :recoverable),
903
+ line: line_buf.read_int.zero? ? nil : line_buf.read_int,
904
+ column: col_buf.read_int.zero? ? nil : col_buf.read_int,
905
+ code: code)
894
906
  end.compact
895
907
  end
896
908
 
@@ -555,6 +555,11 @@ module Leptris
555
555
  # the parse; only an entirely empty result is an error.
556
556
  attach_function :leptris_parse_html_string,
557
557
  [:string, :size_t, :pointer], :leptris_document
558
+ # leptris-ruby#1771: the WHATWG engine with per-call options
559
+ # (LEPTRIS_PARSE_COLLECT_HTML_ERRORS records the parse-error
560
+ # token stream on the document).
561
+ attach_function :leptris_parse_html_string_flags,
562
+ [:string, :size_t, :size_t, :pointer], :leptris_document
558
563
  # HTML4/libxml2-compatibility mode (1.9.104, #659): the same
559
564
  # tolerant tokenizer and Nokogiri document shape, except
560
565
  # leading script/style stay in <body> (libxml2's shape —
@@ -1531,6 +1536,11 @@ attach_function :leptris_parse_string,
1531
1536
  [:leptris_document], :size_t
1532
1537
  attach_function :leptris_document_parse_diag,
1533
1538
  [:leptris_document, :size_t, :pointer, :pointer, :size_t], :int
1539
+ # leptris-ruby#1771: the diag's machine-readable code + line +
1540
+ # column (the §13.2.5 parse-error token name for HTML lints).
1541
+ attach_function :leptris_document_parse_diag_code,
1542
+ [:leptris_document, :size_t, :pointer, :size_t, :pointer,
1543
+ :pointer], :int
1534
1544
  attach_function :leptris_dtd_parse,
1535
1545
  [:string, :size_t], :leptris_dtd
1536
1546
  attach_function :leptris_dtd_free, [:leptris_dtd], :void
data/lib/leptris/xml.rb CHANGED
@@ -83,17 +83,27 @@ module Leptris
83
83
  # (script/style/noscript/template/... lift) and foster
84
84
  # parenting (1.9.105) — html5lib corpus 294/1555 vs Nokogiri's
85
85
  # 372; the shapes genuinely differ by spec.
86
- def self.parse_html(html, mode: :html4)
86
+ def self.parse_html(html, mode: :html4, collect_errors: false)
87
87
  html = html.to_s
88
- entry =
89
- case mode
90
- when :html4 then Leptris::XML::FFI.method(:leptris_parse_html4_string)
91
- when :whatwg then Leptris::XML::FFI.method(:leptris_parse_html_string)
92
- else
93
- raise ArgumentError,
94
- "mode must be :html4 or :whatwg, got #{mode.inspect}"
95
- end
96
- raw = entry.call(html, html.bytesize, nil)
88
+ # leptris-ruby#1771: collect_errors records the §13.2.5
89
+ # parse-error token stream as document errors (code + line +
90
+ # column); the WHATWG engine only (the html4 compat lane
91
+ # keeps the libxml2 posture).
92
+ if collect_errors && mode == :whatwg
93
+ flags = 64 # LEPTRIS_PARSE_COLLECT_HTML_ERRORS
94
+ raw = Leptris::XML::FFI.leptris_parse_html_string_flags(
95
+ html, html.bytesize, flags, nil)
96
+ else
97
+ entry =
98
+ case mode
99
+ when :html4 then Leptris::XML::FFI.method(:leptris_parse_html4_string)
100
+ when :whatwg then Leptris::XML::FFI.method(:leptris_parse_html_string)
101
+ else
102
+ raise ArgumentError,
103
+ "mode must be :html4 or :whatwg, got #{mode.inspect}"
104
+ end
105
+ raw = entry.call(html, html.bytesize, nil)
106
+ end
97
107
  if raw.null?
98
108
  raise Leptris::XML::ParseError,
99
109
  "HTML parse failed: #{Leptris::XML::FFI.leptris_last_error}"
@@ -111,16 +121,21 @@ module Leptris
111
121
  # SyntaxError shape — a message plus optional position — served
112
122
  # by Document#errors.
113
123
  class SyntaxError < Error
114
- attr_reader :message, :line, :column, :severity
124
+ attr_reader :message, :line, :column, :severity, :code
115
125
 
116
126
  # severity: :fatal (the strict lane raises on this class) or
117
127
  # :recoverable (leptris-ruby#1749) — raise-on-fatal /
118
128
  # recover-on-recoverable without string matching.
119
- def initialize(message, line: nil, column: nil, severity: :recoverable)
129
+ # code: the machine-readable token (leptris-ruby#1771 — the
130
+ # §13.2.5 parse-error name for HTML lints, e.g.
131
+ # "unexpected-tag-in-body"); nil when the diag has no code.
132
+ def initialize(message, line: nil, column: nil, severity: :recoverable,
133
+ code: nil)
120
134
  @message = message
121
135
  @line = line
122
136
  @column = column
123
137
  @severity = severity
138
+ @code = code
124
139
  super(message)
125
140
  end
126
141
 
data/vendor-src/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  Sources vendored in this gem (recompile rights; the ruby
2
2
  variant builds them at install via extconf.rb):
3
3
 
4
- - libleptris v1.9.370
4
+ - libleptris v1.9.371
5
5
  - utf8proc v2.11.0
6
6
 
7
7
  Rebuild by hand:
@@ -1,5 +1,34 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.9.371] - 2026-10-19
4
+
5
+ ### Added
6
+
7
+ - **html: the §13.2.5 parse-error lint surface (#1771, PR #1772).**
8
+ `LEPTRIS_PARSE_COLLECT_HTML_ERRORS` on the new
9
+ `leptris_parse_html_string_flags` entry records the WHATWG
10
+ parse-error token stream as diagnostics — machine-readable **code**
11
+ (the spec token name: "duplicate-attribute", "eof-in-tag",
12
+ "unexpected-doctype", ...), **line**, **column** — one class
13
+ (RECOVER), per the issue's "a single class + code is enough for
14
+ canon to map" contract. The diag record gains a machine-readable
15
+ code field; the read side gains
16
+ `leptris_document_parse_diag_code` (the existing kind+message face
17
+ is untouched — no ABI break). Slice 1 instruments the sites the
18
+ parser already self-documents: duplicate-attribute (structural
19
+ stash + generic start-tag paths), eof-in-tag (all three drop
20
+ shapes), unexpected-doctype outside the initial mode, and the
21
+ malformed-construct bogus-comment fallback; follow-up slices
22
+ extend the coverage per the §13.2.5 list. Off by default — the
23
+ tree construction is exactly as fast without the bit. Engine suite
24
+ 2044/2044 including the html5lib corpus gate and the export-surface
25
+ gate; MSVC format-attribute guard included.
26
+ ### Fixed
27
+
28
+ - guard the h_diag format attribute for MSVC (html)
29
+
30
+
31
+
3
32
  ## [1.9.370] - 2026-10-18
4
33
 
5
34
  <!-- Edit this section with the actual release notes. -->
@@ -3,7 +3,7 @@
3
3
  cmake_minimum_required(VERSION 3.20)
4
4
 
5
5
  project(leptris
6
- VERSION 1.9.370
6
+ VERSION 1.9.371
7
7
  DESCRIPTION "Fast XML parser and XPath evaluator in pure C"
8
8
  LANGUAGES C CXX
9
9
  )
@@ -152,6 +152,28 @@ LEPTRIS_API int leptris_document_parse_diag(LeptrisDocument doc,
152
152
  char* message,
153
153
  size_t message_cap);
154
154
 
155
+ /**
156
+ * Read one parse diagnostic's machine-readable code and position
157
+ * (leptris-ruby#1771: the §13.2.5 parse-error token name for HTML
158
+ * lints; NULL code for diags without one).
159
+ *
160
+ * @param doc Document handle
161
+ * @param index Diagnostic index (< leptris_document_parse_diag_count)
162
+ * @param code Out: NUL-terminated code buffer (may be NULL)
163
+ * @param code_cap Capacity of code (ignored when code NULL)
164
+ * @param line Out: 1-based line (may be NULL; 0 when unknown)
165
+ * @param col Out: 1-based column (may be NULL; 0 when unknown)
166
+ * @return 1 on success; 0 for NULL doc or out-of-range index
167
+ *
168
+ * Memory: Code is copied into the caller's buffer; diagnostics
169
+ * are owned by the document until leptris_document_free.
170
+ */
171
+ LEPTRIS_API int leptris_document_parse_diag_code(LeptrisDocument doc,
172
+ size_t index,
173
+ char* code,
174
+ size_t code_cap,
175
+ int* line, int* col);
176
+
155
177
  #ifdef __cplusplus
156
178
  }
157
179
  #endif
@@ -72,6 +72,21 @@ LEPTRIS_API LeptrisDocument leptris_parse_html4_string(const char* html,
72
72
  size_t length,
73
73
  LeptrisStatus* status);
74
74
 
75
+ /**
76
+ * Parse HTML with per-call options (leptris-ruby#1771).
77
+ *
78
+ * Identical to leptris_parse_html_string; `flags` may carry
79
+ * LEPTRIS_PARSE_COLLECT_HTML_ERRORS to record the WHATWG §13.2.5
80
+ * parse-error token stream as diagnostics (read via
81
+ * leptris_document_parse_diag_count + leptris_document_parse_diag +
82
+ * leptris_document_parse_diag_code).
83
+ *
84
+ * @return Document, or NULL on failure
85
+ */
86
+ LEPTRIS_API LeptrisDocument leptris_parse_html_string_flags(
87
+ const char* buf, size_t len, LeptrisParseFlags flags,
88
+ LeptrisStatus* status);
89
+
75
90
  #ifdef __cplusplus
76
91
  }
77
92
  #endif
@@ -245,7 +245,12 @@ typedef enum {
245
245
  * is dropped, unclosed elements close at EOF. Each recovery
246
246
  * records a parse diagnostic (mismatched-tag / unclosed-element
247
247
  * kind). Without the bit the parse fails as before. */
248
- LEPTRIS_PARSE_RECOVER = 32u
248
+ LEPTRIS_PARSE_RECOVER = 32u,
249
+ /* leptris-ruby#1771: record the WHATWG §13.2.5 parse-error
250
+ * token stream as diagnostics (code + line + column) on the
251
+ * HTML parse — the lint layer validators need. Opt-in: the
252
+ * tree construction stays exactly as fast without it. */
253
+ LEPTRIS_PARSE_COLLECT_HTML_ERRORS = 64u
249
254
  } LeptrisParseFlags;
250
255
 
251
256
  /* ============================================================================
@@ -22,6 +22,7 @@ LeptrisDiag* leptris_diag_emit(LeptrisDiag** list, int* count, int* cap,
22
22
  LeptrisDiag* d = &(*list)[*count];
23
23
  d->kind = kind;
24
24
  d->offender_name = NULL;
25
+ d->code = NULL;
25
26
  d->line = 0;
26
27
  d->col_start = 0;
27
28
  d->col_end = 0;
@@ -46,10 +47,40 @@ LeptrisDiag* leptris_diag_emit(LeptrisDiag** list, int* count, int* cap,
46
47
  return d;
47
48
  }
48
49
 
50
+ LeptrisDiag* leptris_diag_emit_code(LeptrisDiag** list, int* count, int* cap,
51
+ LeptrisDiagKind kind, const char* code,
52
+ int line, int col, const char* fmt, ...) {
53
+ if (!list || !count || !cap) return NULL;
54
+ if (*count == *cap) {
55
+ int nc = *cap ? *cap * 2 : 8;
56
+ LeptrisDiag* nl = (LeptrisDiag*)realloc(
57
+ *list, sizeof(LeptrisDiag) * (size_t)nc);
58
+ if (!nl) return NULL;
59
+ *list = nl;
60
+ *cap = nc;
61
+ }
62
+ LeptrisDiag* d = &(*list)[*count];
63
+ d->kind = kind;
64
+ d->offender_name = NULL;
65
+ d->code = code ? leptris_strdup(code) : NULL;
66
+ d->line = line;
67
+ d->col_start = col;
68
+ d->col_end = 0;
69
+ va_list ap;
70
+ va_start(ap, fmt);
71
+ vsnprintf(d->message, sizeof(d->message), fmt, ap);
72
+ va_end(ap);
73
+ (*count)++;
74
+ return d;
75
+ }
76
+
49
77
  void leptris_diag_free(LeptrisDiag** list, int* count, int* cap) {
50
78
  if (!list) return;
51
79
  if (*list && count) {
52
- for (int i = 0; i < *count; i++) free((*list)[i].offender_name);
80
+ for (int i = 0; i < *count; i++) {
81
+ free((*list)[i].offender_name);
82
+ free((*list)[i].code);
83
+ }
53
84
  }
54
85
  free(*list);
55
86
  *list = NULL;
@@ -27,6 +27,10 @@ typedef struct LeptrisDiag {
27
27
  * validated document is freed — musl
28
28
  * unmaps the region and the report
29
29
  * pass segfaulted reading it. */
30
+ /* #1771: the machine-readable CODE — the §13.2.5 parse-error
31
+ * token name for HTML lints ("unexpected-tag-in-body"), NULL
32
+ * when the diag has no code. Captured at emit (strdup'd). */
33
+ char* code;
30
34
 
31
35
  int line;
32
36
  int col_start; /* Jing: element/attr errors */
@@ -53,6 +57,18 @@ LeptrisDiag* leptris_diag_emit(LeptrisDiag** list, int* count, int* cap,
53
57
  #endif
54
58
  ;
55
59
 
60
+ /* #1771: the code-carrying variant — same record, plus the
61
+ * machine-readable code string (strdup'd into the record). Line/col
62
+ * come from the caller (the tokenizer's own position); the record's
63
+ * node-derived position path does not apply. */
64
+ LeptrisDiag* leptris_diag_emit_code(LeptrisDiag** list, int* count, int* cap,
65
+ LeptrisDiagKind kind, const char* code,
66
+ int line, int col, const char* fmt, ...)
67
+ #if defined(__GNUC__)
68
+ __attribute__((format(printf, 8, 9)))
69
+ #endif
70
+ ;
71
+
56
72
  /* Release a record list (the messages are inline, so this is one
57
73
  * free + reset). */
58
74
  void leptris_diag_free(LeptrisDiag** list, int* count, int* cap);
@@ -153,4 +153,19 @@ LEPTRIS_API int leptris_document_parse_diag(LeptrisDocument doc,
153
153
  message[message_cap - 1] = '\0';
154
154
  }
155
155
  return 1;
156
- }
156
+ }
157
+
158
+ LEPTRIS_API int leptris_document_parse_diag_code(LeptrisDocument doc,
159
+ size_t index,
160
+ char* code,
161
+ size_t code_cap,
162
+ int* line, int* col) {
163
+ if (!doc || index >= (size_t)doc->parse_diag_count) return 0;
164
+ struct LeptrisDiag* d = (struct LeptrisDiag*)doc->parse_diags + index;
165
+ if (code && code_cap) {
166
+ snprintf(code, code_cap, "%s", d->code ? d->code : "");
167
+ }
168
+ if (line) *line = d->line;
169
+ if (col) *col = d->col_start;
170
+ return 1;
171
+ }
@@ -17,8 +17,10 @@
17
17
  #include "../dom/comment.h"
18
18
  #include "../dom/doctype.h"
19
19
  #include "../dom/root_doc_map.h"
20
+ #include "../dom/diag.h"
20
21
  #include "../dom/cdata.h"
21
22
  #include "../dom/pi.h"
23
+ #include <stdarg.h>
22
24
  #include <stdio.h>
23
25
  #include <stdlib.h>
24
26
  #include <stdint.h>
@@ -2687,6 +2689,15 @@ typedef struct {
2687
2689
  /* #659 master mode flag (the per-slice flags below derive from
2688
2690
  * the same html_parse_shared arg). */
2689
2691
  int whatwg;
2692
+ /* #1771: the parse-lint channel — record the §13.2.5
2693
+ * parse-error token stream when the option bit is set. The
2694
+ * input base + length turn token offsets into 1-based
2695
+ * line/column at emit (amortized: last-line memo). */
2696
+ int collect_errors;
2697
+ const char* input_base;
2698
+ size_t input_len;
2699
+ size_t err_line_memo_off;
2700
+ int err_line_memo;
2690
2701
  /* #659 frameset mode (WHATWG): a <frameset> before any body
2691
2702
  * content REPLACES the body — the commit synthesis emits
2692
2703
  * html > [head, frameset]. */
@@ -2877,6 +2888,8 @@ static int h_in_head_noscript(HBuilder* b) {
2877
2888
  /* #659: stash the attributes of a dropped structural tag —
2878
2889
  * pool-owned flat name/value pairs, applied to the synthesized
2879
2890
  * element at commit. */
2891
+ static void h_diag(HBuilder* b, const char* code, size_t off,
2892
+ const char* fmt, ...);
2880
2893
  static void h_stash_attrs(HBuilder* b, const char* q, const char* end,
2881
2894
  char** attrs, int* n) {
2882
2895
  while (q < end && *q != '>' && *n + 1 < 32) {
@@ -2927,7 +2940,12 @@ static void h_stash_attrs(HBuilder* b, const char* q, const char* end,
2927
2940
  int dup = 0;
2928
2941
  for (int j = 0; j + 1 < *n; j += 2)
2929
2942
  if (strcmp(attrs[j], aname) == 0) { dup = 1; break; }
2930
- if (!dup) {
2943
+ if (dup) {
2944
+ h_diag(b, "duplicate-attribute",
2945
+ (size_t)(as - b->input_base),
2946
+ "duplicate attribute '%s' ignored; first value "
2947
+ "wins", aname);
2948
+ } else {
2931
2949
  attrs[(*n)++] = aname;
2932
2950
  attrs[(*n)++] = aval;
2933
2951
  }
@@ -5532,8 +5550,43 @@ static LeptrisTextNode* h_text_node(HBuilder* b, const char* s,
5532
5550
  return leptris_text_create(dec, dlen, b->pool, b->doc);
5533
5551
  }
5534
5552
 
5553
+ /* #1771: record one §13.2.5 parse-error token. Code is the spec
5554
+ * token name; `off` is the byte offset into the owned input.
5555
+ * Line/column are computed with a forward-only line memo (lint
5556
+ * volumes are ordered, so the walk amortizes). No-op unless the
5557
+ * option bit is set — the tree construction stays exactly as
5558
+ * fast without it. */
5559
+ #if defined(__GNUC__)
5560
+ static void h_diag(HBuilder* b, const char* code, size_t off,
5561
+ const char* fmt, ...)
5562
+ __attribute__((format(printf, 4, 5)));
5563
+ #endif
5564
+ static void h_diag(HBuilder* b, const char* code, size_t off,
5565
+ const char* fmt, ...) {
5566
+ if (!b->collect_errors || !b->doc) return;
5567
+ if (off > b->input_len) off = b->input_len;
5568
+ int line = b->err_line_memo, col = 1;
5569
+ size_t from = b->err_line_memo_off;
5570
+ if (off < from) { from = 0; line = 1; }
5571
+ for (size_t i = from; i < off; i++) {
5572
+ if (b->input_base[i] == '\n') { line++; col = 1; }
5573
+ else col++;
5574
+ }
5575
+ b->err_line_memo = line;
5576
+ b->err_line_memo_off = off;
5577
+ char detail[192];
5578
+ va_list ap;
5579
+ va_start(ap, fmt);
5580
+ vsnprintf(detail, sizeof(detail), fmt, ap);
5581
+ va_end(ap);
5582
+ leptris_diag_emit_code(&b->doc->parse_diags, &b->doc->parse_diag_count,
5583
+ &b->doc->parse_diag_cap, LEPTRIS_DIAG_RECOVER,
5584
+ code, line, col, "%s", detail);
5585
+ }
5586
+
5535
5587
  static LeptrisDocument html_parse_shared(
5536
- const char* buf, size_t len, LeptrisStatus* status, int whatwg) {
5588
+ const char* buf, size_t len, LeptrisStatus* status, int whatwg,
5589
+ LeptrisParseFlags flags) {
5537
5590
  if (status) *status = LEPTRIS_OK;
5538
5591
  if (!buf) {
5539
5592
  if (status) *status = LEPTRIS_ERROR_NULL_ARG;
@@ -5561,6 +5614,8 @@ static LeptrisDocument html_parse_shared(
5561
5614
  b.doc = doc;
5562
5615
  b.pool = doc->pool;
5563
5616
  b.whatwg = whatwg;
5617
+ b.collect_errors =
5618
+ (flags & LEPTRIS_PARSE_COLLECT_HTML_ERRORS) ? 1 : 0;
5564
5619
  b.whatwg_head_set = whatwg;
5565
5620
  b.whatwg_foster = whatwg;
5566
5621
  b.whatwg_adopt = whatwg;
@@ -5576,6 +5631,10 @@ static LeptrisDocument html_parse_shared(
5576
5631
  }
5577
5632
  memcpy(owned, buf, len);
5578
5633
  owned[len] = '\0';
5634
+ /* #1771: the lint channel's offset base — after `owned` exists. */
5635
+ b.input_base = owned;
5636
+ b.input_len = len;
5637
+ b.err_line_memo = 1;
5579
5638
  b.owned = owned;
5580
5639
  h_id_luts_init();
5581
5640
 
@@ -5724,6 +5783,10 @@ static LeptrisDocument html_parse_shared(
5724
5783
  }
5725
5784
  if (dt_kw && (doc->doctype || b.left_initial ||
5726
5785
  h_in_head_noscript(&b))) {
5786
+ h_diag(&b, "unexpected-doctype",
5787
+ (size_t)(p - b.input_base),
5788
+ "DOCTYPE token outside the initial mode "
5789
+ "is ignored");
5727
5790
  while (q < end && *q != '>') q++;
5728
5791
  p = (q < end) ? q + 1 : end;
5729
5792
  text = p;
@@ -5946,6 +6009,10 @@ static LeptrisDocument html_parse_shared(
5946
6009
  }
5947
6010
  const char* q2 = ns2;
5948
6011
  while (q2 < end && *q2 != '>') q2++;
6012
+ h_diag(&b, "unexpected-character-in-tag",
6013
+ (size_t)(ns2 - b.input_base),
6014
+ "malformed tag construct became a bogus "
6015
+ "comment");
5949
6016
  h_bogus_comment(&b, ns2, (size_t)(q2 - ns2));
5950
6017
  p = (q2 < end) ? q2 + 1 : end;
5951
6018
  text = p;
@@ -5955,6 +6022,8 @@ static LeptrisDocument html_parse_shared(
5955
6022
  /* #659 eof-in-tag: an unterminated END tag at EOF is
5956
6023
  * dropped too (same 13.2.5.44 rule). */
5957
6024
  if (b.whatwg && !memchr(p, '>', (size_t)(end - p))) {
6025
+ h_diag(&b, "eof-in-tag", (size_t)(p - b.input_base),
6026
+ "end tag at EOF is dropped");
5958
6027
  if (text < p) {
5959
6028
  LeptrisTextNode* t = h_text_node(&b, text, p);
5960
6029
  if (t) h_append(&b, (LeptrisNodeRef)t);
@@ -6760,6 +6829,9 @@ static LeptrisDocument html_parse_shared(
6760
6829
  * a parse error — the tag is dropped, pending text lands,
6761
6830
  * parsing ends (webkit01:4: '<di' -> empty body). */
6762
6831
  if (b.whatwg && !memchr(p, '>', (size_t)(end - p))) {
6832
+ /* #1771: eof-in-tag (13.2.5.44). */
6833
+ h_diag(&b, "eof-in-tag", (size_t)(p - b.input_base),
6834
+ "start tag at EOF is dropped");
6763
6835
  if (text < p) {
6764
6836
  LeptrisTextNode* t = h_text_node(&b, text, p);
6765
6837
  if (t) h_append(&b, (LeptrisNodeRef)t);
@@ -6800,6 +6872,10 @@ static LeptrisDocument html_parse_shared(
6800
6872
  scan++;
6801
6873
  }
6802
6874
  if (!closed) {
6875
+ /* #1771: eof-in-tag — the unterminated START tag
6876
+ * is dropped (13.2.5.44, webkit02:5). */
6877
+ h_diag(&b, "eof-in-tag", (size_t)(p - b.input_base),
6878
+ "start tag at EOF is dropped");
6803
6879
  if (text < p) {
6804
6880
  LeptrisTextNode* t =
6805
6881
  h_text_node(&b, text, p);
@@ -7861,6 +7937,14 @@ static LeptrisDocument html_parse_shared(
7861
7937
  vs = scan;
7862
7938
  while (scan < end && *scan != quote) scan++;
7863
7939
  vlen = (size_t)(scan - vs);
7940
+ if (scan >= end) {
7941
+ /* #1771: the quote never closed — 13.2.5
7942
+ * eof-in-tag. */
7943
+ h_diag(&b, "eof-in-tag",
7944
+ (size_t)(as - b.input_base),
7945
+ "attribute value at EOF is "
7946
+ "unterminated");
7947
+ }
7864
7948
  if (scan < end) scan++;
7865
7949
  } else {
7866
7950
  vs = scan;
@@ -7883,6 +7967,13 @@ static LeptrisDocument html_parse_shared(
7883
7967
  aval = (char*)"";
7884
7968
  }
7885
7969
  if (aval) {
7970
+ /* #1771: a same-named attribute is ignored (first
7971
+ * value wins, 13.2.5.3 duplicate-attribute). */
7972
+ if (leptris_element_attribute(e, aname))
7973
+ h_diag(&b, "duplicate-attribute",
7974
+ (size_t)(as - b.input_base),
7975
+ "duplicate attribute '%s' ignored; first "
7976
+ "value wins", aname);
7886
7977
  /* #659: foreign attribute-name adjustment
7887
7978
  * (viewBox, definitionURL, ...). */
7888
7979
  const char* aadj =
@@ -8300,7 +8391,7 @@ LEPTRIS_API LeptrisDocument leptris_parse_html_string(
8300
8391
  /* #659: THE WHATWG ENGINE — the full "in head" set lifts into
8301
8392
  * the implied head (script/style/noscript/template/...). The
8302
8393
  * html5lib corpus is this entry's conformance meter. */
8303
- return html_parse_shared(buf, len, status, 1);
8394
+ return html_parse_shared(buf, len, status, 1, LEPTRIS_PARSE_DEFAULT);
8304
8395
  }
8305
8396
 
8306
8397
  LEPTRIS_API LeptrisDocument leptris_parse_html4_string(
@@ -8308,5 +8399,13 @@ LEPTRIS_API LeptrisDocument leptris_parse_html4_string(
8308
8399
  /* #659: libxml2/Nokogiri compatibility — leading script/style
8309
8400
  * stay in body (title/meta/link/base still lift). The
8310
8401
  * committed Nokogiri reference trees measure this entry. */
8311
- return html_parse_shared(buf, len, status, 0);
8402
+ return html_parse_shared(buf, len, status, 0, LEPTRIS_PARSE_DEFAULT);
8403
+ }
8404
+
8405
+ LEPTRIS_API LeptrisDocument leptris_parse_html_string_flags(
8406
+ const char* buf, size_t len, LeptrisParseFlags flags,
8407
+ LeptrisStatus* status) {
8408
+ /* #1771: the WHATWG engine with per-call options — the parse-
8409
+ * lint bit rides here. */
8410
+ return html_parse_shared(buf, len, status, 1, flags);
8312
8411
  }
@@ -154,6 +154,7 @@ if(LEPTRIS_ENABLE_DUCET)
154
154
  endif()
155
155
  leptris_add_test(test_xquery xquery/test_xquery.cpp)
156
156
  leptris_add_test(test_html html/test_html.cpp)
157
+ leptris_add_test(test_html_parse_errors html/test_html_parse_errors.cpp)
157
158
  leptris_add_test(test_html_builder html/test_html_builder.cpp)
158
159
  leptris_add_test(test_html_borrowed_names html/test_html_borrowed_names.cpp)
159
160
  leptris_add_test(test_html5lib html/test_html5lib.cpp)
@@ -0,0 +1,102 @@
1
+ // test/html/test_html_parse_errors.cpp — the #1771 parse-lint
2
+ // surface: LEPTRIS_PARSE_COLLECT_HTML_ERRORS records the §13.2.5
3
+ // parse-error token stream (code + line + column) on the document's
4
+ // diagnostic list. Opt-in: without the bit the list stays empty and
5
+ // the tree construction is unchanged.
6
+ #include <gtest/gtest.h>
7
+ #include <leptris.h>
8
+ #include <leptris/html.h>
9
+ #include <leptris/error.h>
10
+ #include <cstring>
11
+
12
+ namespace {
13
+
14
+ struct Diag {
15
+ LeptrisDiagKind kind;
16
+ std::string code;
17
+ std::string message;
18
+ int line, col;
19
+ };
20
+
21
+ std::vector<Diag> drain(LeptrisDocument doc) {
22
+ std::vector<Diag> out;
23
+ size_t n = leptris_document_parse_diag_count(doc);
24
+ for (size_t i = 0; i < n; i++) {
25
+ LeptrisDiagKind kind = LEPTRIS_DIAG_INVALID;
26
+ char msg[256] = {0};
27
+ char code[64] = {0};
28
+ int line = 0, col = 0;
29
+ EXPECT_TRUE(leptris_document_parse_diag(doc, i, &kind, msg,
30
+ sizeof(msg)));
31
+ EXPECT_TRUE(leptris_document_parse_diag_code(doc, i, code,
32
+ sizeof(code),
33
+ &line, &col));
34
+ out.push_back({kind, code, msg, line, col});
35
+ }
36
+ return out;
37
+ }
38
+
39
+ } // namespace
40
+
41
+ TEST(HtmlParseErrors, OffByDefaultListStaysEmpty) {
42
+ LeptrisStatus st = LEPTRIS_OK;
43
+ const char* xml = "<a id=\"1\" id=\"2\">x</a>";
44
+ LeptrisDocument d = leptris_parse_html_string(xml, strlen(xml), &st);
45
+ ASSERT_NE(d, nullptr);
46
+ EXPECT_EQ(leptris_document_parse_diag_count(d), 0u);
47
+ leptris_document_free(d);
48
+ }
49
+
50
+ TEST(HtmlParseErrors, DuplicateAttributeCarriesSpecCode) {
51
+ LeptrisStatus st = LEPTRIS_OK;
52
+ const char* xml = "<a id=\"1\" id=\"2\">x</a>";
53
+ LeptrisDocument d = leptris_parse_html_string_flags(
54
+ xml, strlen(xml), LEPTRIS_PARSE_COLLECT_HTML_ERRORS, &st);
55
+ ASSERT_NE(d, nullptr);
56
+ auto diags = drain(d);
57
+ ASSERT_GE(diags.size(), 1u);
58
+ bool found = false;
59
+ for (auto& dd : diags)
60
+ if (dd.code == "duplicate-attribute") found = true;
61
+ EXPECT_TRUE(found);
62
+ EXPECT_EQ(diags[0].kind, LEPTRIS_DIAG_RECOVER);
63
+ leptris_document_free(d);
64
+ }
65
+
66
+ TEST(HtmlParseErrors, EofInTagCarriesSpecCode) {
67
+ LeptrisStatus st = LEPTRIS_OK;
68
+ const char* xml = "<p>text<a href=\"";
69
+ LeptrisDocument d = leptris_parse_html_string_flags(
70
+ xml, strlen(xml), LEPTRIS_PARSE_COLLECT_HTML_ERRORS, &st);
71
+ ASSERT_NE(d, nullptr);
72
+ auto diags = drain(d);
73
+ ASSERT_GE(diags.size(), 1u);
74
+ EXPECT_EQ(diags[0].code, "eof-in-tag");
75
+ EXPECT_EQ(diags[0].line, 1);
76
+ EXPECT_EQ(diags[0].col, 8);
77
+ leptris_document_free(d);
78
+ }
79
+
80
+ TEST(HtmlParseErrors, DoctypeOutsideInitialIgnored) {
81
+ LeptrisStatus st = LEPTRIS_OK;
82
+ const char* xml = "<div><!DOCTYPE html></div>";
83
+ LeptrisDocument d = leptris_parse_html_string_flags(
84
+ xml, strlen(xml), LEPTRIS_PARSE_COLLECT_HTML_ERRORS, &st);
85
+ ASSERT_NE(d, nullptr);
86
+ auto diags = drain(d);
87
+ ASSERT_GE(diags.size(), 1u);
88
+ EXPECT_EQ(diags[0].code, "unexpected-doctype");
89
+ leptris_document_free(d);
90
+ }
91
+
92
+ TEST(HtmlParseErrors, LineColumnTrackRealLines) {
93
+ LeptrisStatus st = LEPTRIS_OK;
94
+ const char* xml = "<p>one</p>\n<p>two</p>\n<a id=\"1\" id=\"2\">x</a>";
95
+ LeptrisDocument d = leptris_parse_html_string_flags(
96
+ xml, strlen(xml), LEPTRIS_PARSE_COLLECT_HTML_ERRORS, &st);
97
+ ASSERT_NE(d, nullptr);
98
+ auto diags = drain(d);
99
+ ASSERT_GE(diags.size(), 1u);
100
+ EXPECT_EQ(diags[0].line, 3);
101
+ leptris_document_free(d);
102
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg/master/scripts/vcpkg.schema.json",
3
3
  "name": "leptris",
4
- "version": "1.9.370",
4
+ "version": "1.9.371",
5
5
  "description": "High-performance XML parser and XPath 1.0 engine in C (libleptris)",
6
6
  "homepage": "https://github.com/leptris/leptris",
7
7
  "license": "MIT",
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: leptris
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.370.1
4
+ version: 1.9.371.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-18 00:00:00.000000000 Z
11
+ date: 2026-10-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ffi
@@ -841,6 +841,7 @@ files:
841
841
  - vendor-src/libleptris/test/html/test_html5lib.cpp
842
842
  - vendor-src/libleptris/test/html/test_html_borrowed_names.cpp
843
843
  - vendor-src/libleptris/test/html/test_html_builder.cpp
844
+ - vendor-src/libleptris/test/html/test_html_parse_errors.cpp
844
845
  - vendor-src/libleptris/test/memory/test_arena.cpp
845
846
  - vendor-src/libleptris/test/memory/test_doc_block.cpp
846
847
  - vendor-src/libleptris/test/memory/test_mut_recycle.cpp