hjkl_engine/tag.rs
1//! HTML/XML tag matching — discipline-agnostic buffer substrate.
2//!
3//! Everything here is a pure query over a [`View`](hjkl_buffer::View) (plus
4//! one edit helper that goes through `Editor`): find the tag under the cursor,
5//! find its structural partner, decide whether an element is void. None of it
6//! knows what a mode or an operator is.
7//!
8//! It rode along into `hjkl-vim` when the vim FSM relocated (#267) purely
9//! because that is where the file happened to be. It lives here now so any
10//! discipline — vscode, a future helix/emacs — can match tags without depending
11//! on the vim crate (#265).
12
13use crate::Editor;
14use crate::buf_helpers::{buf_line, buf_set_cursor_rc};
15
16/// Tag kind detected at a cursor position.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum TagKind {
19 Open,
20 Close,
21}
22/// A single tag instance located in the buffer.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct TagSpan {
25 kind: TagKind,
26 name: String,
27 /// Row index in the buffer.
28 row: usize,
29 /// Char-column range of the tag NAME (excluding `<`, `</`, attributes, `>`).
30 name_start_col: usize,
31 name_end_col: usize,
32}
33/// Detect the tag containing `(row, col)` in `line`. Returns the tag kind
34/// (Open / Close), its name, and the char-column range of that name.
35/// Returns `None` when the cursor is not inside a tag-name region.
36pub fn detect_tag_at_cursor(line: &str, row: usize, col: usize) -> Option<TagSpan> {
37 let chars: Vec<char> = line.chars().collect();
38 // Find the nearest `<` at or before the cursor column.
39 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 // Bail if we cross a `>` (we're outside any open tag).
49 if c == '>' {
50 return None;
51 }
52 }
53 let lt = lt?;
54 // Detect close tag (`</`) vs open (`<`).
55 let (kind, name_start) = if chars.get(lt + 1) == Some(&'/') {
56 (TagKind::Close, lt + 2)
57 } else {
58 (TagKind::Open, lt + 1)
59 };
60 // First char of the name must be a letter.
61 let first = chars.get(name_start)?;
62 if !first.is_ascii_alphabetic() {
63 return None;
64 }
65 // Tag name = [A-Za-z][A-Za-z0-9-]*
66 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 // Cursor must be inside the name range, inclusive of both ends: any insert
73 // mode leaves the cursor one past the last typed char, so landing right
74 // after the name must still resolve to it.
75 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}
87/// Scan the buffer to find the structural partner of `anchor` using a
88/// depth counter. Names are intentionally NOT compared during the scan —
89/// the anchor is the source of truth and the partner inherits its name.
90/// Otherwise an in-flight rename (the whole point of this feature) would
91/// look like a malformed pair and bail.
92///
93/// Forward scan from an opener: opens increment depth, closes decrement
94/// depth. The close that brings depth back to zero is the partner.
95/// Backward scan from a closer is symmetric (closes increment, opens
96/// decrement).
97///
98/// Returns `None` when the buffer end is reached before depth hits zero
99/// (orphan tag or malformed input).
100pub 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 // Skip the anchor itself when we walk over its line.
126 if r == anchor.row
127 && tag.name_start_col == anchor.name_start_col
128 && tag.kind == anchor.kind
129 {
130 continue;
131 }
132 // On the anchor's own row, gate by direction relative to anchor
133 // so the scan only inspects tags AFTER the anchor (forward) or
134 // BEFORE the anchor (backward).
135 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}
155/// Collect all tag opens / closes on a single line in left-to-right order.
156/// Skips comments (`<!-- ... -->`) and self-closing tags (`<br />`), and
157/// excludes void HTML elements that don't form a pair.
158pub 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 // `<!--` comment — skip to `-->`.
168 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 // Validate name start.
182 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 // Find the closing `>` to know whether this tag is self-closing.
194 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 // Unterminated tag on this line — bail.
207 break;
208 }
209 let name: String = chars[name_start..name_end].iter().collect();
210 // Skip self-closing and void elements (no pair).
211 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}
224/// If the cursor sits inside an HTML/XML tag name AND the paired tag's name
225/// differs, rewrite the paired tag's name to match. Called from
226/// `leave_insert_to_normal_bridge` so the magical sync fires exactly when
227/// the user finishes editing.
228pub 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 // Rewrite the partner's name range with the anchor's name.
249 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 // Restore the user's cursor — mutate_edit may have moved it during the
262 // partner-side rewrite when the partner is on a row before the cursor.
263 buf_set_cursor_rc(ed.buffer_mut(), row, col);
264}
265/// Resolve the HTML/XML tag-name pair under the cursor for matchparen-style
266/// highlight (#243). Returns `[(row, name_start_col, name_end_col); 2]` for
267/// the tag under the cursor and its structural partner, or `None` when the
268/// cursor is not on a tag name or the tag is unpaired. Char-column ranges
269/// (display), consistent with `motions::matching_bracket_pos`.
270pub fn matching_tag_pair(
271 buffer: &hjkl_buffer::View,
272 row: usize,
273 col: usize,
274) -> Option<[(usize, usize, usize); 2]> {
275 let line = buf_line(buffer, row)?;
276 let anchor = detect_tag_at_cursor(&line, row, col)?;
277 let partner = find_matching_tag(buffer, &anchor)?;
278 Some([
279 (anchor.row, anchor.name_start_col, anchor.name_end_col),
280 (partner.row, partner.name_start_col, partner.name_end_col),
281 ])
282}
283/// Void HTML elements that must never get an auto-close tag.
284pub fn is_void_element(tag: &str) -> bool {
285 matches!(
286 tag.to_ascii_lowercase().as_str(),
287 "area"
288 | "base"
289 | "br"
290 | "col"
291 | "embed"
292 | "hr"
293 | "img"
294 | "input"
295 | "link"
296 | "meta"
297 | "param"
298 | "source"
299 | "track"
300 | "wbr"
301 )
302}
303/// Scan backward from `col` (exclusive) in `line` for a `<tagname…` opener.
304///
305/// Returns `Some(tag_name)` when:
306/// - An opening `<` is found
307/// - The tag name matches `[A-Za-z][A-Za-z0-9-]*`
308/// - The tag is not self-closing (does not end with `/` before `>`)
309/// - The tag is not a void element
310///
311/// Returns `None` otherwise (no opener, self-closing, void, or malformed).
312pub fn scan_tag_opener(line: &str, col: usize) -> Option<String> {
313 // col is where `>` was just inserted (the char is already in the line).
314 // We look at the slice BEFORE the `>`.
315 let before = if col > 0 { &line[..col] } else { return None };
316
317 // Walk backward to find the matching `<`.
318 let lt_pos = before.rfind('<')?;
319 let inner = &before[lt_pos + 1..]; // e.g. "div class=\"foo\""
320
321 // A `!` opener is a comment/doctype — skip.
322 if inner.starts_with('!') {
323 return None;
324 }
325 // Self-closing if the last non-space char before `>` was `/`.
326 if inner.trim_end().ends_with('/') {
327 return None;
328 }
329
330 // Extract tag name: first token of `inner`.
331 let tag: String = inner
332 .chars()
333 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
334 .collect();
335 if tag.is_empty() {
336 return None;
337 }
338 // First char must be a letter.
339 if !tag
340 .chars()
341 .next()
342 .map(|c| c.is_ascii_alphabetic())
343 .unwrap_or(false)
344 {
345 return None;
346 }
347 if is_void_element(&tag) {
348 return None;
349 }
350 Some(tag)
351}
352
353/// Filetypes that get HTML/XML-family treatment (`<` pairing + tag autoclose).
354pub fn is_html_filetype(ft: &str) -> bool {
355 matches!(
356 ft,
357 "html" | "xml" | "svg" | "jsx" | "tsx" | "vue" | "svelte"
358 )
359}