atproto blogging
1use super::types::{FacetFeature, NormalizedFacet};
2use super::FacetOutput;
3use std::cmp::Ordering;
4
5#[derive(Debug, Clone)]
6struct FacetEvent<'a> {
7 pos: usize,
8 is_start: bool,
9 feature: FacetFeature<'a>,
10 facet_idx: usize,
11}
12
13impl<'a> FacetEvent<'a> {
14 fn start(pos: usize, feature: FacetFeature<'a>, facet_idx: usize) -> Self {
15 Self {
16 pos,
17 is_start: true,
18 feature,
19 facet_idx,
20 }
21 }
22
23 fn end(pos: usize, feature: FacetFeature<'a>, facet_idx: usize) -> Self {
24 Self {
25 pos,
26 is_start: false,
27 feature,
28 facet_idx,
29 }
30 }
31}
32
33impl<'a> PartialEq for FacetEvent<'a> {
34 fn eq(&self, other: &Self) -> bool {
35 self.pos == other.pos && self.is_start == other.is_start
36 }
37}
38
39impl<'a> Eq for FacetEvent<'a> {}
40
41impl<'a> PartialOrd for FacetEvent<'a> {
42 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
43 Some(self.cmp(other))
44 }
45}
46
47impl<'a> Ord for FacetEvent<'a> {
48 fn cmp(&self, other: &Self) -> Ordering {
49 match self.pos.cmp(&other.pos) {
50 Ordering::Equal => {
51 // At same position: ends before starts for proper nesting
52 match (self.is_start, other.is_start) {
53 (false, true) => Ordering::Less,
54 (true, false) => Ordering::Greater,
55 _ => Ordering::Equal,
56 }
57 }
58 ord => ord,
59 }
60 }
61}
62
63pub fn process_faceted_text<'a, O: FacetOutput>(
64 text: &'a str,
65 facets: &[NormalizedFacet<'a>],
66 output: &mut O,
67) -> Result<(), O::Error> {
68 let mut events: Vec<FacetEvent<'a>> = Vec::new();
69
70 for (idx, facet) in facets.iter().enumerate() {
71 if facet.index.is_empty() {
72 continue;
73 }
74 for feature in &facet.features {
75 events.push(FacetEvent::start(facet.index.start(), feature.clone(), idx));
76 events.push(FacetEvent::end(facet.index.end(), feature.clone(), idx));
77 }
78 }
79
80 events.sort();
81
82 // Track active features in a stack: (feature, facet_idx)
83 let mut active_stack: Vec<(FacetFeature<'a>, usize)> = Vec::new();
84 let mut last_pos = 0;
85
86 for event in events {
87 let pos = event.pos.min(text.len());
88
89 // Write text up to this position
90 if pos > last_pos {
91 if let Some(segment) = text.get(last_pos..pos) {
92 output.write_text(segment)?;
93 }
94 last_pos = pos;
95 }
96
97 if event.is_start {
98 output.start_feature(&event.feature)?;
99 active_stack.push((event.feature, event.facet_idx));
100 } else {
101 // Find the feature in the stack that matches this end event
102 let close_from = active_stack
103 .iter()
104 .rposition(|(f, idx)| *idx == event.facet_idx && feature_matches(f, &event.feature));
105
106 if let Some(close_idx) = close_from {
107 // Close features from top down to the one we need to close
108 let to_reopen: Vec<_> = active_stack.drain(close_idx..).collect();
109
110 // Close all features we're draining (in reverse order)
111 for (f, _) in to_reopen.iter().rev() {
112 output.end_feature(f)?;
113 }
114
115 // Reopen features that aren't the one we're closing (skip first which is the one we're closing)
116 for (f, idx) in to_reopen.into_iter().skip(1) {
117 output.start_feature(&f)?;
118 active_stack.push((f, idx));
119 }
120 }
121 }
122 }
123
124 // Write remaining text
125 if last_pos < text.len() {
126 output.write_text(&text[last_pos..])?;
127 }
128
129 // Close any remaining open features
130 for (feature, _) in active_stack.into_iter().rev() {
131 output.end_feature(&feature)?;
132 }
133
134 Ok(())
135}
136
137fn feature_matches(a: &FacetFeature<'_>, b: &FacetFeature<'_>) -> bool {
138 std::mem::discriminant(a) == std::mem::discriminant(b)
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use crate::facet::types::ByteRange;
145
146 struct TestOutput {
147 buffer: String,
148 }
149
150 impl TestOutput {
151 fn new() -> Self {
152 Self {
153 buffer: String::new(),
154 }
155 }
156 }
157
158 impl FacetOutput for TestOutput {
159 type Error = std::fmt::Error;
160
161 fn write_text(&mut self, text: &str) -> Result<(), Self::Error> {
162 self.buffer.push_str(text);
163 Ok(())
164 }
165
166 fn start_feature(&mut self, feature: &FacetFeature<'_>) -> Result<(), Self::Error> {
167 match feature {
168 FacetFeature::Bold => self.buffer.push_str("<b>"),
169 FacetFeature::Italic => self.buffer.push_str("<i>"),
170 FacetFeature::Link { uri } => {
171 self.buffer.push_str(&format!("<a href=\"{}\">", uri))
172 }
173 _ => self.buffer.push_str("<?>"),
174 }
175 Ok(())
176 }
177
178 fn end_feature(&mut self, feature: &FacetFeature<'_>) -> Result<(), Self::Error> {
179 match feature {
180 FacetFeature::Bold => self.buffer.push_str("</b>"),
181 FacetFeature::Italic => self.buffer.push_str("</i>"),
182 FacetFeature::Link { .. } => self.buffer.push_str("</a>"),
183 _ => self.buffer.push_str("</?>"),
184 }
185 Ok(())
186 }
187 }
188
189 #[test]
190 fn test_simple_bold() {
191 let text = "hello world";
192 let facets = vec![NormalizedFacet {
193 index: ByteRange::new(0, 5),
194 features: vec![FacetFeature::Bold],
195 }];
196
197 let mut output = TestOutput::new();
198 process_faceted_text(text, &facets, &mut output).unwrap();
199
200 assert_eq!(output.buffer, "<b>hello</b> world");
201 }
202
203 #[test]
204 fn test_overlapping_facets() {
205 // "bold and italic just italic"
206 // ^^^^^^^^^^^^^ <- bold (0-13)
207 // ^^^^^^^^^^^^^^^^^^^^^^^ <- italic (5-27)
208 let text = "bold and italic just italic";
209 let facets = vec![
210 NormalizedFacet {
211 index: ByteRange::new(0, 15),
212 features: vec![FacetFeature::Bold],
213 },
214 NormalizedFacet {
215 index: ByteRange::new(5, 27),
216 features: vec![FacetFeature::Italic],
217 },
218 ];
219
220 let mut output = TestOutput::new();
221 process_faceted_text(text, &facets, &mut output).unwrap();
222
223 // Should properly nest: <b>bold <i>and italic</i></b><i> just italic</i>
224 assert_eq!(
225 output.buffer,
226 "<b>bold <i>and italic</i></b><i> just italic</i>"
227 );
228 }
229
230 #[test]
231 fn test_no_facets() {
232 let text = "plain text";
233 let facets: Vec<NormalizedFacet> = vec![];
234
235 let mut output = TestOutput::new();
236 process_faceted_text(text, &facets, &mut output).unwrap();
237
238 assert_eq!(output.buffer, "plain text");
239 }
240
241 #[test]
242 fn test_link_facet() {
243 let text = "click here for more";
244 let facets = vec![NormalizedFacet {
245 index: ByteRange::new(6, 10),
246 features: vec![FacetFeature::Link {
247 uri: "https://example.com",
248 }],
249 }];
250
251 let mut output = TestOutput::new();
252 process_faceted_text(text, &facets, &mut output).unwrap();
253
254 assert_eq!(
255 output.buffer,
256 "click <a href=\"https://example.com\">here</a> for more"
257 );
258 }
259}