Skip to main content

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 Some(line) = buf_line(ed.buffer(), row) else {
234        return;
235    };
236    let Some(anchor) = detect_tag_at_cursor(&line, row, col) else {
237        return;
238    };
239    let Some(partner) = find_matching_tag(ed.buffer(), &anchor) else {
240        return;
241    };
242    if partner.name == anchor.name {
243        return;
244    }
245    // Rewrite the partner's name range with the anchor's name.
246    use hjkl_buffer::{Edit, MotionKind, Position};
247    let start = Position::new(partner.row, partner.name_start_col);
248    let end = Position::new(partner.row, partner.name_end_col);
249    ed.mutate_edit(Edit::DeleteRange {
250        start,
251        end,
252        kind: MotionKind::Char,
253    });
254    ed.mutate_edit(Edit::InsertStr {
255        at: start,
256        text: anchor.name,
257    });
258    // Restore the user's cursor — mutate_edit may have moved it during the
259    // partner-side rewrite when the partner is on a row before the cursor.
260    buf_set_cursor_rc(ed.buffer_mut(), row, col);
261}
262/// Resolve the HTML/XML tag-name pair under the cursor for matchparen-style
263/// highlight (#243). Returns `[(row, name_start_col, name_end_col); 2]` for
264/// the tag under the cursor and its structural partner, or `None` when the
265/// cursor is not on a tag name or the tag is unpaired. Char-column ranges
266/// (display), consistent with `motions::matching_bracket_pos`.
267pub fn matching_tag_pair(
268    buffer: &hjkl_buffer::View,
269    row: usize,
270    col: usize,
271) -> Option<[(usize, usize, usize); 2]> {
272    let line = buf_line(buffer, row)?;
273    let anchor = detect_tag_at_cursor(&line, row, col)?;
274    let partner = find_matching_tag(buffer, &anchor)?;
275    Some([
276        (anchor.row, anchor.name_start_col, anchor.name_end_col),
277        (partner.row, partner.name_start_col, partner.name_end_col),
278    ])
279}
280/// Void HTML elements that must never get an auto-close tag.
281pub fn is_void_element(tag: &str) -> bool {
282    matches!(
283        tag.to_ascii_lowercase().as_str(),
284        "area"
285            | "base"
286            | "br"
287            | "col"
288            | "embed"
289            | "hr"
290            | "img"
291            | "input"
292            | "link"
293            | "meta"
294            | "param"
295            | "source"
296            | "track"
297            | "wbr"
298    )
299}
300/// Scan backward from `col` (exclusive) in `line` for a `<tagname…` opener.
301///
302/// Returns `Some(tag_name)` when:
303/// - An opening `<` is found
304/// - The tag name matches `[A-Za-z][A-Za-z0-9-]*`
305/// - The tag is not self-closing (does not end with `/` before `>`)
306/// - The tag is not a void element
307///
308/// Returns `None` otherwise (no opener, self-closing, void, or malformed).
309pub fn scan_tag_opener(line: &str, col: usize) -> Option<String> {
310    // col is where `>` was just inserted (the char is already in the line).
311    // We look at the slice BEFORE the `>`.
312    let before = if col > 0 { &line[..col] } else { return None };
313
314    // Walk backward to find the matching `<`.
315    let lt_pos = before.rfind('<')?;
316    let inner = &before[lt_pos + 1..]; // e.g. "div class=\"foo\""
317
318    // A `!` opener is a comment/doctype — skip.
319    if inner.starts_with('!') {
320        return None;
321    }
322    // Self-closing if the last non-space char before `>` was `/`.
323    if inner.trim_end().ends_with('/') {
324        return None;
325    }
326
327    // Extract tag name: first token of `inner`.
328    let tag: String = inner
329        .chars()
330        .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
331        .collect();
332    if tag.is_empty() {
333        return None;
334    }
335    // First char must be a letter.
336    if !tag.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
337        return None;
338    }
339    if is_void_element(&tag) {
340        return None;
341    }
342    Some(tag)
343}
344
345/// Filetypes that get HTML/XML-family treatment (`<` pairing + tag autoclose).
346pub fn is_html_filetype(ft: &str) -> bool {
347    matches!(
348        ft,
349        "html" | "xml" | "svg" | "jsx" | "tsx" | "vue" | "svelte"
350    )
351}