on master help sign in
Merge @import into @include

@include now has what @import had: quoted names, globs (parts/*.hl) and namespacing (@include lib.hl as ui, which takes only the definitions). A file with only definitions emits no content, so importing a library is just @include. htmlang upgrade rewrites @import (and @use) to @include.

commit5fa0bb49e5d592e99e910b7201bb9fba9d1390eb authoriko <mail@iko.soy> dateSat 26 Sep 2026, 01:59 (2 weeks ago) parent56ce116
3 files changed+32−95
crates/htmlang-core/src/parser.rs+19−87 src/upgrade.rs+6−2 tests/snapshots.rs+7−6
crates/htmlang-core/src/parser.rs+19−87
@@ -931,79 +931,7 @@ impl Parser {
931931 }
932932
933933 if let Some(rest) = content.strip_prefix("@include ") {
934− let filename = substitute_vars(rest.trim(), &ctx.variables);
935− let resolved = match &ctx.base_path {
936− Some(base) => base.join(&filename),
937− None => PathBuf::from(&filename),
938− };
939−
940− // Circular include check
941− if ctx.include_stack.contains(&resolved) {
942− let cycle_chain = format_include_chain(&ctx.include_stack);
943− ctx.diagnostics.push(Diagnostic {
944− line: line_num,
945− column: None,
946− message: format!(
947− "circular include '{}' (cycle: {} → {})",
948− filename, cycle_chain, filename
949− ),
950− severity: Severity::Error,
951− source_line: Some(content.clone()),
952− });
953− return Ok(None);
954− }
955−
956− // Use file cache to avoid redundant reads
957− let included_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
958− cached.clone()
959− } else {
960− match std::fs::read_to_string(&resolved) {
961− Ok(text) => {
962− ctx.file_cache.insert(resolved.clone(), text.clone());
963− text
964− }
965− Err(e) => {
966− ctx.diagnostics.push(Diagnostic {
967− line: line_num,
968− column: None,
969− message: format!("cannot include '{}': {}", filename, e),
970− severity: Severity::Error,
971− source_line: Some(content.clone()),
972− });
973− return Ok(None);
974− }
975− }
976− };
977−
978− ctx.included_files.push(resolved.clone());
979− ctx.include_stack.push(resolved.clone());
980− let saved_base = ctx.base_path.clone();
981− ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
982−
983− let diag_count_before = ctx.diagnostics.len();
984− let included_lines = preprocess(&included_text);
985− let mut included_parser = Parser {
986− lines: included_lines,
987− pos: 0,
988− };
989− let nodes = included_parser.parse_children(0, ctx);
990−
991− // Annotate new diagnostics with include chain
992− let include_chain = format_include_chain(&ctx.include_stack);
993− for d in &mut ctx.diagnostics[diag_count_before..] {
994− d.message = format!("{}\n in {}", d.message, include_chain);
995− }
996−
997− ctx.base_path = saved_base;
998− ctx.include_stack.pop();
999− return Ok(Some(nodes));
1000− }
1001−
1002− // --- @import (definitions only, no DOM nodes) ---
1003−
1004− if let Some(rest) = content.strip_prefix("@import ") {
1005934 let rest = rest.trim();
1006− // Support @import "file.hl" as prefix — namespace imported definitions
1007935 let (filename, alias) = if let Some((file_part, alias_part)) = rest.rsplit_once(" as ")
1008936 {
1009937 (
@@ -1077,13 +1005,14 @@ impl Parser {
10771005 });
10781006 return Ok(None);
10791007 }
1008+ let mut matched_nodes = Vec::new();
10801009 for file_path in matched_files {
10811010 let rel_name = file_path
10821011 .strip_prefix(&base_dir)
10831012 .unwrap_or(&file_path)
10841013 .to_string_lossy()
10851014 .to_string();
1086− // Synthesize an @import line for each matched file
1015+ // Synthesize an @include line for each matched file
10871016 let import_line = match &alias {
10881017 Some(pfx) => {
10891018 let stem = file_path
@@ -1091,18 +1020,18 @@ impl Parser {
10911020 .unwrap_or_default()
10921021 .to_string_lossy()
10931022 .to_string();
1094− format!("@import \"{}\" as {}.{}", rel_name, pfx, stem)
1023+ format!("@include \"{}\" as {}.{}", rel_name, pfx, stem)
10951024 }
1096− None => format!("@import \"{}\"", rel_name),
1025+ None => format!("@include \"{}\"", rel_name),
10971026 };
10981027 let synth_lines = preprocess(&import_line);
10991028 let mut synth_parser = Parser {
11001029 lines: synth_lines,
11011030 pos: 0,
11021031 };
1103− let _ = synth_parser.parse_children(0, ctx);
1032+ matched_nodes.extend(synth_parser.parse_children(0, ctx));
11041033 }
1105− return Ok(None);
1034+ return Ok(Some(matched_nodes));
11061035 }
11071036
11081037 let resolved = match &ctx.base_path {
@@ -1116,7 +1045,7 @@ impl Parser {
11161045 line: line_num,
11171046 column: None,
11181047 message: format!(
1119− "circular import '{}' (cycle: {} → {})",
1048+ "circular include '{}' (cycle: {} → {})",
11201049 filename, cycle_chain, filename
11211050 ),
11221051 severity: Severity::Error,
@@ -1137,7 +1066,7 @@ impl Parser {
11371066 ctx.diagnostics.push(Diagnostic {
11381067 line: line_num,
11391068 column: None,
1140− message: format!("cannot import '{}': {}", filename, e),
1069+ message: format!("cannot include '{}': {}", filename, e),
11411070 severity: Severity::Error,
11421071 source_line: Some(content.clone()),
11431072 });
@@ -1153,6 +1082,7 @@ impl Parser {
11531082 ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
11541083
11551084 let diag_count_before = ctx.diagnostics.len();
1085+ let mut included_nodes = Vec::new();
11561086
11571087 if let Some(ref prefix) = alias {
11581088 // Snapshot just the *key sets* before parsing — much cheaper
@@ -1209,13 +1139,12 @@ impl Parser {
12091139 }
12101140 }
12111141 } else {
1212− // Parse the file but discard DOM nodes — only keep definitions
1213− let imported_lines = preprocess(&imported_text);
1214− let mut imported_parser = Parser {
1215− lines: imported_lines,
1142+ let included_lines = preprocess(&imported_text);
1143+ let mut included_parser = Parser {
1144+ lines: included_lines,
12161145 pos: 0,
12171146 };
1218− let _discarded_nodes = imported_parser.parse_children(0, ctx);
1147+ included_nodes = included_parser.parse_children(0, ctx);
12191148 }
12201149
12211150 // Annotate new diagnostics with import chain
@@ -1227,7 +1156,7 @@ impl Parser {
12271156 ctx.base_path = saved_base;
12281157 ctx.include_stack.pop();
12291158 ctx.import_stack.pop();
1230− return Ok(None); // No nodes emitted
1159+ return Ok(Some(included_nodes));
12311160 }
12321161
12331162 // --- @env (compile-time environment variables) ---
@@ -2714,7 +2643,6 @@ const KNOWN_DIRECTIVES: &[&str] = &[
27142643 "page",
27152644 "let",
27162645 "include",
2717− "import",
27182646 "raw",
27192647 "keyframes",
27202648 "if",
@@ -2776,7 +2704,11 @@ const REMOVED_SYNTAX: &[(&str, &str)] = &[
27762704 "@switch",
27772705 "use `@match`, with `@let __switch [...]` inside a case for its attributes",
27782706 ),
2779− ("@use", "use `@import`"),
2707+ ("@use", "use `@include`"),
2708+ (
2709+ "@import",
2710+ "use `@include` (a file with only definitions emits no content)",
2711+ ),
27802712 ("@with", "use `@let alias $source`"),
27812713 (
27822714 "@layout",
src/upgrade.rs+6−2
@@ -447,6 +447,9 @@ fn rewrite_directive_line(
447447 {
448448 return format!("{pad}@let {} = {}", name, value.trim());
449449 }
450+ if let Some(rest) = trimmed.strip_prefix("@import ") {
451+ return format!("{pad}@include {}", rest);
452+ }
450453 if let Some(rest) = trimmed.strip_prefix("@unless ") {
451454 return format!("{pad}@if not {}", rest);
452455 }
@@ -466,7 +469,7 @@ fn rewrite_directive_line(
466469 manual.push((idx + 1, "could not read the file name in @use".to_string()));
467470 return line.to_string();
468471 }
469− return format!("{pad}@import {}", file);
472+ return format!("{pad}@include {}", file);
470473 }
471474 line.to_string()
472475}
@@ -869,7 +872,8 @@ mod tests {
869872 assert_eq!(up("@fn card $t\n @text $t"), "@let card $t\n @text $t");
870873 assert_eq!(up("@define c [bold]\n@mixin m [italic]"), "@let c [bold]\n@let m [italic]");
871874 assert_eq!(up("@for $i in 1..3\n $i"), "@each $i in 1..3\n $i");
872− assert_eq!(up("@use \"lib.hl\" a, b"), "@import lib.hl");
875+ assert_eq!(up("@use \"lib.hl\" a, b"), "@include lib.hl");
876+ assert_eq!(up("@import theme.hl\n@import \"ui.hl\" as ui"), "@include theme.hl\n@include \"ui.hl\" as ui");
873877 assert_eq!(
874878 up("@repeat 2\n @text $_count"),
875879 "@each $_ in 1..2\n @text 2"
tests/snapshots.rs+7−6
@@ -758,6 +758,7 @@ fn removed_directives_point_to_upgrade() {
758758 "@repeat 2\n hi",
759759 "@switch $v\n @case a\n hi",
760760 "@use \"a.hl\" x",
761+ "@import a.hl",
761762 "@with $a as b\n hi",
762763 "@layout base.hl\n hi",
763764 "@scope .c\n .t {}",
@@ -3153,13 +3154,13 @@ fn test_carousel_children_snap() {
31533154fn test_import_missing_file() {
31543155 // We can't test @use with actual files in unit tests easily, but we can verify
31553156 // the parser recognizes the directive without errors when it can't find the file
3156− let result = htmlang::parser::parse("@import nonexistent.hl");
3157+ let result = htmlang::parser::parse("@include nonexistent.hl");
31573158 let has_use_error = result.diagnostics.iter().any(|d| {
3158− d.message.contains("cannot import") && d.severity == htmlang::parser::Severity::Error
3159+ d.message.contains("cannot include") && d.severity == htmlang::parser::Severity::Error
31593160 });
31603161 assert!(
31613162 has_use_error,
3162− "@import should report error for missing file, got: {:?}",
3163+ "@include should report error for missing file, got: {:?}",
31633164 result.diagnostics
31643165 );
31653166}
@@ -5155,7 +5156,7 @@ fn perf_large_document() {
51555156}
51565157
51575158// ---------------------------------------------------------------------------
5158−// Filesystem-based error tests for @include / @import / @data
5159+// Filesystem-based error tests for @include / @data
51595160// ---------------------------------------------------------------------------
51605161
51615162#[test]
@@ -5185,14 +5186,14 @@ fn error_circular_include_filesystem() {
51855186
51865187#[test]
51875188fn import_with_alias_prefixes_definitions() {
5188− // Verify that @import with alias registers imported fns under `alias.name`.
5189+ // Verify that @include with alias registers imported fns under `alias.name`.
51895190 let dir = std::env::temp_dir().join("htmlang_test_import_alias");
51905191 let _ = std::fs::remove_dir_all(&dir);
51915192 std::fs::create_dir_all(&dir).unwrap();
51925193 let lib_path = dir.join("lib.hl");
51935194 std::fs::write(&lib_path, "@let card\n @el\n @text card-body\n").unwrap();
51945195
5195− let input = "@import lib.hl as ui\n@ui.card\n";
5196+ let input = "@include lib.hl as ui\n@ui.card\n";
51965197 let result = htmlang::parser::parse_with_base(input, Some(&dir));
51975198 let html = htmlang::codegen::generate(&result.document);
51985199 let _ = std::fs::remove_dir_all(&dir);