diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e93a37..add6c19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,15 @@ and this project adheres to reports a single failing test case with the process output, instead of parsing that output with a regular expression. A process that fails while no test case does gets a failing case of its own. +- `kotlin_wasm_binary` / `kotlin_wasm_library` read the WIT file with the shared + parser instead of line-based regular expressions. Comments, a function + declared over several lines, a versioned package (`package a:b@1.0;`) and + `use`, `variant` or `flags` declarations no longer confuse it, and the methods + of a `resource` are no longer generated as functions of the enclosing + interface. An interface in a file without a `package` line takes the package + declared in another file of the directory. A WIT type that cannot be mapped to + Kotlin (a `tuple`) is now an error naming the function and parameter, instead + of a generated name that does not compile. ## [0.4.0] - 2026-10-03 diff --git a/tools/please_kotlin_wasm/BUILD b/tools/please_kotlin_wasm/BUILD index 0a186cf..8116546 100644 --- a/tools/please_kotlin_wasm/BUILD +++ b/tools/please_kotlin_wasm/BUILD @@ -7,6 +7,7 @@ go_binary( "main.go", ], visibility = ["PUBLIC"], + deps = ["//tools/common/wit/ast"], ) go_test( @@ -17,4 +18,5 @@ go_test( "main.go", "main_test.go", ], + deps = ["//tools/common/wit/ast"], ) diff --git a/tools/please_kotlin_wasm/compile.go b/tools/please_kotlin_wasm/compile.go index 6bf8ffb..a994637 100644 --- a/tools/please_kotlin_wasm/compile.go +++ b/tools/please_kotlin_wasm/compile.go @@ -1,7 +1,6 @@ package main import ( - "bufio" "fmt" "io" "os" @@ -9,6 +8,8 @@ import ( "path/filepath" "regexp" "strings" + + "tools/common/wit/ast" ) // WasmOptions configures a kotlinc-wasm compilation and linking invocation. @@ -153,69 +154,77 @@ func ToPascalCase(s string) string { return res } -// MapWitTypeToKotlin maps standard WIT primitives to Kotlin types. -func MapWitTypeToKotlin(witType string) string { - witType = strings.TrimSpace(witType) - switch witType { - case "s8": - return "Byte" - case "s16": - return "Short" - case "s32", "u8", "u16", "u32": - return "Int" - case "s64", "u64": - return "Long" - case "f32": - return "Float" - case "f64": - return "Double" - case "bool": - return "Boolean" - case "string": - return "String" - case "_", "unit": - return "Unit" - } - - // Handle result - if strings.HasPrefix(witType, "result<") && strings.HasSuffix(witType, ">") { - inner := witType[7 : len(witType)-1] - parts := strings.Split(inner, ",") - if len(parts) > 0 { - okType := strings.TrimSpace(parts[0]) - if okType == "_" { - return "Unit" - } - mapped := MapWitTypeToKotlin(okType) - if mapped == "Unit" { - return "Unit" - } - return mapped + "?" +// MapTypeToKotlin maps a WIT type to the Kotlin type of the generated interface. A nil type +// (a function without a result) is Unit. +func MapTypeToKotlin(t *ast.TypeRef) (string, error) { + if t == nil { + return "Unit", nil + } + switch t.Kind { + case ast.KindPrimitive, ast.KindNamed: + switch t.Name { + case "s8": + return "Byte", nil + case "s16": + return "Short", nil + case "s32", "u8", "u16", "u32": + return "Int", nil + case "s64", "u64": + return "Long", nil + case "f32": + return "Float", nil + case "f64": + return "Double", nil + case "bool": + return "Boolean", nil + case "string": + return "String", nil + case "_", "unit": + return "Unit", nil + } + return ToPascalCase(t.Name), nil + + case ast.KindList: + if len(t.TypeArgs) != 1 { + return "", fmt.Errorf("list needs an element type, got %s", t) + } + elem, err := MapTypeToKotlin(t.TypeArgs[0]) + if err != nil { + return "", err } - return "Unit" - } + return fmt.Sprintf("List<%s>", elem), nil - // Handle list - if strings.HasPrefix(witType, "list<") && strings.HasSuffix(witType, ">") { - elemType := witType[5 : len(witType)-1] - return fmt.Sprintf("List<%s>", MapWitTypeToKotlin(elemType)) - } + case ast.KindOption: + if len(t.TypeArgs) != 1 { + return "", fmt.Errorf("option needs an element type, got %s", t) + } + elem, err := MapTypeToKotlin(t.TypeArgs[0]) + if err != nil { + return "", err + } + return elem + "?", nil - // Handle option - if strings.HasPrefix(witType, "option<") && strings.HasSuffix(witType, ">") { - elemType := witType[7 : len(witType)-1] - return MapWitTypeToKotlin(elemType) + "?" + case ast.KindResult: + // The error side is not modelled: a result is its ok value, absent on failure. + if len(t.TypeArgs) == 0 { + return "Unit", nil + } + ok, err := MapTypeToKotlin(t.TypeArgs[0]) + if err != nil { + return "", err + } + if ok == "Unit" { + return "Unit", nil + } + return ok + "?", nil } - - return ToPascalCase(witType) + return "", fmt.Errorf("type %s is not supported", t) } -var funcRegex = regexp.MustCompile(`^\s*([a-zA-Z0-9_-]+)\s*:\s*func\s*\((.*?)\)(?:\s*->\s*(.+))?`) -var packageRegex = regexp.MustCompile(`(?m)^\s*package\s+([a-zA-Z0-9_:-]+);`) -var ifaceRegex = regexp.MustCompile(`^\s*interface\s+([a-zA-Z0-9_-]+)\s*\{`) -var resourceRegex = regexp.MustCompile(`^\s*resource\s+([a-zA-Z0-9_-]+)\s*\{`) - -// ParseWit parses .wit files in a given path into WitInterface structures. +// ParseWit parses the .wit files at witPath (a file, or the .wit files under a directory) into +// WitInterface structures. Declarations that generate nothing (use, variant, flags, ...) are +// skipped. All files of a directory belong to one WIT package, so an interface in a file without +// a package declaration takes the package declared in another file. func ParseWit(witPath string) ([]WitInterface, error) { var files []string fi, err := os.Stat(witPath) @@ -224,91 +233,70 @@ func ParseWit(witPath string) ([]WitInterface, error) { } if fi.IsDir() { - _ = filepath.Walk(witPath, func(path string, info os.FileInfo, err error) error { + err := filepath.Walk(witPath, func(path string, info os.FileInfo, err error) error { if err == nil && info != nil && !info.IsDir() && strings.HasSuffix(path, ".wit") { files = append(files, path) } return nil }) + if err != nil { + return nil, err + } } else { files = append(files, witPath) } - var interfaces []WitInterface - + var parsed []*ast.Package + dirPackage := "" for _, file := range files { - data, err := os.ReadFile(file) + pkg, err := ast.ParseFileLenient(file) if err != nil { - continue + return nil, fmt.Errorf("%s: %w", file, err) } - - pkgName := "" - if match := packageRegex.FindStringSubmatch(string(data)); len(match) > 1 { - pkgName = strings.ReplaceAll(match[1], ":", ".") + parsed = append(parsed, pkg) + if dirPackage == "" { + dirPackage = strings.ReplaceAll(pkg.FullName(), ":", ".") } + } - scanner := bufio.NewScanner(strings.NewReader(string(data))) - var currentIface *WitInterface - - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if strings.HasPrefix(line, "//") { - continue - } - - if m := ifaceRegex.FindStringSubmatch(line); len(m) > 1 { - interfaces = append(interfaces, WitInterface{ - Name: m[1], - Package: pkgName, - }) - currentIface = &interfaces[len(interfaces)-1] - continue - } - - if currentIface != nil { - if m := resourceRegex.FindStringSubmatch(line); len(m) > 1 { - continue - } - - if m := funcRegex.FindStringSubmatch(line); len(m) > 1 { - funcName := m[1] - rawParams := m[2] - rawReturn := strings.TrimSuffix(strings.TrimSpace(m[3]), ";") - - var params []WitParam - if rawParams != "" { - for _, p := range strings.Split(rawParams, ",") { - p = strings.TrimSpace(p) - if p == "" { - continue - } - parts := strings.SplitN(p, ":", 2) - if len(parts) == 2 { - pName := ToCamelCase(strings.TrimSpace(parts[0])) - pType := MapWitTypeToKotlin(strings.TrimSpace(parts[1])) - params = append(params, WitParam{Name: pName, Type: pType}) - } - } - } - - retType := "Unit" - if rawReturn != "" { - retType = MapWitTypeToKotlin(rawReturn) - } - - currentIface.Functions = append(currentIface.Functions, WitFunc{ - Name: funcName, - Params: params, - ReturnType: retType, - }) + var interfaces []WitInterface + for i, pkg := range parsed { + pkgName := strings.ReplaceAll(pkg.FullName(), ":", ".") + if pkgName == "" { + pkgName = dirPackage + } + for _, iface := range pkg.Interfaces { + witIface := WitInterface{Name: iface.Name, Package: pkgName} + for _, fn := range iface.Functions { + witFn, err := toWitFunc(fn) + if err != nil { + return nil, fmt.Errorf("%s: interface %s: function %s: %w", files[i], iface.Name, fn.Name, err) } + witIface.Functions = append(witIface.Functions, witFn) } + interfaces = append(interfaces, witIface) } } return interfaces, nil } +func toWitFunc(fn ast.Function) (WitFunc, error) { + var params []WitParam + for _, p := range fn.Params { + typ, err := MapTypeToKotlin(p.Type) + if err != nil { + return WitFunc{}, fmt.Errorf("parameter %s: %w", p.Name, err) + } + params = append(params, WitParam{Name: ToCamelCase(p.Name), Type: typ}) + } + ret, err := MapTypeToKotlin(fn.Results) + if err != nil { + return WitFunc{}, fmt.Errorf("result: %w", err) + } + return WitFunc{Name: fn.Name, Params: params, ReturnType: ret}, nil +} + // DetectPackage finds the declared Kotlin package across source files. func DetectPackage(srcs []string) string { pkgRegex := regexp.MustCompile(`(?m)^\s*package\s+([a-zA-Z0-9_.]+)`) diff --git a/tools/please_kotlin_wasm/compile_test.go b/tools/please_kotlin_wasm/compile_test.go index 30aa1a8..237f57f 100644 --- a/tools/please_kotlin_wasm/compile_test.go +++ b/tools/please_kotlin_wasm/compile_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "tools/common/wit/ast" ) func TestExpandCommaSeparated(t *testing.T) { @@ -97,22 +99,110 @@ func TestCasesConversion(t *testing.T) { } } -func TestMapWitTypeToKotlin(t *testing.T) { +// witType parses a WIT type by declaring it as the result of a function. +func witType(t *testing.T, wit string) *ast.TypeRef { + t.Helper() + pkg, err := ast.ParseContent("interface i { f: func() -> " + wit + "; }") + if err != nil { + t.Fatalf("%s: %v", wit, err) + } + return pkg.Interfaces[0].Functions[0].Results +} + +func TestMapTypeToKotlin(t *testing.T) { tests := map[string]string{ - "s32": "Int", - "s64": "Long", - "bool": "Boolean", - "string": "String", - "list": "List", - "result": "Int?", - "option": "Boolean?", - } - for wit, kt := range tests { - res := MapWitTypeToKotlin(wit) - if res != kt { - t.Errorf("for WIT %s, expected Kotlin %s, got %s", wit, kt, res) + "s32": "Int", + "u8": "Int", + "s64": "Long", + "f64": "Double", + "bool": "Boolean", + "string": "String", + "list": "List", + "list>": "List>", + "result": "Int?", + "result": "Int?", + "result<_, string>": "Unit", + "result, string>": "List?", + "option": "Boolean?", + "option>": "List?", + "shape": "Shape", + "two-sum": "TwoSum", + "result, error>": "Int??", + } + for wit, want := range tests { + got, err := MapTypeToKotlin(witType(t, wit)) + if err != nil || got != want { + t.Errorf("for WIT %s, expected Kotlin %s, got %s (err %v)", wit, want, got, err) + } + } + if got, err := MapTypeToKotlin(nil); err != nil || got != "Unit" { + t.Errorf("nil type = %q, %v; want Unit", got, err) + } +} + +func TestMapTypeToKotlinRejectsWhatItCannotMap(t *testing.T) { + _, err := MapTypeToKotlin(witType(t, "tuple")) + if err == nil || !strings.Contains(err.Error(), "tuple is not supported") { + t.Errorf("err = %v", err) + } + // ...and the error says where, once it comes out of ParseWit. + file := filepath.Join(t.TempDir(), "x.wit") + _ = os.WriteFile(file, []byte("package a:b;\ninterface i { pair: func(x: tuple) -> s32; }\n"), 0644) + _, err = ParseWit(file) + if err == nil || !strings.Contains(err.Error(), "interface i: function pair: parameter x") { + t.Errorf("err = %v", err) + } +} + +func TestParseWitReadsWitStructure(t *testing.T) { + dir := t.TempDir() + // The package is declared in one file only; interfaces of the others belong to it. + write := func(name, content string) { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatal(err) } } + write("a-root.wit", "// package fake:one;\npackage example:orders@1.2.0;\n\ninterface api {\n /// Doc\n place: func(\n item: string,\n quantity: u32,\n ) -> result;\n use other.{thing};\n variant status { open, closed(u32) }\n flags perms { read, write }\n resource cart {\n constructor();\n add: func(item: string);\n total: static func() -> u32;\n }\n cancel: func(id: u32);\n}\n") + write("b-more.wit", "interface audit { log: func(message: string) -> list; }\n") + write("notes.txt", "interface ignored { nope: func(); }\n") + + ifaces, err := ParseWit(dir) + if err != nil { + t.Fatal(err) + } + if len(ifaces) != 2 { + t.Fatalf("interfaces = %+v", ifaces) + } + api, audit := ifaces[0], ifaces[1] + if api.Name != "api" || api.Package != "example.orders" || audit.Name != "audit" || audit.Package != "example.orders" { + t.Errorf("api = %s/%s, audit = %s/%s", api.Package, api.Name, audit.Package, audit.Name) + } + // resource methods are not interface functions, and use/variant/flags generate nothing + if len(api.Functions) != 2 { + t.Fatalf("api functions = %+v", api.Functions) + } + place := api.Functions[0] + if place.Name != "place" || place.ReturnType != "Int?" || len(place.Params) != 2 || + place.Params[0] != (WitParam{Name: "item", Type: "String"}) || place.Params[1] != (WitParam{Name: "quantity", Type: "Int"}) { + t.Errorf("place = %+v", place) + } + if api.Functions[1].Name != "cancel" || api.Functions[1].ReturnType != "Unit" { + t.Errorf("cancel = %+v", api.Functions[1]) + } + if audit.Functions[0].ReturnType != "List" { + t.Errorf("log = %+v", audit.Functions[0]) + } +} + +func TestParseWitErrors(t *testing.T) { + if _, err := ParseWit(filepath.Join(t.TempDir(), "missing")); err == nil { + t.Error("expected an error for a missing path") + } + file := filepath.Join(t.TempDir(), "bad.wit") + _ = os.WriteFile(file, []byte("interface {\n"), 0644) + if _, err := ParseWit(file); err == nil || !strings.Contains(err.Error(), file) { + t.Errorf("err = %v, want one naming %s", err, file) + } } func TestParseWitAndGenerate(t *testing.T) {