mirror of
https://github.com/joshuadavidthomas/django-language-server.git
synced 2025-09-12 05:16:46 +00:00
add TagSpecs and fix parsing of Django tag blocks
This commit is contained in:
parent
59e2665c6f
commit
745b1e40ad
24 changed files with 902 additions and 467 deletions
|
@ -4,8 +4,11 @@ version = "0.0.0"
|
|||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
toml = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
insta = { version = "1.41", features = ["yaml"] }
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::str::FromStr;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
|
@ -37,11 +36,7 @@ pub enum Node {
|
|||
#[derive(Clone, Debug, Serialize)]
|
||||
pub enum DjangoNode {
|
||||
Comment(String),
|
||||
Tag {
|
||||
kind: DjangoTagKind,
|
||||
bits: Vec<String>,
|
||||
children: Vec<Node>,
|
||||
},
|
||||
Tag(TagNode),
|
||||
Variable {
|
||||
bits: Vec<String>,
|
||||
filters: Vec<DjangoFilter>,
|
||||
|
@ -49,109 +44,18 @@ pub enum DjangoNode {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub enum DjangoTagKind {
|
||||
Autoescape,
|
||||
Block,
|
||||
Comment,
|
||||
CsrfToken,
|
||||
Cycle,
|
||||
Debug,
|
||||
Elif,
|
||||
Else,
|
||||
Empty,
|
||||
Extends,
|
||||
Filter,
|
||||
FirstOf,
|
||||
For,
|
||||
If,
|
||||
IfChanged,
|
||||
Include,
|
||||
Load,
|
||||
Lorem,
|
||||
Now,
|
||||
Other(String),
|
||||
Querystring, // 5.1
|
||||
Regroup,
|
||||
ResetCycle,
|
||||
Spaceless,
|
||||
TemplateTag,
|
||||
Url,
|
||||
Verbatim,
|
||||
WidthRatio,
|
||||
With,
|
||||
}
|
||||
|
||||
impl DjangoTagKind {
|
||||
const AUTOESCAPE: &'static str = "autoescape";
|
||||
const BLOCK: &'static str = "block";
|
||||
const COMMENT: &'static str = "comment";
|
||||
const CSRF_TOKEN: &'static str = "csrf_token";
|
||||
const CYCLE: &'static str = "cycle";
|
||||
const DEBUG: &'static str = "debug";
|
||||
const ELIF: &'static str = "elif";
|
||||
const ELSE: &'static str = "else";
|
||||
const EMPTY: &'static str = "empty";
|
||||
const EXTENDS: &'static str = "extends";
|
||||
const FILTER: &'static str = "filter";
|
||||
const FIRST_OF: &'static str = "firstof";
|
||||
const FOR: &'static str = "for";
|
||||
const IF: &'static str = "if";
|
||||
const IF_CHANGED: &'static str = "ifchanged";
|
||||
const INCLUDE: &'static str = "include";
|
||||
const LOAD: &'static str = "load";
|
||||
const LOREM: &'static str = "lorem";
|
||||
const NOW: &'static str = "now";
|
||||
const QUERYSTRING: &'static str = "querystring";
|
||||
const REGROUP: &'static str = "regroup";
|
||||
const RESET_CYCLE: &'static str = "resetcycle";
|
||||
const SPACELESS: &'static str = "spaceless";
|
||||
const TEMPLATE_TAG: &'static str = "templatetag";
|
||||
const URL: &'static str = "url";
|
||||
const VERBATIM: &'static str = "verbatim";
|
||||
const WIDTH_RATIO: &'static str = "widthratio";
|
||||
const WITH: &'static str = "with";
|
||||
}
|
||||
|
||||
impl FromStr for DjangoTagKind {
|
||||
type Err = AstError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
if s.is_empty() {
|
||||
return Err(AstError::EmptyTag);
|
||||
}
|
||||
|
||||
match s {
|
||||
Self::AUTOESCAPE => Ok(Self::Autoescape),
|
||||
Self::BLOCK => Ok(Self::Block),
|
||||
Self::COMMENT => Ok(Self::Comment),
|
||||
Self::CSRF_TOKEN => Ok(Self::CsrfToken),
|
||||
Self::CYCLE => Ok(Self::Cycle),
|
||||
Self::DEBUG => Ok(Self::Debug),
|
||||
Self::ELIF => Ok(Self::Elif),
|
||||
Self::ELSE => Ok(Self::Else),
|
||||
Self::EMPTY => Ok(Self::Empty),
|
||||
Self::EXTENDS => Ok(Self::Extends),
|
||||
Self::FILTER => Ok(Self::Filter),
|
||||
Self::FIRST_OF => Ok(Self::FirstOf),
|
||||
Self::FOR => Ok(Self::For),
|
||||
Self::IF => Ok(Self::If),
|
||||
Self::IF_CHANGED => Ok(Self::IfChanged),
|
||||
Self::INCLUDE => Ok(Self::Include),
|
||||
Self::LOAD => Ok(Self::Load),
|
||||
Self::LOREM => Ok(Self::Lorem),
|
||||
Self::NOW => Ok(Self::Now),
|
||||
Self::QUERYSTRING => Ok(Self::Querystring),
|
||||
Self::REGROUP => Ok(Self::Regroup),
|
||||
Self::RESET_CYCLE => Ok(Self::ResetCycle),
|
||||
Self::SPACELESS => Ok(Self::Spaceless),
|
||||
Self::TEMPLATE_TAG => Ok(Self::TemplateTag),
|
||||
Self::URL => Ok(Self::Url),
|
||||
Self::VERBATIM => Ok(Self::Verbatim),
|
||||
Self::WIDTH_RATIO => Ok(Self::WidthRatio),
|
||||
Self::WITH => Ok(Self::With),
|
||||
other => Ok(Self::Other(other.to_string())),
|
||||
}
|
||||
}
|
||||
pub enum TagNode {
|
||||
Block {
|
||||
name: String,
|
||||
bits: Vec<String>,
|
||||
children: Vec<Node>,
|
||||
},
|
||||
Branching {
|
||||
name: String,
|
||||
bits: Vec<String>,
|
||||
children: Vec<Node>,
|
||||
branches: Vec<TagNode>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
mod ast;
|
||||
mod lexer;
|
||||
mod parser;
|
||||
mod tagspecs;
|
||||
mod tokens;
|
||||
|
||||
pub use ast::Ast;
|
||||
|
|
|
@ -1,41 +1,65 @@
|
|||
use crate::ast::{
|
||||
Ast, AstError, AttributeValue, DjangoFilter, DjangoNode, DjangoTagKind, HtmlNode, Node,
|
||||
ScriptCommentKind, ScriptNode, StyleNode,
|
||||
Ast, AstError, AttributeValue, DjangoFilter, DjangoNode, HtmlNode, Node, ScriptCommentKind,
|
||||
ScriptNode, StyleNode, TagNode,
|
||||
};
|
||||
use crate::tagspecs::TagSpec;
|
||||
use crate::tokens::{Token, TokenStream, TokenType};
|
||||
use std::collections::BTreeMap;
|
||||
use std::str::FromStr;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use thiserror::Error;
|
||||
|
||||
pub struct Parser {
|
||||
tokens: TokenStream,
|
||||
current: usize,
|
||||
specs: HashMap<String, TagSpec>,
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
pub fn new(tokens: TokenStream) -> Self {
|
||||
Parser { tokens, current: 0 }
|
||||
Parser {
|
||||
tokens,
|
||||
current: 0,
|
||||
specs: TagSpec::load_builtin_specs().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(&mut self) -> Result<Ast, ParserError> {
|
||||
let mut ast = Ast::default();
|
||||
let mut had_nodes = false;
|
||||
|
||||
while !self.is_at_end() {
|
||||
match self.next_node() {
|
||||
Ok(node) => {
|
||||
eprintln!("Adding node: {:?}", node);
|
||||
ast.add_node(node);
|
||||
had_nodes = true;
|
||||
}
|
||||
Err(ParserError::StreamError(Stream::AtEnd)) => {
|
||||
if ast.nodes().is_empty() {
|
||||
eprintln!("Stream at end, nodes: {:?}", ast.nodes());
|
||||
if !had_nodes {
|
||||
return Err(ParserError::StreamError(Stream::UnexpectedEof));
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
Err(ParserError::ErrorSignal(Signal::SpecialTag(tag))) => {
|
||||
eprintln!("Got special tag: {}", tag);
|
||||
continue;
|
||||
}
|
||||
Err(ParserError::UnclosedTag(tag)) => {
|
||||
eprintln!("Got unclosed tag: {}", tag);
|
||||
return Err(ParserError::UnclosedTag(tag));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Got error: {:?}", e);
|
||||
self.synchronize()?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("Final nodes: {:?}", ast.nodes());
|
||||
if !had_nodes {
|
||||
return Err(ParserError::StreamError(Stream::UnexpectedEof));
|
||||
}
|
||||
ast.finalize()?;
|
||||
Ok(ast)
|
||||
}
|
||||
|
@ -79,6 +103,7 @@ impl Parser {
|
|||
TokenType::Text(s) => Ok(Node::Text(s.to_string())),
|
||||
TokenType::Whitespace(_) => self.next_node(),
|
||||
}?;
|
||||
eprintln!("{:?}", node);
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
|
@ -134,61 +159,96 @@ impl Parser {
|
|||
}
|
||||
|
||||
fn parse_django_block(&mut self, s: &str) -> Result<Node, ParserError> {
|
||||
eprintln!("Parsing django block: {}", s);
|
||||
let bits: Vec<String> = s.split_whitespace().map(String::from).collect();
|
||||
let kind = DjangoTagKind::from_str(&bits[0])?;
|
||||
let tag_name = bits.first().ok_or(AstError::EmptyTag)?.clone();
|
||||
eprintln!("Tag name: {}", tag_name);
|
||||
|
||||
if bits[0].starts_with("end") {
|
||||
return Err(ParserError::ErrorSignal(Signal::ClosingTagFound(
|
||||
bits[0].clone(),
|
||||
)));
|
||||
eprintln!("Loaded specs: {:?}", self.specs);
|
||||
|
||||
// Check if this is a closing tag according to ANY spec
|
||||
for (_, spec) in self.specs.iter() {
|
||||
if Some(&tag_name) == spec.closing.as_ref() {
|
||||
eprintln!("Found closing tag: {}", tag_name);
|
||||
return Err(ParserError::ErrorSignal(Signal::SpecialTag(tag_name)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut all_children = Vec::new();
|
||||
let mut current_section = Vec::new();
|
||||
let end_tag = format!("end{}", bits[0]);
|
||||
// Check if this is an intermediate tag according to ANY spec
|
||||
for (_, spec) in self.specs.iter() {
|
||||
if let Some(intermediates) = &spec.intermediates {
|
||||
if intermediates.contains(&tag_name) {
|
||||
eprintln!("Found intermediate tag: {}", tag_name);
|
||||
return Err(ParserError::ErrorSignal(Signal::SpecialTag(tag_name)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the tag spec for this tag
|
||||
let tag_spec = self.specs.get(tag_name.as_str()).cloned();
|
||||
eprintln!("Tag spec: {:?}", tag_spec);
|
||||
|
||||
let mut children = Vec::new();
|
||||
let mut branches = Vec::new();
|
||||
|
||||
while !self.is_at_end() {
|
||||
match self.next_node() {
|
||||
Ok(node) => {
|
||||
current_section.push(node);
|
||||
eprintln!("Adding node: {:?}", node);
|
||||
children.push(node);
|
||||
}
|
||||
Err(ParserError::ErrorSignal(Signal::ClosingTagFound(tag))) => {
|
||||
match tag.as_str() {
|
||||
tag if tag == end_tag.as_str() => {
|
||||
// Found matching end tag, complete the block
|
||||
all_children.extend(current_section);
|
||||
return Ok(Node::Django(DjangoNode::Tag {
|
||||
kind,
|
||||
Err(ParserError::ErrorSignal(Signal::SpecialTag(tag))) => {
|
||||
eprintln!("Got special tag: {}", tag);
|
||||
if let Some(spec) = &tag_spec {
|
||||
// Check if this is a closing tag
|
||||
if Some(&tag) == spec.closing.as_ref() {
|
||||
eprintln!("Found matching closing tag: {}", tag);
|
||||
// Found our closing tag, create appropriate tag type
|
||||
let tag_node = if !branches.is_empty() {
|
||||
TagNode::Branching {
|
||||
name: tag_name,
|
||||
bits,
|
||||
children: all_children,
|
||||
}));
|
||||
children,
|
||||
branches,
|
||||
}
|
||||
tag if !tag.starts_with("end") => {
|
||||
// Found intermediate tag (like 'else', 'elif')
|
||||
all_children.extend(current_section);
|
||||
all_children.push(Node::Django(DjangoNode::Tag {
|
||||
kind: DjangoTagKind::from_str(tag)?,
|
||||
bits: vec![tag.to_string()],
|
||||
children: Vec::new(),
|
||||
}));
|
||||
current_section = Vec::new();
|
||||
continue; // Continue parsing after intermediate tag
|
||||
} else {
|
||||
TagNode::Block {
|
||||
name: tag_name,
|
||||
bits,
|
||||
children,
|
||||
}
|
||||
tag => {
|
||||
// Found unexpected end tag
|
||||
return Err(ParserError::ErrorSignal(Signal::ClosingTagFound(
|
||||
tag.to_string(),
|
||||
)));
|
||||
};
|
||||
return Ok(Node::Django(DjangoNode::Tag(tag_node)));
|
||||
}
|
||||
// Check if this is an intermediate tag
|
||||
if let Some(intermediates) = &spec.intermediates {
|
||||
if intermediates.contains(&tag) {
|
||||
eprintln!("Found intermediate tag: {}", tag);
|
||||
// Add current children as a branch and start fresh
|
||||
branches.push(TagNode::Block {
|
||||
name: tag.clone(),
|
||||
bits: vec![tag.clone()],
|
||||
children,
|
||||
});
|
||||
children = Vec::new();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we get here, it's an unexpected tag
|
||||
eprintln!("Unexpected tag: {}", tag);
|
||||
return Err(ParserError::UnexpectedTag(tag));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {:?}", e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(ParserError::StreamError(Stream::UnexpectedEof))
|
||||
// If we get here, we never found the closing tag
|
||||
eprintln!("Never found closing tag: {}", tag_name);
|
||||
Err(ParserError::UnclosedTag(tag_name))
|
||||
}
|
||||
|
||||
fn parse_django_variable(&mut self, s: &str) -> Result<Node, ParserError> {
|
||||
|
@ -359,6 +419,7 @@ impl Parser {
|
|||
}
|
||||
|
||||
let mut children = Vec::new();
|
||||
let mut found_closing_tag = false;
|
||||
|
||||
while !self.is_at_end() {
|
||||
match self.next_node() {
|
||||
|
@ -368,6 +429,7 @@ impl Parser {
|
|||
Err(ParserError::ErrorSignal(Signal::ClosingTagFound(tag))) => {
|
||||
if tag == "style" {
|
||||
self.consume()?;
|
||||
found_closing_tag = true;
|
||||
break;
|
||||
}
|
||||
// If it's not our closing tag, keep collecting children
|
||||
|
@ -376,6 +438,10 @@ impl Parser {
|
|||
}
|
||||
}
|
||||
|
||||
if !found_closing_tag {
|
||||
return Err(ParserError::UnclosedTag("style".to_string()));
|
||||
}
|
||||
|
||||
Ok(Node::Style(StyleNode::Element {
|
||||
attributes,
|
||||
children,
|
||||
|
@ -454,12 +520,13 @@ impl Parser {
|
|||
}
|
||||
|
||||
fn consume_if(&mut self, token_type: TokenType) -> Result<Token, ParserError> {
|
||||
let token = self.peek()?;
|
||||
if token.is_token_type(&token_type) {
|
||||
return Err(ParserError::ExpectedTokenType(token_type));
|
||||
}
|
||||
self.consume()?;
|
||||
let token = self.consume()?;
|
||||
if token.token_type() == &token_type {
|
||||
Ok(token)
|
||||
} else {
|
||||
self.backtrack(1)?;
|
||||
Err(ParserError::ExpectedTokenType(format!("{:?}", token_type)))
|
||||
}
|
||||
}
|
||||
|
||||
fn consume_until(&mut self, end_type: TokenType) -> Result<Vec<Token>, ParserError> {
|
||||
|
@ -472,7 +539,6 @@ impl Parser {
|
|||
}
|
||||
|
||||
fn synchronize(&mut self) -> Result<(), ParserError> {
|
||||
println!("--- Starting synchronization ---");
|
||||
const SYNC_TYPES: &[TokenType] = &[
|
||||
TokenType::DjangoBlock(String::new()),
|
||||
TokenType::HtmlTagOpen(String::new()),
|
||||
|
@ -485,39 +551,53 @@ impl Parser {
|
|||
|
||||
while !self.is_at_end() {
|
||||
let current = self.peek()?;
|
||||
println!("--- Sync checking token: {:?}", current);
|
||||
|
||||
// Debug print for token type comparison
|
||||
for sync_type in SYNC_TYPES {
|
||||
println!("--- Comparing with sync type: {:?}", sync_type);
|
||||
if matches!(current.token_type(), sync_type) {
|
||||
println!("--- Found sync point at: {:?}", current);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
println!("--- Consuming token in sync: {:?}", current);
|
||||
self.consume()?;
|
||||
}
|
||||
println!("--- Reached end during synchronization");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ParserError {
|
||||
#[error("token stream {0}")]
|
||||
StreamError(Stream),
|
||||
#[error("parsing signal: {0:?}")]
|
||||
#[error("unclosed tag: {0}")]
|
||||
UnclosedTag(String),
|
||||
#[error("unexpected tag: {0}")]
|
||||
UnexpectedTag(String),
|
||||
#[error("unsupported tag type")]
|
||||
UnsupportedTagType,
|
||||
#[error("empty tag")]
|
||||
EmptyTag,
|
||||
#[error("invalid tag type")]
|
||||
InvalidTagType,
|
||||
#[error("missing required args")]
|
||||
MissingRequiredArgs,
|
||||
#[error("invalid argument '{0:?}' '{1:?}")]
|
||||
InvalidArgument(String, String),
|
||||
#[error("unexpected closing tag {0}")]
|
||||
UnexpectedClosingTag(String),
|
||||
#[error("unexpected intermediate tag {0}")]
|
||||
UnexpectedIntermediateTag(String),
|
||||
#[error("unclosed block {0}")]
|
||||
UnclosedBlock(String),
|
||||
#[error(transparent)]
|
||||
StreamError(#[from] Stream),
|
||||
#[error("internal signal: {0:?}")]
|
||||
ErrorSignal(Signal),
|
||||
#[error("unexpected token, expected type '{0:?}'")]
|
||||
ExpectedTokenType(TokenType),
|
||||
#[error("expected token: {0}")]
|
||||
ExpectedTokenType(String),
|
||||
#[error("unexpected token '{0:?}'")]
|
||||
UnexpectedToken(Token),
|
||||
#[error("multi-line comment outside of script or style context")]
|
||||
InvalidMultLineComment,
|
||||
#[error(transparent)]
|
||||
Ast(#[from] AstError),
|
||||
AstError(#[from] AstError),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
@ -529,10 +609,7 @@ pub enum Stream {
|
|||
InvalidAccess,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Signal {
|
||||
ClosingTagFound(String),
|
||||
}
|
||||
impl std::error::Error for Stream {}
|
||||
|
||||
impl std::fmt::Display for Stream {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
|
@ -546,49 +623,36 @@ impl std::fmt::Display for Stream {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Signal {
|
||||
ClosingTagFound(String),
|
||||
IntermediateTagFound(String, Vec<String>),
|
||||
IntermediateTag(String),
|
||||
SpecialTag(String),
|
||||
ClosingTag,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Stream;
|
||||
use super::*;
|
||||
use crate::lexer::Lexer;
|
||||
|
||||
mod html {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_comments() {
|
||||
let source = r#"<!-- HTML comment -->
|
||||
{# Django comment #}
|
||||
<script>
|
||||
// JS single line
|
||||
/* JS multi
|
||||
line */
|
||||
</script>
|
||||
<style>
|
||||
/* CSS comment */
|
||||
</style>"#;
|
||||
fn test_parse_html_doctype() {
|
||||
let source = "<!DOCTYPE html>";
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let ast = parser.parse().unwrap();
|
||||
insta::assert_yaml_snapshot!(ast);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_django_block() {
|
||||
let source = r#"{% if user.is_staff %}Admin{% else %}User{% endif %}"#;
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let ast = parser.parse().unwrap();
|
||||
insta::assert_yaml_snapshot!(ast);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_django_variable() {
|
||||
let source = r#"{{ user.name|default:"Anonymous"|title }}"#;
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let ast = parser.parse().unwrap();
|
||||
insta::assert_yaml_snapshot!(ast);
|
||||
}
|
||||
#[test]
|
||||
fn test_parse_html_tag() {
|
||||
let source = r#"<div class="container" id="main" disabled></div>"#;
|
||||
let source = "<div class=\"container\">Hello</div>";
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let ast = parser.parse().unwrap();
|
||||
|
@ -597,7 +661,20 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_parse_html_void() {
|
||||
let source = r#"<img src="example.png" />"#;
|
||||
let source = "<input type=\"text\" />";
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let ast = parser.parse().unwrap();
|
||||
insta::assert_yaml_snapshot!(ast);
|
||||
}
|
||||
}
|
||||
|
||||
mod django {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_django_variable() {
|
||||
let source = "{{ user.name|title }}";
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let ast = parser.parse().unwrap();
|
||||
|
@ -605,80 +682,125 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_html_doctype() {
|
||||
let source = r#"<!DOCTYPE html>"#;
|
||||
fn test_parse_filter_chains() {
|
||||
let source = "{{ value|default:'nothing'|title|upper }}";
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let ast = parser.parse().unwrap();
|
||||
insta::assert_yaml_snapshot!(ast);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_django_if_block() {
|
||||
let source = "{% if user.is_authenticated %}Welcome{% endif %}";
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let ast = parser.parse().unwrap();
|
||||
insta::assert_yaml_snapshot!(ast);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_django_for_block() {
|
||||
let source = "{% for item in items %}{{ item }}{% empty %}No items{% endfor %}";
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let ast = parser.parse().unwrap();
|
||||
insta::assert_yaml_snapshot!(ast);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_complex_if_elif() {
|
||||
let source = "{% if x > 0 %}Positive{% elif x < 0 %}Negative{% else %}Zero{% endif %}";
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let ast = parser.parse().unwrap();
|
||||
insta::assert_yaml_snapshot!(ast);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_nested_for_if() {
|
||||
let source =
|
||||
"{% for item in items %}{% if item.active %}{{ item.name }}{% endif %}{% endfor %}";
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let ast = parser.parse().unwrap();
|
||||
insta::assert_yaml_snapshot!(ast);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mixed_content() {
|
||||
let source = "Welcome, {% if user.is_authenticated %}
|
||||
{{ user.name|title|default:'Guest' }}
|
||||
{% for group in user.groups %}
|
||||
{% if forloop.first %}({% endif %}
|
||||
{{ group.name }}
|
||||
{% if not forloop.last %}, {% endif %}
|
||||
{% if forloop.last %}){% endif %}
|
||||
{% empty %}
|
||||
(no groups)
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
Guest
|
||||
{% endif %}!";
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let ast = parser.parse().unwrap();
|
||||
insta::assert_yaml_snapshot!(ast);
|
||||
}
|
||||
}
|
||||
|
||||
mod script {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_script() {
|
||||
let source = r#"<script type="text/javascript">
|
||||
// Single line comment
|
||||
const x = 1;
|
||||
let source = "<script>
|
||||
const x = 42;
|
||||
// JavaScript comment
|
||||
/* Multi-line
|
||||
comment */
|
||||
console.log(x);
|
||||
</script>"#;
|
||||
</script>";
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let ast = parser.parse().unwrap();
|
||||
insta::assert_yaml_snapshot!(ast);
|
||||
}
|
||||
}
|
||||
|
||||
mod style {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_style() {
|
||||
let source = r#"<style type="text/css">
|
||||
/* Header styles */
|
||||
.header {
|
||||
color: blue;
|
||||
let source = "<style>
|
||||
/* CSS comment */
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
</style>"#;
|
||||
</style>";
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let ast = parser.parse().unwrap();
|
||||
insta::assert_yaml_snapshot!(ast);
|
||||
}
|
||||
}
|
||||
|
||||
mod comments {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_full() {
|
||||
let source = r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style type="text/css">
|
||||
/* Style header */
|
||||
.header { color: blue; }
|
||||
</style>
|
||||
<script type="text/javascript">
|
||||
// Init app
|
||||
const app = {
|
||||
/* Config */
|
||||
debug: true
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header section -->
|
||||
<div class="header" id="main" data-value="123" disabled>
|
||||
{% if user.is_authenticated %}
|
||||
{# Welcome message #}
|
||||
<h1>Welcome, {{ user.name|default:"Guest"|title }}!</h1>
|
||||
{% if user.is_staff %}
|
||||
<span>Admin</span>
|
||||
{% else %}
|
||||
<span>User</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>"#;
|
||||
fn test_parse_comments() {
|
||||
let source = "<!-- HTML comment -->{# Django comment #}";
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let ast = parser.parse().unwrap();
|
||||
insta::assert_yaml_snapshot!(ast);
|
||||
}
|
||||
}
|
||||
|
||||
mod errors {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_unexpected_eof() {
|
||||
|
@ -691,4 +813,64 @@ mod tests {
|
|||
Err(ParserError::StreamError(Stream::UnexpectedEof))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_unclosed_django_if() {
|
||||
let source = "{% if user.is_authenticated %}Welcome";
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let result = parser.parse();
|
||||
println!("Error: {:?}", result);
|
||||
assert!(matches!(result, Err(ParserError::UnclosedTag(tag)) if tag == "if"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_unclosed_django_for() {
|
||||
let source = "{% for item in items %}{{ item.name }}";
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let result = parser.parse();
|
||||
println!("Error: {:?}", result);
|
||||
assert!(matches!(result, Err(ParserError::UnclosedTag(tag)) if tag == "for"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_unclosed_style() {
|
||||
let source = "<style>body { color: blue; ";
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let result = parser.parse();
|
||||
println!("Error: {:?}", result);
|
||||
assert!(matches!(result, Err(ParserError::UnclosedTag(tag)) if tag == "style"));
|
||||
}
|
||||
}
|
||||
|
||||
mod full_templates {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_full() {
|
||||
let source = r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{% block title %}Default Title{% endblock %}</title>
|
||||
<style>
|
||||
/* CSS styles */
|
||||
body { font-family: sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome{% if user.is_authenticated %}, {{ user.name }}{% endif %}!</h1>
|
||||
<script>
|
||||
// JavaScript code
|
||||
console.log('Hello!');
|
||||
</script>
|
||||
</body>
|
||||
</html>"#;
|
||||
let tokens = Lexer::new(source).tokenize().unwrap();
|
||||
let mut parser = Parser::new(tokens);
|
||||
let ast = parser.parse().unwrap();
|
||||
insta::assert_yaml_snapshot!(ast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
source: crates/djls-template-ast/src/parser.rs
|
||||
expression: ast
|
||||
---
|
||||
nodes:
|
||||
- Html:
|
||||
Comment: HTML comment
|
||||
- Django:
|
||||
Comment: Django comment
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
source: crates/djls-template-ast/src/parser.rs
|
||||
expression: ast
|
||||
---
|
||||
nodes:
|
||||
- Django:
|
||||
Tag:
|
||||
Branching:
|
||||
name: if
|
||||
bits:
|
||||
- if
|
||||
- x
|
||||
- ">"
|
||||
- "0"
|
||||
children:
|
||||
- Text: Zero
|
||||
branches:
|
||||
- Block:
|
||||
name: elif
|
||||
bits:
|
||||
- elif
|
||||
children:
|
||||
- Text: Positive
|
||||
- Block:
|
||||
name: else
|
||||
bits:
|
||||
- else
|
||||
children:
|
||||
- Text: Negative
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
source: crates/djls-template-ast/src/parser.rs
|
||||
expression: ast
|
||||
---
|
||||
nodes:
|
||||
- Django:
|
||||
Tag:
|
||||
Branching:
|
||||
name: for
|
||||
bits:
|
||||
- for
|
||||
- item
|
||||
- in
|
||||
- items
|
||||
children:
|
||||
- Text: No items
|
||||
branches:
|
||||
- Block:
|
||||
name: empty
|
||||
bits:
|
||||
- empty
|
||||
children:
|
||||
- Django:
|
||||
Variable:
|
||||
bits:
|
||||
- item
|
||||
filters: []
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
source: crates/djls-template-ast/src/parser.rs
|
||||
expression: ast
|
||||
---
|
||||
nodes:
|
||||
- Django:
|
||||
Tag:
|
||||
Block:
|
||||
name: if
|
||||
bits:
|
||||
- if
|
||||
- user.is_authenticated
|
||||
children:
|
||||
- Text: Welcome
|
|
@ -9,8 +9,5 @@ nodes:
|
|||
- user
|
||||
- name
|
||||
filters:
|
||||
- name: default
|
||||
arguments:
|
||||
- Anonymous
|
||||
- name: title
|
||||
arguments: []
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
source: crates/djls-template-ast/src/parser.rs
|
||||
expression: ast
|
||||
---
|
||||
nodes:
|
||||
- Django:
|
||||
Variable:
|
||||
bits:
|
||||
- value
|
||||
filters:
|
||||
- name: default
|
||||
arguments:
|
||||
- "'nothing'"
|
||||
- name: title
|
||||
arguments: []
|
||||
- name: upper
|
||||
arguments: []
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
source: crates/djls-template-ast/src/parser.rs
|
||||
expression: ast
|
||||
---
|
||||
nodes:
|
||||
- Text: "Welcome, "
|
||||
- Django:
|
||||
Tag:
|
||||
Branching:
|
||||
name: if
|
||||
bits:
|
||||
- if
|
||||
- user.is_authenticated
|
||||
children:
|
||||
- Text: Guest
|
||||
branches:
|
||||
- Block:
|
||||
name: else
|
||||
bits:
|
||||
- else
|
||||
children:
|
||||
- Django:
|
||||
Variable:
|
||||
bits:
|
||||
- user
|
||||
- name
|
||||
filters:
|
||||
- name: title
|
||||
arguments: []
|
||||
- name: default
|
||||
arguments:
|
||||
- "'Guest'"
|
||||
- Django:
|
||||
Tag:
|
||||
Branching:
|
||||
name: for
|
||||
bits:
|
||||
- for
|
||||
- group
|
||||
- in
|
||||
- user.groups
|
||||
children:
|
||||
- Text: (no groups)
|
||||
branches:
|
||||
- Block:
|
||||
name: empty
|
||||
bits:
|
||||
- empty
|
||||
children:
|
||||
- Django:
|
||||
Tag:
|
||||
Block:
|
||||
name: if
|
||||
bits:
|
||||
- if
|
||||
- forloop.first
|
||||
children:
|
||||
- Text: (
|
||||
- Django:
|
||||
Variable:
|
||||
bits:
|
||||
- group
|
||||
- name
|
||||
filters: []
|
||||
- Django:
|
||||
Tag:
|
||||
Block:
|
||||
name: if
|
||||
bits:
|
||||
- if
|
||||
- not
|
||||
- forloop.last
|
||||
children:
|
||||
- Text: ", "
|
||||
- Django:
|
||||
Tag:
|
||||
Block:
|
||||
name: if
|
||||
bits:
|
||||
- if
|
||||
- forloop.last
|
||||
children:
|
||||
- Text: )
|
||||
- Text: "!"
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
source: crates/djls-template-ast/src/parser.rs
|
||||
expression: ast
|
||||
---
|
||||
nodes:
|
||||
- Django:
|
||||
Tag:
|
||||
Block:
|
||||
name: for
|
||||
bits:
|
||||
- for
|
||||
- item
|
||||
- in
|
||||
- items
|
||||
children:
|
||||
- Django:
|
||||
Tag:
|
||||
Block:
|
||||
name: if
|
||||
bits:
|
||||
- if
|
||||
- item.active
|
||||
children:
|
||||
- Django:
|
||||
Variable:
|
||||
bits:
|
||||
- item
|
||||
- name
|
||||
filters: []
|
|
@ -0,0 +1,68 @@
|
|||
---
|
||||
source: crates/djls-template-ast/src/parser.rs
|
||||
expression: ast
|
||||
---
|
||||
nodes:
|
||||
- Html:
|
||||
Doctype: "!DOCTYPE"
|
||||
- Html:
|
||||
Element:
|
||||
tag_name: html
|
||||
attributes: {}
|
||||
children:
|
||||
- Html:
|
||||
Element:
|
||||
tag_name: head
|
||||
attributes: {}
|
||||
children:
|
||||
- Html:
|
||||
Element:
|
||||
tag_name: title
|
||||
attributes: {}
|
||||
children: []
|
||||
- Style:
|
||||
Element:
|
||||
attributes: {}
|
||||
children:
|
||||
- Style:
|
||||
Comment: CSS styles
|
||||
- Text: "body "
|
||||
- Text: "{"
|
||||
- Text: "font-family: sans-serif; }"
|
||||
- Html:
|
||||
Element:
|
||||
tag_name: body
|
||||
attributes: {}
|
||||
children:
|
||||
- Html:
|
||||
Element:
|
||||
tag_name: h1
|
||||
attributes: {}
|
||||
children:
|
||||
- Text: Welcome
|
||||
- Django:
|
||||
Tag:
|
||||
Block:
|
||||
name: if
|
||||
bits:
|
||||
- if
|
||||
- user.is_authenticated
|
||||
children:
|
||||
- Text: ", "
|
||||
- Django:
|
||||
Variable:
|
||||
bits:
|
||||
- user
|
||||
- name
|
||||
filters: []
|
||||
- Text: "!"
|
||||
- Script:
|
||||
Element:
|
||||
attributes:
|
||||
script: Boolean
|
||||
children:
|
||||
- Script:
|
||||
Comment:
|
||||
content: JavaScript code
|
||||
kind: SingleLine
|
||||
- Text: "console.log('Hello!');"
|
|
@ -9,7 +9,5 @@ nodes:
|
|||
attributes:
|
||||
class:
|
||||
Value: container
|
||||
disabled: Boolean
|
||||
id:
|
||||
Value: main
|
||||
children: []
|
||||
children:
|
||||
- Text: Hello
|
|
@ -5,7 +5,7 @@ expression: ast
|
|||
nodes:
|
||||
- Html:
|
||||
Void:
|
||||
tag_name: img
|
||||
tag_name: input
|
||||
attributes:
|
||||
src:
|
||||
Value: example.png
|
||||
type:
|
||||
Value: text
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
source: crates/djls-template-ast/src/parser.rs
|
||||
expression: ast
|
||||
---
|
||||
nodes:
|
||||
- Html:
|
||||
Comment: HTML comment
|
||||
- Django:
|
||||
Comment: Django comment
|
||||
- Script:
|
||||
Element:
|
||||
attributes:
|
||||
script: Boolean
|
||||
children:
|
||||
- Script:
|
||||
Comment:
|
||||
content: JS single line
|
||||
kind: SingleLine
|
||||
- Script:
|
||||
Comment:
|
||||
content: "JS multi\n line"
|
||||
kind: MultiLine
|
||||
- Style:
|
||||
Element:
|
||||
attributes: {}
|
||||
children:
|
||||
- Style:
|
||||
Comment: CSS comment
|
|
@ -1,13 +0,0 @@
|
|||
---
|
||||
source: crates/djls-template-ast/src/parser.rs
|
||||
expression: ast
|
||||
---
|
||||
nodes:
|
||||
- Django:
|
||||
Tag:
|
||||
kind: If
|
||||
bits:
|
||||
- if
|
||||
- user.is_staff
|
||||
children:
|
||||
- Text: Admin
|
|
@ -1,106 +0,0 @@
|
|||
---
|
||||
source: crates/djls-template-ast/src/parser.rs
|
||||
expression: ast
|
||||
---
|
||||
nodes:
|
||||
- Html:
|
||||
Doctype: "!DOCTYPE"
|
||||
- Html:
|
||||
Element:
|
||||
tag_name: html
|
||||
attributes: {}
|
||||
children:
|
||||
- Html:
|
||||
Element:
|
||||
tag_name: head
|
||||
attributes: {}
|
||||
children:
|
||||
- Style:
|
||||
Element:
|
||||
attributes:
|
||||
type:
|
||||
Value: text/css
|
||||
children:
|
||||
- Style:
|
||||
Comment: Style header
|
||||
- Text: ".header "
|
||||
- Text: "{"
|
||||
- Text: "color: blue; }"
|
||||
- Script:
|
||||
Element:
|
||||
attributes:
|
||||
script: Boolean
|
||||
type:
|
||||
Value: text/javascript
|
||||
children:
|
||||
- Script:
|
||||
Comment:
|
||||
content: Init app
|
||||
kind: SingleLine
|
||||
- Text: "const app = "
|
||||
- Text: "{"
|
||||
- Script:
|
||||
Comment:
|
||||
content: Config
|
||||
kind: MultiLine
|
||||
- Text: "debug: true"
|
||||
- Text: "};"
|
||||
- Html:
|
||||
Element:
|
||||
tag_name: body
|
||||
attributes: {}
|
||||
children:
|
||||
- Html:
|
||||
Comment: Header section
|
||||
- Html:
|
||||
Element:
|
||||
tag_name: div
|
||||
attributes:
|
||||
class:
|
||||
Value: header
|
||||
data-value:
|
||||
Value: "123"
|
||||
disabled: Boolean
|
||||
id:
|
||||
Value: main
|
||||
children:
|
||||
- Django:
|
||||
Tag:
|
||||
kind: If
|
||||
bits:
|
||||
- if
|
||||
- user.is_authenticated
|
||||
children:
|
||||
- Django:
|
||||
Comment: Welcome message
|
||||
- Html:
|
||||
Element:
|
||||
tag_name: h1
|
||||
attributes: {}
|
||||
children:
|
||||
- Text: "Welcome, "
|
||||
- Django:
|
||||
Variable:
|
||||
bits:
|
||||
- user
|
||||
- name
|
||||
filters:
|
||||
- name: default
|
||||
arguments:
|
||||
- Guest
|
||||
- name: title
|
||||
arguments: []
|
||||
- Text: "!"
|
||||
- Django:
|
||||
Tag:
|
||||
kind: If
|
||||
bits:
|
||||
- if
|
||||
- user.is_staff
|
||||
children:
|
||||
- Html:
|
||||
Element:
|
||||
tag_name: span
|
||||
attributes: {}
|
||||
children:
|
||||
- Text: Admin
|
|
@ -7,14 +7,12 @@ nodes:
|
|||
Element:
|
||||
attributes:
|
||||
script: Boolean
|
||||
type:
|
||||
Value: text/javascript
|
||||
children:
|
||||
- Text: const x = 42;
|
||||
- Script:
|
||||
Comment:
|
||||
content: Single line comment
|
||||
content: JavaScript comment
|
||||
kind: SingleLine
|
||||
- Text: const x = 1;
|
||||
- Script:
|
||||
Comment:
|
||||
content: "Multi-line\n comment"
|
|
@ -5,13 +5,11 @@ expression: ast
|
|||
nodes:
|
||||
- Style:
|
||||
Element:
|
||||
attributes:
|
||||
type:
|
||||
Value: text/css
|
||||
attributes: {}
|
||||
children:
|
||||
- Style:
|
||||
Comment: Header styles
|
||||
- Text: ".header "
|
||||
Comment: CSS comment
|
||||
- Text: "body "
|
||||
- Text: "{"
|
||||
- Text: "color: blue;"
|
||||
- Text: "font-family: sans-serif;"
|
||||
- Text: "}"
|
116
crates/djls-template-ast/src/tagspecs.rs
Normal file
116
crates/djls-template-ast/src/tagspecs.rs
Normal file
|
@ -0,0 +1,116 @@
|
|||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use toml::Value;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct TagSpec {
|
||||
#[serde(rename = "type")]
|
||||
pub tag_type: TagType,
|
||||
pub closing: Option<String>,
|
||||
pub intermediates: Option<Vec<String>>,
|
||||
pub args: Option<Vec<ArgSpec>>,
|
||||
}
|
||||
|
||||
impl TagSpec {
|
||||
pub fn load_builtin_specs() -> Result<HashMap<String, TagSpec>> {
|
||||
let specs_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tagspecs");
|
||||
|
||||
let mut all_specs = HashMap::new();
|
||||
|
||||
for entry in fs::read_dir(&specs_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
|
||||
let content = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read {:?}", path))?;
|
||||
|
||||
let value: Value = toml::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse {:?}", path))?;
|
||||
|
||||
Self::extract_specs(&value, "", &mut all_specs)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_specs)
|
||||
}
|
||||
|
||||
fn extract_specs(
|
||||
value: &Value,
|
||||
prefix: &str,
|
||||
specs: &mut HashMap<String, TagSpec>,
|
||||
) -> Result<()> {
|
||||
if let Value::Table(table) = value {
|
||||
// If this table has a 'type' field, try to parse it as a TagSpec
|
||||
if table.contains_key("type") {
|
||||
if let Ok(tag_spec) = TagSpec::deserialize(value.clone()) {
|
||||
let name = prefix.split('.').last().unwrap_or(prefix);
|
||||
specs.insert(name.to_string(), tag_spec);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, recursively process each field
|
||||
for (key, value) in table {
|
||||
let new_prefix = if prefix.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{}.{}", prefix, key)
|
||||
};
|
||||
Self::extract_specs(value, &new_prefix, specs)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TagType {
|
||||
Block,
|
||||
Tag,
|
||||
Assignment,
|
||||
Variable,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ArgSpec {
|
||||
pub name: String,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
impl ArgSpec {
|
||||
pub fn is_placeholder(arg: &str) -> bool {
|
||||
arg.starts_with('{') && arg.ends_with('}')
|
||||
}
|
||||
|
||||
pub fn get_placeholder_name(arg: &str) -> Option<&str> {
|
||||
if Self::is_placeholder(arg) {
|
||||
Some(&arg[1..arg.len() - 1])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_specs_are_valid() -> Result<()> {
|
||||
let specs = TagSpec::load_builtin_specs()?;
|
||||
|
||||
assert!(!specs.is_empty(), "Should have loaded at least one spec");
|
||||
|
||||
println!("Loaded {} tag specs:", specs.len());
|
||||
for (name, spec) in &specs {
|
||||
println!(" {} ({:?})", name, spec.tag_type);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
83
crates/djls-template-ast/tagspecs/README.md
Normal file
83
crates/djls-template-ast/tagspecs/README.md
Normal file
|
@ -0,0 +1,83 @@
|
|||
# djls-template-ast Tag Specifications
|
||||
|
||||
Configuration files defining template tag behavior for the Django Language Server Protocol.
|
||||
|
||||
## Schema
|
||||
|
||||
```toml
|
||||
[package.module.path.tag_name] # Path where tag is registered, e.g., django.template.defaulttags
|
||||
type = "block" | "tag" | "assignment" | "variable" # Required: Type of template tag
|
||||
closing = "endtag" # Optional: Name of closing tag for block tags
|
||||
intermediates = ["else", "elif"] # Optional: Allowed intermediate tags
|
||||
|
||||
[[package.module.path.tag_name.args]] # Optional: Arguments specification
|
||||
name = "arg_name" # Name of the argument
|
||||
required = true | false # Whether the argument is required
|
||||
```
|
||||
|
||||
## Tag Types
|
||||
|
||||
- `block`: Tags that wrap content and require a closing tag
|
||||
|
||||
```django
|
||||
{% if condition %}content{% endif %}
|
||||
{% for item in items %}content{% endfor %}
|
||||
```
|
||||
|
||||
- `tag`: Single tags that don't wrap content
|
||||
|
||||
```django
|
||||
{% csrf_token %}
|
||||
{% include "template.html" %}
|
||||
```
|
||||
|
||||
- `assignment`: Tags that assign their output to a variable
|
||||
|
||||
```django
|
||||
{% url 'view-name' as url_var %}
|
||||
{% with total=business.employees.count %}
|
||||
```
|
||||
|
||||
- `variable`: Tags that output a value directly
|
||||
|
||||
```django
|
||||
{% cycle 'odd' 'even' %}
|
||||
{% firstof var1 var2 var3 %}
|
||||
```
|
||||
|
||||
## Argument Specification
|
||||
|
||||
Arguments can be either:
|
||||
|
||||
- Literal values that must match exactly (e.g., "in")
|
||||
- Placeholders for variables (wrapped in curly braces, e.g., "{item}")
|
||||
|
||||
## Examples
|
||||
|
||||
```toml
|
||||
[django.template.defaulttags.if]
|
||||
type = "block"
|
||||
closing = "endif"
|
||||
intermediates = ["else", "elif"]
|
||||
|
||||
[[django.template.defaulttags.if.args]]
|
||||
name = "condition"
|
||||
required = true
|
||||
|
||||
[django.template.defaulttags.for]
|
||||
type = "block"
|
||||
closing = "endfor"
|
||||
intermediates = ["empty"]
|
||||
|
||||
[[django.template.defaulttags.for.args]]
|
||||
name = "{item}"
|
||||
required = true
|
||||
|
||||
[[django.template.defaulttags.for.args]]
|
||||
name = "in"
|
||||
required = true
|
||||
|
||||
[[django.template.defaulttags.for.args]]
|
||||
name = "{iterable}"
|
||||
required = true
|
||||
```
|
25
crates/djls-template-ast/tagspecs/django.toml
Normal file
25
crates/djls-template-ast/tagspecs/django.toml
Normal file
|
@ -0,0 +1,25 @@
|
|||
[django.template.defaulttags.if]
|
||||
type = "block"
|
||||
closing = "endif"
|
||||
intermediates = ["else", "elif"]
|
||||
|
||||
[[django.template.defaulttags.if.args]]
|
||||
name = "condition"
|
||||
required = true
|
||||
|
||||
[django.template.defaulttags.for]
|
||||
type = "block"
|
||||
closing = "endfor"
|
||||
intermediates = ["empty"]
|
||||
|
||||
[[django.template.defaulttags.for.args]]
|
||||
name = "{item}"
|
||||
required = true
|
||||
|
||||
[[django.template.defaulttags.for.args]]
|
||||
name = "in"
|
||||
required = true
|
||||
|
||||
[[django.template.defaulttags.for.args]]
|
||||
name = "{iterable}"
|
||||
required = true
|
Loading…
Add table
Add a link
Reference in a new issue