on master help sign in
Fix CI: formatting, Windows line endings, playground deploy

- cargo fmt --check failed on formatting drift. Apply rustfmt, and mark the hand-laid tables (TAGS, the vocab word lists, the LSP doc tables) #[rustfmt::skip] so they keep one row per entry. - Snapshot tests failed on Windows because the expected .html files were checked out with CRLF. .gitattributes keeps text files LF everywhere. - The Pages workflow ran 'htmlang playground', which was removed with the other extra CLI commands. The playground is now a static page in crates/htmlang-wasm/playground that loads the wasm-bindgen output and opens with examples/demo.hl; the workflow copies them into dist/. - Drop two comments that described removed features (@theme tokens and the 'rounded' attribute).

commit6b4f106b7c8c67cc417249f22eef82ed459050f3 authoriko <mail@iko.soy> dateSat 26 Sep 2026, 04:47 (2 weeks ago) parent74fc727
17 files changed+512−219
.gitattributes+3 .github/workflows/playground.yml+4−5 crates/htmlang-core/src/ast.rs+14−4 crates/htmlang-core/src/codegen.rs+33−24 crates/htmlang-core/src/codegen_tests.rs+2−1 crates/htmlang-core/src/expr.rs+39−10 crates/htmlang-core/src/parser.rs+80−50 crates/htmlang-core/src/syntax.rs+38−14 crates/htmlang-core/src/vocab.rs+38−7 src/bin/htmlang_lsp/completion.rs+73−26 src/bin/htmlang_lsp/docs.rs+22−4 src/bin/htmlang_lsp/state.rs−1 src/cli.rs+1−3 src/fmt.rs+7−10 src/main.rs+14−9 tests/regressions.rs+57−14 tests/snapshots.rs+87−37
.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:
4040 --out-dir target/wasm-pkg \
4141 target/wasm32-unknown-unknown/release/htmlang_wasm.wasm
4242
43− - name: Build htmlang CLI
44− run: cargo build --release --bin htmlang
45−
46− - name: Generate playground HTML
43+ - name: Assemble the playground
4744 run: |
4845 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/
5049
5150 - name: Upload Pages artifact
5251 uses: actions/upload-pages-artifact@v3
crates/htmlang-core/src/ast.rs+14−4
@@ -38,7 +38,6 @@ pub struct Element {
3838 pub line_num: usize,
3939}
4040
41−
4241#[derive(Debug, Clone)]
4342pub struct Attribute {
4443 pub key: String,
@@ -95,7 +94,9 @@ impl ElementKind {
9594 /// the semantic containers)?
9695 pub fn is_column(&self) -> bool {
9796 matches!(self, ElementKind::El)
98− || self.spec().is_some_and(|spec| spec.css.contains("flex-direction:column"))
97+ || self
98+ .spec()
99+ .is_some_and(|spec| spec.css.contains("flex-direction:column"))
99100 }
100101
101102 /// Is this the table element named `name` (e.g. `"main"`)?
@@ -123,8 +124,16 @@ impl ElementKind {
123124 /// Every element name, for suggestions and completions.
124125 pub fn all_names() -> impl Iterator<Item = &'static str> {
125126 [
126− "row", "el", "text", "paragraph", "link", "image", "script", "fragment",
127− "children", "slot",
127+ "row",
128+ "el",
129+ "text",
130+ "paragraph",
131+ "link",
132+ "image",
133+ "script",
134+ "fragment",
135+ "children",
136+ "slot",
128137 ]
129138 .into_iter()
130139 .chain(TAGS.iter().map(|spec| spec.name))
@@ -176,6 +185,7 @@ impl TagSpec {
176185}
177186
178187/// Every element besides the core ones in [`ElementKind`].
188+#[rustfmt::skip]
179189pub static TAGS: &[TagSpec] = &[
180190 TagSpec { name: "nav", html: "nav", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
181191 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 {
159159 fn to_css_formatted(&self, dev: bool) -> String {
160160 let mut css = String::new();
161161 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+ });
163167
164168 // Non-responsive rules. Base and pseudo rules are merged separately by
165169 // 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 {
595599 generate_node(node, None, &mut body, &mut styles, &mut ctx);
596600 }
597601
598−
599602 let element_css = build_element_css(doc, &styles, dev);
600603
601604 // Build meta tags string
@@ -774,9 +777,8 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
774777fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> String {
775778 let mut element_css = String::new();
776779
777− // Collect all CSS custom properties (explicit `@let --name` / `@theme`
778− // tokens, plus any auto-extracted repeats) so they can be emitted in a
779− // single `:root` block below.
780+ // Collect the CSS custom properties declared with `@let --name` so they
781+ // can be emitted in a single `:root` block below.
780782 let mut root_vars: Vec<(String, String)> = Vec::new();
781783 for (name, value) in &doc.css_vars {
782784 root_vars.push((name.clone(), value.clone()));
@@ -834,7 +836,10 @@ fn reset_css(dev: bool, focus_visible_css: &str) -> String {
834836 };
835837 let rules = format!("{}{}", base, focus_visible_css);
836838 if dev {
837− format!("@layer hl-reset, htmlang;\n@layer hl-reset {{\n{}}}\n", rules)
839+ format!(
840+ "@layer hl-reset, htmlang;\n@layer hl-reset {{\n{}}}\n",
841+ rules
842+ )
838843 } else {
839844 format!("@layer hl-reset,htmlang;@layer hl-reset{{{}}}", rules)
840845 }
@@ -879,12 +884,10 @@ fn generate_node(
879884 match node {
880885 Node::Element(elem) => generate_element(elem, parent_kind, out, styles, ctx),
881886 Node::Text(segments) => {
882− let needs_wrap = matches!(
883− parent_kind,
884− Some(ElementKind::Row | ElementKind::El)
885− ) || parent_kind
886− .and_then(ElementKind::spec)
887− .is_some_and(|spec| spec.wraps_text);
887+ let needs_wrap = matches!(parent_kind, Some(ElementKind::Row | ElementKind::El))
888+ || parent_kind
889+ .and_then(ElementKind::spec)
890+ .is_some_and(|spec| spec.wraps_text);
888891 if needs_wrap {
889892 out.push_str(&ctx.indent());
890893 out.push_str("<span>");
@@ -903,7 +906,6 @@ fn generate_node(
903906 }
904907}
905908
906−
907909/// Emit the argument of an element whose argument is an HTML attribute
908910/// (`@iframe URL` → `src="URL"`, `@form /submit` → `action="/submit"`).
909911fn emit_argument_attr(out: &mut String, elem: &Element) {
@@ -1091,7 +1093,6 @@ fn generate_element(
10911093 out.push('<');
10921094 out.push_str(tag);
10931095
1094−
10951096 if elem.kind == ElementKind::Link
10961097 && let Some(url) = &elem.argument
10971098 {
@@ -1132,8 +1133,8 @@ fn generate_element(
11321133
11331134 // Children
11341135 ctx.depth += 1;
1135− let is_paragraph = elem.kind == ElementKind::Paragraph
1136− || elem.kind.spec().is_some_and(|spec| spec.inline);
1136+ let is_paragraph =
1137+ elem.kind == ElementKind::Paragraph || elem.kind.spec().is_some_and(|spec| spec.inline);
11371138 for (i, child) in elem.children.iter().enumerate() {
11381139 generate_node(child, Some(&elem.kind), out, styles, ctx);
11391140 if is_paragraph && i < elem.children.len() - 1 {
@@ -1341,9 +1342,20 @@ fn compute_class(
13411342
13421343 let dark = attrs_to_css(attrs, "dark:", kind, parent_kind, has_overlay_children);
13431344 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_reduce =
1346− attrs_to_css(attrs, "motion-reduce:", 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+ );
1352+ let motion_reduce = attrs_to_css(
1353+ attrs,
1354+ "motion-reduce:",
1355+ kind,
1356+ parent_kind,
1357+ has_overlay_children,
1358+ );
13471359 let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind, has_overlay_children);
13481360 let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind, has_overlay_children);
13491361
@@ -1491,8 +1503,6 @@ fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option
14911503/// semantic wrapper that behaves like a column.
14921504const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
14931505
1494−
1495−
14961506fn attrs_to_css(
14971507 attrs: &[Attribute],
14981508 state_prefix: &str,
@@ -2002,7 +2012,8 @@ fn css_px(value: &str) -> String {
20022012 return "0".to_string();
20032013 }
20042014 let is_bare_number = !v.is_empty()
2005− && v.bytes().all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'+'))
2015+ && v.bytes()
2016+ .all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'+'))
20062017 && v.parse::<f64>().is_ok();
20072018 if is_bare_number {
20082019 format!("{}px", v)
@@ -2147,5 +2158,3 @@ fn vlq_encode(value: i64, out: &mut String) {
21472158 }
21482159 }
21492160}
2150−
2151−
crates/htmlang-core/src/codegen_tests.rs+2−1
@@ -47,7 +47,8 @@ mod tests {
4747
4848 #[test]
4949 fn dev_mode_is_deterministic_across_runs() {
50− let src = "@row [spacing 10]\n @text [font-weight bold] a\n @text [font-style italic] b\n";
50+ let src =
51+ "@row [spacing 10]\n @text [font-weight bold] a\n @text [font-style italic] b\n";
5152 let r1 = parse(src);
5253 let r2 = parse(src);
5354 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> {
375375 if args.len() == n {
376376 Ok(())
377377 } else {
378− Err(format!("{}() takes {} argument{}", name, n, if n == 1 { "" } else { "s" }))
378+ Err(format!(
379+ "{}() takes {} argument{}",
380+ name,
381+ n,
382+ if n == 1 { "" } else { "s" }
383+ ))
379384 }
380385 };
381386 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> {
394399 other => other.to_string().contains(&text(1)),
395400 })
396401 }
397− "starts-with" => { arity(2)?; Value::Bool(text(0).starts_with(&text(1))) }
398− "ends-with" => { arity(2)?; Value::Bool(text(0).ends_with(&text(1))) }
399− "uppercase" => { arity(1)?; Value::Str(text(0).to_uppercase()) }
400− "lowercase" => { arity(1)?; Value::Str(text(0).to_lowercase()) }
402+ "starts-with" => {
403+ arity(2)?;
404+ Value::Bool(text(0).starts_with(&text(1)))
405+ }
406+ "ends-with" => {
407+ arity(2)?;
408+ Value::Bool(text(0).ends_with(&text(1)))
409+ }
410+ "uppercase" => {
411+ arity(1)?;
412+ Value::Str(text(0).to_uppercase())
413+ }
414+ "lowercase" => {
415+ arity(1)?;
416+ Value::Str(text(0).to_lowercase())
417+ }
401418 "capitalize" => {
402419 arity(1)?;
403420 let s = text(0);
@@ -407,7 +424,10 @@ fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
407424 None => String::new(),
408425 })
409426 }
410− "trim" => { arity(1)?; Value::Str(text(0).trim().to_string()) }
427+ "trim" => {
428+ arity(1)?;
429+ Value::Str(text(0).trim().to_string())
430+ }
411431 "length" => {
412432 arity(1)?;
413433 Value::Num(match &args[0] {
@@ -415,7 +435,10 @@ fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
415435 other => other.to_string().chars().count(),
416436 } as f64)
417437 }
418− "reverse" => { arity(1)?; Value::Str(text(0).chars().rev().collect()) }
438+ "reverse" => {
439+ arity(1)?;
440+ Value::Str(text(0).chars().rev().collect())
441+ }
419442 "truncate" => {
420443 arity(2)?;
421444 let (s, n) = (text(0), num(1)? as usize);
@@ -425,7 +448,10 @@ fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
425448 s
426449 })
427450 }
428− "replace" => { arity(3)?; Value::Str(text(0).replace(&text(1), &text(2))) }
451+ "replace" => {
452+ arity(3)?;
453+ Value::Str(text(0).replace(&text(1), &text(2)))
454+ }
429455 "default" => {
430456 arity(2)?;
431457 let s = text(0);
@@ -501,7 +527,6 @@ fn mix_colors(c1: (u8, u8, u8), c2: (u8, u8, u8), weight: f64) -> (u8, u8, u8) {
501527 (r, g, b)
502528}
503529
504−
505530/// Replace `$name` references inside a string literal.
506531fn interpolate(s: &str, resolve: Resolver) -> String {
507532 let mut out = String::with_capacity(s.len());
@@ -513,7 +538,11 @@ fn interpolate(s: &str, resolve: Resolver) -> String {
513538 if end == 0 {
514539 out.push('$');
515540 } else {
516− out.push_str(&resolve(&after[..end]).map(|v| v.to_string()).unwrap_or_default());
541+ out.push_str(
542+ &resolve(&after[..end])
543+ .map(|v| v.to_string())
544+ .unwrap_or_default(),
545+ );
517546 }
518547 rest = &after[end..];
519548 }
crates/htmlang-core/src/parser.rs+80−50
@@ -226,12 +226,22 @@ impl Evaluator {
226226 nodes
227227 }
228228
229− fn eval(&mut self, node: &Syntax, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
229+ fn eval(
230+ &mut self,
231+ node: &Syntax,
232+ ctx: &mut ParseContext,
233+ ) -> Result<Option<Vec<Node>>, ParseError> {
230234 let line_num = node.line();
231235 ctx.current_line = line_num;
232236 let (content, current_indent, children) = match node {
233237 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+ } => {
235245 self.define_function(name, params, defaults, body, line_num, ctx);
236246 return Ok(None);
237247 }
@@ -250,10 +260,20 @@ impl Evaluator {
250260 }
251261 return Ok(chosen.map(|body| self.eval_scoped(body, ctx)));
252262 }
253− Syntax::Each { header, body, empty, .. } => {
263+ Syntax::Each {
264+ header,
265+ body,
266+ empty,
267+ ..
268+ } => {
254269 return self.eval_each(header, body, empty, line_num, ctx).map(Some);
255270 }
256− Syntax::Line { text, indent, children, .. } => (text.clone(), *indent, children),
271+ Syntax::Line {
272+ text,
273+ indent,
274+ children,
275+ ..
276+ } => (text.clone(), *indent, children),
257277 };
258278
259279 // --- Directives ---
@@ -288,7 +308,6 @@ impl Evaluator {
288308 return Ok(None);
289309 }
290310
291−
292311 if let Some(rest) = content.strip_prefix("@let ") {
293312 let rest = rest.trim();
294313
@@ -355,7 +374,6 @@ impl Evaluator {
355374 return Ok(None);
356375 }
357376
358−
359377 if content == "@head" || content.starts_with("@head ") {
360378 let trimmed = block_text(children).trim().to_string();
361379 if !trimmed.is_empty() {
@@ -373,13 +391,13 @@ impl Evaluator {
373391 return Ok(None);
374392 }
375393
376−
377394 // --- @markdown block or file (convert markdown to HTML) ---
378395 if content.trim() == "@markdown" || content.trim().starts_with("@markdown ") {
379396 let arg = content.trim().strip_prefix("@markdown").unwrap().trim();
380397 if arg.is_empty() {
381398 // Inline markdown block: indented children
382− let md_lines: Vec<String> = block_text(children).lines().map(String::from).collect();
399+ let md_lines: Vec<String> =
400+ block_text(children).lines().map(String::from).collect();
383401 let html = markdown_to_html(&md_lines);
384402 return Ok(Some(vec![Node::Raw(html)]));
385403 } else {
@@ -416,8 +434,6 @@ impl Evaluator {
416434 }
417435 }
418436
419−
420−
421437 if let Some(rest) = content.strip_prefix("@include ") {
422438 let rest = rest.trim();
423439 let filename = rest
@@ -487,8 +503,6 @@ impl Evaluator {
487503 return Ok(Some(included_nodes));
488504 }
489505
490−
491−
492506 // --- @data (load JSON file into variables) ---
493507
494508 // @data $name file.json values as $name.key
@@ -737,7 +751,10 @@ impl Evaluator {
737751 message: "@each requires: @each $item in LIST".to_string(),
738752 });
739753 };
740− let names: Vec<&str> = names.split(',').map(|v| v.trim().trim_start_matches('$')).collect();
754+ let names: Vec<&str> = names
755+ .split(',')
756+ .map(|v| v.trim().trim_start_matches('$'))
757+ .collect();
741758 if names.len() > 2 {
742759 return Err(ParseError {
743760 line: line_num,
@@ -752,11 +769,16 @@ impl Evaluator {
752769
753770 // A list loaded from JSON, by name
754771 let data_list = list_src.strip_prefix('$').and_then(|name| {
755− let len = ctx.variables.get(&format!("{}#", name))?.parse::<usize>().ok()?;
772+ let len = ctx
773+ .variables
774+ .get(&format!("{}#", name))?
775+ .parse::<usize>()
776+ .ok()?;
756777 Some((name.to_string(), len))
757778 });
758779 let undefined = list_src.strip_prefix('$').filter(|name| {
759− name.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
780+ name.chars()
781+ .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
760782 && !ctx.variables.contains_key(*name)
761783 });
762784 let text_items: Vec<String> = match (&data_list, undefined) {
@@ -963,7 +985,10 @@ impl Evaluator {
963985
964986 // Attributes that aren't parameters style the function's root
965987 // 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));
967992 if !forwarded.is_empty() || scope_class.is_some() {
968993 let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
969994 Node::Element(e) => Some(e),
@@ -981,7 +1006,8 @@ impl Evaluator {
9811006 match root.attrs.iter_mut().find(|a| a.html && a.key == "class") {
9821007 Some(attr) => {
9831008 let existing = attr.value.take().unwrap_or_default();
984− attr.value = Some(format!("{} {}", existing, class).trim().to_string());
1009+ attr.value =
1010+ Some(format!("{} {}", existing, class).trim().to_string());
9851011 }
9861012 None => root.attrs.push(Attribute {
9871013 key: "class".to_string(),
@@ -1184,7 +1210,10 @@ fn parse_single_element(
11841210 Some(restore_escapes(&rest_sub))
11851211 }
11861212 } 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+ )))
11881217 };
11891218
11901219 // For @slot, the argument is the slot name
@@ -1231,17 +1260,7 @@ pub fn known_directives() -> &'static [&'static str] {
12311260}
12321261
12331262const KNOWN_DIRECTIVES: &[&str] = &[
1234− "page",
1235− "let",
1236− "include",
1237− "raw",
1238− "if",
1239− "else",
1240− "each",
1241− "meta",
1242− "head",
1243− "style",
1244− "markdown",
1263+ "page", "let", "include", "raw", "if", "else", "each", "meta", "head", "style", "markdown",
12451264 "data",
12461265];
12471266
@@ -1935,7 +1954,10 @@ fn parse_attr_list(
19351954
19361955 // Color validation for hex colors
19371956 if !attr.html
1938− && matches!(crate::vocab::base_attribute(&attr.key), "background" | "color")
1957+ && matches!(
1958+ crate::vocab::base_attribute(&attr.key),
1959+ "background" | "color"
1960+ )
19391961 && let Some(ref val) = attr.value
19401962 && val.starts_with('#')
19411963 && !is_valid_hex_color(val)
@@ -2249,8 +2271,6 @@ fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
22492271 items
22502272}
22512273
2252−
2253−
22542274/// Evaluate `if(cond, a)` or `if(cond, a, b)` when it is all of `text`,
22552275/// returning the chosen branch (empty when `cond` fails and there is no `b`).
22562276fn choose_if(text: &str, ctx: &mut ParseContext, line: usize) -> Option<String> {
@@ -2376,10 +2396,11 @@ pub fn lint(nodes: &[Node]) -> Vec<Diagnostic> {
23762396 depth
23772397 ));
23782398 }
2379− if matches!(elem.kind, ElementKind::Row | ElementKind::El)
2380− && elem.children.is_empty()
2381− {
2382− warn(format!("empty container (@{}) has no children", elem.kind.name()));
2399+ if matches!(elem.kind, ElementKind::Row | ElementKind::El) && elem.children.is_empty() {
2400+ warn(format!(
2401+ "empty container (@{}) has no children",
2402+ elem.kind.name()
2403+ ));
23832404 }
23842405 if elem.kind.is_tag("button") && !elem.attrs.iter().any(|a| a.key == "type") {
23852406 warn("@button missing 'type' attribute (defaults to submit)".to_string());
@@ -2392,7 +2413,6 @@ pub fn lint(nodes: &[Node]) -> Vec<Diagnostic> {
23922413 out
23932414}
23942415
2395−
23962416fn validate_tree(
23972417 nodes: &[Node],
23982418 parent_kind: Option<&ElementKind>,
@@ -2475,8 +2495,7 @@ fn validate_tree(
24752495 }
24762496
24772497 // 'rows'/'cols' only on @textarea
2478− if (base == "rows" || base == "cols") && !elem.kind.is_tag("textarea")
2479− {
2498+ if (base == "rows" || base == "cols") && !elem.kind.is_tag("textarea") {
24802499 diagnostics.push(Diagnostic {
24812500 line: elem.line_num,
24822501 column: None,
@@ -2526,9 +2545,7 @@ fn validate_tree(
25262545 source_line: None,
25272546 });
25282547 }
2529− if elem.kind.is_tag("input")
2530− && !elem.attrs.iter().any(|a| a.key == "type")
2531− {
2548+ if elem.kind.is_tag("input") && !elem.attrs.iter().any(|a| a.key == "type") {
25322549 diagnostics.push(Diagnostic {
25332550 line: elem.line_num,
25342551 column: None,
@@ -2570,7 +2587,10 @@ fn validate_tree(
25702587 .find(|a| crate::vocab::base_attribute(&a.key) == "color")
25712588 .and_then(|a| a.value.as_deref());
25722589 if let (Some(bg), Some(fg)) = (bg_color, fg_color)
2573− && let (Some(bg_rgb), Some(fg_rgb)) = (crate::expr::parse_hex_rgb(bg), crate::expr::parse_hex_rgb(fg))
2590+ && let (Some(bg_rgb), Some(fg_rgb)) = (
2591+ crate::expr::parse_hex_rgb(bg),
2592+ crate::expr::parse_hex_rgb(fg),
2593+ )
25742594 {
25752595 let ratio = contrast_ratio(bg_rgb, fg_rgb);
25762596 if ratio < 4.5 {
@@ -2612,9 +2632,7 @@ fn validate_tree(
26122632 }
26132633
26142634 // @iframe should have title attribute
2615− if elem.kind.is_tag("iframe")
2616− && !elem.attrs.iter().any(|a| a.key == "title")
2617− {
2635+ if elem.kind.is_tag("iframe") && !elem.attrs.iter().any(|a| a.key == "title") {
26182636 diagnostics.push(Diagnostic {
26192637 line: elem.line_num,
26202638 column: None,
@@ -2729,7 +2747,9 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
27292747 rest = &after[close + 1..];
27302748 continue;
27312749 }
2732− let mut end = after.find(|c: char| !is_name_char(c)).unwrap_or(after.len());
2750+ let mut end = after
2751+ .find(|c: char| !is_name_char(c))
2752+ .unwrap_or(after.len());
27332753 while end > 0 && after[..end].ends_with('.') {
27342754 end -= 1;
27352755 }
@@ -2756,7 +2776,10 @@ fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
27562776 };
27572777 let is_inline_svg = elem.kind == ElementKind::Image
27582778 && elem.attrs.iter().any(|a| a.key == "inline" && !a.html)
2759− && elem.argument.as_deref().is_some_and(|src| src.ends_with(".svg"));
2779+ && elem
2780+ .argument
2781+ .as_deref()
2782+ .is_some_and(|src| src.ends_with(".svg"));
27602783 if !is_inline_svg {
27612784 return node;
27622785 }
@@ -3231,10 +3254,17 @@ fn flatten_json(prefix: &str, value: &JsonValue, vars: &mut HashMap<String, Stri
32313254/// JSON array, else its text.
32323255fn lookup(vars: &HashMap<String, String>, name: &str) -> Option<crate::expr::Value> {
32333256 use crate::expr::Value;
3234− match vars.get(&format!("{}#", name)).and_then(|n| n.parse::<usize>().ok()) {
3257+ match vars
3258+ .get(&format!("{}#", name))
3259+ .and_then(|n| n.parse::<usize>().ok())
3260+ {
32353261 Some(len) => Some(Value::List(
32363262 (0..len)
3237− .map(|i| vars.get(&format!("{}.{}", name, i)).cloned().unwrap_or_default())
3263+ .map(|i| {
3264+ vars.get(&format!("{}.{}", name, i))
3265+ .cloned()
3266+ .unwrap_or_default()
3267+ })
32383268 .collect(),
32393269 )),
32403270 None => vars.get(name).cloned().map(Value::Str),
crates/htmlang-core/src/syntax.rs+38−14
@@ -78,7 +78,12 @@ impl Syntax {
7878 pub fn source(&self) -> String {
7979 match self {
8080 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+ } => {
8287 let mut out = format!("@let {}", name);
8388 for param in params {
8489 match defaults.get(param) {
@@ -333,7 +338,11 @@ pub(crate) fn open_attr_depth(line: &str) -> i32 {
333338 return 0;
334339 };
335340 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+ };
337346 }
338347 let bytes = line.as_bytes();
339348 let mut depth: i32 = 0;
@@ -343,11 +352,7 @@ pub(crate) fn open_attr_depth(line: &str) -> i32 {
343352 b'[' => {
344353 // Is this the start of an attribute list?
345354 let before = line[..i].trim_end();
346− let last_directive = before
347− .rsplit([']', '>'])
348− .next()
349− .unwrap_or("")
350− .trim();
355+ let last_directive = before.rsplit([']', '>']).next().unwrap_or("").trim();
351356 let tokens: Vec<&str> = last_directive.split_whitespace().collect();
352357 let starts_list = match tokens.as_slice() {
353358 [] => before.is_empty() || before.ends_with('>'),
@@ -369,7 +374,6 @@ pub(crate) fn open_attr_depth(line: &str) -> i32 {
369374 depth
370375}
371376
372−
373377#[cfg(test)]
374378mod tests {
375379 use super::*;
@@ -393,21 +397,41 @@ mod tests {
393397 "@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",
394398 );
395399 assert_eq!(kinds(&tree), ["function", "if", "each", "line"]);
396− let Syntax::Function { name, params, defaults, body, .. } = &tree[0] else { unreachable!() };
397− assert_eq!((name.as_str(), params.len(), defaults["icon"].as_str()), ("card", 2, "x"));
400+ let Syntax::Function {
401+ name,
402+ params,
403+ defaults,
404+ body,
405+ ..
406+ } = &tree[0]
407+ else {
408+ unreachable!()
409+ };
410+ assert_eq!(
411+ (name.as_str(), params.len(), defaults["icon"].as_str()),
412+ ("card", 2, "x")
413+ );
398414 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+ };
400418 let conditions: Vec<_> = branches.iter().map(|b| b.condition.as_deref()).collect();
401419 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+ };
403423 assert_eq!(kinds(empty), ["line"]);
404424 }
405425
406426 #[test]
407427 fn text_bodies_are_verbatim() {
408428 let tree = parse("@style\n :root {\n --brand: red;\n }\n@text x\n");
409− let Syntax::Line { children, .. } = &tree[0] else { unreachable!() };
410− let [Syntax::Raw { text, .. }] = children.as_slice() else { panic!("{:?}", children) };
429+ let Syntax::Line { children, .. } = &tree[0] else {
430+ unreachable!()
431+ };
432+ let [Syntax::Raw { text, .. }] = children.as_slice() else {
433+ panic!("{:?}", children)
434+ };
411435 assert_eq!(text, ":root {\n --brand: red;\n}");
412436 assert_eq!(kinds(&tree), ["line", "line"]);
413437 }
crates/htmlang-core/src/vocab.rs+38−7
@@ -2,13 +2,14 @@
22//!
33//! An attribute in `[...]` is one of three things:
44//! - 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;
66//! - a standard CSS property, copied into the generated CSS unchanged;
77//! - an HTML attribute, written `key=value` (or bare, for booleans such as
88//! `required`) and emitted on the element.
99
1010/// htmlang's own attributes (those that are neither a CSS property nor an
1111/// HTML attribute).
12+#[rustfmt::skip]
1213pub const HTMLANG_ATTRIBUTES: &[&str] = &[
1314 "align-bottom", "align-left", "align-right", "align-top", "center-x", "center-y",
1415 "col-span", "grid-cols", "grid-rows", "inline", "row-span",
@@ -17,6 +18,7 @@ pub const HTMLANG_ATTRIBUTES: &[&str] = &[
1718
1819/// Standard CSS properties, sorted. Any of these can be written as an
1920/// attribute and is copied into the generated CSS as-is.
21+#[rustfmt::skip]
2022pub const CSS_PROPERTIES: &[&str] = &[
2123 "accent-color", "align-content", "align-items", "align-self", "all", "anchor-name",
2224 "animation", "animation-composition", "animation-delay", "animation-direction",
@@ -116,6 +118,7 @@ pub const CSS_PROPERTIES: &[&str] = &[
116118/// Boolean HTML attributes, written bare (`[required]`) and rendered
117119/// without a value (`<input required>`). HTML attributes with a value are
118120/// written `key=value`.
121+#[rustfmt::skip]
119122pub const BOOLEAN_HTML_ATTRS: &[&str] = &[
120123 "allowfullscreen", "async", "autofocus", "autoplay", "checked", "controls", "defer",
121124 "disabled", "download", "formnovalidate", "inert", "loop", "multiple", "muted", "nomodule",
@@ -125,6 +128,7 @@ pub const BOOLEAN_HTML_ATTRS: &[&str] = &[
125128
126129/// Common HTML attribute names, used to suggest `key=value` when one is
127130/// written like a style (`type email`). Any name works with `=`.
131+#[rustfmt::skip]
128132pub const HTML_ATTRIBUTES: &[&str] = &[
129133 "abbr", "accept", "action", "allow", "allowfullscreen", "alt", "aria-atomic", "aria-live",
130134 "aria-relevant", "async", "autocomplete", "autofocus", "autoplay", "blocking", "charset",
@@ -145,15 +149,42 @@ pub const HTML_ATTRIBUTES: &[&str] = &[
145149/// CSS properties whose bare numbers are lengths, so `margin-top 16` means
146150/// `16px`. (Others, like `opacity` or `z-index`, take unitless numbers.)
147151pub 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+ ];
149159 const SUFFIXES: &[&str] = &["-width", "-height", "-radius", "-offset", "-spacing", "gap"];
150160 const EXACT: &[&str] = &[
151− "top", "right", "bottom", "left", "width", "height", "font-size", "block-size",
152− "inline-size", "min-block-size", "max-block-size", "min-inline-size", "max-inline-size",
153− "text-indent", "flex-basis", "perspective", "text-decoration-thickness",
161+ "top",
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",
177+ "text-decoration-thickness",
154178 // Shorthands that start with a width: `border 1 solid red`
155− "border", "border-top", "border-right", "border-bottom", "border-left", "border-block",
156− "border-inline", "outline", "column-rule",
179+ "border",
180+ "border-top",
181+ "border-right",
182+ "border-bottom",
183+ "border-left",
184+ "border-block",
185+ "border-inline",
186+ "outline",
187+ "column-rule",
157188 ];
158189 // A bare `line-height` number is a multiplier, not a length
159190 name != "line-height"
src/bin/htmlang_lsp/completion.rs+73−26
@@ -100,7 +100,6 @@ pub(crate) fn in_brackets(text: &str) -> bool {
100100 depth > 0
101101}
102102
103−
104103fn item(
105104 label: &str,
106105 kind: CompletionItemKind,
@@ -145,7 +144,13 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
145144 .map(|name| {
146145 let label = format!("@{}", name);
147146 let detail = docs::directive(name).map_or("Directive", |d| d.summary);
148− item(&label, CompletionItemKind::SNIPPET, detail, &format!("{} ", label), range)
147+ item(
148+ &label,
149+ CompletionItemKind::SNIPPET,
150+ detail,
151+ &format!("{} ", label),
152+ range,
153+ )
149154 })
150155 .collect()
151156}
@@ -257,7 +262,6 @@ pub(crate) fn path_completions(uri: &Url, position: Position) -> Vec<CompletionI
257262 items
258263}
259264
260−
261265/// Walk back from `position` to find the element directive that opened the
262266/// nearest unmatched `[`. Returns the bare name without the leading `@`
263267/// (e.g. `"input"`).
@@ -266,8 +270,8 @@ pub(crate) fn owning_element(text: &str, position: Position) -> Option<String> {
266270 // First, locate the line that contains the unmatched `[`. We scan from
267271 // the cursor back, tracking depth.
268272 let cursor_line = position.line as usize;
269− let cursor_col = (position.character as usize)
270− .min(lines.get(cursor_line).map(|l| l.len()).unwrap_or(0));
273+ let cursor_col =
274+ (position.character as usize).min(lines.get(cursor_line).map(|l| l.len()).unwrap_or(0));
271275 let mut depth: i32 = 0;
272276 let mut bracket_line: Option<usize> = None;
273277 let mut bracket_col: usize = 0;
@@ -348,7 +352,14 @@ fn element_specific_attrs(element: &str) -> &'static [&'static str] {
348352 "popovertarget",
349353 "popovertargetaction",
350354 ],
351− "select" => &["name", "multiple", "required", "disabled", "size", "autofocus"],
355+ "select" => &[
356+ "name",
357+ "multiple",
358+ "required",
359+ "disabled",
360+ "size",
361+ "autofocus",
362+ ],
352363 "textarea" => &[
353364 "name",
354365 "rows",
@@ -384,7 +395,14 @@ fn element_specific_attrs(element: &str) -> &'static [&'static str] {
384395 "srcset",
385396 "sizes",
386397 ],
387− "link" => &["href", "target", "rel", "download", "referrerpolicy", "type"],
398+ "link" => &[
399+ "href",
400+ "target",
401+ "rel",
402+ "download",
403+ "referrerpolicy",
404+ "type",
405+ ],
388406 "video" => &[
389407 "src",
390408 "controls",
@@ -490,7 +508,14 @@ fn attr_value_completions(before: &str, range: Range) -> Option<Vec<CompletionIt
490508 ],
491509 "text-align" => &["left", "center", "right", "justify", "start", "end"],
492510 "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+ ],
494519 "overflow" | "overflow-x" | "overflow-y" => {
495520 &["visible", "hidden", "scroll", "auto", "clip"]
496521 }
@@ -545,21 +570,22 @@ fn attr_value_completions(before: &str, range: Range) -> Option<Vec<CompletionIt
545570 "scope" => &["row", "col", "rowgroup", "colgroup"],
546571 "wrap" => &["soft", "hard", "off"],
547572 "inputmode" => &[
548− "text",
549− "numeric",
550− "decimal",
551− "email",
552− "search",
553− "tel",
554− "url",
555− "none",
573+ "text", "numeric", "decimal", "email", "search", "tel", "url", "none",
556574 ],
557575 "enterkeyhint" => &["enter", "done", "go", "next", "previous", "search", "send"],
558576 "fetchpriority" => &["high", "low", "auto"],
559577 "spellcheck" | "translate" => &["true", "false"],
560578 "color-scheme" => &["light", "dark", "light dark", "normal"],
561579 "appearance" => &["none", "auto"],
562− "autocomplete" => &["on", "off", "name", "email", "username", "current-password", "new-password"],
580+ "autocomplete" => &[
581+ "on",
582+ "off",
583+ "name",
584+ "email",
585+ "username",
586+ "current-password",
587+ "new-password",
588+ ],
563589 "scroll-behavior" => &["smooth", "auto"],
564590 "resize" => &["none", "both", "horizontal", "vertical", "block", "inline"],
565591 "writing-mode" => &["horizontal-tb", "vertical-rl", "vertical-lr"],
@@ -598,7 +624,9 @@ fn attr_value_completions(before: &str, range: Range) -> Option<Vec<CompletionIt
598624 "auto",
599625 "pinch-zoom",
600626 ],
601− "contain" => &["none", "strict", "content", "size", "layout", "style", "paint"],
627+ "contain" => &[
628+ "none", "strict", "content", "size", "layout", "style", "paint",
629+ ],
602630 "content-visibility" => &["visible", "auto", "hidden"],
603631 _ => return None,
604632 };
@@ -649,12 +677,24 @@ fn attr_completions(range: Range, element: Option<&str>) -> Vec<CompletionItem>
649677 }
650678 for name in vocab::BOOLEAN_HTML_ATTRS {
651679 if !vocab::is_style_attribute(name) {
652− push(name.to_string(), name.to_string(), "HTML attribute (boolean)", "3", name);
680+ push(
681+ name.to_string(),
682+ name.to_string(),
683+ "HTML attribute (boolean)",
684+ "3",
685+ name,
686+ );
653687 }
654688 }
655689 for name in vocab::HTML_ATTRIBUTES {
656690 if !vocab::BOOLEAN_HTML_ATTRS.contains(name) {
657− push(format!("{}=", name), format!("{}=", name), "HTML attribute", "3", name);
691+ push(
692+ format!("{}=", name),
693+ format!("{}=", name),
694+ "HTML attribute",
695+ "3",
696+ name,
697+ );
658698 }
659699 }
660700 let prefixes = vocab::PSEUDO_PREFIXES
@@ -800,7 +840,13 @@ fn variable_completions(text: &str, range: Range) -> Vec<CompletionItem> {
800840 for doc in docs::BUNDLES {
801841 let label = format!("${}", doc.name);
802842 let detail = format!("{} (standard-library bundle)", doc.summary);
803− items.push(item(&label, CompletionItemKind::CONSTANT, &detail, &label, range));
843+ items.push(item(
844+ &label,
845+ CompletionItemKind::CONSTANT,
846+ &detail,
847+ &label,
848+ range,
849+ ));
804850 }
805851
806852 for line in text.lines() {
@@ -918,10 +964,7 @@ mod tests {
918964 #[test]
919965 fn owning_element_finds_element_across_lines() {
920966 let text = "@button [\n padding 10,\n ";
921− assert_eq!(
922− owning_element(text, pos(2, 2)),
923− Some("button".to_string())
924− );
967+ assert_eq!(owning_element(text, pos(2, 2)), Some("button".to_string()));
925968 }
926969
927970 #[test]
@@ -946,7 +989,11 @@ mod tests {
946989 let elements = element_completions(range);
947990 for name in ElementKind::all_names() {
948991 let label = format!("@{}", name);
949− assert!(elements.iter().any(|i| i.label == label), "missing {}", label);
992+ assert!(
993+ elements.iter().any(|i| i.label == label),
994+ "missing {}",
995+ label
996+ );
950997 }
951998 assert!(elements.iter().any(|i| i.label == "@spacer"));
952999 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) ->
3232
3333/// Elements (names without `@`). Elements not listed get a summary derived
3434/// from their row in `TAGS`.
35+#[rustfmt::skip]
3536pub(crate) const ELEMENTS: &[Doc] = &[
3637 doc("row", "Horizontal layout: a flex row.", "@row [spacing 10]\n @text A\n @text B"),
3738 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] = &[
8283];
8384
8485/// Directives (names without `@`).
86+#[rustfmt::skip]
8587pub(crate) const DIRECTIVES: &[Doc] = &[
8688 doc(
8789 "page",
@@ -110,6 +112,7 @@ pub(crate) const DIRECTIVES: &[Doc] = &[
110112];
111113
112114/// htmlang's own attributes, plus CSS properties htmlang treats specially.
115+#[rustfmt::skip]
113116pub(crate) const ATTRIBUTES: &[Doc] = &[
114117 doc("spacing", "Gap between children.", "spacing 20"),
115118 doc("padding", "Inner space: 1 to 4 values; bare numbers are px.", "padding 12 24"),
@@ -132,11 +135,13 @@ pub(crate) const ATTRIBUTES: &[Doc] = &[
132135];
133136
134137/// Standard-library components (`std.hl`), used like elements.
138+#[rustfmt::skip]
135139pub(crate) const COMPONENTS: &[Doc] = &[
136140 doc("spacer", "Takes up the remaining space in a row or column.", "@spacer"),
137141];
138142
139143/// Standard-library attribute bundles, used as `[$name]`.
144+#[rustfmt::skip]
140145pub(crate) const BUNDLES: &[Doc] = &[
141146 doc("truncate", "Cuts text off at one line with an ellipsis.", "@text [$truncate] A long title"),
142147];
@@ -187,7 +192,10 @@ fn markdown(title: &str, summary: &str, usage: &str) -> String {
187192 if usage.is_empty() {
188193 format!("**{}** \u{2014} {}", title, summary)
189194 } else {
190− format!("**{}** \u{2014} {}\n\n```htmlang\n{}\n```", title, summary, usage)
195+ format!(
196+ "**{}** \u{2014} {}\n\n```htmlang\n{}\n```",
197+ title, summary, usage
198+ )
191199 }
192200}
193201
@@ -255,7 +263,9 @@ fn attribute_hover(name: &str) -> Option<String> {
255263 "**{name}** \u{2014} Boolean HTML attribute, written bare: `[{name}]`."
256264 ));
257265 }
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-")
259269 {
260270 return Some(format!(
261271 "**{name}** \u{2014} HTML attribute, written `{name}=value`."
@@ -329,10 +339,18 @@ mod tests {
329339 #[test]
330340 fn documented_names_exist_in_the_compiler() {
331341 for doc in ELEMENTS {
332− assert!(ElementKind::from_name(doc.name).is_some(), "@{} is not an element", doc.name);
342+ assert!(
343+ ElementKind::from_name(doc.name).is_some(),
344+ "@{} is not an element",
345+ doc.name
346+ );
333347 }
334348 for doc in ATTRIBUTES {
335− assert!(vocab::is_style_attribute(doc.name), "{} is not a style attribute", doc.name);
349+ assert!(
350+ vocab::is_style_attribute(doc.name),
351+ "{} is not a style attribute",
352+ doc.name
353+ );
336354 }
337355 }
338356
src/bin/htmlang_lsp/state.rs−1
@@ -274,4 +274,3 @@ mod tests {
274274 assert_eq!(s, "ab!\ncd");
275275 }
276276}
277−
src/cli.rs+1−3
@@ -2,9 +2,7 @@ use std::env;
22use std::process;
33
44/// Subcommands understood by the CLI.
5−pub const COMMANDS: &[&str] = &[
6− "build", "serve", "watch", "check", "lint", "fmt", "lsp",
7−];
5+pub const COMMANDS: &[&str] = &["build", "serve", "watch", "check", "lint", "fmt", "lsp"];
86
97pub fn print_help() {
108 eprintln!(
src/fmt.rs+7−10
@@ -87,15 +87,9 @@ fn open_attr_depth(line: &str) -> i32 {
8787/// Directives whose indented body is foreign content (CSS, JS, Markdown,
8888/// JSON, raw HTML) that must not be reformatted.
8989fn is_raw_body_header(code: &str) -> bool {
90− [
91− "@style",
92− "@script",
93− "@markdown",
94− "@head",
95− "@raw",
96− ]
97− .iter()
98− .any(|d| code == *d || code.strip_prefix(d).is_some_and(|r| r.starts_with(' ')))
90+ ["@style", "@script", "@markdown", "@head", "@raw"]
91+ .iter()
92+ .any(|d| code == *d || code.strip_prefix(d).is_some_and(|r| r.starts_with(' ')))
9993}
10094
10195/// Re-indent a verbatim line by `delta` bytes, keeping its relative
@@ -496,7 +490,10 @@ mod tests {
496490 #[test]
497491 fn commas_inside_parens_stay_in_value() {
498492 let src = "@el [box-shadow 0 1px 3px rgba(0,0,0,0.1),font-weight bold] hi\n";
499− assert_eq!(format(src), "@el [box-shadow 0 1px 3px rgba(0,0,0,0.1), font-weight bold] hi\n");
493+ assert_eq!(
494+ format(src),
495+ "@el [box-shadow 0 1px 3px rgba(0,0,0,0.1), font-weight bold] hi\n"
496+ );
500497 }
501498
502499 #[test]
src/main.rs+14−9
@@ -156,7 +156,6 @@ fn json_escape_string(s: &str) -> String {
156156 out
157157}
158158
159−
160159fn json_array(items: impl Iterator<Item = String>) -> String {
161160 let inner: Vec<String> = items.collect();
162161 format!("[{}]", inner.join(","))
@@ -349,11 +348,18 @@ fn lint_file(path: &str) -> Vec<String> {
349348 .diagnostics
350349 .iter()
351350 .chain(&lint)
352− .map(|d| format!("{}:{}:{}: {}", path, d.line, severity_label(d.severity), d.message))
351+ .map(|d| {
352+ format!(
353+ "{}:{}:{}: {}",
354+ path,
355+ d.line,
356+ severity_label(d.severity),
357+ d.message
358+ )
359+ })
353360 .collect()
354361}
355362
356−
357363fn open_in_browser(port: u16) {
358364 let url = format!("http://127.0.0.1:{}", port);
359365 let cmd = if cfg!(target_os = "macos") {
@@ -516,7 +522,6 @@ fn main() {
516522 return;
517523 }
518524
519−
520525 // Handle "fmt" subcommand
521526 if args.len() >= 3 && args[1] == "fmt" {
522527 let file = &args[2];
@@ -646,7 +651,6 @@ fn main() {
646651 return;
647652 }
648653
649−
650654 // Handle "lint" subcommand
651655 if args.len() >= 2 && args[1] == "lint" {
652656 let mut lint_target = ".";
@@ -699,7 +703,6 @@ fn main() {
699703 return;
700704 }
701705
702−
703706 // Handle "check" subcommand
704707 if args.len() >= 2 && args[1] == "check" {
705708 let mut check_target = None;
@@ -773,7 +776,6 @@ fn main() {
773776 return;
774777 }
775778
776−
777779 // Handle "serve" standalone subcommand
778780 if args.len() >= 2 && args[1] == "serve" {
779781 let mut serve_target = None;
@@ -837,7 +839,11 @@ fn main() {
837839 let server_tx = tx.clone();
838840 std::thread::spawn(move || {
839841 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+ ));
841847 });
842848 if serve_open {
843849 open_in_browser(effective_port);
@@ -997,7 +1003,6 @@ fn main() {
9971003 return;
9981004 }
9991005
1000−
10011006 let mut i = 1;
10021007 while i < args.len() {
10031008 match args[i].as_str() {
tests/regressions.rs+57−14
@@ -9,7 +9,10 @@ use htmlang::parser::{self, Severity};
99fn compile(input: &str) -> String {
1010 let result = parser::parse(input);
1111 assert!(
12− result.diagnostics.iter().all(|d| d.severity != Severity::Error),
12+ result
13+ .diagnostics
14+ .iter()
15+ .all(|d| d.severity != Severity::Error),
1316 "unexpected parse errors: {:?}",
1417 result.diagnostics
1518 );
@@ -100,7 +103,8 @@ fn px_values_are_not_doubled() {
100103
101104#[test]
102105fn minify_keeps_significant_spaces() {
103− let result = parser::parse("@page T\n@paragraph\n Built with {@text [font-weight bold] htmlang}.");
106+ let result =
107+ parser::parse("@page T\n@paragraph\n Built with {@text [font-weight bold] htmlang}.");
104108 let out = codegen::generate_minified(&result.document);
105109 assert!(out.contains("Built with <span"), "{}", out);
106110}
@@ -147,7 +151,8 @@ fn image_with_css_width_gets_no_intrinsic_height() {
147151
148152#[test]
149153fn source_map_uses_real_output_lines() {
150− let html = "<!DOCTYPE html>\n<html>\n<div data-hl-line=\"3\">\n<span data-hl-line=\"4\">x</span>\n";
154+ let html =
155+ "<!DOCTYPE html>\n<html>\n<div data-hl-line=\"3\">\n<span data-hl-line=\"4\">x</span>\n";
151156 let map = codegen::source_map_for_html(html, "a.hl");
152157 // Lines 1-2 have no mapping; line 3 maps to source line 3 (0-based 2 =
153158 // VLQ "E"), line 4 to source line 4 (delta 1 = "C").
@@ -158,7 +163,8 @@ fn source_map_uses_real_output_lines() {
158163
159164#[test]
160165fn bracket_in_text_does_not_join_lines() {
161− let out = compile("@el\n Use [ to open a list\n @text [font-weight bold] Second\n @text Third");
166+ let out =
167+ compile("@el\n Use [ to open a list\n @text [font-weight bold] Second\n @text Third");
162168 assert!(out.contains("Use [ to open a list"), "{}", out);
163169 assert!(out.contains(">Second<"), "{}", out);
164170 assert!(out.contains(">Third<"), "{}", out);
@@ -173,7 +179,9 @@ fn multiline_attributes_still_join() {
173179
174180#[test]
175181fn multi_line_content_is_a_function() {
176− let out = compile("@let intro\n First line\n Second {@text [font-weight bold] line}\n@paragraph\n @intro\n");
182+ let out = compile(
183+ "@let intro\n First line\n Second {@text [font-weight bold] line}\n@paragraph\n @intro\n",
184+ );
177185 assert!(out.contains("First line"), "{}", out);
178186 assert!(out.contains(">line</span>"), "{}", out);
179187}
@@ -216,7 +224,11 @@ fn markdown_keeps_paragraphs_rules_and_ordered_lists() {
216224 assert!(out.contains("<p>First para.</p>"), "{}", out);
217225 assert!(out.contains("<p>Second para.</p>"), "{}", out);
218226 assert!(out.contains("<hr>"), "{}", out);
219− assert!(out.contains("<ol>\n<li>one</li>\n<li>two</li>\n</ol>"), "{}", out);
227+ assert!(
228+ out.contains("<ol>\n<li>one</li>\n<li>two</li>\n</ol>"),
229+ "{}",
230+ out
231+ );
220232 assert!(out.contains("if x:\n y"), "{}", out);
221233}
222234
@@ -284,7 +296,9 @@ fn container_arguments_are_rendered_as_text() {
284296
285297#[test]
286298fn html_attributes_use_equals_and_are_all_emitted() {
287− let out = compile("@link [target=_blank, rel=me] /x Home\n@iframe [sandbox, allow=camera] https://e.com");
299+ let out = compile(
300+ "@link [target=_blank, rel=me] /x Home\n@iframe [sandbox, allow=camera] https://e.com",
301+ );
288302 assert!(out.contains("target=\"_blank\""), "{}", out);
289303 assert!(out.contains("rel=\"me\""), "{}", out);
290304 assert!(out.contains(" sandbox"), "{}", out);
@@ -364,7 +378,11 @@ fn data_glob_loads_a_list_of_records() {
364378 "@data $posts posts/*.json\n@text ${length($posts)} posts\n@each $p, $i in $posts\n @text $i $p.title ($p.file)",
365379 );
366380 assert!(out.contains("2 posts"), "{}", out);
367− assert!(out.contains(">0 First (a)<") && out.contains(">1 Second (b)<"), "{}", out);
381+ assert!(
382+ out.contains(">0 First (a)<") && out.contains(">1 Second (b)<"),
383+ "{}",
384+ out
385+ );
368386}
369387
370388#[test]
@@ -378,18 +396,39 @@ fn lint_adds_only_checks_the_compiler_does_not_already_make() {
378396 .map(|d| d.message.as_str())
379397 .collect();
380398 // Each problem is reported once, whichever pass finds it.
381− assert_eq!(messages.iter().filter(|m| m.contains("'alt'")).count(), 1, "{:?}", messages);
382− assert!(messages.iter().any(|m| m.contains("empty container")), "{:?}", messages);
383− assert!(messages.iter().any(|m| m.contains("'type'")), "{:?}", messages);
399+ assert_eq!(
400+ messages.iter().filter(|m| m.contains("'alt'")).count(),
401+ 1,
402+ "{:?}",
403+ messages
404+ );
405+ assert!(
406+ messages.iter().any(|m| m.contains("empty container")),
407+ "{:?}",
408+ messages
409+ );
410+ assert!(
411+ messages.iter().any(|m| m.contains("'type'")),
412+ "{:?}",
413+ messages
414+ );
384415}
385416
386417#[test]
387418fn inline_svg_resolves_from_the_page_and_keeps_attributes() {
388419 let dir = scratch_dir("inline_svg");
389420 std::fs::create_dir_all(dir.join("icons")).unwrap();
390− std::fs::write(dir.join("icons/a.svg"), r#"<svg viewBox="0 0 24 24"></svg>"#).unwrap();
421+ std::fs::write(
422+ dir.join("icons/a.svg"),
423+ r#"<svg viewBox="0 0 24 24"></svg>"#,
424+ )
425+ .unwrap();
391426 let out = compile_in(&dir, "@image [inline, width 24, class=icon] icons/a.svg");
392− assert!(out.contains(r#"<svg viewBox="0 0 24 24" width="24" class="icon">"#), "{}", out);
427+ assert!(
428+ out.contains(r#"<svg viewBox="0 0 24 24" width="24" class="icon">"#),
429+ "{}",
430+ out
431+ );
393432}
394433
395434#[test]
@@ -407,7 +446,11 @@ fn lines_under_text_are_its_siblings() {
407446 let html = htmlang::codegen::generate(
408447 &htmlang::parser::parse("@el\n Some text\n more text\n").document,
409448 );
410− assert!(html.contains("<span>Some text</span><span>more text</span>"), "{}", html);
449+ assert!(
450+ html.contains("<span>Some text</span><span>more text</span>"),
451+ "{}",
452+ html
453+ );
411454}
412455
413456#[test]
tests/snapshots.rs+87−37
@@ -790,8 +790,7 @@ fn match_selects_correct_case() {
790790
791791#[test]
792792fn 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");
795794 assert!(output.contains("Default"));
796795 assert!(!output.contains(">A<"));
797796}
@@ -884,7 +883,8 @@ fn ternary_expression_in_attrs() {
884883
885884#[test]
886885fn ternary_expression_false() {
887− let output = compile("@page T\n@let active false\n@el [color if($active, green, gray)]\n test");
886+ let output =
887+ compile("@page T\n@let active false\n@el [color if($active, green, gray)]\n test");
888888 assert!(output.contains("color:gray"));
889889}
890890
@@ -902,7 +902,8 @@ fn comparison_operators_lt() {
902902
903903#[test]
904904fn comparison_operators_contains() {
905− let output = compile("@page T\n@let name hello world\n@if contains($name, world)\n @text found");
905+ let output =
906+ compile("@page T\n@let name hello world\n@if contains($name, world)\n @text found");
906907 assert!(output.contains("found"));
907908}
908909
@@ -967,7 +968,9 @@ fn internal_link_no_noopener() {
967968
968969#[test]
969970fn theme_color_meta_from_theme() {
970− let output = compile("@page T\n@let primary #3b82f6\n@let --primary #3b82f6\n@meta theme-color #3b82f6\n@el\n test");
971+ let output = compile(
972+ "@page T\n@let primary #3b82f6\n@let --primary #3b82f6\n@meta theme-color #3b82f6\n@el\n test",
973+ );
971974 assert!(output.contains("theme-color"));
972975 assert!(output.contains("#3b82f6"));
973976}
@@ -1286,9 +1289,17 @@ fn each_binds_records() {
12861289#[test]
12871290fn each_second_variable_is_the_index() {
12881291 let output = compile("@each $item, $i in New York, Paris\n @text $i=$item");
1289− assert!(output.contains("0=New York") && output.contains("1=Paris"), "{}", output);
1292+ assert!(
1293+ output.contains("0=New York") && output.contains("1=Paris"),
1294+ "{}",
1295+ output
1296+ );
12901297 let diags = parse_diagnostics("@each $a, $b, $c in x\n @text $a");
1291− assert!(diags.iter().any(|d| d.message.contains("`$item, $index`")), "{:?}", diags);
1298+ assert!(
1299+ diags.iter().any(|d| d.message.contains("`$item, $index`")),
1300+ "{:?}",
1301+ diags
1302+ );
12921303}
12931304
12941305// ---------------------------------------------------------------------------
@@ -1395,7 +1406,12 @@ fn fmt_examples_idempotent_and_semantics_preserving() {
13951406 let src = fs::read_to_string(&path).unwrap();
13961407 let once = htmlang::fmt::format(&src);
13971408 let twice = htmlang::fmt::format(&once);
1398− assert_eq!(once, twice, "formatter not idempotent on {}", path.display());
1409+ assert_eq!(
1410+ once,
1411+ twice,
1412+ "formatter not idempotent on {}",
1413+ path.display()
1414+ );
13991415 assert_eq!(
14001416 compile_with_base(&src, &dir),
14011417 compile_with_base(&once, &dir),
@@ -2337,9 +2353,8 @@ fn element_grid() {
23372353
23382354#[test]
23392355fn element_in_front() {
2340− let output = compile(
2341− "@page T\n@el [width 200, height 200]\n Main\n @in-front\n @text Overlay",
2342− );
2356+ let output =
2357+ compile("@page T\n@el [width 200, height 200]\n Main\n @in-front\n @text Overlay");
23432358 assert!(
23442359 output.contains("position:absolute"),
23452360 "@in-front should have position:absolute: {}",
@@ -2370,8 +2385,7 @@ fn element_behind() {
23702385
23712386#[test]
23722387fn 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");
23752389 assert!(
23762390 output.contains("position:relative"),
23772391 "parent of @in-front should be position:relative: {}",
@@ -2795,8 +2809,9 @@ fn test_color_filter_lighten() {
27952809
27962810#[test]
27972811fn test_color_filter_darken() {
2798− let result =
2799− htmlang::parser::parse("@let primary #ffffff\n@el [background ${darken($primary, 50)}] Content");
2812+ let result = htmlang::parser::parse(
2813+ "@let primary #ffffff\n@el [background ${darken($primary, 50)}] Content",
2814+ );
28002815 let html = htmlang::codegen::generate(&result.document);
28012816 // Darken white by 50% should produce gray (#808080 approximately)
28022817 assert!(
@@ -2812,8 +2827,9 @@ fn test_color_filter_darken() {
28122827
28132828#[test]
28142829fn test_color_filter_alpha() {
2815− let result =
2816− htmlang::parser::parse("@let primary #3b82f6\n@el [background ${alpha($primary, 0.5)}] Content");
2830+ let result = htmlang::parser::parse(
2831+ "@let primary #3b82f6\n@el [background ${alpha($primary, 0.5)}] Content",
2832+ );
28172833 let html = htmlang::codegen::generate(&result.document);
28182834 // Should produce 8-digit hex with alpha
28192835 assert!(
@@ -2911,8 +2927,9 @@ fn snapshot_layer_wrapping() {
29112927
29122928#[test]
29132929fn grid_template_areas_passthrough() {
2914− let output =
2915− compile("@page T\n@el [display grid, grid-template-areas \"a b\"]\n @el [grid-area a]\n A");
2930+ let output = compile(
2931+ "@page T\n@el [display grid, grid-template-areas \"a b\"]\n @el [grid-area a]\n A",
2932+ );
29162933 assert!(
29172934 output.contains("grid-template-areas:\"a b\""),
29182935 "grid-template-areas should pass through: {}",
@@ -2941,9 +2958,7 @@ fn view_transition_name_passthrough() {
29412958
29422959#[test]
29432960fn animate_generates_animation_css() {
2944− let output = compile(
2945− "@page T\n@el [animation fade 0.3s ease]\n Content",
2946− );
2961+ let output = compile("@page T\n@el [animation fade 0.3s ease]\n Content");
29472962 assert!(
29482963 output.contains("animation:fade 0.3s ease"),
29492964 "animate should generate animation CSS: {}",
@@ -3133,7 +3148,9 @@ fn search_element() {
31333148
31343149#[test]
31353150fn font_face_directive() {
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");
3151+ let output = compile(
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+ );
31373154 assert!(output.contains("@font-face"), "font-face: {}", output);
31383155 assert!(
31393156 output.contains("font-family: 'Inter'"),
@@ -3146,7 +3163,9 @@ fn font_face_directive() {
31463163
31473164#[test]
31483165fn json_ld_directive() {
3149− let output = compile("@page T\n@head\n <script type=\"application/ld+json\">\n {\"@type\": \"WebPage\"}\n </script>\n@text Hello");
3166+ let output = compile(
3167+ "@page T\n@head\n <script type=\"application/ld+json\">\n {\"@type\": \"WebPage\"}\n </script>\n@text Hello",
3168+ );
31503169 assert!(
31513170 output.contains("application/ld+json"),
31523171 "json-ld type: {}",
@@ -3438,16 +3457,31 @@ fn function_with_style_is_scoped() {
34383457 let html = compile(
34393458 "@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",
34403459 );
3441− assert!(html.contains("<div class=\"a box hl-card\"><span class=\"t\">Hello"), "{}", html);
3442− assert!(html.contains(".hl-card {& { padding: 4px; }.t { color: red; }}"), "{}", html);
3443− let diags = parse_diagnostics("@let pair\n @style\n p { color: red; }\n @text A\n @text B\n@pair\n");
3444− assert!(diags.iter().any(|d| d.message.contains("no single root element to scope it to")), "{:?}", diags);
3460+ assert!(
3461+ html.contains("<div class=\"a box hl-card\"><span class=\"t\">Hello"),
3462+ "{}",
3463+ html
3464+ );
3465+ assert!(
3466+ html.contains(".hl-card {& { padding: 4px; }.t { color: red; }}"),
3467+ "{}",
3468+ html
3469+ );
3470+ let diags = parse_diagnostics(
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()
3476+ .any(|d| d.message.contains("no single root element to scope it to")),
3477+ "{:?}",
3478+ diags
3479+ );
34453480}
34463481
34473482#[test]
34483483fn 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");
34513485 assert!(!html.contains("hl-box"), "{}", html);
34523486 assert!(html.contains("Inside"));
34533487}
@@ -3921,7 +3955,9 @@ fn markdown_renders_list() {
39213955
39223956#[test]
39233957fn scope_block_generates_css() {
3924− let output = compile("@page Test\n@style\n @scope (.card) {\n .title { color: red; }\n }\n@text hello");
3958+ let output = compile(
3959+ "@page Test\n@style\n @scope (.card) {\n .title { color: red; }\n }\n@text hello",
3960+ );
39253961 assert!(
39263962 output.contains("@scope (.card)"),
39273963 "should generate @scope CSS block"
@@ -3930,7 +3966,9 @@ fn scope_block_generates_css() {
39303966
39313967#[test]
39323968fn starting_style_generates_css() {
3933− let output = compile("@page Test\n@style\n @starting-style {\n .fade { opacity: 0; }\n }\n@text hello");
3969+ let output = compile(
3970+ "@page Test\n@style\n @starting-style {\n .fade { opacity: 0; }\n }\n@text hello",
3971+ );
39343972 assert!(
39353973 output.contains("@starting-style"),
39363974 "should generate @starting-style CSS block"
@@ -4128,7 +4166,10 @@ fn svg_directive_with_attrs() {
41284166 )
41294167 .unwrap();
41304168
4131− let input = format!("@image [inline, width 24, color red] {}", svg_path.display());
4169+ let input = format!(
4170+ "@image [inline, width 24, color red] {}",
4171+ svg_path.display()
4172+ );
41324173 let result = htmlang::parser::parse(&input);
41334174 let html = htmlang::codegen::generate(&result.document);
41344175 assert!(
@@ -4464,14 +4505,17 @@ fn markdown_file_missing_reports_error() {
44644505
44654506#[test]
44664507fn function_call_text_becomes_children() {
4467− let output = compile("@let box\n @el [padding 4]\n @children\n@box Hello {@text [bold] world}");
4508+ let output =
4509+ compile("@let box\n @el [padding 4]\n @children\n@box Hello {@text [bold] world}");
44684510 assert!(output.contains("Hello"), "{}", output);
44694511 assert!(output.contains(">world</span>"), "{}", output);
44704512}
44714513
44724514#[test]
44734515fn function_call_extra_attributes_style_the_root() {
4474− let output = compile("@let box $label\n @el [padding 4] $label\n@box [label Hi, background red, hover:color blue]");
4516+ let output = compile(
4517+ "@let box $label\n @el [padding 4] $label\n@box [label Hi, background red, hover:color blue]",
4518+ );
44754519 assert!(output.contains("background:red"), "{}", output);
44764520 assert!(output.contains(":hover{color:blue"), "{}", output);
44774521 assert!(output.contains("Hi"), "{}", output);
@@ -4481,7 +4525,9 @@ fn function_call_extra_attributes_style_the_root() {
44814525fn function_call_extra_attributes_need_a_single_root() {
44824526 let diags = parse_diagnostics("@let two\n @text A\n @text B\n@two [background red]");
44834527 assert!(
4484− diags.iter().any(|d| d.message.contains("no single root element")),
4528+ diags
4529+ .iter()
4530+ .any(|d| d.message.contains("no single root element")),
44854531 "{:?}",
44864532 diags
44874533 );
@@ -4497,7 +4543,11 @@ fn children_prefix_styles_direct_children() {
44974543fn standard_library_components_compile_cleanly() {
44984544 let src = "@row\n @text A\n @spacer\n @text [$truncate] B";
44994545 let diags = parse_diagnostics(src);
4500− assert!(diags.is_empty(), "standard library produced diagnostics: {:?}", diags);
4546+ assert!(
4547+ diags.is_empty(),
4548+ "standard library produced diagnostics: {:?}",
4549+ diags
4550+ );
45014551 let output = compile(src);
45024552 assert!(output.contains("flex:1"), "{}", output);
45034553 assert!(output.contains("text-overflow:ellipsis"), "{}", output);