diff --git a/crates/typst-syntax/src/ast.rs b/crates/typst-syntax/src/ast.rs index 01e0944dd..1cd9cd429 100644 --- a/crates/typst-syntax/src/ast.rs +++ b/crates/typst-syntax/src/ast.rs @@ -2040,29 +2040,54 @@ impl<'a> ImportItems<'a> { pub fn iter(self) -> impl DoubleEndedIterator> { self.0.children().filter_map(|child| match child.kind() { SyntaxKind::RenamedImportItem => child.cast().map(ImportItem::Renamed), - SyntaxKind::Ident => child.cast().map(ImportItem::Simple), + SyntaxKind::ImportItemPath => child.cast().map(ImportItem::Simple), _ => Option::None, }) } } +node! { + /// A path to a submodule's imported name: `a.b.c`. + ImportItemPath +} + +impl<'a> ImportItemPath<'a> { + /// An iterator over the path's components. + pub fn iter(self) -> impl DoubleEndedIterator> { + self.0.children().filter_map(SyntaxNode::cast) + } + + /// The name of the imported item. This is the last segment in the path. + pub fn name(self) -> Ident<'a> { + self.iter().last().unwrap_or_default() + } +} + /// An imported item, potentially renamed to another identifier. #[derive(Debug, Copy, Clone, Hash)] pub enum ImportItem<'a> { /// A non-renamed import (the item's name in the scope is the same as its /// name). - Simple(Ident<'a>), + Simple(ImportItemPath<'a>), /// A renamed import (the item was bound to a different name in the scope /// than the one it was defined as). Renamed(RenamedImportItem<'a>), } impl<'a> ImportItem<'a> { + /// The path to the imported item. + pub fn path(self) -> ImportItemPath<'a> { + match self { + Self::Simple(path) => path, + Self::Renamed(renamed_item) => renamed_item.path(), + } + } + /// The original name of the imported item, at its source. This will be the /// equal to the bound name if the item wasn't renamed with 'as'. pub fn original_name(self) -> Ident<'a> { match self { - Self::Simple(name) => name, + Self::Simple(path) => path.name(), Self::Renamed(renamed_item) => renamed_item.original_name(), } } @@ -2071,7 +2096,7 @@ impl<'a> ImportItem<'a> { /// name, if it was renamed; otherwise, it's just its original name. pub fn bound_name(self) -> Ident<'a> { match self { - Self::Simple(name) => name, + Self::Simple(path) => path.name(), Self::Renamed(renamed_item) => renamed_item.new_name(), } } @@ -2083,17 +2108,22 @@ node! { } impl<'a> RenamedImportItem<'a> { - /// The original name of the imported item (`a` in `a as d`). - pub fn original_name(self) -> Ident<'a> { + /// The path to the imported item. + pub fn path(self) -> ImportItemPath<'a> { self.0.cast_first_match().unwrap_or_default() } + /// The original name of the imported item (`a` in `a as d` or `c.b.a as d`). + pub fn original_name(self) -> Ident<'a> { + self.path().name() + } + /// The new name of the imported item (`d` in `a as d`). pub fn new_name(self) -> Ident<'a> { self.0 .children() .filter_map(SyntaxNode::cast) - .nth(1) + .last() .unwrap_or_default() } } diff --git a/crates/typst-syntax/src/highlight.rs b/crates/typst-syntax/src/highlight.rs index b6aa9e8da..0c1f3d5fd 100644 --- a/crates/typst-syntax/src/highlight.rs +++ b/crates/typst-syntax/src/highlight.rs @@ -277,6 +277,7 @@ pub fn highlight(node: &LinkedNode) -> Option { SyntaxKind::ForLoop => None, SyntaxKind::ModuleImport => None, SyntaxKind::ImportItems => None, + SyntaxKind::ImportItemPath => None, SyntaxKind::RenamedImportItem => None, SyntaxKind::ModuleInclude => None, SyntaxKind::LoopBreak => None, diff --git a/crates/typst-syntax/src/kind.rs b/crates/typst-syntax/src/kind.rs index 4c3d178f2..7505dbc61 100644 --- a/crates/typst-syntax/src/kind.rs +++ b/crates/typst-syntax/src/kind.rs @@ -262,6 +262,8 @@ pub enum SyntaxKind { ModuleImport, /// Items to import from a module: `a, b, c`. ImportItems, + /// A path to an imported name from a submodule: `a.b.c`. + ImportItemPath, /// A renamed import item: `a as d`. RenamedImportItem, /// A module include: `include "chapter1.typ"`. @@ -488,6 +490,7 @@ impl SyntaxKind { Self::ForLoop => "for-loop expression", Self::ModuleImport => "`import` expression", Self::ImportItems => "import items", + Self::ImportItemPath => "imported item path", Self::RenamedImportItem => "renamed import item", Self::ModuleInclude => "`include` expression", Self::LoopBreak => "`break` expression", diff --git a/crates/typst-syntax/src/parser.rs b/crates/typst-syntax/src/parser.rs index d8ac11987..d341bca28 100644 --- a/crates/typst-syntax/src/parser.rs +++ b/crates/typst-syntax/src/parser.rs @@ -1003,6 +1003,13 @@ fn import_items(p: &mut Parser) { p.unexpected(); } + // Nested import path: `a.b.c` + while p.eat_if(SyntaxKind::Dot) { + p.expect(SyntaxKind::Ident); + } + + p.wrap(item_marker, SyntaxKind::ImportItemPath); + // Rename imported item. if p.eat_if(SyntaxKind::As) { p.expect(SyntaxKind::Ident); diff --git a/crates/typst/src/eval/import.rs b/crates/typst/src/eval/import.rs index 588578891..fbd55b7c5 100644 --- a/crates/typst/src/eval/import.rs +++ b/crates/typst/src/eval/import.rs @@ -63,23 +63,62 @@ impl Eval for ast::ModuleImport<'_> { Some(ast::Imports::Items(items)) => { let mut errors = eco_vec![]; for item in items.iter() { - let original_ident = item.original_name(); - if let Some(value) = scope.get(&original_ident) { - // Warn on `import ...: x as x` - if let ast::ImportItem::Renamed(renamed_item) = &item { - if renamed_item.original_name().as_str() - == renamed_item.new_name().as_str() - { - vm.engine.tracer.warn(warning!( - renamed_item.new_name().span(), - "unnecessary import rename to same name", - )); - } - } + let mut path = item.path().iter().peekable(); + let mut scope = scope; - vm.define(item.bound_name(), value.clone()); - } else { - errors.push(error!(original_ident.span(), "unresolved import")); + while let Some(component) = &path.next() { + let Some(value) = scope.get(component) else { + errors.push(error!(component.span(), "unresolved import")); + break; + }; + + if path.peek().is_some() { + // Nested import, as this is not the last component. + // This must be a submodule. + let Some(submodule) = value.scope() else { + let error = if matches!(value, Value::Func(function) if function.scope().is_none()) + { + error!( + component.span(), + "cannot import from user-defined functions" + ) + } else if !matches!( + value, + Value::Func(_) | Value::Module(_) | Value::Type(_) + ) { + error!( + component.span(), + "expected module, function, or type, found {}", + value.ty() + ) + } else { + panic!("unexpected nested import failure") + }; + errors.push(error); + break; + }; + + // Walk into the submodule. + scope = submodule; + } else { + // Now that we have the scope of the innermost submodule + // in the import path, we may extract the desired item from + // it. + + // Warn on `import ...: x as x` + if let ast::ImportItem::Renamed(renamed_item) = &item { + if renamed_item.original_name().as_str() + == renamed_item.new_name().as_str() + { + vm.engine.tracer.warn(warning!( + renamed_item.new_name().span(), + "unnecessary import rename to same name", + )); + } + } + + vm.define(item.bound_name(), value.clone()); + } } } if !errors.is_empty() { diff --git a/tests/suite/scripting/import.typ b/tests/suite/scripting/import.typ index 820f81d6c..8bfa8ca68 100644 --- a/tests/suite/scripting/import.typ +++ b/tests/suite/scripting/import.typ @@ -45,6 +45,16 @@ #test(val, 1) #test(other(1, 2), 3) +--- import-nested-item --- +// Nested item imports. +#import "modules/chap1.typ" as orig-chap1 +#import "modules/chap2.typ" as orig-chap2 +#import "module.typ": chap2, chap2.name, chap2.chap1, chap2.chap1.name as othername +#test(chap2, orig-chap2) +#test(chap1, orig-chap1) +#test(name, "Klaus") +#test(othername, "Klaus") + --- import-from-function-scope --- // Test importing from function scopes. @@ -63,6 +73,28 @@ #import assert: eq as aseq #aseq(10, 10) +--- import-from-function-scope-nested-import --- +// Test importing items from function scopes via nested import. +#import std: grid.cell, table.cell as tcell +#test(cell, grid.cell) +#test(tcell, table.cell) + +--- import-from-type-scope --- +// Test importing from a type's scope. +#import array: zip +#test(zip((1, 2), (3, 4)), ((1, 3), (2, 4))) + +--- import-from-type-scope-item-renamed --- +// Test importing from a type's scope with renaming. +#import array: pop as renamed-pop +#test(renamed-pop((1, 2)), 2) + +--- import-from-type-scope-nested-import --- +// Test importing from a type's scope with nested import. +#import std: array.zip, array.pop as renamed-pop +#test(zip((1, 2), (3, 4)), ((1, 3), (2, 4))) +#test(renamed-pop((1, 2)), 2) + --- import-from-file-bare --- // A module import without items. #import "module.typ" @@ -225,6 +257,10 @@ This is never reached. // Error: 7-12 unknown variable: chap1 #test(chap1.b, "Klaus") +--- import-nested-invalid-type --- +// Error: 19-21 expected module, function, or type, found float +#import std: calc.pi.something + --- import-incomplete --- // Error: 8 expected expression #import @@ -262,6 +298,15 @@ This is never reached. // Error: 16-17 unexpected integer #import "": a: 1 +--- import-incomplete-nested --- +// Error: 15 expected identifier +#import "": a. + +--- import-wildcard-in-nested --- +// Error: 15 expected identifier +// Error: 15-16 unexpected star +#import "": a.* + --- import-missing-comma --- // Error: 14 expected comma #import "": a b diff --git a/tests/suite/scripting/module.typ b/tests/suite/scripting/module.typ index 8a67d2255..8e388bfbf 100644 --- a/tests/suite/scripting/module.typ +++ b/tests/suite/scripting/module.typ @@ -1,5 +1,6 @@ // SKIP // A file to import in import / include tests. +#import "modules/chap2.typ" #let a #let b = 1 diff --git a/tests/suite/scripting/modules/chap2.typ b/tests/suite/scripting/modules/chap2.typ index 9c9d12d7f..f1a886d66 100644 --- a/tests/suite/scripting/modules/chap2.typ +++ b/tests/suite/scripting/modules/chap2.typ @@ -1,4 +1,5 @@ // SKIP +#import "chap1.typ" #let name = "Klaus" == Chapter 2