diff --git a/Package.swift b/Package.swift index bee013061..4a344e06f 100644 --- a/Package.swift +++ b/Package.swift @@ -206,7 +206,14 @@ let package = Package( ], swiftSettings: swiftSettings ), - .testTarget(name: "WITTests", dependencies: ["WIT"], swiftSettings: swiftSettings), + .testTarget( + name: "WITTests", + dependencies: [ + "WIT", + .target(name: "WasmTools", condition: .when(traits: ["ComponentModel"])), + ], + swiftSettings: swiftSettings + ), .target( name: "WAVE", diff --git a/Sources/WIT/AST.swift b/Sources/WIT/AST.swift index f1ee86851..c96032b3b 100644 --- a/Sources/WIT/AST.swift +++ b/Sources/WIT/AST.swift @@ -173,7 +173,7 @@ public enum HandleSyntax: Equatable, Hashable, Sendable { case own(resource: Identifier) case borrow(resource: Identifier) - var id: Identifier { + public var id: Identifier { switch self { case .own(let resource): return resource case .borrow(let resource): return resource @@ -182,7 +182,7 @@ public enum HandleSyntax: Equatable, Hashable, Sendable { } public struct ResourceSyntax: Equatable, Hashable, Sendable { - var functions: [ResourceFunctionSyntax] + public var functions: [ResourceFunctionSyntax] } public enum ResourceFunctionSyntax: Equatable, Hashable, Sendable { @@ -312,7 +312,7 @@ public struct UseNameSyntax: Equatable, Hashable, Sendable { public struct IncludeSyntax: Equatable, Hashable, Sendable { var attributes: [AttributeSyntax] - var from: UsePathSyntax + public var from: UsePathSyntax var names: [IncludeNameSyntax] } diff --git a/Sources/WIT/TextParser/ParseFunctionDecl.swift b/Sources/WIT/TextParser/ParseFunctionDecl.swift index 146f8ea58..8d48932b2 100644 --- a/Sources/WIT/TextParser/ParseFunctionDecl.swift +++ b/Sources/WIT/TextParser/ParseFunctionDecl.swift @@ -99,11 +99,11 @@ extension FunctionSyntax { } extension NamedFunctionSyntax { - static func parse(lexer: inout Lexer, documents: DocumentsSyntax) throws -> SyntaxNode { + static func parse(lexer: inout Lexer, documents: DocumentsSyntax, attributes: [AttributeSyntax] = []) throws -> SyntaxNode { let name = try Identifier.parse(lexer: &lexer) try lexer.expect(.colon) let function = try FunctionSyntax.parse(lexer: &lexer) try lexer.expectSemicolon() - return .init(syntax: NamedFunctionSyntax(documents: documents, attributes: [], name: name, function: function)) + return .init(syntax: NamedFunctionSyntax(documents: documents, attributes: attributes, name: name, function: function)) } } diff --git a/Sources/WIT/TextParser/ParseInterface.swift b/Sources/WIT/TextParser/ParseInterface.swift index 193036624..c19a6f1de 100644 --- a/Sources/WIT/TextParser/ParseInterface.swift +++ b/Sources/WIT/TextParser/ParseInterface.swift @@ -44,9 +44,9 @@ extension InterfaceItemSyntax { case .union: return try .typeDef(.init(syntax: .parseUnion(lexer: &lexer, documents: documents, attributes: attributes))) case .id, .explicitId: - return try .function(NamedFunctionSyntax.parse(lexer: &lexer, documents: documents)) + return try .function(NamedFunctionSyntax.parse(lexer: &lexer, documents: documents, attributes: attributes)) case .use: - return try .use(UseSyntax.parse(lexer: &lexer)) + return try .use(UseSyntax.parse(lexer: &lexer, attributes: attributes)) default: throw ParseError(description: "`import`, `export`, `include`, `use`, or type definition") } diff --git a/Sources/WIT/TextParser/ParseTop.swift b/Sources/WIT/TextParser/ParseTop.swift index f4619d1aa..c2a755352 100644 --- a/Sources/WIT/TextParser/ParseTop.swift +++ b/Sources/WIT/TextParser/ParseTop.swift @@ -178,7 +178,7 @@ extension TopLevelUseSyntax { } extension UseSyntax { - static func parse(lexer: inout Lexer) throws -> SyntaxNode { + static func parse(lexer: inout Lexer, attributes: [AttributeSyntax] = []) throws -> SyntaxNode { try lexer.expect(.use) let from = try UsePathSyntax.parse(lexer: &lexer) try lexer.expect(.period) @@ -197,7 +197,7 @@ extension UseSyntax { } } try lexer.expectSemicolon() - return .init(syntax: UseSyntax(attributes: [], from: from, names: names)) + return .init(syntax: UseSyntax(attributes: attributes, from: from, names: names)) } } diff --git a/Sources/WIT/TextParser/ParseTypes.swift b/Sources/WIT/TextParser/ParseTypes.swift index 382c5e447..fc9cbfea7 100644 --- a/Sources/WIT/TextParser/ParseTypes.swift +++ b/Sources/WIT/TextParser/ParseTypes.swift @@ -158,7 +158,8 @@ extension TypeDefSyntax { if lexer.eat(.leftBrace) { while !lexer.eat(.rightBrace) { let docs = try DocumentsSyntax.parse(lexer: &lexer) - functions.append(try ResourceFunctionSyntax.parse(lexer: &lexer, documents: docs, attributes: [])) + let attributes = try AttributeSyntax.parseItems(lexer: &lexer) + functions.append(try ResourceFunctionSyntax.parse(lexer: &lexer, documents: docs, attributes: attributes)) } } else { try lexer.expectSemicolon() diff --git a/Sources/WIT/TextParser/ParseWorld.swift b/Sources/WIT/TextParser/ParseWorld.swift index d325d8e90..f58ca900a 100644 --- a/Sources/WIT/TextParser/ParseWorld.swift +++ b/Sources/WIT/TextParser/ParseWorld.swift @@ -34,7 +34,7 @@ extension WorldItemSyntax { case .export: return try .export(.parse(lexer: &lexer, documents: documents, attributes: attributes)) case .use: - return try .use(UseSyntax.parse(lexer: &lexer)) + return try .use(UseSyntax.parse(lexer: &lexer, attributes: attributes)) case .type: return try .type(.init(syntax: .parse(lexer: &lexer, documents: documents, attributes: attributes))) case .flags: diff --git a/Sources/WIT/WITFormatter.swift b/Sources/WIT/WITFormatter.swift new file mode 100644 index 000000000..569a066fb --- /dev/null +++ b/Sources/WIT/WITFormatter.swift @@ -0,0 +1,380 @@ +/// Formats WIT AST nodes to canonical WIT text. +/// +/// The output format matches `wasm-tools component wit --all-features --no-docs`: +/// - 2-space indentation +/// - Trailing commas in variant/enum/flags cases +/// - Blank lines between interface-level items +/// - No doc comments +/// - Attributes on their own line before the item +/// +/// Output is written line by line to the `TextOutputStream` sink stored on the formatter, so a +/// caller can drive it into a streaming destination instead of the formatter forcing a whole-document +/// `String`. `String` conforms to `TextOutputStream`; the static `format(package:)` convenience uses +/// that to return the rendered text for callers that want a `String`. +public struct WITFormatter: ~Copyable { + public private(set) var output: Output + + public init(output: Output) { + self.output = output + } + + // MARK: - Package + + /// Write an entire package (all source files merged) as canonical WIT text. + public mutating func write(package: PackageUnit) { + output.write("package \(package.packageName);\n") + + // Collect all interfaces and worlds across source files + var interfaces: [SyntaxNode] = [] + var worlds: [SyntaxNode] = [] + for sourceFile in package.sourceFiles { + for item in sourceFile.items { + switch item { + case .interface(let iface): interfaces.append(iface) + case .world(let world): worlds.append(world) + case .use: break + } + } + } + + for iface in interfaces { + output.write("\n") + write(interface: iface.syntax, indent: 0) + } + for world in worlds { + output.write("\n") + write(world: world.syntax, indent: 0) + } + } + + // MARK: - Top-level items + + mutating func write(interface: InterfaceSyntax, indent: Int) { + writeAttributes(interface.attributes, indent: indent) + line("interface \(ident(interface.name)) {", indent: indent) + writeInterfaceItems(interface.items, indent: indent + 1) + line("}", indent: indent) + } + + mutating func write(world: WorldSyntax, indent: Int) { + writeAttributes(world.attributes, indent: indent) + line("world \(ident(world.name)) {", indent: indent) + for item in world.items { + switch item { + case .import(let imp): + writeAttributes(imp.attributes, indent: indent + 1) + line("import \(formatExternKind(imp.kind));", indent: indent + 1) + case .export(let exp): + writeAttributes(exp.attributes, indent: indent + 1) + line("export \(formatExternKind(exp.kind));", indent: indent + 1) + case .use(let use): + writeUse(use.syntax, indent: indent + 1) + case .type(let typeDef): + writeTypeDef(typeDef.syntax, indent: indent + 1) + case .include(let include): + writeInclude(include, indent: indent + 1) + } + } + line("}", indent: indent) + } + + // MARK: - Interface items + + mutating func writeInterfaceItems(_ items: [InterfaceItemSyntax], indent: Int) { + // Canonical order: use statements, type definitions, functions + let sorted = items.sorted { a, b in + func rank(_ item: InterfaceItemSyntax) -> Int { + switch item { + case .use: return 0 + case .typeDef: return 1 + case .function: return 2 + } + } + return rank(a) < rank(b) + } + for (i, item) in sorted.enumerated() { + // Blank line between items, except between consecutive use statements + if i > 0 { + let prevIsUse = { + if case .use = sorted[i - 1] { return true } + return false + }() + let currIsUse = { + if case .use = item { return true } + return false + }() + if !(prevIsUse && currIsUse) { + output.write("\n") + } + } + switch item { + case .typeDef(let typeDef): + writeTypeDef(typeDef.syntax, indent: indent) + case .function(let namedFunc): + writeAttributes(namedFunc.attributes, indent: indent) + line("\(ident(namedFunc.name)): \(formatFunc(namedFunc.function));", indent: indent) + case .use(let use): + writeUse(use.syntax, indent: indent) + } + } + } + + // MARK: - Type definitions + + mutating func writeTypeDef(_ typeDef: TypeDefSyntax, indent: Int) { + writeAttributes(typeDef.attributes, indent: indent) + switch typeDef.body { + case .alias(let alias): + line("type \(ident(typeDef.name)) = \(formatTypeRepr(alias.typeRepr));", indent: indent) + case .record(let record): + line("record \(ident(typeDef.name)) {", indent: indent) + for field in record.fields { + line("\(ident(field.name)): \(formatTypeRepr(field.type)),", indent: indent + 1) + } + line("}", indent: indent) + case .variant(let variant): + line("variant \(ident(typeDef.name)) {", indent: indent) + for c in variant.cases { + if let type = c.type { + line("\(ident(c.name))(\(formatTypeRepr(type))),", indent: indent + 1) + } else { + line("\(ident(c.name)),", indent: indent + 1) + } + } + line("}", indent: indent) + case .enum(let enumType): + line("enum \(ident(typeDef.name)) {", indent: indent) + for c in enumType.cases { + line("\(ident(c.name)),", indent: indent + 1) + } + line("}", indent: indent) + case .flags(let flags): + line("flags \(ident(typeDef.name)) {", indent: indent) + for f in flags.flags { + line("\(ident(f.name)),", indent: indent + 1) + } + line("}", indent: indent) + case .resource(let resource): + if resource.functions.isEmpty { + line("resource \(ident(typeDef.name));", indent: indent) + } else { + line("resource \(ident(typeDef.name)) {", indent: indent) + for resourceFunc in resource.functions { + writeResourceFunc(resourceFunc, indent: indent + 1) + } + line("}", indent: indent) + } + case .union(let union): + line("union \(ident(typeDef.name)) {", indent: indent) + for c in union.cases { + line("\(formatTypeRepr(c.type)),", indent: indent + 1) + } + line("}", indent: indent) + } + } + + // MARK: - Resource functions + + mutating func writeResourceFunc(_ resourceFunc: ResourceFunctionSyntax, indent: Int) { + switch resourceFunc { + case .constructor(let namedFunc): + writeAttributes(namedFunc.attributes, indent: indent) + let params = formatParams(namedFunc.function.parameters) + line("constructor(\(params));", indent: indent) + case .method(let namedFunc): + writeAttributes(namedFunc.attributes, indent: indent) + line("\(ident(namedFunc.name)): \(formatFunc(namedFunc.function));", indent: indent) + case .static(let namedFunc): + writeAttributes(namedFunc.attributes, indent: indent) + line("\(ident(namedFunc.name)): static \(formatFunc(namedFunc.function));", indent: indent) + } + } + + // MARK: - Use statements + + mutating func writeUse(_ use: UseSyntax, indent: Int) { + writeAttributes(use.attributes, indent: indent) + let names = use.names.map { name in + if let asName = name.asName { + return "\(ident(name.name)) as \(ident(asName))" + } + return ident(name.name) + }.joined(separator: ", ") + line("use \(formatUsePath(use.from)).{\(names)};", indent: indent) + } + + // MARK: - Include + + mutating func writeInclude(_ include: IncludeSyntax, indent: Int) { + writeAttributes(include.attributes, indent: indent) + var text = "include \(formatUsePath(include.from))" + if !include.names.isEmpty { + let names = include.names.map { "\(ident($0.name)) as \(ident($0.asName))" }.joined(separator: ", ") + text += " with { \(names) }" + } + line(text + ";", indent: indent) + } + + // MARK: - Attributes + + mutating func writeAttributes(_ attrs: [AttributeSyntax], indent: Int) { + for attr in attrs { + switch attr { + case .since(let since): + var text = "@since(version = \(since.version)" + if let feature = since.feature { + text += ", feature = \(ident(feature))" + } + text += ")" + line(text, indent: indent) + case .unstable(let unstable): + line("@unstable(feature = \(ident(unstable.feature)))", indent: indent) + case .deprecated(let deprecated): + line("@deprecated(version = \(deprecated.version))", indent: indent) + } + } + } + + // MARK: - Functions + + func formatFunc(_ func_: FunctionSyntax) -> String { + let params = formatParams(func_.parameters) + let results = formatResults(func_.results) + if results.isEmpty { + return "func(\(params))" + } + return "func(\(params)) -> \(results)" + } + + func formatParams(_ params: ParameterList) -> String { + params.map { "\(ident($0.name)): \(formatTypeRepr($0.type))" }.joined(separator: ", ") + } + + func formatResults(_ results: ResultListSyntax) -> String { + switch results { + case .named(let params): + if params.isEmpty { return "" } + if params.count == 1 { + return "\(ident(params[0].name)): \(formatTypeRepr(params[0].type))" + } + let inner = params.map { "\(ident($0.name)): \(formatTypeRepr($0.type))" }.joined(separator: ", ") + return "(\(inner))" + case .anon(let typeRepr): + return formatTypeRepr(typeRepr) + } + } + + // MARK: - Type representations + + func formatTypeRepr(_ type: TypeReprSyntax) -> String { + switch type { + case .bool: return "bool" + case .u8: return "u8" + case .u16: return "u16" + case .u32: return "u32" + case .u64: return "u64" + case .s8: return "s8" + case .s16: return "s16" + case .s32: return "s32" + case .s64: return "s64" + case .float32: return "f32" + case .float64: return "f64" + case .char: return "char" + case .string: return "string" + case .name(let id): return ident(id) + case .list(let element): return "list<\(formatTypeRepr(element))>" + case .option(let wrapped): return "option<\(formatTypeRepr(wrapped))>" + case .tuple(let types): + return "tuple<\(types.map { formatTypeRepr($0) }.joined(separator: ", "))>" + case .handle(.own(let resource)): return "own<\(ident(resource))>" + case .handle(.borrow(let resource)): return "borrow<\(ident(resource))>" + case .result(let result): + switch (result.ok, result.error) { + case (nil, nil): return "result" + case (.some(let ok), nil): return "result<\(formatTypeRepr(ok))>" + case (nil, .some(let err)): return "result<_, \(formatTypeRepr(err))>" + case (.some(let ok), .some(let err)): return "result<\(formatTypeRepr(ok)), \(formatTypeRepr(err))>" + } + case .future(let element): + if let element { return "future<\(formatTypeRepr(element))>" } + return "future" + case .stream(let stream): + switch (stream.element, stream.end) { + case (nil, nil): return "stream" + case (.some(let element), nil): return "stream<\(formatTypeRepr(element))>" + case (nil, .some(let end)): return "stream<_, \(formatTypeRepr(end))>" + case (.some(let element), .some(let end)): return "stream<\(formatTypeRepr(element)), \(formatTypeRepr(end))>" + } + } + } + + // MARK: - Extern kinds + + func formatExternKind(_ kind: ExternKindSyntax) -> String { + switch kind { + case .path(let path): return formatUsePath(path) + case .function(let name, let function): + return "\(ident(name)): \(formatFunc(function))" + case .interface(let name, _): + // Inline interface (rare in WASIp2). The body payload is not rendered. + return "\(ident(name)): interface { ... }" + } + } + + // MARK: - Use paths + + func formatUsePath(_ path: UsePathSyntax) -> String { + switch path { + case .id(let id): return ident(id) + case .package(let packageName, let name): + // WIT format: ns:pkg/iface@version (version after interface name) + var text = "\(packageName.namespace.text):\(packageName.name.text)/\(ident(name))" + if let version = packageName.version { + text += "@\(version)" + } + return text + } + } + + // MARK: - Helpers + + func ident(_ id: Identifier) -> String { + let text = id.text + if witKeywords.contains(text) { + return "%\(text)" + } + return text + } + + private mutating func line(_ text: String, indent: Int) { + output.write(String(repeating: " ", count: indent) + text + "\n") + } +} + +/// WIT keywords and built-in type names that need `%` escaping when used as identifiers. +/// File-scope because Swift forbids stored `static let` in a generic type. +private let witKeywords: Set = [ + // Structural keywords + "use", "type", "resource", "func", "record", "enum", "flags", + "variant", "static", "interface", "world", "import", "export", + "package", "include", "constructor", "with", "union", + // Built-in type names + "bool", "char", "string", + "u8", "u16", "u32", "u64", + "s8", "s16", "s32", "s64", + "f32", "f64", "float32", "float64", + "list", "option", "result", "tuple", + "future", "stream", + "own", "borrow", +] + +// MARK: - String convenience + +extension WITFormatter where Output == String { + /// Format an entire package (all source files merged) to canonical WIT text. + public static func format(package: PackageUnit) -> String { + var formatter = WITFormatter(output: "") + formatter.write(package: package) + return formatter.output + } +} diff --git a/Sources/WasmTools/WasmTools.swift b/Sources/WasmTools/WasmTools.swift index c56bff546..3c36b25d5 100644 --- a/Sources/WasmTools/WasmTools.swift +++ b/Sources/WasmTools/WasmTools.swift @@ -296,6 +296,47 @@ package func wasm2wat( return result.stdoutString } +/// Formats a WIT package directory to canonical WIT text using `wasm-tools component wit`. +/// +/// Loads the `.wit` files (including any under `deps/`) into an in-memory filesystem rather than +/// preopening the host directory, so it opens no host file descriptors and is safe to run +/// concurrently with the suite's `.host()`-based WASI tests. +package func componentWit( + wasmToolsPath: String = defaultWasmToolsPath, + packageDirectory: String, + allFeatures: Bool = true, + noDocs: Bool = true +) throws -> String { + let guestRoot = "/package" + // Resolve symlinks so the enumerator's URLs share this prefix (macOS temp dirs symlink /var to + // /private/var, which would otherwise break the relative-path slicing below). + let hostBase = URL(fileURLWithPath: packageDirectory).resolvingSymlinksInPath() + guard + let enumerator = FileManager.default.enumerator( + at: hostBase, includingPropertiesForKeys: [.isRegularFileKey]) + else { + throw WasmToolsError.fileNotFound(path: packageDirectory) + } + + let context = try WasmToolsContext() + for case let fileURL as URL in enumerator where fileURL.pathExtension == "wit" { + // Mirror the host layout (e.g. "/random.wit", "/deps/io/streams.wit") under the guest root. + let relative = fileURL.resolvingSymlinksInPath().path.dropFirst(hostBase.path.count) + let content = try [UInt8](Data(contentsOf: fileURL)) + try context.memoryFS.addFile(at: guestRoot + relative, content: content) + } + + var args = ["component", "wit", guestRoot] + if allFeatures { args.append("--all-features") } + if noDocs { args.append("--no-docs") } + + let result = try runWasmTools(wasmToolsPath: wasmToolsPath, args: args, context: context) + guard result.exitCode == 0 else { + throw WasmToolsError.executionFailed(exitCode: result.exitCode, stderr: result.stderrString) + } + return result.stdoutString +} + package func wat2wasm( wasmToolsPath: String = defaultWasmToolsPath, watContent: [UInt8] diff --git a/Tests/WITTests/WASIp2ParseTests.swift b/Tests/WITTests/WASIp2ParseTests.swift new file mode 100644 index 000000000..150351cba --- /dev/null +++ b/Tests/WITTests/WASIp2ParseTests.swift @@ -0,0 +1,333 @@ +import Foundation +import Testing + +@testable import WIT + +#if ComponentModel + import WasmTools +#endif + +/// Absolute path to the vendored WASIp2 WIT proposals (`Vendor/wasi/proposals`). +/// `Vendor/wasi` is checked out by `Vendor/checkout-dependency` under the component-model +/// category; the suite below is skipped when it is absent. +private let wasiProposalsPath: String = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // WITTests/ + .deletingLastPathComponent() // Tests/ + .deletingLastPathComponent() // repo root + .appendingPathComponent("Vendor/wasi/proposals").path + +/// Verifies that all WASIp2 WIT packages parse correctly by roundtripping +/// through our parser and formatter, then comparing against wasm-tools +/// reference output. +@Suite(.enabled(if: FileManager.default.fileExists(atPath: wasiProposalsPath), "Vendor/wasi not checked out")) +struct WASIp2ParseTests { + + private let loader = LocalFileLoader() + + private func parsePackage(_ name: String) throws -> PackageUnit { + try PackageUnit.parse( + directory: "\(wasiProposalsPath)/\(name)/wit", + loader: loader + ) + } + + // Reference comparison needs `WasmTools`, which `WITTests` depends on only under the + // `ComponentModel` trait. + #if ComponentModel + /// Format a parsed package with our formatter and compare each interface/world + /// against wasm-tools reference output. + /// + /// wasm-tools may reorder interfaces (dependency order), so we compare + /// per-interface/world rather than the whole file. + private func assertMatchesReference(_ name: String, deps: [String] = []) throws { + // 1. Parse with our parser + let pkg = try parsePackage(name) + + // 2. Get reference output from wasm-tools, then parse it to extract + // per-interface/world blocks + let referenceOutput = try getReferenceOutput(name, deps: deps) + let referenceBlocks = extractBlocks(from: referenceOutput) + + // 3. Format each interface/world with our formatter and compare + for sourceFile in pkg.sourceFiles { + for item in sourceFile.items { + switch item { + case .interface(let iface): + var formatter = WITFormatter(output: "") + formatter.write(interface: iface.syntax, indent: 0) + let ourBlock = formatter.output + let key = iface.name.text + guard let refBlock = referenceBlocks[key] else { + Issue.record("\(name): interface '\(key)' not found in wasm-tools output") + continue + } + compareBlocks(name: "\(name)/\(key)", ours: ourBlock, ref: refBlock) + case .world: + // wasm-tools resolves transitive imports and reorders world items, + // so we can't directly compare worlds. Interface comparison is sufficient + // since worlds just reference interfaces by name. + break + case .use: + break + } + } + } + } + + /// Extract top-level interface/world blocks from wasm-tools output, keyed by name. + private func extractBlocks(from text: String) -> [String: String] { + var blocks: [String: String] = [:] + let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var i = 0 + while i < lines.count { + let line = lines[i] + // Detect start of an interface or world block (possibly preceded by attributes) + var blockStart = i + // Look back to include attribute lines + while blockStart > 0 && lines[blockStart - 1].hasPrefix("@") { + blockStart -= 1 + } + // Skip empty lines before attributes + if line.hasPrefix("interface ") || line.hasPrefix("world ") { + let name: String + if line.hasPrefix("interface ") { + name = String(line.dropFirst("interface ".count).prefix(while: { $0 != " " && $0 != "{" })) + } else { + name = String(line.dropFirst("world ".count).prefix(while: { $0 != " " && $0 != "{" })) + } + // Find the closing `}` + var depth = 0 + var blockEnd = i + for j in i.. SplitBlock { + let lines = block.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + guard lines.count >= 2 else { return SplitBlock(header: block, items: []) } + + // Find the opening `{` line + var headerEnd = 0 + for (i, line) in lines.enumerated() { + if line.hasSuffix("{") { + headerEnd = i + break + } + } + + let header = lines[0...headerEnd].joined(separator: "\n") + + // Split body into items (groups of non-empty lines separated by blank lines) + var items: [String] = [] + var current: [String] = [] + for line in lines[(headerEnd + 1)...] { + if line.trimmingCharacters(in: .whitespaces).isEmpty { + if !current.isEmpty { + items.append(current.joined(separator: "\n")) + current = [] + } + } else if line.trimmingCharacters(in: .whitespaces) == "}" { + if !current.isEmpty { + items.append(current.joined(separator: "\n")) + current = [] + } + } else { + current.append(line) + } + } + if !current.isEmpty { + items.append(current.joined(separator: "\n")) + } + + return SplitBlock(header: header, items: items) + } + + private func getReferenceOutput(_ name: String, deps: [String]) throws -> String { + if deps.isEmpty { + // Standalone package: pass directory directly + return try componentWit(packageDirectory: "\(wasiProposalsPath)/\(name)/wit") + } else { + // Package with deps: create temp directory with deps/ structure + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("wasip2-test-\(name)-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: tmpDir) } + + // Copy package .wit files only (skip deps.toml/deps.lock) + let srcDir = URL(fileURLWithPath: "\(wasiProposalsPath)/\(name)/wit") + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + for file in try FileManager.default.contentsOfDirectory(atPath: srcDir.path) { + guard file.hasSuffix(".wit") else { continue } + try FileManager.default.copyItem( + at: srcDir.appendingPathComponent(file), + to: tmpDir.appendingPathComponent(file) + ) + } + + // Copy deps + let depsDir = tmpDir.appendingPathComponent("deps") + try FileManager.default.createDirectory(at: depsDir, withIntermediateDirectories: true) + for dep in deps { + let depSrc = URL(fileURLWithPath: "\(wasiProposalsPath)/\(dep)/wit") + let depDst = depsDir.appendingPathComponent(dep) + try FileManager.default.copyItem(at: depSrc, to: depDst) + } + + return try componentWit(packageDirectory: tmpDir.path) + } + } + + // MARK: - Standalone packages (no cross-package deps) + + @Test func roundtripRandom() throws { + try assertMatchesReference("random") + } + + @Test func roundtripIO() throws { + try assertMatchesReference("io") + } + + // MARK: - Packages with dependencies + + @Test func roundtripClocks() throws { + try assertMatchesReference("clocks", deps: ["io"]) + } + + @Test func roundtripFilesystem() throws { + try assertMatchesReference("filesystem", deps: ["io", "clocks"]) + } + + @Test func roundtripSockets() throws { + try assertMatchesReference("sockets", deps: ["io", "clocks"]) + } + + @Test func roundtripCLI() throws { + try assertMatchesReference("cli", deps: ["io", "clocks", "random", "filesystem", "sockets"]) + } + + @Test func roundtripHTTP() throws { + try assertMatchesReference("http", deps: ["io", "clocks", "random", "cli", "filesystem", "sockets"]) + } + #endif + + // MARK: - Multi-package resolution + + @Test func resolveAllWASIp2Packages() throws { + let packageResolver = PackageResolver() + for name in ["io", "clocks", "random", "filesystem", "sockets", "cli"] { + let pkg = try parsePackage(name) + packageResolver.register(packageUnit: pkg) + } + #expect(packageResolver.packages.count == 6) + } + + // MARK: - Formatter (no wasm-tools reference required) + + /// Every WASIp2 package formats to non-empty canonical WIT text with a package header. + @Test(arguments: ["io", "clocks", "random", "filesystem", "sockets", "cli"]) + func formatsPackage(name: String) throws { + let pkg = try parsePackage(name) + let text = WITFormatter.format(package: pkg) + #expect(text.hasPrefix("package ")) + #expect(text.contains("interface ") || text.contains("world ")) + } + + /// Formatting is idempotent: format -> reparse -> format yields identical text. + @Test(arguments: ["random", "io"]) + func formatIsIdempotent(name: String) throws { + let pkg = try parsePackage(name) + let once = WITFormatter.format(package: pkg) + + let reparsed = try SourceFileSyntax.parse(once, fileName: "\(name).wit") + var builder = PackageBuilder() + try builder.append(reparsed) + let rebuilt = try builder.build() + + let twice = WITFormatter.format(package: rebuilt) + #expect(once == twice) + } + + /// The formatter streams into any `TextOutputStream` sink (not only a `String` buffer) and + /// emits the document line by line: each sink write is exactly one rendered line ending in a + /// single trailing newline. A `write(package:)` that emitted one buffered blob would produce a + /// single chunk and fail the per-newline count, so the invariant discriminates streaming from + /// buffering rather than being tautological. + @Test func streamsLineByLineIntoCustomSink() throws { + struct ChunkRecorder: TextOutputStream { + var chunks: [String] = [] + mutating func write(_ string: String) { chunks.append(string) } + } + + let pkg = try parsePackage("random") + + var formatter = WITFormatter(output: ChunkRecorder()) + formatter.write(package: pkg) + let chunks = formatter.output.chunks + let streamed = chunks.joined() + + // Concatenation of the streamed chunks equals the String-convenience output. + #expect(streamed == WITFormatter.format(package: pkg)) + // Each write is exactly one rendered line: non-empty, ending in one trailing newline with + // no interior newline. + #expect(!chunks.isEmpty) + #expect( + chunks.allSatisfy { chunk in + chunk.hasSuffix("\n") && !chunk.dropLast().contains("\n") + }) + // One chunk per newline in the output: a single buffered write would make this 1, not many. + #expect(chunks.count == streamed.filter { $0 == "\n" }.count) + } +} diff --git a/Vendor/dependencies.json b/Vendor/dependencies.json index 487355ca1..7205ebf66 100644 --- a/Vendor/dependencies.json +++ b/Vendor/dependencies.json @@ -19,6 +19,11 @@ "revision": "0b951b72b3f4019edf386a93585782587d221a0c", "categories": ["component-model"] }, + "wasi": { + "repository": "https://github.com/WebAssembly/wasi.git", + "revision": "184b0c0e9fd437e5e5601d6e327a28feddbbd7f7", + "categories": ["component-model"] + }, "wasm-tools-prebuilt": { "url": "https://github.com/bytecodealliance/wasm-tools/releases/download/v1.244.0/wasm-tools-1.244.0-wasm32-wasip1.tar.gz", "sha256": "010f8a3c591f3070d06a1c13830c8a61dcbf6527a62ab289970ed3341f0008e7",