A better Rust ATProto crate

parser

Orual 62e76554 c4daaadb

+188
+188
crates/jacquard/src/richtext.rs
··· 4 4 //! and detection of embed candidates (record and external embeds). 5 5 6 6 use crate::common::CowStr; 7 + use jacquard_common::IntoStatic; 8 + use jacquard_common::types::did::{DID_REGEX, Did}; 9 + use jacquard_common::types::handle::HANDLE_REGEX; 7 10 use regex::Regex; 8 11 use std::marker::PhantomData; 9 12 use std::ops::Range; ··· 73 76 pub struct RichTextBuilder<State> { 74 77 text: String, 75 78 facet_candidates: Vec<FacetCandidate>, 79 + #[cfg(feature = "api_bluesky")] 80 + embed_candidates: Vec<EmbedCandidate<'static>>, 76 81 _state: PhantomData<State>, 77 82 } 78 83 ··· 402 407 403 408 facets 404 409 } 410 + 411 + use jacquard_common::types::string::AtStrError; 412 + use thiserror::Error; 413 + 414 + /// Errors that can occur during richtext building 415 + #[derive(Debug, Error)] 416 + pub enum RichTextError { 417 + /// Handle found that needs resolution but no resolver provided 418 + #[error("Handle '{0}' requires resolution - use build_async() with an IdentityResolver")] 419 + HandleNeedsResolution(String), 420 + 421 + /// Facets overlap (not allowed by spec) 422 + #[error("Facets overlap at byte range {0}..{1}")] 423 + OverlappingFacets(usize, usize), 424 + 425 + /// Identity resolution failed 426 + #[error("Failed to resolve identity")] 427 + IdentityResolution(#[from] jacquard_identity::resolver::IdentityError), 428 + 429 + /// Invalid byte range 430 + #[error("Invalid byte range {start}..{end} for text of length {text_len}")] 431 + InvalidRange { 432 + start: usize, 433 + end: usize, 434 + text_len: usize, 435 + }, 436 + 437 + /// Invalid AT Protocol string (URI, DID, or Handle) 438 + #[error("Invalid AT Protocol string")] 439 + InvalidAtStr(#[from] AtStrError), 440 + 441 + /// Invalid URI 442 + #[error("Invalid URI")] 443 + Uri(#[from] jacquard_common::types::uri::UriParseError), 444 + } 445 + 446 + #[cfg(feature = "api_bluesky")] 447 + impl RichTextBuilder<Resolved> { 448 + /// Build the richtext (sync - all facets must be resolved) 449 + pub fn build( 450 + self, 451 + ) -> Result< 452 + ( 453 + String, 454 + Option<Vec<crate::api::app_bsky::richtext::facet::Facet<'static>>>, 455 + ), 456 + RichTextError, 457 + > { 458 + use std::collections::BTreeMap; 459 + if self.facet_candidates.is_empty() { 460 + return Ok((self.text, None)); 461 + } 462 + 463 + // Sort facets by start position 464 + let mut candidates = self.facet_candidates; 465 + candidates.sort_by_key(|fc| match fc { 466 + FacetCandidate::MarkdownLink { display_range, .. } => display_range.start, 467 + FacetCandidate::Mention { range, .. } => range.start, 468 + FacetCandidate::Link { range } => range.start, 469 + FacetCandidate::Tag { range } => range.start, 470 + }); 471 + 472 + // Check for overlaps and convert to Facet types 473 + let mut facets = Vec::with_capacity(candidates.len()); 474 + let mut last_end = 0; 475 + let text_len = self.text.len(); 476 + 477 + for candidate in candidates { 478 + let (range, feature) = match candidate { 479 + FacetCandidate::MarkdownLink { display_range, url } => { 480 + // MarkdownLink stores URL directly, use display_range for index 481 + 482 + let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Link( 483 + Box::new(crate::api::app_bsky::richtext::facet::Link { 484 + uri: crate::types::uri::Uri::new_owned(&url)?, 485 + extra_data: BTreeMap::new(), 486 + }), 487 + ); 488 + (display_range, feature) 489 + } 490 + FacetCandidate::Mention { range, did } => { 491 + // In Resolved state, DID must be present 492 + let did = did.ok_or_else(|| { 493 + // Extract handle from text for error message 494 + let handle = if range.end <= text_len { 495 + self.text[range.clone()].trim_start_matches('@') 496 + } else { 497 + "<invalid range>" 498 + }; 499 + RichTextError::HandleNeedsResolution(handle.to_string()) 500 + })?; 501 + 502 + let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Mention( 503 + Box::new(crate::api::app_bsky::richtext::facet::Mention { 504 + did, 505 + extra_data: BTreeMap::new(), 506 + }), 507 + ); 508 + (range, feature) 509 + } 510 + FacetCandidate::Link { range } => { 511 + // Extract URL from text[range] and normalize 512 + if range.end > text_len { 513 + return Err(RichTextError::InvalidRange { 514 + start: range.start, 515 + end: range.end, 516 + text_len, 517 + }); 518 + } 519 + 520 + let mut url = self.text[range.clone()].to_string(); 521 + 522 + // Prepend https:// if URL doesn't have a scheme 523 + if !url.starts_with("http://") && !url.starts_with("https://") { 524 + url = format!("https://{}", url); 525 + } 526 + 527 + let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Link( 528 + Box::new(crate::api::app_bsky::richtext::facet::Link { 529 + uri: crate::types::uri::Uri::new_owned(&url)?, 530 + extra_data: BTreeMap::new(), 531 + }), 532 + ); 533 + (range, feature) 534 + } 535 + FacetCandidate::Tag { range } => { 536 + // Extract tag from text[range] (includes #), strip # and trailing punct 537 + 538 + use smol_str::ToSmolStr; 539 + if range.end > text_len { 540 + return Err(RichTextError::InvalidRange { 541 + start: range.start, 542 + end: range.end, 543 + text_len, 544 + }); 545 + } 546 + 547 + let tag_with_hash = &self.text[range.clone()]; 548 + // Strip # prefix (could be # or #) 549 + let tag = tag_with_hash 550 + .trim_start_matches('#') 551 + .trim_start_matches('#'); 552 + 553 + let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Tag( 554 + Box::new(crate::api::app_bsky::richtext::facet::Tag { 555 + tag: CowStr::from(tag.to_smolstr()), 556 + extra_data: BTreeMap::new(), 557 + }), 558 + ); 559 + (range, feature) 560 + } 561 + }; 562 + 563 + // Check overlap 564 + if range.start < last_end { 565 + return Err(RichTextError::OverlappingFacets(range.start, range.end)); 566 + } 567 + 568 + // Validate range 569 + if range.end > text_len { 570 + return Err(RichTextError::InvalidRange { 571 + start: range.start, 572 + end: range.end, 573 + text_len, 574 + }); 575 + } 576 + 577 + facets.push(crate::api::app_bsky::richtext::facet::Facet { 578 + index: crate::api::app_bsky::richtext::facet::ByteSlice { 579 + byte_start: range.start as i64, 580 + byte_end: range.end as i64, 581 + extra_data: BTreeMap::new(), 582 + }, 583 + features: vec![feature], 584 + extra_data: BTreeMap::new(), 585 + }); 586 + 587 + last_end = range.end; 588 + } 589 + 590 + Ok((self.text, Some(facets.into_static()))) 591 + } 592 + }