summaryrefslogtreecommitdiff
path: root/src/diagnostics
diff options
context:
space:
mode:
authorMica White <botahamec@outlook.com>2026-07-12 19:49:14 -0400
committerMica White <botahamec@outlook.com>2026-07-12 19:49:14 -0400
commit8399983fd45bc0f11eac9def280c0833ffb93c04 (patch)
treef84173ca30a3c62db7abb92a0d869ff5d5199c51 /src/diagnostics
initial commitHEADmain
Diffstat (limited to 'src/diagnostics')
-rw-r--r--src/diagnostics/duplicate_attribute_names.rs41
-rw-r--r--src/diagnostics/empty_tag_name.rs32
-rw-r--r--src/diagnostics/end_tag_with_attributes.rs34
-rw-r--r--src/diagnostics/forbidden_top_level_tag.rs42
-rw-r--r--src/diagnostics/html_tag.rs34
-rw-r--r--src/diagnostics/image_tag.rs34
-rw-r--r--src/diagnostics/non_alphabetic_attribute_name.rs37
-rw-r--r--src/diagnostics/non_alphabetic_tag_name.rs38
-rw-r--r--src/diagnostics/non_self_closing_void_tag.rs35
-rw-r--r--src/diagnostics/non_void_self_closing_tag.rs35
-rw-r--r--src/diagnostics/self_closing_end_tag.rs37
-rw-r--r--src/diagnostics/stray_end_tag.rs22
-rw-r--r--src/diagnostics/stray_equal_sign.rs22
-rw-r--r--src/diagnostics/too_many_equal_signs.rs35
-rw-r--r--src/diagnostics/top_level_character_data.rs41
-rw-r--r--src/diagnostics/unclosed_start_tag.rs24
-rw-r--r--src/diagnostics/unterminated_comment.rs31
-rw-r--r--src/diagnostics/unterminated_tag.rs33
18 files changed, 607 insertions, 0 deletions
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<Span>,
+}
+
+impl AstDiagnostic for DuplicateAttributeNames {
+ fn handle_element(element: &Element<'_>) -> Option<Self> {
+ 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<Self> {
+ 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<Self> {
+ 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<Span>,
+}
+
+impl AstDiagnostic for ForbiddenTopLevelTag {
+ fn handle_document(document: &crate::parse::Document<'_>) -> Option<Self> {
+ 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::<Vec<_>>();
+
+ 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<Self> {
+ 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<Self> {
+ 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<Self> {
+ 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<Self> {
+ 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<Self> {
+ 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<Self> {
+ 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<Self> {
+ 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<Self> {
+ 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<Self> {
+ 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<Self> {
+ 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<Span>,
+}
+
+impl AstDiagnostic for TopLevelCharacterData {
+ fn handle_document(document: &crate::parse::Document<'_>) -> Option<Self> {
+ 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::<Vec<_>>();
+
+ 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<Self> {
+ 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<Self> {
+ 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<Self> {
+ let TokenType::Tag {
+ greater_than_exists,
+ ..
+ } = token.ty
+ else {
+ return None;
+ };
+
+ if greater_than_exists {
+ return None;
+ }
+
+ Some(Self { tag: token.span })
+ }
+}