1use crate::{
5 parser::Language,
6 semantic_index::{
7 extract_semantic_file, grammar_version, grammar_version_by_name, language_name,
8 },
9};
10use objects::{
11 error::HeddleError,
12 object::{
13 ContentHash, SemanticEntryKind, SemanticFileFacts, SemanticFileNode, SemanticIndexRoot,
14 SemanticTreeEntry, SemanticTreeNode, Tree, TreeEntryTarget,
15 },
16 store::{ObjectSource, ObjectStore},
17};
18use std::collections::{BTreeMap, HashMap};
19
20const MAX_SEMANTIC_TREE_DEPTH: usize = 1024;
21
22const SEMANTIC_FILE_BUDGET_BYTES: usize = 1 << 20;
26
27type PendingSemanticBlobs = Vec<(ContentHash, Vec<u8>)>;
28pub type DeferredSemanticRoot = (SemanticIndexRoot, ContentHash, PendingSemanticBlobs);
29
30#[derive(Debug, thiserror::Error)]
33pub enum SemanticAssemblyError {
34 #[error("semantic analysis cancelled")]
35 Cancelled,
36 #[error("semantic analysis deadline exceeded")]
37 Deadline,
38 #[error("semantic analysis source work budget exceeded")]
39 SourceLimit,
40 #[error("semantic analysis output budget exceeded")]
41 OutputLimit,
42 #[error("malformed semantic object: {0}")]
43 Malformed(String),
44 #[error(transparent)]
45 Storage(#[from] HeddleError),
46}
47
48pub type SemanticAssemblyResult<T> = std::result::Result<T, SemanticAssemblyError>;
49
50impl From<SemanticAssemblyError> for HeddleError {
51 fn from(error: SemanticAssemblyError) -> Self {
52 match error {
53 SemanticAssemblyError::Storage(error) => error,
54 other => HeddleError::InvalidObject(other.to_string()),
55 }
56 }
57}
58
59#[derive(Clone, Copy)]
62struct BuiltEntry {
63 kind: SemanticEntryKind,
64 node: ContentHash,
65 semantic_digest: ContentHash,
66}
67
68pub struct SemanticIndexBuilder<'store, S: ObjectSource> {
77 store: &'store S,
78 budget: Option<crate::parser::ParseBudget>,
79 work_entries: usize,
80 work_bytes: usize,
81 output_limit: Option<usize>,
82 pending_bytes: usize,
83 source_blobs: Option<&'store HashMap<ContentHash, &'store [u8]>>,
84 source_trees: Option<&'store HashMap<ContentHash, &'store Tree>>,
85 extractor_version: u32,
86 file_memo: HashMap<(ContentHash, Language), BuiltEntry>,
91 grammars: BTreeMap<String, String>,
94 pending: Vec<(ContentHash, Vec<u8>)>,
97 pub parse_count: usize,
99}
100
101impl<'store, S: ObjectSource> SemanticIndexBuilder<'store, S> {
102 pub fn new(store: &'store S, extractor_version: u32) -> Self {
103 Self {
104 store,
105 budget: None,
106 work_entries: 0,
107 work_bytes: 0,
108 output_limit: None,
109 pending_bytes: 0,
110 source_blobs: None,
111 source_trees: None,
112 extractor_version,
113 file_memo: HashMap::new(),
114 grammars: BTreeMap::new(),
115 pending: Vec::new(),
116 parse_count: 0,
117 }
118 }
119
120 pub fn with_budget(mut self, budget: crate::parser::ParseBudget) -> Self {
122 self.budget = Some(budget);
123 self.output_limit.get_or_insert(15 * 1024 * 1024);
124 self
125 }
126 pub fn with_output_limit(mut self, bytes: usize) -> Self {
130 self.output_limit = Some(bytes);
131 self
132 }
133
134 fn check_work(&self) -> SemanticAssemblyResult<()> {
135 if let Some(budget) = &self.budget {
136 if budget.cancelled.load(std::sync::atomic::Ordering::Acquire) {
137 return Err(SemanticAssemblyError::Cancelled);
138 }
139 if std::time::Instant::now() >= budget.deadline {
140 return Err(SemanticAssemblyError::Deadline);
141 }
142 }
143 if self.budget.is_some() && (self.work_entries > 4096 || self.work_bytes > 32 * 1024 * 1024)
144 {
145 return Err(SemanticAssemblyError::SourceLimit);
146 }
147 Ok(())
148 }
149
150 fn interruption_error(&self) -> SemanticAssemblyError {
151 if self
152 .budget
153 .as_ref()
154 .is_some_and(|budget| budget.cancelled.load(std::sync::atomic::Ordering::Acquire))
155 {
156 SemanticAssemblyError::Cancelled
157 } else {
158 SemanticAssemblyError::Deadline
159 }
160 }
161
162 pub fn with_source_objects(
163 store: &'store S,
164 extractor_version: u32,
165 source_blobs: &'store HashMap<ContentHash, &'store [u8]>,
166 source_trees: &'store HashMap<ContentHash, &'store Tree>,
167 ) -> Self {
168 Self {
169 source_blobs: Some(source_blobs),
170 source_trees: Some(source_trees),
171 ..Self::new(store, extractor_version)
172 }
173 }
174
175 pub fn build_root_deferred(
179 &mut self,
180 tree: &Tree,
181 parent: Option<&ParentIndex>,
182 ) -> SemanticAssemblyResult<DeferredSemanticRoot> {
183 let parent = parent.filter(|p| self.parent_is_reusable(p));
187 if let Some(parent) = parent {
188 self.grammars = parent.root.grammars.clone();
189 }
190 let parent_ctx = parent.map(|p| (&p.source_tree, &p.semantic_tree));
191 let (node_hash, digest) = self.build_tree(tree, parent_ctx, 0)?;
192 let root = SemanticIndexRoot::new(
193 self.extractor_version,
194 std::mem::take(&mut self.grammars),
195 node_hash,
196 digest,
197 );
198 let root_hash = self.put_node(&root)?;
199 Ok((root, root_hash, std::mem::take(&mut self.pending)))
200 }
201
202 fn parent_is_reusable(&self, parent: &ParentIndex) -> bool {
205 parent.root.extractor_version == self.extractor_version
206 && parent
207 .root
208 .grammars
209 .iter()
210 .all(|(name, version)| grammar_version_by_name(name) == Some(version.as_str()))
211 }
212
213 fn build_tree(
214 &mut self,
215 tree: &Tree,
216 parent: Option<(&Tree, &SemanticTreeNode)>,
217 depth: usize,
218 ) -> SemanticAssemblyResult<(ContentHash, ContentHash)> {
219 if depth > MAX_SEMANTIC_TREE_DEPTH {
220 return Err(SemanticAssemblyError::Malformed(format!(
221 "semantic index tree exceeds max depth {MAX_SEMANTIC_TREE_DEPTH}"
222 )));
223 }
224 self.work_entries = self.work_entries.saturating_add(tree.len());
225 self.check_work()?;
226 let mut entries = Vec::with_capacity(tree.len());
227 for entry in tree.entries() {
228 self.check_work()?;
229 let name = entry.name();
230 let built = match entry.target() {
231 TreeEntryTarget::Tree { hash } => self.build_dir(name, *hash, parent, depth)?,
232 TreeEntryTarget::Blob { hash, .. } => self.build_file(name, *hash, parent)?,
233 TreeEntryTarget::Symlink { hash } => BuiltEntry {
234 kind: SemanticEntryKind::Opaque,
235 node: *hash,
236 semantic_digest: *hash,
237 },
238 TreeEntryTarget::Gitlink { .. } | TreeEntryTarget::Spoollink { .. } => {
241 let digest = opaque_edge_digest(entry.target());
242 BuiltEntry {
243 kind: SemanticEntryKind::Opaque,
244 node: digest,
245 semantic_digest: digest,
246 }
247 }
248 };
249 entries.push(SemanticTreeEntry {
250 name: name.to_string(),
251 kind: built.kind,
252 node: built.node,
253 semantic_digest: built.semantic_digest,
254 });
255 }
256 let (node, digest) = SemanticTreeNode::new(entries);
257 let node_hash = self.put_node(&node)?;
258 Ok((node_hash, digest))
259 }
260
261 fn build_dir(
262 &mut self,
263 name: &str,
264 source_hash: ContentHash,
265 parent: Option<(&Tree, &SemanticTreeNode)>,
266 depth: usize,
267 ) -> SemanticAssemblyResult<BuiltEntry> {
268 if let Some((parent_source, parent_sem)) = parent
272 && let Some(parent_entry) = parent_source.get(name)
273 && parent_entry.tree_hash() == Some(source_hash)
274 && let Some(sem_entry) = parent_sem.get(name)
275 && sem_entry.kind == SemanticEntryKind::Dir
276 {
277 return Ok(BuiltEntry {
278 kind: SemanticEntryKind::Dir,
279 node: sem_entry.node,
280 semantic_digest: sem_entry.semantic_digest,
281 });
282 }
283
284 let source_tree = match self
285 .source_trees
286 .and_then(|trees| trees.get(&source_hash).copied())
287 {
288 Some(tree) => tree.clone(),
289 None => self
290 .store
291 .get_tree(&source_hash)?
292 .ok_or_else(|| HeddleError::NotFound(format!("tree {source_hash}")))?,
293 };
294
295 let child_parent = self.child_parent_ctx(name, parent)?;
297 let child_parent_ref = child_parent.as_ref().map(|(t, n)| (t, n));
298 let (node, digest) = self.build_tree(&source_tree, child_parent_ref, depth + 1)?;
299 Ok(BuiltEntry {
300 kind: SemanticEntryKind::Dir,
301 node,
302 semantic_digest: digest,
303 })
304 }
305
306 fn child_parent_ctx(
309 &self,
310 name: &str,
311 parent: Option<(&Tree, &SemanticTreeNode)>,
312 ) -> SemanticAssemblyResult<Option<(Tree, SemanticTreeNode)>> {
313 let Some((parent_source, parent_sem)) = parent else {
314 return Ok(None);
315 };
316 let Some(source_entry) = parent_source.get(name) else {
317 return Ok(None);
318 };
319 let Some(source_hash) = source_entry.tree_hash() else {
320 return Ok(None);
321 };
322 let Some(sem_entry) = parent_sem.get(name) else {
323 return Ok(None);
324 };
325 if sem_entry.kind != SemanticEntryKind::Dir {
326 return Ok(None);
327 }
328 let Some(source_tree) = self.store.get_tree(&source_hash)? else {
329 return Ok(None);
330 };
331 let Some(blob) = self.store.get_blob(&sem_entry.node)? else {
332 return Ok(None);
333 };
334 match SemanticTreeNode::decode(blob.content()) {
335 Ok(sem_tree) => Ok(Some((source_tree, sem_tree))),
336 Err(_) => Ok(None),
337 }
338 }
339
340 fn build_file(
341 &mut self,
342 name: &str,
343 source_hash: ContentHash,
344 parent: Option<(&Tree, &SemanticTreeNode)>,
345 ) -> SemanticAssemblyResult<BuiltEntry> {
346 let language = Language::from_path(std::path::Path::new(name));
350 let memo_key = (source_hash, language);
351 if let Some(built) = self.file_memo.get(&memo_key) {
352 return Ok(*built);
353 }
354
355 if let Some((parent_source, parent_sem)) = parent
358 && let Some(parent_entry) = parent_source.get(name)
359 && parent_entry.blob_hash() == Some(source_hash)
360 && let Some(sem_entry) = parent_sem.get(name)
361 {
362 let built = BuiltEntry {
363 kind: sem_entry.kind,
364 node: sem_entry.node,
365 semantic_digest: sem_entry.semantic_digest,
366 };
367 self.file_memo.insert(memo_key, built);
368 return Ok(built);
369 }
370
371 let built = self.parse_file(language, source_hash)?;
372 self.file_memo.insert(memo_key, built);
373 Ok(built)
374 }
375
376 fn parse_file(
377 &mut self,
378 language: Language,
379 source_hash: ContentHash,
380 ) -> SemanticAssemblyResult<BuiltEntry> {
381 let opaque = BuiltEntry {
382 kind: SemanticEntryKind::Opaque,
383 node: source_hash,
384 semantic_digest: source_hash,
385 };
386
387 if language.parser_handle().is_none() {
388 return Ok(opaque);
389 }
390 let blob = match self
391 .source_blobs
392 .and_then(|blobs| blobs.get(&source_hash).copied())
393 {
394 Some(bytes) => Some(objects::object::Blob::from(bytes.to_vec())),
395 None => self.store.get_blob(&source_hash)?,
396 };
397 let Some(blob) = blob else {
398 return Ok(opaque);
399 };
400 self.work_bytes = self.work_bytes.saturating_add(blob.size());
401 self.check_work()?;
402 if blob.size() > SEMANTIC_FILE_BUDGET_BYTES {
403 return Ok(opaque);
404 }
405 let extracted = match &self.budget {
406 Some(budget) => crate::semantic_index::extract_semantic_file_bounded(
407 blob.content(),
408 language,
409 budget,
410 )
411 .map_err(|error| match error {
412 crate::semantic_index::ExtractionBudgetError::Interrupted => {
413 self.interruption_error()
414 }
415 crate::semantic_index::ExtractionBudgetError::Exceeded(_) => {
416 SemanticAssemblyError::SourceLimit
417 }
418 })?,
419 None => extract_semantic_file(blob.content(), language),
420 };
421 self.check_work()?;
422 let Some(extracted) = extracted else {
423 return Ok(opaque);
425 };
426 self.parse_count += 1;
427
428 let lang = language_name(extracted.language).to_string();
429 let gv = grammar_version(extracted.language).to_string();
430 self.grammars.insert(lang.clone(), gv.clone());
433
434 let node = SemanticFileNode::new(
435 lang,
436 gv,
437 self.extractor_version,
438 source_hash,
439 extracted.scaffold_hash,
440 SemanticFileFacts {
441 symbols: extracted.symbols,
442 scopes: extracted.scopes,
443 imports: extracted.imports,
444 occurrences: extracted.occurrences,
445 },
446 );
447 let digest = node.semantic_digest;
448 let node_hash = self.put_node(&node)?;
449 Ok(BuiltEntry {
450 kind: SemanticEntryKind::File,
451 node: node_hash,
452 semantic_digest: digest,
453 })
454 }
455
456 fn put_node(&mut self, node: &impl serde::Serialize) -> SemanticAssemblyResult<ContentHash> {
459 self.check_work()?;
460 let remaining = self
461 .output_limit
462 .unwrap_or(usize::MAX)
463 .saturating_sub(self.pending_bytes);
464 let mut writer = SemanticNodeWriter {
465 bytes: Vec::new(),
466 remaining,
467 exceeded: false,
468 };
469 let encoded =
470 node.serialize(&mut rmp_serde::Serializer::new(&mut writer).with_struct_map());
471 if writer.exceeded {
472 return Err(SemanticAssemblyError::OutputLimit);
473 }
474 encoded.map_err(|error| SemanticAssemblyError::Malformed(error.to_string()))?;
475 let bytes = writer.bytes;
476 let hash = ContentHash::compute_typed("blob", &bytes);
477 self.pending_bytes = self.pending_bytes.saturating_add(bytes.len());
478 self.pending.push((hash, bytes));
479 Ok(hash)
480 }
481}
482
483impl<S: ObjectStore + ObjectSource> SemanticIndexBuilder<'_, S> {
484 pub fn build_root(
486 &mut self,
487 tree: &Tree,
488 parent: Option<&ParentIndex>,
489 ) -> SemanticAssemblyResult<(SemanticIndexRoot, ContentHash)> {
490 let (root, root_hash, pending) = self.build_root_deferred(tree, parent)?;
491 self.check_work()?;
492 self.store.put_blobs_packed(pending)?;
493 Ok((root, root_hash))
494 }
495}
496
497struct SemanticNodeWriter {
500 bytes: Vec<u8>,
501 remaining: usize,
502 exceeded: bool,
503}
504impl std::io::Write for SemanticNodeWriter {
505 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
506 if bytes.len() > self.remaining {
507 self.exceeded = true;
508 return Err(std::io::Error::other(
509 "semantic analysis output budget exceeded",
510 ));
511 }
512 self.bytes.extend_from_slice(bytes);
513 self.remaining = self.remaining.saturating_sub(bytes.len());
514 Ok(bytes.len())
515 }
516 fn flush(&mut self) -> std::io::Result<()> {
517 Ok(())
518 }
519}
520
521fn opaque_edge_digest(target: &TreeEntryTarget) -> ContentHash {
524 match target {
525 TreeEntryTarget::Gitlink { target } => {
526 ContentHash::compute_typed("hd-sem-opaque-gitlink", target.as_bytes())
527 }
528 TreeEntryTarget::Spoollink { spool_id, state_id } => {
529 let mut buf = Vec::new();
530 buf.extend_from_slice(spool_id.as_str().as_bytes());
531 buf.push(0);
532 buf.extend_from_slice(state_id.as_bytes());
533 ContentHash::compute_typed("hd-sem-opaque-spoollink", &buf)
534 }
535 _ => ContentHash::compute_typed("hd-sem-opaque", &[]),
537 }
538}
539
540pub struct ParentIndex {
542 pub source_tree: Tree,
543 pub semantic_tree: SemanticTreeNode,
544 pub root: SemanticIndexRoot,
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550 use crate::parser::ParseBudget;
551 use crate::semantic_index::EXTRACTOR_VERSION;
552 use objects::object::{Blob, SemanticEntryKind, SemanticTreeEntry, State, StateId, TreeEntry};
553
554 struct ReadOnly<'a>(&'a objects::store::InMemoryStore);
555 impl ObjectSource for ReadOnly<'_> {
556 fn get_tree(&self, hash: &ContentHash) -> objects::store::Result<Option<Tree>> {
557 ObjectSource::get_tree(self.0, hash)
558 }
559 fn get_state(&self, id: &StateId) -> objects::store::Result<Option<State>> {
560 ObjectSource::get_state(self.0, id)
561 }
562 fn get_blob(&self, hash: &ContentHash) -> objects::store::Result<Option<Blob>> {
563 ObjectSource::get_blob(self.0, hash)
564 }
565 }
566
567 struct FailingSource;
568 impl ObjectSource for FailingSource {
569 fn get_tree(&self, _: &ContentHash) -> objects::store::Result<Option<Tree>> {
570 Ok(None)
571 }
572 fn get_state(&self, _: &StateId) -> objects::store::Result<Option<State>> {
573 Ok(None)
574 }
575 fn get_blob(&self, _: &ContentHash) -> objects::store::Result<Option<Blob>> {
576 Err(HeddleError::InvalidObject("source read failed".into()))
577 }
578 }
579
580 #[test]
581 fn read_only_deferred_assembly_matches_persisted_output() {
582 let store = objects::store::InMemoryStore::new();
583 let source_hash = ObjectStore::put_blob(&store, &Blob::from_slice(b"fn main() {}\n"))
584 .expect("source blob");
585 let tree = Tree::from_entries(vec![
586 TreeEntry::file("main.rs", source_hash, false).expect("entry"),
587 ]);
588 let read_only = ReadOnly(&store);
589 let (deferred_root, deferred_hash, blobs) =
590 SemanticIndexBuilder::new(&read_only, EXTRACTOR_VERSION)
591 .build_root_deferred(&tree, None)
592 .expect("read-only assembly");
593 assert!(
594 ObjectStore::get_blob(&store, &deferred_hash)
595 .expect("lookup")
596 .is_none()
597 );
598 let (persisted_root, persisted_hash) = SemanticIndexBuilder::new(&store, EXTRACTOR_VERSION)
599 .build_root(&tree, None)
600 .expect("persisted assembly");
601 assert_eq!(deferred_root, persisted_root);
602 assert_eq!(deferred_hash, persisted_hash);
603 for (hash, bytes) in blobs {
604 assert_eq!(
605 ObjectStore::get_blob(&store, &hash)
606 .expect("lookup")
607 .expect("persisted blob")
608 .content(),
609 bytes
610 );
611 }
612 let semantic_tree = SemanticTreeNode::decode(
613 ObjectStore::get_blob(&store, &persisted_root.tree)
614 .expect("lookup")
615 .expect("semantic tree")
616 .content(),
617 )
618 .expect("decode semantic tree");
619 let parent = ParentIndex {
620 source_tree: tree.clone(),
621 semantic_tree,
622 root: persisted_root,
623 };
624 let mut reused = SemanticIndexBuilder::new(&read_only, EXTRACTOR_VERSION);
625 let (reused_root, reused_hash, _) = reused
626 .build_root_deferred(&tree, Some(&parent))
627 .expect("reuse parent");
628 assert_eq!(reused_hash, persisted_hash);
629 assert_eq!(reused_root, parent.root);
630 assert_eq!(reused.parse_count, 0);
631 }
632
633 #[test]
634 fn semantic_failures_keep_cancellation_deadline_and_limits_distinct() {
635 use std::sync::{Arc, atomic::AtomicBool};
636 let store = objects::store::InMemoryStore::new();
637 let cancelled = ParseBudget {
638 cancelled: Arc::new(AtomicBool::new(true)),
639 deadline: std::time::Instant::now() + std::time::Duration::from_secs(30),
640 };
641 let mut builder =
642 SemanticIndexBuilder::new(&store, EXTRACTOR_VERSION).with_budget(cancelled);
643 assert!(matches!(
644 builder.build_root_deferred(&Tree::new(), None),
645 Err(SemanticAssemblyError::Cancelled)
646 ));
647
648 let deadline = ParseBudget {
649 cancelled: Arc::new(AtomicBool::new(false)),
650 deadline: std::time::Instant::now(),
651 };
652 let mut builder =
653 SemanticIndexBuilder::new(&store, EXTRACTOR_VERSION).with_budget(deadline);
654 assert!(matches!(
655 builder.build_root_deferred(&Tree::new(), None),
656 Err(SemanticAssemblyError::Deadline)
657 ));
658
659 let budget = ParseBudget {
660 cancelled: Arc::new(AtomicBool::new(false)),
661 deadline: std::time::Instant::now() + std::time::Duration::from_secs(30),
662 };
663 let mut builder = SemanticIndexBuilder::new(&store, EXTRACTOR_VERSION).with_budget(budget);
664 builder.work_entries = 4097;
665 assert!(matches!(
666 builder.build_root_deferred(&Tree::new(), None),
667 Err(SemanticAssemblyError::SourceLimit)
668 ));
669
670 let mut builder = SemanticIndexBuilder::new(&store, EXTRACTOR_VERSION).with_output_limit(0);
671 assert!(matches!(
672 builder.build_root_deferred(&Tree::new(), None),
673 Err(SemanticAssemblyError::OutputLimit)
674 ));
675
676 let mut builder = SemanticIndexBuilder::new(&store, EXTRACTOR_VERSION);
677 assert!(matches!(
678 builder.build_tree(&Tree::new(), None, MAX_SEMANTIC_TREE_DEPTH + 1),
679 Err(SemanticAssemblyError::Malformed(_))
680 ));
681
682 let tree = Tree::from_entries(vec![
683 TreeEntry::file("main.rs", ContentHash::from_bytes([7; 32]), false).expect("entry"),
684 ]);
685 let mut builder = SemanticIndexBuilder::new(&FailingSource, EXTRACTOR_VERSION);
686 assert!(matches!(
687 builder.build_root_deferred(&tree, None),
688 Err(SemanticAssemblyError::Storage(_))
689 ));
690 }
691 #[test]
692 fn bounded_analysis_encodes_canonical_nodes_with_cumulative_output_limit() {
693 let store = objects::store::InMemoryStore::new();
694 let (node, _) = SemanticTreeNode::new(vec![SemanticTreeEntry {
695 name: "a.rs".into(),
696 kind: SemanticEntryKind::Opaque,
697 node: ContentHash::from_bytes([23; 32]),
698 semantic_digest: ContentHash::from_bytes([23; 32]),
699 }]);
700 let canonical = node.encode().expect("canonical bytes");
701 let mut builder = SemanticIndexBuilder::new(&store, EXTRACTOR_VERSION)
702 .with_output_limit(canonical.len() * 2 - 1);
703 let hash = builder.put_node(&node).expect("first node fits");
704 assert_eq!(hash, ContentHash::compute_typed("blob", &canonical));
705 assert_eq!(
706 builder.pending[0].1, canonical,
707 "encoder retains exact canonical representation"
708 );
709 let error = builder
710 .put_node(&node)
711 .expect_err("second node exceeds total output allowance");
712 assert!(
713 error.to_string().contains("output budget exceeded"),
714 "{error}"
715 );
716 assert_eq!(builder.pending.len(), 1, "failed node is never queued");
717 assert_eq!(builder.pending_bytes, canonical.len());
718 assert!(
719 ObjectStore::get_blob(&store, &hash)
720 .expect("lookup")
721 .is_none(),
722 "no node is published during bounded assembly"
723 );
724 }
725
726 #[test]
727 fn bounded_analysis_writer_rejects_before_buffer_growth() {
728 use std::io::Write;
729 let mut writer = SemanticNodeWriter {
730 bytes: Vec::new(),
731 remaining: 7,
732 exceeded: false,
733 };
734 writer.write_all(b"small").expect("within allowance");
735 let capacity = writer.bytes.capacity();
736 let expanded = vec![0; 1 << 20];
737 let error = writer
738 .write_all(&expanded)
739 .expect_err("reject before growing buffer");
740 assert!(error.to_string().contains("output budget exceeded"));
741 assert_eq!(writer.bytes, b"small");
742 assert_eq!(writer.bytes.capacity(), capacity);
743 }
744}