1use crate::aaml::parsing::{strip_comment, unwrap_quotes};
2use crate::error::AamlError;
3use crate::pipeline::Pipeline;
4use memmap2::{Mmap, MmapOptions};
5use rustc_hash::FxHasher;
6use std::fs;
7use std::fs::File;
8use std::hash::Hasher;
9use std::path::{Path, PathBuf};
10use tinyvec::TinyVec;
11
12const INVALID_INDEX: u32 = u32::MAX;
15const CURRENT_AOT_VERSION: u32 = 1;
16type FrontendErrors = TinyVec<[Option<AamlError>; 4]>;
17
18#[inline]
19fn push_frontend_error(errors: &mut FrontendErrors, err: AamlError) {
20 errors.push(Some(err));
21}
22
23#[inline]
24fn finalize_frontend_errors(errors: FrontendErrors) -> Option<Vec<AamlError>> {
25 if errors.is_empty() {
26 return None;
27 }
28
29 Some(errors.into_iter().flatten().collect())
30}
31
32#[repr(u32)]
33#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize, Debug, Clone, Copy)]
34pub enum NodeKind {
35 Root = 0,
36 Assignment = 1,
37}
38
39#[repr(C)]
41#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize, Debug, Clone, Copy, Default)]
42pub struct Span {
43 pub start: u32,
44 pub len: u32,
45}
46
47#[repr(C)]
49#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize, Debug, Clone, Copy)]
50pub struct FlatNode {
51 pub kind: u32,
52 pub parent: u32,
53 pub first_child: u32,
54 pub next_sibling: u32,
55}
56
57#[repr(C)]
58#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize, Debug, Clone, Copy)]
59pub struct SymbolEntry {
60 pub hash: u64,
61 pub node_index: u32,
62}
63
64#[repr(C)]
66#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize, Debug, Clone)]
67pub struct CookedAotBlob {
68 pub version: u32,
69 pub string_blob: Vec<u8>,
70 pub nodes: Vec<FlatNode>,
71 pub key_spans: Vec<Span>,
72 pub value_spans: Vec<Span>,
73 pub hash_table: Vec<SymbolEntry>,
74}
75
76impl CookedAotBlob {
77 fn empty() -> Self {
78 Self {
79 version: CURRENT_AOT_VERSION,
80 string_blob: Vec::new(),
81 nodes: vec![FlatNode {
82 kind: NodeKind::Root as u32,
83 parent: INVALID_INDEX,
84 first_child: INVALID_INDEX,
85 next_sibling: INVALID_INDEX,
86 }],
87 key_spans: vec![Span::default()],
88 value_spans: vec![Span::default()],
89 hash_table: Vec::new(),
90 }
91 }
92}
93
94struct SoaBuilder {
95 string_blob: Vec<u8>,
96 nodes: Vec<FlatNode>,
97 key_spans: Vec<Span>,
98 value_spans: Vec<Span>,
99 symbols: Vec<SymbolEntry>,
100 last_root_child: Option<u32>,
101 #[cfg(feature = "release")]
102 queue_type_checks: Vec<u32>,
103 #[cfg(feature = "release")]
104 queue_ref_resolve: Vec<u32>,
105}
106
107impl SoaBuilder {
108 fn with_capacity(n: usize) -> Self {
109 let base = CookedAotBlob::empty();
110 Self {
111 string_blob: Vec::with_capacity(n.saturating_mul(32)),
112 nodes: {
113 let mut v = base.nodes;
114 v.reserve(n);
115 v
116 },
117 key_spans: {
118 let mut v = base.key_spans;
119 v.reserve(n);
120 v
121 },
122 value_spans: {
123 let mut v = base.value_spans;
124 v.reserve(n);
125 v
126 },
127 symbols: Vec::with_capacity(n),
128 last_root_child: None,
129 #[cfg(feature = "release")]
130 queue_type_checks: Vec::with_capacity(n),
131 #[cfg(feature = "release")]
132 queue_ref_resolve: Vec::new(),
133 }
134 }
135
136 fn push_string(&mut self, value: &str) -> Result<Span, AamlError> {
137 let start = u32::try_from(self.string_blob.len()).map_err(|_| AamlError::InvalidValue {
138 details: "string blob exceeds u32 address space".to_string(),
139 expected: "input <= 4GB".to_string(),
140 diagnostics: None,
141 })?;
142 let len = u32::try_from(value.len()).map_err(|_| AamlError::InvalidValue {
143 details: "string length exceeds u32".to_string(),
144 expected: "single token <= 4GB".to_string(),
145 diagnostics: None,
146 })?;
147 self.string_blob.extend_from_slice(value.as_bytes());
148 Ok(Span { start, len })
149 }
150
151 fn push_assignment(&mut self, key: &str, value: &str) -> Result<u32, AamlError> {
152 let key_span = self.push_string(key)?;
153 let value_span = self.push_string(value)?;
154 let idx = u32::try_from(self.nodes.len()).map_err(|_| AamlError::InvalidValue {
155 details: "node count exceeds u32".to_string(),
156 expected: "<= 4,294,967,295 nodes".to_string(),
157 diagnostics: None,
158 })?;
159
160 self.nodes.push(FlatNode {
161 kind: NodeKind::Assignment as u32,
162 parent: 0,
163 first_child: INVALID_INDEX,
164 next_sibling: INVALID_INDEX,
165 });
166 self.key_spans.push(key_span);
167 self.value_spans.push(value_span);
168
169 match self.last_root_child {
170 Some(last) => {
171 self.nodes[last as usize].next_sibling = idx;
172 }
173 None => {
174 self.nodes[0].first_child = idx;
175 }
176 }
177 self.last_root_child = Some(idx);
178
179 #[cfg(feature = "release")]
180 {
181 self.queue_type_checks.push(idx);
182 if value.starts_with('$') {
183 self.queue_ref_resolve.push(idx);
184 }
185 }
186
187 Ok(idx)
188 }
189 #[cfg(feature = "release")]
190 fn validate_release_type_checks(&self) -> Result<(), AamlError> {
191 for &node_index in &self.queue_type_checks {
192 let i = node_index as usize;
193 if self.key_spans[i].len == 0 || self.value_spans[i].len == 0 {
194 return Err(AamlError::InvalidValue {
195 details: format!("empty key/value at node {node_index}"),
196 expected: "non-empty key and value".to_string(),
197 diagnostics: None,
198 });
199 }
200 }
201
202 Ok(())
203 }
204
205 #[cfg(feature = "release")]
206 fn validate_release_references(&self) -> Result<(), AamlError> {
207 for &node_index in &self.queue_ref_resolve {
208 let value = self.span_bytes(self.value_spans[node_index as usize])?;
209 if !value.starts_with(b"$") {
210 continue;
211 }
212
213 let reference =
214 std::str::from_utf8(&value[1..]).map_err(|_| AamlError::InvalidValue {
215 details: "reference value is not utf8".to_string(),
216 expected: "references like $some.key".to_string(),
217 diagnostics: None,
218 })?;
219
220 if self.find_symbol(reference).is_none() {
221 return Err(AamlError::NotFound {
222 key: reference.to_string(),
223 context: "release reference validation".to_string(),
224 diagnostics: None,
225 });
226 }
227 }
228
229 Ok(())
230 }
231
232 #[cfg(feature = "release")]
233 fn run_release_validation(&self) -> Result<(), AamlError> {
234 self.validate_release_type_checks()?;
235 self.validate_release_references()
236 }
237
238 #[cfg(feature = "release")]
239 fn span_bytes(&self, span: Span) -> Result<&[u8], AamlError> {
240 let start = span.start as usize;
241 let end = start + span.len as usize;
242 self.string_blob
243 .get(start..end)
244 .ok_or_else(|| AamlError::InvalidValue {
245 details: "span points outside string blob".to_string(),
246 expected: "valid span range".to_string(),
247 diagnostics: None,
248 })
249 }
250 #[cfg(feature = "release")]
251 fn lower_bound_symbol_hash(&self, target_hash: u64) -> usize {
252 let mut left = 0usize;
253 let mut right = self.symbols.len();
254
255 while left < right {
256 let mid = left + ((right - left) / 2);
257 let entry = self.symbols[mid];
258 if entry.hash < target_hash {
259 left = mid + 1;
260 } else {
261 right = mid;
262 }
263 }
264
265 left
266 }
267
268 #[cfg(feature = "release")]
269 fn find_symbol(&self, key: &str) -> Option<u32> {
270 let target_hash = hash64(key.as_bytes());
271 let mut i = self.lower_bound_symbol_hash(target_hash);
272 while i < self.symbols.len() {
273 let entry = self.symbols[i];
274 if entry.hash != target_hash {
275 break;
276 }
277 let span = self.key_spans[entry.node_index as usize];
278 if self.span_bytes(span).ok()? == key.as_bytes() {
279 return Some(entry.node_index);
280 }
281 i += 1;
282 }
283
284 None
285 }
286
287 fn finish(self) -> CookedAotBlob {
288 let capacity = (self.symbols.len() * 2).next_power_of_two();
289 let capacity = std::cmp::max(capacity, 16);
290
291 let mut hash_table = vec![
292 SymbolEntry {
293 hash: 0,
294 node_index: INVALID_INDEX
295 };
296 capacity
297 ];
298
299 let mask = (capacity - 1) as u64;
300
301 for entry in self.symbols {
302 #[allow(clippy::cast_possible_truncation)]
303 let mut idx = (entry.hash & mask) as usize;
304 loop {
305 if hash_table[idx].node_index == INVALID_INDEX {
306 hash_table[idx] = entry;
307 break;
308 }
309 #[allow(clippy::cast_possible_truncation)]
310 let m_idx = mask as usize;
311 idx = (idx + 1) & m_idx;
312 }
313 }
314
315 CookedAotBlob {
316 version: CURRENT_AOT_VERSION,
317 string_blob: self.string_blob,
318 nodes: self.nodes,
319 key_spans: self.key_spans,
320 value_spans: self.value_spans,
321 hash_table,
322 }
323 }
324}
325
326#[inline]
327fn hash64(bytes: &[u8]) -> u64 {
328 let mut hasher = FxHasher::default();
329 hasher.write(bytes);
330 let h = hasher.finish();
331 h ^ h.rotate_right(32)
332}
333
334fn split_assignment(line: &str) -> Option<(&str, &str)> {
335 let pos = line.find('=')?;
336 let key = line[..pos].trim();
337 let raw_value = line[pos + 1..].trim();
338 if key.is_empty() || raw_value.is_empty() {
339 return None;
340 }
341
342 let value = if raw_value.starts_with('{') || raw_value.starts_with('[') {
343 raw_value
344 } else {
345 unwrap_quotes(raw_value)
346 };
347 Some((key, value))
348}
349
350fn should_use_pipeline_compile(text: &str) -> bool {
351 if text
352 .lines()
353 .any(|line| strip_comment(line).trim_start().starts_with('@'))
354 {
355 return true;
356 }
357 has_multiline_block(text)
358}
359
360fn process_bracket_depth(line: &str) -> i32 {
361 let mut depth: i32 = 0;
362 let mut in_string = false;
363 let mut string_char = '"';
364 for ch in line.chars() {
365 if in_string {
366 if ch == string_char {
367 in_string = false;
368 }
369 continue;
370 }
371 match ch {
372 '"' | '\'' => {
373 in_string = true;
374 string_char = ch;
375 }
376 '{' | '[' => depth += 1,
377 '}' | ']' if depth > 0 => {
378 depth -= 1;
379 }
380 _ => {}
381 }
382 }
383 depth
384}
385
386fn has_multiline_block(text: &str) -> bool {
387 for line in text.lines() {
388 let line = strip_comment(line);
389 if process_bracket_depth(line) > 0 {
390 return true;
391 }
392 }
393 false
394}
395
396fn parse_assignment_line(
397 builder: &mut SoaBuilder,
398 errors: &mut FrontendErrors,
399 line_idx: usize,
400 raw_line: &str,
401 line: &str,
402 #[cfg(feature = "dev")] dev_symbols: &mut rustc_hash::FxHashMap<u64, TinyVec<[u32; 4]>>,
403 #[cfg(feature = "dev")] dev_root_children: &mut tinyvec::ArrayVec<[u32; 16]>,
404) {
405 let Some((key, value)) = split_assignment(line) else {
406 push_frontend_error(
407 errors,
408 AamlError::ParseError {
409 line: line_idx + 1,
410 content: raw_line.to_string(),
411 details: "expected 'key = value' assignment".to_string(),
412 diagnostics: None,
413 },
414 );
415 return;
416 };
417
418 match builder.push_assignment(key, value) {
419 Ok(node_index) => {
420 let hash = hash64(key.as_bytes());
421 #[cfg(feature = "dev")]
422 {
423 dev_symbols.entry(hash).or_default().push(node_index);
424 if dev_root_children.len() < dev_root_children.capacity() {
425 dev_root_children.push(node_index);
426 }
427 }
428 #[cfg(not(feature = "dev"))]
429 {
430 builder.symbols.push(SymbolEntry { hash, node_index });
431 }
432 }
433 Err(err) => push_frontend_error(errors, err),
434 }
435}
436
437fn compile_text(text: &str, source_dir: Option<&Path>) -> Result<CookedAotBlob, Vec<AamlError>> {
438 if should_use_pipeline_compile(text) {
439 return compile_via_pipeline(text, source_dir);
440 }
441
442 let estimated = text.lines().count();
443 let mut builder = SoaBuilder::with_capacity(estimated);
444 let mut errors: FrontendErrors = TinyVec::new();
445
446 #[cfg(feature = "dev")]
447 let mut dev_symbols: rustc_hash::FxHashMap<u64, TinyVec<[u32; 4]>> =
448 rustc_hash::FxHashMap::default();
449 #[cfg(feature = "dev")]
450 let mut dev_root_children: tinyvec::ArrayVec<[u32; 16]> = tinyvec::ArrayVec::new();
451
452 for (line_idx, raw_line) in text.lines().enumerate() {
453 let line = strip_comment(raw_line).trim();
454 if line.is_empty() {
455 continue;
456 }
457
458 if line.starts_with('@') {
459 continue;
460 }
461
462 parse_assignment_line(
463 &mut builder,
464 &mut errors,
465 line_idx,
466 raw_line,
467 line,
468 #[cfg(feature = "dev")]
469 &mut dev_symbols,
470 #[cfg(feature = "dev")]
471 &mut dev_root_children,
472 );
473 }
474
475 if let Some(errors) = finalize_frontend_errors(errors) {
476 return Err(errors);
477 }
478
479 #[cfg(feature = "dev")]
480 {
481 for (hash, indices) in dev_symbols {
482 for node_index in indices {
483 builder.symbols.push(SymbolEntry { hash, node_index });
484 }
485 }
486 let _ = dev_root_children;
487 }
488
489 builder.symbols.sort_unstable_by_key(|entry| entry.hash);
490
491 #[cfg(feature = "release")]
492 if let Err(err) = builder.run_release_validation() {
493 return Err(vec![err]);
494 }
495
496 Ok(builder.finish())
497}
498
499fn compile_via_pipeline(
500 text: &str,
501 source_dir: Option<&Path>,
502) -> Result<CookedAotBlob, Vec<AamlError>> {
503 let pipeline = Pipeline::new();
504 let output = pipeline.process_with_source_dir(text, source_dir)?;
505
506 let estimated = output.map.len();
507 let mut builder = SoaBuilder::with_capacity(estimated);
508 let mut errors: FrontendErrors = TinyVec::new();
509
510 for (key, value) in output.map {
511 let hash = hash64(key.as_bytes());
512 match builder.push_assignment(&key, &value) {
513 Ok(node_index) => {
514 builder.symbols.push(SymbolEntry { hash, node_index });
515 }
516 Err(err) => push_frontend_error(&mut errors, err),
517 }
518 }
519
520 if let Some(errors) = finalize_frontend_errors(errors) {
521 return Err(errors);
522 }
523
524 builder.symbols.sort_unstable_by_key(|entry| entry.hash);
525
526 #[cfg(feature = "release")]
527 if let Err(err) = builder.run_release_validation() {
528 return Err(vec![err]);
529 }
530
531 Ok(builder.finish())
532}
533
534fn io_err(details: impl Into<String>) -> AamlError {
535 AamlError::IoError {
536 details: details.into(),
537 diagnostics: None,
538 }
539}
540
541fn cache_path(source: &Path) -> PathBuf {
542 source.with_extension("aam.bin")
543}
544
545fn cache_needs_rebuild(source: &Path, cache: &Path) -> Result<bool, AamlError> {
546 let cache_meta = match fs::metadata(cache) {
547 Ok(meta) => meta,
548 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(true),
549 Err(e) => {
550 return Err(io_err(format!(
551 "failed to stat cache '{}': {e}",
552 cache.display()
553 )));
554 }
555 };
556
557 let source_meta = fs::metadata(source)
558 .map_err(|e| io_err(format!("failed to stat source '{}': {e}", source.display())))?;
559
560 let cache_mtime = cache_meta
561 .modified()
562 .map_err(|e| io_err(format!("failed to read cache mtime: {e}")))?;
563 let source_mtime = source_meta
564 .modified()
565 .map_err(|e| io_err(format!("failed to read source mtime: {e}")))?;
566
567 Ok(cache_mtime < source_mtime)
568}
569
570pub struct AamCompiler;
571
572impl AamCompiler {
573 pub fn cook(source_path: impl AsRef<Path>) -> Result<PathBuf, Vec<AamlError>> {
580 let source_path = source_path.as_ref();
581 let cache = cache_path(source_path);
582
583 let content = fs::read_to_string(source_path).map_err(|e| {
584 vec![io_err(format!(
585 "failed to read source asset '{}': {e}",
586 source_path.display()
587 ))]
588 })?;
589
590 let cooked = compile_text(&content, source_path.parent())?;
591 let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&cooked).map_err(|e| {
592 vec![AamlError::DirectiveError {
593 directive: "cook".to_string(),
594 message: format!("failed to archive cooked output: {e}"),
595 diagnostics: None,
596 }]
597 })?;
598
599 let tmp_cache = cache.with_extension("aam.bin.tmp");
600
601 let mut temp_file = File::create(&tmp_cache)
602 .map_err(|e| vec![io_err(format!("failed to create temp file: {e}"))])?;
603
604 std::io::Write::write_all(&mut temp_file, bytes.as_slice())
605 .map_err(|e| vec![io_err(format!("failed to write cooked cache: {e}"))])?;
606
607 temp_file
608 .sync_all()
609 .map_err(|e| vec![io_err(format!("failed to sync cooked cache: {e}"))])?;
610
611 drop(temp_file);
612
613 fs::rename(&tmp_cache, &cache)
614 .map_err(|e| vec![io_err(format!("failed to rename cooked cache: {e}"))])?;
615
616 Ok(cache)
617 }
618}
619
620#[derive(Debug)]
622pub struct MappedAam {
623 mmap: Mmap,
624}
625
626impl MappedAam {
627 #[must_use]
628 pub fn archived(&self) -> &rkyv::Archived<CookedAotBlob> {
629 unsafe { rkyv::access_unchecked::<rkyv::Archived<CookedAotBlob>>(&self.mmap) }
631 }
632
633 const fn span_bounds(span: &rkyv::Archived<Span>) -> (usize, usize) {
634 let start = span.start.to_native() as usize;
635 let end = start + span.len.to_native() as usize;
636 (start, end)
637 }
638
639 #[must_use]
640 pub fn get(&self, key: &str) -> Option<&str> {
641 let archived = self.archived();
642 let table = archived.hash_table.as_slice();
643 if table.is_empty() {
644 return None;
645 }
646
647 let key_spans = archived.key_spans.as_slice();
648 let value_spans = archived.value_spans.as_slice();
649 let blob = archived.string_blob.as_slice();
650
651 let key_bytes = key.as_bytes();
652 let hash = hash64(key_bytes);
653
654 let mask = table.len() - 1;
655 #[allow(clippy::cast_possible_truncation)]
656 let mut idx = (hash as usize) & mask;
657
658 loop {
659 let entry = unsafe { table.get_unchecked(idx) };
660 let node_idx = entry.node_index.to_native() as usize;
661
662 if node_idx == INVALID_INDEX as usize {
663 return None;
664 }
665
666 if entry.hash.to_native() == hash {
667 let span = unsafe { key_spans.get_unchecked(node_idx) };
668 let start = span.start.to_native() as usize;
669 let end = start + span.len.to_native() as usize;
670
671 if blob.get(start..end) == Some(key_bytes) {
672 let val_span = unsafe { value_spans.get_unchecked(node_idx) };
673 let v_start = val_span.start.to_native() as usize;
674 let v_end = v_start + val_span.len.to_native() as usize;
675
676 return Some(unsafe {
677 std::str::from_utf8_unchecked(blob.get_unchecked(v_start..v_end))
678 });
679 }
680 }
681 idx = (idx + 1) & mask;
682 }
683 }
684 #[must_use]
685 pub fn len(&self) -> usize {
686 self.archived().hash_table.len()
687 }
688
689 #[must_use]
690 pub fn is_empty(&self) -> bool {
691 self.len() == 0
692 }
693
694 pub fn iter_pairs(&self) -> impl Iterator<Item = (&str, &str)> {
695 let archived = self.archived();
696 let blob = archived.string_blob.as_slice();
697 let key_spans = archived.key_spans.as_slice();
698 let value_spans = archived.value_spans.as_slice();
699
700 (1..archived.nodes.len()).filter_map(move |idx| {
701 let key = key_spans.get(idx)?;
702 let value = value_spans.get(idx)?;
703 let (k_start, k_end) = Self::span_bounds(key);
704 let (v_start, v_end) = Self::span_bounds(value);
705 let k = std::str::from_utf8(blob.get(k_start..k_end)?).ok()?;
706 let v = std::str::from_utf8(blob.get(v_start..v_end)?).ok()?;
707 Some((k, v))
708 })
709 }
710}
711
712pub struct AamLoader;
713
714impl AamLoader {
715 pub fn load_fast(source_path: impl AsRef<Path>) -> Result<MappedAam, Vec<AamlError>> {
724 let source_path = source_path.as_ref();
725 let cache = cache_path(source_path);
726
727 let needs_rebuild = cache_needs_rebuild(source_path, &cache).map_err(|e| vec![e])?;
728 if needs_rebuild {
729 AamCompiler::cook(source_path)?;
730 }
731
732 let file = File::open(&cache).map_err(|e| {
733 vec![io_err(format!(
734 "failed to open cooked cache '{}': {e}",
735 cache.display()
736 ))]
737 })?;
738
739 let mmap = unsafe {
740 MmapOptions::new().map(&file).map_err(|e| {
741 vec![io_err(format!(
742 "failed to mmap cooked cache '{}': {e}",
743 cache.display()
744 ))]
745 })?
746 };
747
748 #[cfg(not(feature = "unsafe_fast_path"))]
749 rkyv::access::<rkyv::Archived<CookedAotBlob>, rkyv::rancor::Error>(&mmap).map_err(|e| {
750 vec![AamlError::DirectiveError {
751 directive: "load_fast".to_string(),
752 message: format!("invalid cooked archive '{}': {e}", cache.display()),
753 diagnostics: None,
754 }]
755 })?;
756
757 let archived_version = {
758 let blob = unsafe { rkyv::access_unchecked::<rkyv::Archived<CookedAotBlob>>(&mmap) };
760 blob.version.to_native()
761 };
762
763 if archived_version != CURRENT_AOT_VERSION {
764 let _ = fs::remove_file(&cache);
765 AamCompiler::cook(source_path)?;
766 return Self::load_fast(source_path);
767 }
768
769 Ok(MappedAam { mmap })
770 }
771}