Skip to content

Commit b83ed62

Browse files
Refactor and simplify initial tagspecs (#110)
1 parent 646f12b commit b83ed62

File tree

3 files changed

+198
-249
lines changed

3 files changed

+198
-249
lines changed

crates/djls-templates/src/tagspecs.rs

Lines changed: 140 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use anyhow::Result;
2-
use serde::Deserialize;
2+
use serde::{Deserialize, Serialize};
33
use std::collections::HashMap;
44
use std::fs;
55
use std::path::Path;
@@ -27,38 +27,41 @@ impl TagSpecs {
2727
}
2828

2929
/// Load specs from a TOML file, looking under the specified table path
30-
fn load_from_toml(path: &Path, table_path: &[&str]) -> Result<Self, anyhow::Error> {
30+
fn load_from_toml(path: &Path, table_path: &[&str]) -> Result<Self, TagSpecError> {
3131
let content = fs::read_to_string(path)?;
3232
let value: Value = toml::from_str(&content)?;
3333

34-
// Navigate to the specified table
35-
let table = table_path
34+
let start_node = table_path
3635
.iter()
37-
.try_fold(&value, |current, &key| {
38-
current
39-
.get(key)
40-
.ok_or_else(|| anyhow::anyhow!("Missing table: {}", key))
41-
})
42-
.unwrap_or(&value);
36+
.try_fold(&value, |current, &key| current.get(key));
4337

4438
let mut specs = HashMap::new();
45-
TagSpec::extract_specs(table, None, &mut specs)
46-
.map_err(|e| TagSpecError::Extract(e.to_string()))?;
39+
40+
if let Some(node) = start_node {
41+
let initial_prefix = if table_path.is_empty() {
42+
None
43+
} else {
44+
Some(table_path.join("."))
45+
};
46+
TagSpec::extract_specs(node, initial_prefix.as_deref(), &mut specs)
47+
.map_err(TagSpecError::Extract)?;
48+
}
49+
4750
Ok(TagSpecs(specs))
4851
}
4952

5053
/// Load specs from a user's project directory
5154
pub fn load_user_specs(project_root: &Path) -> Result<Self, anyhow::Error> {
52-
// List of config files to try, in priority order
5355
let config_files = ["djls.toml", ".djls.toml", "pyproject.toml"];
5456

5557
for &file in &config_files {
5658
let path = project_root.join(file);
5759
if path.exists() {
58-
return match file {
60+
let result = match file {
5961
"pyproject.toml" => Self::load_from_toml(&path, &["tool", "djls", "tagspecs"]),
60-
_ => Self::load_from_toml(&path, &["tagspecs"]), // Root level for other files
62+
_ => Self::load_from_toml(&path, &["tagspecs"]),
6163
};
64+
return result.map_err(anyhow::Error::from);
6265
}
6366
}
6467
Ok(Self::default())
@@ -72,8 +75,8 @@ impl TagSpecs {
7275
for entry in fs::read_dir(&specs_dir)? {
7376
let entry = entry?;
7477
let path = entry.path();
75-
if path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
76-
let file_specs = Self::load_from_toml(&path, &[])?;
78+
if path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
79+
let file_specs = Self::load_from_toml(&path, &["tagspecs"])?;
7780
specs.extend(file_specs.0);
7881
}
7982
}
@@ -95,87 +98,94 @@ impl TagSpecs {
9598
}
9699
}
97100

98-
#[derive(Debug, Clone, Deserialize)]
101+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
99102
pub struct TagSpec {
100-
#[serde(rename = "type")]
101-
pub tag_type: TagType,
102-
pub closing: Option<String>,
103+
pub end: Option<EndTag>,
103104
#[serde(default)]
104-
pub branches: Option<Vec<String>>,
105-
pub args: Option<Vec<ArgSpec>>,
105+
pub intermediates: Option<Vec<String>>,
106106
}
107107

108108
impl TagSpec {
109+
/// Recursive extraction: Check if node is spec, otherwise recurse if table.
109110
fn extract_specs(
110111
value: &Value,
111-
prefix: Option<&str>,
112+
prefix: Option<&str>, // Path *to* this value node
112113
specs: &mut HashMap<String, TagSpec>,
113114
) -> Result<(), String> {
114-
// Try to deserialize as a tag spec first
115-
match TagSpec::deserialize(value.clone()) {
116-
Ok(tag_spec) => {
117-
let name = prefix.map_or_else(String::new, |p| {
118-
p.split('.').last().unwrap_or(p).to_string()
119-
});
120-
specs.insert(name, tag_spec);
115+
// Check if the current node *itself* represents a TagSpec definition
116+
// We can be more specific: check if it's a table containing 'end' or 'intermediates'
117+
let mut is_spec_node = false;
118+
if let Some(table) = value.as_table() {
119+
if table.contains_key("end") || table.contains_key("intermediates") {
120+
// Looks like a spec, try to deserialize
121+
match TagSpec::deserialize(value.clone()) {
122+
Ok(tag_spec) => {
123+
// It is a TagSpec. Get name from prefix.
124+
if let Some(p) = prefix {
125+
if let Some(name) = p.split('.').next_back().filter(|s| !s.is_empty()) {
126+
specs.insert(name.to_string(), tag_spec);
127+
is_spec_node = true;
128+
} else {
129+
return Err(format!(
130+
"Invalid prefix '{}' resulted in empty tag name component.",
131+
p
132+
));
133+
}
134+
} else {
135+
return Err("Cannot determine tag name for TagSpec: prefix is None."
136+
.to_string());
137+
}
138+
}
139+
Err(e) => {
140+
// Looked like a spec but failed to deserialize. This is an error.
141+
return Err(format!(
142+
"Failed to deserialize potential TagSpec at prefix '{}': {}",
143+
prefix.unwrap_or("<root>"),
144+
e
145+
));
146+
}
147+
}
121148
}
122-
Err(_) => {
123-
// Not a tag spec, try recursing into any table values
124-
for (key, value) in value.as_table().iter().flat_map(|t| t.iter()) {
149+
}
150+
151+
// If the node was successfully processed as a spec, DO NOT recurse into its fields.
152+
// Otherwise, if it's a table, recurse into its children.
153+
if !is_spec_node {
154+
if let Some(table) = value.as_table() {
155+
for (key, inner_value) in table.iter() {
125156
let new_prefix = match prefix {
126157
None => key.clone(),
127158
Some(p) => format!("{}.{}", p, key),
128159
};
129-
Self::extract_specs(value, Some(&new_prefix), specs)?;
160+
Self::extract_specs(inner_value, Some(&new_prefix), specs)?;
130161
}
131162
}
132163
}
164+
133165
Ok(())
134166
}
135167
}
136168

137-
#[derive(Clone, Debug, Deserialize, PartialEq)]
138-
#[serde(rename_all = "lowercase")]
139-
pub enum TagType {
140-
Container,
141-
Inclusion,
142-
Single,
143-
}
144-
145-
#[derive(Clone, Debug, Deserialize)]
146-
pub struct ArgSpec {
147-
pub name: String,
148-
pub required: bool,
169+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
170+
pub struct EndTag {
171+
pub tag: String,
149172
#[serde(default)]
150-
pub allowed_values: Option<Vec<String>>,
151-
#[serde(default)]
152-
pub is_kwarg: bool,
153-
}
154-
155-
impl ArgSpec {
156-
pub fn is_placeholder(arg: &str) -> bool {
157-
arg.starts_with('{') && arg.ends_with('}')
158-
}
159-
160-
pub fn get_placeholder_name(arg: &str) -> Option<&str> {
161-
if Self::is_placeholder(arg) {
162-
Some(&arg[1..arg.len() - 1])
163-
} else {
164-
None
165-
}
166-
}
173+
pub optional: bool,
167174
}
168175

169176
#[cfg(test)]
170177
mod tests {
171178
use super::*;
179+
use std::fs;
172180

173181
#[test]
174182
fn test_can_load_builtins() -> Result<(), anyhow::Error> {
175183
let specs = TagSpecs::load_builtin_specs()?;
176184

177185
assert!(!specs.0.is_empty(), "Should have loaded at least one spec");
178186

187+
assert!(specs.get("if").is_some(), "'if' tag should be present");
188+
179189
for name in specs.0.keys() {
180190
assert!(!name.is_empty(), "Tag name should not be empty");
181191
}
@@ -190,29 +200,34 @@ mod tests {
190200
"autoescape",
191201
"block",
192202
"comment",
193-
"cycle",
194-
"debug",
195-
"extends",
196203
"filter",
197204
"for",
198-
"firstof",
199205
"if",
200-
"include",
201-
"load",
202-
"now",
206+
"ifchanged",
203207
"spaceless",
204-
"templatetag",
205-
"url",
206208
"verbatim",
207209
"with",
210+
"cache",
211+
"localize",
212+
"blocktranslate",
213+
"localtime",
214+
"timezone",
208215
];
209216
let missing_tags = [
210217
"csrf_token",
211-
"ifchanged",
218+
"cycle",
219+
"debug",
220+
"extends",
221+
"firstof",
222+
"include",
223+
"load",
212224
"lorem",
225+
"now",
213226
"querystring", // 5.1
214227
"regroup",
215228
"resetcycle",
229+
"templatetag",
230+
"url",
216231
"widthratio",
217232
];
218233

@@ -237,33 +252,44 @@ mod tests {
237252
let root = dir.path();
238253

239254
let pyproject_content = r#"
240-
[tool.djls.template.tags.mytag]
241-
type = "container"
242-
closing = "endmytag"
243-
branches = ["mybranch"]
244-
args = [{ name = "myarg", required = true }]
255+
[tool.djls.tagspecs.mytag]
256+
end = { tag = "endmytag" }
257+
intermediates = ["mybranch"]
258+
259+
[tool.djls.tagspecs.anothertag]
260+
end = { tag = "endanothertag", optional = true }
245261
"#;
246262
fs::write(root.join("pyproject.toml"), pyproject_content)?;
247263

264+
// Load all (built-in + user)
248265
let specs = TagSpecs::load_all(root)?;
249266

250-
let if_tag = specs.get("if").expect("if tag should be present");
251-
assert_eq!(if_tag.tag_type, TagType::Container);
267+
assert!(specs.get("if").is_some(), "'if' tag should be present");
252268

253269
let my_tag = specs.get("mytag").expect("mytag should be present");
254-
assert_eq!(my_tag.tag_type, TagType::Container);
255-
assert_eq!(my_tag.closing, Some("endmytag".to_string()));
256-
257-
let branches = my_tag
258-
.branches
259-
.as_ref()
260-
.expect("mytag should have branches");
261-
assert!(branches.iter().any(|b| b == "mybranch"));
262-
263-
let args = my_tag.args.as_ref().expect("mytag should have args");
264-
let arg = &args[0];
265-
assert_eq!(arg.name, "myarg");
266-
assert!(arg.required);
270+
assert_eq!(
271+
my_tag.end,
272+
Some(EndTag {
273+
tag: "endmytag".to_string(),
274+
optional: false
275+
})
276+
);
277+
assert_eq!(my_tag.intermediates, Some(vec!["mybranch".to_string()]));
278+
279+
let another_tag = specs
280+
.get("anothertag")
281+
.expect("anothertag should be present");
282+
assert_eq!(
283+
another_tag.end,
284+
Some(EndTag {
285+
tag: "endanothertag".to_string(),
286+
optional: true
287+
})
288+
);
289+
assert!(
290+
another_tag.intermediates.is_none(),
291+
"anothertag should have no intermediates"
292+
);
267293

268294
dir.close()?;
269295
Ok(())
@@ -274,36 +300,45 @@ args = [{ name = "myarg", required = true }]
274300
let dir = tempfile::tempdir()?;
275301
let root = dir.path();
276302

303+
// djls.toml has higher priority
277304
let djls_content = r#"
278-
[mytag1]
279-
type = "container"
280-
closing = "endmytag1"
305+
[tagspecs.mytag1]
306+
end = { tag = "endmytag1_from_djls" }
281307
"#;
282308
fs::write(root.join("djls.toml"), djls_content)?;
283309

310+
// pyproject.toml has lower priority
284311
let pyproject_content = r#"
285-
[tool.djls.template.tags]
286-
mytag2.type = "container"
287-
mytag2.closing = "endmytag2"
312+
[tool.djls.tagspecs.mytag1]
313+
end = { tag = "endmytag1_from_pyproject" }
314+
315+
[tool.djls.tagspecs.mytag2]
316+
end = { tag = "endmytag2_from_pyproject" }
288317
"#;
289318
fs::write(root.join("pyproject.toml"), pyproject_content)?;
290319

291320
let specs = TagSpecs::load_user_specs(root)?;
292321

293-
assert!(specs.get("mytag1").is_some(), "mytag1 should be present");
322+
let tag1 = specs.get("mytag1").expect("mytag1 should be present");
323+
assert_eq!(tag1.end.as_ref().unwrap().tag, "endmytag1_from_djls");
324+
325+
// Should not find mytag2 because djls.toml was found first
294326
assert!(
295327
specs.get("mytag2").is_none(),
296-
"mytag2 should not be present"
328+
"mytag2 should not be present when djls.toml exists"
297329
);
298330

331+
// Remove djls.toml, now pyproject.toml should be used
299332
fs::remove_file(root.join("djls.toml"))?;
300333
let specs = TagSpecs::load_user_specs(root)?;
301334

335+
let tag1 = specs.get("mytag1").expect("mytag1 should be present now");
336+
assert_eq!(tag1.end.as_ref().unwrap().tag, "endmytag1_from_pyproject");
337+
302338
assert!(
303-
specs.get("mytag1").is_none(),
304-
"mytag1 should not be present"
339+
specs.get("mytag2").is_some(),
340+
"mytag2 should be present when only pyproject.toml exists"
305341
);
306-
assert!(specs.get("mytag2").is_some(), "mytag2 should be present");
307342

308343
dir.close()?;
309344
Ok(())

0 commit comments

Comments
 (0)