on master help sign in
Add @mixin, @assert directives, CSS math functions, and CLI tools

Implement 11 improvements: - @mixin directive with spread syntax for composable style groups - @assert directive for compile-time design system constraints - CSS math functions (clamp, min, max) passthrough without px appending - htmlang doctor, migrate, benchmark CLI commands - LSP semantic token modifiers for unused variable dimming - Snippet completions with tab stops for @fn parameters - Comprehensive snapshot and unit test coverage (25+ new tests)

commit5cdd429363b2df247e4c5bd4f4b45e36bacbe2be authoriko <mail@iko.soy> dateWed 15 Apr 2026, 17:03 (6 months ago) parent39f698d
9 files changed+599−16
DESIGN.md+24 src/bin/htmlang-lsp.rs+124−15 src/codegen.rs+1−1 src/main.rs+207 src/parser.rs+73 tests/snapshots.rs+146 tests/snapshots/assert_directive.hl+10 tests/snapshots/clamp_css.hl+4 tests/snapshots/mixin_spread.hl+10
DESIGN.md+24
@@ -219,6 +219,30 @@ Creates a named attribute bundle. Referenced with `$name` inside attribute lists
219219
220220Attributes listed after `$name` override those in the definition.
221221
222+### `@mixin`
223+
224+Creates a composable style group. Like `@define`, but intended for reusable style patterns. Referenced with `...$name` (spread syntax) or `$name` inside attribute lists.
225+
226+```
227+@mixin card-style [padding 20, rounded 8, background white]
228+@mixin interactive [cursor pointer, hover:background #e5e7eb, transition all 0.15s ease]
229+
230+@el [...$card-style, ...$interactive, border 1 #ccc]
231+ Composed styles from two mixins
232+```
233+
234+### `@assert`
235+
236+Compile-time assertion. If the condition is false, a compile error is emitted. Useful for enforcing design system constraints.
237+
238+```
239+@let spacing 16
240+@let primary #3b82f6
241+
242+@assert $spacing == 16
243+@assert $primary != red
244+```
245+
222246### `@fn`
223247
224248Defines a pure function (reusable component). Parameters are prefixed with `$`. Default values can be specified with `=`.
src/bin/htmlang-lsp.rs+124−15
@@ -89,7 +89,9 @@ impl LanguageServer for Backend {
8989 SemanticTokenType::COMMENT,
9090 SemanticTokenType::PROPERTY,
9191 ],
92− token_modifiers: vec![],
92+ token_modifiers: vec![
93+ SemanticTokenModifier::new("deprecated"), // bit 0 = 1 → dimmed/strikethrough
94+ ],
9395 },
9496 full: Some(SemanticTokensFullOptions::Bool(true)),
9597 range: None,
@@ -700,6 +702,8 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
700702 ("@base", "Set base URL for relative links", "@base "),
701703 ("@font-face", "Define a custom font face", "@font-face"),
702704 ("@json-ld", "Add JSON-LD structured data to head", "@json-ld"),
705+ ("@mixin", "Define a composable style group (use with ...$name)", "@mixin "),
706+ ("@assert", "Compile-time assertion for variable values", "@assert "),
703707 ]
704708 .iter()
705709 .map(|(name, detail, insert)| {
@@ -1328,19 +1332,39 @@ fn function_completions(text: &str, range: Range) -> Vec<CompletionItem> {
13281332 if let Some(rest) = trimmed.strip_prefix("@fn ") {
13291333 let parts: Vec<&str> = rest.split_whitespace().collect();
13301334 if let Some(name) = parts.first() {
1331− let params = parts[1..].join(" ");
1335+ let params: Vec<&str> = parts[1..].iter()
1336+ .map(|p| p.strip_prefix('$').unwrap_or(p))
1337+ .collect();
13321338 let detail = if params.is_empty() {
13331339 "Function".to_string()
13341340 } else {
1335− format!("Function({})", params)
1341+ format!("Function({})", params.join(", "))
13361342 };
1337− items.push(item(
1338− &format!("@{}", name),
1339− CompletionItemKind::FUNCTION,
1340− &detail,
1341− &format!("@{}", name),
1342− range,
1343− ));
1343+ // Generate snippet with tab stops for parameters
1344+ let insert_text = if params.is_empty() {
1345+ format!("@{}", name)
1346+ } else {
1347+ let param_snippets: Vec<String> = params.iter().enumerate().map(|(i, p)| {
1348+ let p_name = p.split('=').next().unwrap_or(p);
1349+ let default = p.split('=').nth(1).unwrap_or(p_name);
1350+ format!("{} ${{{}:{}}}", p_name, i + 1, default)
1351+ }).collect();
1352+ format!("@{} [{}]", name, param_snippets.join(", "))
1353+ };
1354+ let mut ci = CompletionItem {
1355+ label: format!("@{}", name),
1356+ kind: Some(CompletionItemKind::FUNCTION),
1357+ detail: Some(detail),
1358+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
1359+ range,
1360+ new_text: insert_text,
1361+ })),
1362+ ..Default::default()
1363+ };
1364+ if !params.is_empty() {
1365+ ci.insert_text_format = Some(tower_lsp::lsp_types::InsertTextFormat::SNIPPET);
1366+ }
1367+ items.push(ci);
13441368 }
13451369 }
13461370 }
@@ -2485,6 +2509,42 @@ fn code_actions(
24852509 }
24862510 }
24872511
2512+ // Quick-fix: remove unused @mixin
2513+ if msg.contains("unused mixin") {
2514+ let line = diag.range.start.line as usize;
2515+ let lines: Vec<&str> = text.lines().collect();
2516+ if let Some(source_line) = lines.get(line) {
2517+ let trimmed = source_line.trim_start();
2518+ if trimmed.starts_with("@mixin ") {
2519+ let mixin_name = trimmed.strip_prefix("@mixin ")
2520+ .and_then(|r| {
2521+ let r = r.trim();
2522+ r.find('[').map(|b| r[..b].trim()).or_else(|| r.split_whitespace().next())
2523+ })
2524+ .unwrap_or("?");
2525+ let edit = TextEdit {
2526+ range: Range::new(
2527+ Position::new(diag.range.start.line, 0),
2528+ Position::new(diag.range.start.line + 1, 0),
2529+ ),
2530+ new_text: String::new(),
2531+ };
2532+ let mut changes = HashMap::new();
2533+ changes.insert(uri.clone(), vec![edit]);
2534+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2535+ title: format!("Remove unused mixin '{}'", mixin_name),
2536+ kind: Some(CodeActionKind::QUICKFIX),
2537+ diagnostics: Some(vec![diag.clone()]),
2538+ edit: Some(WorkspaceEdit {
2539+ changes: Some(changes),
2540+ ..Default::default()
2541+ }),
2542+ ..Default::default()
2543+ }));
2544+ }
2545+ }
2546+ }
2547+
24882548 // Quick-fix: add alt attribute to @image
24892549 if msg.contains("@image should have") && msg.contains("alt") {
24902550 let line = diag.range.start.line as usize;
@@ -2929,6 +2989,44 @@ fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
29292989 let mut prev_line: u32 = 0;
29302990 let mut prev_start: u32 = 0;
29312991
2992+ // Build set of unused variables by parsing diagnostics
2993+ let result = htmlang::parser::parse(text);
2994+ let mut unused_vars: std::collections::HashSet<String> = std::collections::HashSet::new();
2995+ for d in &result.diagnostics {
2996+ if d.message.contains("unused variable '$") {
2997+ if let Some(start) = d.message.find("'$") {
2998+ let rest = &d.message[start + 2..];
2999+ if let Some(end) = rest.find('\'') {
3000+ unused_vars.insert(rest[..end].to_string());
3001+ }
3002+ }
3003+ }
3004+ if d.message.contains("unused function '@") {
3005+ if let Some(start) = d.message.find("'@") {
3006+ let rest = &d.message[start + 2..];
3007+ if let Some(end) = rest.find('\'') {
3008+ unused_vars.insert(format!("@{}", &rest[..end]));
3009+ }
3010+ }
3011+ }
3012+ if d.message.contains("unused define '$") {
3013+ if let Some(start) = d.message.find("'$") {
3014+ let rest = &d.message[start + 2..];
3015+ if let Some(end) = rest.find('\'') {
3016+ unused_vars.insert(rest[..end].to_string());
3017+ }
3018+ }
3019+ }
3020+ if d.message.contains("unused mixin '") {
3021+ if let Some(start) = d.message.find("unused mixin '") {
3022+ let rest = &d.message[start + 14..];
3023+ if let Some(end) = rest.find('\'') {
3024+ unused_vars.insert(rest[..end].to_string());
3025+ }
3026+ }
3027+ }
3028+ }
3029+
29323030 for (line_idx, line) in text.lines().enumerate() {
29333031 let trimmed = line.trim();
29343032 let line_num = line_idx as u32;
@@ -2936,7 +3034,7 @@ fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
29363034 // Detect comments
29373035 if trimmed.starts_with("--") {
29383036 let col = (line.len() - trimmed.len()) as u32;
2939− push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, col, trimmed.len() as u32, 4);
3037+ push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, col, trimmed.len() as u32, 4, 0);
29403038 continue;
29413039 }
29423040
@@ -2957,13 +3055,20 @@ fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
29573055 | "@match" | "@case" | "@default" | "@slot" | "@children"
29583056 | "@warn" | "@debug" | "@lang" | "@favicon" | "@fragment"
29593057 | "@unless" | "@og" | "@breakpoint"
2960− | "@canonical" | "@base" | "@font-face" | "@json-ld" => 0, // keyword
3058+ | "@canonical" | "@base" | "@font-face" | "@json-ld"
3059+ | "@mixin" | "@assert" | "@theme" | "@deprecated" | "@extends"
3060+ | "@use" => 0, // keyword
29613061 _ => {
29623062 // Check if it's a user function call (starts with @ but not a builtin element)
29633063 if is_builtin_element(word) { 0 } else { 2 } // function
29643064 }
29653065 };
2966− push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, start as u32, (i - start) as u32, token_type);
3066+ // Mark unused @fn definitions with deprecated modifier (dimmed)
3067+ let modifier = if (trimmed.starts_with("@fn ") || trimmed.starts_with("@define ") || trimmed.starts_with("@mixin ")) && word != "@fn" && word != "@define" && word != "@mixin" {
3068+ let name_part = &word[1..]; // strip @
3069+ if unused_vars.contains(&format!("@{}", name_part)) { 1 } else { 0 }
3070+ } else { 0 };
3071+ push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, start as u32, (i - start) as u32, token_type, modifier);
29673072 } else if bytes[i] == b'$' {
29683073 let start = i;
29693074 i += 1;
@@ -2971,7 +3076,10 @@ fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
29713076 i += 1;
29723077 }
29733078 if i > start + 1 {
2974− push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, start as u32, (i - start) as u32, 1); // variable
3079+ // Check if this is an unused variable definition on a @let line
3080+ let var_name = &line[start + 1..i];
3081+ let modifier = if trimmed.starts_with("@let ") && unused_vars.contains(var_name) { 1 } else { 0 };
3082+ push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, start as u32, (i - start) as u32, 1, modifier); // variable
29753083 }
29763084 } else {
29773085 i += 1;
@@ -3005,6 +3113,7 @@ fn push_token(
30053113 start: u32,
30063114 length: u32,
30073115 token_type: u32,
3116+ token_modifiers_bitset: u32,
30083117) {
30093118 let delta_line = line - *prev_line;
30103119 let delta_start = if delta_line == 0 {
@@ -3017,7 +3126,7 @@ fn push_token(
30173126 delta_start,
30183127 length,
30193128 token_type,
3020− token_modifiers_bitset: 0,
3129+ token_modifiers_bitset,
30213130 });
30223131 *prev_line = line;
30233132 *prev_start = start;
src/codegen.rs+1−1
@@ -2371,7 +2371,7 @@ fn css_px(value: &str) -> String {
23712371 if CSS_UNITS.iter().any(|u| v.ends_with(u)) {
23722372 return v.to_string();
23732373 }
2374− if v.starts_with("var(") || v.starts_with("calc(") {
2374+ if v.starts_with("var(") || v.starts_with("calc(") || v.starts_with("clamp(") || v.starts_with("min(") || v.starts_with("max(") {
23752375 return v.to_string();
23762376 }
23772377 format!("{}px", v)
src/main.rs+207
@@ -223,6 +223,9 @@ Commands:
223223 playground [out.html] Generate a self-contained HTML playground
224224 clean [dir] Remove generated .html files
225225 outline <file.hl> Show document structure tree
226+ doctor Check toolchain health
227+ migrate [dir|file] Auto-upgrade deprecated syntax
228+ benchmark <file|dir> Measure compile time and output size
226229
227230Options:
228231 -w, --watch Watch for changes and recompile
@@ -1806,6 +1809,210 @@ document.getElementById('editor').addEventListener('keydown',e=>{if(e.key==='Ent
18061809 return;
18071810 }
18081811
1812+ // Handle "doctor" subcommand — check toolchain health
1813+ if args.len() >= 2 && args[1] == "doctor" {
1814+ eprintln!("htmlang doctor — checking toolchain health\n");
1815+ let mut ok = true;
1816+
1817+ // Check htmlang version
1818+ eprintln!(" htmlang: v{}", env!("CARGO_PKG_VERSION"));
1819+
1820+ // Check if htmlang-lsp binary is available
1821+ let lsp_status = process::Command::new("htmlang-lsp")
1822+ .arg("--version")
1823+ .output();
1824+ match lsp_status {
1825+ Ok(output) if output.status.success() => {
1826+ let ver = String::from_utf8_lossy(&output.stdout);
1827+ eprintln!(" htmlang-lsp: {}", ver.trim());
1828+ }
1829+ _ => {
1830+ eprintln!(" htmlang-lsp: not found (optional — needed for editor integration)");
1831+ }
1832+ }
1833+
1834+ // Check if cargo is available (for building from source)
1835+ let cargo_status = process::Command::new("cargo")
1836+ .arg("--version")
1837+ .output();
1838+ match cargo_status {
1839+ Ok(output) if output.status.success() => {
1840+ let ver = String::from_utf8_lossy(&output.stdout);
1841+ eprintln!(" cargo: {}", ver.trim());
1842+ }
1843+ _ => {
1844+ eprintln!(" cargo: not found");
1845+ ok = false;
1846+ }
1847+ }
1848+
1849+ // Check if git is available (for deploy)
1850+ let git_status = process::Command::new("git")
1851+ .arg("--version")
1852+ .output();
1853+ match git_status {
1854+ Ok(output) if output.status.success() => {
1855+ let ver = String::from_utf8_lossy(&output.stdout);
1856+ eprintln!(" git: {}", ver.trim());
1857+ }
1858+ _ => {
1859+ eprintln!(" git: not found (optional — needed for deploy)");
1860+ }
1861+ }
1862+
1863+ // Check for htmlang.toml in current directory
1864+ if Path::new("htmlang.toml").exists() {
1865+ eprintln!(" config: htmlang.toml found");
1866+ } else {
1867+ eprintln!(" config: no htmlang.toml (using defaults)");
1868+ }
1869+
1870+ // Check for .hl files
1871+ let hl_count = collect_hl_files_recursive(Path::new(".")).len();
1872+ eprintln!(" .hl files: {} found in current directory", hl_count);
1873+
1874+ eprintln!();
1875+ if ok {
1876+ eprintln!("all checks passed");
1877+ } else {
1878+ eprintln!("some checks failed — see above");
1879+ process::exit(1);
1880+ }
1881+ return;
1882+ }
1883+
1884+ // Handle "migrate" subcommand — auto-upgrade deprecated syntax
1885+ if args.len() >= 2 && args[1] == "migrate" {
1886+ let target = if args.len() >= 3 { &args[2] } else { "." };
1887+ let path = Path::new(target);
1888+ let hl_files = if path.is_dir() {
1889+ collect_hl_files_recursive(path)
1890+ } else {
1891+ vec![PathBuf::from(target)]
1892+ };
1893+ if hl_files.is_empty() {
1894+ eprintln!("no .hl files found in {}", target);
1895+ process::exit(1);
1896+ }
1897+ let mut total_changes = 0usize;
1898+ for file in &hl_files {
1899+ let input = match fs::read_to_string(file) {
1900+ Ok(s) => s,
1901+ Err(_) => continue,
1902+ };
1903+ let mut output = String::new();
1904+ let mut changes = 0usize;
1905+ for line in input.lines() {
1906+ let mut migrated = line.to_string();
1907+ // Migrate @divider -> @hr
1908+ if migrated.trim().starts_with("@divider") {
1909+ migrated = migrated.replace("@divider", "@hr");
1910+ changes += 1;
1911+ }
1912+ // Migrate @ul -> @list
1913+ if migrated.trim().starts_with("@ul") && !migrated.trim().starts_with("@unless") {
1914+ migrated = migrated.replacen("@ul", "@list", 1);
1915+ changes += 1;
1916+ }
1917+ // Migrate align-center -> center-x (common mistake)
1918+ if migrated.contains("align-center") {
1919+ migrated = migrated.replace("align-center", "center-x");
1920+ changes += 1;
1921+ }
1922+ output.push_str(&migrated);
1923+ output.push('\n');
1924+ }
1925+ // Remove trailing extra newline if original didn't end with one
1926+ if !input.ends_with('\n') && output.ends_with('\n') {
1927+ output.pop();
1928+ }
1929+ if changes > 0 {
1930+ match fs::write(file, &output) {
1931+ Ok(()) => {
1932+ eprintln!("migrated {} ({} change{})", file.display(), changes, if changes == 1 { "" } else { "s" });
1933+ total_changes += changes;
1934+ }
1935+ Err(e) => eprintln!("error: {}: {}", file.display(), e),
1936+ }
1937+ }
1938+ }
1939+ if total_changes == 0 {
1940+ eprintln!("no migrations needed");
1941+ } else {
1942+ eprintln!("\n{} total change(s) across {} file(s)", total_changes, hl_files.len());
1943+ }
1944+ return;
1945+ }
1946+
1947+ // Handle "benchmark" subcommand — measure compile time and output size
1948+ if args.len() >= 2 && args[1] == "benchmark" {
1949+ if args.len() < 3 {
1950+ eprintln!("usage: htmlang benchmark <file.hl | dir>");
1951+ process::exit(1);
1952+ }
1953+ let target = &args[2];
1954+ let path = Path::new(target);
1955+ let hl_files = if path.is_dir() {
1956+ collect_hl_files_recursive(path)
1957+ } else {
1958+ vec![PathBuf::from(target)]
1959+ };
1960+ if hl_files.is_empty() {
1961+ eprintln!("no .hl files found in {}", target);
1962+ process::exit(1);
1963+ }
1964+
1965+ let mut total_source = 0usize;
1966+ let mut total_output = 0usize;
1967+ let mut total_css_rules = 0usize;
1968+ let mut total_elements = 0usize;
1969+ let mut total_errors = 0usize;
1970+
1971+ let start = std::time::Instant::now();
1972+ for file in &hl_files {
1973+ let input = match fs::read_to_string(file) {
1974+ Ok(s) => s,
1975+ Err(_) => continue,
1976+ };
1977+ total_source += input.len();
1978+ let base = file.parent();
1979+ let result = htmlang::parser::parse_with_base(&input, base);
1980+ let has_errors = result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error);
1981+ if has_errors {
1982+ total_errors += 1;
1983+ continue;
1984+ }
1985+ let html = htmlang::codegen::generate(&result.document);
1986+ total_output += html.len();
1987+ total_css_rules += html.matches('{').count().saturating_sub(1);
1988+ let mut ec = 0;
1989+ let mut colors = std::collections::HashSet::new();
1990+ let mut fonts = std::collections::HashSet::new();
1991+ count_elements(&result.document.nodes, &mut ec, &mut colors, &mut fonts);
1992+ total_elements += ec;
1993+ }
1994+ let elapsed = start.elapsed();
1995+
1996+ eprintln!("--- benchmark results ---");
1997+ eprintln!(" files: {}", hl_files.len());
1998+ eprintln!(" compile time: {:.1}ms", elapsed.as_secs_f64() * 1000.0);
1999+ eprintln!(" source size: {} bytes", total_source);
2000+ eprintln!(" output size: {} bytes", total_output);
2001+ if total_source > 0 {
2002+ eprintln!(" ratio: {:.1}x", total_output as f64 / total_source as f64);
2003+ }
2004+ eprintln!(" elements: {}", total_elements);
2005+ eprintln!(" CSS rules: ~{}", total_css_rules);
2006+ if total_errors > 0 {
2007+ eprintln!(" errors: {} file(s) had errors", total_errors);
2008+ }
2009+ if hl_files.len() > 1 {
2010+ let per_file = elapsed.as_secs_f64() * 1000.0 / hl_files.len() as f64;
2011+ eprintln!(" per file: {:.2}ms", per_file);
2012+ }
2013+ return;
2014+ }
2015+
18092016 // Handle "convert" subcommand
18102017 if args.len() >= 2 && args[1] == "convert" {
18112018 if args.len() < 3 {
src/parser.rs+73
@@ -100,6 +100,12 @@ struct ParseContext {
100100 deprecated_fns: HashMap<String, String>,
101101 /// Theme tokens: (name, value) pairs from @theme
102102 theme_tokens: Vec<(String, String)>,
103+ /// Mixin definitions: name -> list of attributes
104+ mixins: HashMap<String, Vec<Attribute>>,
105+ /// Line numbers of @mixin definitions (name -> line)
106+ mixin_lines: HashMap<String, usize>,
107+ /// Track which @mixin definitions are referenced (for unused warnings)
108+ used_mixins: HashSet<String>,
103109 /// Canonical URL
104110 canonical: Option<String>,
105111 /// Base URL for relative links
@@ -154,6 +160,9 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
154160 base_url: None,
155161 font_faces: Vec::new(),
156162 json_ld_blocks: Vec::new(),
163+ mixins: HashMap::new(),
164+ mixin_lines: HashMap::new(),
165+ used_mixins: HashSet::new(),
157166 };
158167 let nodes = parser.parse_children(0, &mut ctx);
159168 validate_tree(&nodes, None, &mut ctx.diagnostics);
@@ -478,6 +487,37 @@ impl Parser {
478487 return Ok(None);
479488 }
480489
490+ // --- @mixin directive (composable style groups) ---
491+
492+ if let Some(rest) = content.strip_prefix("@mixin ") {
493+ let rest = rest.trim();
494+ if let Some(bracket_start) = rest.find('[') {
495+ let name = rest[..bracket_start].trim();
496+ let attrs_str = &rest[bracket_start..];
497+ let (attrs, _) = parse_attr_brackets(attrs_str, line_num, ctx)?;
498+ ctx.mixins.insert(name.to_string(), attrs);
499+ ctx.mixin_lines.entry(name.to_string()).or_insert(line_num);
500+ }
501+ return Ok(None);
502+ }
503+
504+ // --- @assert directive (compile-time assertions) ---
505+
506+ if let Some(rest) = content.strip_prefix("@assert ") {
507+ let rest = rest.trim();
508+ let condition = substitute_vars(rest, &ctx.variables);
509+ track_var_refs(rest, &mut ctx.used_variables);
510+ if !evaluate_condition(&condition) {
511+ ctx.diagnostics.push(Diagnostic {
512+ line: line_num,
513+ message: format!("assertion failed: {}", rest),
514+ severity: Severity::Error,
515+ source_line: Some(content.clone()),
516+ });
517+ }
518+ return Ok(None);
519+ }
520+
481521 if let Some(rest) = content.strip_prefix("@include ") {
482522 let filename = substitute_vars(rest.trim(), &ctx.variables);
483523 let resolved = match &ctx.base_path {
@@ -1617,6 +1657,7 @@ const KNOWN_DIRECTIVES: &[&str] = &[
16171657 "unless", "og", "breakpoint", "lang", "favicon",
16181658 "use", "theme", "deprecated", "extends",
16191659 "canonical", "base", "font-face", "json-ld",
1660+ "mixin", "assert",
16201661];
16211662
16221663fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
@@ -1884,6 +1925,9 @@ fn has_css_unit(value: &str) -> bool {
18841925 CSS_UNIT_SUFFIXES.iter().any(|u| value.ends_with(u))
18851926 || value.starts_with("var(")
18861927 || value.starts_with("calc(")
1928+ || value.starts_with("clamp(")
1929+ || value.starts_with("min(")
1930+ || value.starts_with("max(")
18871931}
18881932
18891933/// Attributes that accept numeric OR keyword values.
@@ -2029,6 +2073,17 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
20292073 continue;
20302074 }
20312075
2076+ // ...$mixin spread — expand mixin attributes
2077+ if part.starts_with("...$") {
2078+ let name = &part[4..];
2079+ if let Some(mixin_attrs) = ctx.mixins.get(name) {
2080+ ctx.used_mixins.insert(name.to_string());
2081+ attrs.extend(mixin_attrs.clone());
2082+ continue;
2083+ }
2084+ // Fall through to try as define
2085+ }
2086+
20322087 // $define reference — expand
20332088 if part.starts_with('$') {
20342089 let name = &part[1..];
@@ -2037,6 +2092,12 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
20372092 attrs.extend(define_attrs.clone());
20382093 continue;
20392094 }
2095+ // Also try mixin expansion with $name syntax
2096+ if let Some(mixin_attrs) = ctx.mixins.get(name) {
2097+ ctx.used_mixins.insert(name.to_string());
2098+ attrs.extend(mixin_attrs.clone());
2099+ continue;
2100+ }
20402101 }
20412102
20422103 // Substitute variables in value
@@ -3044,4 +3105,16 @@ fn check_unused(ctx: &mut ParseContext) {
30443105 });
30453106 }
30463107 }
3108+
3109+ // Check unused @mixin definitions
3110+ for (name, &line) in &ctx.mixin_lines {
3111+ if !ctx.used_mixins.contains(name) {
3112+ ctx.diagnostics.push(Diagnostic {
3113+ line,
3114+ message: format!("unused mixin '{}' (defined but never referenced)", name),
3115+ severity: Severity::Warning,
3116+ source_line: None,
3117+ });
3118+ }
3119+ }
30473120}
tests/snapshots.rs+146
@@ -2884,3 +2884,149 @@ fn no_warning_script_attrs() {
28842884 diags
28852885 );
28862886}
2887+
2888+// ---------------------------------------------------------------------------
2889+// @mixin and spread attributes
2890+// ---------------------------------------------------------------------------
2891+
2892+#[test]
2893+fn snapshot_mixin_spread() {
2894+ snapshot_test("mixin_spread");
2895+}
2896+
2897+#[test]
2898+fn mixin_expands_in_attrs() {
2899+ let output = compile("@mixin card [padding 20, rounded 8]\n@el [...$card]\n Hi");
2900+ assert!(output.contains("padding:20px"), "mixin should expand padding: {}", output);
2901+ assert!(output.contains("border-radius:8px"), "mixin should expand rounded: {}", output);
2902+}
2903+
2904+#[test]
2905+fn mixin_with_dollar_syntax() {
2906+ let output = compile("@mixin card [padding 20, rounded 8]\n@el [$card]\n Hi");
2907+ assert!(output.contains("padding:20px"), "mixin with $ syntax should expand: {}", output);
2908+}
2909+
2910+#[test]
2911+fn mixin_compose_with_extra_attrs() {
2912+ let output = compile("@mixin base [padding 10]\n@el [...$base, background red]\n Hi");
2913+ assert!(output.contains("padding:10px"), "mixin should expand: {}", output);
2914+ assert!(output.contains("background:red"), "extra attrs should work: {}", output);
2915+}
2916+
2917+#[test]
2918+fn warning_unused_mixin() {
2919+ let diags = parse_diagnostics("@mixin card [padding 10]\n@el [background red]");
2920+ assert!(
2921+ diags.iter().any(|d| d.message.contains("unused mixin")),
2922+ "expected unused mixin warning, got: {:?}",
2923+ diags
2924+ );
2925+}
2926+
2927+#[test]
2928+fn no_warning_used_mixin() {
2929+ let diags = parse_diagnostics("@mixin card [padding 10]\n@el [...$card]");
2930+ assert!(
2931+ !diags.iter().any(|d| d.message.contains("unused mixin")),
2932+ "should not warn about used mixin, got: {:?}",
2933+ diags
2934+ );
2935+}
2936+
2937+// ---------------------------------------------------------------------------
2938+// @assert directive
2939+// ---------------------------------------------------------------------------
2940+
2941+#[test]
2942+fn snapshot_assert_directive() {
2943+ snapshot_test("assert_directive");
2944+}
2945+
2946+#[test]
2947+fn assert_passes_no_error() {
2948+ let diags = parse_diagnostics("@let x hello\n@assert $x == hello\n@el [padding 10]");
2949+ assert!(
2950+ !diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error),
2951+ "passing assertion should produce no error, got: {:?}",
2952+ diags
2953+ );
2954+}
2955+
2956+#[test]
2957+fn assert_fails_produces_error() {
2958+ let diags = parse_diagnostics("@let x hello\n@assert $x == world");
2959+ assert!(
2960+ diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error && d.message.contains("assertion failed")),
2961+ "failing assertion should produce error, got: {:?}",
2962+ diags
2963+ );
2964+}
2965+
2966+#[test]
2967+fn assert_not_equal() {
2968+ let diags = parse_diagnostics("@let x hello\n@assert $x != world\n@el");
2969+ assert!(
2970+ !diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error),
2971+ "!= assertion should pass when values differ, got: {:?}",
2972+ diags
2973+ );
2974+}
2975+
2976+#[test]
2977+fn assert_truthy() {
2978+ let diags = parse_diagnostics("@let x true\n@assert $x\n@el");
2979+ assert!(
2980+ !diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error),
2981+ "truthy assertion should pass, got: {:?}",
2982+ diags
2983+ );
2984+}
2985+
2986+// ---------------------------------------------------------------------------
2987+// clamp() / min() / max() CSS functions
2988+// ---------------------------------------------------------------------------
2989+
2990+#[test]
2991+fn snapshot_clamp_css() {
2992+ snapshot_test("clamp_css");
2993+}
2994+
2995+#[test]
2996+fn clamp_passthrough() {
2997+ let output = compile("@el [size clamp(16px, 2vw, 24px)]");
2998+ assert!(output.contains("font-size:clamp(16px, 2vw, 24px)"), "clamp should pass through: {}", output);
2999+}
3000+
3001+#[test]
3002+fn min_passthrough() {
3003+ let output = compile("@el [width min(100%, 800px)]");
3004+ assert!(output.contains("width:min(100%, 800px)"), "min() should pass through: {}", output);
3005+}
3006+
3007+#[test]
3008+fn max_passthrough() {
3009+ let output = compile("@el [padding max(10px, 2vw)]");
3010+ assert!(output.contains("padding:max(10px, 2vw)"), "max() should pass through: {}", output);
3011+}
3012+
3013+// ---------------------------------------------------------------------------
3014+// Round-trip test: parse -> codegen -> convert
3015+// ---------------------------------------------------------------------------
3016+
3017+#[test]
3018+fn round_trip_basic() {
3019+ let input = "@page Round Trip\n@column [padding 20, spacing 10]\n @text [bold, size 24] Hello\n @text World";
3020+ let result = htmlang::parser::parse(input);
3021+ assert!(!result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error));
3022+ let html = htmlang::codegen::generate_dev(&result.document);
3023+ // Convert back to .hl
3024+ let hl = htmlang::convert::convert(&html);
3025+ // The round-trip should produce valid .hl that parses without errors
3026+ let result2 = htmlang::parser::parse(&hl);
3027+ assert!(
3028+ !result2.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error),
3029+ "round-trip should produce valid .hl, got errors: {:?}",
3030+ result2.diagnostics
3031+ );
3032+}
tests/snapshots/assert_directive.hlnew file+10
@@ -0,0 +1,10 @@
1+@page Assert Test
2+
3+@let spacing 16
4+@let primary #3b82f6
5+
6+@assert $spacing == 16
7+@assert $primary != red
8+
9+@el [padding $spacing, color $primary]
10+ Assertions passed
tests/snapshots/clamp_css.hlnew file+4
@@ -0,0 +1,4 @@
1+@page Clamp CSS Test
2+
3+@el [size clamp(16px, 2vw, 24px), width min(100%, 800px), padding max(10px, 2vw)]
4+ Fluid typography and sizing
tests/snapshots/mixin_spread.hlnew file+10
@@ -0,0 +1,10 @@
1+@page Mixin Test
2+
3+@mixin card-style [padding 20, rounded 8, background #f3f4f6]
4+@mixin interactive [cursor pointer, hover:background #e5e7eb, transition all 0.15s ease]
5+
6+@el [...$card-style, ...$interactive]
7+ Hello mixin world
8+
9+@el [$card-style, border 1 #ccc]
10+ Using $define syntax too