1use crate::Editor;
14use crate::buf_helpers::{buf_line, buf_set_cursor_rc};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum TagKind {
19 Open,
20 Close,
21}
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct TagSpan {
25 kind: TagKind,
26 name: String,
27 row: usize,
29 name_start_col: usize,
31 name_end_col: usize,
32}
33pub fn detect_tag_at_cursor(line: &str, row: usize, col: usize) -> Option<TagSpan> {
37 let chars: Vec<char> = line.chars().collect();
38 let mut lt = None;
40 let mut i = col.min(chars.len());
41 while i > 0 {
42 i -= 1;
43 let c = chars[i];
44 if c == '<' {
45 lt = Some(i);
46 break;
47 }
48 if c == '>' {
50 return None;
51 }
52 }
53 let lt = lt?;
54 let (kind, name_start) = if chars.get(lt + 1) == Some(&'/') {
56 (TagKind::Close, lt + 2)
57 } else {
58 (TagKind::Open, lt + 1)
59 };
60 let first = chars.get(name_start)?;
62 if !first.is_ascii_alphabetic() {
63 return None;
64 }
65 let mut name_end = name_start;
67 while name_end < chars.len()
68 && (chars[name_end].is_ascii_alphanumeric() || chars[name_end] == '-')
69 {
70 name_end += 1;
71 }
72 if col < name_start || col > name_end {
76 return None;
77 }
78 let name: String = chars[name_start..name_end].iter().collect();
79 Some(TagSpan {
80 kind,
81 name,
82 row,
83 name_start_col: name_start,
84 name_end_col: name_end,
85 })
86}
87pub fn find_matching_tag(buffer: &hjkl_buffer::View, anchor: &TagSpan) -> Option<TagSpan> {
101 let row_count = buffer.row_count();
102 let scan_forward = anchor.kind == TagKind::Open;
103 let row_iter: Box<dyn Iterator<Item = usize>> = if scan_forward {
104 Box::new(anchor.row..row_count)
105 } else {
106 Box::new((0..=anchor.row).rev())
107 };
108 let push_kind = if scan_forward {
109 TagKind::Open
110 } else {
111 TagKind::Close
112 };
113 let mut depth: usize = 1;
114
115 for r in row_iter {
116 let line = buf_line(buffer, r)?;
117 let chars: Vec<char> = line.chars().collect();
118 let tags = scan_line_tags(&chars, r);
119 let tags_iter: Box<dyn Iterator<Item = TagSpan>> = if scan_forward {
120 Box::new(tags.into_iter())
121 } else {
122 Box::new(tags.into_iter().rev())
123 };
124 for tag in tags_iter {
125 if r == anchor.row
127 && tag.name_start_col == anchor.name_start_col
128 && tag.kind == anchor.kind
129 {
130 continue;
131 }
132 if r == anchor.row {
136 if scan_forward && tag.name_start_col < anchor.name_start_col {
137 continue;
138 }
139 if !scan_forward && tag.name_start_col > anchor.name_start_col {
140 continue;
141 }
142 }
143 if tag.kind == push_kind {
144 depth += 1;
145 } else {
146 depth -= 1;
147 if depth == 0 {
148 return Some(tag);
149 }
150 }
151 }
152 }
153 None
154}
155pub fn scan_line_tags(chars: &[char], row: usize) -> Vec<TagSpan> {
159 let mut out = Vec::new();
160 let n = chars.len();
161 let mut i = 0;
162 while i < n {
163 if chars[i] != '<' {
164 i += 1;
165 continue;
166 }
167 if chars[i..].starts_with(&['<', '!', '-', '-']) {
169 let mut j = i + 4;
170 while j + 2 < n && !(chars[j] == '-' && chars[j + 1] == '-' && chars[j + 2] == '>') {
171 j += 1;
172 }
173 i = (j + 3).min(n);
174 continue;
175 }
176 let (kind, name_start) = if chars.get(i + 1) == Some(&'/') {
177 (TagKind::Close, i + 2)
178 } else {
179 (TagKind::Open, i + 1)
180 };
181 if chars
183 .get(name_start)
184 .is_none_or(|c| !c.is_ascii_alphabetic())
185 {
186 i += 1;
187 continue;
188 }
189 let mut name_end = name_start;
190 while name_end < n && (chars[name_end].is_ascii_alphanumeric() || chars[name_end] == '-') {
191 name_end += 1;
192 }
193 let mut k = name_end;
195 let mut self_closing = false;
196 while k < n {
197 if chars[k] == '>' {
198 if k > name_end && chars[k - 1] == '/' {
199 self_closing = true;
200 }
201 break;
202 }
203 k += 1;
204 }
205 if k >= n {
206 break;
208 }
209 let name: String = chars[name_start..name_end].iter().collect();
210 if !(self_closing || kind == TagKind::Open && is_void_element(&name)) {
212 out.push(TagSpan {
213 kind,
214 name,
215 row,
216 name_start_col: name_start,
217 name_end_col: name_end,
218 });
219 }
220 i = k + 1;
221 }
222 out
223}
224pub fn sync_paired_tag_on_exit<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::View, H>) {
229 if !is_html_filetype(&ed.settings().filetype) {
230 return;
231 }
232 let (row, col) = ed.cursor();
233 let line = match buf_line(ed.buffer(), row) {
234 Some(l) => l,
235 None => return,
236 };
237 let anchor = match detect_tag_at_cursor(&line, row, col) {
238 Some(t) => t,
239 None => return,
240 };
241 let partner = match find_matching_tag(ed.buffer(), &anchor) {
242 Some(t) => t,
243 None => return,
244 };
245 if partner.name == anchor.name {
246 return;
247 }
248 use hjkl_buffer::{Edit, MotionKind, Position};
250 let start = Position::new(partner.row, partner.name_start_col);
251 let end = Position::new(partner.row, partner.name_end_col);
252 ed.mutate_edit(Edit::DeleteRange {
253 start,
254 end,
255 kind: MotionKind::Char,
256 });
257 ed.mutate_edit(Edit::InsertStr {
258 at: start,
259 text: anchor.name.clone(),
260 });
261 buf_set_cursor_rc(ed.buffer_mut(), row, col);
264 ed.push_buffer_cursor_to_textarea();
265}
266pub fn matching_tag_pair(
272 buffer: &hjkl_buffer::View,
273 row: usize,
274 col: usize,
275) -> Option<[(usize, usize, usize); 2]> {
276 let line = buf_line(buffer, row)?;
277 let anchor = detect_tag_at_cursor(&line, row, col)?;
278 let partner = find_matching_tag(buffer, &anchor)?;
279 Some([
280 (anchor.row, anchor.name_start_col, anchor.name_end_col),
281 (partner.row, partner.name_start_col, partner.name_end_col),
282 ])
283}
284pub fn is_void_element(tag: &str) -> bool {
286 matches!(
287 tag.to_ascii_lowercase().as_str(),
288 "area"
289 | "base"
290 | "br"
291 | "col"
292 | "embed"
293 | "hr"
294 | "img"
295 | "input"
296 | "link"
297 | "meta"
298 | "param"
299 | "source"
300 | "track"
301 | "wbr"
302 )
303}
304pub fn scan_tag_opener(line: &str, col: usize) -> Option<String> {
314 let before = if col > 0 { &line[..col] } else { return None };
317
318 let lt_pos = before.rfind('<')?;
320 let inner = &before[lt_pos + 1..]; if inner.starts_with('!') {
324 return None;
325 }
326 if inner.trim_end().ends_with('/') {
328 return None;
329 }
330
331 let tag: String = inner
333 .chars()
334 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
335 .collect();
336 if tag.is_empty() {
337 return None;
338 }
339 if !tag
341 .chars()
342 .next()
343 .map(|c| c.is_ascii_alphabetic())
344 .unwrap_or(false)
345 {
346 return None;
347 }
348 if is_void_element(&tag) {
349 return None;
350 }
351 Some(tag)
352}
353
354pub fn is_html_filetype(ft: &str) -> bool {
356 matches!(
357 ft,
358 "html" | "xml" | "svg" | "jsx" | "tsx" | "vue" | "svelte"
359 )
360}