diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/TypeScriptWriter.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/TypeScriptWriter.java index 5fbf2afdd09..4ff414ba8ae 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/TypeScriptWriter.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/TypeScriptWriter.java @@ -20,6 +20,7 @@ import java.util.function.UnaryOperator; import software.amazon.smithy.codegen.core.CodegenException; import software.amazon.smithy.codegen.core.Symbol; +import software.amazon.smithy.codegen.core.SymbolDependency; import software.amazon.smithy.codegen.core.SymbolReference; import software.amazon.smithy.codegen.core.SymbolWriter; import software.amazon.smithy.model.Model; @@ -29,6 +30,7 @@ import software.amazon.smithy.model.traits.DeprecatedTrait; import software.amazon.smithy.model.traits.DocumentationTrait; import software.amazon.smithy.model.traits.InternalTrait; +import software.amazon.smithy.typescript.codegen.validation.ImportFrom; import software.amazon.smithy.utils.SmithyUnstableApi; import software.amazon.smithy.utils.StringUtils; @@ -114,12 +116,30 @@ public TypeScriptWriter addIgnoredDefaultImport(String name, String from, String */ @Deprecated public TypeScriptWriter addImport(String name, String as, String from) { + ImportFrom importFrom = new ImportFrom(from); + + if (importFrom.isDeclarablePackageImport()) { + String packageName = importFrom.getPackageName(); + if (getDependencies() + .stream() + .map(SymbolDependency::getPackageName) + .noneMatch(packageName::equals)) { + throw new CodegenException( + """ + The import %s does not correspond to a registered dependency. + TypeScriptWriter::addDependency() is required before ::addImport(). + """.formatted(from) + ); + } + } + getImportContainer().addImport(name, as, from); return this; } /** * Imports a type using an alias from a module only if necessary. + * Adds the dependency. * * @param name Type to import. * @param as Alias to refer to the type as. @@ -127,6 +147,9 @@ public TypeScriptWriter addImport(String name, String as, String from) { * @return Returns the writer. */ public TypeScriptWriter addImport(String name, String as, PackageContainer from) { + if (from instanceof Dependency) { + addDependency((Dependency) from); + } return this.addImport(name, as, from.getPackageName()); } diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/validation/ImportFrom.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/validation/ImportFrom.java new file mode 100644 index 00000000000..d70fe9fec0f --- /dev/null +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/validation/ImportFrom.java @@ -0,0 +1,95 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.typescript.codegen.validation; + +import java.util.Set; +import software.amazon.smithy.utils.SetUtils; +import software.amazon.smithy.utils.SmithyInternalApi; + +/** + * Interprets the string portion of an import statement. + */ +@SmithyInternalApi +public class ImportFrom { + public static final Set NODE_NATIVE_DEPENDENCIES = SetUtils.of( + "buffer", + "child_process", + "crypto", + "dns", + "events", + "fs", + "http", + "http2", + "https", + "os", + "path", + "process", + "stream", + "tls", + "url", + "util", + "zlib" + ); + + private final String from; + + public ImportFrom(String importTargetExpression) { + this.from = importTargetExpression; + } + + /** + * @return whether we recognize it as a Node.js native module. These + * do not need to be declared in package.json. This check + * is not exhaustive since the list of native modules varies + * by version. + */ + public boolean isNodejsNative() { + String[] packageNameSegments = from.split("/"); + return from.startsWith("node:") + || NODE_NATIVE_DEPENDENCIES.contains(packageNameSegments[0]); + } + + /** + * @return whether the import has an org or namespace prefix like \@smithy/pkg. + */ + public boolean isNamespaced() { + return from.startsWith("@") && from.contains("/"); + } + + /** + * @return whether the import starts with / or . indicating a relative import. + * These would not be added to package.json dependencies. + */ + public boolean isRelative() { + return from.startsWith("/") || from.startsWith("."); + } + + /** + * @return whether the import should correspond to an entry in + * package.json. + */ + public boolean isDeclarablePackageImport() { + return !isNodejsNative() && !isRelative(); + } + + /** + * @return the package name. This excludes sub-paths of packages. + * + * For example in \@smithy/pkg/module the package name is \@smithy/pkg. + */ + public String getPackageName() { + String[] packageNameSegments = from.split("/"); + String packageName; + if (isNodejsNative()) { + packageName = packageNameSegments[0].substring("node:".length()); + } else if (isNamespaced()) { + packageName = packageNameSegments[0] + "/" + packageNameSegments[1]; + } else { + packageName = packageNameSegments[0]; + } + return packageName; + } +} diff --git a/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/TypeScriptWriterTest.java b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/TypeScriptWriterTest.java index fb4153fb246..1facfde1c57 100644 --- a/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/TypeScriptWriterTest.java +++ b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/TypeScriptWriterTest.java @@ -21,15 +21,17 @@ public void writesDocStrings() { public void doesNotAddNewlineBetweenManagedAndExplicitImports() { TypeScriptWriter writer = new TypeScriptWriter("foo"); writer.write("import { Foo } from \"baz\";"); - writer.addImport("Baz", "Baz", "hello"); + writer.addImport("Baz", "Baz", "./hello"); writer.addImport("Bar", "__Bar", TypeScriptDependency.SMITHY_TYPES); writer.addRelativeImport("Qux", "__Qux", Paths.get("./qux")); String result = writer.toString(); - assertThat(result, equalTo(CODEGEN_INDICATOR + "import { Qux as __Qux } from \"./qux\";\n" - + "import { Bar as __Bar } from \"@smithy/types\";\n" - + "import { Baz } from \"hello\";\n" - + "import { Foo } from \"baz\";\n")); + assertThat(result, equalTo(""" + %simport { Baz } from "./hello"; + import { Qux as __Qux } from "./qux"; + import { Bar as __Bar } from "@smithy/types"; + import { Foo } from "baz"; + """.formatted(CODEGEN_INDICATOR))); } @Test diff --git a/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/validation/ImportFromTest.java b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/validation/ImportFromTest.java new file mode 100644 index 00000000000..087b1a8ff8f --- /dev/null +++ b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/validation/ImportFromTest.java @@ -0,0 +1,101 @@ +package software.amazon.smithy.typescript.codegen.validation; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ImportFromTest { + + @Test + void isNodejsNative() { + assertTrue( + new ImportFrom("node:buffer").isNodejsNative() + ); + assertTrue( + new ImportFrom("stream").isNodejsNative() + ); + assertFalse( + new ImportFrom("@smithy/util").isNodejsNative() + ); + assertFalse( + new ImportFrom("../file").isNodejsNative() + ); + } + + @Test + void isNamespaced() { + assertTrue( + new ImportFrom("@smithy/util/submodule").isNamespaced() + ); + assertTrue( + new ImportFrom("@smithy/util").isNamespaced() + ); + assertFalse( + new ImportFrom("node:stream").isNamespaced() + ); + assertFalse( + new ImportFrom("fs/promises").isNamespaced() + ); + } + + @Test + void isRelative() { + assertTrue( + new ImportFrom("/file/path").isRelative() + ); + assertTrue( + new ImportFrom("./file/path").isRelative() + ); + assertTrue( + new ImportFrom("../../../../file/path").isRelative() + ); + assertFalse( + new ImportFrom("@smithy/util").isRelative() + ); + assertFalse( + new ImportFrom("fs/promises").isRelative() + ); + } + + @Test + void isDeclarablePackageImport() { + assertTrue( + new ImportFrom("@smithy/util/submodule").isDeclarablePackageImport() + ); + assertTrue( + new ImportFrom("@smithy/util").isDeclarablePackageImport() + ); + assertTrue( + new ImportFrom("smithy_pkg").isDeclarablePackageImport() + ); + assertTrue( + new ImportFrom("smithy_pkg/array").isDeclarablePackageImport() + ); + assertFalse( + new ImportFrom("node:buffer").isDeclarablePackageImport() + ); + assertFalse( + new ImportFrom("../pkg/pkg").isDeclarablePackageImport() + ); + } + + @Test + void getPackageName() { + assertEquals( + new ImportFrom("smithy_pkg/array").getPackageName(), + "smithy_pkg" + ); + assertEquals( + new ImportFrom("@smithy/util/submodule").getPackageName(), + "@smithy/util" + ); + assertEquals( + new ImportFrom("node:fs/promises").getPackageName(), + "fs" + ); + assertEquals( + new ImportFrom("smithy_pkg").getPackageName(), + "smithy_pkg" + ); + } +} \ No newline at end of file