leptris 1.9.204.0 → 1.9.207.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -19
  3. data/Rakefile +1 -1
  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/x86_64-darwin/libleptris.dylib +0 -0
  11. data/lib/leptris/vendor/x86_64-linux/libleptris.so +0 -0
  12. data/lib/leptris/vendor/x86_64-linux-musl/libleptris.so +0 -0
  13. data/lib/leptris/version.rb +1 -1
  14. data/lib/leptris/xml/ffi.rb +28 -0
  15. data/vendor-src/libleptris/CHANGELOG.md +51 -0
  16. data/vendor-src/libleptris/CMakeLists.txt +1 -1
  17. data/vendor-src/libleptris/src/include/leptris/error.h +56 -0
  18. data/vendor-src/libleptris/src/include/leptris/types.h +29 -1
  19. data/vendor-src/libleptris/src/leptris/dom/diag.h +2 -13
  20. data/vendor-src/libleptris/src/leptris/dom/digest.c +5 -1
  21. data/vendor-src/libleptris/src/leptris/error.c +22 -0
  22. data/vendor-src/libleptris/src/leptris/flat/direct_parse.c +61 -6
  23. data/vendor-src/libleptris/src/leptris/html/html_parse.c +125 -39
  24. data/vendor-src/libleptris/src/leptris/leptris.c +7 -0
  25. data/vendor-src/libleptris/src/leptris/leptris_internal.h +23 -1
  26. data/vendor-src/libleptris/src/leptris/sax/pull.c +52 -17
  27. data/vendor-src/libleptris/src/leptris/sax/streaming.c +90 -5
  28. data/vendor-src/libleptris/test/dom/test_digest.cpp +36 -0
  29. data/vendor-src/libleptris/test/dom/test_dom.cpp +24 -0
  30. data/vendor-src/libleptris/test/parser/test_parser.cpp +43 -0
  31. data/vendor-src/libleptris/test/sax/test_pull.cpp +32 -0
  32. data/vendor-src/libleptris/vcpkg.json +1 -1
  33. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0facebf2d44046c5a2c7ddaca5fdc8d2b5aea2b92017f6f1de8844122ec0eda4
4
- data.tar.gz: f0f09d7786bd551d6f7077e1112e499ad25d6236a379d10607f59aeb2ddef02e
3
+ metadata.gz: b10f689b2142684316157264cfd935433d6d0d22db8cf90b4a27e9c4ba79bd88
4
+ data.tar.gz: f3e625c375d995087bec8cd6c058a7a9c900791125f1422f6da8b2436e90047b
5
5
  SHA512:
6
- metadata.gz: cbb0f3100ba9c705da214daafe066dcdbd973e41abb08d04e4b6db69a18091976b0083e223c9e888829fc46e5b6c4fe49ddf7d3defa380e62631fdc2d3897f7b
7
- data.tar.gz: e11a0141773522b79b5ddc5217aa706b1ef01517e8981924842e0f1b7d272499601b3723d6df23c2741dea1e5e5733818a8b743a67af19b63be0f4ce9a554c10
6
+ metadata.gz: ddf28875bb34e2eb14074c51834614f5cb5acddf0a33c58f55a50d9e6970a065eb70cb6f3da51c1114cad3a8c062a6dec98d6ce271ff27a81a77033f92e774ea
7
+ data.tar.gz: b1c7bc0629b2840a7a23d71bd27b08303426ab7b013d8a272d9a1fedc82b8f40ba8fdd6805724f878fbf4374752912658ea5922e79548f16a53a859cf4de3164
data/CHANGELOG.md CHANGED
@@ -54,25 +54,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
54
54
  ### Not fixed despite upstream closes
55
55
 
56
56
  - **Iterparse attribute reads: all-nil runs on ubuntu-latest Ruby
57
- 3.4 CI legs** — filed upstream as leptris/leptris#1242
58
- (leptris-ruby#279 tracks the investigation). Six strikes; the
59
- last three were consecutive reruns of one job, suggesting
60
- per-machine determinism. Unreproducible across ~75k local reads
61
- on darwin/arm64 (native + FFI, GC.stress) and x86_64 linux
62
- (qemu; native + FFI; threaded and not). The leg sets
63
- `LEPTRIS_SKIP_1242=1` to quarantine exactly that one spec on
64
- that one leg until the engine ships the fix; every other leg
65
- keeps the full guard.
66
-
67
- - **#1220 reopened with the 1.9.203 repro**: HTML-document
68
- mutations are still lost on serialization (the exact original
69
- repro stands; node reads back mutated, serializer sees the old
70
- tree). Tracking continues upstream.
71
- - **#1197 reopened with fresh 1.9.203 evidence**: the s390x qemu
72
- leg failed 442/742 with the identical `malformed input`
73
- signature at ASCII offsets; the reopen quotes the close
74
- comment's own terms. (1.9.204's notes carry no BE fix; the leg
75
- stays experimental until the engine ships one.)
57
+ ## [1.9.207.0] - 2026-09-19
58
+
59
+ ### Fixed engine sync: big-endian close-tag parse (#1197 upstream)
60
+
61
+ - Vendored libleptris 1.9.207: the close-tag fast-path compare
62
+ masked the wrong end of its 8-byte name load on big-endian hosts,
63
+ rejecting ordinary paired-tag documents with "malformed input"
64
+ (434/727 binding-suite failures under qemu s390x). The mask now
65
+ follows memory order per byte dialect; the s390x-linux-musl
66
+ platform gem becomes viable with this release.
67
+
68
+ ### Added FFI mirror adopts the 1.9.202–1.9.207 engine surface
69
+
70
+ - audit:symbols lockstep attachments: recover diagnostics
71
+ (leptris_document_parse_diag[_count]), the standalone DTD family
72
+ (leptris_dtd_*), declaration/doctype removal, engine-heap buffer
73
+ allocation, and one-shot SAX. leptris_set_error stays internal
74
+ the engine now hides it on ELF too (leptris#1243), so the export
75
+ surface agrees across Linux/macOS/Windows.
76
76
 
77
77
  ## [1.9.201.3] - 2026-09-19
78
78
 
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.204"
11
+ LIBLEPTRIS_VERSION = "1.9.207"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.204.0"
4
+ VERSION = "1.9.207.0"
5
5
  end
@@ -137,6 +137,7 @@ module Leptris
137
137
  typedef :pointer, :leptris_iterparse
138
138
  typedef :pointer, :leptris_xpath_var_set
139
139
  typedef :pointer, :leptris_sax_parser
140
+ typedef :pointer, :leptris_dtd
140
141
  typedef :int, :leptris_status
141
142
 
142
143
  class SAXHandler < ::FFI::Struct
@@ -1156,6 +1157,33 @@ attach_function :leptris_parse_string,
1156
1157
  [:leptris_document, :pointer, :pointer], :leptris_status
1157
1158
  attach_function :leptris_status_string, [:leptris_status], :string
1158
1159
  attach_function :leptris_error_message, [:leptris_status], :string
1160
+ # Engine surface adopted with libleptris 1.9.206 (the FFI
1161
+ # mirror audit keeps attachments and exports in lockstep):
1162
+ # recover diagnostics (#1200), the standalone DTD family
1163
+ # (leptris_dtd_*), declaration/doctype removal (#1094), the
1164
+ # engine-heap buffer (#1219), one-shot SAX (#1178), and the
1165
+ # exported error-recorder entry point.
1166
+ attach_function :leptris_alloc_buffer, [:size_t], :pointer
1167
+ attach_function :leptris_document_clear_declaration,
1168
+ [:leptris_document], :leptris_status
1169
+ attach_function :leptris_document_remove_doctype,
1170
+ [:leptris_document], :leptris_status
1171
+ attach_function :leptris_document_parse_diag_count,
1172
+ [:leptris_document], :size_t
1173
+ attach_function :leptris_document_parse_diag,
1174
+ [:leptris_document, :size_t, :pointer, :pointer, :size_t], :int
1175
+ attach_function :leptris_dtd_parse,
1176
+ [:string, :size_t], :leptris_dtd
1177
+ attach_function :leptris_dtd_free, [:leptris_dtd], :void
1178
+ attach_function :leptris_dtd_parse_external_subset,
1179
+ [:leptris_dtd, :string, :size_t], :int
1180
+ attach_function :leptris_dtd_set_pe_loader,
1181
+ [:leptris_dtd, :pointer, :pointer], :void
1182
+ attach_function :leptris_dtd_validate,
1183
+ [:leptris_document, :leptris_dtd, :pointer], :int
1184
+ attach_function :leptris_dtd_error_free, [:pointer], :void
1185
+ attach_function :leptris_sax_parser_set_one_shot,
1186
+ [:leptris_sax_parser, :int], :void
1159
1187
  # Thread-global last-failure position (error.h): companion to
1160
1188
  # leptris_last_error; populated by recover parses (#547).
1161
1189
  attach_function :leptris_last_error_position,
@@ -1,5 +1,56 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.9.207] - 2026-09-19
4
+
5
+ ### Fixed
6
+
7
+ - stop leptris_set_error leaking into the ELF dynamic table (build)
8
+
9
+
10
+
11
+ ## [1.9.206] - 2026-09-19
12
+
13
+ ### Fixed
14
+
15
+ - endian-neutral close-tag name compare (#1197) (parse)
16
+
17
+ ### Performance
18
+
19
+ - memchr finds the text span end (TODO.max-perf/2-3 slice 3) (sax)
20
+ - zero-copy slice emission for one-shot pulls (#1178 tail) (sax)
21
+
22
+
23
+
24
+ ## [1.9.205] - 2026-09-19
25
+
26
+ ### Added
27
+
28
+ - Parse recover diagnostics (#1200): the DOM parse now records
29
+ duplicate-attribute events on the document — `LeptrisDiagKind`
30
+ (with the new `LEPTRIS_DIAG_RECOVER` kind) is public in
31
+ types.h, and the diag list reads through
32
+ `leptris_document_parse_diag_count` /
33
+ `leptris_document_parse_diag` (error.h). First definition wins,
34
+ matching the SAX lane's libxml2-recover surface; the digest
35
+ gate no longer needs a SAX probe pass.
36
+ - `LEPTRIS_DIGEST_ATTR_ORDER` (#1200 part 1): opt-in digest flag
37
+ that makes attribute order identity-bearing instead of sorted
38
+ away.
39
+
40
+ ### Fixed
41
+
42
+ - MSVC C2027 in `leptris_internal.h`: the `LeptrisDiag` forward
43
+ declaration moved to file scope (broke the windows-dll-exports
44
+ leg).
45
+
46
+ ### Performance
47
+
48
+ - First-char-bucketed tag classifier for the WHATWG HTML path:
49
+ the tokenizer's name-list strcmp scans become one-load bucket
50
+ lookups — parse +31-37% on the #1218 bench shapes.
51
+
52
+
53
+
3
54
  ## [1.9.204] - 2026-09-19
4
55
 
5
56
  ### Added
@@ -3,7 +3,7 @@
3
3
  cmake_minimum_required(VERSION 3.20)
4
4
 
5
5
  project(leptris
6
- VERSION 1.9.204
6
+ VERSION 1.9.207
7
7
  DESCRIPTION "Fast XML parser and XPath evaluator in pure C"
8
8
  LANGUAGES C CXX
9
9
  )
@@ -96,6 +96,62 @@ LEPTRIS_API void leptris_last_error_position(int* line, int* column);
96
96
  */
97
97
  LEPTRIS_API const char* leptris_document_last_error(LeptrisDocument doc);
98
98
 
99
+ /* ============================================================================
100
+ * Parse diagnostics (#1200): recover-class events recorded on the
101
+ * DOM parse (duplicate attributes today) — the SAX lane's recover
102
+ * error list, unified engine-side so both lanes report the same
103
+ * surface. Documents stay usable; the diagnostics are advisory.
104
+ * ============================================================================ */
105
+
106
+ #ifndef LEPTRIS_API
107
+ # ifdef _WIN32
108
+ # ifdef LEPTRIS_BUILD_SHARED
109
+ # define LEPTRIS_API __declspec(dllexport)
110
+ # elif defined(LEPTRIS_BUILDING_DLL)
111
+ # define LEPTRIS_API __declspec(dllexport)
112
+ # elif defined(LEPTRIS_USE_SHARED)
113
+ # define LEPTRIS_API __declspec(dllimport)
114
+ # else
115
+ # define LEPTRIS_API
116
+ # endif
117
+ # else
118
+ # if defined(__GNUC__) && !defined(__clang__)
119
+ # define LEPTRIS_API __attribute__((visibility("default"), used, \
120
+ externally_visible))
121
+ # else
122
+ # define LEPTRIS_API __attribute__((visibility("default"), used))
123
+ # endif
124
+ # endif
125
+ #endif
126
+
127
+ /**
128
+ * Number of recover-class diagnostics recorded during the
129
+ * document's parse.
130
+ *
131
+ * @param doc Document handle
132
+ * @return Diagnostic count (0 for NULL doc or a clean parse)
133
+ */
134
+ LEPTRIS_API size_t leptris_document_parse_diag_count(LeptrisDocument doc);
135
+
136
+ /**
137
+ * Read one parse diagnostic by index.
138
+ *
139
+ * @param doc Document handle
140
+ * @param index Diagnostic index (< leptris_document_parse_diag_count)
141
+ * @param kind Out: diagnostic kind (may be NULL)
142
+ * @param message Out: NUL-terminated message buffer (may be NULL)
143
+ * @param message_cap Capacity of message (ignored when message NULL)
144
+ * @return 1 on success; 0 for NULL doc or out-of-range index
145
+ *
146
+ * Memory: Message is copied into the caller's buffer; diagnostics
147
+ * are owned by the document until leptris_document_free.
148
+ */
149
+ LEPTRIS_API int leptris_document_parse_diag(LeptrisDocument doc,
150
+ size_t index,
151
+ LeptrisDiagKind* kind,
152
+ char* message,
153
+ size_t message_cap);
154
+
99
155
  #ifdef __cplusplus
100
156
  }
101
157
  #endif
@@ -246,6 +246,28 @@ typedef enum {
246
246
  LEPTRIS_DIFF_IGNORE_WS_TEXT = 1u
247
247
  } LeptrisDiffFlags;
248
248
 
249
+ /* Unified narration record kinds (#1126). One structured record
250
+ * per validation/recover event, shared by every validator (RNG
251
+ * today; XSD/Schematron follow) and the DOM parse's recover
252
+ * channel (#1200). The kind selects the reporting convention;
253
+ * append-only. */
254
+ typedef enum {
255
+ LEPTRIS_DIAG_INVALID = 0,
256
+ LEPTRIS_DIAG_NOT_ALLOWED_ANYWHERE, /* name never valid in model */
257
+ LEPTRIS_DIAG_NOT_ALLOWED_HERE, /* valid name, wrong position */
258
+ LEPTRIS_DIAG_NOT_ALLOWED_YET, /* later; missing required first */
259
+ LEPTRIS_DIAG_INCOMPLETE, /* content ends before required */
260
+ LEPTRIS_DIAG_MISSING_REQUIRED_ATTR,
261
+ LEPTRIS_DIAG_ATTR_NOT_ALLOWED,
262
+ LEPTRIS_DIAG_ATTR_VALUE_INVALID,
263
+ LEPTRIS_DIAG_CHAR_CONTENT_INVALID, /* data/value/list mismatch */
264
+ /* Recover-class parse event: the document is usable, the input
265
+ * was not conformant (duplicate attribute, ...). Not a model
266
+ * error; SAX-lane "recover" parity (#1200). */
267
+ LEPTRIS_DIAG_RECOVER,
268
+ LEPTRIS_DIAG_COUNT_
269
+ } LeptrisDiagKind;
270
+
249
271
  /* Subtree digest flags (issue #869). The digest is content-defined
250
272
  * (no pointers/addresses — stable across processes); equality
251
273
  * implies subtree equivalence under the flag semantics. */
@@ -253,7 +275,13 @@ typedef enum {
253
275
  LEPTRIS_DIGEST_DEFAULT = 0,
254
276
  /* Skip whitespace-only text nodes entirely (comparator's
255
277
  * strip-profile default). */
256
- LEPTRIS_DIGEST_DROP_WS_TEXT = 1u
278
+ LEPTRIS_DIGEST_DROP_WS_TEXT = 1u,
279
+ /* Hash attributes in DOCUMENT order instead of the sorted
280
+ * canonical order — documents differing only in attribute
281
+ * order digest DIFFERENT under this flag (canon's verbose
282
+ * diff gate, #1200). Equality under this flag additionally
283
+ * implies attribute-order identity. */
284
+ LEPTRIS_DIGEST_ATTR_ORDER = 2u
257
285
  } LeptrisDigestFlags;
258
286
 
259
287
  typedef struct {
@@ -17,20 +17,9 @@
17
17
  extern "C" {
18
18
  #endif
19
19
 
20
- typedef enum {
21
- LEPTRIS_DIAG_INVALID = 0,
22
- LEPTRIS_DIAG_NOT_ALLOWED_ANYWHERE, /* name never valid in model */
23
- LEPTRIS_DIAG_NOT_ALLOWED_HERE, /* valid name, wrong position */
24
- LEPTRIS_DIAG_NOT_ALLOWED_YET, /* later; missing required first */
25
- LEPTRIS_DIAG_INCOMPLETE, /* content ends before required */
26
- LEPTRIS_DIAG_MISSING_REQUIRED_ATTR,
27
- LEPTRIS_DIAG_ATTR_NOT_ALLOWED,
28
- LEPTRIS_DIAG_ATTR_VALUE_INVALID,
29
- LEPTRIS_DIAG_CHAR_CONTENT_INVALID, /* data/value/list mismatch */
30
- LEPTRIS_DIAG_COUNT_
31
- } LeptrisDiagKind;
20
+ /* LeptrisDiagKind lives in leptris/types.h (public, #1200). */
32
21
 
33
- typedef struct {
22
+ typedef struct LeptrisDiag {
34
23
  LeptrisDiagKind kind;
35
24
  char* offender_name; /* captured at emit time (strdup'd);
36
25
  * NULL when the diag has no offender.
@@ -181,7 +181,11 @@ static uint64_t digest_element(LeptrisElement e, LeptrisDigestFlags flags) {
181
181
  attrs[n].value_len = leptris_attr_value_sv(a).length;
182
182
  n++;
183
183
  }
184
- qsort(attrs, n, sizeof(DigestAttr), digest_attr_cmp);
184
+ /* ATTR_ORDER (#1200): document order is canonical — the
185
+ * walk above already collected in document order, so
186
+ * skip the sort and attribute order feeds the hash. */
187
+ if (!(flags & LEPTRIS_DIGEST_ATTR_ORDER))
188
+ qsort(attrs, n, sizeof(DigestAttr), digest_attr_cmp);
185
189
  }
186
190
  h = digest_mix_u64(h, n);
187
191
  for (size_t i = 0; i < n; i++) {
@@ -7,6 +7,7 @@
7
7
  #include "common/port.h" /* LEPTRIS_THREAD_LOCAL (TODO.concurrency/01) */
8
8
  #include <stdio.h>
9
9
  #include <string.h>
10
+ #include "dom/diag.h" /* LeptrisDiag layout (#1200) */
10
11
 
11
12
  /* Error state — THREAD-LOCAL since TODO.concurrency/01: concurrent
12
13
  * parses (one document per thread) no longer race the channel. The
@@ -131,4 +132,25 @@ LEPTRIS_API const char* leptris_status_string(LeptrisStatus status) {
131
132
  case LEPTRIS_ERROR_IO: return "I/O error";
132
133
  default: return "Unknown error";
133
134
  }
135
+ }
136
+
137
+ /* ---- #1200: recover-class parse diagnostics ------------------- */
138
+
139
+ LEPTRIS_API size_t leptris_document_parse_diag_count(LeptrisDocument doc) {
140
+ return doc ? (size_t)doc->parse_diag_count : 0;
141
+ }
142
+
143
+ LEPTRIS_API int leptris_document_parse_diag(LeptrisDocument doc,
144
+ size_t index,
145
+ LeptrisDiagKind* kind,
146
+ char* message,
147
+ size_t message_cap) {
148
+ if (!doc || index >= (size_t)doc->parse_diag_count) return 0;
149
+ struct LeptrisDiag* d = (struct LeptrisDiag*)doc->parse_diags + index;
150
+ if (kind) *kind = d->kind;
151
+ if (message && message_cap) {
152
+ strncpy(message, d->message, message_cap - 1);
153
+ message[message_cap - 1] = '\0';
154
+ }
155
+ return 1;
134
156
  }
@@ -437,7 +437,47 @@ static inline int dp_add_attr_inline(DParser* p, LeptrisElement elem,
437
437
  * Careful: first_attribute_off == 0 means empty list. We can't decode
438
438
  * the pointer and check for NULL — at offset 0 the decoded pointer
439
439
  * is `elem` itself, which is non-NULL. Check the offset field. */
440
+ /* #1200: duplicate-attribute detection. Prefilter on (len,
441
+ * first byte) — a mismatching pair costs two loads; the memcmp
442
+ * only runs on genuine candidates. First definition wins
443
+ * (libxml2 recover semantics, and the digest's first-wins
444
+ * becomes official); the duplicate never enters the chain, it
445
+ * costs its already-carved slot. */
440
446
  if (elem->first_attribute_off != 0) {
447
+ int dup = 0;
448
+ for (struct leptris_attribute* a =
449
+ (struct leptris_attribute*)((char*)elem +
450
+ elem->first_attribute_off);
451
+ a; a = leptris_attr_next(a)) {
452
+ if (a->name_view.length == attr->name_view.length &&
453
+ a->name_view.data[0] == attr->name_view.data[0] &&
454
+ memcmp(a->name_view.data, attr->name_view.data,
455
+ attr->name_view.length) == 0) {
456
+ dup = 1;
457
+ break;
458
+ }
459
+ }
460
+ if (dup) {
461
+ if (p->doc) {
462
+ extern struct LeptrisDiag* leptris_diag_emit(
463
+ struct LeptrisDiag**, int*, int*,
464
+ LeptrisDiagKind, LeptrisNodeRef,
465
+ const char*, ...);
466
+ char qname[130];
467
+ size_t ql = attr->name_view.length < 129
468
+ ? attr->name_view.length : 129;
469
+ memcpy(qname, attr->name_view.data, ql);
470
+ qname[ql] = '\0';
471
+ leptris_diag_emit(&p->doc->parse_diags,
472
+ &p->doc->parse_diag_count,
473
+ &p->doc->parse_diag_cap,
474
+ LEPTRIS_DIAG_RECOVER, NULL,
475
+ "Attribute %s redefined", qname);
476
+ }
477
+ /* 1 = duplicate skipped (keep first): the caller skips
478
+ * the raw-attr registration so BOTH surfaces agree. */
479
+ return 1;
480
+ }
441
481
  /* Cache should always be valid mid-parse. Fall back to walk
442
482
  * only if cache is NULL (defensive — shouldn't happen). */
443
483
  struct leptris_attribute* tail = p->current_elem_last_attr;
@@ -845,13 +885,16 @@ static int dp_parse_attrs(DParser* p, LeptrisElement elem) {
845
885
  continue;
846
886
  }
847
887
 
848
- /* Regular attribute — zero-copy name/value, bulk-allocated struct. */
849
- if (dp_add_attr_inline(p, elem, name_start, name_len,
850
- val_start, val_len, has_amp, has_ws,
851
- attr_colon) != 0)
888
+ /* Regular attribute — zero-copy name/value, bulk-allocated
889
+ * struct. 1 = duplicate skipped: neither surface sees it. */
890
+ int arc = dp_add_attr_inline(p, elem, name_start, name_len,
891
+ val_start, val_len, has_amp,
892
+ has_ws, attr_colon);
893
+ if (arc < 0) return -1;
894
+ if (arc == 0 &&
895
+ dp_raw_attr(p, elem, name_start, name_len,
896
+ val_start, val_len) != 0)
852
897
  return -1;
853
- if (dp_raw_attr(p, elem, name_start, name_len,
854
- val_start, val_len) != 0) return -1;
855
898
  }
856
899
  /* for (;;): every exit is a return inside the loop (0 = tag
857
900
  * closed, 1 = self-closing, -1 = error) — there is no
@@ -1571,9 +1614,21 @@ static struct leptris_document* direct_parse_internal(char* buf, size_t len,
1571
1614
  if (close_local_len <= 8 &&
1572
1615
  (p.probe_slack ||
1573
1616
  close_local + 8 <= p.end + 1)) {
1617
+ /* The mask follows MEMORY order: memcpy fills the low
1618
+ * bytes on little-endian and the high bytes on
1619
+ * big-endian, so each dialect must mask the end its
1620
+ * load populated. The unconditional low-bytes mask
1621
+ * compared bytes past the name on big-endian and
1622
+ * failed every close tag (#1197). */
1623
+ #if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
1624
+ uint64_t mask = (close_local_len == 8)
1625
+ ? ~(uint64_t)0
1626
+ : (~(uint64_t)0 << (64 - close_local_len * 8));
1627
+ #else
1574
1628
  uint64_t mask = (close_local_len == 8)
1575
1629
  ? ~(uint64_t)0
1576
1630
  : ((1ull << (close_local_len * 8)) - 1);
1631
+ #endif
1577
1632
  uint64_t a, b;
1578
1633
  memcpy(&a, open_name, 8);
1579
1634
  memcpy(&b, close_local, 8);
@@ -3061,30 +3061,130 @@ static int h_is_formatting(const char* n) {
3061
3061
 
3062
3062
  /* WHATWG "special" category (13.2.4.2) — the adoption agency's
3063
3063
  * furthest-block candidates. */
3064
+ /* Generated tag classifier (#1218 hot path): one first-char
3065
+ * bucket walk replaces the ~78-entry strcmp scans that the
3066
+ * sampler pinned at ~21% of WHATWG parse (h_is_special_ww +
3067
+ * h_reconstructs). Keep in sync with the WHATWG special and
3068
+ * no-formatting sets. */
3069
+ typedef struct {
3070
+ char name[12];
3071
+ uint8_t len;
3072
+ uint8_t special; /* WHATWG 13.2.4 special set */
3073
+ uint8_t no_fmt; /* never reconstructed (formatting) */
3074
+ } HTagInfo;
3075
+
3076
+ static const HTagInfo h_tag_infos[] = {
3077
+ {"address", 7, 1, 1},
3078
+ {"applet", 6, 1, 0},
3079
+ {"area", 4, 1, 0},
3080
+ {"article", 7, 1, 1},
3081
+ {"aside", 5, 1, 1},
3082
+ {"base", 4, 1, 1},
3083
+ {"basefont", 8, 1, 1},
3084
+ {"bgsound", 7, 1, 1},
3085
+ {"blockquote", 10, 1, 1},
3086
+ {"body", 4, 1, 1},
3087
+ {"br", 2, 1, 0},
3088
+ {"button", 6, 1, 0},
3089
+ {"caption", 7, 1, 1},
3090
+ {"center", 6, 1, 1},
3091
+ {"col", 3, 1, 1},
3092
+ {"colgroup", 8, 1, 1},
3093
+ {"dd", 2, 1, 1},
3094
+ {"details", 7, 1, 1},
3095
+ {"dialog", 6, 1, 1},
3096
+ {"dir", 3, 1, 1},
3097
+ {"div", 3, 1, 1},
3098
+ {"dl", 2, 1, 1},
3099
+ {"dt", 2, 1, 1},
3100
+ {"embed", 5, 1, 0},
3101
+ {"fieldset", 8, 1, 1},
3102
+ {"figcaption", 10, 1, 1},
3103
+ {"figure", 6, 1, 1},
3104
+ {"footer", 6, 1, 1},
3105
+ {"form", 4, 1, 1},
3106
+ {"frame", 5, 1, 1},
3107
+ {"frameset", 8, 1, 1},
3108
+ {"h1", 2, 1, 1},
3109
+ {"h2", 2, 1, 1},
3110
+ {"h3", 2, 1, 1},
3111
+ {"h4", 2, 1, 1},
3112
+ {"h5", 2, 1, 1},
3113
+ {"h6", 2, 1, 1},
3114
+ {"head", 4, 1, 1},
3115
+ {"header", 6, 1, 1},
3116
+ {"hgroup", 6, 1, 1},
3117
+ {"hr", 2, 1, 1},
3118
+ {"html", 4, 1, 1},
3119
+ {"iframe", 6, 1, 0},
3120
+ {"img", 3, 1, 0},
3121
+ {"input", 5, 1, 0},
3122
+ {"keygen", 6, 1, 0},
3123
+ {"li", 2, 1, 1},
3124
+ {"link", 4, 1, 1},
3125
+ {"listing", 7, 1, 1},
3126
+ {"main", 4, 1, 1},
3127
+ {"marquee", 7, 1, 0},
3128
+ {"menu", 4, 1, 1},
3129
+ {"meta", 4, 1, 1},
3130
+ {"nav", 3, 1, 1},
3131
+ {"noembed", 7, 1, 0},
3132
+ {"noframes", 8, 1, 1},
3133
+ {"noscript", 8, 1, 0},
3134
+ {"object", 6, 1, 0},
3135
+ {"ol", 2, 1, 1},
3136
+ {"p", 1, 1, 1},
3137
+ {"param", 5, 1, 0},
3138
+ {"plaintext", 9, 1, 1},
3139
+ {"pre", 3, 1, 1},
3140
+ {"script", 6, 1, 1},
3141
+ {"search", 6, 1, 1},
3142
+ {"section", 7, 1, 1},
3143
+ {"select", 6, 1, 0},
3144
+ {"source", 6, 1, 0},
3145
+ {"style", 5, 1, 1},
3146
+ {"summary", 7, 1, 1},
3147
+ {"table", 5, 1, 1},
3148
+ {"tbody", 5, 1, 1},
3149
+ {"td", 2, 1, 1},
3150
+ {"template", 8, 1, 1},
3151
+ {"textarea", 8, 1, 1},
3152
+ {"tfoot", 5, 1, 1},
3153
+ {"th", 2, 1, 1},
3154
+ {"thead", 5, 1, 1},
3155
+ {"title", 5, 1, 1},
3156
+ {"tr", 2, 1, 1},
3157
+ {"track", 5, 1, 0},
3158
+ {"ul", 2, 1, 1},
3159
+ {"wbr", 3, 1, 0},
3160
+ {"xmp", 3, 1, 0},
3161
+ };
3162
+
3163
+ /* bucket[first_char] = start index; bucket[first_char+1] = end */
3164
+ static const uint8_t h_tag_bucket[27] = {
3165
+ 0, 5, 12, 16, 23, 24, 31, 31, 42,
3166
+ 45, 45, 46, 49, 53, 57, 59, 63, 63,
3167
+ 63, 70, 81, 82, 82, 83, 84, 84, 84,
3168
+ };
3169
+
3170
+ /* Resolve a (lowercased) tag name's flags; NULL when unknown. */
3171
+ static const HTagInfo* h_tag_lookup(const char* n) {
3172
+ if (!n) return NULL;
3173
+ unsigned char c = (unsigned char)n[0];
3174
+ if (c < 'a' || c > 'z') return NULL;
3175
+ for (int i = h_tag_bucket[c - 'a'];
3176
+ i < h_tag_bucket[c - 'a' + 1]; i++) {
3177
+ const HTagInfo* t = &h_tag_infos[i];
3178
+ if ((uint8_t)n[t->len] == 0 && t->name[t->len - 1] == n[t->len - 1] &&
3179
+ memcmp(n, t->name, t->len) == 0)
3180
+ return t;
3181
+ }
3182
+ return NULL;
3183
+ }
3184
+
3064
3185
  static int h_is_special_ww(const char* n) {
3065
- static const char* const k[] = {
3066
- "address", "applet", "area", "article", "aside",
3067
- "base", "basefont", "bgsound", "blockquote", "body",
3068
- "br", "button", "caption", "center", "col",
3069
- "colgroup", "dd", "details", "dialog", "dir",
3070
- "div",
3071
- "dl", "dt", "embed", "fieldset", "figcaption",
3072
- "figure", "footer", "form", "frame", "frameset",
3073
- "h1", "h2", "h3", "h4", "h5",
3074
- "h6", "head", "header", "hgroup", "hr",
3075
- "html", "iframe", "img", "input", "keygen",
3076
- "li", "link", "listing", "main", "marquee",
3077
- "menu", "meta", "nav", "noembed", "noframes",
3078
- "noscript", "object", "ol", "p", "param",
3079
- "plaintext", "pre", "script", "search", "section",
3080
- "select", "source", "style", "summary", "table",
3081
- "tbody", "td", "template", "textarea", "tfoot",
3082
- "th", "thead", "title", "tr", "track",
3083
- "ul", "wbr", "xmp", NULL};
3084
- if (!n) return 0;
3085
- for (int i = 0; k[i]; i++)
3086
- if (strcmp(n, k[i]) == 0) return 1;
3087
- return 0;
3186
+ const HTagInfo* t = h_tag_lookup(n);
3187
+ return t && t->special;
3088
3188
  }
3089
3189
 
3090
3190
  #define H_NS_HTML 0
@@ -3256,22 +3356,8 @@ static int h_input_type_hidden(const char* q, const char* end) {
3256
3356
  * head set, the block-level set (they close p instead), the table
3257
3357
  * family, and raw-text elements. */
3258
3358
  static int h_reconstructs(const char* n) {
3259
- static const char* const no[] = {
3260
- "html", "head", "body", "frameset", "base", "basefont",
3261
- "bgsound", "link", "meta", "script", "style", "template",
3262
- "title", "noframes", "address", "article", "aside",
3263
- "blockquote", "center", "details", "dialog", "dir",
3264
- "div", "dl", "fieldset", "figcaption", "figure",
3265
- "footer", "header", "hgroup", "main", "menu", "nav",
3266
- "ol", "p", "search", "section", "summary", "ul",
3267
- "h1", "h2", "h3", "h4", "h5", "h6",
3268
- "pre", "listing", "form", "li", "dd", "dt",
3269
- "plaintext", "textarea", "table", "hr", "caption", "col",
3270
- "colgroup", "frame", "tbody", "tfoot", "thead", "td",
3271
- "th", "tr", NULL};
3272
- for (int i = 0; no[i]; i++)
3273
- if (strcmp(n, no[i]) == 0) return 0;
3274
- return 1;
3359
+ const HTagInfo* t = h_tag_lookup(n);
3360
+ return !(t && t->no_fmt);
3275
3361
  }
3276
3362
 
3277
3363
  /* ---- #659 foreign content (WHATWG 12.2.6.5) ---- */
@@ -836,6 +836,13 @@ LEPTRIS_API void leptris_document_free(struct leptris_document* doc) {
836
836
  /* The pool will be destroyed below, freeing all elements */
837
837
  }
838
838
 
839
+ /* Parse diagnostics (#1200): the recover-channel list. */
840
+ {
841
+ extern void leptris_diag_free(struct LeptrisDiag**, int*, int*);
842
+ leptris_diag_free(&doc->parse_diags, &doc->parse_diag_count,
843
+ &doc->parse_diag_cap);
844
+ }
845
+
839
846
  /* Free document fields */
840
847
  if (doc->encoding) {
841
848
  LEPTRIS_FREE(doc->encoding);
@@ -9,6 +9,19 @@
9
9
  #ifndef LEPTRIS_INTERNAL_H
10
10
  #define LEPTRIS_INTERNAL_H
11
11
 
12
+ /* Internal entry points must not leak into the shared-library ABI.
13
+ * Windows already hides them (no __declspec(dllexport)) and macOS
14
+ * build flags hide non-exported globals, but plain ELF keeps
15
+ * default visibility — leptris_set_error escaped into the Linux
16
+ * dynamic table while being absent from the PE exports, which no
17
+ * cross-platform FFI mirror can attach against (#1197 release
18
+ * cascade). */
19
+ #if defined(_WIN32) || !(defined(__GNUC__) || defined(__clang__))
20
+ #define LEPTRIS_LOCAL
21
+ #else
22
+ #define LEPTRIS_LOCAL __attribute__((visibility("hidden")))
23
+ #endif
24
+
12
25
  #include <stddef.h>
13
26
  #include <stdint.h>
14
27
 
@@ -104,6 +117,8 @@ struct leptris_mut_attr_block {
104
117
  char bytes[];
105
118
  };
106
119
 
120
+ struct LeptrisDiag;
121
+
107
122
  struct leptris_document {
108
123
  struct leptris_element* root; /* Root element (legacy API) */
109
124
  /* Snapshot of this document's last failing parse message
@@ -111,6 +126,13 @@ struct leptris_document {
111
126
  * thread-local channel). */
112
127
  char last_error_message[256];
113
128
 
129
+ /* Recover-class parse diagnostics (#1200): the DOM parse's
130
+ * parity surface with the SAX lane's recover errors. Pool-free
131
+ * list (leptris_diag_* owns it); released in document_free. */
132
+ struct LeptrisDiag* parse_diags;
133
+ int parse_diag_count;
134
+ int parse_diag_cap;
135
+
114
136
  /* EXSLT-style extension pack enabled via leptris_exslt_enable
115
137
  * (TODO.concurrency/06). */
116
138
  int exslt_enabled;
@@ -883,7 +905,7 @@ static inline int leptris_strcmp(const char* s1, const char* s2) {
883
905
  * ============================================================================ */
884
906
 
885
907
  /* Set error with basic message */
886
- void leptris_set_error(leptris_error_code code, const char* message);
908
+ LEPTRIS_LOCAL void leptris_set_error(leptris_error_code code, const char* message);
887
909
 
888
910
  /* Set error with line/column position */
889
911
  void leptris_set_parse_error_position(int line, int column);
@@ -8,6 +8,7 @@
8
8
  * by the slice, not the document. All event strings are owned by the
9
9
  * puller and remain valid until the NEXT leptris_pull_next call. */
10
10
  #include "../../include/leptris/sax/sax.h"
11
+ #include "sax_internal.h"
11
12
  #include <stdio.h>
12
13
  #include <stdlib.h>
13
14
  #include <string.h>
@@ -22,6 +23,11 @@ typedef struct pull_event {
22
23
  /* START_ELEMENT attributes: owned flat copies [n1,v1,n2,v2...] */
23
24
  char** attrs;
24
25
  size_t attr_count;
26
+ /* One-shot buffer pulls (TODO.max-perf/2-3 slice 1): strings
27
+ * borrow the sax parser's stable storage (input buffer + scratch,
28
+ * alive until parser free) — queue_reset_event frees only the
29
+ * attrs array itself. */
30
+ int borrowed;
25
31
  } pull_event;
26
32
 
27
33
  struct leptris_pull_parser {
@@ -57,9 +63,14 @@ struct leptris_pull_parser {
57
63
  };
58
64
 
59
65
  static void queue_reset_event(pull_event* e) {
60
- free(e->name); free(e->text);
66
+ if (!e->borrowed) {
67
+ free(e->name);
68
+ free(e->text);
69
+ }
61
70
  if (e->attrs) {
62
- for (size_t i = 0; i < e->attr_count * 2; i++) free(e->attrs[i]);
71
+ if (!e->borrowed)
72
+ for (size_t i = 0; i < e->attr_count * 2; i++)
73
+ free(e->attrs[i]);
63
74
  free(e->attrs);
64
75
  }
65
76
  memset(e, 0, sizeof(*e));
@@ -73,6 +84,18 @@ static char* pull_strdup_n(const char* s, size_t n) {
73
84
  return c;
74
85
  }
75
86
 
87
+ /* Event strings: borrow in one-shot mode (stable until parser free),
88
+ * strdup otherwise (streaming file mode — the source chunk dies at
89
+ * the next feed). */
90
+ static char* ev_string(pull_event* e, const struct leptris_pull_parser* p,
91
+ const char* s, size_t n) {
92
+ if (p->sax->one_shot) {
93
+ e->borrowed = 1;
94
+ return (char*)(s ? s : "");
95
+ }
96
+ return pull_strdup_n(s ? s : "", n);
97
+ }
98
+
76
99
  static pull_event* queue_push(struct leptris_pull_parser* p) {
77
100
  size_t used = (p->tail + p->cap - p->head) % p->cap;
78
101
  if (used + 1 == p->cap) { /* full: grow */
@@ -104,16 +127,25 @@ static void cb_start_element(void* ud, const char* name, const char** attrs) {
104
127
  pull_event* e = queue_push(p);
105
128
  if (!e) return;
106
129
  e->type = LEPTRIS_PULL_START_ELEMENT;
107
- e->name = pull_strdup_n(name, strlen(name));
130
+ e->name = ev_string(e, p, name, strlen(name));
108
131
  if (attrs) {
109
132
  size_t n = 0;
110
133
  while (attrs[n]) n += 2; /* flat [name, value, ..., NULL] */
111
134
  e->attr_count = n / 2;
112
135
  e->attrs = (char**)calloc(n + 1, sizeof(char*));
113
136
  if (e->attrs) {
114
- for (size_t i = 0; i < n; i++)
115
- e->attrs[i] = pull_strdup_n(attrs[i] ? attrs[i] : "",
116
- attrs[i] ? strlen(attrs[i]) : 0);
137
+ if (p->sax->one_shot) {
138
+ /* Names/values are frame/scratch-owned (parser
139
+ * lifetime); the array itself stays queue-owned. */
140
+ e->borrowed = 1;
141
+ for (size_t i = 0; i < n; i++)
142
+ e->attrs[i] = (char*)(attrs[i] ? attrs[i] : "");
143
+ } else {
144
+ for (size_t i = 0; i < n; i++)
145
+ e->attrs[i] = pull_strdup_n(
146
+ attrs[i] ? attrs[i] : "",
147
+ attrs[i] ? strlen(attrs[i]) : 0);
148
+ }
117
149
  }
118
150
  }
119
151
  }
@@ -123,7 +155,7 @@ static void cb_end_element(void* ud, const char* name) {
123
155
  pull_event* e = queue_push(p);
124
156
  if (!e) return;
125
157
  e->type = LEPTRIS_PULL_END_ELEMENT;
126
- e->name = pull_strdup_n(name, strlen(name));
158
+ e->name = ev_string(e, p, name, strlen(name));
127
159
  }
128
160
 
129
161
  static void cb_characters(void* ud, const char* text, size_t len) {
@@ -131,7 +163,10 @@ static void cb_characters(void* ud, const char* text, size_t len) {
131
163
  pull_event* e = queue_push(p);
132
164
  if (!e) return;
133
165
  e->type = LEPTRIS_PULL_TEXT;
134
- e->text = pull_strdup_n(text, len);
166
+ /* Text spans are raw input-buffer slices — no NUL terminator in
167
+ * stable storage, so they always copy even in one-shot mode
168
+ * (the public event contract is NUL-terminated strings). */
169
+ e->text = pull_strdup_n(text ? text : "", len);
135
170
  e->text_len = len;
136
171
  }
137
172
 
@@ -140,7 +175,7 @@ static void cb_comment(void* ud, const char* comment) {
140
175
  pull_event* e = queue_push(p);
141
176
  if (!e) return;
142
177
  e->type = LEPTRIS_PULL_COMMENT;
143
- e->text = pull_strdup_n(comment, strlen(comment));
178
+ e->text = ev_string(e, p, comment, strlen(comment));
144
179
  e->text_len = strlen(comment);
145
180
  }
146
181
 
@@ -149,7 +184,7 @@ static void cb_cdata(void* ud, const char* cdata) {
149
184
  pull_event* e = queue_push(p);
150
185
  if (!e) return;
151
186
  e->type = LEPTRIS_PULL_CDATA;
152
- e->text = pull_strdup_n(cdata, strlen(cdata));
187
+ e->text = ev_string(e, p, cdata, strlen(cdata));
153
188
  e->text_len = strlen(cdata);
154
189
  }
155
190
 
@@ -158,9 +193,9 @@ static void cb_pi(void* ud, const char* target, const char* data) {
158
193
  pull_event* e = queue_push(p);
159
194
  if (!e) return;
160
195
  e->type = LEPTRIS_PULL_PI;
161
- e->name = pull_strdup_n(target, strlen(target));
196
+ e->name = ev_string(e, p, target, strlen(target));
162
197
  if (data) {
163
- e->text = pull_strdup_n(data, strlen(data));
198
+ e->text = ev_string(e, p, data, strlen(data));
164
199
  e->text_len = strlen(data);
165
200
  }
166
201
  }
@@ -177,9 +212,9 @@ static void cb_start_prefix(void* ud, const char* prefix, const char* uri) {
177
212
  pull_event* e = queue_push(p);
178
213
  if (!e) return;
179
214
  e->type = LEPTRIS_PULL_START_PREFIX;
180
- e->name = pull_strdup_n(prefix ? prefix : "",
181
- prefix ? strlen(prefix) : 0);
182
- e->text = pull_strdup_n(uri ? uri : "", uri ? strlen(uri) : 0);
215
+ e->name = ev_string(e, p, prefix ? prefix : "",
216
+ prefix ? strlen(prefix) : 0);
217
+ e->text = ev_string(e, p, uri ? uri : "", uri ? strlen(uri) : 0);
183
218
  e->text_len = uri ? strlen(uri) : 0;
184
219
  }
185
220
 
@@ -188,8 +223,8 @@ static void cb_end_prefix(void* ud, const char* prefix) {
188
223
  pull_event* e = queue_push(p);
189
224
  if (!e) return;
190
225
  e->type = LEPTRIS_PULL_END_PREFIX;
191
- e->name = pull_strdup_n(prefix ? prefix : "",
192
- prefix ? strlen(prefix) : 0);
226
+ e->name = ev_string(e, p, prefix ? prefix : "",
227
+ prefix ? strlen(prefix) : 0);
193
228
  }
194
229
 
195
230
  static void cb_error(void* ud, const char* message, int line, int column) {
@@ -222,6 +222,36 @@ static int sxs_elem_push(LeptrisSAXParser* p, const char* name, size_t name_len)
222
222
  return 0;
223
223
  }
224
224
 
225
+ /* One-shot push: the name lives in the append-only scratch arena
226
+ * (parser lifetime) instead of the reusable frame slot, so
227
+ * handler-visible START/END names stay valid for borrowed consumers
228
+ * (pull.c ev_string) even after the stack slot serves a later
229
+ * sibling. */
230
+ static int sxs_elem_push_oneshot(LeptrisSAXParser* p, const char* name,
231
+ size_t name_len) {
232
+ if (p->elem_depth >= LEPTRIS_SAX_MAX_DEPTH) {
233
+ sxs_set_error(p, "Element nesting too deep");
234
+ return -1;
235
+ }
236
+ const char* nm = sxs_scratch_append(p, name, name_len);
237
+ if (!nm) {
238
+ sxs_set_error(p, "out of memory");
239
+ return -1;
240
+ }
241
+ SaxElementFrame* f = &p->elem_stack[p->elem_depth];
242
+ f->name = (char*)nm; /* frame only ever reads the name */
243
+ f->name_heap = NULL;
244
+ f->name_len = name_len;
245
+ f->attrs = f->attrs_inline;
246
+ f->attrs_inline[0] = NULL;
247
+ f->attrs_heap = NULL;
248
+ f->attr_count = 0;
249
+ f->attr_cap = SAX_ATTRS_INLINE;
250
+ f->self_closing = 0;
251
+ p->elem_depth++;
252
+ return 0;
253
+ }
254
+
225
255
  static void sxs_elem_pop(LeptrisSAXParser* p) {
226
256
  if (p->elem_depth == 0) return;
227
257
  p->elem_depth--;
@@ -591,7 +621,7 @@ static int sxs_step_elem_open_name(LeptrisSAXParser* p, int is_final) {
591
621
  }
592
622
  const char* nb = p->pos;
593
623
  while (p->pos < p->end && sxs_is_name_char(*p->pos)) p->pos++;
594
- if (sxs_elem_push(p, nb, (size_t)(p->pos - nb)) < 0) {
624
+ if (sxs_elem_push_oneshot(p, nb, (size_t)(p->pos - nb)) < 0) {
595
625
  return SAX_STEP_ERR;
596
626
  }
597
627
  p->column += (int)(p->pos - nb);
@@ -716,6 +746,32 @@ static int sxs_step_attr_list(LeptrisSAXParser* p, int is_final) {
716
746
  * ============================================================================ */
717
747
 
718
748
  static int sxs_step_attr_name(LeptrisSAXParser* p, int is_final) {
749
+ if (p->one_shot) {
750
+ /* Slice 1 (TODO.max-perf/2-3): single-feed input never
751
+ * splits the token — scan in place, materialize the name
752
+ * with ONE scratch copy; the carry path below costs a
753
+ * reserve+store call per name byte. */
754
+ if (p->pos >= p->end) {
755
+ return is_final ? (sxs_set_error(p, "Expected attribute name"), SAX_STEP_ERR)
756
+ : SAX_STEP_NEED_MORE;
757
+ }
758
+ if (!sxs_is_name_start(*p->pos)) {
759
+ sxs_set_error(p, "Expected attribute name");
760
+ return SAX_STEP_ERR;
761
+ }
762
+ p->pending_attr_line = p->line;
763
+ p->pending_attr_column = p->column;
764
+ const char* nb = p->pos;
765
+ while (p->pos < p->end && sxs_is_name_char(*p->pos)) {
766
+ sxs_pos_putc(p, *p->pos);
767
+ p->pos++;
768
+ }
769
+ const char* nm = sxs_scratch_append(p, nb, (size_t)(p->pos - nb));
770
+ if (!nm) { sxs_set_error(p, "out of memory"); return SAX_STEP_ERR; }
771
+ p->pending_attr_name = nm;
772
+ p->state = SAX_ST_ATTR_EQ;
773
+ return SAX_STEP_OK;
774
+ }
719
775
  /* Diagnostic point for recoverable errors: the name's start
720
776
  * (issue #647) — captured before any byte of the name is
721
777
  * consumed, including across chunk-boundary resumes (the first
@@ -821,6 +877,26 @@ static int sxs_step_attr_value_quote(LeptrisSAXParser* p, int is_final) {
821
877
 
822
878
  static int sxs_step_attr_value(LeptrisSAXParser* p, int is_final) {
823
879
  char quote = p->carry[0];
880
+ const char* raw;
881
+ size_t raw_len;
882
+ if (p->one_shot) {
883
+ /* Slice 1: scan the value span in place (same position
884
+ * accounting as the carry loop); the span below is
885
+ * materialized with one scratch copy instead of a
886
+ * reserve+store call per value byte. */
887
+ raw = p->pos;
888
+ while (p->pos < p->end && *p->pos != quote) {
889
+ sxs_pos_putc(p, *p->pos);
890
+ p->pos++;
891
+ }
892
+ if (p->pos >= p->end) {
893
+ return is_final ? (sxs_set_error(p, "Unterminated attribute value"), SAX_STEP_ERR)
894
+ : SAX_STEP_NEED_MORE;
895
+ }
896
+ raw_len = (size_t)(p->pos - raw);
897
+ sxs_pos_putc(p, *p->pos);
898
+ p->pos++;
899
+ } else {
824
900
  /* p->carry[1..] may already have value bytes if we resumed mid-value. */
825
901
  while (p->pos < p->end && *p->pos != quote) {
826
902
  if (sxs_carry_putc(p, *p->pos) < 0) {
@@ -838,14 +914,15 @@ static int sxs_step_attr_value(LeptrisSAXParser* p, int is_final) {
838
914
  /* p->pos points at closing quote. */
839
915
  sxs_pos_putc(p, *p->pos);
840
916
  p->pos++;
841
- /* Build value: skip carry[0] (quote), copy carry[1..].
917
+ raw = p->carry + 1;
918
+ raw_len = p->carry_len - 1;
919
+ }
920
+ /* Build value.
842
921
  *
843
922
  * XML 1.0 3.3.3: attribute values must be delivered with
844
923
  * entity/character references expanded. Values without '&'
845
924
  * (the common case) copy verbatim; values with '&' decode
846
925
  * through a temporary buffer first. */
847
- const char* raw = p->carry + 1;
848
- size_t raw_len = p->carry_len - 1;
849
926
  const char* value;
850
927
  if (raw_len && memchr(raw, '&', raw_len)) {
851
928
  LeptrisStringView sv = leptris_sv_from_ptr(raw, raw_len);
@@ -995,7 +1072,15 @@ static int sxs_step_elem_content(LeptrisSAXParser* p, int is_final) {
995
1072
 
996
1073
  static int sxs_step_text(LeptrisSAXParser* p, int is_final) {
997
1074
  const char* start = p->pos;
998
- while (p->pos < p->end && *p->pos != '<') p->pos++;
1075
+ /* memchr finds the span end at libc speeds; the loop form costs
1076
+ * a load+compare+branch per byte. Position accounting is
1077
+ * unaffected — this state never updates line/column per text
1078
+ * byte (tag states own the counters). */
1079
+ {
1080
+ const char* lt = (const char*)memchr(p->pos, '<',
1081
+ (size_t)(p->end - p->pos));
1082
+ p->pos = lt ? lt : p->end;
1083
+ }
999
1084
  size_t len = (size_t)(p->pos - start);
1000
1085
 
1001
1086
  if (p->carry_len == 0 && (len == 0 || !memchr(start, '&', len))) {
@@ -103,6 +103,42 @@ TEST(Digest, DropWsTextSkipsWhitespaceOnlyNodes) {
103
103
  digest_of("<r><a/> <b/></r>", LEPTRIS_DIGEST_DEFAULT));
104
104
  }
105
105
 
106
+ /* #1200: ATTR_ORDER keeps document order in the hash — reordered
107
+ * attributes digest DIFFERENT, same-order documents stay equal,
108
+ * and the flag composes with DROP_WS_TEXT. The dedup (first-wins
109
+ * on the resolved key) still applies. */
110
+ TEST(Digest, AttrOrderSensitiveVariant) {
111
+ EXPECT_NE(digest_of("<r a='1' b='2'/>", LEPTRIS_DIGEST_ATTR_ORDER),
112
+ digest_of("<r b='2' a='1'/>", LEPTRIS_DIGEST_ATTR_ORDER));
113
+ EXPECT_EQ(digest_of("<r a='1' b='2'/>", LEPTRIS_DIGEST_ATTR_ORDER),
114
+ digest_of("<r a='1' b='2'/>", LEPTRIS_DIGEST_ATTR_ORDER));
115
+ }
116
+
117
+ TEST(Digest, AttrOrderComposesWithDropWs) {
118
+ EXPECT_EQ(
119
+ digest_of("<r a='1' b='2'><a/><b/></r>",
120
+ (LeptrisDigestFlags)(LEPTRIS_DIGEST_ATTR_ORDER |
121
+ LEPTRIS_DIGEST_DROP_WS_TEXT)),
122
+ digest_of("<r a='1' b='2'><a/> <b/></r>",
123
+ (LeptrisDigestFlags)(LEPTRIS_DIGEST_ATTR_ORDER |
124
+ LEPTRIS_DIGEST_DROP_WS_TEXT)));
125
+ EXPECT_NE(
126
+ digest_of("<r a='1' b='2'><a/></r>",
127
+ (LeptrisDigestFlags)(LEPTRIS_DIGEST_ATTR_ORDER |
128
+ LEPTRIS_DIGEST_DROP_WS_TEXT)),
129
+ digest_of("<r b='2' a='1'><a/></r>",
130
+ (LeptrisDigestFlags)(LEPTRIS_DIGEST_ATTR_ORDER |
131
+ LEPTRIS_DIGEST_DROP_WS_TEXT)));
132
+ }
133
+
134
+ TEST(Digest, AttrOrderRecursesIntoChildren) {
135
+ /* The child's attribute order participates too: the Merkle
136
+ * combine feeds each child's digest (with the flag) upward. */
137
+ EXPECT_NE(
138
+ digest_of("<r><c x='1' y='2'/></r>", LEPTRIS_DIGEST_ATTR_ORDER),
139
+ digest_of("<r><c y='2' x='1'/></r>", LEPTRIS_DIGEST_ATTR_ORDER));
140
+ }
141
+
106
142
  /* Null contract: no node, no digest. */
107
143
  TEST(Digest, NullNodeIsZero) {
108
144
  EXPECT_EQ(leptris_node_digest(nullptr, LEPTRIS_DIGEST_DEFAULT), 0u);
@@ -32,6 +32,30 @@ TEST(DomBasics, EmptyDocumentRoundTrips) {
32
32
  leptris_document_free(doc);
33
33
  }
34
34
 
35
+ // #1197: the close-tag fast-path compare masked the wrong end of the
36
+ // 8-byte load on big-endian and failed every paired close tag. The
37
+ // unit corpus was self-closing-heavy and never caught it; sweep the
38
+ // compare across all mask lengths plus the wide-family report.
39
+ TEST(DomBasics, CloseTagCompareAcrossNameLengths) {
40
+ for (int len = 1; len <= 9; len++) {
41
+ std::string name(static_cast<size_t>(len), 'n');
42
+ std::string xml = "<" + name + "><" + name + "/></" + name + ">";
43
+ LeptrisStatus st = LEPTRIS_OK;
44
+ LeptrisDocument doc =
45
+ leptris_parse_string(xml.data(), xml.size(), &st);
46
+ ASSERT_NE(doc, nullptr)
47
+ << "close-tag name of length " << len << " failed to parse";
48
+ leptris_document_free(doc);
49
+ }
50
+ std::string xml = "<r>";
51
+ for (int i = 1; i <= 80; i++) xml += "<n" + std::to_string(i) + "/>";
52
+ xml += "</r>";
53
+ LeptrisDocument doc =
54
+ leptris_parse_string(xml.data(), xml.size(), NULL);
55
+ ASSERT_NE(doc, nullptr) << "80-child family failed to parse";
56
+ leptris_document_free(doc);
57
+ }
58
+
35
59
  TEST(DomBasics, DocumentCreateHasNoRoot) {
36
60
  LeptrisDocument doc = leptris_document_create();
37
61
  ASSERT_NE(doc, nullptr);
@@ -563,3 +563,46 @@ TEST(FreshDocumentContract, TruncatedLengthIsACleanParseError) {
563
563
  EXPECT_EQ(doc, nullptr);
564
564
  EXPECT_EQ(st, LEPTRIS_ERROR_PARSE);
565
565
  }
566
+
567
+ // ---- #1200 part 2: recover diagnostics on the DOM parse -------
568
+
569
+ TEST(ParseDiag, DuplicateAttributeKeepsFirstAndReports) {
570
+ /* libxml2/SAX-lane parity: recover-class diagnostic on the DOM
571
+ * parse instead of silence. First definition wins (the digest's
572
+ * first-wins semantics made official). */
573
+ const char xml[] =
574
+ "<body lang=\"en\" xml:lang=\"en\" xml:lang=\"fr\"><div/></body>";
575
+ LeptrisStatus st = LEPTRIS_OK;
576
+ LeptrisDocument doc = leptris_parse_string(xml, strlen(xml), NULL);
577
+ ASSERT_NE(doc, nullptr);
578
+
579
+ EXPECT_EQ(leptris_document_parse_diag_count(doc), 1u);
580
+ LeptrisDiagKind kind = (LeptrisDiagKind)-1;
581
+ char msg[192] = {0};
582
+ ASSERT_EQ(leptris_document_parse_diag(doc, 0, &kind, msg,
583
+ sizeof(msg)), 1);
584
+ EXPECT_EQ(kind, LEPTRIS_DIAG_RECOVER);
585
+ EXPECT_STREQ(msg, "Attribute xml:lang redefined");
586
+
587
+ /* First value survived; the duplicate never entered the list. */
588
+ LeptrisElement root = leptris_document_root(doc);
589
+ ASSERT_NE(root, nullptr);
590
+ EXPECT_EQ(leptris_element_attribute_count(root), 2u);
591
+ EXPECT_STREQ(leptris_element_attribute(root, "xml:lang"), "en");
592
+ leptris_document_free(doc);
593
+ }
594
+
595
+ TEST(ParseDiag, CleanDocumentHasNoDiags) {
596
+ const char xml[] = "<body lang=\"en\" xml:lang=\"en\"><div/></body>";
597
+ LeptrisDocument doc = leptris_parse_string(xml, strlen(xml), NULL);
598
+ ASSERT_NE(doc, nullptr);
599
+ EXPECT_EQ(leptris_document_parse_diag_count(doc), 0u);
600
+ LeptrisDiagKind kind;
601
+ char msg[64];
602
+ EXPECT_EQ(leptris_document_parse_diag(doc, 0, &kind, msg,
603
+ sizeof(msg)), 0);
604
+ EXPECT_EQ(leptris_document_parse_diag(NULL, 0, &kind, msg,
605
+ sizeof(msg)), 0);
606
+ EXPECT_EQ(leptris_document_parse_diag_count(NULL), 0u);
607
+ leptris_document_free(doc);
608
+ }
@@ -88,6 +88,38 @@ TEST(Pull, CommentsCdataPiAndEndDocument) {
88
88
  EXPECT_EQ(end_doc, 1);
89
89
  }
90
90
 
91
+ TEST(Pull, EntityTextAndAttrValuesExpandInBufferMode) {
92
+ /* Buffer-mode (one-shot) pulls borrow event strings from the
93
+ * input/scratch — entities must still arrive EXPANDED, for both
94
+ * text and attribute values (borrow-safety pin for
95
+ * TODO.max-perf/2-3 slice 1). */
96
+ const char* xml =
97
+ "<r tag='r&amp;d' code='&#65;'>a &amp; b &lt;c&gt;</r>";
98
+ LeptrisPullParser p = leptris_pull_new(xml, strlen(xml));
99
+ ASSERT_NE(p, nullptr);
100
+ const LeptrisPullEvent* ev = leptris_pull_next(p);
101
+ ASSERT_NE(ev, nullptr);
102
+ ASSERT_EQ(ev->type, LEPTRIS_PULL_START_ELEMENT);
103
+ EXPECT_EQ(leptris_pull_attr_count(p), 2u);
104
+ EXPECT_STREQ(leptris_pull_attr_name(p, 0), "tag");
105
+ EXPECT_STREQ(leptris_pull_attr_value(p, 0), "r&d");
106
+ EXPECT_STREQ(leptris_pull_attr_name(p, 1), "code");
107
+ EXPECT_STREQ(leptris_pull_attr_value(p, 1), "A");
108
+ ev = leptris_pull_next(p);
109
+ ASSERT_NE(ev, nullptr);
110
+ ASSERT_EQ(ev->type, LEPTRIS_PULL_TEXT);
111
+ ASSERT_NE(ev->text, nullptr);
112
+ EXPECT_STREQ(ev->text, "a & b <c>");
113
+ ev = leptris_pull_next(p);
114
+ ASSERT_NE(ev, nullptr);
115
+ ASSERT_EQ(ev->type, LEPTRIS_PULL_END_ELEMENT);
116
+ EXPECT_STREQ(ev->name, "r");
117
+ ev = leptris_pull_next(p);
118
+ ASSERT_NE(ev, nullptr);
119
+ EXPECT_EQ(ev->type, LEPTRIS_PULL_END_DOCUMENT);
120
+ leptris_pull_free(p);
121
+ }
122
+
91
123
  TEST(Pull, MalformedInputYieldsErrorEvent) {
92
124
  LeptrisPullParser p = leptris_pull_new("<a><b></a>", 10);
93
125
  ASSERT_NE(p, nullptr);
@@ -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.204",
4
+ "version": "1.9.207",
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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: leptris
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.204.0
4
+ version: 1.9.207.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.