1use oxc_diagnostics::OxcDiagnostic;
2use oxc_span::Span;
3
4use crate::{err, typescript::parse_expr};
5
6/// Information about the id attribute in a tag.
7#[derive(Debug, Clone, Copy, PartialEq)]
8pub enum AttributeValue {
9 /// No id attribute found
10 None,
11 /// Staticly analyzed string literal (raw source)
12 Static { span: Span, is_quoted: bool },
13 /// Dynamic expression
14 Dynamic,
15}
16
17#[derive(Debug, Clone, PartialEq)]
18pub struct Open<'a> {
19 pub src: &'a str,
20 /// Only set if the tag name is a static value
21 literal_tag_name: Span,
22 pub self_closing: bool,
23 /// Offset after tag name and class/id shorthands, where new shorthand could be inserted
24 shorthand_end: u32,
25 /// Information about the id attribute
26 pub id: AttributeValue,
27}
28
29impl<'a> Open<'a> {
30 /// Convert to an owned version for storage.
31 pub fn to_owned(&self) -> OpenOwned {
32 OpenOwned {
33 src: self.src.to_string(),
34 literal_tag_name: self.literal_tag_name,
35 self_closing: self.self_closing,
36 shorthand_end: self.shorthand_end,
37 id: self.id,
38 }
39 }
40
41 /// Returns the element tag name. Empty string when dynamic interpolation.
42 pub fn tag_name(&self) -> &'a str {
43 &self.src[self.literal_tag_name]
44 }
45
46 /// Returns the span of the tag's name for errors. Works for dynamic interpolations
47 pub fn tag_name_span(&self) -> Span {
48 // fast case, when a literal tag name is passed
49 if !self.literal_tag_name.is_empty() {
50 self.literal_tag_name.expand_left(1)
51 } else {
52 // slow path, re-parse the js expression
53 let mut l = LexState::new(self.src);
54 l.advance(3);
55 parse_expr(&mut l).expect("validated text should pass");
56 Span::new(3, l.offset())
57 }
58 }
59
60 /// Returns the id attribute value if it's a static string.
61 /// Returns `None` if no id or if the id is dynamic.
62 pub fn id_attr(&self) -> Option<(&'a str, bool)> {
63 match self.id {
64 AttributeValue::Static { span, is_quoted } => Some((&self.src[span], is_quoted)),
65 AttributeValue::None | AttributeValue::Dynamic => None,
66 }
67 }
68
69 /// Returns whether the id is dynamic (contains ${} or is a non-string expression).
70 pub fn id_is_dynamic(&self) -> bool {
71 matches!(self.id, AttributeValue::Dynamic)
72 }
73
74 pub(crate) fn new(
75 src: &'a str,
76 tag_name: Span,
77 self_closing: bool,
78 shorthand_end: u32,
79 id: AttributeValue,
80 ) -> Self {
81 Self {
82 src,
83 literal_tag_name: tag_name,
84 self_closing,
85 shorthand_end,
86 id,
87 }
88 }
89}
90
91/// Owned version of [Open] for storage in AST nodes, which do not allow specifying a lifetime.
92/// Waste of memory.
93#[derive(Debug, Clone, PartialEq)]
94pub struct OpenOwned {
95 pub src: String,
96 literal_tag_name: Span,
97 pub self_closing: bool,
98 /// Offset after tag name and class/id shorthands, where new shorthand could be inserted
99 shorthand_end: u32,
100 /// Information about the id attribute
101 pub id: AttributeValue,
102}
103
104impl OpenOwned {
105 /// Borrow as an [Open] reference to access methods.
106 pub fn as_ref(&self) -> Open<'_> {
107 Open {
108 src: &self.src,
109 literal_tag_name: self.literal_tag_name,
110 self_closing: self.self_closing,
111 shorthand_end: self.shorthand_end,
112 id: self.id,
113 }
114 }
115
116 /// Set the id attribute to a static string value. Asserts there is not
117 /// an ID attribute present; caller should prefer the existing one.
118 pub fn insert_id_attr(&mut self, new_id: &str) {
119 match self.id {
120 AttributeValue::None => {
121 // Insert new shorthand #id at shorthand_end position
122 let insert_pos = self.shorthand_end;
123 let mut src = String::with_capacity(self.src.len() + 1 + new_id.len());
124 src.push_str(&self.src[..insert_pos as usize]);
125 src.push('#');
126 src.push_str(new_id);
127 src.push_str(&self.src[insert_pos as usize..]);
128
129 let id_start = insert_pos + 1; // after the #
130 let id_end = id_start + new_id.len() as u32;
131
132 self.src = src;
133 self.shorthand_end = id_end;
134 self.id = AttributeValue::Static {
135 span: Span::new(id_start, id_end),
136 is_quoted: false,
137 };
138 }
139 AttributeValue::Dynamic | AttributeValue::Static { .. } => {
140 panic!("Cannot replace ID attribute");
141 }
142 }
143 }
144
145 /// Replace the tag name while preserving all shorthands and attributes.
146 /// For example: `<h3#foo class="bar">` → `<Heading#foo class="bar">`
147 pub fn replace_tag_name(&mut self, new_name: &str) {
148 let old_start = self.literal_tag_name.start as usize;
149 let old_end = self.literal_tag_name.end as usize;
150 let old_len = old_end - old_start;
151 let new_len = new_name.len();
152 let delta = new_len as i32 - old_len as i32;
153
154 // Build new source
155 let mut new_src = String::with_capacity((self.src.len() as i32 + delta) as usize);
156 new_src.push_str(&self.src[..old_start]);
157 new_src.push_str(new_name);
158 new_src.push_str(&self.src[old_end..]);
159
160 // Update spans
161 self.literal_tag_name = Span::new(old_start as u32, (old_start + new_len) as u32);
162 self.shorthand_end = (self.shorthand_end as i32 + delta) as u32;
163
164 // Update id span if present
165 if let AttributeValue::Static { span, is_quoted } = self.id {
166 self.id = AttributeValue::Static {
167 span: Span::new(
168 (span.start as i32 + delta) as u32,
169 (span.end as i32 + delta) as u32,
170 ),
171 is_quoted,
172 };
173 }
174
175 self.src = new_src;
176 }
177
178 /// Insert an attribute after the shorthands (before other attributes).
179 /// For example: `<Heading#foo class="bar">` with `level=3` → `<Heading#foo level=3 class="bar">`
180 pub fn insert_attr(&mut self, attr: &str) {
181 let insert_pos = self.shorthand_end as usize;
182 let insert_str = format!(" {attr}");
183 let delta = insert_str.len();
184
185 // Build new source
186 let mut new_src = String::with_capacity(self.src.len() + delta);
187 new_src.push_str(&self.src[..insert_pos]);
188 new_src.push_str(&insert_str);
189 new_src.push_str(&self.src[insert_pos..]);
190
191 // Update shorthand_end to point after the new attribute
192 self.shorthand_end += delta as u32;
193
194 // Update id span if it comes after the insertion point
195 if let AttributeValue::Static { span, is_quoted } = self.id {
196 if span.start >= insert_pos as u32 {
197 self.id = AttributeValue::Static {
198 span: Span::new(span.start + delta as u32, span.end + delta as u32),
199 is_quoted,
200 };
201 }
202 }
203
204 self.src = new_src;
205 }
206
207 /// Create a new OpenOwned from a tag name (for synthesizing tags).
208 /// Creates a simple `<tagname>` with no attributes.
209 pub fn from_tag_name(tag_name: &str) -> Self {
210 let src = format!("<{tag_name}>");
211 let tag_name_span = Span::new(1, 1 + tag_name.len() as u32);
212 OpenOwned {
213 src,
214 literal_tag_name: tag_name_span,
215 self_closing: false,
216 shorthand_end: 1 + tag_name.len() as u32,
217 id: AttributeValue::None,
218 }
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct Close<'a> {
224 pub tag_name: Option<&'a str>,
225 pub length: u32,
226}
227
228impl Close<'_> {
229 /// Returns the span of the tag name relative to the start of the tag.
230 /// For `</div>`, returns Span(2, 5) pointing to "div".
231 /// For `</>`, returns None.
232 pub fn tag_name_span(&self) -> Option<Span> {
233 self.tag_name.map(|name| {
234 // Close tag starts with '</', so tag name starts at offset 2
235 let start = 2u32;
236 let end = start + (name.len() as u32);
237 Span::new(start, end)
238 })
239 }
240}
241
242pub enum OpenOrClose<'a> {
243 Open(Open<'a>),
244 Close(Close<'a>),
245}
246
247pub struct LexState<'a> {
248 pub src: &'a str,
249 pub offset: u32,
250}
251
252impl<'a> LexState<'a> {
253 pub fn new(src: &'a str) -> LexState<'a> {
254 LexState { src, offset: 0 }
255 }
256
257 pub fn offset(&self) -> u32 {
258 self.offset
259 }
260
261 pub fn advance(&mut self, n: u32) {
262 self.offset += n;
263 }
264
265 pub fn restore(&mut self, n: u32) {
266 self.offset = n;
267 }
268
269 pub fn peek_byte(&self) -> Option<u8> {
270 self.src.as_bytes().get(self.offset as usize).copied()
271 }
272
273 pub fn peek_rest(&self) -> &'a str {
274 &self.src[self.offset as usize..]
275 }
276
277 pub fn expect_byte(&mut self) -> Result<u8, OxcDiagnostic> {
278 if let Some(b) = self.peek_byte() {
279 self.offset += 1;
280 return Ok(b);
281 }
282 Err(err("Unexpected end of file", self.offset, 1))
283 }
284
285 pub fn expect(&mut self, expected: &str) -> Result<(), OxcDiagnostic> {
286 if self.src[self.offset as usize..].starts_with(expected) {
287 self.offset += expected.len() as u32;
288 Ok(())
289 } else {
290 Err(err(format!("Expected {expected}"), self.offset, 1))
291 }
292 }
293
294 pub fn skip_whitespace(&mut self) {
295 while let Some(b' ' | b'\n' | b'\t' | b'\r') = self.peek_byte() {
296 self.offset += 1;
297 }
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use crate::marko::parse_open;
305
306 #[test]
307 fn test_replace_tag_name_simple() {
308 let mut open = OpenOwned::from_tag_name("h3");
309 assert_eq!(open.src, "<h3>");
310 open.replace_tag_name("Heading");
311 assert_eq!(open.src, "<Heading>");
312 assert_eq!(open.as_ref().tag_name(), "Heading");
313 }
314
315 #[test]
316 fn test_replace_tag_name_with_shorthand() {
317 let mut open = parse_open("<h3#foo>").unwrap().to_owned();
318 assert_eq!(open.as_ref().tag_name(), "h3");
319 open.replace_tag_name("Heading");
320 assert_eq!(open.src, "<Heading#foo>");
321 assert_eq!(open.as_ref().tag_name(), "Heading");
322 // ID should still be accessible
323 assert_eq!(open.as_ref().id_attr(), Some(("foo", false)));
324 }
325
326 #[test]
327 fn test_replace_tag_name_with_attributes() {
328 let mut open = parse_open("<h3 class=\"section\">").unwrap().to_owned();
329 open.replace_tag_name("HeadingComponent__markodown__");
330 assert_eq!(
331 open.src,
332 "<HeadingComponent__markodown__ class=\"section\">"
333 );
334 }
335
336 #[test]
337 fn test_insert_attr_simple() {
338 let mut open = OpenOwned::from_tag_name("Heading");
339 open.insert_attr("level=3");
340 assert_eq!(open.src, "<Heading level=3>");
341 }
342
343 #[test]
344 fn test_insert_attr_with_shorthand() {
345 let mut open = parse_open("<Heading#foo>").unwrap().to_owned();
346 open.insert_attr("level=3");
347 assert_eq!(open.src, "<Heading#foo level=3>");
348 // ID should still be accessible
349 assert_eq!(open.as_ref().id_attr(), Some(("foo", false)));
350 }
351
352 #[test]
353 fn test_insert_attr_with_existing_attrs() {
354 let mut open = parse_open("<Heading#foo class=\"section\">")
355 .unwrap()
356 .to_owned();
357 open.insert_attr("level=3");
358 assert_eq!(open.src, "<Heading#foo level=3 class=\"section\">");
359 }
360
361 #[test]
362 fn test_combined_replace_and_insert() {
363 let mut open = parse_open("<h3#about class=\"section\">")
364 .unwrap()
365 .to_owned();
366 open.replace_tag_name("HeadingComponent__markodown__");
367 open.insert_attr("level=3");
368 assert_eq!(
369 open.src,
370 "<HeadingComponent__markodown__#about level=3 class=\"section\">"
371 );
372 assert_eq!(open.as_ref().id_attr(), Some(("about", false)));
373 }
374}