1use std::collections::HashMap;
4use std::fs::File;
5use std::io::Read;
6use std::path::Path;
7
8#[cfg(not(target_arch = "wasm32"))]
9use memmap2::Mmap;
10
11pub const LEX_MAGIC: [u8; 8] = *b"Q42LEX\0\0";
12const MAGIC: &[u8; 8] = &LEX_MAGIC;
13pub const LEX_HEADER_SIZE: usize = 32;
14const HEADER_SIZE: usize = LEX_HEADER_SIZE;
15const INDEX_ENTRY_SIZE: usize = 16;
16pub const LEX_VERSION_PAGED: u64 = 2;
17pub const PAGED_DIRECTORY_HEADER_SIZE: usize = 8;
18pub const PAGED_DIRECTORY_ENTRY_SIZE: usize = 32;
19pub const PAGED_PAGE_HEADER_SIZE: usize = 16;
20pub const DEFAULT_LEX_PAGE_ENTRIES: usize = 4_096;
24
25const LEX_TAG_STRING: u8 = 0x01; const LEX_TAG_EMBEDDED: u8 = 0x02; const LEX_TAG_WEBIZEN: u8 = 0x03; pub fn serialize_string_lexicon(entries: &HashMap<u64, String>) -> Result<Vec<u8>, LexError> {
46 let mut sorted: Vec<(&u64, &String)> = entries.iter().collect();
47 sorted.sort_unstable_by_key(|(h, _)| **h);
48 let entry_count = sorted.len() as u64;
49 let strings_offset = HEADER_SIZE as u64 + entry_count * INDEX_ENTRY_SIZE as u64;
50
51 let mut index: Vec<u8> = Vec::with_capacity(sorted.len() * INDEX_ENTRY_SIZE);
52 let mut blob: Vec<u8> = Vec::new();
53 for (hash, text) in &sorted {
54 let str_off = blob.len() as u64;
55 if text.len() > u16::MAX as usize {
56 return Err(LexError::TermTooLong);
57 }
58 blob.push(LEX_TAG_STRING);
59 blob.extend_from_slice(&(text.len() as u16).to_le_bytes());
60 blob.extend_from_slice(text.as_bytes());
61 index.extend_from_slice(&hash.to_le_bytes());
62 index.extend_from_slice(&str_off.to_le_bytes());
63 }
64
65 let mut out = Vec::with_capacity(HEADER_SIZE + index.len() + blob.len());
66 out.extend_from_slice(MAGIC);
67 out.extend_from_slice(&entry_count.to_le_bytes());
68 out.extend_from_slice(&strings_offset.to_le_bytes());
69 out.extend_from_slice(&1u64.to_le_bytes()); out.extend_from_slice(&index);
71 out.extend_from_slice(&blob);
72 Ok(out)
73}
74
75pub fn serialize_paged_string_lexicon(
84 entries: &HashMap<u64, String>,
85 page_entries: usize,
86) -> Result<Vec<u8>, LexError> {
87 if page_entries == 0 {
88 return Err(LexError::BadIndex);
89 }
90 let mut sorted: Vec<(&u64, &String)> = entries.iter().collect();
91 sorted.sort_unstable_by_key(|(hash, _)| **hash);
92 let page_count = (sorted.len() + page_entries - 1) / page_entries;
93 let directory_len = PAGED_DIRECTORY_HEADER_SIZE
94 .checked_add(
95 page_count
96 .checked_mul(PAGED_DIRECTORY_ENTRY_SIZE)
97 .ok_or(LexError::Truncated)?,
98 )
99 .ok_or(LexError::Truncated)?;
100 let mut out = Vec::with_capacity(HEADER_SIZE + directory_len + entries.len() * 24);
101 out.extend_from_slice(MAGIC);
102 out.extend_from_slice(&(sorted.len() as u64).to_le_bytes());
103 out.extend_from_slice(&(HEADER_SIZE as u64).to_le_bytes());
104 out.extend_from_slice(&LEX_VERSION_PAGED.to_le_bytes());
105 out.extend_from_slice(&(page_count as u64).to_le_bytes());
106 out.resize(HEADER_SIZE + directory_len, 0);
107
108 for (page_index, chunk) in sorted.chunks(page_entries).enumerate() {
109 let page_offset = out.len() as u64;
110 let page_start = out.len();
111 out.extend_from_slice(&(chunk.len() as u32).to_le_bytes());
112 out.extend_from_slice(&0u32.to_le_bytes());
113 let blob_offset = PAGED_PAGE_HEADER_SIZE
114 .checked_add(
115 chunk
116 .len()
117 .checked_mul(INDEX_ENTRY_SIZE)
118 .ok_or(LexError::Truncated)?,
119 )
120 .ok_or(LexError::Truncated)?;
121 out.extend_from_slice(&(blob_offset as u64).to_le_bytes());
122 let index_start = out.len();
123 out.resize(index_start + chunk.len() * INDEX_ENTRY_SIZE, 0);
124 for (entry_index, (hash, text)) in chunk.iter().enumerate() {
125 if text.len() > u16::MAX as usize {
126 return Err(LexError::TermTooLong);
127 }
128 let relative = (out.len() - page_start - blob_offset) as u64;
129 out.push(LEX_TAG_STRING);
130 out.extend_from_slice(&(text.len() as u16).to_le_bytes());
131 out.extend_from_slice(text.as_bytes());
132 let index_offset = index_start + entry_index * INDEX_ENTRY_SIZE;
133 out[index_offset..index_offset + 8].copy_from_slice(&hash.to_le_bytes());
134 out[index_offset + 8..index_offset + 16].copy_from_slice(&relative.to_le_bytes());
135 }
136 let page_length = (out.len() - page_start) as u64;
137 let directory =
138 HEADER_SIZE + PAGED_DIRECTORY_HEADER_SIZE + page_index * PAGED_DIRECTORY_ENTRY_SIZE;
139 out[directory..directory + 8].copy_from_slice(&chunk[0].0.to_le_bytes());
140 out[directory + 8..directory + 16].copy_from_slice(&page_offset.to_le_bytes());
141 out[directory + 16..directory + 24].copy_from_slice(&page_length.to_le_bytes());
142 out[directory + 24..directory + 28].copy_from_slice(&(chunk.len() as u32).to_le_bytes());
143 }
144 Ok(out)
145}
146
147pub enum LexiconKey<'a> {
150 Str(&'a str),
152 Triple(&'a [u64; 3]),
154}
155
156#[derive(Debug, Clone)]
158pub enum LexiconEntry {
159 String(String),
161 EmbeddedTriple([u64; 3]),
163 Webizen(String),
165}
166
167#[derive(Debug, Default)]
169pub struct Q42Lexicon {
170 pub entries: HashMap<u64, String>,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum LexError {
175 InvalidMagic,
176 Truncated,
177 BadStringOffset,
178 BadIndex,
179 BadEntry,
180 InvalidUtf8,
181 TermTooLong,
182}
183
184#[derive(Debug, Clone, Copy)]
186pub struct Q42LexMmap<'a> {
187 data: &'a [u8],
188 entry_count: usize,
189 strings_offset: usize,
190 format_version: u64,
191 page_count: usize,
192}
193
194impl<'a> Q42LexMmap<'a> {
195 pub fn from_bytes(data: &'a [u8]) -> Result<Self, LexError> {
197 if data.len() < HEADER_SIZE {
198 return Err(LexError::Truncated);
199 }
200 if data[0..8] != *MAGIC {
201 return Err(LexError::InvalidMagic);
202 }
203 let format_version = u64::from_le_bytes(data[24..32].try_into().unwrap());
204 let entry_count = usize::try_from(u64::from_le_bytes(data[8..16].try_into().unwrap()))
205 .map_err(|_| LexError::Truncated)?;
206 let strings_offset = usize::try_from(u64::from_le_bytes(data[16..24].try_into().unwrap()))
207 .map_err(|_| LexError::Truncated)?;
208 let index_end = HEADER_SIZE
209 .checked_add(
210 entry_count
211 .checked_mul(INDEX_ENTRY_SIZE)
212 .ok_or(LexError::Truncated)?,
213 )
214 .ok_or(LexError::Truncated)?;
215 let (page_count, flat) = match format_version {
216 1 => (0, true),
217 LEX_VERSION_PAGED => {
218 let page_count_end = strings_offset
219 .checked_add(PAGED_DIRECTORY_HEADER_SIZE)
220 .ok_or(LexError::Truncated)?;
221 if page_count_end > data.len() {
222 return Err(LexError::Truncated);
223 }
224 let page_count = usize::try_from(u64::from_le_bytes(
225 data[strings_offset..page_count_end].try_into().unwrap(),
226 ))
227 .map_err(|_| LexError::Truncated)?;
228 let directory_end = page_count_end
229 .checked_add(
230 page_count
231 .checked_mul(PAGED_DIRECTORY_ENTRY_SIZE)
232 .ok_or(LexError::Truncated)?,
233 )
234 .ok_or(LexError::Truncated)?;
235 if strings_offset != HEADER_SIZE || directory_end > data.len() {
236 return Err(LexError::Truncated);
237 }
238 (page_count, false)
239 }
240 _ => return Err(LexError::BadIndex),
241 };
242 if flat && (index_end > data.len() || strings_offset != index_end) {
243 return Err(LexError::Truncated);
244 }
245 let view = Self {
246 data,
247 entry_count,
248 strings_offset,
249 format_version,
250 page_count,
251 };
252 view.validate_entries()?;
253 Ok(view)
254 }
255
256 #[inline]
257 pub fn entry_count(&self) -> usize {
258 self.entry_count
259 }
260
261 pub fn hash_at(&self, i: usize) -> Option<u64> {
265 if i >= self.entry_count {
266 return None;
267 }
268 if self.format_version == LEX_VERSION_PAGED {
269 let mut base = 0usize;
270 for page in 0..self.page_count {
271 let (_, offset, _, count) = self.page_directory_entry(page)?;
272 if i < base + count {
273 let entry = offset + PAGED_PAGE_HEADER_SIZE + (i - base) * INDEX_ENTRY_SIZE;
274 return Some(u64::from_le_bytes(
275 self.data.get(entry..entry + 8)?.try_into().ok()?,
276 ));
277 }
278 base += count;
279 }
280 return None;
281 }
282 let off = HEADER_SIZE + i * INDEX_ENTRY_SIZE;
283 Some(u64::from_le_bytes(
284 self.data.get(off..off + 8)?.try_into().ok()?,
285 ))
286 }
287
288 pub fn lookup_hash(&self, hash: u64) -> Option<&'a str> {
290 if self.format_version == LEX_VERSION_PAGED {
291 return self.lookup_hash_paged(hash);
292 }
293 let mut lo = 0usize;
294 let mut hi = self.entry_count;
295 while lo < hi {
296 let mid = lo + (hi - lo) / 2;
297 let off = HEADER_SIZE + mid * INDEX_ENTRY_SIZE;
298 let entry_hash = u64::from_le_bytes(self.data[off..off + 8].try_into().ok()?);
299 match entry_hash.cmp(&hash) {
300 std::cmp::Ordering::Less => lo = mid + 1,
301 std::cmp::Ordering::Greater => hi = mid,
302 std::cmp::Ordering::Equal => {
303 let str_off =
304 u64::from_le_bytes(self.data[off + 8..off + 16].try_into().ok()?) as usize;
305 return Self::read_string_at(self.data, self.strings_offset, str_off);
306 }
307 }
308 }
309 None
310 }
311
312 pub fn string_at(&self, i: usize) -> Option<&'a str> {
316 if i >= self.entry_count {
317 return None;
318 }
319 if self.format_version == LEX_VERSION_PAGED {
320 let mut base = 0usize;
321 for page in 0..self.page_count {
322 let (_, offset, length, count) = self.page_directory_entry(page)?;
323 if i < base + count {
324 return self.page_string_at(offset, length, i - base);
325 }
326 base += count;
327 }
328 return None;
329 }
330 let off = HEADER_SIZE + i * INDEX_ENTRY_SIZE;
331 let str_off = u64::from_le_bytes(self.data[off + 8..off + 16].try_into().ok()?) as usize;
332 Self::read_string_at(self.data, self.strings_offset, str_off)
333 }
334
335 fn validate_entries(&self) -> Result<(), LexError> {
339 if self.format_version == LEX_VERSION_PAGED {
340 return self.validate_paged_entries();
341 }
342 let mut previous_hash = None;
343 for i in 0..self.entry_count {
344 let off = HEADER_SIZE + i * INDEX_ENTRY_SIZE;
345 let hash = u64::from_le_bytes(
346 self.data[off..off + 8]
347 .try_into()
348 .map_err(|_| LexError::BadIndex)?,
349 );
350 if let Some(previous) = previous_hash {
351 if hash <= previous {
352 return Err(LexError::BadIndex);
353 }
354 }
355 previous_hash = Some(hash);
356 let rel_off = usize::try_from(u64::from_le_bytes(
357 self.data[off + 8..off + 16]
358 .try_into()
359 .map_err(|_| LexError::BadIndex)?,
360 ))
361 .map_err(|_| LexError::BadStringOffset)?;
362 let start = self
363 .strings_offset
364 .checked_add(rel_off)
365 .ok_or(LexError::BadStringOffset)?;
366 let tag = *self.data.get(start).ok_or(LexError::BadStringOffset)?;
367 match tag {
368 LEX_TAG_STRING | LEX_TAG_WEBIZEN => {
369 let length_end = start.checked_add(3).ok_or(LexError::BadEntry)?;
370 let length_bytes = self
371 .data
372 .get(start + 1..length_end)
373 .ok_or(LexError::BadEntry)?;
374 let length = u16::from_le_bytes(
375 length_bytes.try_into().map_err(|_| LexError::BadEntry)?,
376 ) as usize;
377 let text_start = length_end;
378 let text_end = text_start.checked_add(length).ok_or(LexError::BadEntry)?;
379 let text = self
380 .data
381 .get(text_start..text_end)
382 .ok_or(LexError::BadEntry)?;
383 std::str::from_utf8(text).map_err(|_| LexError::InvalidUtf8)?;
384 }
385 LEX_TAG_EMBEDDED => {
386 let end = start.checked_add(25).ok_or(LexError::BadEntry)?;
387 self.data.get(start..end).ok_or(LexError::BadEntry)?;
388 }
389 _ => return Err(LexError::BadEntry),
390 }
391 }
392 Ok(())
393 }
394
395 fn page_directory_entry(&self, index: usize) -> Option<(u64, usize, usize, usize)> {
396 if index >= self.page_count {
397 return None;
398 }
399 let offset =
400 self.strings_offset + PAGED_DIRECTORY_HEADER_SIZE + index * PAGED_DIRECTORY_ENTRY_SIZE;
401 let first_hash = u64::from_le_bytes(self.data.get(offset..offset + 8)?.try_into().ok()?);
402 let page_offset = usize::try_from(u64::from_le_bytes(
403 self.data.get(offset + 8..offset + 16)?.try_into().ok()?,
404 ))
405 .ok()?;
406 let page_length = usize::try_from(u64::from_le_bytes(
407 self.data.get(offset + 16..offset + 24)?.try_into().ok()?,
408 ))
409 .ok()?;
410 let count =
411 u32::from_le_bytes(self.data.get(offset + 24..offset + 28)?.try_into().ok()?) as usize;
412 Some((first_hash, page_offset, page_length, count))
413 }
414
415 fn lookup_hash_paged(&self, hash: u64) -> Option<&'a str> {
416 let mut lo = 0usize;
417 let mut hi = self.page_count;
418 while lo < hi {
419 let mid = lo + (hi - lo) / 2;
420 if self.page_directory_entry(mid)?.0 <= hash {
421 lo = mid + 1;
422 } else {
423 hi = mid;
424 }
425 }
426 let page = lo.checked_sub(1)?;
427 let (_, offset, length, count) = self.page_directory_entry(page)?;
428 let mut left = 0usize;
429 let mut right = count;
430 while left < right {
431 let mid = left + (right - left) / 2;
432 let entry = offset + PAGED_PAGE_HEADER_SIZE + mid * INDEX_ENTRY_SIZE;
433 let entry_hash = u64::from_le_bytes(self.data.get(entry..entry + 8)?.try_into().ok()?);
434 match entry_hash.cmp(&hash) {
435 std::cmp::Ordering::Less => left = mid + 1,
436 std::cmp::Ordering::Greater => right = mid,
437 std::cmp::Ordering::Equal => return self.page_string_at(offset, length, mid),
438 }
439 }
440 None
441 }
442
443 fn page_string_at(
444 &self,
445 page_offset: usize,
446 page_length: usize,
447 index: usize,
448 ) -> Option<&'a str> {
449 let page_end = page_offset.checked_add(page_length)?;
450 let count = u32::from_le_bytes(
451 self.data
452 .get(page_offset..page_offset + 4)?
453 .try_into()
454 .ok()?,
455 ) as usize;
456 let blob_offset = usize::try_from(u64::from_le_bytes(
457 self.data
458 .get(page_offset + 8..page_offset + 16)?
459 .try_into()
460 .ok()?,
461 ))
462 .ok()?;
463 if index >= count {
464 return None;
465 }
466 let entry = page_offset + PAGED_PAGE_HEADER_SIZE + index * INDEX_ENTRY_SIZE;
467 let relative = usize::try_from(u64::from_le_bytes(
468 self.data.get(entry + 8..entry + 16)?.try_into().ok()?,
469 ))
470 .ok()?;
471 let start = page_offset
472 .checked_add(blob_offset)?
473 .checked_add(relative)?;
474 if start + 3 > page_end || self.data[start] != LEX_TAG_STRING {
475 return None;
476 }
477 let length = u16::from_le_bytes(self.data[start + 1..start + 3].try_into().ok()?) as usize;
478 let end = start.checked_add(3)?.checked_add(length)?;
479 if end > page_end {
480 return None;
481 }
482 std::str::from_utf8(&self.data[start + 3..end]).ok()
483 }
484
485 fn validate_paged_entries(&self) -> Result<(), LexError> {
486 let mut total = 0usize;
487 let mut previous = None;
488 for page in 0..self.page_count {
489 let (first, offset, length, count) =
490 self.page_directory_entry(page).ok_or(LexError::BadIndex)?;
491 if count == 0
492 || offset < HEADER_SIZE
493 || offset
494 .checked_add(length)
495 .is_none_or(|end| end > self.data.len())
496 {
497 return Err(LexError::BadIndex);
498 }
499 if previous.is_some_and(|value| first <= value) {
500 return Err(LexError::BadIndex);
501 }
502 let actual_count =
503 u32::from_le_bytes(self.data[offset..offset + 4].try_into().unwrap()) as usize;
504 let blob = usize::try_from(u64::from_le_bytes(
505 self.data[offset + 8..offset + 16].try_into().unwrap(),
506 ))
507 .map_err(|_| LexError::BadIndex)?;
508 if actual_count != count
509 || blob != PAGED_PAGE_HEADER_SIZE + count * INDEX_ENTRY_SIZE
510 || blob > length
511 {
512 return Err(LexError::BadIndex);
513 }
514 for item in 0..count {
515 let entry = offset + PAGED_PAGE_HEADER_SIZE + item * INDEX_ENTRY_SIZE;
516 let hash = u64::from_le_bytes(self.data[entry..entry + 8].try_into().unwrap());
517 if item == 0 && hash != first {
518 return Err(LexError::BadIndex);
519 }
520 if previous.is_some_and(|value| hash <= value) {
521 return Err(LexError::BadIndex);
522 }
523 previous = Some(hash);
524 if self.page_string_at(offset, length, item).is_none() {
525 return Err(LexError::BadEntry);
526 }
527 }
528 total = total.checked_add(count).ok_or(LexError::BadIndex)?;
529 }
530 if total != self.entry_count {
531 return Err(LexError::BadIndex);
532 }
533 Ok(())
534 }
535
536 fn read_string_at(data: &[u8], blob_base: usize, rel_off: usize) -> Option<&str> {
537 let start = blob_base.checked_add(rel_off)?;
538 if start.checked_add(3)? > data.len() {
539 return None;
540 }
541 if data[start] != LEX_TAG_STRING {
543 return None;
544 }
545 let len = u16::from_le_bytes(data[start + 1..start + 3].try_into().ok()?) as usize;
546 let text_start = start + 3;
547 let text_end = text_start.checked_add(len)?;
548 if text_end > data.len() {
549 return None;
550 }
551 std::str::from_utf8(&data[text_start..text_end]).ok()
552 }
553
554 pub fn lookup_embedded_triple(&self, hash: u64) -> Option<[u64; 3]> {
559 if self.format_version == LEX_VERSION_PAGED {
560 return None;
561 }
562 let mut lo = 0usize;
563 let mut hi = self.entry_count;
564 while lo < hi {
565 let mid = lo + (hi - lo) / 2;
566 let off = HEADER_SIZE + mid * INDEX_ENTRY_SIZE;
567 let entry_hash = u64::from_le_bytes(self.data[off..off + 8].try_into().ok()?);
568 match entry_hash.cmp(&hash) {
569 std::cmp::Ordering::Less => lo = mid + 1,
570 std::cmp::Ordering::Greater => hi = mid,
571 std::cmp::Ordering::Equal => {
572 let str_off =
573 u64::from_le_bytes(self.data[off + 8..off + 16].try_into().ok()?) as usize;
574 return Self::read_embedded_triple_at(self.data, self.strings_offset, str_off)
575 .copied();
576 }
577 }
578 }
579 None
580 }
581
582 pub fn lookup_webizen_identity(&self, hash: u64) -> Option<&'a str> {
584 if self.format_version == LEX_VERSION_PAGED {
585 return None;
586 }
587 let mut lo = 0usize;
588 let mut hi = self.entry_count;
589 while lo < hi {
590 let mid = lo + (hi - lo) / 2;
591 let off = HEADER_SIZE + mid * INDEX_ENTRY_SIZE;
592 let entry_hash = u64::from_le_bytes(self.data[off..off + 8].try_into().ok()?);
593 match entry_hash.cmp(&hash) {
594 std::cmp::Ordering::Less => lo = mid + 1,
595 std::cmp::Ordering::Greater => hi = mid,
596 std::cmp::Ordering::Equal => {
597 let str_off =
598 u64::from_le_bytes(self.data[off + 8..off + 16].try_into().ok()?) as usize;
599 return Self::read_webizen_at(self.data, self.strings_offset, str_off);
600 }
601 }
602 }
603 None
604 }
605
606 fn read_webizen_at(data: &[u8], blob_base: usize, rel_off: usize) -> Option<&str> {
607 let start = blob_base.checked_add(rel_off)?;
608 if start.checked_add(3)? > data.len() || data[start] != LEX_TAG_WEBIZEN {
609 return None;
610 }
611 let len = u16::from_le_bytes(data[start + 1..start + 3].try_into().ok()?) as usize;
612 let text_start = start + 3;
613 let text_end = text_start.checked_add(len)?;
614 if text_end > data.len() {
615 return None;
616 }
617 std::str::from_utf8(&data[text_start..text_end]).ok()
618 }
619
620 fn read_embedded_triple_at(data: &[u8], blob_base: usize, rel_off: usize) -> Option<&[u64; 3]> {
624 let start = blob_base.saturating_add(rel_off);
625 if start + 1 + 24 > data.len() {
626 return None;
627 }
628 if data[start] != LEX_TAG_EMBEDDED {
630 return None;
631 }
632 let triple_start = start + 1;
634 let bytes = &data[triple_start..triple_start + 24];
635 let ptr = bytes.as_ptr() as *const [u64; 3];
636 unsafe { Some(&*ptr) }
637 }
638}
639
640#[cfg(not(target_arch = "wasm32"))]
642pub struct Q42LexFile {
643 mmap: Mmap,
644}
645
646#[cfg(not(target_arch = "wasm32"))]
647impl Q42LexFile {
648 pub fn open(path: &Path) -> std::io::Result<Self> {
649 let file = File::open(path)?;
650 let mmap = unsafe { Mmap::map(&file)? };
651 Q42LexMmap::from_bytes(&mmap)
652 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{e:?}")))?;
653 Ok(Self { mmap })
654 }
655
656 #[inline]
657 pub fn view(&self) -> Q42LexMmap<'_> {
658 Q42LexMmap::from_bytes(&self.mmap).expect("validated at open")
659 }
660}
661
662impl Q42Lexicon {
663 #[cfg(not(target_arch = "wasm32"))]
665 pub fn load_for_q42(q42_path: &Path) -> std::io::Result<Self> {
666 if crate::q42_volume::is_unified_volume(q42_path)? {
667 let vol = crate::q42_volume::Q42Volume::open(q42_path)?;
668 if vol.volume_manifest()?.is_some() {
669 let set = crate::q42_volume::Q42VolumeSet::open_root(q42_path)?;
670 let mut lexicon = Self::load_from_lex_bytes(set.root().lex_bytes())?;
671 for shard in set.lexicon_segments() {
672 let entries = Self::load_from_lex_bytes(shard.lex_bytes())?;
673 lexicon.entries.extend(entries.entries);
674 }
675 return Ok(lexicon);
676 }
677 return Self::load_from_lex_bytes(vol.lex_bytes());
678 }
679 let sidecar = q42_path.with_extension("q42.lex");
680 if sidecar.is_file() {
681 return Self::load(&sidecar);
682 }
683 Err(std::io::Error::new(
684 std::io::ErrorKind::NotFound,
685 format!(
686 "no lexicon in {} or sidecar {}",
687 q42_path.display(),
688 sidecar.display()
689 ),
690 ))
691 }
692
693 pub fn load_from_lex_bytes(data: &[u8]) -> std::io::Result<Self> {
695 let view = Q42LexMmap::from_bytes(data)
696 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{e:?}")))?;
697 let mut entries = HashMap::with_capacity(view.entry_count());
698 for i in 0..view.entry_count() {
699 let Some(hash) = view.hash_at(i) else {
700 break;
701 };
702 if let Some(text) = view.lookup_hash(hash) {
703 entries.insert(hash, text.to_string());
704 }
705 }
706 Ok(Self { entries })
707 }
708
709 pub fn load(path: &Path) -> std::io::Result<Self> {
710 let mut file = File::open(path)?;
711 let mut header = [0u8; 32];
712 file.read_exact(&mut header)?;
713 if header[0..8] != *MAGIC {
714 return Err(std::io::Error::new(
715 std::io::ErrorKind::InvalidData,
716 "invalid Q42LEX magic",
717 ));
718 }
719 let entry_count = u64::from_le_bytes(header[8..16].try_into().unwrap()) as usize;
720 let strings_offset = u64::from_le_bytes(header[16..24].try_into().unwrap()) as usize;
721
722 let mut index_buf = vec![0u8; entry_count * 16];
723 file.read_exact(&mut index_buf)?;
724
725 let mut blob = Vec::new();
726 file.seek(std::io::SeekFrom::Start(strings_offset as u64))?;
727 file.read_to_end(&mut blob)?;
728
729 let mut entries = HashMap::with_capacity(entry_count);
730 for i in 0..entry_count {
731 let off = i * 16;
732 let hash = u64::from_le_bytes(index_buf[off..off + 8].try_into().unwrap());
733 let str_off =
734 u64::from_le_bytes(index_buf[off + 8..off + 16].try_into().unwrap()) as usize;
735 if str_off + 2 > blob.len() {
736 continue;
737 }
738 let len = u16::from_le_bytes(blob[str_off..str_off + 2].try_into().unwrap()) as usize;
739 let start = str_off + 2;
740 let end = start.saturating_add(len).min(blob.len());
741 if let Ok(text) = std::str::from_utf8(&blob[start..end]) {
742 entries.insert(hash, text.to_string());
743 }
744 }
745
746 Ok(Self { entries })
747 }
748
749 pub fn lookup(&self, hash: u64) -> Option<&str> {
750 self.entries.get(&hash).map(|s| s.as_str())
751 }
752
753 pub fn find_literal(&self, needle: &str) -> Option<u64> {
755 let needle = needle.to_lowercase();
756 self.entries
757 .iter()
758 .find(|(_, v)| v.to_lowercase() == needle)
759 .map(|(h, _)| *h)
760 }
761
762 pub fn search_contains(&self, sub: &str, limit: usize) -> Vec<(u64, String)> {
764 let sub = sub.to_lowercase();
765 let mut out = Vec::new();
766 for (h, v) in &self.entries {
767 if v.to_lowercase().contains(&sub) {
768 out.push((*h, v.clone()));
769 if out.len() >= limit {
770 break;
771 }
772 }
773 }
774 out
775 }
776}
777
778use std::io::Seek;
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783 use std::io::Write;
784 use tempfile::NamedTempFile;
785
786 fn write_lex_bytes(entries: &[(u64, &str)]) -> Vec<u8> {
787 let mut sorted: Vec<(u64, &str)> = entries.to_vec();
788 sorted.sort_unstable_by_key(|(h, _)| *h);
789 let entry_count = sorted.len() as u64;
790 let strings_offset = 32 + entry_count * 16;
791 let mut blob = Vec::new();
792 let mut index = Vec::new();
793 for (hash, text) in &sorted {
794 let str_off = blob.len() as u64;
795 blob.push(LEX_TAG_STRING);
797 let b = text.as_bytes();
798 let len = b.len().min(65535) as u16;
799 blob.extend_from_slice(&len.to_le_bytes());
800 blob.extend_from_slice(&b[..len as usize]);
801 index.extend_from_slice(&hash.to_le_bytes());
802 index.extend_from_slice(&str_off.to_le_bytes());
803 }
804 let mut out = Vec::new();
805 out.extend_from_slice(MAGIC);
806 out.extend_from_slice(&entry_count.to_le_bytes());
807 out.extend_from_slice(&strings_offset.to_le_bytes());
808 out.extend_from_slice(&1u64.to_le_bytes());
809 out.extend_from_slice(&index);
810 out.extend_from_slice(&blob);
811 out
812 }
813
814 #[test]
815 fn mmap_lex_binary_search() {
816 let h1 = crate::q_hash("Patient");
817 let h2 = crate::q_hash("fever");
818 let bytes = write_lex_bytes(&[(h1, "Patient"), (h2, "fever")]);
819 let lex = Q42LexMmap::from_bytes(&bytes).unwrap();
820 assert_eq!(lex.lookup_hash(h1), Some("Patient"));
821 assert_eq!(lex.lookup_hash(h2), Some("fever"));
822 assert_eq!(lex.lookup_hash(0xDEAD), None);
823 }
824
825 #[test]
830 fn serialize_lexicon_round_trips_unicode() {
831 let samples = [
832 "carefully", "välinpitämättömästi", "อย่างสะเพร่า", "不注意に", "بلا مبالاة", "невнимательно", "a definition — with em-dash & 😀", ];
840 let mut map = HashMap::new();
841 for s in samples {
842 map.insert(crate::q_hash(s), s.to_string());
843 }
844 let bytes = serialize_string_lexicon(&map).unwrap();
845 let lex = Q42LexMmap::from_bytes(&bytes).unwrap();
846 assert_eq!(lex.entry_count(), samples.len());
847 for s in samples {
848 assert_eq!(
849 lex.lookup_hash(crate::q_hash(s)),
850 Some(s),
851 "multilingual lexeme must round-trip byte-identical: {s:?}"
852 );
853 }
854 }
855
856 #[test]
859 fn serialize_lexicon_rejects_overlong_term() {
860 let long: String = "あ".repeat(22_000);
863 let h = crate::q_hash(&long);
864 let mut map = HashMap::new();
865 map.insert(h, long);
866 assert_eq!(serialize_string_lexicon(&map), Err(LexError::TermTooLong));
867 }
868
869 #[test]
870 fn paged_lexicon_binary_search_crosses_page_boundaries() {
871 let mut map = HashMap::new();
872 for value in 0u64..9 {
873 map.insert(value * 10 + 3, format!("urn:q42:paged:{value}"));
874 }
875 let bytes = serialize_paged_string_lexicon(&map, 2).unwrap();
876 assert_eq!(
877 u64::from_le_bytes(bytes[24..32].try_into().unwrap()),
878 LEX_VERSION_PAGED
879 );
880 let view = Q42LexMmap::from_bytes(&bytes).unwrap();
881 assert_eq!(view.entry_count(), 9);
882 assert_eq!(view.lookup_hash(3), Some("urn:q42:paged:0"));
883 assert_eq!(view.lookup_hash(83), Some("urn:q42:paged:8"));
884 assert_eq!(view.string_at(4), Some("urn:q42:paged:4"));
885 assert_eq!(view.lookup_hash(4), None);
886 let cold = Q42Lexicon::load_from_lex_bytes(&bytes).unwrap();
887 assert_eq!(cold.lookup(43), Some("urn:q42:paged:4"));
888 }
889
890 #[test]
891 fn mmap_lex_file_roundtrip() {
892 let h = crate::q_hash("Entity");
893 let bytes = write_lex_bytes(&[(h, "Entity")]);
894 let mut tmp = NamedTempFile::new().unwrap();
895 tmp.write_all(&bytes).unwrap();
896 let file = Q42LexFile::open(tmp.path()).unwrap();
897 assert_eq!(file.view().lookup_hash(h), Some("Entity"));
898 }
899}