Skip to content

Commit 955fb63

Browse files
authored
feat: add ImportDeclaration bindings and InjectImport mutation (#83)
Adds top-level `import { ... } from "..."`, `import type { ... }`, and bare `import "..."` support so callers can prepend imports to the generated TypeScript output. ```go ts.ApplyMutations( config.InjectImport("zod", "z"), // import { z } from "zod" config.InjectTypeImport("./types", "Foo", "Bar=Renamed"), // import type { Bar as Renamed, Foo } from "./types" config.InjectSideEffectImport("./polyfill"), // import "./polyfill" ) ```
1 parent c0d0751 commit 955fb63

9 files changed

Lines changed: 657 additions & 1 deletion

File tree

bindings/bindings.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ func (b *Bindings) ToTypescriptDeclarationNode(ety DeclarationType) (*goja.Objec
8282
siObj, err = b.VariableStatement(ety)
8383
case *Enum:
8484
siObj, err = b.EnumDeclaration(ety)
85+
case *ImportDeclaration:
86+
siObj, err = b.ImportDeclaration(ety)
8587
default:
8688
return nil, xerrors.Errorf("unsupported type for declaration type: %T", ety)
8789
}
@@ -798,3 +800,67 @@ func convertDeprecation(txt string) string {
798800

799801
return txt
800802
}
803+
804+
// ImportSpecifier builds the goja ImportSpecifier for a single named import.
805+
//
806+
// When Alias is empty, this emits `Name`. When Alias is set, this emits
807+
// `Name as Alias` by passing Name as the TypeScript propertyName and Alias
808+
// as the local binding name.
809+
func (b *Bindings) ImportSpecifier(spec *ImportSpecifier) (*goja.Object, error) {
810+
specF, err := b.f("importSpecifier")
811+
if err != nil {
812+
return nil, err
813+
}
814+
815+
var propertyName goja.Value = goja.Undefined()
816+
localName := spec.Name
817+
if spec.Alias != "" {
818+
propertyName = b.vm.ToValue(spec.Name)
819+
localName = spec.Alias
820+
}
821+
822+
res, err := specF(goja.Undefined(),
823+
b.vm.ToValue(spec.IsTypeOnly),
824+
propertyName,
825+
b.vm.ToValue(localName),
826+
)
827+
if err != nil {
828+
return nil, xerrors.Errorf("call importSpecifier: %w", err)
829+
}
830+
return res.ToObject(b.vm), nil
831+
}
832+
833+
// ImportDeclaration builds the goja ImportDeclaration for a top-level
834+
// `import { ... } from "module"` statement, or a side-effect import
835+
// (`import "module"`) when decl.SideEffect is true.
836+
func (b *Bindings) ImportDeclaration(decl *ImportDeclaration) (*goja.Object, error) {
837+
declF, err := b.f("importDeclaration")
838+
if err != nil {
839+
return nil, err
840+
}
841+
842+
var namedImports goja.Value
843+
if decl.SideEffect {
844+
namedImports = goja.Undefined()
845+
} else {
846+
specifiers := make([]interface{}, 0, len(decl.Named))
847+
for _, n := range decl.Named {
848+
obj, err := b.ImportSpecifier(n)
849+
if err != nil {
850+
return nil, fmt.Errorf("import specifier %q: %w", n.Name, err)
851+
}
852+
specifiers = append(specifiers, obj)
853+
}
854+
namedImports = b.vm.NewArray(specifiers...)
855+
}
856+
857+
res, err := declF(goja.Undefined(),
858+
b.vm.ToValue(decl.IsTypeOnly),
859+
b.vm.ToValue(decl.Module),
860+
namedImports,
861+
)
862+
if err != nil {
863+
return nil, xerrors.Errorf("call importDeclaration: %w", err)
864+
}
865+
return res.ToObject(b.vm), nil
866+
}

bindings/declarations.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,48 @@ type Enum struct {
121121

122122
func (*Enum) isNode() {}
123123
func (*Enum) isDeclarationType() {}
124+
125+
// ImportDeclaration is a top-level ECMAScript import statement.
126+
//
127+
// import { z } from "zod"
128+
// import { Foo as Bar } from "./schemas"
129+
// import type { Baz } from "./types"
130+
// import "polyfill"
131+
//
132+
// Only the named-imports and side-effect forms are modeled. Default imports
133+
// (`import foo from "x"`) and namespace imports (`import * as ns from "x"`)
134+
// are not yet supported; add them if you need them.
135+
type ImportDeclaration struct {
136+
// Module is the module specifier (the right-hand side of `from`).
137+
Module string
138+
// Named is the list of imported names. Order is preserved while merging;
139+
// the final emitted order is sorted alphabetically in Serialize.
140+
// Ignored when SideEffect is true.
141+
Named []*ImportSpecifier
142+
// IsTypeOnly emits `import type { ... }` instead of `import { ... }`.
143+
// Ignored when SideEffect is true (`import type "x"` is not valid TS).
144+
IsTypeOnly bool
145+
// SideEffect emits the bare form `import "module"` with no import clause.
146+
// When true, Named and IsTypeOnly are ignored.
147+
SideEffect bool
148+
SupportComments
149+
Source
150+
}
151+
152+
func (*ImportDeclaration) isNode() {}
153+
func (*ImportDeclaration) isDeclarationType() {}
154+
155+
// ImportSpecifier is a single named import entry inside the braces of an
156+
// `import { ... }` clause.
157+
//
158+
// Name="foo", Alias="" -> foo
159+
// Name="foo", Alias="bar" -> foo as bar
160+
// IsTypeOnly=true -> type foo, type foo as bar
161+
type ImportSpecifier struct {
162+
Name string
163+
Alias string
164+
IsTypeOnly bool
165+
}
166+
167+
func (*ImportSpecifier) isNode() {}
168+

bindings/imports_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package bindings_test
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/coder/guts/bindings"
10+
)
11+
12+
// TestImportDeclaration exercises the goja factory wrappers for
13+
// ImportSpecifier and ImportDeclaration end-to-end: build the bindings node,
14+
// convert through ToTypescriptNode, then ask the embedded printer for its
15+
// TypeScript representation.
16+
func TestImportDeclaration(t *testing.T) {
17+
t.Parallel()
18+
19+
cases := []struct {
20+
name string
21+
decl *bindings.ImportDeclaration
22+
want string
23+
notWant string
24+
}{
25+
{
26+
name: "single",
27+
decl: &bindings.ImportDeclaration{
28+
Module: "zod",
29+
Named: []*bindings.ImportSpecifier{
30+
{Name: "z"},
31+
},
32+
},
33+
want: `import { z } from "zod";`,
34+
},
35+
{
36+
name: "multiple",
37+
decl: &bindings.ImportDeclaration{
38+
Module: "./schemas",
39+
Named: []*bindings.ImportSpecifier{
40+
{Name: "Foo"},
41+
{Name: "Bar"},
42+
},
43+
},
44+
want: `import { Foo, Bar } from "./schemas";`,
45+
},
46+
{
47+
name: "aliased",
48+
decl: &bindings.ImportDeclaration{
49+
Module: "./schemas",
50+
Named: []*bindings.ImportSpecifier{
51+
{Name: "Foo", Alias: "Bar"},
52+
},
53+
},
54+
want: `import { Foo as Bar } from "./schemas";`,
55+
},
56+
{
57+
name: "type only",
58+
decl: &bindings.ImportDeclaration{
59+
Module: "./types",
60+
IsTypeOnly: true,
61+
Named: []*bindings.ImportSpecifier{
62+
{Name: "Baz"},
63+
},
64+
},
65+
want: `import type { Baz } from "./types";`,
66+
},
67+
{
68+
name: "mixed",
69+
decl: &bindings.ImportDeclaration{
70+
Module: "./schemas",
71+
Named: []*bindings.ImportSpecifier{
72+
{Name: "Foo"},
73+
{Name: "Bar", Alias: "Baz"},
74+
},
75+
},
76+
want: `import { Foo, Bar as Baz } from "./schemas";`,
77+
},
78+
{
79+
name: "side effect",
80+
decl: &bindings.ImportDeclaration{
81+
Module: "./polyfill",
82+
SideEffect: true,
83+
},
84+
want: `import "./polyfill";`,
85+
},
86+
{
87+
name: "side effect ignores type only",
88+
decl: &bindings.ImportDeclaration{
89+
Module: "./polyfill",
90+
IsTypeOnly: true,
91+
SideEffect: true,
92+
},
93+
want: `import "./polyfill";`,
94+
},
95+
}
96+
97+
for _, tc := range cases {
98+
tc := tc
99+
t.Run(tc.name, func(t *testing.T) {
100+
t.Parallel()
101+
102+
b, err := bindings.New()
103+
require.NoError(t, err)
104+
105+
node, err := b.ToTypescriptNode(tc.decl)
106+
require.NoError(t, err)
107+
108+
got, err := b.SerializeToTypescript(node)
109+
require.NoError(t, err)
110+
111+
require.Equal(t, tc.want, strings.TrimSpace(got))
112+
})
113+
}
114+
}

config/imports.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package config
2+
3+
import (
4+
"github.com/coder/guts"
5+
"github.com/coder/guts/bindings"
6+
)
7+
8+
// InjectImport returns a mutation that appends a value-import statement
9+
// for the given module to the top of the generated output.
10+
//
11+
// ts.ApplyMutations(config.InjectImport("zod", "z"))
12+
// // import { z } from "zod"
13+
//
14+
// Repeat calls for the same module are merged by the underlying
15+
// (*guts.Typescript).AppendImport: specifiers are unioned by (Name, Alias,
16+
// IsTypeOnly) while preserving the order of first occurrence.
17+
//
18+
// Aliased names use the "Name=Alias" form, e.g.
19+
//
20+
// config.InjectImport("./schemas", "Foo=Bar")
21+
// // import { Foo as Bar } from "./schemas"
22+
func InjectImport(module string, names ...string) guts.MutationFunc {
23+
return injectImport(module, false, names...)
24+
}
25+
26+
// InjectTypeImport returns a mutation that appends a type-only import
27+
// statement for the given module:
28+
//
29+
// ts.ApplyMutations(config.InjectTypeImport("./schemas", "Foo"))
30+
// // import type { Foo } from "./schemas"
31+
//
32+
// Aliasing follows the same "Name=Alias" form as InjectImport.
33+
func InjectTypeImport(module string, names ...string) guts.MutationFunc {
34+
return injectImport(module, true, names...)
35+
}
36+
37+
// InjectSideEffectImport returns a mutation that appends a bare side-effect
38+
// import for the given module:
39+
//
40+
// ts.ApplyMutations(config.InjectSideEffectImport("./polyfill"))
41+
// // import "./polyfill"
42+
//
43+
// Repeat calls for the same module are deduplicated.
44+
func InjectSideEffectImport(module string) guts.MutationFunc {
45+
decl := &bindings.ImportDeclaration{
46+
Module: module,
47+
SideEffect: true,
48+
}
49+
return func(ts *guts.Typescript) {
50+
ts.AppendImport(decl)
51+
}
52+
}
53+
54+
func injectImport(module string, isTypeOnly bool, names ...string) guts.MutationFunc {
55+
specs := make([]*bindings.ImportSpecifier, 0, len(names))
56+
for _, n := range names {
57+
name, alias := splitNameAlias(n)
58+
specs = append(specs, &bindings.ImportSpecifier{
59+
Name: name,
60+
Alias: alias,
61+
})
62+
}
63+
decl := &bindings.ImportDeclaration{
64+
Module: module,
65+
Named: specs,
66+
IsTypeOnly: isTypeOnly,
67+
}
68+
return func(ts *guts.Typescript) {
69+
ts.AppendImport(decl)
70+
}
71+
}
72+
73+
// splitNameAlias parses entries of the form "Name" or "Name=Alias".
74+
func splitNameAlias(s string) (name, alias string) {
75+
for i := 0; i < len(s); i++ {
76+
if s[i] == '=' {
77+
return s[:i], s[i+1:]
78+
}
79+
}
80+
return s, ""
81+
}

config/imports_internal_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package config
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
func TestSplitNameAlias(t *testing.T) {
10+
t.Parallel()
11+
12+
cases := []struct {
13+
in string
14+
wantName string
15+
wantAlias string
16+
}{
17+
{in: "Foo", wantName: "Foo"},
18+
{in: "Foo=Bar", wantName: "Foo", wantAlias: "Bar"},
19+
{in: "=Bar", wantAlias: "Bar"},
20+
{in: "Foo=", wantName: "Foo"},
21+
{in: "", wantName: ""},
22+
}
23+
24+
for _, tc := range cases {
25+
tc := tc
26+
t.Run(tc.in, func(t *testing.T) {
27+
t.Parallel()
28+
29+
name, alias := splitNameAlias(tc.in)
30+
require.Equal(t, tc.wantName, name)
31+
require.Equal(t, tc.wantAlias, alias)
32+
})
33+
}
34+
}

0 commit comments

Comments
 (0)