use noxid_compiler_core::compile;
use noxid_source::{Diagnostic, SourceFile, SourceId};
fn check(source: &str) -> Vec<Diagnostic> {
let file = SourceFile::new(SourceId(0), "Wo55.nox", source);
compile(&file).diagnostics
}
fn only(source: &str, code: &str) -> String {
let diagnostics = check(source);
let matching = diagnostics
.iter()
.filter(|diagnostic| diagnostic.code == code)
.collect::<Vec<_>>();
assert!(
!matching.is_empty(),
"expected {code}, saw {:?}",
diagnostics
.iter()
.map(|diagnostic| (diagnostic.code, diagnostic.message.as_str()))
.collect::<Vec<_>>()
);
matching[0].message.clone()
}
fn assert_clean(source: &str) {
let diagnostics = check(source);
let css = diagnostics
.iter()
.filter(|diagnostic| diagnostic.code.starts_with("CSS_"))
.map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message))
.collect::<Vec<_>>();
assert!(css.is_empty(), "expected no CSS refusals, saw {css:#?}");
}
#[test]
fn unused_selector_names_the_nearest_rendered_element() {
let message = only(
r#"
component Panel {
view { <article class="card"><span>ok</span></article> }
style { .crd { color: red; } }
}
"#,
"CSS_UNUSED_SELECTOR",
);
assert!(message.contains("`.crd`"), "{message}");
assert!(
message.contains("matches no element this component's view can render"),
"{message}"
);
assert!(
message.contains("the nearest element this view renders is `.card`"),
"{message}"
);
assert!(message.contains("wrap it in `:global(...)`"), "{message}");
}
#[test]
fn global_selectors_are_exempt_from_the_view_oracle() {
assert_clean(
r#"
component Panel {
view { <article class="card">ok</article> }
style {
:global(body) { margin: 0; }
.card { color: red; }
}
}
"#,
);
}
#[test]
fn a_bound_class_is_never_guessed_at() {
assert_clean(
r#"
component Panel {
state { tone: String = "warm" }
view { <article class={tone}>ok</article> }
style { .warm { color: red; } .cool { color: blue; } }
}
"#,
);
}
#[test]
fn descendant_and_child_combinators_match_through_the_view() {
assert_clean(
r#"
component Panel {
view {
<main class="layout">
<section class="cards"><article class="card"><span>ok</span></article></section>
</main>
}
style {
.layout .card { color: red; }
.cards > .card { border-color: blue; }
.card > span { display: block; }
}
}
"#,
);
let message = only(
r#"
component Panel {
view {
<main class="layout"><section class="cards">ok</section></main>
}
style { .cards > .layout { color: red; } }
}
"#,
"CSS_UNUSED_SELECTOR",
);
assert!(message.contains("`.cards > .layout`"), "{message}");
}
#[test]
fn unknown_property_names_the_nearest_known_one() {
let message = only(
r#"
component Panel {
view { <p class="note">ok</p> }
style { .note { bacground: red; } }
}
"#,
"CSS_UNKNOWN_PROPERTY",
);
assert!(
message.contains("unknown CSS property `bacground`"),
"{message}"
);
assert!(message.contains("Write `background` instead."), "{message}");
assert!(
message.contains("Custom properties (`--name`)"),
"{message}"
);
}
#[test]
fn custom_and_vendor_prefixed_properties_stay_legal() {
assert_clean(
r#"
component Panel {
view { <p class="note">ok</p> }
style { .note { --brand: #0ea5e9; -webkit-line-clamp: 3; color: var(--brand); } }
}
"#,
);
}
#[test]
fn invalid_value_lists_the_legal_kinds() {
let message = only(
r#"
component Panel {
view { <p class="note">ok</p> }
style { .note { color: 12px; } }
}
"#,
"CSS_INVALID_VALUE",
);
assert!(
message.contains("`12px` is not a legal value for `color`"),
"{message}"
);
assert!(message.contains("`color` accepts a color."), "{message}");
let keyword = only(
r#"
component Panel {
view { <p class="note">ok</p> }
style { .note { display: flexx; } }
}
"#,
"CSS_INVALID_VALUE",
);
assert!(keyword.contains("`flexx`"), "{keyword}");
assert!(keyword.contains("`flex`"), "{keyword}");
}
#[test]
fn unresolvable_values_are_never_refused() {
assert_clean(
r##"
design Product {
tokens { primary: Color = "#0ea5e9" }
components { Button }
}
component Panel {
view { <p class="note">ok</p> }
style {
.note {
color: token(primary);
padding: calc(1rem + 2px);
background: var(--anything, red);
width: clamp(1rem, 5vw, 10rem);
}
}
}
"##,
);
}
#[test]
fn shadowed_declaration_names_both_rules() {
let message = only(
r#"
component Panel {
view { <article class="card">ok</article> }
style {
.card { color: red; padding: 1rem; }
.card { color: blue; }
}
}
"#,
"CSS_SHADOWED_DECLARATION",
);
assert!(message.contains("`color` declared on `.card`"), "{message}");
assert!(message.contains("fully overridden"), "{message}");
assert!(message.contains("equal or higher specificity"), "{message}");
}
#[test]
fn pseudo_class_rules_do_not_shadow() {
assert_clean(
r#"
component Panel {
view { <button class="cta">ok</button> }
style {
.cta { color: red; }
.cta:hover { color: blue; }
}
}
"#,
);
}
#[test]
fn media_context_does_not_shadow_the_top_level() {
assert_clean(
r#"
component Panel {
view { <article class="card">ok</article> }
style {
.card { padding: 1.25rem; }
@media (min-width: 40rem) { .card { padding: 1.5rem; } }
}
}
"#,
);
}
#[test]
fn important_is_refused_in_a_component_block() {
let message = only(
r#"
component Panel {
view { <p class="note">ok</p> }
style { .note { color: red !important; } }
}
"#,
"CSS_IMPORTANT_FORBIDDEN",
);
assert!(message.contains("`!important` is not allowed"), "{message}");
assert!(message.contains("scoping already isolates"), "{message}");
assert!(message.contains("Delete `!important`"), "{message}");
}
#[test]
fn inapplicable_nested_property_names_the_parent_display() {
let message = only(
r#"
component Panel {
view { <div class="row"><span class="item">ok</span></div> }
style {
.row { display: block; }
.item { align-self: center; }
}
}
"#,
"CSS_INVALID_NESTING",
);
assert!(
message.contains("`align-self` only has an effect on a flex item"),
"{message}"
);
assert!(
message.contains("`display: block` on the parent `.row`"),
"{message}"
);
assert!(
message.contains("Declare `display: flex` on `.row`"),
"{message}"
);
}
#[test]
fn a_declared_flex_parent_makes_the_item_property_legal() {
assert_clean(
r#"
component Panel {
view { <div class="row"><span class="item">ok</span></div> }
style {
.row { display: flex; }
.item { align-self: center; }
}
}
"#,
);
}
#[test]
fn an_undeclared_display_is_never_inferred() {
assert_clean(
r#"
component Panel {
view { <div class="row"><span class="item">ok</span></div> }
style { .item { align-self: center; } }
}
"#,
);
}
#[test]
fn container_properties_check_the_elements_own_display() {
let message = only(
r#"
component Panel {
view { <div class="row">ok</div> }
style { .row { display: block; flex-direction: column; } }
}
"#,
"CSS_INVALID_NESTING",
);
assert!(
message.contains("`flex-direction` only has an effect on a flex container"),
"{message}"
);
assert!(message.contains("`display: block` on `.row`"), "{message}");
}
#[test]
fn every_shipped_example_and_the_website_stay_clean() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let mut checked = 0usize;
let mut refusals = Vec::new();
let mut stack = vec![root.join("examples"), root.join("website")];
while let Some(directory) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&directory) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
if path.extension().and_then(|value| value.to_str()) != Some("nox") {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
if !text.contains("style {") {
continue;
}
let file = SourceFile::new(SourceId(0), &path, text);
let diagnostics = compile(&file).diagnostics;
if diagnostics.iter().any(|diagnostic| {
diagnostic.severity == noxid_source::Severity::Error
&& !diagnostic.code.starts_with("CSS_")
}) {
continue;
}
checked += 1;
for diagnostic in diagnostics {
if diagnostic.code.starts_with("CSS_") {
refusals.push(format!(
"{}: {}: {}",
path.display(),
diagnostic.code,
diagnostic.message
));
}
}
}
}
assert!(
checked > 20,
"expected the shipped corpus, saw {checked} files"
);
assert!(refusals.is_empty(), "{refusals:#?}");
}
const STRICT_DESIGN: &str = r##"
design Product strict {
tokens {
ink: Color = "#0f172a"
surface: Color = "#ffffff"
faint: Color = "#a3b1c6"
spaceMd: Spacing = "1rem"
radiusMd: Radius = "8px"
quick: Duration = "150ms"
md: Breakpoint = "40rem"
}
components { Button }
}
"##;
fn strict(body: &str) -> String {
format!("{STRICT_DESIGN}\n{body}")
}
#[test]
fn a_raw_color_under_strict_enumerates_the_declared_tokens() {
let message = only(
&strict(
r#"
component Panel {
view { <p class="note">x</p> }
style { .note { color: #0f172a; } }
}
"#,
),
"CSS_TOKEN_REQUIRED",
);
assert!(
message.contains("`#0f172a` is a raw Color value"),
"{message}"
);
assert!(message.contains("`design Product strict`"), "{message}");
assert!(message.contains("`token(ink)`"), "{message}");
assert!(message.contains("`token(surface)`"), "{message}");
}
#[test]
fn strict_refuses_raw_lengths_radii_and_durations_by_position() {
let source = strict(
r#"
component Panel {
view { <p class="note">x</p> }
style {
.note {
padding: 1.25rem;
border-radius: 12px;
transition: color;
transition-duration: 200ms;
}
}
}
"#,
);
let kinds = check(&source)
.into_iter()
.filter(|diagnostic| diagnostic.code == "CSS_TOKEN_REQUIRED")
.map(|diagnostic| diagnostic.message)
.collect::<Vec<_>>();
assert!(
kinds
.iter()
.any(|message| message.contains("raw Spacing value")
&& message.contains("`token(spaceMd)`")),
"{kinds:#?}"
);
assert!(
kinds
.iter()
.any(|message| message.contains("raw Radius value")
&& message.contains("`token(radiusMd)`")),
"{kinds:#?}"
);
assert!(
kinds
.iter()
.any(|message| message.contains("raw Duration value")
&& message.contains("`token(quick)`")),
"{kinds:#?}"
);
}
#[test]
fn structural_literals_stay_legal_under_strict() {
assert_clean(&strict(
r#"
component Panel {
view { <p class="note">x</p> }
style {
.note {
margin: 0;
width: 100%;
max-width: none;
height: auto;
opacity: 1;
color: token(ink);
background: token(surface);
padding: token(spaceMd);
}
}
}
"#,
));
}
#[test]
fn a_width_query_without_a_breakpoint_token_is_refused() {
let message = only(
&strict(
r#"
component Panel {
view { <p class="note">x</p> }
style {
.note { color: token(ink); }
@media (min-width: 40rem) { .note { background: token(surface); } }
}
}
"#,
),
"CSS_TOKEN_REQUIRED",
);
assert!(message.contains("raw Breakpoint value"), "{message}");
assert!(message.contains("`token(md)`"), "{message}");
}
#[test]
fn a_width_query_naming_a_breakpoint_token_is_legal() {
assert_clean(&strict(
r#"
component Panel {
view { <p class="note">x</p> }
style {
.note { color: token(ink); }
@media (min-width: token(md)) { .note { background: token(surface); } }
}
}
"#,
));
}
#[test]
fn a_breakpoint_token_resolves_to_its_width_in_the_query_itself() {
let source = strict(
r#"
component Panel {
view { <p class="note">x</p> }
style {
.note { color: token(ink); }
@media (min-width: token(md)) { .note { background: token(surface); } }
}
}
"#,
);
let file = SourceFile::new(SourceId(0), "Wo55.nox", &source);
let styles = compile(&file).styles.to_json();
assert!(
styles.contains("\"prelude\":\"(min-width: 40rem)\""),
"breakpoint token did not resolve in the prelude: {styles}"
);
assert!(
!styles.contains("\"prelude\":\"(min-width: var("),
"a query prelude cannot use a custom property: {styles}"
);
}
#[test]
fn a_failing_contrast_pair_reports_the_ratio() {
let message = only(
&strict(
r#"
component Panel {
view { <p class="note">x</p> }
style { .note { color: token(faint); background: token(surface); } }
}
"#,
),
"CSS_CONTRAST_INSUFFICIENT",
);
assert!(message.contains("token(faint)"), "{message}");
assert!(message.contains("token(surface)"), "{message}");
assert!(message.contains(":1"), "{message}");
assert!(message.contains("WCAG AA minimum of 4.5:1"), "{message}");
}
#[test]
fn a_passing_contrast_pair_is_accepted_and_untokened_pairs_are_not_guessed() {
assert_clean(&strict(
r#"
component Panel {
view { <p class="note">x</p> }
style { .note { color: token(ink); background: token(surface); } }
}
"#,
));
assert_clean(
r#"
component Panel {
view { <p class="note">x</p> }
style { .note { color: #a3b1c6; background: #ffffff; } }
}
"#,
);
}
#[test]
fn a_non_strict_design_system_leaves_literals_alone() {
assert_clean(
r##"
design Product {
tokens { ink: Color = "#0f172a" }
components { Button }
}
component Panel {
view { <p class="note">x</p> }
style { .note { color: #123456; padding: 1rem; } }
}
"##,
);
}