Skip to content

Commit c084748

Browse files
committed
fix: enhance WIT validation and binding generation with source path support
- Updated `generateBindingsFromWasm` to accept an optional source path for improved context during binding generation. - Modified `validateWitSyntaxDetailedFromWasm` to include source path for detailed validation. - Introduced recursive file collection for WIT files in `wasmUtils.ts` to support local imports. - Added tests for local import resolution and validation scenarios. - Updated WIT interface definitions to reflect new parameters for validation and binding functions. - Bumped dependencies in `Cargo.lock` and `Cargo.toml` for compatibility with new features. Fixes Packages split across multiple files fail Fixes #121 Signed-off-by: Gordon Smith <GordonJSmith@gmail.com>
1 parent 58a9d99 commit c084748

File tree

14 files changed

+929
-377
lines changed

14 files changed

+929
-377
lines changed

package-lock.json

Lines changed: 225 additions & 236 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -75,30 +75,41 @@
7575
},
7676
"dependencies": {},
7777
"devDependencies": {
78-
"@bytecodealliance/jco": "1.17.1",
78+
"@bytecodealliance/jco": "1.17.5",
7979
"@eslint/css": "1.0.0",
8080
"@eslint/js": "10.0.1",
8181
"@types/node": "25.5.0",
8282
"@types/vscode": "1.99.0",
83-
"@typescript-eslint/parser": "8.57.1",
84-
"@vitest/coverage-v8": "4.1.0",
85-
"@vitest/ui": "4.1.0",
83+
"@typescript-eslint/parser": "8.57.2",
84+
"@vitest/coverage-v8": "4.1.1",
85+
"@vitest/ui": "4.1.1",
8686
"@vscode/vsce": "3.7.1",
8787
"esbuild": "0.27.4",
8888
"esbuild-node-externals": "1.20.1",
89-
"eslint": "10.0.3",
89+
"eslint": "10.1.0",
9090
"eslint-plugin-prettier": "5.5.5",
9191
"globals": "17.4.0",
9292
"npm-run-all": "4.1.5",
93-
"ovsx": "0.10.9",
93+
"ovsx": "0.10.10",
9494
"prettier": "3.8.1",
9595
"rimraf": "6.1.3",
96-
"typescript": "5.9.3",
97-
"typescript-eslint": "8.57.1",
98-
"vitest": "4.1.0",
96+
"typescript": "6.0.2",
97+
"typescript-eslint": "8.57.2",
98+
"vitest": "4.1.1",
9999
"vscode-tmgrammar-test": "0.1.3",
100100
"wit-bindgen-wasm": "file:wit-bindgen-wasm/pkg"
101101
},
102+
"overrides": {
103+
"typescript-eslint": {
104+
"typescript": "$typescript"
105+
},
106+
"@typescript-eslint/eslint-plugin": {
107+
"typescript": "$typescript"
108+
},
109+
"@typescript-eslint/parser": {
110+
"typescript": "$typescript"
111+
}
112+
},
102113
"contributes": {
103114
"colors": [
104115
{

src/extension.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,12 @@ export function activate(context: vscode.ExtensionContext) {
596596

597597
const outputPath = outputUri[0].fsPath;
598598

599-
const bindingFiles = await generateBindingsFromWasm(witContent, language);
599+
const bindingFiles = await generateBindingsFromWasm(
600+
witContent,
601+
language,
602+
undefined,
603+
diagDoc?.uri.fsPath ?? targetUri.fsPath
604+
);
600605

601606
const fileEntries = Object.entries(bindingFiles);
602607
const errorFile = fileEntries.find(([filename]) => filename === "error.txt");

src/validator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export class WitSyntaxValidator {
3434
public async validate(path: string, content: string): Promise<ReturnType<typeof extractErrorInfo> | null> {
3535
try {
3636
// Use the enhanced WASM-based WIT validation with detailed error reporting
37-
const validationResult = await validateWitSyntaxDetailedFromWasm(content);
37+
const validationResult = await validateWitSyntaxDetailedFromWasm(content, path);
3838

3939
if (validationResult.valid) {
4040
return null;

src/wasmUtils.ts

Lines changed: 149 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,134 @@
1+
import { readdir, readFile } from "node:fs/promises";
2+
import type { Dirent } from "node:fs";
3+
import path from "node:path";
4+
15
/**
26
* Cached reference to the witValidator interface from the WASM component module.
37
* The jco-transpiled module self-initializes via top-level await,
48
* so the module is ready to use once the dynamic import resolves.
59
*/
610
let witValidatorApi: typeof import("wit-bindgen-wasm").witValidator | null = null;
711

12+
interface PreparedSourceContext {
13+
sourcePath?: string;
14+
sourceFilesJson?: string;
15+
}
16+
17+
function normalizeSourcePath(sourcePath?: string): string | undefined {
18+
const trimmedPath = sourcePath?.trim();
19+
if (!trimmedPath) {
20+
return undefined;
21+
}
22+
23+
return path.resolve(trimmedPath);
24+
}
25+
26+
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
27+
return error instanceof Error && "code" in error;
28+
}
29+
30+
async function readDirectoryEntries(directoryPath: string): Promise<Array<Dirent<string>>> {
31+
try {
32+
return await readdir(directoryPath, { encoding: "utf8", withFileTypes: true });
33+
} catch (error: unknown) {
34+
if (isErrnoException(error) && error.code === "ENOENT") {
35+
return [];
36+
}
37+
38+
throw error;
39+
}
40+
}
41+
42+
async function collectWitFilePathsRecursively(directoryPath: string, filePaths: Array<string>): Promise<void> {
43+
const entries = await readDirectoryEntries(directoryPath);
44+
for (const entry of entries) {
45+
const entryPath = path.join(directoryPath, entry.name);
46+
if (entry.isDirectory()) {
47+
await collectWitFilePathsRecursively(entryPath, filePaths);
48+
continue;
49+
}
50+
51+
if (entry.isFile() && entry.name.toLowerCase().endsWith(".wit")) {
52+
filePaths.push(entryPath);
53+
}
54+
}
55+
}
56+
57+
async function readWitFilesWithConcurrency(filePaths: Array<string>, target: Record<string, string>): Promise<void> {
58+
if (filePaths.length === 0) {
59+
return;
60+
}
61+
62+
const maxConcurrency = 8;
63+
let currentIndex = 0;
64+
65+
const worker = async (): Promise<void> => {
66+
while (true) {
67+
const index = currentIndex;
68+
if (index >= filePaths.length) {
69+
return;
70+
}
71+
72+
currentIndex += 1;
73+
const filePath = filePaths[index];
74+
const contents = await readFile(filePath, "utf8");
75+
target[filePath] = contents;
76+
}
77+
};
78+
79+
const workerCount = Math.min(maxConcurrency, filePaths.length);
80+
const workers: Array<Promise<void>> = [];
81+
for (let i = 0; i < workerCount; i += 1) {
82+
workers.push(worker());
83+
}
84+
85+
await Promise.all(workers);
86+
}
87+
88+
async function collectWitFilesRecursively(directoryPath: string, sourceFiles: Record<string, string>): Promise<void> {
89+
const filePaths: Array<string> = [];
90+
await collectWitFilePathsRecursively(directoryPath, filePaths);
91+
await readWitFilesWithConcurrency(filePaths, sourceFiles);
92+
}
93+
94+
async function collectWitContext(sourceDirectory: string): Promise<Record<string, string>> {
95+
const sourceFiles: Record<string, string> = {};
96+
const filePaths: Array<string> = [];
97+
98+
const entries = await readDirectoryEntries(sourceDirectory);
99+
for (const entry of entries) {
100+
const entryPath = path.join(sourceDirectory, entry.name);
101+
if (entry.isDirectory()) {
102+
if (entry.name === "deps") {
103+
await collectWitFilePathsRecursively(entryPath, filePaths);
104+
}
105+
continue;
106+
}
107+
108+
if (entry.isFile() && entry.name.toLowerCase().endsWith(".wit")) {
109+
filePaths.push(entryPath);
110+
}
111+
}
112+
113+
await readWitFilesWithConcurrency(filePaths, sourceFiles);
114+
return sourceFiles;
115+
}
116+
117+
async function prepareSourceContext(content: string, sourcePath?: string): Promise<PreparedSourceContext> {
118+
const normalizedSourcePath = normalizeSourcePath(sourcePath);
119+
if (!normalizedSourcePath) {
120+
return {};
121+
}
122+
123+
const sourceFiles = await collectWitContext(path.dirname(normalizedSourcePath));
124+
sourceFiles[normalizedSourcePath] = content;
125+
126+
return {
127+
sourcePath: normalizedSourcePath,
128+
sourceFilesJson: JSON.stringify(sourceFiles),
129+
};
130+
}
131+
8132
/**
9133
* Initialize the WASM module by dynamically importing it.
10134
* The jco-transpiled component handles WASM loading internally.
@@ -68,9 +192,10 @@ export async function isWitFileExtensionFromWasm(filename: string): Promise<bool
68192
* @param content - The WIT content to validate
69193
* @returns Promise that resolves to true if the syntax is valid
70194
*/
71-
export async function validateWitSyntaxFromWasm(content: string): Promise<boolean> {
195+
export async function validateWitSyntaxFromWasm(content: string, sourcePath?: string): Promise<boolean> {
72196
const api = await getApi();
73-
return api.validateWitSyntax(content);
197+
const preparedSource = await prepareSourceContext(content, sourcePath);
198+
return api.validateWitSyntax(content, preparedSource.sourcePath, preparedSource.sourceFilesJson);
74199
}
75200

76201
/**
@@ -122,11 +247,19 @@ export async function extractInterfacesFromWasm(content: string): Promise<string
122247
export async function generateBindingsFromWasm(
123248
content: string,
124249
language: string,
125-
worldName?: string
250+
worldName?: string,
251+
sourcePath?: string
126252
): Promise<Record<string, string>> {
127253
const api = await getApi();
128-
const jsonResult = api.generateBindings(content, language, worldName);
129-
return JSON.parse(jsonResult);
254+
const preparedSource = await prepareSourceContext(content, sourcePath);
255+
const jsonResult = api.generateBindings(
256+
content,
257+
language,
258+
worldName,
259+
preparedSource.sourcePath,
260+
preparedSource.sourceFilesJson
261+
);
262+
return JSON.parse(jsonResult) as Record<string, string>;
130263
}
131264

132265
/**
@@ -143,8 +276,16 @@ export interface WitValidationResult {
143276
* @param content - The WIT content to validate
144277
* @returns Promise that resolves to detailed validation results
145278
*/
146-
export async function validateWitSyntaxDetailedFromWasm(content: string): Promise<WitValidationResult> {
279+
export async function validateWitSyntaxDetailedFromWasm(
280+
content: string,
281+
sourcePath?: string
282+
): Promise<WitValidationResult> {
147283
const api = await getApi();
148-
const resultJson = api.validateWitSyntaxDetailed(content);
149-
return JSON.parse(resultJson);
284+
const preparedSource = await prepareSourceContext(content, sourcePath);
285+
const resultJson = api.validateWitSyntaxDetailed(
286+
content,
287+
preparedSource.sourcePath,
288+
preparedSource.sourceFilesJson
289+
);
290+
return JSON.parse(resultJson) as WitValidationResult;
150291
}

tests/bindings-generation.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ describe("Bindings Generation for All Languages", () => {
2828

2929
supportedLanguages.forEach(({ lang, extension, expectedContent, minLength }) => {
3030
it(`should generate actual code stubs for ${lang}`, () => {
31-
const resultJson = witValidator.generateBindings(TEST_WIT, lang, undefined);
31+
const resultJson = witValidator.generateBindings(TEST_WIT, lang, undefined, undefined, undefined);
3232
const result = JSON.parse(resultJson);
3333

3434
// Verify files were generated
@@ -66,7 +66,7 @@ describe("Bindings Generation for All Languages", () => {
6666
});
6767

6868
it(`should not generate only README files for ${lang}`, () => {
69-
const resultJson = witValidator.generateBindings(TEST_WIT, lang, undefined);
69+
const resultJson = witValidator.generateBindings(TEST_WIT, lang, undefined, undefined, undefined);
7070
const result = JSON.parse(resultJson);
7171

7272
// Verify that not all files are README files
@@ -90,7 +90,7 @@ describe("Bindings Generation for All Languages", () => {
9090
const results: Record<string, Record<string, string>> = {};
9191

9292
for (const { lang } of supportedLanguages) {
93-
const resultJson = witValidator.generateBindings(TEST_WIT, lang, undefined);
93+
const resultJson = witValidator.generateBindings(TEST_WIT, lang, undefined, undefined, undefined);
9494
results[lang] = JSON.parse(resultJson);
9595
}
9696

@@ -149,7 +149,7 @@ world app {
149149
`;
150150

151151
for (const { lang, extension } of supportedLanguages) {
152-
const resultJson = witValidator.generateBindings(complexWit, lang, undefined);
152+
const resultJson = witValidator.generateBindings(complexWit, lang, undefined, undefined, undefined);
153153
const result = JSON.parse(resultJson);
154154

155155
expect(Object.keys(result).length).toBeGreaterThan(0);

tests/grammar/issue-121/wit/a.wit

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package local:demo;
2+
3+
world my-world {
4+
import host;
5+
6+
export another-interface;
7+
}
8+
9+
interface host {
10+
// ...
11+
}

tests/grammar/issue-121/wit/b.wit

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
interface another-interface {
2+
// ...
3+
}

tests/import-resolution.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
6+
import { generateBindingsFromWasm, validateWitSyntaxDetailedFromWasm } from "../src/wasmUtils.js";
7+
8+
describe("local import resolution", () => {
9+
const tempDirectories: string[] = [];
10+
11+
afterEach(async () => {
12+
await Promise.all(tempDirectories.map((directory) => rm(directory, { recursive: true, force: true })));
13+
tempDirectories.length = 0;
14+
});
15+
16+
async function createIssue121Fixture(): Promise<{ content: string; sourcePath: string }> {
17+
const fixtureRoot = await mkdtemp(path.join(os.tmpdir(), "wit-idl-imports-"));
18+
tempDirectories.push(fixtureRoot);
19+
20+
const witDirectory = path.join(fixtureRoot, "wit");
21+
await mkdir(witDirectory, { recursive: true });
22+
23+
const sourcePath = path.join(witDirectory, "a.wit");
24+
await writeFile(
25+
sourcePath,
26+
`package local:demo;
27+
28+
world my-world {
29+
import host;
30+
31+
export another-interface;
32+
}
33+
34+
interface host {
35+
ping: func();
36+
}
37+
`,
38+
"utf8"
39+
);
40+
await writeFile(
41+
path.join(witDirectory, "b.wit"),
42+
`interface another-interface {
43+
pong: func();
44+
}
45+
`,
46+
"utf8"
47+
);
48+
49+
return {
50+
content: await readFile(sourcePath, "utf8"),
51+
sourcePath,
52+
};
53+
}
54+
55+
it("validates WIT with sibling imports when given a source path", async () => {
56+
const fixture = await createIssue121Fixture();
57+
58+
const result = await validateWitSyntaxDetailedFromWasm(fixture.content, fixture.sourcePath);
59+
60+
expect(result).toEqual({ valid: true });
61+
});
62+
63+
it("uses unsaved editor content while still resolving sibling imports", async () => {
64+
const fixture = await createIssue121Fixture();
65+
const invalidContent = `${fixture.content}\nthis is not valid wit\n`;
66+
67+
const result = await validateWitSyntaxDetailedFromWasm(invalidContent, fixture.sourcePath);
68+
69+
expect(result.valid).toBe(false);
70+
expect(result.error).toContain(fixture.sourcePath);
71+
});
72+
73+
it("generates bindings from in-memory content while resolving sibling imports", async () => {
74+
const fixture = await createIssue121Fixture();
75+
const updatedContent = fixture.content.replace("world my-world", "world staged-world");
76+
77+
const files = await generateBindingsFromWasm(updatedContent, "rust", "staged-world", fixture.sourcePath);
78+
79+
expect(files["error.txt"]).toBeUndefined();
80+
expect(Object.keys(files).some((filename) => filename.endsWith(".rs"))).toBe(true);
81+
});
82+
});

0 commit comments

Comments
 (0)