mdream 1.5.1

Fastest HTML-to-Markdown converter. Zero dependencies, streaming support.
Documentation
//! Low-level HTML scanning primitives: whitespace, comments, tag attributes.

use crate::consts::*;
use crate::entities::decode_html_entities;
use crate::types::Attributes;

/// Whitespace check optimized for the hot character loop.
/// Uses a 33-bit bitmap: space(32), CR(13), LF(10), TAB(9).
#[inline(always)]
pub(crate) fn is_whitespace(c: u8) -> bool {
  if c > 32 {
    return false;
  }
  // Bitmap: bit 9 (tab), bit 10 (LF), bit 12 (FF), bit 13 (CR), bit 32 (space)
  const MASK: u64 = (1u64 << 9) | (1u64 << 10) | (1u64 << 12) | (1u64 << 13) | (1u64 << 32);
  (MASK >> c) & 1 == 1
}

pub(crate) struct CommentResult {
  pub(crate) complete: bool,
  pub(crate) new_position: usize,
  /// Start offset into html_chunk for remaining text (only meaningful when !complete)
  pub(crate) remaining_start: usize,
}

pub(crate) fn process_comment_or_doctype(html_chunk: &str, position: usize) -> CommentResult {
  let mut i = position;
  let bytes = html_chunk.as_bytes();
  let chunk_length = bytes.len();

  if i + 3 < chunk_length && bytes[i + 2] == DASH_CHAR && bytes[i + 3] == DASH_CHAR {
    i += 4;
    while i < chunk_length - 2 {
      if bytes[i] == DASH_CHAR && bytes[i + 1] == DASH_CHAR && bytes[i + 2] == GT_CHAR {
        i += 3;
        return CommentResult {
          complete: true,
          new_position: i,
          remaining_start: 0,
        };
      }
      i += 1;
    }
    CommentResult {
      complete: false,
      new_position: position,
      remaining_start: position,
    }
  } else {
    i += 2;
    while i < chunk_length {
      if bytes[i] == GT_CHAR {
        i += 1;
        return CommentResult {
          complete: true,
          new_position: i,
          remaining_start: 0,
        };
      }
      i += 1;
    }
    CommentResult {
      complete: false,
      new_position: i,
      remaining_start: position,
    }
  }
}

pub(crate) fn process_tag_attributes(
  html_chunk: &str,
  position: usize,
  tag_handler: Option<&crate::types::TagHandler>,
  skip_attrs: bool,
) -> (bool, usize, Attributes, bool) {
  let mut i = position;
  let bytes = html_chunk.as_bytes();
  let chunk_length = bytes.len();

  let self_closing = tag_handler.is_some_and(|h| h.is_self_closing);
  let mut inside_quote = false;
  let mut quote_char: u8 = 0;
  let attr_start_pos = i;

  while i < chunk_length {
    let c = bytes[i];

    if inside_quote {
      if c == quote_char {
        inside_quote = false;
      }
      i += 1;
      continue;
    } else if c == QUOTE_CHAR || c == APOS_CHAR {
      inside_quote = true;
      quote_char = c;
    } else if c == SLASH_CHAR && i + 1 < chunk_length && bytes[i + 1] == GT_CHAR {
      let attrs = if skip_attrs {
        Attributes::new()
      } else {
        parse_attributes(html_chunk[attr_start_pos..i].trim())
      };
      return (true, i + 2, attrs, true);
    } else if c == GT_CHAR {
      let attrs = if skip_attrs {
        Attributes::new()
      } else {
        parse_attributes(html_chunk[attr_start_pos..i].trim())
      };
      return (true, i + 1, attrs, self_closing);
    }

    i += 1;
  }

  (false, i, Attributes::new(), false)
}

#[allow(clippy::collapsible_match)]
pub(crate) fn parse_attributes(attr_str: &str) -> Attributes {
  let mut result = Attributes::with_capacity(4);
  if attr_str.is_empty() {
    return result;
  }

  let bytes = attr_str.as_bytes();
  let len = bytes.len();
  let mut i = 0;

  const WHITESPACE: u8 = 0;
  const NAME: u8 = 1;
  const AFTER_NAME: u8 = 2;
  const BEFORE_VALUE: u8 = 3;
  const QUOTED_VALUE: u8 = 4;
  const UNQUOTED_VALUE: u8 = 5;

  let mut state = WHITESPACE;
  let mut name_start = 0;
  let mut name_end;
  let mut value_start = 0;
  let mut quote_char = 0;
  let mut name_start_saved = 0;
  let mut name_end_saved = 0;

  while i < len {
    let char_code = bytes[i];
    let is_space = is_whitespace(char_code);

    match state {
      WHITESPACE => {
        if !is_space {
          state = NAME;
          name_start = i;
        }
      }
      NAME => {
        if char_code == EQUALS_CHAR || is_space {
          name_end = i;
          name_start_saved = name_start;
          name_end_saved = name_end;
          state = if char_code == EQUALS_CHAR {
            BEFORE_VALUE
          } else {
            AFTER_NAME
          };
        }
      }
      AFTER_NAME => {
        if char_code == EQUALS_CHAR {
          state = BEFORE_VALUE;
        } else if !is_space {
          let raw = &attr_str[name_start_saved..name_end_saved];
          // Single-pass lowercase: the result is owned either way,
          // so the uppercase pre-scan would only add a redundant pass.
          let name = raw.to_ascii_lowercase();
          result.insert(name, String::new());
          state = NAME;
          name_start = i;
        }
      }
      BEFORE_VALUE => {
        if !is_space {
          if char_code == QUOTE_CHAR || char_code == APOS_CHAR {
            state = QUOTED_VALUE;
            quote_char = char_code;
            value_start = i + 1;
          } else {
            state = UNQUOTED_VALUE;
            value_start = i;
          }
        }
      }
      QUOTED_VALUE => {
        if char_code == quote_char {
          let raw = &attr_str[name_start_saved..name_end_saved];
          // Single-pass lowercase: the result is owned either way,
          // so the uppercase pre-scan would only add a redundant pass.
          let name = raw.to_ascii_lowercase();
          result.insert(
            name,
            decode_html_entities(&attr_str[value_start..i]).into_owned(),
          );
          state = WHITESPACE;
        }
      }
      UNQUOTED_VALUE => {
        if is_space {
          let raw = &attr_str[name_start_saved..name_end_saved];
          // Single-pass lowercase: the result is owned either way,
          // so the uppercase pre-scan would only add a redundant pass.
          let name = raw.to_ascii_lowercase();
          result.insert(
            name,
            decode_html_entities(&attr_str[value_start..i]).into_owned(),
          );
          state = WHITESPACE;
        }
      }
      _ => {}
    }
    i += 1;
  }

  if state == NAME {
    let raw = &attr_str[name_start..];
    let lc = raw.to_ascii_lowercase();
    result.insert(lc, String::new());
  } else if state == UNQUOTED_VALUE {
    let raw = &attr_str[name_start_saved..name_end_saved];
    let name = raw.to_ascii_lowercase();
    result.insert(
      name,
      decode_html_entities(&attr_str[value_start..]).into_owned(),
    );
  } else if state == AFTER_NAME || state == BEFORE_VALUE {
    let raw = &attr_str[name_start_saved..name_end_saved];
    let name = raw.to_ascii_lowercase();
    result.insert(name, String::new());
  }

  result
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn whitespace_detection() {
    for c in [b' ', b'\t', b'\n', b'\r'] {
      assert!(is_whitespace(c));
    }
    for c in [b'a', b'0', b'-', 0u8] {
      assert!(!is_whitespace(c));
    }
  }

  #[test]
  fn parses_quoted_and_unquoted_attributes() {
    let a = parse_attributes("href=\"/x\" id=main");
    assert_eq!(a.get("href").map(String::as_str), Some("/x"));
    assert_eq!(a.get("id").map(String::as_str), Some("main"));
  }

  #[test]
  fn parses_valueless_and_empty_attributes() {
    let a = parse_attributes("disabled checked");
    assert!(a.contains_key("disabled"));
    assert!(a.contains_key("checked"));
    let empty = parse_attributes("");
    assert!(empty.is_empty());
  }

  #[test]
  fn attribute_names_lowercased_values_decoded() {
    let a = parse_attributes("DATA-X='a &amp; b'");
    assert_eq!(a.get("data-x").map(String::as_str), Some("a & b"));
  }

  #[test]
  fn form_feed_is_whitespace() {
    assert!(is_whitespace(0x0C));
  }

  #[test]
  fn valueless_equals_attribute_kept_as_empty() {
    // `<a href=>` — attribute ends in `name=`, must survive as empty value
    let a = parse_attributes("href=");
    assert!(a.contains_key("href"));
    assert_eq!(a.get("href").map(String::as_str), Some(""));
  }

  #[test]
  fn process_tag_attributes_finds_close() {
    // "<a href=\"x\">" — scan from after the tag name
    let html = "a href=\"x\">rest";
    let (complete, new_pos, attrs, self_closing) = process_tag_attributes(html, 1, None, false);
    assert!(complete);
    assert!(!self_closing);
    assert_eq!(&html[new_pos..], "rest");
    assert_eq!(attrs.get("href").map(String::as_str), Some("x"));
  }
}