pub(super) fn auto_pair_closer(c: char) -> Option<char> {
match c {
'(' => Some(')'),
'[' => Some(']'),
'{' => Some('}'),
'"' => Some('"'),
'\'' => Some('\''),
'`' => Some('`'),
_ => None,
}
}
pub(super) fn is_auto_pair_closer(c: char) -> bool {
matches!(c, ')' | ']' | '}' | '"' | '\'' | '`')
}
pub(super) fn should_auto_pair(opener: char, prev: Option<char>, next: Option<char>) -> bool {
if let Some(n) = next
&& (n.is_alphanumeric() || n == '_')
{
return false;
}
if matches!(opener, '"' | '\'' | '`')
&& let Some(p) = prev
&& (p.is_alphanumeric() || p == '_' || p == opener)
{
return false;
}
true
}
pub(super) fn supports_tag_autopair(ext: Option<&str>) -> bool {
matches!(
ext,
Some("tsx" | "jsx" | "html" | "htm" | "xml" | "vue" | "svelte" | "astro" | "md" | "mdx")
)
}
pub(super) fn supports_fragment(ext: Option<&str>) -> bool {
matches!(ext, Some("tsx" | "jsx"))
}
fn is_html_void(name: &str) -> bool {
matches!(
name,
"area"
| "base"
| "br"
| "col"
| "embed"
| "hr"
| "img"
| "input"
| "link"
| "meta"
| "param"
| "source"
| "track"
| "wbr"
)
}
pub(super) enum TagAutopair {
Close(String),
}
pub(super) fn detect_open_tag(
line: &str,
cursor_col: usize,
ext: Option<&str>,
) -> Option<TagAutopair> {
if !supports_tag_autopair(ext) {
return None;
}
if cursor_col == 0 {
return None;
}
let chars: Vec<char> = line.chars().collect();
if cursor_col > chars.len() {
return None;
}
if chars[cursor_col - 1] == '/' {
return None;
}
let mut in_str: Option<char> = None;
let mut i = cursor_col;
let open_idx;
loop {
if i == 0 {
return None;
}
i -= 1;
let c = chars[i];
if let Some(q) = in_str {
if c == q {
in_str = None;
}
continue;
}
match c {
'"' | '\'' => in_str = Some(c),
'>' => return None,
'<' => {
open_idx = i;
break;
}
_ => {}
}
}
let after_open = open_idx + 1;
if let Some(&next_c) = chars.get(after_open)
&& matches!(next_c, '/' | '!' | '?')
{
return None;
}
let mut j = after_open;
let mut name = String::new();
while j < cursor_col {
let ch = chars[j];
if name.is_empty() {
if ch.is_alphabetic() || ch == '_' {
name.push(ch);
j += 1;
} else {
break;
}
} else if ch.is_alphanumeric() || matches!(ch, '_' | '-' | '.' | ':') {
name.push(ch);
j += 1;
} else {
break;
}
}
if name.is_empty() {
if after_open == cursor_col && supports_fragment(ext) {
return Some(TagAutopair::Close(String::new()));
}
return None;
}
if matches!(ext, Some("html") | Some("htm") | Some("xml"))
&& is_html_void(&name.to_ascii_lowercase())
{
return None;
}
Some(TagAutopair::Close(name))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_jsx_simple_tag() {
let line = "<div";
match detect_open_tag(line, line.chars().count(), Some("tsx")) {
Some(TagAutopair::Close(n)) => assert_eq!(n, "div"),
_ => panic!("expected Close(div)"),
}
}
#[test]
fn detects_jsx_with_attributes() {
let line = "<Button onClick={x} class=\"y\"";
match detect_open_tag(line, line.chars().count(), Some("tsx")) {
Some(TagAutopair::Close(n)) => assert_eq!(n, "Button"),
_ => panic!("expected Close(Button)"),
}
}
#[test]
fn detects_dotted_member_name() {
let line = "<Foo.Bar prop";
match detect_open_tag(line, line.chars().count(), Some("tsx")) {
Some(TagAutopair::Close(n)) => assert_eq!(n, "Foo.Bar"),
_ => panic!("expected Close(Foo.Bar)"),
}
}
#[test]
fn fragment_in_tsx() {
let line = "<";
match detect_open_tag(line, line.chars().count(), Some("tsx")) {
Some(TagAutopair::Close(n)) => assert!(n.is_empty()),
_ => panic!("expected fragment"),
}
}
#[test]
fn fragment_not_in_html() {
let line = "<";
assert!(detect_open_tag(line, line.chars().count(), Some("html")).is_none());
}
#[test]
fn self_closing_skipped() {
let line = "<img src=\"a\" /";
assert!(detect_open_tag(line, line.chars().count(), Some("tsx")).is_none());
}
#[test]
fn closing_tag_skipped() {
let line = "</div";
assert!(detect_open_tag(line, line.chars().count(), Some("tsx")).is_none());
}
#[test]
fn comment_open_skipped() {
let line = "<!-- hi --";
assert!(detect_open_tag(line, line.chars().count(), Some("html")).is_none());
}
#[test]
fn already_closed_tag_skipped() {
let line = "<div>foo";
assert!(detect_open_tag(line, line.chars().count(), Some("tsx")).is_none());
}
#[test]
fn quoted_lt_inside_attr_ignored() {
let line = "<input value=\"a<b\"";
if detect_open_tag(line, line.chars().count(), Some("html")).is_some() {
panic!("`input` is a void element — should not auto-close");
}
}
#[test]
fn html_void_element_skipped() {
let line = "<br";
assert!(detect_open_tag(line, line.chars().count(), Some("html")).is_none());
}
#[test]
fn html_void_element_still_closed_in_jsx() {
let line = "<br";
match detect_open_tag(line, line.chars().count(), Some("tsx")) {
Some(TagAutopair::Close(n)) => assert_eq!(n, "br"),
_ => panic!("expected Close(br)"),
}
}
#[test]
fn unsupported_ext_returns_none() {
let line = "<div";
assert!(detect_open_tag(line, line.chars().count(), Some("rs")).is_none());
assert!(detect_open_tag(line, line.chars().count(), None).is_none());
}
}