Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions bindings/bindings.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ func (b *Bindings) ToTypescriptDeclarationNode(ety DeclarationType) (*goja.Objec
siObj, err = b.VariableStatement(ety)
case *Enum:
siObj, err = b.EnumDeclaration(ety)
case *ImportDeclaration:
siObj, err = b.ImportDeclaration(ety)
default:
return nil, xerrors.Errorf("unsupported type for declaration type: %T", ety)
}
Expand Down Expand Up @@ -798,3 +800,67 @@ func convertDeprecation(txt string) string {

return txt
}

// ImportSpecifier builds the goja ImportSpecifier for a single named import.
//
// When Alias is empty, this emits `Name`. When Alias is set, this emits
// `Name as Alias` by passing Name as the TypeScript propertyName and Alias
// as the local binding name.
func (b *Bindings) ImportSpecifier(spec *ImportSpecifier) (*goja.Object, error) {
specF, err := b.f("importSpecifier")
if err != nil {
return nil, err
}

var propertyName goja.Value = goja.Undefined()
localName := spec.Name
if spec.Alias != "" {
propertyName = b.vm.ToValue(spec.Name)
localName = spec.Alias
}

res, err := specF(goja.Undefined(),
b.vm.ToValue(spec.IsTypeOnly),
propertyName,
b.vm.ToValue(localName),
)
if err != nil {
return nil, xerrors.Errorf("call importSpecifier: %w", err)
}
return res.ToObject(b.vm), nil
}

// ImportDeclaration builds the goja ImportDeclaration for a top-level
// `import { ... } from "module"` statement, or a side-effect import
// (`import "module"`) when decl.SideEffect is true.
func (b *Bindings) ImportDeclaration(decl *ImportDeclaration) (*goja.Object, error) {
declF, err := b.f("importDeclaration")
if err != nil {
return nil, err
}

var namedImports goja.Value
if decl.SideEffect {
namedImports = goja.Undefined()
} else {
specifiers := make([]interface{}, 0, len(decl.Named))
for _, n := range decl.Named {
obj, err := b.ImportSpecifier(n)
if err != nil {
return nil, fmt.Errorf("import specifier %q: %w", n.Name, err)
}
specifiers = append(specifiers, obj)
}
namedImports = b.vm.NewArray(specifiers...)
}

res, err := declF(goja.Undefined(),
b.vm.ToValue(decl.IsTypeOnly),
b.vm.ToValue(decl.Module),
namedImports,
)
if err != nil {
return nil, xerrors.Errorf("call importDeclaration: %w", err)
}
return res.ToObject(b.vm), nil
}
45 changes: 45 additions & 0 deletions bindings/declarations.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,48 @@ type Enum struct {

func (*Enum) isNode() {}
func (*Enum) isDeclarationType() {}

// ImportDeclaration is a top-level ECMAScript import statement.
//
// import { z } from "zod"
// import { Foo as Bar } from "./schemas"
// import type { Baz } from "./types"
// import "polyfill"
//
// Only the named-imports and side-effect forms are modeled. Default imports
// (`import foo from "x"`) and namespace imports (`import * as ns from "x"`)
// are not yet supported; add them if you need them.
type ImportDeclaration struct {
// Module is the module specifier (the right-hand side of `from`).
Module string
// Named is the list of imported names. Order is preserved while merging;
// the final emitted order is sorted alphabetically in Serialize.
// Ignored when SideEffect is true.
Named []*ImportSpecifier
// IsTypeOnly emits `import type { ... }` instead of `import { ... }`.
// Ignored when SideEffect is true (`import type "x"` is not valid TS).
IsTypeOnly bool
// SideEffect emits the bare form `import "module"` with no import clause.
// When true, Named and IsTypeOnly are ignored.
SideEffect bool
SupportComments
Source
}

func (*ImportDeclaration) isNode() {}
func (*ImportDeclaration) isDeclarationType() {}

// ImportSpecifier is a single named import entry inside the braces of an
// `import { ... }` clause.
//
// Name="foo", Alias="" -> foo
// Name="foo", Alias="bar" -> foo as bar
// IsTypeOnly=true -> type foo, type foo as bar
type ImportSpecifier struct {
Name string
Alias string
IsTypeOnly bool
}

func (*ImportSpecifier) isNode() {}

114 changes: 114 additions & 0 deletions bindings/imports_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package bindings_test

import (
"strings"
"testing"

"github.com/stretchr/testify/require"

"github.com/coder/guts/bindings"
)

// TestImportDeclaration exercises the goja factory wrappers for
// ImportSpecifier and ImportDeclaration end-to-end: build the bindings node,
// convert through ToTypescriptNode, then ask the embedded printer for its
// TypeScript representation.
func TestImportDeclaration(t *testing.T) {
t.Parallel()

cases := []struct {
name string
decl *bindings.ImportDeclaration
want string
notWant string
}{
{
name: "single",
decl: &bindings.ImportDeclaration{
Module: "zod",
Named: []*bindings.ImportSpecifier{
{Name: "z"},
},
},
want: `import { z } from "zod";`,
},
{
name: "multiple",
decl: &bindings.ImportDeclaration{
Module: "./schemas",
Named: []*bindings.ImportSpecifier{
{Name: "Foo"},
{Name: "Bar"},
},
},
want: `import { Foo, Bar } from "./schemas";`,
},
{
name: "aliased",
decl: &bindings.ImportDeclaration{
Module: "./schemas",
Named: []*bindings.ImportSpecifier{
{Name: "Foo", Alias: "Bar"},
},
},
want: `import { Foo as Bar } from "./schemas";`,
},
{
name: "type only",
decl: &bindings.ImportDeclaration{
Module: "./types",
IsTypeOnly: true,
Named: []*bindings.ImportSpecifier{
{Name: "Baz"},
},
},
want: `import type { Baz } from "./types";`,
},
{
name: "mixed",
decl: &bindings.ImportDeclaration{
Module: "./schemas",
Named: []*bindings.ImportSpecifier{
{Name: "Foo"},
{Name: "Bar", Alias: "Baz"},
},
},
want: `import { Foo, Bar as Baz } from "./schemas";`,
},
{
name: "side effect",
decl: &bindings.ImportDeclaration{
Module: "./polyfill",
SideEffect: true,
},
want: `import "./polyfill";`,
},
{
name: "side effect ignores type only",
decl: &bindings.ImportDeclaration{
Module: "./polyfill",
IsTypeOnly: true,
SideEffect: true,
},
want: `import "./polyfill";`,
},
}

for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

b, err := bindings.New()
require.NoError(t, err)

node, err := b.ToTypescriptNode(tc.decl)
require.NoError(t, err)

got, err := b.SerializeToTypescript(node)
require.NoError(t, err)

require.Equal(t, tc.want, strings.TrimSpace(got))
})
}
}
81 changes: 81 additions & 0 deletions config/imports.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package config

import (
"github.com/coder/guts"
"github.com/coder/guts/bindings"
)

// InjectImport returns a mutation that appends a value-import statement
// for the given module to the top of the generated output.
//
// ts.ApplyMutations(config.InjectImport("zod", "z"))
// // import { z } from "zod"
//
// Repeat calls for the same module are merged by the underlying
// (*guts.Typescript).AppendImport: specifiers are unioned by (Name, Alias,
// IsTypeOnly) while preserving the order of first occurrence.
//
// Aliased names use the "Name=Alias" form, e.g.
//
// config.InjectImport("./schemas", "Foo=Bar")
// // import { Foo as Bar } from "./schemas"
func InjectImport(module string, names ...string) guts.MutationFunc {
return injectImport(module, false, names...)
}

// InjectTypeImport returns a mutation that appends a type-only import
// statement for the given module:
//
// ts.ApplyMutations(config.InjectTypeImport("./schemas", "Foo"))
// // import type { Foo } from "./schemas"
//
// Aliasing follows the same "Name=Alias" form as InjectImport.
func InjectTypeImport(module string, names ...string) guts.MutationFunc {
return injectImport(module, true, names...)
}

// InjectSideEffectImport returns a mutation that appends a bare side-effect
// import for the given module:
//
// ts.ApplyMutations(config.InjectSideEffectImport("./polyfill"))
// // import "./polyfill"
//
// Repeat calls for the same module are deduplicated.
func InjectSideEffectImport(module string) guts.MutationFunc {
decl := &bindings.ImportDeclaration{
Module: module,
SideEffect: true,
}
return func(ts *guts.Typescript) {
ts.AppendImport(decl)
}
}

func injectImport(module string, isTypeOnly bool, names ...string) guts.MutationFunc {
specs := make([]*bindings.ImportSpecifier, 0, len(names))
for _, n := range names {
name, alias := splitNameAlias(n)
specs = append(specs, &bindings.ImportSpecifier{
Name: name,
Alias: alias,
})
}
decl := &bindings.ImportDeclaration{
Module: module,
Named: specs,
IsTypeOnly: isTypeOnly,
}
return func(ts *guts.Typescript) {
ts.AppendImport(decl)
}
}

// splitNameAlias parses entries of the form "Name" or "Name=Alias".
func splitNameAlias(s string) (name, alias string) {
for i := 0; i < len(s); i++ {
if s[i] == '=' {
return s[:i], s[i+1:]
}
}
return s, ""
}
34 changes: 34 additions & 0 deletions config/imports_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package config

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestSplitNameAlias(t *testing.T) {
t.Parallel()

cases := []struct {
in string
wantName string
wantAlias string
}{
{in: "Foo", wantName: "Foo"},
{in: "Foo=Bar", wantName: "Foo", wantAlias: "Bar"},
{in: "=Bar", wantAlias: "Bar"},
{in: "Foo=", wantName: "Foo"},
{in: "", wantName: ""},
}

for _, tc := range cases {
tc := tc
t.Run(tc.in, func(t *testing.T) {
t.Parallel()

name, alias := splitNameAlias(tc.in)
require.Equal(t, tc.wantName, name)
require.Equal(t, tc.wantAlias, alias)
})
}
}
Loading
Loading