on master help sign in
@include does one thing: drop 'as' namespacing

An aliased include took only a file's definitions and renamed them ui.name. Now an include always brings in the file's content and definitions. htmlang upgrade removes the alias and its prefix.

commit030614eb41169b2f6be35c3b37276cd726e21416 authoriko <mail@iko.soy> dateSat 26 Sep 2026, 03:02 (2 weeks ago) parent01778ce
4 files changed+53−122
DESIGN.md+1−1 crates/htmlang-core/src/parser.rs+21−90 src/upgrade.rs+31−2 tests/snapshots.rs−29
DESIGN.md+1−1
@@ -388,7 +388,6 @@ A range counts down when its start is greater than its end.
388388| Directive | Effect |
389389|---|---|
390390| `@include file.hl` | Insert another file: its content and definitions (a library of `@let`s emits nothing) |
391−| `@include lib.hl as ui` | Take only its definitions, named `ui.name` |
392391| `@data $name file.json` | Load JSON values as variables (`$name.key`) |
393392| `@data $name dir/*.json` | Load each file as `$name.STEM.key`; `$name` lists the stems |
394393| `@data $name env:NAME [default]` | Read an environment variable |
@@ -486,6 +485,7 @@ removed form with its replacement.
486485| `$a ~ " " ~ $b` | `"$a $b"` |
487486| `@each $x in LIST [page N]` | Split the list, or filter it with `@if` |
488487| `...$bundle` | `$bundle` |
488+| `@include lib.hl as ui` | `@include lib.hl`, without the `ui.` prefix |
489489| `@data file.json` (keys as `$key`) | `@data $name file.json` (keys as `$name.key`) |
490490| `[attrs]` alone on a line | `@el [attrs]` |
491491| `animate`, `inset-area` | `animation`, `position-area` |
crates/htmlang-core/src/parser.rs+21−90
@@ -95,8 +95,6 @@ struct ParseContext {
9595 canonical: Option<String>,
9696 /// Base URL for relative links
9797 base_url: Option<String>,
98− /// Track @import paths for circular dependency detection
99− import_stack: Vec<PathBuf>,
10098}
10199
102100/// Evaluates a syntax tree (see `syntax.rs`) into the document's nodes.
@@ -177,7 +175,6 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
177175 define_lines: HashMap::new(),
178176 canonical: None,
179177 base_url: None,
180− import_stack: Vec::new(),
181178 };
182179 load_prelude(&mut ctx);
183180 let nodes = Evaluator.eval_block(&tree, &mut ctx);
@@ -499,23 +496,22 @@ impl Evaluator {
499496
500497 if let Some(rest) = content.strip_prefix("@include ") {
501498 let rest = rest.trim();
502− let (filename, alias) = if let Some((file_part, alias_part)) = rest.rsplit_once(" as ")
503− {
504− (
505− file_part.trim().to_string(),
506− Some(alias_part.trim().to_string()),
507− )
508− } else {
509− (rest.to_string(), None)
510− };
511− // Strip quotes from filename
512− let filename =
513− if filename.starts_with('"') && filename.ends_with('"') && filename.len() >= 2 {
514− filename[1..filename.len() - 1].to_string()
515− } else {
516− filename
517− };
518− let filename = substitute_vars(&filename, &ctx.variables);
499+ if let Some((file, alias)) = rest.rsplit_once(" as ") {
500+ return Err(ParseError {
501+ line: line_num,
502+ message: format!(
503+ "`@include ... as` was removed: write `@include {}` and drop the `{}.` \
504+ prefix (run `htmlang upgrade`)",
505+ file.trim(),
506+ alias.trim()
507+ ),
508+ });
509+ }
510+ let filename = rest
511+ .strip_prefix('"')
512+ .and_then(|f| f.strip_suffix('"'))
513+ .unwrap_or(rest);
514+ let filename = substitute_vars(filename, &ctx.variables);
519515
520516 // Glob support: if filename contains *, expand to multiple imports
521517 if filename.contains('*') {
@@ -579,18 +575,7 @@ impl Evaluator {
579575 .unwrap_or(&file_path)
580576 .to_string_lossy()
581577 .to_string();
582− // Synthesize an @include line for each matched file
583− let import_line = match &alias {
584− Some(pfx) => {
585− let stem = file_path
586− .file_stem()
587− .unwrap_or_default()
588− .to_string_lossy()
589− .to_string();
590− format!("@include \"{}\" as {}.{}", rel_name, pfx, stem)
591− }
592− None => format!("@include \"{}\"", rel_name),
593− };
578+ let import_line = format!("@include \"{}\"", rel_name);
594579 matched_nodes.extend(self.eval_block(&syntax::parse(&import_line), ctx));
595580 }
596581 return Ok(Some(matched_nodes));
@@ -601,8 +586,8 @@ impl Evaluator {
601586 None => PathBuf::from(&filename),
602587 };
603588
604− if ctx.include_stack.contains(&resolved) || ctx.import_stack.contains(&resolved) {
605− let cycle_chain = format_include_chain(&ctx.import_stack);
589+ if ctx.include_stack.contains(&resolved) {
590+ let cycle_chain = format_include_chain(&ctx.include_stack);
606591 ctx.diagnostics.push(Diagnostic {
607592 line: line_num,
608593 column: None,
@@ -639,65 +624,12 @@ impl Evaluator {
639624
640625 ctx.included_files.push(resolved.clone());
641626 ctx.include_stack.push(resolved.clone());
642− ctx.import_stack.push(resolved.clone());
643627 let saved_base = ctx.base_path.clone();
644628 ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
645629
646630 let diag_count_before = ctx.diagnostics.len();
647− let mut included_nodes = Vec::new();
648−
649− if let Some(ref prefix) = alias {
650− // Snapshot just the *key sets* before parsing — much cheaper
651− // than cloning the full HashMaps, since we only need to know
652− // which definitions are new afterwards.
653− let fn_keys_before: std::collections::HashSet<String> =
654− ctx.functions.keys().cloned().collect();
655− let define_keys_before: std::collections::HashSet<String> =
656− ctx.defines.keys().cloned().collect();
657− let var_keys_before: std::collections::HashSet<String> =
658− ctx.variables.keys().cloned().collect();
659−
660− let _discarded_nodes = self.eval_block(&syntax::parse(&imported_text), ctx);
661−
662− // Remove newly added entries and re-insert them under the prefix.
663− let new_fn_keys: Vec<String> = ctx
664− .functions
665− .keys()
666− .filter(|k| !fn_keys_before.contains(*k))
667− .cloned()
668− .collect();
669− for name in new_fn_keys {
670− if let Some(def) = ctx.functions.remove(&name) {
671− ctx.functions.insert(format!("{}.{}", prefix, name), def);
672− }
673− }
674− let new_define_keys: Vec<String> = ctx
675− .defines
676− .keys()
677− .filter(|k| !define_keys_before.contains(*k))
678− .cloned()
679− .collect();
680− for name in new_define_keys {
681− if let Some(attrs) = ctx.defines.remove(&name) {
682− ctx.defines.insert(format!("{}.{}", prefix, name), attrs);
683− }
684− }
685− let new_var_keys: Vec<String> = ctx
686− .variables
687− .keys()
688− .filter(|k| !var_keys_before.contains(*k))
689− .cloned()
690− .collect();
691− for name in new_var_keys {
692− if let Some(val) = ctx.variables.remove(&name)
693− && !name.starts_with("__")
694− {
695− ctx.variables.insert(format!("{}.{}", prefix, name), val);
696− }
697− }
698− } else {
699− included_nodes = self.eval_block(&syntax::parse(&imported_text), ctx);
700− }
631+
632+ let included_nodes = self.eval_block(&syntax::parse(&imported_text), ctx);
701633
702634 // Annotate new diagnostics with import chain
703635 let import_chain = format_include_chain(&ctx.include_stack);
@@ -707,7 +639,6 @@ impl Evaluator {
707639
708640 ctx.base_path = saved_base;
709641 ctx.include_stack.pop();
710− ctx.import_stack.pop();
711642 return Ok(Some(included_nodes));
712643 }
713644
src/upgrade.rs+31−2
@@ -238,13 +238,42 @@ pub fn upgrade(input: &str) -> Upgrade {
238238 if input.ends_with('\n') {
239239 output.push('\n');
240240 }
241+ let (output, unaliased) = drop_include_aliases(&output);
241242 Upgrade {
242243 output,
243− changes,
244+ changes: changes + unaliased,
244245 manual,
245246 }
246247}
247248
249+/// `@include lib.hl as ui` → `@include lib.hl`, and `@ui.card` / `$ui.x`
250+/// lose their `ui.` prefix.
251+fn drop_include_aliases(text: &str) -> (String, usize) {
252+ let mut aliases = Vec::new();
253+ let mut lines: Vec<String> = Vec::new();
254+ for line in text.split('\n') {
255+ let trimmed = line.trim_start();
256+ if let Some(rest) = trimmed.strip_prefix("@include ")
257+ && let Some((file, alias)) = rest.rsplit_once(" as ")
258+ {
259+ aliases.push(alias.trim().to_string());
260+ lines.push(format!("{}@include {}", &line[..line.len() - trimmed.len()], file.trim()));
261+ } else {
262+ lines.push(line.to_string());
263+ }
264+ }
265+ if aliases.is_empty() {
266+ return (text.to_string(), 0);
267+ }
268+ let mut out = lines.join("\n");
269+ for alias in &aliases {
270+ out = out
271+ .replace(&format!("@{}.", alias), "@")
272+ .replace(&format!("${}.", alias), "$");
273+ }
274+ (out, aliases.len())
275+}
276+
248277struct Block {
249278 lines: Vec<String>,
250279 end: usize,
@@ -1403,7 +1432,7 @@ mod tests {
14031432 assert_eq!(up("@define c [bold]\n@mixin m [italic]"), "@let c [bold]\n@let m [italic]");
14041433 assert_eq!(up("@for $i in 1..3\n $i"), "@each $i in 1..3\n $i");
14051434 assert_eq!(up("@use \"lib.hl\" a, b"), "@include lib.hl");
1406− assert_eq!(up("@import theme.hl\n@import \"ui.hl\" as ui"), "@include theme.hl\n@include \"ui.hl\" as ui");
1435+ assert_eq!(up("@import theme.hl\n@import \"ui.hl\" as ui\n@ui.card [x $ui.gap]"), "@include theme.hl\n@include \"ui.hl\"\n@card [x $gap]");
14071436 assert_eq!(
14081437 up("@repeat 2\n @text $_count"),
14091438 "@each $_ in 1..2\n @text 2"
tests/snapshots.rs−29
@@ -4849,35 +4849,6 @@ fn error_circular_include_filesystem() {
48494849 );
48504850}
48514851
4852−#[test]
4853−fn import_with_alias_prefixes_definitions() {
4854− // Verify that @include with alias registers imported fns under `alias.name`.
4855− let dir = std::env::temp_dir().join("htmlang_test_import_alias");
4856− let _ = std::fs::remove_dir_all(&dir);
4857− std::fs::create_dir_all(&dir).unwrap();
4858− let lib_path = dir.join("lib.hl");
4859− std::fs::write(&lib_path, "@let card\n @el\n @text card-body\n").unwrap();
4860−
4861− let input = "@include lib.hl as ui\n@ui.card\n";
4862− let result = htmlang::parser::parse_with_base(input, Some(&dir));
4863− let html = htmlang::codegen::generate(&result.document);
4864− let _ = std::fs::remove_dir_all(&dir);
4865−
4866− assert!(
4867− result
4868− .diagnostics
4869− .iter()
4870− .all(|d| d.severity != htmlang::parser::Severity::Error),
4871− "no errors expected, got: {:?}",
4872− result.diagnostics
4873− );
4874− assert!(
4875− html.contains("card-body"),
4876− "alias prefixed call should expand, got: {}",
4877− html
4878− );
4879−}
4880−
48814852#[test]
48824853fn error_invalid_json_in_data_directive() {
48834854 let dir = std::env::temp_dir().join("htmlang_test_bad_json");