use crate::Editor;
use crate::buf_helpers::{buf_line, buf_set_cursor_rc};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TagKind {
Open,
Close,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TagSpan {
kind: TagKind,
name: String,
row: usize,
name_start_col: usize,
name_end_col: usize,
}
pub fn detect_tag_at_cursor(line: &str, row: usize, col: usize) -> Option<TagSpan> {
let chars: Vec<char> = line.chars().collect();
let mut lt = None;
let mut i = col.min(chars.len());
while i > 0 {
i -= 1;
let c = chars[i];
if c == '<' {
lt = Some(i);
break;
}
if c == '>' {
return None;
}
}
let lt = lt?;
let (kind, name_start) = if chars.get(lt + 1) == Some(&'/') {
(TagKind::Close, lt + 2)
} else {
(TagKind::Open, lt + 1)
};
let first = chars.get(name_start)?;
if !first.is_ascii_alphabetic() {
return None;
}
let mut name_end = name_start;
while name_end < chars.len()
&& (chars[name_end].is_ascii_alphanumeric() || chars[name_end] == '-')
{
name_end += 1;
}
if col < name_start || col > name_end {
return None;
}
let name: String = chars[name_start..name_end].iter().collect();
Some(TagSpan {
kind,
name,
row,
name_start_col: name_start,
name_end_col: name_end,
})
}
pub fn find_matching_tag(buffer: &hjkl_buffer::Buffer, anchor: &TagSpan) -> Option<TagSpan> {
let row_count = buffer.row_count();
let scan_forward = anchor.kind == TagKind::Open;
let row_iter: Box<dyn Iterator<Item = usize>> = if scan_forward {
Box::new(anchor.row..row_count)
} else {
Box::new((0..=anchor.row).rev())
};
let push_kind = if scan_forward {
TagKind::Open
} else {
TagKind::Close
};
let mut depth: usize = 1;
for r in row_iter {
let line = buf_line(buffer, r)?;
let chars: Vec<char> = line.chars().collect();
let tags = scan_line_tags(&chars, r);
let tags_iter: Box<dyn Iterator<Item = TagSpan>> = if scan_forward {
Box::new(tags.into_iter())
} else {
Box::new(tags.into_iter().rev())
};
for tag in tags_iter {
if r == anchor.row
&& tag.name_start_col == anchor.name_start_col
&& tag.kind == anchor.kind
{
continue;
}
if r == anchor.row {
if scan_forward && tag.name_start_col < anchor.name_start_col {
continue;
}
if !scan_forward && tag.name_start_col > anchor.name_start_col {
continue;
}
}
if tag.kind == push_kind {
depth += 1;
} else {
depth -= 1;
if depth == 0 {
return Some(tag);
}
}
}
}
None
}
pub fn scan_line_tags(chars: &[char], row: usize) -> Vec<TagSpan> {
let mut out = Vec::new();
let n = chars.len();
let mut i = 0;
while i < n {
if chars[i] != '<' {
i += 1;
continue;
}
if chars[i..].starts_with(&['<', '!', '-', '-']) {
let mut j = i + 4;
while j + 2 < n && !(chars[j] == '-' && chars[j + 1] == '-' && chars[j + 2] == '>') {
j += 1;
}
i = (j + 3).min(n);
continue;
}
let (kind, name_start) = if chars.get(i + 1) == Some(&'/') {
(TagKind::Close, i + 2)
} else {
(TagKind::Open, i + 1)
};
if chars
.get(name_start)
.is_none_or(|c| !c.is_ascii_alphabetic())
{
i += 1;
continue;
}
let mut name_end = name_start;
while name_end < n && (chars[name_end].is_ascii_alphanumeric() || chars[name_end] == '-') {
name_end += 1;
}
let mut k = name_end;
let mut self_closing = false;
while k < n {
if chars[k] == '>' {
if k > name_end && chars[k - 1] == '/' {
self_closing = true;
}
break;
}
k += 1;
}
if k >= n {
break;
}
let name: String = chars[name_start..name_end].iter().collect();
if !(self_closing || kind == TagKind::Open && is_void_element(&name)) {
out.push(TagSpan {
kind,
name,
row,
name_start_col: name_start,
name_end_col: name_end,
});
}
i = k + 1;
}
out
}
pub fn sync_paired_tag_on_exit<H: crate::types::Host>(ed: &mut Editor<hjkl_buffer::Buffer, H>) {
if !is_html_filetype(&ed.settings().filetype) {
return;
}
let (row, col) = ed.cursor();
let line = match buf_line(ed.buffer(), row) {
Some(l) => l,
None => return,
};
let anchor = match detect_tag_at_cursor(&line, row, col) {
Some(t) => t,
None => return,
};
let partner = match find_matching_tag(ed.buffer(), &anchor) {
Some(t) => t,
None => return,
};
if partner.name == anchor.name {
return;
}
use hjkl_buffer::{Edit, MotionKind, Position};
let start = Position::new(partner.row, partner.name_start_col);
let end = Position::new(partner.row, partner.name_end_col);
ed.mutate_edit(Edit::DeleteRange {
start,
end,
kind: MotionKind::Char,
});
ed.mutate_edit(Edit::InsertStr {
at: start,
text: anchor.name.clone(),
});
buf_set_cursor_rc(ed.buffer_mut(), row, col);
ed.push_buffer_cursor_to_textarea();
}
pub fn matching_tag_pair(
buffer: &hjkl_buffer::Buffer,
row: usize,
col: usize,
) -> Option<[(usize, usize, usize); 2]> {
let line = buf_line(buffer, row)?;
let anchor = detect_tag_at_cursor(&line, row, col)?;
let partner = find_matching_tag(buffer, &anchor)?;
Some([
(anchor.row, anchor.name_start_col, anchor.name_end_col),
(partner.row, partner.name_start_col, partner.name_end_col),
])
}
pub fn is_void_element(tag: &str) -> bool {
matches!(
tag.to_ascii_lowercase().as_str(),
"area"
| "base"
| "br"
| "col"
| "embed"
| "hr"
| "img"
| "input"
| "link"
| "meta"
| "param"
| "source"
| "track"
| "wbr"
)
}
pub fn scan_tag_opener(line: &str, col: usize) -> Option<String> {
let before = if col > 0 { &line[..col] } else { return None };
let lt_pos = before.rfind('<')?;
let inner = &before[lt_pos + 1..];
if inner.starts_with('!') {
return None;
}
if inner.trim_end().ends_with('/') {
return None;
}
let tag: String = inner
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect();
if tag.is_empty() {
return None;
}
if !tag
.chars()
.next()
.map(|c| c.is_ascii_alphabetic())
.unwrap_or(false)
{
return None;
}
if is_void_element(&tag) {
return None;
}
Some(tag)
}
pub fn is_html_filetype(ft: &str) -> bool {
matches!(
ft,
"html" | "xml" | "svg" | "jsx" | "tsx" | "vue" | "svelte"
)
}