on master help sign in
Fix CI: formatting, Windows line endings, playground deploy
6b4f106iko
.gitattributesnew file+3
@@ -0,0 +1,3 @@
1+# Check out text files with LF endings on every platform, so snapshot
2+# fixtures compare equal on Windows too.
3+* text=auto eol=lf
.github/workflows/playground.yml+4−5
@@ -40,13 +40,12 @@ jobs:
40 --out-dir target/wasm-pkg \
40 --out-dir target/wasm-pkg \
41 target/wasm32-unknown-unknown/release/htmlang_wasm.wasm
41 target/wasm32-unknown-unknown/release/htmlang_wasm.wasm
42
42
43− - name: Build htmlang CLI
43+ - name: Assemble the playground
44− run: cargo build --release --bin htmlang
45−
46− - name: Generate playground HTML
47 run: |
44 run: |
48 mkdir -p dist
45 mkdir -p dist
49− ./target/release/htmlang playground dist/index.html
46+ cp crates/htmlang-wasm/playground/index.html dist/
47+ cp target/wasm-pkg/htmlang_wasm.js target/wasm-pkg/htmlang_wasm_bg.wasm dist/
48+ cp examples/demo.hl dist/
50
49
51 - name: Upload Pages artifact
50 - name: Upload Pages artifact
52 uses: actions/upload-pages-artifact@v3
51 uses: actions/upload-pages-artifact@v3
crates/htmlang-core/src/ast.rs+14−4
@@ -38,7 +38,6 @@ pub struct Element {
38 pub line_num: usize,
38 pub line_num: usize,
39}
39}
40
40
41−
42#[derive(Debug, Clone)]
41#[derive(Debug, Clone)]
43pub struct Attribute {
42pub struct Attribute {
44 pub key: String,
43 pub key: String,
@@ -95,7 +94,9 @@ impl ElementKind {
95 /// the semantic containers)?
94 /// the semantic containers)?
96 pub fn is_column(&self) -> bool {
95 pub fn is_column(&self) -> bool {
97 matches!(self, ElementKind::El)
96 matches!(self, ElementKind::El)
97+ || self
98+ .spec()
98− || self.spec().is_some_and(|spec| spec.css.contains("flex-direction:column"))
99+ .is_some_and(|spec| spec.css.contains("flex-direction:column"))
99 }
100 }
100
101
101 /// Is this the table element named `name` (e.g. `"main"`)?
102 /// Is this the table element named `name` (e.g. `"main"`)?
@@ -123,8 +124,16 @@ impl ElementKind {
123 /// Every element name, for suggestions and completions.
124 /// Every element name, for suggestions and completions.
124 pub fn all_names() -> impl Iterator<Item = &'static str> {
125 pub fn all_names() -> impl Iterator<Item = &'static str> {
125 [
126 [
126− "row", "el", "text", "paragraph", "link", "image", "script", "fragment",
127+ "row",
128+ "el",
129+ "text",
130+ "paragraph",
131+ "link",
132+ "image",
133+ "script",
134+ "fragment",
127− "children", "slot",
135+ "children",
136+ "slot",
128 ]
137 ]
129 .into_iter()
138 .into_iter()
130 .chain(TAGS.iter().map(|spec| spec.name))
139 .chain(TAGS.iter().map(|spec| spec.name))
@@ -176,6 +185,7 @@ impl TagSpec {
176}
185}
177
186
178/// Every element besides the core ones in [`ElementKind`].
187/// Every element besides the core ones in [`ElementKind`].
188+#[rustfmt::skip]
179pub static TAGS: &[TagSpec] = &[
189pub static TAGS: &[TagSpec] = &[
180 TagSpec { name: "nav", html: "nav", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
190 TagSpec { name: "nav", html: "nav", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
181 TagSpec { name: "header", html: "header", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
191 TagSpec { name: "header", html: "header", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
crates/htmlang-core/src/codegen.rs+33−24
@@ -159,7 +159,11 @@ impl StyleCollector {
159 fn to_css_formatted(&self, dev: bool) -> String {
159 fn to_css_formatted(&self, dev: bool) -> String {
160 let mut css = String::new();
160 let mut css = String::new();
161 let inner_indent = if dev { " " } else { "" };
161 let inner_indent = if dev { " " } else { "" };
162− css.push_str(if dev { "@layer htmlang {\n" } else { "@layer htmlang{" });
162+ css.push_str(if dev {
163+ "@layer htmlang {\n"
164+ } else {
165+ "@layer htmlang{"
166+ });
163
167
164 // Non-responsive rules. Base and pseudo rules are merged separately by
168 // Non-responsive rules. Base and pseudo rules are merged separately by
165 // identical body so that e.g. `.a,.b{display:flex;flex-direction:column;}`
169 // identical body so that e.g. `.a,.b{display:flex;flex-direction:column;}`
@@ -595,7 +599,6 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
595 generate_node(node, None, &mut body, &mut styles, &mut ctx);
599 generate_node(node, None, &mut body, &mut styles, &mut ctx);
596 }
600 }
597
601
598−
599 let element_css = build_element_css(doc, &styles, dev);
602 let element_css = build_element_css(doc, &styles, dev);
600
603
601 // Build meta tags string
604 // Build meta tags string
@@ -774,9 +777,8 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
774fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> String {
777fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> String {
775 let mut element_css = String::new();
778 let mut element_css = String::new();
776
779
777− // Collect all CSS custom properties (explicit `@let --name` / `@theme`
780+ // Collect the CSS custom properties declared with `@let --name` so they
778− // tokens, plus any auto-extracted repeats) so they can be emitted in a
779− // single `:root` block below.
781+ // can be emitted in a single `:root` block below.
780 let mut root_vars: Vec<(String, String)> = Vec::new();
782 let mut root_vars: Vec<(String, String)> = Vec::new();
781 for (name, value) in &doc.css_vars {
783 for (name, value) in &doc.css_vars {
782 root_vars.push((name.clone(), value.clone()));
784 root_vars.push((name.clone(), value.clone()));
@@ -834,7 +836,10 @@ fn reset_css(dev: bool, focus_visible_css: &str) -> String {
834 };
836 };
835 let rules = format!("{}{}", base, focus_visible_css);
837 let rules = format!("{}{}", base, focus_visible_css);
836 if dev {
838 if dev {
839+ format!(
837− format!("@layer hl-reset, htmlang;\n@layer hl-reset {{\n{}}}\n", rules)
840+ "@layer hl-reset, htmlang;\n@layer hl-reset {{\n{}}}\n",
841+ rules
842+ )
838 } else {
843 } else {
839 format!("@layer hl-reset,htmlang;@layer hl-reset{{{}}}", rules)
844 format!("@layer hl-reset,htmlang;@layer hl-reset{{{}}}", rules)
840 }
845 }
@@ -879,12 +884,10 @@ fn generate_node(
879 match node {
884 match node {
880 Node::Element(elem) => generate_element(elem, parent_kind, out, styles, ctx),
885 Node::Element(elem) => generate_element(elem, parent_kind, out, styles, ctx),
881 Node::Text(segments) => {
886 Node::Text(segments) => {
882− let needs_wrap = matches!(
883− parent_kind,
884− Some(ElementKind::Row | ElementKind::El)
887+ let needs_wrap = matches!(parent_kind, Some(ElementKind::Row | ElementKind::El))
885− ) || parent_kind
888+ || parent_kind
886− .and_then(ElementKind::spec)
889+ .and_then(ElementKind::spec)
887− .is_some_and(|spec| spec.wraps_text);
890+ .is_some_and(|spec| spec.wraps_text);
888 if needs_wrap {
891 if needs_wrap {
889 out.push_str(&ctx.indent());
892 out.push_str(&ctx.indent());
890 out.push_str("<span>");
893 out.push_str("<span>");
@@ -903,7 +906,6 @@ fn generate_node(
903 }
906 }
904}
907}
905
908
906−
907/// Emit the argument of an element whose argument is an HTML attribute
909/// Emit the argument of an element whose argument is an HTML attribute
908/// (`@iframe URL` → `src="URL"`, `@form /submit` → `action="/submit"`).
910/// (`@iframe URL` → `src="URL"`, `@form /submit` → `action="/submit"`).
909fn emit_argument_attr(out: &mut String, elem: &Element) {
911fn emit_argument_attr(out: &mut String, elem: &Element) {
@@ -1091,7 +1093,6 @@ fn generate_element(
1091 out.push('<');
1093 out.push('<');
1092 out.push_str(tag);
1094 out.push_str(tag);
1093
1095
1094−
1095 if elem.kind == ElementKind::Link
1096 if elem.kind == ElementKind::Link
1096 && let Some(url) = &elem.argument
1097 && let Some(url) = &elem.argument
1097 {
1098 {
@@ -1132,8 +1133,8 @@ fn generate_element(
1132
1133
1133 // Children
1134 // Children
1134 ctx.depth += 1;
1135 ctx.depth += 1;
1135− let is_paragraph = elem.kind == ElementKind::Paragraph
1136+ let is_paragraph =
1136− || elem.kind.spec().is_some_and(|spec| spec.inline);
1137+ elem.kind == ElementKind::Paragraph || elem.kind.spec().is_some_and(|spec| spec.inline);
1137 for (i, child) in elem.children.iter().enumerate() {
1138 for (i, child) in elem.children.iter().enumerate() {
1138 generate_node(child, Some(&elem.kind), out, styles, ctx);
1139 generate_node(child, Some(&elem.kind), out, styles, ctx);
1139 if is_paragraph && i < elem.children.len() - 1 {
1140 if is_paragraph && i < elem.children.len() - 1 {
@@ -1341,9 +1342,20 @@ fn compute_class(
1341
1342
1342 let dark = attrs_to_css(attrs, "dark:", kind, parent_kind, has_overlay_children);
1343 let dark = attrs_to_css(attrs, "dark:", kind, parent_kind, has_overlay_children);
1343 let print = attrs_to_css(attrs, "print:", kind, parent_kind, has_overlay_children);
1344 let print = attrs_to_css(attrs, "print:", kind, parent_kind, has_overlay_children);
1344− let motion_safe = attrs_to_css(attrs, "motion-safe:", kind, parent_kind, has_overlay_children);
1345+ let motion_safe = attrs_to_css(
1346+ attrs,
1347+ "motion-safe:",
1348+ kind,
1349+ parent_kind,
1350+ has_overlay_children,
1351+ );
1345− let motion_reduce =
1352+ let motion_reduce = attrs_to_css(
1353+ attrs,
1354+ "motion-reduce:",
1355+ kind,
1356+ parent_kind,
1346− attrs_to_css(attrs, "motion-reduce:", kind, parent_kind, has_overlay_children);
1357+ has_overlay_children,
1358+ );
1347 let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind, has_overlay_children);
1359 let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind, has_overlay_children);
1348 let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind, has_overlay_children);
1360 let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind, has_overlay_children);
1349
1361
@@ -1491,8 +1503,6 @@ fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option
1491/// semantic wrapper that behaves like a column.
1503/// semantic wrapper that behaves like a column.
1492const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
1504const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
1493
1505
1494−
1495−
1496fn attrs_to_css(
1506fn attrs_to_css(
1497 attrs: &[Attribute],
1507 attrs: &[Attribute],
1498 state_prefix: &str,
1508 state_prefix: &str,
@@ -2002,7 +2012,8 @@ fn css_px(value: &str) -> String {
2002 return "0".to_string();
2012 return "0".to_string();
2003 }
2013 }
2004 let is_bare_number = !v.is_empty()
2014 let is_bare_number = !v.is_empty()
2015+ && v.bytes()
2005− && v.bytes().all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'+'))
2016+ .all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'+'))
2006 && v.parse::<f64>().is_ok();
2017 && v.parse::<f64>().is_ok();
2007 if is_bare_number {
2018 if is_bare_number {
2008 format!("{}px", v)
2019 format!("{}px", v)
@@ -2147,5 +2158,3 @@ fn vlq_encode(value: i64, out: &mut String) {
2147 }
2158 }
2148 }
2159 }
2149}
2160}
2150−
2151−
crates/htmlang-core/src/codegen_tests.rs+2−1
@@ -47,7 +47,8 @@ mod tests {
47
47
48 #[test]
48 #[test]
49 fn dev_mode_is_deterministic_across_runs() {
49 fn dev_mode_is_deterministic_across_runs() {
50+ let src =
50− let src = "@row [spacing 10]\n @text [font-weight bold] a\n @text [font-style italic] b\n";
51+ "@row [spacing 10]\n @text [font-weight bold] a\n @text [font-style italic] b\n";
51 let r1 = parse(src);
52 let r1 = parse(src);
52 let r2 = parse(src);
53 let r2 = parse(src);
53 assert_eq!(generate_dev(&r1.document), generate_dev(&r2.document));
54 assert_eq!(generate_dev(&r1.document), generate_dev(&r2.document));
crates/htmlang-core/src/expr.rs+39−10
@@ -375,7 +375,12 @@ fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
375 if args.len() == n {
375 if args.len() == n {
376 Ok(())
376 Ok(())
377 } else {
377 } else {
378+ Err(format!(
378− Err(format!("{}() takes {} argument{}", name, n, if n == 1 { "" } else { "s" }))
379+ "{}() takes {} argument{}",
380+ name,
381+ n,
382+ if n == 1 { "" } else { "s" }
383+ ))
379 }
384 }
380 };
385 };
381 let color = |rgb: (u8, u8, u8)| Value::Str(format!("#{:02x}{:02x}{:02x}", rgb.0, rgb.1, rgb.2));
386 let color = |rgb: (u8, u8, u8)| Value::Str(format!("#{:02x}{:02x}{:02x}", rgb.0, rgb.1, rgb.2));
@@ -394,10 +399,22 @@ fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
394 other => other.to_string().contains(&text(1)),
399 other => other.to_string().contains(&text(1)),
395 })
400 })
396 }
401 }
402+ "starts-with" => {
403+ arity(2)?;
397− "starts-with" => { arity(2)?; Value::Bool(text(0).starts_with(&text(1))) }
404+ Value::Bool(text(0).starts_with(&text(1)))
405+ }
406+ "ends-with" => {
407+ arity(2)?;
398− "ends-with" => { arity(2)?; Value::Bool(text(0).ends_with(&text(1))) }
408+ Value::Bool(text(0).ends_with(&text(1)))
409+ }
410+ "uppercase" => {
411+ arity(1)?;
399− "uppercase" => { arity(1)?; Value::Str(text(0).to_uppercase()) }
412+ Value::Str(text(0).to_uppercase())
413+ }
414+ "lowercase" => {
415+ arity(1)?;
400− "lowercase" => { arity(1)?; Value::Str(text(0).to_lowercase()) }
416+ Value::Str(text(0).to_lowercase())
417+ }
401 "capitalize" => {
418 "capitalize" => {
402 arity(1)?;
419 arity(1)?;
403 let s = text(0);
420 let s = text(0);
@@ -407,7 +424,10 @@ fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
407 None => String::new(),
424 None => String::new(),
408 })
425 })
409 }
426 }
427+ "trim" => {
428+ arity(1)?;
410− "trim" => { arity(1)?; Value::Str(text(0).trim().to_string()) }
429+ Value::Str(text(0).trim().to_string())
430+ }
411 "length" => {
431 "length" => {
412 arity(1)?;
432 arity(1)?;
413 Value::Num(match &args[0] {
433 Value::Num(match &args[0] {
@@ -415,7 +435,10 @@ fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
415 other => other.to_string().chars().count(),
435 other => other.to_string().chars().count(),
416 } as f64)
436 } as f64)
417 }
437 }
438+ "reverse" => {
439+ arity(1)?;
418− "reverse" => { arity(1)?; Value::Str(text(0).chars().rev().collect()) }
440+ Value::Str(text(0).chars().rev().collect())
441+ }
419 "truncate" => {
442 "truncate" => {
420 arity(2)?;
443 arity(2)?;
421 let (s, n) = (text(0), num(1)? as usize);
444 let (s, n) = (text(0), num(1)? as usize);
@@ -425,7 +448,10 @@ fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
425 s
448 s
426 })
449 })
427 }
450 }
451+ "replace" => {
452+ arity(3)?;
428− "replace" => { arity(3)?; Value::Str(text(0).replace(&text(1), &text(2))) }
453+ Value::Str(text(0).replace(&text(1), &text(2)))
454+ }
429 "default" => {
455 "default" => {
430 arity(2)?;
456 arity(2)?;
431 let s = text(0);
457 let s = text(0);
@@ -501,7 +527,6 @@ fn mix_colors(c1: (u8, u8, u8), c2: (u8, u8, u8), weight: f64) -> (u8, u8, u8) {
501 (r, g, b)
527 (r, g, b)
502}
528}
503
529
504−
505/// Replace `$name` references inside a string literal.
530/// Replace `$name` references inside a string literal.
506fn interpolate(s: &str, resolve: Resolver) -> String {
531fn interpolate(s: &str, resolve: Resolver) -> String {
507 let mut out = String::with_capacity(s.len());
532 let mut out = String::with_capacity(s.len());
@@ -513,7 +538,11 @@ fn interpolate(s: &str, resolve: Resolver) -> String {
513 if end == 0 {
538 if end == 0 {
514 out.push('$');
539 out.push('$');
515 } else {
540 } else {
541+ out.push_str(
542+ &resolve(&after[..end])
543+ .map(|v| v.to_string())
516− out.push_str(&resolve(&after[..end]).map(|v| v.to_string()).unwrap_or_default());
544+ .unwrap_or_default(),
545+ );
517 }
546 }
518 rest = &after[end..];
547 rest = &after[end..];
519 }
548 }
crates/htmlang-core/src/parser.rs+80−50
@@ -226,12 +226,22 @@ impl Evaluator {
226 nodes
226 nodes
227 }
227 }
228
228
229+ fn eval(
230+ &mut self,
231+ node: &Syntax,
232+ ctx: &mut ParseContext,
229− fn eval(&mut self, node: &Syntax, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
233+ ) -> Result<Option<Vec<Node>>, ParseError> {
230 let line_num = node.line();
234 let line_num = node.line();
231 ctx.current_line = line_num;
235 ctx.current_line = line_num;
232 let (content, current_indent, children) = match node {
236 let (content, current_indent, children) = match node {
233 Syntax::Raw { text, .. } => return Ok(Some(vec![Node::Raw(text.clone())])),
237 Syntax::Raw { text, .. } => return Ok(Some(vec![Node::Raw(text.clone())])),
234− Syntax::Function { name, params, defaults, body, .. } => {
238+ Syntax::Function {
239+ name,
240+ params,
241+ defaults,
242+ body,
243+ ..
244+ } => {
235 self.define_function(name, params, defaults, body, line_num, ctx);
245 self.define_function(name, params, defaults, body, line_num, ctx);
236 return Ok(None);
246 return Ok(None);
237 }
247 }
@@ -250,10 +260,20 @@ impl Evaluator {
250 }
260 }
251 return Ok(chosen.map(|body| self.eval_scoped(body, ctx)));
261 return Ok(chosen.map(|body| self.eval_scoped(body, ctx)));
252 }
262 }
253− Syntax::Each { header, body, empty, .. } => {
263+ Syntax::Each {
264+ header,
265+ body,
266+ empty,
267+ ..
268+ } => {
254 return self.eval_each(header, body, empty, line_num, ctx).map(Some);
269 return self.eval_each(header, body, empty, line_num, ctx).map(Some);
255 }
270 }
271+ Syntax::Line {
272+ text,
273+ indent,
274+ children,
275+ ..
256− Syntax::Line { text, indent, children, .. } => (text.clone(), *indent, children),
276+ } => (text.clone(), *indent, children),
257 };
277 };
258
278
259 // --- Directives ---
279 // --- Directives ---
@@ -288,7 +308,6 @@ impl Evaluator {
288 return Ok(None);
308 return Ok(None);
289 }
309 }
290
310
291−
292 if let Some(rest) = content.strip_prefix("@let ") {
311 if let Some(rest) = content.strip_prefix("@let ") {
293 let rest = rest.trim();
312 let rest = rest.trim();
294
313
@@ -355,7 +374,6 @@ impl Evaluator {
355 return Ok(None);
374 return Ok(None);
356 }
375 }
357
376
358−
359 if content == "@head" || content.starts_with("@head ") {
377 if content == "@head" || content.starts_with("@head ") {
360 let trimmed = block_text(children).trim().to_string();
378 let trimmed = block_text(children).trim().to_string();
361 if !trimmed.is_empty() {
379 if !trimmed.is_empty() {
@@ -373,13 +391,13 @@ impl Evaluator {
373 return Ok(None);
391 return Ok(None);
374 }
392 }
375
393
376−
377 // --- @markdown block or file (convert markdown to HTML) ---
394 // --- @markdown block or file (convert markdown to HTML) ---
378 if content.trim() == "@markdown" || content.trim().starts_with("@markdown ") {
395 if content.trim() == "@markdown" || content.trim().starts_with("@markdown ") {
379 let arg = content.trim().strip_prefix("@markdown").unwrap().trim();
396 let arg = content.trim().strip_prefix("@markdown").unwrap().trim();
380 if arg.is_empty() {
397 if arg.is_empty() {
381 // Inline markdown block: indented children
398 // Inline markdown block: indented children
399+ let md_lines: Vec<String> =
382− let md_lines: Vec<String> = block_text(children).lines().map(String::from).collect();
400+ block_text(children).lines().map(String::from).collect();
383 let html = markdown_to_html(&md_lines);
401 let html = markdown_to_html(&md_lines);
384 return Ok(Some(vec![Node::Raw(html)]));
402 return Ok(Some(vec![Node::Raw(html)]));
385 } else {
403 } else {
@@ -416,8 +434,6 @@ impl Evaluator {
416 }
434 }
417 }
435 }
418
436
419−
420−
421 if let Some(rest) = content.strip_prefix("@include ") {
437 if let Some(rest) = content.strip_prefix("@include ") {
422 let rest = rest.trim();
438 let rest = rest.trim();
423 let filename = rest
439 let filename = rest
@@ -487,8 +503,6 @@ impl Evaluator {
487 return Ok(Some(included_nodes));
503 return Ok(Some(included_nodes));
488 }
504 }
489
505
490−
491−
492 // --- @data (load JSON file into variables) ---
506 // --- @data (load JSON file into variables) ---
493
507
494 // @data $name file.json values as $name.key
508 // @data $name file.json values as $name.key
@@ -737,7 +751,10 @@ impl Evaluator {
737 message: "@each requires: @each $item in LIST".to_string(),
751 message: "@each requires: @each $item in LIST".to_string(),
738 });
752 });
739 };
753 };
754+ let names: Vec<&str> = names
755+ .split(',')
740− let names: Vec<&str> = names.split(',').map(|v| v.trim().trim_start_matches('$')).collect();
756+ .map(|v| v.trim().trim_start_matches('$'))
757+ .collect();
741 if names.len() > 2 {
758 if names.len() > 2 {
742 return Err(ParseError {
759 return Err(ParseError {
743 line: line_num,
760 line: line_num,
@@ -752,11 +769,16 @@ impl Evaluator {
752
769
753 // A list loaded from JSON, by name
770 // A list loaded from JSON, by name
754 let data_list = list_src.strip_prefix('$').and_then(|name| {
771 let data_list = list_src.strip_prefix('$').and_then(|name| {
772+ let len = ctx
773+ .variables
755− let len = ctx.variables.get(&format!("{}#", name))?.parse::<usize>().ok()?;
774+ .get(&format!("{}#", name))?
775+ .parse::<usize>()
776+ .ok()?;
756 Some((name.to_string(), len))
777 Some((name.to_string(), len))
757 });
778 });
758 let undefined = list_src.strip_prefix('$').filter(|name| {
779 let undefined = list_src.strip_prefix('$').filter(|name| {
780+ name.chars()
759− name.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
781+ .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
760 && !ctx.variables.contains_key(*name)
782 && !ctx.variables.contains_key(*name)
761 });
783 });
762 let text_items: Vec<String> = match (&data_list, undefined) {
784 let text_items: Vec<String> = match (&data_list, undefined) {
@@ -963,7 +985,10 @@ impl Evaluator {
963
985
964 // Attributes that aren't parameters style the function's root
986 // Attributes that aren't parameters style the function's root
965 // element, and a scoped @style's class goes on it too.
987 // element, and a scoped @style's class goes on it too.
966− let scope_class = ctx.scoped_functions.contains(name).then(|| format!("hl-{}", name));
988+ let scope_class = ctx
989+ .scoped_functions
990+ .contains(name)
991+ .then(|| format!("hl-{}", name));
967 if !forwarded.is_empty() || scope_class.is_some() {
992 if !forwarded.is_empty() || scope_class.is_some() {
968 let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
993 let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
969 Node::Element(e) => Some(e),
994 Node::Element(e) => Some(e),
@@ -981,7 +1006,8 @@ impl Evaluator {
981 match root.attrs.iter_mut().find(|a| a.html && a.key == "class") {
1006 match root.attrs.iter_mut().find(|a| a.html && a.key == "class") {
982 Some(attr) => {
1007 Some(attr) => {
983 let existing = attr.value.take().unwrap_or_default();
1008 let existing = attr.value.take().unwrap_or_default();
1009+ attr.value =
984− attr.value = Some(format!("{} {}", existing, class).trim().to_string());
1010+ Some(format!("{} {}", existing, class).trim().to_string());
985 }
1011 }
986 None => root.attrs.push(Attribute {
1012 None => root.attrs.push(Attribute {
987 key: "class".to_string(),
1013 key: "class".to_string(),
@@ -1184,7 +1210,10 @@ fn parse_single_element(
1184 Some(restore_escapes(&rest_sub))
1210 Some(restore_escapes(&rest_sub))
1185 }
1211 }
1186 } else {
1212 } else {
1187− Some(restore_escapes(&substitute_vars(&protect_escapes(&rest), &ctx.variables)))
1213+ Some(restore_escapes(&substitute_vars(
1214+ &protect_escapes(&rest),
1215+ &ctx.variables,
1216+ )))
1188 };
1217 };
1189
1218
1190 // For @slot, the argument is the slot name
1219 // For @slot, the argument is the slot name
@@ -1231,17 +1260,7 @@ pub fn known_directives() -> &'static [&'static str] {
1231}
1260}
1232
1261
1233const KNOWN_DIRECTIVES: &[&str] = &[
1262const KNOWN_DIRECTIVES: &[&str] = &[
1234− "page",
1263+ "page", "let", "include", "raw", "if", "else", "each", "meta", "head", "style", "markdown",
1235− "let",
1236− "include",
1237− "raw",
1238− "if",
1239− "else",
1240− "each",
1241− "meta",
1242− "head",
1243− "style",
1244− "markdown",
1245 "data",
1264 "data",
1246];
1265];
1247
1266
@@ -1935,7 +1954,10 @@ fn parse_attr_list(
1935
1954
1936 // Color validation for hex colors
1955 // Color validation for hex colors
1937 if !attr.html
1956 if !attr.html
1957+ && matches!(
1938− && matches!(crate::vocab::base_attribute(&attr.key), "background" | "color")
1958+ crate::vocab::base_attribute(&attr.key),
1959+ "background" | "color"
1960+ )
1939 && let Some(ref val) = attr.value
1961 && let Some(ref val) = attr.value
1940 && val.starts_with('#')
1962 && val.starts_with('#')
1941 && !is_valid_hex_color(val)
1963 && !is_valid_hex_color(val)
@@ -2249,8 +2271,6 @@ fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
2249 items
2271 items
2250}
2272}
2251
2273
2252−
2253−
2254/// Evaluate `if(cond, a)` or `if(cond, a, b)` when it is all of `text`,
2274/// Evaluate `if(cond, a)` or `if(cond, a, b)` when it is all of `text`,
2255/// returning the chosen branch (empty when `cond` fails and there is no `b`).
2275/// returning the chosen branch (empty when `cond` fails and there is no `b`).
2256fn choose_if(text: &str, ctx: &mut ParseContext, line: usize) -> Option<String> {
2276fn choose_if(text: &str, ctx: &mut ParseContext, line: usize) -> Option<String> {
@@ -2376,10 +2396,11 @@ pub fn lint(nodes: &[Node]) -> Vec<Diagnostic> {
2376 depth
2396 depth
2377 ));
2397 ));
2378 }
2398 }
2379− if matches!(elem.kind, ElementKind::Row | ElementKind::El)
2399+ if matches!(elem.kind, ElementKind::Row | ElementKind::El) && elem.children.is_empty() {
2380− && elem.children.is_empty()
2400+ warn(format!(
2381− {
2382− warn(format!("empty container (@{}) has no children", elem.kind.name()));
2401+ "empty container (@{}) has no children",
2402+ elem.kind.name()
2403+ ));
2383 }
2404 }
2384 if elem.kind.is_tag("button") && !elem.attrs.iter().any(|a| a.key == "type") {
2405 if elem.kind.is_tag("button") && !elem.attrs.iter().any(|a| a.key == "type") {
2385 warn("@button missing 'type' attribute (defaults to submit)".to_string());
2406 warn("@button missing 'type' attribute (defaults to submit)".to_string());
@@ -2392,7 +2413,6 @@ pub fn lint(nodes: &[Node]) -> Vec<Diagnostic> {
2392 out
2413 out
2393}
2414}
2394
2415
2395−
2396fn validate_tree(
2416fn validate_tree(
2397 nodes: &[Node],
2417 nodes: &[Node],
2398 parent_kind: Option<&ElementKind>,
2418 parent_kind: Option<&ElementKind>,
@@ -2475,8 +2495,7 @@ fn validate_tree(
2475 }
2495 }
2476
2496
2477 // 'rows'/'cols' only on @textarea
2497 // 'rows'/'cols' only on @textarea
2478− if (base == "rows" || base == "cols") && !elem.kind.is_tag("textarea")
2498+ if (base == "rows" || base == "cols") && !elem.kind.is_tag("textarea") {
2479− {
2480 diagnostics.push(Diagnostic {
2499 diagnostics.push(Diagnostic {
2481 line: elem.line_num,
2500 line: elem.line_num,
2482 column: None,
2501 column: None,
@@ -2526,9 +2545,7 @@ fn validate_tree(
2526 source_line: None,
2545 source_line: None,
2527 });
2546 });
2528 }
2547 }
2529− if elem.kind.is_tag("input")
2530− && !elem.attrs.iter().any(|a| a.key == "type")
2548+ if elem.kind.is_tag("input") && !elem.attrs.iter().any(|a| a.key == "type") {
2531− {
2532 diagnostics.push(Diagnostic {
2549 diagnostics.push(Diagnostic {
2533 line: elem.line_num,
2550 line: elem.line_num,
2534 column: None,
2551 column: None,
@@ -2570,7 +2587,10 @@ fn validate_tree(
2570 .find(|a| crate::vocab::base_attribute(&a.key) == "color")
2587 .find(|a| crate::vocab::base_attribute(&a.key) == "color")
2571 .and_then(|a| a.value.as_deref());
2588 .and_then(|a| a.value.as_deref());
2572 if let (Some(bg), Some(fg)) = (bg_color, fg_color)
2589 if let (Some(bg), Some(fg)) = (bg_color, fg_color)
2590+ && let (Some(bg_rgb), Some(fg_rgb)) = (
2573− && let (Some(bg_rgb), Some(fg_rgb)) = (crate::expr::parse_hex_rgb(bg), crate::expr::parse_hex_rgb(fg))
2591+ crate::expr::parse_hex_rgb(bg),
2592+ crate::expr::parse_hex_rgb(fg),
2593+ )
2574 {
2594 {
2575 let ratio = contrast_ratio(bg_rgb, fg_rgb);
2595 let ratio = contrast_ratio(bg_rgb, fg_rgb);
2576 if ratio < 4.5 {
2596 if ratio < 4.5 {
@@ -2612,9 +2632,7 @@ fn validate_tree(
2612 }
2632 }
2613
2633
2614 // @iframe should have title attribute
2634 // @iframe should have title attribute
2615− if elem.kind.is_tag("iframe")
2616− && !elem.attrs.iter().any(|a| a.key == "title")
2635+ if elem.kind.is_tag("iframe") && !elem.attrs.iter().any(|a| a.key == "title") {
2617− {
2618 diagnostics.push(Diagnostic {
2636 diagnostics.push(Diagnostic {
2619 line: elem.line_num,
2637 line: elem.line_num,
2620 column: None,
2638 column: None,
@@ -2729,7 +2747,9 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
2729 rest = &after[close + 1..];
2747 rest = &after[close + 1..];
2730 continue;
2748 continue;
2731 }
2749 }
2750+ let mut end = after
2732− let mut end = after.find(|c: char| !is_name_char(c)).unwrap_or(after.len());
2751+ .find(|c: char| !is_name_char(c))
2752+ .unwrap_or(after.len());
2733 while end > 0 && after[..end].ends_with('.') {
2753 while end > 0 && after[..end].ends_with('.') {
2734 end -= 1;
2754 end -= 1;
2735 }
2755 }
@@ -2756,7 +2776,10 @@ fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
2756 };
2776 };
2757 let is_inline_svg = elem.kind == ElementKind::Image
2777 let is_inline_svg = elem.kind == ElementKind::Image
2758 && elem.attrs.iter().any(|a| a.key == "inline" && !a.html)
2778 && elem.attrs.iter().any(|a| a.key == "inline" && !a.html)
2779+ && elem
2780+ .argument
2781+ .as_deref()
2759− && elem.argument.as_deref().is_some_and(|src| src.ends_with(".svg"));
2782+ .is_some_and(|src| src.ends_with(".svg"));
2760 if !is_inline_svg {
2783 if !is_inline_svg {
2761 return node;
2784 return node;
2762 }
2785 }
@@ -3231,10 +3254,17 @@ fn flatten_json(prefix: &str, value: &JsonValue, vars: &mut HashMap<String, Stri
3231/// JSON array, else its text.
3254/// JSON array, else its text.
3232fn lookup(vars: &HashMap<String, String>, name: &str) -> Option<crate::expr::Value> {
3255fn lookup(vars: &HashMap<String, String>, name: &str) -> Option<crate::expr::Value> {
3233 use crate::expr::Value;
3256 use crate::expr::Value;
3257+ match vars
3258+ .get(&format!("{}#", name))
3234− match vars.get(&format!("{}#", name)).and_then(|n| n.parse::<usize>().ok()) {
3259+ .and_then(|n| n.parse::<usize>().ok())
3260+ {
3235 Some(len) => Some(Value::List(
3261 Some(len) => Some(Value::List(
3236 (0..len)
3262 (0..len)
3263+ .map(|i| {
3237− .map(|i| vars.get(&format!("{}.{}", name, i)).cloned().unwrap_or_default())
3264+ vars.get(&format!("{}.{}", name, i))
3265+ .cloned()
3266+ .unwrap_or_default()
3267+ })
3238 .collect(),
3268 .collect(),
3239 )),
3269 )),
3240 None => vars.get(name).cloned().map(Value::Str),
3270 None => vars.get(name).cloned().map(Value::Str),
crates/htmlang-core/src/syntax.rs+38−14
@@ -78,7 +78,12 @@ impl Syntax {
78 pub fn source(&self) -> String {
78 pub fn source(&self) -> String {
79 match self {
79 match self {
80 Syntax::Raw { text, .. } | Syntax::Line { text, .. } => text.clone(),
80 Syntax::Raw { text, .. } | Syntax::Line { text, .. } => text.clone(),
81− Syntax::Function { name, params, defaults, .. } => {
81+ Syntax::Function {
82+ name,
83+ params,
84+ defaults,
85+ ..
86+ } => {
82 let mut out = format!("@let {}", name);
87 let mut out = format!("@let {}", name);
83 for param in params {
88 for param in params {
84 match defaults.get(param) {
89 match defaults.get(param) {
@@ -333,7 +338,11 @@ pub(crate) fn open_attr_depth(line: &str) -> i32 {
333 return 0;
338 return 0;
334 };
339 };
335 let inline = &line[start + 1..];
340 let inline = &line[start + 1..];
336− return if inline.contains('}') { 0 } else { open_attr_depth(inline) };
341+ return if inline.contains('}') {
342+ 0
343+ } else {
344+ open_attr_depth(inline)
345+ };
337 }
346 }
338 let bytes = line.as_bytes();
347 let bytes = line.as_bytes();
339 let mut depth: i32 = 0;
348 let mut depth: i32 = 0;
@@ -343,11 +352,7 @@ pub(crate) fn open_attr_depth(line: &str) -> i32 {
343 b'[' => {
352 b'[' => {
344 // Is this the start of an attribute list?
353 // Is this the start of an attribute list?
345 let before = line[..i].trim_end();
354 let before = line[..i].trim_end();
346− let last_directive = before
355+ let last_directive = before.rsplit([']', '>']).next().unwrap_or("").trim();
347− .rsplit([']', '>'])
348− .next()
349− .unwrap_or("")
350− .trim();
351 let tokens: Vec<&str> = last_directive.split_whitespace().collect();
356 let tokens: Vec<&str> = last_directive.split_whitespace().collect();
352 let starts_list = match tokens.as_slice() {
357 let starts_list = match tokens.as_slice() {
353 [] => before.is_empty() || before.ends_with('>'),
358 [] => before.is_empty() || before.ends_with('>'),
@@ -369,7 +374,6 @@ pub(crate) fn open_attr_depth(line: &str) -> i32 {
369 depth
374 depth
370}
375}
371
376
372−
373#[cfg(test)]
377#[cfg(test)]
374mod tests {
378mod tests {
375 use super::*;
379 use super::*;
@@ -393,21 +397,41 @@ mod tests {
393 "@let card $title $icon=x\n @el\n @children\n@if $a\n A\n@else if $b\n B\n@else\n C\n@each $x in 1..3\n $x\n@else\n none\n@text done\n",
397 "@let card $title $icon=x\n @el\n @children\n@if $a\n A\n@else if $b\n B\n@else\n C\n@each $x in 1..3\n $x\n@else\n none\n@text done\n",
394 );
398 );
395 assert_eq!(kinds(&tree), ["function", "if", "each", "line"]);
399 assert_eq!(kinds(&tree), ["function", "if", "each", "line"]);
396− let Syntax::Function { name, params, defaults, body, .. } = &tree[0] else { unreachable!() };
400+ let Syntax::Function {
401+ name,
402+ params,
403+ defaults,
404+ body,
405+ ..
406+ } = &tree[0]
407+ else {
408+ unreachable!()
409+ };
410+ assert_eq!(
397− assert_eq!((name.as_str(), params.len(), defaults["icon"].as_str()), ("card", 2, "x"));
411+ (name.as_str(), params.len(), defaults["icon"].as_str()),
412+ ("card", 2, "x")
413+ );
398 assert_eq!(kinds(body), ["line"]);
414 assert_eq!(kinds(body), ["line"]);
399− let Syntax::If { branches } = &tree[1] else { unreachable!() };
415+ let Syntax::If { branches } = &tree[1] else {
416+ unreachable!()
417+ };
400 let conditions: Vec<_> = branches.iter().map(|b| b.condition.as_deref()).collect();
418 let conditions: Vec<_> = branches.iter().map(|b| b.condition.as_deref()).collect();
401 assert_eq!(conditions, [Some("$a"), Some("$b"), None]);
419 assert_eq!(conditions, [Some("$a"), Some("$b"), None]);
402− let Syntax::Each { empty, .. } = &tree[2] else { unreachable!() };
420+ let Syntax::Each { empty, .. } = &tree[2] else {
421+ unreachable!()
422+ };
403 assert_eq!(kinds(empty), ["line"]);
423 assert_eq!(kinds(empty), ["line"]);
404 }
424 }
405
425
406 #[test]
426 #[test]
407 fn text_bodies_are_verbatim() {
427 fn text_bodies_are_verbatim() {
408 let tree = parse("@style\n :root {\n --brand: red;\n }\n@text x\n");
428 let tree = parse("@style\n :root {\n --brand: red;\n }\n@text x\n");
409− let Syntax::Line { children, .. } = &tree[0] else { unreachable!() };
429+ let Syntax::Line { children, .. } = &tree[0] else {
430+ unreachable!()
431+ };
410− let [Syntax::Raw { text, .. }] = children.as_slice() else { panic!("{:?}", children) };
432+ let [Syntax::Raw { text, .. }] = children.as_slice() else {
433+ panic!("{:?}", children)
434+ };
411 assert_eq!(text, ":root {\n --brand: red;\n}");
435 assert_eq!(text, ":root {\n --brand: red;\n}");
412 assert_eq!(kinds(&tree), ["line", "line"]);
436 assert_eq!(kinds(&tree), ["line", "line"]);
413 }
437 }
crates/htmlang-core/src/vocab.rs+38−7
@@ -2,13 +2,14 @@
2//!
2//!
3//! An attribute in `[...]` is one of three things:
3//! An attribute in `[...]` is one of three things:
4//! - one of htmlang's own attributes, which mean more than a single CSS
4//! - one of htmlang's own attributes, which mean more than a single CSS
5−//! property (`spacing`, `center-x`, `rounded`, ...), handled in codegen;
5+//! property (`spacing`, `center-x`, `width fill`, ...), handled in codegen;
6//! - a standard CSS property, copied into the generated CSS unchanged;
6//! - a standard CSS property, copied into the generated CSS unchanged;
7//! - an HTML attribute, written `key=value` (or bare, for booleans such as
7//! - an HTML attribute, written `key=value` (or bare, for booleans such as
8//! `required`) and emitted on the element.
8//! `required`) and emitted on the element.
9
9
10/// htmlang's own attributes (those that are neither a CSS property nor an
10/// htmlang's own attributes (those that are neither a CSS property nor an
11/// HTML attribute).
11/// HTML attribute).
12+#[rustfmt::skip]
12pub const HTMLANG_ATTRIBUTES: &[&str] = &[
13pub const HTMLANG_ATTRIBUTES: &[&str] = &[
13 "align-bottom", "align-left", "align-right", "align-top", "center-x", "center-y",
14 "align-bottom", "align-left", "align-right", "align-top", "center-x", "center-y",
14 "col-span", "grid-cols", "grid-rows", "inline", "row-span",
15 "col-span", "grid-cols", "grid-rows", "inline", "row-span",
@@ -17,6 +18,7 @@ pub const HTMLANG_ATTRIBUTES: &[&str] = &[
17
18
18/// Standard CSS properties, sorted. Any of these can be written as an
19/// Standard CSS properties, sorted. Any of these can be written as an
19/// attribute and is copied into the generated CSS as-is.
20/// attribute and is copied into the generated CSS as-is.
21+#[rustfmt::skip]
20pub const CSS_PROPERTIES: &[&str] = &[
22pub const CSS_PROPERTIES: &[&str] = &[
21 "accent-color", "align-content", "align-items", "align-self", "all", "anchor-name",
23 "accent-color", "align-content", "align-items", "align-self", "all", "anchor-name",
22 "animation", "animation-composition", "animation-delay", "animation-direction",
24 "animation", "animation-composition", "animation-delay", "animation-direction",
@@ -116,6 +118,7 @@ pub const CSS_PROPERTIES: &[&str] = &[
116/// Boolean HTML attributes, written bare (`[required]`) and rendered
118/// Boolean HTML attributes, written bare (`[required]`) and rendered
117/// without a value (`<input required>`). HTML attributes with a value are
119/// without a value (`<input required>`). HTML attributes with a value are
118/// written `key=value`.
120/// written `key=value`.
121+#[rustfmt::skip]
119pub const BOOLEAN_HTML_ATTRS: &[&str] = &[
122pub const BOOLEAN_HTML_ATTRS: &[&str] = &[
120 "allowfullscreen", "async", "autofocus", "autoplay", "checked", "controls", "defer",
123 "allowfullscreen", "async", "autofocus", "autoplay", "checked", "controls", "defer",
121 "disabled", "download", "formnovalidate", "inert", "loop", "multiple", "muted", "nomodule",
124 "disabled", "download", "formnovalidate", "inert", "loop", "multiple", "muted", "nomodule",
@@ -125,6 +128,7 @@ pub const BOOLEAN_HTML_ATTRS: &[&str] = &[
125
128
126/// Common HTML attribute names, used to suggest `key=value` when one is
129/// Common HTML attribute names, used to suggest `key=value` when one is
127/// written like a style (`type email`). Any name works with `=`.
130/// written like a style (`type email`). Any name works with `=`.
131+#[rustfmt::skip]
128pub const HTML_ATTRIBUTES: &[&str] = &[
132pub const HTML_ATTRIBUTES: &[&str] = &[
129 "abbr", "accept", "action", "allow", "allowfullscreen", "alt", "aria-atomic", "aria-live",
133 "abbr", "accept", "action", "allow", "allowfullscreen", "alt", "aria-atomic", "aria-live",
130 "aria-relevant", "async", "autocomplete", "autofocus", "autoplay", "blocking", "charset",
134 "aria-relevant", "async", "autocomplete", "autofocus", "autoplay", "blocking", "charset",
@@ -145,15 +149,42 @@ pub const HTML_ATTRIBUTES: &[&str] = &[
145/// CSS properties whose bare numbers are lengths, so `margin-top 16` means
149/// CSS properties whose bare numbers are lengths, so `margin-top 16` means
146/// `16px`. (Others, like `opacity` or `z-index`, take unitless numbers.)
150/// `16px`. (Others, like `opacity` or `z-index`, take unitless numbers.)
147pub fn is_length_property(name: &str) -> bool {
151pub fn is_length_property(name: &str) -> bool {
148− const PREFIXES: &[&str] = &["margin", "padding", "inset", "scroll-margin", "scroll-padding"];
152+ const PREFIXES: &[&str] = &[
153+ "margin",
154+ "padding",
155+ "inset",
156+ "scroll-margin",
157+ "scroll-padding",
158+ ];
149 const SUFFIXES: &[&str] = &["-width", "-height", "-radius", "-offset", "-spacing", "gap"];
159 const SUFFIXES: &[&str] = &["-width", "-height", "-radius", "-offset", "-spacing", "gap"];
150 const EXACT: &[&str] = &[
160 const EXACT: &[&str] = &[
151− "top", "right", "bottom", "left", "width", "height", "font-size", "block-size",
161+ "top",
152− "inline-size", "min-block-size", "max-block-size", "min-inline-size", "max-inline-size",
162+ "right",
163+ "bottom",
164+ "left",
165+ "width",
166+ "height",
167+ "font-size",
168+ "block-size",
169+ "inline-size",
170+ "min-block-size",
171+ "max-block-size",
172+ "min-inline-size",
173+ "max-inline-size",
174+ "text-indent",
175+ "flex-basis",
176+ "perspective",
153− "text-indent", "flex-basis", "perspective", "text-decoration-thickness",
177+ "text-decoration-thickness",
154 // Shorthands that start with a width: `border 1 solid red`
178 // Shorthands that start with a width: `border 1 solid red`
155− "border", "border-top", "border-right", "border-bottom", "border-left", "border-block",
179+ "border",
180+ "border-top",
181+ "border-right",
182+ "border-bottom",
183+ "border-left",
184+ "border-block",
156− "border-inline", "outline", "column-rule",
185+ "border-inline",
186+ "outline",
187+ "column-rule",
157 ];
188 ];
158 // A bare `line-height` number is a multiplier, not a length
189 // A bare `line-height` number is a multiplier, not a length
159 name != "line-height"
190 name != "line-height"
src/bin/htmlang_lsp/completion.rs+73−26
@@ -100,7 +100,6 @@ pub(crate) fn in_brackets(text: &str) -> bool {
100 depth > 0
100 depth > 0
101}
101}
102
102
103−
104fn item(
103fn item(
105 label: &str,
104 label: &str,
106 kind: CompletionItemKind,
105 kind: CompletionItemKind,
@@ -145,7 +144,13 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
145 .map(|name| {
144 .map(|name| {
146 let label = format!("@{}", name);
145 let label = format!("@{}", name);
147 let detail = docs::directive(name).map_or("Directive", |d| d.summary);
146 let detail = docs::directive(name).map_or("Directive", |d| d.summary);
147+ item(
148+ &label,
148− item(&label, CompletionItemKind::SNIPPET, detail, &format!("{} ", label), range)
149+ CompletionItemKind::SNIPPET,
150+ detail,
151+ &format!("{} ", label),
152+ range,
153+ )
149 })
154 })
150 .collect()
155 .collect()
151}
156}
@@ -257,7 +262,6 @@ pub(crate) fn path_completions(uri: &Url, position: Position) -> Vec<CompletionI
257 items
262 items
258}
263}
259
264
260−
261/// Walk back from `position` to find the element directive that opened the
265/// Walk back from `position` to find the element directive that opened the
262/// nearest unmatched `[`. Returns the bare name without the leading `@`
266/// nearest unmatched `[`. Returns the bare name without the leading `@`
263/// (e.g. `"input"`).
267/// (e.g. `"input"`).
@@ -266,8 +270,8 @@ pub(crate) fn owning_element(text: &str, position: Position) -> Option<String> {
266 // First, locate the line that contains the unmatched `[`. We scan from
270 // First, locate the line that contains the unmatched `[`. We scan from
267 // the cursor back, tracking depth.
271 // the cursor back, tracking depth.
268 let cursor_line = position.line as usize;
272 let cursor_line = position.line as usize;
269− let cursor_col = (position.character as usize)
273+ let cursor_col =
270− .min(lines.get(cursor_line).map(|l| l.len()).unwrap_or(0));
274+ (position.character as usize).min(lines.get(cursor_line).map(|l| l.len()).unwrap_or(0));
271 let mut depth: i32 = 0;
275 let mut depth: i32 = 0;
272 let mut bracket_line: Option<usize> = None;
276 let mut bracket_line: Option<usize> = None;
273 let mut bracket_col: usize = 0;
277 let mut bracket_col: usize = 0;
@@ -348,7 +352,14 @@ fn element_specific_attrs(element: &str) -> &'static [&'static str] {
348 "popovertarget",
352 "popovertarget",
349 "popovertargetaction",
353 "popovertargetaction",
350 ],
354 ],
351− "select" => &["name", "multiple", "required", "disabled", "size", "autofocus"],
355+ "select" => &[
356+ "name",
357+ "multiple",
358+ "required",
359+ "disabled",
360+ "size",
361+ "autofocus",
362+ ],
352 "textarea" => &[
363 "textarea" => &[
353 "name",
364 "name",
354 "rows",
365 "rows",
@@ -384,7 +395,14 @@ fn element_specific_attrs(element: &str) -> &'static [&'static str] {
384 "srcset",
395 "srcset",
385 "sizes",
396 "sizes",
386 ],
397 ],
398+ "link" => &[
399+ "href",
400+ "target",
401+ "rel",
402+ "download",
387− "link" => &["href", "target", "rel", "download", "referrerpolicy", "type"],
403+ "referrerpolicy",
404+ "type",
405+ ],
388 "video" => &[
406 "video" => &[
389 "src",
407 "src",
390 "controls",
408 "controls",
@@ -490,7 +508,14 @@ fn attr_value_completions(before: &str, range: Range) -> Option<Vec<CompletionIt
490 ],
508 ],
491 "text-align" => &["left", "center", "right", "justify", "start", "end"],
509 "text-align" => &["left", "center", "right", "justify", "start", "end"],
492 "text-transform" => &["uppercase", "lowercase", "capitalize", "none"],
510 "text-transform" => &["uppercase", "lowercase", "capitalize", "none"],
493− "white-space" => &["normal", "nowrap", "pre", "pre-line", "pre-wrap", "break-spaces"],
511+ "white-space" => &[
512+ "normal",
513+ "nowrap",
514+ "pre",
515+ "pre-line",
516+ "pre-wrap",
517+ "break-spaces",
518+ ],
494 "overflow" | "overflow-x" | "overflow-y" => {
519 "overflow" | "overflow-x" | "overflow-y" => {
495 &["visible", "hidden", "scroll", "auto", "clip"]
520 &["visible", "hidden", "scroll", "auto", "clip"]
496 }
521 }
@@ -545,21 +570,22 @@ fn attr_value_completions(before: &str, range: Range) -> Option<Vec<CompletionIt
545 "scope" => &["row", "col", "rowgroup", "colgroup"],
570 "scope" => &["row", "col", "rowgroup", "colgroup"],
546 "wrap" => &["soft", "hard", "off"],
571 "wrap" => &["soft", "hard", "off"],
547 "inputmode" => &[
572 "inputmode" => &[
548− "text",
573+ "text", "numeric", "decimal", "email", "search", "tel", "url", "none",
549− "numeric",
550− "decimal",
551− "email",
552− "search",
553− "tel",
554− "url",
555− "none",
556 ],
574 ],
557 "enterkeyhint" => &["enter", "done", "go", "next", "previous", "search", "send"],
575 "enterkeyhint" => &["enter", "done", "go", "next", "previous", "search", "send"],
558 "fetchpriority" => &["high", "low", "auto"],
576 "fetchpriority" => &["high", "low", "auto"],
559 "spellcheck" | "translate" => &["true", "false"],
577 "spellcheck" | "translate" => &["true", "false"],
560 "color-scheme" => &["light", "dark", "light dark", "normal"],
578 "color-scheme" => &["light", "dark", "light dark", "normal"],
561 "appearance" => &["none", "auto"],
579 "appearance" => &["none", "auto"],
580+ "autocomplete" => &[
581+ "on",
582+ "off",
583+ "name",
584+ "email",
585+ "username",
562− "autocomplete" => &["on", "off", "name", "email", "username", "current-password", "new-password"],
586+ "current-password",
587+ "new-password",
588+ ],
563 "scroll-behavior" => &["smooth", "auto"],
589 "scroll-behavior" => &["smooth", "auto"],
564 "resize" => &["none", "both", "horizontal", "vertical", "block", "inline"],
590 "resize" => &["none", "both", "horizontal", "vertical", "block", "inline"],
565 "writing-mode" => &["horizontal-tb", "vertical-rl", "vertical-lr"],
591 "writing-mode" => &["horizontal-tb", "vertical-rl", "vertical-lr"],
@@ -598,7 +624,9 @@ fn attr_value_completions(before: &str, range: Range) -> Option<Vec<CompletionIt
598 "auto",
624 "auto",
599 "pinch-zoom",
625 "pinch-zoom",
600 ],
626 ],
627+ "contain" => &[
601− "contain" => &["none", "strict", "content", "size", "layout", "style", "paint"],
628+ "none", "strict", "content", "size", "layout", "style", "paint",
629+ ],
602 "content-visibility" => &["visible", "auto", "hidden"],
630 "content-visibility" => &["visible", "auto", "hidden"],
603 _ => return None,
631 _ => return None,
604 };
632 };
@@ -649,12 +677,24 @@ fn attr_completions(range: Range, element: Option<&str>) -> Vec<CompletionItem>
649 }
677 }
650 for name in vocab::BOOLEAN_HTML_ATTRS {
678 for name in vocab::BOOLEAN_HTML_ATTRS {
651 if !vocab::is_style_attribute(name) {
679 if !vocab::is_style_attribute(name) {
680+ push(
681+ name.to_string(),
682+ name.to_string(),
652− push(name.to_string(), name.to_string(), "HTML attribute (boolean)", "3", name);
683+ "HTML attribute (boolean)",
684+ "3",
685+ name,
686+ );
653 }
687 }
654 }
688 }
655 for name in vocab::HTML_ATTRIBUTES {
689 for name in vocab::HTML_ATTRIBUTES {
656 if !vocab::BOOLEAN_HTML_ATTRS.contains(name) {
690 if !vocab::BOOLEAN_HTML_ATTRS.contains(name) {
691+ push(
692+ format!("{}=", name),
693+ format!("{}=", name),
657− push(format!("{}=", name), format!("{}=", name), "HTML attribute", "3", name);
694+ "HTML attribute",
695+ "3",
696+ name,
697+ );
658 }
698 }
659 }
699 }
660 let prefixes = vocab::PSEUDO_PREFIXES
700 let prefixes = vocab::PSEUDO_PREFIXES
@@ -800,7 +840,13 @@ fn variable_completions(text: &str, range: Range) -> Vec<CompletionItem> {
800 for doc in docs::BUNDLES {
840 for doc in docs::BUNDLES {
801 let label = format!("${}", doc.name);
841 let label = format!("${}", doc.name);
802 let detail = format!("{} (standard-library bundle)", doc.summary);
842 let detail = format!("{} (standard-library bundle)", doc.summary);
843+ items.push(item(
844+ &label,
803− items.push(item(&label, CompletionItemKind::CONSTANT, &detail, &label, range));
845+ CompletionItemKind::CONSTANT,
846+ &detail,
847+ &label,
848+ range,
849+ ));
804 }
850 }
805
851
806 for line in text.lines() {
852 for line in text.lines() {
@@ -918,10 +964,7 @@ mod tests {
918 #[test]
964 #[test]
919 fn owning_element_finds_element_across_lines() {
965 fn owning_element_finds_element_across_lines() {
920 let text = "@button [\n padding 10,\n ";
966 let text = "@button [\n padding 10,\n ";
921− assert_eq!(
922− owning_element(text, pos(2, 2)),
967+ assert_eq!(owning_element(text, pos(2, 2)), Some("button".to_string()));
923− Some("button".to_string())
924− );
925 }
968 }
926
969
927 #[test]
970 #[test]
@@ -946,7 +989,11 @@ mod tests {
946 let elements = element_completions(range);
989 let elements = element_completions(range);
947 for name in ElementKind::all_names() {
990 for name in ElementKind::all_names() {
948 let label = format!("@{}", name);
991 let label = format!("@{}", name);
992+ assert!(
949− assert!(elements.iter().any(|i| i.label == label), "missing {}", label);
993+ elements.iter().any(|i| i.label == label),
994+ "missing {}",
995+ label
996+ );
950 }
997 }
951 assert!(elements.iter().any(|i| i.label == "@spacer"));
998 assert!(elements.iter().any(|i| i.label == "@spacer"));
952 let directives = directive_completions(range);
999 let directives = directive_completions(range);
src/bin/htmlang_lsp/docs.rs+22−4
@@ -32,6 +32,7 @@ const fn doc(name: &'static str, summary: &'static str, usage: &'static str) ->
32
32
33/// Elements (names without `@`). Elements not listed get a summary derived
33/// Elements (names without `@`). Elements not listed get a summary derived
34/// from their row in `TAGS`.
34/// from their row in `TAGS`.
35+#[rustfmt::skip]
35pub(crate) const ELEMENTS: &[Doc] = &[
36pub(crate) const ELEMENTS: &[Doc] = &[
36 doc("row", "Horizontal layout: a flex row.", "@row [spacing 10]\n @text A\n @text B"),
37 doc("row", "Horizontal layout: a flex row.", "@row [spacing 10]\n @text A\n @text B"),
37 doc("el", "The container: a flex column (only `@row` lays out horizontally).", "@el [padding 20, background white]\n Content"),
38 doc("el", "The container: a flex column (only `@row` lays out horizontally).", "@el [padding 20, background white]\n Content"),
@@ -82,6 +83,7 @@ pub(crate) const ELEMENTS: &[Doc] = &[
82];
83];
83
84
84/// Directives (names without `@`).
85/// Directives (names without `@`).
86+#[rustfmt::skip]
85pub(crate) const DIRECTIVES: &[Doc] = &[
87pub(crate) const DIRECTIVES: &[Doc] = &[
86 doc(
88 doc(
87 "page",
89 "page",
@@ -110,6 +112,7 @@ pub(crate) const DIRECTIVES: &[Doc] = &[
110];
112];
111
113
112/// htmlang's own attributes, plus CSS properties htmlang treats specially.
114/// htmlang's own attributes, plus CSS properties htmlang treats specially.
115+#[rustfmt::skip]
113pub(crate) const ATTRIBUTES: &[Doc] = &[
116pub(crate) const ATTRIBUTES: &[Doc] = &[
114 doc("spacing", "Gap between children.", "spacing 20"),
117 doc("spacing", "Gap between children.", "spacing 20"),
115 doc("padding", "Inner space: 1 to 4 values; bare numbers are px.", "padding 12 24"),
118 doc("padding", "Inner space: 1 to 4 values; bare numbers are px.", "padding 12 24"),
@@ -132,11 +135,13 @@ pub(crate) const ATTRIBUTES: &[Doc] = &[
132];
135];
133
136
134/// Standard-library components (`std.hl`), used like elements.
137/// Standard-library components (`std.hl`), used like elements.
138+#[rustfmt::skip]
135pub(crate) const COMPONENTS: &[Doc] = &[
139pub(crate) const COMPONENTS: &[Doc] = &[
136 doc("spacer", "Takes up the remaining space in a row or column.", "@spacer"),
140 doc("spacer", "Takes up the remaining space in a row or column.", "@spacer"),
137];
141];
138
142
139/// Standard-library attribute bundles, used as `[$name]`.
143/// Standard-library attribute bundles, used as `[$name]`.
144+#[rustfmt::skip]
140pub(crate) const BUNDLES: &[Doc] = &[
145pub(crate) const BUNDLES: &[Doc] = &[
141 doc("truncate", "Cuts text off at one line with an ellipsis.", "@text [$truncate] A long title"),
146 doc("truncate", "Cuts text off at one line with an ellipsis.", "@text [$truncate] A long title"),
142];
147];
@@ -187,7 +192,10 @@ fn markdown(title: &str, summary: &str, usage: &str) -> String {
187 if usage.is_empty() {
192 if usage.is_empty() {
188 format!("**{}** \u{2014} {}", title, summary)
193 format!("**{}** \u{2014} {}", title, summary)
189 } else {
194 } else {
195+ format!(
196+ "**{}** \u{2014} {}\n\n```htmlang\n{}\n```",
190− format!("**{}** \u{2014} {}\n\n```htmlang\n{}\n```", title, summary, usage)
197+ title, summary, usage
198+ )
191 }
199 }
192}
200}
193
201
@@ -255,7 +263,9 @@ fn attribute_hover(name: &str) -> Option<String> {
255 "**{name}** \u{2014} Boolean HTML attribute, written bare: `[{name}]`."
263 "**{name}** \u{2014} Boolean HTML attribute, written bare: `[{name}]`."
256 ));
264 ));
257 }
265 }
258− if vocab::HTML_ATTRIBUTES.contains(&name) || name.starts_with("aria-") || name.starts_with("data-")
266+ if vocab::HTML_ATTRIBUTES.contains(&name)
267+ || name.starts_with("aria-")
268+ || name.starts_with("data-")
259 {
269 {
260 return Some(format!(
270 return Some(format!(
261 "**{name}** \u{2014} HTML attribute, written `{name}=value`."
271 "**{name}** \u{2014} HTML attribute, written `{name}=value`."
@@ -329,10 +339,18 @@ mod tests {
329 #[test]
339 #[test]
330 fn documented_names_exist_in_the_compiler() {
340 fn documented_names_exist_in_the_compiler() {
331 for doc in ELEMENTS {
341 for doc in ELEMENTS {
342+ assert!(
332− assert!(ElementKind::from_name(doc.name).is_some(), "@{} is not an element", doc.name);
343+ ElementKind::from_name(doc.name).is_some(),
344+ "@{} is not an element",
345+ doc.name
346+ );
333 }
347 }
334 for doc in ATTRIBUTES {
348 for doc in ATTRIBUTES {
349+ assert!(
335− assert!(vocab::is_style_attribute(doc.name), "{} is not a style attribute", doc.name);
350+ vocab::is_style_attribute(doc.name),
351+ "{} is not a style attribute",
352+ doc.name
353+ );
336 }
354 }
337 }
355 }
338
356
src/bin/htmlang_lsp/state.rs−1
@@ -274,4 +274,3 @@ mod tests {
274 assert_eq!(s, "ab!\ncd");
274 assert_eq!(s, "ab!\ncd");
275 }
275 }
276}
276}
277−
src/cli.rs+1−3
@@ -2,9 +2,7 @@ use std::env;
2use std::process;
2use std::process;
3
3
4/// Subcommands understood by the CLI.
4/// Subcommands understood by the CLI.
5−pub const COMMANDS: &[&str] = &[
6− "build", "serve", "watch", "check", "lint", "fmt", "lsp",
5+pub const COMMANDS: &[&str] = &["build", "serve", "watch", "check", "lint", "fmt", "lsp"];
7−];
8
6
9pub fn print_help() {
7pub fn print_help() {
10 eprintln!(
8 eprintln!(
src/fmt.rs+7−10
@@ -87,15 +87,9 @@ fn open_attr_depth(line: &str) -> i32 {
87/// Directives whose indented body is foreign content (CSS, JS, Markdown,
87/// Directives whose indented body is foreign content (CSS, JS, Markdown,
88/// JSON, raw HTML) that must not be reformatted.
88/// JSON, raw HTML) that must not be reformatted.
89fn is_raw_body_header(code: &str) -> bool {
89fn is_raw_body_header(code: &str) -> bool {
90− [
91− "@style",
92− "@script",
93− "@markdown",
90+ ["@style", "@script", "@markdown", "@head", "@raw"]
94− "@head",
95− "@raw",
96− ]
97− .iter()
91+ .iter()
98− .any(|d| code == *d || code.strip_prefix(d).is_some_and(|r| r.starts_with(' ')))
92+ .any(|d| code == *d || code.strip_prefix(d).is_some_and(|r| r.starts_with(' ')))
99}
93}
100
94
101/// Re-indent a verbatim line by `delta` bytes, keeping its relative
95/// Re-indent a verbatim line by `delta` bytes, keeping its relative
@@ -496,7 +490,10 @@ mod tests {
496 #[test]
490 #[test]
497 fn commas_inside_parens_stay_in_value() {
491 fn commas_inside_parens_stay_in_value() {
498 let src = "@el [box-shadow 0 1px 3px rgba(0,0,0,0.1),font-weight bold] hi\n";
492 let src = "@el [box-shadow 0 1px 3px rgba(0,0,0,0.1),font-weight bold] hi\n";
493+ assert_eq!(
494+ format(src),
499− assert_eq!(format(src), "@el [box-shadow 0 1px 3px rgba(0,0,0,0.1), font-weight bold] hi\n");
495+ "@el [box-shadow 0 1px 3px rgba(0,0,0,0.1), font-weight bold] hi\n"
496+ );
500 }
497 }
501
498
502 #[test]
499 #[test]
src/main.rs+14−9
@@ -156,7 +156,6 @@ fn json_escape_string(s: &str) -> String {
156 out
156 out
157}
157}
158
158
159−
160fn json_array(items: impl Iterator<Item = String>) -> String {
159fn json_array(items: impl Iterator<Item = String>) -> String {
161 let inner: Vec<String> = items.collect();
160 let inner: Vec<String> = items.collect();
162 format!("[{}]", inner.join(","))
161 format!("[{}]", inner.join(","))
@@ -349,11 +348,18 @@ fn lint_file(path: &str) -> Vec<String> {
349 .diagnostics
348 .diagnostics
350 .iter()
349 .iter()
351 .chain(&lint)
350 .chain(&lint)
351+ .map(|d| {
352+ format!(
353+ "{}:{}:{}: {}",
354+ path,
355+ d.line,
352− .map(|d| format!("{}:{}:{}: {}", path, d.line, severity_label(d.severity), d.message))
356+ severity_label(d.severity),
357+ d.message
358+ )
359+ })
353 .collect()
360 .collect()
354}
361}
355
362
356−
357fn open_in_browser(port: u16) {
363fn open_in_browser(port: u16) {
358 let url = format!("http://127.0.0.1:{}", port);
364 let url = format!("http://127.0.0.1:{}", port);
359 let cmd = if cfg!(target_os = "macos") {
365 let cmd = if cfg!(target_os = "macos") {
@@ -516,7 +522,6 @@ fn main() {
516 return;
522 return;
517 }
523 }
518
524
519−
520 // Handle "fmt" subcommand
525 // Handle "fmt" subcommand
521 if args.len() >= 3 && args[1] == "fmt" {
526 if args.len() >= 3 && args[1] == "fmt" {
522 let file = &args[2];
527 let file = &args[2];
@@ -646,7 +651,6 @@ fn main() {
646 return;
651 return;
647 }
652 }
648
653
649−
650 // Handle "lint" subcommand
654 // Handle "lint" subcommand
651 if args.len() >= 2 && args[1] == "lint" {
655 if args.len() >= 2 && args[1] == "lint" {
652 let mut lint_target = ".";
656 let mut lint_target = ".";
@@ -699,7 +703,6 @@ fn main() {
699 return;
703 return;
700 }
704 }
701
705
702−
703 // Handle "check" subcommand
706 // Handle "check" subcommand
704 if args.len() >= 2 && args[1] == "check" {
707 if args.len() >= 2 && args[1] == "check" {
705 let mut check_target = None;
708 let mut check_target = None;
@@ -773,7 +776,6 @@ fn main() {
773 return;
776 return;
774 }
777 }
775
778
776−
777 // Handle "serve" standalone subcommand
779 // Handle "serve" standalone subcommand
778 if args.len() >= 2 && args[1] == "serve" {
780 if args.len() >= 2 && args[1] == "serve" {
779 let mut serve_target = None;
781 let mut serve_target = None;
@@ -837,7 +839,11 @@ fn main() {
837 let server_tx = tx.clone();
839 let server_tx = tx.clone();
838 std::thread::spawn(move || {
840 std::thread::spawn(move || {
839 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
841 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
840− rt.block_on(htmlang::serve::run_dir(effective_port, server_dir, server_tx));
842+ rt.block_on(htmlang::serve::run_dir(
843+ effective_port,
844+ server_dir,
845+ server_tx,
846+ ));
841 });
847 });
842 if serve_open {
848 if serve_open {
843 open_in_browser(effective_port);
849 open_in_browser(effective_port);
@@ -997,7 +1003,6 @@ fn main() {
997 return;
1003 return;
998 }
1004 }
999
1005
1000−
1001 let mut i = 1;
1006 let mut i = 1;
1002 while i < args.len() {
1007 while i < args.len() {
1003 match args[i].as_str() {
1008 match args[i].as_str() {
tests/regressions.rs+57−14
@@ -9,7 +9,10 @@ use htmlang::parser::{self, Severity};
9fn compile(input: &str) -> String {
9fn compile(input: &str) -> String {
10 let result = parser::parse(input);
10 let result = parser::parse(input);
11 assert!(
11 assert!(
12+ result
13+ .diagnostics
14+ .iter()
12− result.diagnostics.iter().all(|d| d.severity != Severity::Error),
15+ .all(|d| d.severity != Severity::Error),
13 "unexpected parse errors: {:?}",
16 "unexpected parse errors: {:?}",
14 result.diagnostics
17 result.diagnostics
15 );
18 );
@@ -100,7 +103,8 @@ fn px_values_are_not_doubled() {
100
103
101#[test]
104#[test]
102fn minify_keeps_significant_spaces() {
105fn minify_keeps_significant_spaces() {
106+ let result =
103− let result = parser::parse("@page T\n@paragraph\n Built with {@text [font-weight bold] htmlang}.");
107+ parser::parse("@page T\n@paragraph\n Built with {@text [font-weight bold] htmlang}.");
104 let out = codegen::generate_minified(&result.document);
108 let out = codegen::generate_minified(&result.document);
105 assert!(out.contains("Built with <span"), "{}", out);
109 assert!(out.contains("Built with <span"), "{}", out);
106}
110}
@@ -147,7 +151,8 @@ fn image_with_css_width_gets_no_intrinsic_height() {
147
151
148#[test]
152#[test]
149fn source_map_uses_real_output_lines() {
153fn source_map_uses_real_output_lines() {
154+ let html =
150− let html = "<!DOCTYPE html>\n<html>\n<div data-hl-line=\"3\">\n<span data-hl-line=\"4\">x</span>\n";
155+ "<!DOCTYPE html>\n<html>\n<div data-hl-line=\"3\">\n<span data-hl-line=\"4\">x</span>\n";
151 let map = codegen::source_map_for_html(html, "a.hl");
156 let map = codegen::source_map_for_html(html, "a.hl");
152 // Lines 1-2 have no mapping; line 3 maps to source line 3 (0-based 2 =
157 // Lines 1-2 have no mapping; line 3 maps to source line 3 (0-based 2 =
153 // VLQ "E"), line 4 to source line 4 (delta 1 = "C").
158 // VLQ "E"), line 4 to source line 4 (delta 1 = "C").
@@ -158,7 +163,8 @@ fn source_map_uses_real_output_lines() {
158
163
159#[test]
164#[test]
160fn bracket_in_text_does_not_join_lines() {
165fn bracket_in_text_does_not_join_lines() {
166+ let out =
161− let out = compile("@el\n Use [ to open a list\n @text [font-weight bold] Second\n @text Third");
167+ compile("@el\n Use [ to open a list\n @text [font-weight bold] Second\n @text Third");
162 assert!(out.contains("Use [ to open a list"), "{}", out);
168 assert!(out.contains("Use [ to open a list"), "{}", out);
163 assert!(out.contains(">Second<"), "{}", out);
169 assert!(out.contains(">Second<"), "{}", out);
164 assert!(out.contains(">Third<"), "{}", out);
170 assert!(out.contains(">Third<"), "{}", out);
@@ -173,7 +179,9 @@ fn multiline_attributes_still_join() {
173
179
174#[test]
180#[test]
175fn multi_line_content_is_a_function() {
181fn multi_line_content_is_a_function() {
182+ let out = compile(
176− let out = compile("@let intro\n First line\n Second {@text [font-weight bold] line}\n@paragraph\n @intro\n");
183+ "@let intro\n First line\n Second {@text [font-weight bold] line}\n@paragraph\n @intro\n",
184+ );
177 assert!(out.contains("First line"), "{}", out);
185 assert!(out.contains("First line"), "{}", out);
178 assert!(out.contains(">line</span>"), "{}", out);
186 assert!(out.contains(">line</span>"), "{}", out);
179}
187}
@@ -216,7 +224,11 @@ fn markdown_keeps_paragraphs_rules_and_ordered_lists() {
216 assert!(out.contains("<p>First para.</p>"), "{}", out);
224 assert!(out.contains("<p>First para.</p>"), "{}", out);
217 assert!(out.contains("<p>Second para.</p>"), "{}", out);
225 assert!(out.contains("<p>Second para.</p>"), "{}", out);
218 assert!(out.contains("<hr>"), "{}", out);
226 assert!(out.contains("<hr>"), "{}", out);
227+ assert!(
219− assert!(out.contains("<ol>\n<li>one</li>\n<li>two</li>\n</ol>"), "{}", out);
228+ out.contains("<ol>\n<li>one</li>\n<li>two</li>\n</ol>"),
229+ "{}",
230+ out
231+ );
220 assert!(out.contains("if x:\n y"), "{}", out);
232 assert!(out.contains("if x:\n y"), "{}", out);
221}
233}
222
234
@@ -284,7 +296,9 @@ fn container_arguments_are_rendered_as_text() {
284
296
285#[test]
297#[test]
286fn html_attributes_use_equals_and_are_all_emitted() {
298fn html_attributes_use_equals_and_are_all_emitted() {
299+ let out = compile(
287− let out = compile("@link [target=_blank, rel=me] /x Home\n@iframe [sandbox, allow=camera] https://e.com");
300+ "@link [target=_blank, rel=me] /x Home\n@iframe [sandbox, allow=camera] https://e.com",
301+ );
288 assert!(out.contains("target=\"_blank\""), "{}", out);
302 assert!(out.contains("target=\"_blank\""), "{}", out);
289 assert!(out.contains("rel=\"me\""), "{}", out);
303 assert!(out.contains("rel=\"me\""), "{}", out);
290 assert!(out.contains(" sandbox"), "{}", out);
304 assert!(out.contains(" sandbox"), "{}", out);
@@ -364,7 +378,11 @@ fn data_glob_loads_a_list_of_records() {
364 "@data $posts posts/*.json\n@text ${length($posts)} posts\n@each $p, $i in $posts\n @text $i $p.title ($p.file)",
378 "@data $posts posts/*.json\n@text ${length($posts)} posts\n@each $p, $i in $posts\n @text $i $p.title ($p.file)",
365 );
379 );
366 assert!(out.contains("2 posts"), "{}", out);
380 assert!(out.contains("2 posts"), "{}", out);
381+ assert!(
367− assert!(out.contains(">0 First (a)<") && out.contains(">1 Second (b)<"), "{}", out);
382+ out.contains(">0 First (a)<") && out.contains(">1 Second (b)<"),
383+ "{}",
384+ out
385+ );
368}
386}
369
387
370#[test]
388#[test]
@@ -378,18 +396,39 @@ fn lint_adds_only_checks_the_compiler_does_not_already_make() {
378 .map(|d| d.message.as_str())
396 .map(|d| d.message.as_str())
379 .collect();
397 .collect();
380 // Each problem is reported once, whichever pass finds it.
398 // Each problem is reported once, whichever pass finds it.
399+ assert_eq!(
381− assert_eq!(messages.iter().filter(|m| m.contains("'alt'")).count(), 1, "{:?}", messages);
400+ messages.iter().filter(|m| m.contains("'alt'")).count(),
401+ 1,
402+ "{:?}",
403+ messages
404+ );
405+ assert!(
382− assert!(messages.iter().any(|m| m.contains("empty container")), "{:?}", messages);
406+ messages.iter().any(|m| m.contains("empty container")),
407+ "{:?}",
408+ messages
409+ );
410+ assert!(
383− assert!(messages.iter().any(|m| m.contains("'type'")), "{:?}", messages);
411+ messages.iter().any(|m| m.contains("'type'")),
412+ "{:?}",
413+ messages
414+ );
384}
415}
385
416
386#[test]
417#[test]
387fn inline_svg_resolves_from_the_page_and_keeps_attributes() {
418fn inline_svg_resolves_from_the_page_and_keeps_attributes() {
388 let dir = scratch_dir("inline_svg");
419 let dir = scratch_dir("inline_svg");
389 std::fs::create_dir_all(dir.join("icons")).unwrap();
420 std::fs::create_dir_all(dir.join("icons")).unwrap();
421+ std::fs::write(
422+ dir.join("icons/a.svg"),
390− std::fs::write(dir.join("icons/a.svg"), r#"<svg viewBox="0 0 24 24"></svg>"#).unwrap();
423+ r#"<svg viewBox="0 0 24 24"></svg>"#,
424+ )
425+ .unwrap();
391 let out = compile_in(&dir, "@image [inline, width 24, class=icon] icons/a.svg");
426 let out = compile_in(&dir, "@image [inline, width 24, class=icon] icons/a.svg");
427+ assert!(
392− assert!(out.contains(r#"<svg viewBox="0 0 24 24" width="24" class="icon">"#), "{}", out);
428+ out.contains(r#"<svg viewBox="0 0 24 24" width="24" class="icon">"#),
429+ "{}",
430+ out
431+ );
393}
432}
394
433
395#[test]
434#[test]
@@ -407,7 +446,11 @@ fn lines_under_text_are_its_siblings() {
407 let html = htmlang::codegen::generate(
446 let html = htmlang::codegen::generate(
408 &htmlang::parser::parse("@el\n Some text\n more text\n").document,
447 &htmlang::parser::parse("@el\n Some text\n more text\n").document,
409 );
448 );
449+ assert!(
410− assert!(html.contains("<span>Some text</span><span>more text</span>"), "{}", html);
450+ html.contains("<span>Some text</span><span>more text</span>"),
451+ "{}",
452+ html
453+ );
411}
454}
412
455
413#[test]
456#[test]
tests/snapshots.rs+87−37
@@ -790,8 +790,7 @@ fn match_selects_correct_case() {
790
790
791#[test]
791#[test]
792fn match_falls_to_default() {
792fn match_falls_to_default() {
793− let output =
794− compile("@let x z\n@if $x == \"a\"\n @text A\n@else\n @text Default");
793+ let output = compile("@let x z\n@if $x == \"a\"\n @text A\n@else\n @text Default");
795 assert!(output.contains("Default"));
794 assert!(output.contains("Default"));
796 assert!(!output.contains(">A<"));
795 assert!(!output.contains(">A<"));
797}
796}
@@ -884,7 +883,8 @@ fn ternary_expression_in_attrs() {
884
883
885#[test]
884#[test]
886fn ternary_expression_false() {
885fn ternary_expression_false() {
886+ let output =
887− let output = compile("@page T\n@let active false\n@el [color if($active, green, gray)]\n test");
887+ compile("@page T\n@let active false\n@el [color if($active, green, gray)]\n test");
888 assert!(output.contains("color:gray"));
888 assert!(output.contains("color:gray"));
889}
889}
890
890
@@ -902,7 +902,8 @@ fn comparison_operators_lt() {
902
902
903#[test]
903#[test]
904fn comparison_operators_contains() {
904fn comparison_operators_contains() {
905+ let output =
905− let output = compile("@page T\n@let name hello world\n@if contains($name, world)\n @text found");
906+ compile("@page T\n@let name hello world\n@if contains($name, world)\n @text found");
906 assert!(output.contains("found"));
907 assert!(output.contains("found"));
907}
908}
908
909
@@ -967,7 +968,9 @@ fn internal_link_no_noopener() {
967
968
968#[test]
969#[test]
969fn theme_color_meta_from_theme() {
970fn theme_color_meta_from_theme() {
971+ let output = compile(
970− let output = compile("@page T\n@let primary #3b82f6\n@let --primary #3b82f6\n@meta theme-color #3b82f6\n@el\n test");
972+ "@page T\n@let primary #3b82f6\n@let --primary #3b82f6\n@meta theme-color #3b82f6\n@el\n test",
973+ );
971 assert!(output.contains("theme-color"));
974 assert!(output.contains("theme-color"));
972 assert!(output.contains("#3b82f6"));
975 assert!(output.contains("#3b82f6"));
973}
976}
@@ -1286,9 +1289,17 @@ fn each_binds_records() {
1286#[test]
1289#[test]
1287fn each_second_variable_is_the_index() {
1290fn each_second_variable_is_the_index() {
1288 let output = compile("@each $item, $i in New York, Paris\n @text $i=$item");
1291 let output = compile("@each $item, $i in New York, Paris\n @text $i=$item");
1292+ assert!(
1289− assert!(output.contains("0=New York") && output.contains("1=Paris"), "{}", output);
1293+ output.contains("0=New York") && output.contains("1=Paris"),
1294+ "{}",
1295+ output
1296+ );
1290 let diags = parse_diagnostics("@each $a, $b, $c in x\n @text $a");
1297 let diags = parse_diagnostics("@each $a, $b, $c in x\n @text $a");
1298+ assert!(
1291− assert!(diags.iter().any(|d| d.message.contains("`$item, $index`")), "{:?}", diags);
1299+ diags.iter().any(|d| d.message.contains("`$item, $index`")),
1300+ "{:?}",
1301+ diags
1302+ );
1292}
1303}
1293
1304
1294// ---------------------------------------------------------------------------
1305// ---------------------------------------------------------------------------
@@ -1395,7 +1406,12 @@ fn fmt_examples_idempotent_and_semantics_preserving() {
1395 let src = fs::read_to_string(&path).unwrap();
1406 let src = fs::read_to_string(&path).unwrap();
1396 let once = htmlang::fmt::format(&src);
1407 let once = htmlang::fmt::format(&src);
1397 let twice = htmlang::fmt::format(&once);
1408 let twice = htmlang::fmt::format(&once);
1409+ assert_eq!(
1410+ once,
1411+ twice,
1398− assert_eq!(once, twice, "formatter not idempotent on {}", path.display());
1412+ "formatter not idempotent on {}",
1413+ path.display()
1414+ );
1399 assert_eq!(
1415 assert_eq!(
1400 compile_with_base(&src, &dir),
1416 compile_with_base(&src, &dir),
1401 compile_with_base(&once, &dir),
1417 compile_with_base(&once, &dir),
@@ -2337,9 +2353,8 @@ fn element_grid() {
2337
2353
2338#[test]
2354#[test]
2339fn element_in_front() {
2355fn element_in_front() {
2340− let output = compile(
2356+ let output =
2341− "@page T\n@el [width 200, height 200]\n Main\n @in-front\n @text Overlay",
2357+ compile("@page T\n@el [width 200, height 200]\n Main\n @in-front\n @text Overlay");
2342− );
2343 assert!(
2358 assert!(
2344 output.contains("position:absolute"),
2359 output.contains("position:absolute"),
2345 "@in-front should have position:absolute: {}",
2360 "@in-front should have position:absolute: {}",
@@ -2370,8 +2385,7 @@ fn element_behind() {
2370
2385
2371#[test]
2386#[test]
2372fn in_front_makes_parent_positioning_context() {
2387fn in_front_makes_parent_positioning_context() {
2373− let output =
2374− compile("@page T\n@el [width 200, height 200]\n Main\n @in-front\n Overlay");
2388+ let output = compile("@page T\n@el [width 200, height 200]\n Main\n @in-front\n Overlay");
2375 assert!(
2389 assert!(
2376 output.contains("position:relative"),
2390 output.contains("position:relative"),
2377 "parent of @in-front should be position:relative: {}",
2391 "parent of @in-front should be position:relative: {}",
@@ -2795,8 +2809,9 @@ fn test_color_filter_lighten() {
2795
2809
2796#[test]
2810#[test]
2797fn test_color_filter_darken() {
2811fn test_color_filter_darken() {
2798− let result =
2812+ let result = htmlang::parser::parse(
2799− htmlang::parser::parse("@let primary #ffffff\n@el [background ${darken($primary, 50)}] Content");
2813+ "@let primary #ffffff\n@el [background ${darken($primary, 50)}] Content",
2814+ );
2800 let html = htmlang::codegen::generate(&result.document);
2815 let html = htmlang::codegen::generate(&result.document);
2801 // Darken white by 50% should produce gray (#808080 approximately)
2816 // Darken white by 50% should produce gray (#808080 approximately)
2802 assert!(
2817 assert!(
@@ -2812,8 +2827,9 @@ fn test_color_filter_darken() {
2812
2827
2813#[test]
2828#[test]
2814fn test_color_filter_alpha() {
2829fn test_color_filter_alpha() {
2815− let result =
2830+ let result = htmlang::parser::parse(
2816− htmlang::parser::parse("@let primary #3b82f6\n@el [background ${alpha($primary, 0.5)}] Content");
2831+ "@let primary #3b82f6\n@el [background ${alpha($primary, 0.5)}] Content",
2832+ );
2817 let html = htmlang::codegen::generate(&result.document);
2833 let html = htmlang::codegen::generate(&result.document);
2818 // Should produce 8-digit hex with alpha
2834 // Should produce 8-digit hex with alpha
2819 assert!(
2835 assert!(
@@ -2911,8 +2927,9 @@ fn snapshot_layer_wrapping() {
2911
2927
2912#[test]
2928#[test]
2913fn grid_template_areas_passthrough() {
2929fn grid_template_areas_passthrough() {
2914− let output =
2930+ let output = compile(
2915− compile("@page T\n@el [display grid, grid-template-areas \"a b\"]\n @el [grid-area a]\n A");
2931+ "@page T\n@el [display grid, grid-template-areas \"a b\"]\n @el [grid-area a]\n A",
2932+ );
2916 assert!(
2933 assert!(
2917 output.contains("grid-template-areas:\"a b\""),
2934 output.contains("grid-template-areas:\"a b\""),
2918 "grid-template-areas should pass through: {}",
2935 "grid-template-areas should pass through: {}",
@@ -2941,9 +2958,7 @@ fn view_transition_name_passthrough() {
2941
2958
2942#[test]
2959#[test]
2943fn animate_generates_animation_css() {
2960fn animate_generates_animation_css() {
2944− let output = compile(
2945− "@page T\n@el [animation fade 0.3s ease]\n Content",
2961+ let output = compile("@page T\n@el [animation fade 0.3s ease]\n Content");
2946− );
2947 assert!(
2962 assert!(
2948 output.contains("animation:fade 0.3s ease"),
2963 output.contains("animation:fade 0.3s ease"),
2949 "animate should generate animation CSS: {}",
2964 "animate should generate animation CSS: {}",
@@ -3133,7 +3148,9 @@ fn search_element() {
3133
3148
3134#[test]
3149#[test]
3135fn font_face_directive() {
3150fn font_face_directive() {
3151+ let output = compile(
3136− let output = compile("@page T\n@style\n @font-face { font-family: 'Inter'; src: url('fonts/inter.woff2') format('woff2'); font-display: swap; }\n@head\n <link rel=\"preload\" href=\"fonts/inter.woff2\" as=\"font\" crossorigin>\n@text Hello");
3152+ "@page T\n@style\n @font-face { font-family: 'Inter'; src: url('fonts/inter.woff2') format('woff2'); font-display: swap; }\n@head\n <link rel=\"preload\" href=\"fonts/inter.woff2\" as=\"font\" crossorigin>\n@text Hello",
3153+ );
3137 assert!(output.contains("@font-face"), "font-face: {}", output);
3154 assert!(output.contains("@font-face"), "font-face: {}", output);
3138 assert!(
3155 assert!(
3139 output.contains("font-family: 'Inter'"),
3156 output.contains("font-family: 'Inter'"),
@@ -3146,7 +3163,9 @@ fn font_face_directive() {
3146
3163
3147#[test]
3164#[test]
3148fn json_ld_directive() {
3165fn json_ld_directive() {
3166+ let output = compile(
3149− let output = compile("@page T\n@head\n <script type=\"application/ld+json\">\n {\"@type\": \"WebPage\"}\n </script>\n@text Hello");
3167+ "@page T\n@head\n <script type=\"application/ld+json\">\n {\"@type\": \"WebPage\"}\n </script>\n@text Hello",
3168+ );
3150 assert!(
3169 assert!(
3151 output.contains("application/ld+json"),
3170 output.contains("application/ld+json"),
3152 "json-ld type: {}",
3171 "json-ld type: {}",
@@ -3438,16 +3457,31 @@ fn function_with_style_is_scoped() {
3438 let html = compile(
3457 let html = compile(
3439 "@let card $title\n @style\n & { padding: 4px; }\n .t { color: red; }\n @el [class=box]\n @text [class=t] $title\n@card [title Hello]\n",
3458 "@let card $title\n @style\n & { padding: 4px; }\n .t { color: red; }\n @el [class=box]\n @text [class=t] $title\n@card [title Hello]\n",
3440 );
3459 );
3460+ assert!(
3441− assert!(html.contains("<div class=\"a box hl-card\"><span class=\"t\">Hello"), "{}", html);
3461+ html.contains("<div class=\"a box hl-card\"><span class=\"t\">Hello"),
3462+ "{}",
3463+ html
3464+ );
3465+ assert!(
3442− assert!(html.contains(".hl-card {& { padding: 4px; }.t { color: red; }}"), "{}", html);
3466+ html.contains(".hl-card {& { padding: 4px; }.t { color: red; }}"),
3467+ "{}",
3468+ html
3469+ );
3470+ let diags = parse_diagnostics(
3443− let diags = parse_diagnostics("@let pair\n @style\n p { color: red; }\n @text A\n @text B\n@pair\n");
3471+ "@let pair\n @style\n p { color: red; }\n @text A\n @text B\n@pair\n",
3472+ );
3473+ assert!(
3474+ diags
3475+ .iter()
3444− assert!(diags.iter().any(|d| d.message.contains("no single root element to scope it to")), "{:?}", diags);
3476+ .any(|d| d.message.contains("no single root element to scope it to")),
3477+ "{:?}",
3478+ diags
3479+ );
3445}
3480}
3446
3481
3447#[test]
3482#[test]
3448fn function_without_style_has_no_wrapper() {
3483fn function_without_style_has_no_wrapper() {
3449− let html =
3450− compile("@let box\n @el [padding 10]\n @children\n\n@box\n @text Inside\n");
3484+ let html = compile("@let box\n @el [padding 10]\n @children\n\n@box\n @text Inside\n");
3451 assert!(!html.contains("hl-box"), "{}", html);
3485 assert!(!html.contains("hl-box"), "{}", html);
3452 assert!(html.contains("Inside"));
3486 assert!(html.contains("Inside"));
3453}
3487}
@@ -3921,7 +3955,9 @@ fn markdown_renders_list() {
3921
3955
3922#[test]
3956#[test]
3923fn scope_block_generates_css() {
3957fn scope_block_generates_css() {
3958+ let output = compile(
3924− let output = compile("@page Test\n@style\n @scope (.card) {\n .title { color: red; }\n }\n@text hello");
3959+ "@page Test\n@style\n @scope (.card) {\n .title { color: red; }\n }\n@text hello",
3960+ );
3925 assert!(
3961 assert!(
3926 output.contains("@scope (.card)"),
3962 output.contains("@scope (.card)"),
3927 "should generate @scope CSS block"
3963 "should generate @scope CSS block"
@@ -3930,7 +3966,9 @@ fn scope_block_generates_css() {
3930
3966
3931#[test]
3967#[test]
3932fn starting_style_generates_css() {
3968fn starting_style_generates_css() {
3969+ let output = compile(
3933− let output = compile("@page Test\n@style\n @starting-style {\n .fade { opacity: 0; }\n }\n@text hello");
3970+ "@page Test\n@style\n @starting-style {\n .fade { opacity: 0; }\n }\n@text hello",
3971+ );
3934 assert!(
3972 assert!(
3935 output.contains("@starting-style"),
3973 output.contains("@starting-style"),
3936 "should generate @starting-style CSS block"
3974 "should generate @starting-style CSS block"
@@ -4128,7 +4166,10 @@ fn svg_directive_with_attrs() {
4128 )
4166 )
4129 .unwrap();
4167 .unwrap();
4130
4168
4169+ let input = format!(
4131− let input = format!("@image [inline, width 24, color red] {}", svg_path.display());
4170+ "@image [inline, width 24, color red] {}",
4171+ svg_path.display()
4172+ );
4132 let result = htmlang::parser::parse(&input);
4173 let result = htmlang::parser::parse(&input);
4133 let html = htmlang::codegen::generate(&result.document);
4174 let html = htmlang::codegen::generate(&result.document);
4134 assert!(
4175 assert!(
@@ -4464,14 +4505,17 @@ fn markdown_file_missing_reports_error() {
4464
4505
4465#[test]
4506#[test]
4466fn function_call_text_becomes_children() {
4507fn function_call_text_becomes_children() {
4508+ let output =
4467− let output = compile("@let box\n @el [padding 4]\n @children\n@box Hello {@text [bold] world}");
4509+ compile("@let box\n @el [padding 4]\n @children\n@box Hello {@text [bold] world}");
4468 assert!(output.contains("Hello"), "{}", output);
4510 assert!(output.contains("Hello"), "{}", output);
4469 assert!(output.contains(">world</span>"), "{}", output);
4511 assert!(output.contains(">world</span>"), "{}", output);
4470}
4512}
4471
4513
4472#[test]
4514#[test]
4473fn function_call_extra_attributes_style_the_root() {
4515fn function_call_extra_attributes_style_the_root() {
4516+ let output = compile(
4474− let output = compile("@let box $label\n @el [padding 4] $label\n@box [label Hi, background red, hover:color blue]");
4517+ "@let box $label\n @el [padding 4] $label\n@box [label Hi, background red, hover:color blue]",
4518+ );
4475 assert!(output.contains("background:red"), "{}", output);
4519 assert!(output.contains("background:red"), "{}", output);
4476 assert!(output.contains(":hover{color:blue"), "{}", output);
4520 assert!(output.contains(":hover{color:blue"), "{}", output);
4477 assert!(output.contains("Hi"), "{}", output);
4521 assert!(output.contains("Hi"), "{}", output);
@@ -4481,7 +4525,9 @@ fn function_call_extra_attributes_style_the_root() {
4481fn function_call_extra_attributes_need_a_single_root() {
4525fn function_call_extra_attributes_need_a_single_root() {
4482 let diags = parse_diagnostics("@let two\n @text A\n @text B\n@two [background red]");
4526 let diags = parse_diagnostics("@let two\n @text A\n @text B\n@two [background red]");
4483 assert!(
4527 assert!(
4528+ diags
4529+ .iter()
4484− diags.iter().any(|d| d.message.contains("no single root element")),
4530+ .any(|d| d.message.contains("no single root element")),
4485 "{:?}",
4531 "{:?}",
4486 diags
4532 diags
4487 );
4533 );
@@ -4497,7 +4543,11 @@ fn children_prefix_styles_direct_children() {
4497fn standard_library_components_compile_cleanly() {
4543fn standard_library_components_compile_cleanly() {
4498 let src = "@row\n @text A\n @spacer\n @text [$truncate] B";
4544 let src = "@row\n @text A\n @spacer\n @text [$truncate] B";
4499 let diags = parse_diagnostics(src);
4545 let diags = parse_diagnostics(src);
4546+ assert!(
4547+ diags.is_empty(),
4500− assert!(diags.is_empty(), "standard library produced diagnostics: {:?}", diags);
4548+ "standard library produced diagnostics: {:?}",
4549+ diags
4550+ );
4501 let output = compile(src);
4551 let output = compile(src);
4502 assert!(output.contains("flex:1"), "{}", output);
4552 assert!(output.contains("flex:1"), "{}", output);
4503 assert!(output.contains("text-overflow:ellipsis"), "{}", output);
4553 assert!(output.contains("text-overflow:ellipsis"), "{}", output);