From 8399983fd45bc0f11eac9def280c0833ffb93c04 Mon Sep 17 00:00:00 2001 From: Mica White Date: Sun, 12 Jul 2026 19:49:14 -0400 Subject: initial commit --- src/diagnostics/duplicate_attribute_names.rs | 41 +++++++++++++++++++++++ src/diagnostics/empty_tag_name.rs | 32 ++++++++++++++++++ src/diagnostics/end_tag_with_attributes.rs | 34 +++++++++++++++++++ src/diagnostics/forbidden_top_level_tag.rs | 42 ++++++++++++++++++++++++ src/diagnostics/html_tag.rs | 34 +++++++++++++++++++ src/diagnostics/image_tag.rs | 34 +++++++++++++++++++ src/diagnostics/non_alphabetic_attribute_name.rs | 37 +++++++++++++++++++++ src/diagnostics/non_alphabetic_tag_name.rs | 38 +++++++++++++++++++++ src/diagnostics/non_self_closing_void_tag.rs | 35 ++++++++++++++++++++ src/diagnostics/non_void_self_closing_tag.rs | 35 ++++++++++++++++++++ src/diagnostics/self_closing_end_tag.rs | 37 +++++++++++++++++++++ src/diagnostics/stray_end_tag.rs | 22 +++++++++++++ src/diagnostics/stray_equal_sign.rs | 22 +++++++++++++ src/diagnostics/too_many_equal_signs.rs | 35 ++++++++++++++++++++ src/diagnostics/top_level_character_data.rs | 41 +++++++++++++++++++++++ src/diagnostics/unclosed_start_tag.rs | 24 ++++++++++++++ src/diagnostics/unterminated_comment.rs | 31 +++++++++++++++++ src/diagnostics/unterminated_tag.rs | 33 +++++++++++++++++++ 18 files changed, 607 insertions(+) create mode 100644 src/diagnostics/duplicate_attribute_names.rs create mode 100644 src/diagnostics/empty_tag_name.rs create mode 100644 src/diagnostics/end_tag_with_attributes.rs create mode 100644 src/diagnostics/forbidden_top_level_tag.rs create mode 100644 src/diagnostics/html_tag.rs create mode 100644 src/diagnostics/image_tag.rs create mode 100644 src/diagnostics/non_alphabetic_attribute_name.rs create mode 100644 src/diagnostics/non_alphabetic_tag_name.rs create mode 100644 src/diagnostics/non_self_closing_void_tag.rs create mode 100644 src/diagnostics/non_void_self_closing_tag.rs create mode 100644 src/diagnostics/self_closing_end_tag.rs create mode 100644 src/diagnostics/stray_end_tag.rs create mode 100644 src/diagnostics/stray_equal_sign.rs create mode 100644 src/diagnostics/too_many_equal_signs.rs create mode 100644 src/diagnostics/top_level_character_data.rs create mode 100644 src/diagnostics/unclosed_start_tag.rs create mode 100644 src/diagnostics/unterminated_comment.rs create mode 100644 src/diagnostics/unterminated_tag.rs (limited to 'src/diagnostics') diff --git a/src/diagnostics/duplicate_attribute_names.rs b/src/diagnostics/duplicate_attribute_names.rs new file mode 100644 index 0000000..542d8ec --- /dev/null +++ b/src/diagnostics/duplicate_attribute_names.rs @@ -0,0 +1,41 @@ +use std::collections::HashMap; + +use miette::Diagnostic; +use thiserror::Error; + +use crate::{ + diagnostics::AstDiagnostic, + parse::{Attribute, Element}, + tokenize::Span, +}; + +#[derive(Error, Debug, Diagnostic)] +#[error("Multiple attributes have the same name")] +#[diagnostic(code(DuplicateAttributeNames), severity(Error))] +pub struct DuplicateAttributeNames { + #[label(collection)] + duplicates: Vec, +} + +impl AstDiagnostic for DuplicateAttributeNames { + fn handle_element(element: &Element<'_>) -> Option { + let mut seen_attributes = HashMap::<&str, Attribute<'_>>::new(); + for attribute in element.attributes { + let Some(name) = attribute.name_lowercase() else { + continue; + }; + if let Some(duplicate) = seen_attributes.get(name) { + return Some(Self { + duplicates: vec![ + duplicate.name_token().expect("a named attribute").span, + attribute.name_token().expect("a named attribute").span, + ], + }); + } + + seen_attributes.insert(name, *attribute); + } + + None + } +} diff --git a/src/diagnostics/empty_tag_name.rs b/src/diagnostics/empty_tag_name.rs new file mode 100644 index 0000000..e8fdb07 --- /dev/null +++ b/src/diagnostics/empty_tag_name.rs @@ -0,0 +1,32 @@ +use miette::Diagnostic; +use thiserror::Error; + +use crate::{ + diagnostics::TokenDiagnostic, + tokenize::{Span, Token, TokenType}, +}; + +#[derive(Error, Debug, Diagnostic)] +#[error("Tag names must be alphabetic")] +#[diagnostic(code(EmptyTagName), severity(Error))] +pub struct EmptyTagName { + #[label(primary, "all tags must have a name")] + tag: Span, +} + +impl TokenDiagnostic for EmptyTagName { + fn handle_token(token: &Token<'_>) -> Option { + let TokenType::Tag { + lowercase_tag_name, .. + } = token.ty + else { + return None; + }; + + if !lowercase_tag_name.is_empty() { + return None; + } + + Some(Self { tag: token.span }) + } +} diff --git a/src/diagnostics/end_tag_with_attributes.rs b/src/diagnostics/end_tag_with_attributes.rs new file mode 100644 index 0000000..0631fb6 --- /dev/null +++ b/src/diagnostics/end_tag_with_attributes.rs @@ -0,0 +1,34 @@ +use miette::Diagnostic; +use thiserror::Error; + +use crate::{ + diagnostics::TokenDiagnostic, + tokenize::{Span, TokenType}, +}; + +#[derive(Error, Debug, Diagnostic)] +#[error("End tags cannot have attributes")] +#[diagnostic(code(EndTagWithAttributes), severity(Error))] +pub struct EndTagWithAttributes { + #[label(primary, "attributes are invalid for end tags")] + tag: Span, +} + +impl TokenDiagnostic for EndTagWithAttributes { + fn handle_token(token: &crate::tokenize::Token<'_>) -> Option { + let TokenType::Tag { + is_end, + attribute_tokens, + .. + } = token.ty + else { + return None; + }; + + if !is_end || attribute_tokens.is_empty() { + return None; + } + + Some(Self { tag: token.span }) + } +} diff --git a/src/diagnostics/forbidden_top_level_tag.rs b/src/diagnostics/forbidden_top_level_tag.rs new file mode 100644 index 0000000..8b5301e --- /dev/null +++ b/src/diagnostics/forbidden_top_level_tag.rs @@ -0,0 +1,42 @@ +use miette::Diagnostic; +use thiserror::Error; + +use crate::{ + diagnostics::{ALLOWED_TOP_LEVEL_TAGS, AstDiagnostic}, + parse::Node, + tokenize::Span, +}; + +#[derive(Error, Debug, Diagnostic)] +#[error("Forbidden top-level tag is present")] +#[help("Permitted top level tags are: import, const, block, head, and body")] +#[diagnostic(code(TopLevelCharacterData), severity(Error))] +pub struct ForbiddenTopLevelTag { + #[label(collection, "These tags are not permitted at the top level in kuht")] + nodes: Vec, +} + +impl AstDiagnostic for ForbiddenTopLevelTag { + fn handle_document(document: &crate::parse::Document<'_>) -> Option { + let bad_nodes = document + .children + .iter() + .filter_map(|node| { + if let Node::Element(element) = node + && let Some(tag_name) = element.tag_name_lowercase() + && !ALLOWED_TOP_LEVEL_TAGS.contains(&tag_name) + { + Some(element.start_tag.span) + } else { + None + } + }) + .collect::>(); + + if bad_nodes.is_empty() { + return None; + } + + Some(Self { nodes: bad_nodes }) + } +} diff --git a/src/diagnostics/html_tag.rs b/src/diagnostics/html_tag.rs new file mode 100644 index 0000000..8cf7eb0 --- /dev/null +++ b/src/diagnostics/html_tag.rs @@ -0,0 +1,34 @@ +use miette::Diagnostic; +use thiserror::Error; + +use crate::{ + diagnostics::AstDiagnostic, + tokenize::{Span, TokenType}, +}; + +#[derive(Error, Debug, Diagnostic)] +#[error("html tags is not allowed in kuht")] +#[diagnostic(code(HtmlTag), severity(Error))] +pub struct HtmlTag { + #[label(primary, "invalid html tag")] + tag: Span, +} + +impl AstDiagnostic for HtmlTag { + fn handle_element(element: &crate::parse::Element<'_>) -> Option { + let TokenType::Tag { + lowercase_tag_name, .. + } = element.start_tag.ty + else { + return None; + }; + + if lowercase_tag_name != "html" { + return None; + } + + Some(Self { + tag: element.start_tag.span, + }) + } +} diff --git a/src/diagnostics/image_tag.rs b/src/diagnostics/image_tag.rs new file mode 100644 index 0000000..dc5a633 --- /dev/null +++ b/src/diagnostics/image_tag.rs @@ -0,0 +1,34 @@ +use miette::Diagnostic; +use thiserror::Error; + +use crate::{ + diagnostics::AstDiagnostic, + tokenize::{Span, TokenType}, +}; + +#[derive(Error, Debug, Diagnostic)] +#[error("image tags is not allowed in kuht")] +#[diagnostic(code(ImageTag), severity(Error))] +pub struct ImageTag { + #[label(primary, "invalid image tag")] + tag: Span, +} + +impl AstDiagnostic for ImageTag { + fn handle_element(element: &crate::parse::Element<'_>) -> Option { + let TokenType::Tag { + lowercase_tag_name, .. + } = element.start_tag.ty + else { + return None; + }; + + if lowercase_tag_name != "image" { + return None; + } + + Some(Self { + tag: element.start_tag.span, + }) + } +} diff --git a/src/diagnostics/non_alphabetic_attribute_name.rs b/src/diagnostics/non_alphabetic_attribute_name.rs new file mode 100644 index 0000000..1cb31d9 --- /dev/null +++ b/src/diagnostics/non_alphabetic_attribute_name.rs @@ -0,0 +1,37 @@ +use miette::Diagnostic; +use thiserror::Error; + +use crate::{ + diagnostics::TokenDiagnostic, + tokenize::{AttributeToken, AttributeTokenType, Span}, +}; + +#[derive(Error, Debug, Diagnostic)] +#[error("Attribute names must be alphabetic")] +#[diagnostic(code(NonAlphabeticAttributeName), severity(Error))] +pub struct NonAlphabeticAttributeName { + #[label( + primary, + "attribute names must only contain uppercase and lowercase letters" + )] + attribute_name: Span, +} + +impl TokenDiagnostic for NonAlphabeticAttributeName { + fn handle_attribute_token(token: &AttributeToken<'_>) -> Option { + let AttributeTokenType::Identifier { lowercase_name, .. } = token.ty else { + return None; + }; + + if lowercase_name + .chars() + .all(|char| char.is_ascii_alphabetic()) + { + return None; + } + + Some(Self { + attribute_name: token.span, + }) + } +} diff --git a/src/diagnostics/non_alphabetic_tag_name.rs b/src/diagnostics/non_alphabetic_tag_name.rs new file mode 100644 index 0000000..b36cb75 --- /dev/null +++ b/src/diagnostics/non_alphabetic_tag_name.rs @@ -0,0 +1,38 @@ +use miette::Diagnostic; +use thiserror::Error; + +use crate::{ + diagnostics::TokenDiagnostic, + tokenize::{Span, Token, TokenType}, +}; + +#[derive(Error, Debug, Diagnostic)] +#[error("Tag names must be alphabetic")] +#[diagnostic(code(NonAlphabeticTagName), severity(Error))] +pub struct NonAlphabeticTagName { + #[label(primary, "tag names must only contain uppercase and lowercase letters")] + tag: Span, +} + +impl TokenDiagnostic for NonAlphabeticTagName { + fn handle_token(token: &Token<'_>) -> Option { + let TokenType::Tag { + is_end, + lowercase_tag_name, + .. + } = token.ty + else { + return None; + }; + + if is_end + || lowercase_tag_name + .chars() + .all(|char| char.is_ascii_alphabetic()) + { + return None; + } + + Some(Self { tag: token.span }) + } +} diff --git a/src/diagnostics/non_self_closing_void_tag.rs b/src/diagnostics/non_self_closing_void_tag.rs new file mode 100644 index 0000000..bfff266 --- /dev/null +++ b/src/diagnostics/non_self_closing_void_tag.rs @@ -0,0 +1,35 @@ +use miette::Diagnostic; +use thiserror::Error; + +use crate::{ + diagnostics::{TokenDiagnostic, VOID_TAGS}, + tokenize::{Span, Token, TokenType}, +}; + +#[derive(Error, Debug, Diagnostic)] +#[error("Void tags must be self-closed")] +#[diagnostic(code(NonSelfClosingVoidTag), severity(Error))] +pub struct NonSelfClosingVoidTag { + #[label(primary, "this is a void tag, which must be self-closed")] + tag: Span, +} + +impl TokenDiagnostic for NonSelfClosingVoidTag { + fn handle_token(token: &Token<'_>) -> Option { + let TokenType::Tag { + is_end, + lowercase_tag_name, + self_closing, + .. + } = token.ty + else { + return None; + }; + + if is_end || self_closing || !VOID_TAGS.contains(&lowercase_tag_name) { + return None; + } + + Some(Self { tag: token.span }) + } +} diff --git a/src/diagnostics/non_void_self_closing_tag.rs b/src/diagnostics/non_void_self_closing_tag.rs new file mode 100644 index 0000000..8a64be1 --- /dev/null +++ b/src/diagnostics/non_void_self_closing_tag.rs @@ -0,0 +1,35 @@ +use miette::Diagnostic; +use thiserror::Error; + +use crate::{ + diagnostics::{TokenDiagnostic, VOID_TAGS}, + tokenize::{Span, Token, TokenType}, +}; + +#[derive(Error, Debug, Diagnostic)] +#[error("Non-void tags must not be self-closed")] +#[diagnostic(code(NonVoidSelfClosingTag), severity(Error))] +pub struct NonVoidSelfClosingTag { + #[label(primary, "this is not a void tag and thus cannot be self-closed")] + tag: Span, +} + +impl TokenDiagnostic for NonVoidSelfClosingTag { + fn handle_token(token: &Token<'_>) -> Option { + let TokenType::Tag { + is_end, + lowercase_tag_name, + self_closing, + .. + } = token.ty + else { + return None; + }; + + if is_end || !self_closing || VOID_TAGS.contains(&lowercase_tag_name) { + return None; + } + + Some(Self { tag: token.span }) + } +} diff --git a/src/diagnostics/self_closing_end_tag.rs b/src/diagnostics/self_closing_end_tag.rs new file mode 100644 index 0000000..5b5a680 --- /dev/null +++ b/src/diagnostics/self_closing_end_tag.rs @@ -0,0 +1,37 @@ +use miette::Diagnostic; +use thiserror::Error; + +use crate::{ + diagnostics::TokenDiagnostic, + tokenize::{Span, TokenType}, +}; + +#[derive(Error, Debug, Diagnostic)] +#[error("End tags cannot self-close")] +#[diagnostic(code(SelfClosingEndTag), severity(Error))] +pub struct SelfClosingEndTag { + #[label( + primary, + "this tag has a slash at both the beginning and the end, which is not allowed" + )] + tag: Span, +} + +impl TokenDiagnostic for SelfClosingEndTag { + fn handle_token(token: &crate::tokenize::Token<'_>) -> Option { + let TokenType::Tag { + is_end, + self_closing, + .. + } = token.ty + else { + return None; + }; + + if !is_end || !self_closing { + return None; + } + + Some(Self { tag: token.span }) + } +} diff --git a/src/diagnostics/stray_end_tag.rs b/src/diagnostics/stray_end_tag.rs new file mode 100644 index 0000000..fa4ca07 --- /dev/null +++ b/src/diagnostics/stray_end_tag.rs @@ -0,0 +1,22 @@ +use miette::Diagnostic; +use thiserror::Error; + +use crate::{diagnostics::AstDiagnostic, parse::Node, tokenize::Span}; + +#[derive(Error, Debug, Diagnostic)] +#[error("Stray end tag")] +#[diagnostic(code(StrayEndTag), severity(Error))] +pub struct StrayEndTag { + #[label(primary)] + token: Span, +} + +impl AstDiagnostic for StrayEndTag { + fn handle_node(node: &Node<'_>) -> Option { + let Node::StrayEndTag(token) = node else { + return None; + }; + + Some(Self { token: token.span }) + } +} diff --git a/src/diagnostics/stray_equal_sign.rs b/src/diagnostics/stray_equal_sign.rs new file mode 100644 index 0000000..29674da --- /dev/null +++ b/src/diagnostics/stray_equal_sign.rs @@ -0,0 +1,22 @@ +use miette::Diagnostic; +use thiserror::Error; + +use crate::{diagnostics::AstDiagnostic, parse::Attribute, tokenize::Span}; + +#[derive(Error, Debug, Diagnostic)] +#[error("Stray equal sign found not attached to any attributes")] +#[diagnostic(code(StrayEqualSign), severity(Error))] +pub struct StrayEqualSign { + #[label(primary, "An equal sign is not a valid attribute")] + token: Span, +} + +impl AstDiagnostic for StrayEqualSign { + fn handle_attribute(attribute: &crate::parse::Attribute<'_>) -> Option { + let Attribute::StrayEqualSign(token) = attribute else { + return None; + }; + + Some(Self { token: token.span }) + } +} diff --git a/src/diagnostics/too_many_equal_signs.rs b/src/diagnostics/too_many_equal_signs.rs new file mode 100644 index 0000000..b20cb83 --- /dev/null +++ b/src/diagnostics/too_many_equal_signs.rs @@ -0,0 +1,35 @@ +use miette::Diagnostic; +use thiserror::Error; + +use crate::{diagnostics::AstDiagnostic, parse::Attribute, tokenize::Span}; + +#[derive(Error, Debug, Diagnostic)] +#[error("Multiple equal signs in a row")] +#[diagnostic(code(TooManyEqualSigns), severity(Error))] +pub struct TooManyEqualSigns { + #[label(primary, "only one equal sign should be present")] + token: Span, +} + +impl AstDiagnostic for TooManyEqualSigns { + fn handle_attribute(attribute: &crate::parse::Attribute<'_>) -> Option { + let Attribute::NamedArgumentWithTooManyEqualSigns { equal_signs, .. } = attribute else { + return None; + }; + + Some(Self { + token: Span { + start: equal_signs + .first() + .expect("at least two equal signs") + .span + .start, + end: equal_signs + .last() + .expect("at least two equal signs") + .span + .end, + }, + }) + } +} diff --git a/src/diagnostics/top_level_character_data.rs b/src/diagnostics/top_level_character_data.rs new file mode 100644 index 0000000..5deb595 --- /dev/null +++ b/src/diagnostics/top_level_character_data.rs @@ -0,0 +1,41 @@ +use miette::Diagnostic; +use thiserror::Error; + +use crate::{ + diagnostics::AstDiagnostic, + parse::Node, + tokenize::{Span, TokenType}, +}; + +#[derive(Error, Debug, Diagnostic)] +#[error("Top-level character data is present")] +#[diagnostic(code(TopLevelCharacterData), severity(Error))] +pub struct TopLevelCharacterData { + #[label(collection)] + nodes: Vec, +} + +impl AstDiagnostic for TopLevelCharacterData { + fn handle_document(document: &crate::parse::Document<'_>) -> Option { + let cdata_nodes = document + .children + .iter() + .filter_map(|node| { + if let Node::CharacterData(token) = node + && let TokenType::Data(data) = token.ty + && data.chars().any(|c| !c.is_whitespace()) + { + Some(token.span) + } else { + None + } + }) + .collect::>(); + + if cdata_nodes.is_empty() { + return None; + } + + Some(Self { nodes: cdata_nodes }) + } +} diff --git a/src/diagnostics/unclosed_start_tag.rs b/src/diagnostics/unclosed_start_tag.rs new file mode 100644 index 0000000..db7e3fb --- /dev/null +++ b/src/diagnostics/unclosed_start_tag.rs @@ -0,0 +1,24 @@ +use miette::Diagnostic; +use thiserror::Error; + +use crate::{diagnostics::AstDiagnostic, parse::Element, tokenize::Span}; + +#[derive(Error, Debug, Diagnostic)] +#[error("The start tag was not closed")] +#[diagnostic(code(UnclosedStartTag), severity(Error))] +pub struct UnclosedStartTag { + #[label("no matching end tag found")] + start_tag: Span, +} + +impl AstDiagnostic for UnclosedStartTag { + fn handle_element(element: &Element<'_>) -> Option { + if element.is_self_closing() || element.end_tag.is_some() { + return None; + } + + Some(Self { + start_tag: element.start_tag.span, + }) + } +} diff --git a/src/diagnostics/unterminated_comment.rs b/src/diagnostics/unterminated_comment.rs new file mode 100644 index 0000000..2c583a4 --- /dev/null +++ b/src/diagnostics/unterminated_comment.rs @@ -0,0 +1,31 @@ +use miette::Diagnostic; +use thiserror::Error; + +use crate::{ + diagnostics::TokenDiagnostic, + tokenize::{Span, Token, TokenType}, +}; + +#[derive(Error, Debug, Diagnostic)] +#[error("Unterminated comment")] +#[diagnostic(code(UnterminatedComment), severity(Error))] +pub struct UnterminatedComment { + #[label(primary, "This comment is not terminated by a `-->` sequence")] + comment: Span, +} + +impl TokenDiagnostic for UnterminatedComment { + fn handle_token(token: &Token<'_>) -> Option { + let TokenType::Comment { terminated, .. } = token.ty else { + return None; + }; + + if terminated { + return None; + } + + Some(Self { + comment: token.span, + }) + } +} diff --git a/src/diagnostics/unterminated_tag.rs b/src/diagnostics/unterminated_tag.rs new file mode 100644 index 0000000..cab21f8 --- /dev/null +++ b/src/diagnostics/unterminated_tag.rs @@ -0,0 +1,33 @@ +use miette::Diagnostic; +use thiserror::Error; + +use crate::{ + diagnostics::TokenDiagnostic, + tokenize::{Span, Token, TokenType}, +}; + +#[derive(Error, Debug, Diagnostic)] +#[error("Unterminated tag")] +#[diagnostic(code(UnterminatedTag), severity(Error))] +pub struct UnterminatedTag { + #[label(primary, "This tag is not terminated by a greater-than sign (>)")] + tag: Span, +} + +impl TokenDiagnostic for UnterminatedTag { + fn handle_token(token: &Token<'_>) -> Option { + let TokenType::Tag { + greater_than_exists, + .. + } = token.ty + else { + return None; + }; + + if greater_than_exists { + return None; + } + + Some(Self { tag: token.span }) + } +} -- cgit v1.2.3