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 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    ed.push_buffer_cursor_to_textarea();
265}
266/// Resolve the HTML/XML tag-name pair under the cursor for matchparen-style
267/// highlight (#243). Returns `[(row, name_start_col, name_end_col); 2]` for
268/// the tag under the cursor and its structural partner, or `None` when the
269/// cursor is not on a tag name or the tag is unpaired. Char-column ranges
270/// (display), consistent with `motions::matching_bracket_pos`.
271pub 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}
284/// Void HTML elements that must never get an auto-close tag.
285pub 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}
304/// Scan backward from `col` (exclusive) in `line` for a `<tagname…` opener.
305///
306/// Returns `Some(tag_name)` when:
307/// - An opening `<` is found
308/// - The tag name matches `[A-Za-z][A-Za-z0-9-]*`
309/// - The tag is not self-closing (does not end with `/` before `>`)
310/// - The tag is not a void element
311///
312/// Returns `None` otherwise (no opener, self-closing, void, or malformed).
313pub fn scan_tag_opener(line: &str, col: usize) -> Option<String> {
314    // col is where `>` was just inserted (the char is already in the line).
315    // We look at the slice BEFORE the `>`.
316    let before = if col > 0 { &line[..col] } else { return None };
317
318    // Walk backward to find the matching `<`.
319    let lt_pos = before.rfind('<')?;
320    let inner = &before[lt_pos + 1..]; // e.g. "div class=\"foo\""
321
322    // A `!` opener is a comment/doctype — skip.
323    if inner.starts_with('!') {
324        return None;
325    }
326    // Self-closing if the last non-space char before `>` was `/`.
327    if inner.trim_end().ends_with('/') {
328        return None;
329    }
330
331    // Extract tag name: first token of `inner`.
332    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    // First char must be a letter.
340    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
354/// Filetypes that get HTML/XML-family treatment (`<` pairing + tag autoclose).
355pub fn is_html_filetype(ft: &str) -> bool {
356    matches!(
357        ft,
358        "html" | "xml" | "svg" | "jsx" | "tsx" | "vue" | "svelte"
359    )
360}