Skip to content

Commit de4b362

Browse files
CopilotGordonSmith
authored andcommitted
feat: add pretty print support
Signed-off-by: Gordon Smith <[email protected]>
1 parent b9042ce commit de4b362

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+985
-654
lines changed

.gitattributes

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1-
# Set default behavior to automatically normalize line endings.
1+
*.wit text eol=lf
2+
*.snap text eol=lf
3+
*.ts text eol=lf
4+
*.json text eol=lf
5+
*.mjs text eol=lf
6+
Cargo.toml text eol=lf
7+
README.md text eol=lf
8+
LICENSE text eol=lf# Set default behavior to automatically normalize line endings.
29
* text=auto
310

package.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,11 @@
181181
"command": "wit-idl.extractCoreWasm",
182182
"title": "Extract Core Wasm",
183183
"category": "WIT"
184+
},
185+
{
186+
"command": "wit-idl.formatDocument",
187+
"title": "Format Document",
188+
"category": "WIT"
184189
}
185190
],
186191
"submenus": [
@@ -196,6 +201,11 @@
196201
"when": "resourceExtname == .wit",
197202
"group": "navigation"
198203
},
204+
{
205+
"command": "wit-idl.formatDocument",
206+
"when": "resourceExtname == .wit",
207+
"group": "1_modification@10"
208+
},
199209
{
200210
"submenu": "wit-idl.generateBindings.submenu",
201211
"when": "resourceExtname == .wit || witIdl.isWasmComponent",
@@ -289,6 +299,10 @@
289299
{
290300
"command": "wit-idl.extractCoreWasm",
291301
"when": "witIdl.isWasmComponent"
302+
},
303+
{
304+
"command": "wit-idl.formatDocument",
305+
"when": "editorLangId == wit"
292306
}
293307
]
294308
},
@@ -301,6 +315,11 @@
301315
{
302316
"command": "wit-idl.syntaxCheckWorkspace",
303317
"key": "shift+f7"
318+
},
319+
{
320+
"command": "wit-idl.formatDocument",
321+
"key": "shift+alt+f",
322+
"when": "editorTextFocus && editorLangId == wit"
304323
}
305324
],
306325
"customEditors": [

src/extension.ts

Lines changed: 31 additions & 49 deletions
Large diffs are not rendered by default.

src/formatter.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import * as vscode from "vscode";
2+
3+
export class WitFormatter implements vscode.DocumentFormattingEditProvider {
4+
private static replaceAll(line: string, replacements: Array<[RegExp, string]>): string {
5+
let out = line;
6+
for (const [pattern, repl] of replacements) {
7+
out = out.replace(pattern, repl);
8+
}
9+
return out;
10+
}
11+
12+
private static ensureSemicolon(line: string): string {
13+
return line.replace(/\s*;\s*$/, ";");
14+
}
15+
16+
private static collapseSpaces(line: string): string {
17+
return line.replace(/\s+/g, " ");
18+
}
19+
20+
public provideDocumentFormattingEdits(
21+
document: vscode.TextDocument,
22+
options: vscode.FormattingOptions
23+
): vscode.TextEdit[] {
24+
const text = document.getText();
25+
const formatted = this.formatWitContent(text, options);
26+
if (formatted === text) {
27+
return [];
28+
}
29+
const fullRange = new vscode.Range(document.positionAt(0), document.positionAt(text.length));
30+
return [vscode.TextEdit.replace(fullRange, formatted)];
31+
}
32+
33+
public formatWitContent(content: string, options: vscode.FormattingOptions): string {
34+
const tabSize = options.tabSize ?? 2;
35+
const insertSpaces = options.insertSpaces !== false;
36+
const indentUnit = insertSpaces ? " ".repeat(tabSize) : "\t";
37+
const lines = content.split(/\r?\n/);
38+
const out: string[] = [];
39+
let indentLevel = 0;
40+
let inMultiLineTupleAlias = false;
41+
let aliasGenericDepth = 0;
42+
let inFuncParams = false;
43+
for (let i = 0; i < lines.length; i++) {
44+
const raw = lines[i];
45+
const trimmed = raw.trim();
46+
if (trimmed === "") {
47+
out.push("");
48+
continue;
49+
}
50+
if (/^\}/.test(trimmed)) {
51+
indentLevel = Math.max(0, indentLevel - 1);
52+
}
53+
if (/^\/\//.test(trimmed)) {
54+
const extra = inMultiLineTupleAlias && aliasGenericDepth > 0 && !/^>>/.test(trimmed) ? 1 : 0;
55+
out.push(indentUnit.repeat(indentLevel + extra) + trimmed);
56+
continue;
57+
}
58+
const formattedLine = this.formatLine(trimmed);
59+
const needsTupleExtra = inMultiLineTupleAlias && aliasGenericDepth > 0 && !/^>>/.test(trimmed);
60+
const needsFuncParamExtra = inFuncParams && !/^\)/.test(trimmed);
61+
out.push(
62+
indentUnit.repeat(indentLevel + (needsTupleExtra ? 1 : 0) + (needsFuncParamExtra ? 1 : 0)) +
63+
formattedLine
64+
);
65+
if (this.isOpeningBrace(trimmed)) {
66+
indentLevel++;
67+
}
68+
if (!inFuncParams && /func\($/.test(trimmed)) {
69+
let lookahead = i + 1;
70+
let activate = true;
71+
while (lookahead < lines.length) {
72+
const laTrim = lines[lookahead].trim();
73+
if (laTrim === "") {
74+
lookahead++;
75+
continue;
76+
}
77+
if (/^\/\//.test(laTrim)) {
78+
activate = false;
79+
}
80+
break;
81+
}
82+
if (activate) {
83+
inFuncParams = true;
84+
}
85+
} else if (inFuncParams && /^\)/.test(trimmed)) {
86+
inFuncParams = false;
87+
}
88+
if (!inMultiLineTupleAlias && /^type\s+[^=]+=.*tuple<\s*$/.test(trimmed)) {
89+
inMultiLineTupleAlias = true;
90+
aliasGenericDepth = (trimmed.match(/</g) || []).length - (trimmed.match(/>/g) || []).length;
91+
} else if (inMultiLineTupleAlias) {
92+
const opens = (trimmed.match(/</g) || []).length;
93+
const closes = (trimmed.match(/>/g) || []).length;
94+
aliasGenericDepth += opens - closes;
95+
if (aliasGenericDepth <= 0) {
96+
inMultiLineTupleAlias = false;
97+
}
98+
}
99+
}
100+
return out.join("\n");
101+
}
102+
103+
private isOpeningBrace(line: string): boolean {
104+
return line.endsWith("{") && !line.includes("}");
105+
}
106+
107+
private formatLine(line: string): string {
108+
if (line.startsWith("package ")) return this.formatPackage(line);
109+
if (line.startsWith("interface ")) return this.formatNamedBlock(line);
110+
if (line.startsWith("world ")) return this.formatNamedBlock(line);
111+
if (this.isTypeDecl(line)) return this.formatNamedBlock(line);
112+
if (line.startsWith("type ") && line.includes("=")) return this.formatTypeAlias(line);
113+
if (this.isFuncDecl(line)) return this.formatFunc(line);
114+
if (line.startsWith("import ") || line.startsWith("export ")) return this.formatImportExport(line);
115+
if (line.startsWith("use ")) return this.formatUse(line);
116+
if (this.isFieldDecl(line)) return this.formatField(line);
117+
return line;
118+
}
119+
120+
private isTypeDecl(line: string): boolean {
121+
return /^(record|variant|enum|flags|resource)\s+/.test(line);
122+
}
123+
private isFuncDecl(line: string): boolean {
124+
if (line.startsWith("import ") || line.startsWith("export ")) return false;
125+
return /^[a-zA-Z][\w-]*\s*:\s*func\b/.test(line) || /:\s*func\b/.test(line) || /->/.test(line);
126+
}
127+
private isFieldDecl(line: string): boolean {
128+
const t = line.trim();
129+
return /^[a-zA-Z][a-zA-Z0-9-]*\s*[:,(]/.test(t) || /^[a-zA-Z][a-zA-Z0-9-]*\s*,?\s*$/.test(t);
130+
}
131+
132+
private formatPackage(line: string): string {
133+
return WitFormatter.ensureSemicolon(WitFormatter.collapseSpaces(line));
134+
}
135+
private formatNamedBlock(line: string): string {
136+
return WitFormatter.collapseSpaces(line).replace(/\s*{\s*$/, " {");
137+
}
138+
private formatFunc(line: string): string {
139+
const replacements: Array<[RegExp, string]> = [
140+
[/:func/, ": func"],
141+
[/:\s*func/, ": func"],
142+
[/func\s*\(/, "func("],
143+
[/\)\s*->\s*/, ") -> "],
144+
[/\)->\s*/, ") -> "],
145+
[/\)->/, ") -> "],
146+
[/,\s*/g, ", "],
147+
[/:\s*/g, ": "],
148+
];
149+
return WitFormatter.ensureSemicolon(WitFormatter.replaceAll(line, replacements));
150+
}
151+
private formatImportExport(line: string): string {
152+
const base = line.replace(/^(import|export)\s+/, "$1 ");
153+
if (base.includes(": func") || base.includes(":func")) return this.formatFunc(base);
154+
return WitFormatter.ensureSemicolon(base);
155+
}
156+
private formatUse(line: string): string {
157+
const replacements: Array<[RegExp, string]> = [
158+
[/^use\s+/, "use "],
159+
[/\s+as\s+/, " as "],
160+
[/\s+from\s+/, " from "],
161+
];
162+
return WitFormatter.ensureSemicolon(WitFormatter.replaceAll(line, replacements));
163+
}
164+
private formatTypeAlias(line: string): string {
165+
const replacements: Array<[RegExp, string]> = [
166+
[/^type\s+/, "type "],
167+
[/\s*=\s*/, " = "],
168+
];
169+
return WitFormatter.ensureSemicolon(WitFormatter.replaceAll(line, replacements));
170+
}
171+
private formatField(line: string): string {
172+
const replacements: Array<[RegExp, string]> = [
173+
[/:\s*/g, ": "],
174+
[/,\s*/g, ", "],
175+
[/,\s*$/, ","],
176+
];
177+
return WitFormatter.replaceAll(line, replacements);
178+
}
179+
}

tests/formatter-idempotent.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, it, expect, vi, beforeAll } from "vitest";
2+
import * as fs from "fs";
3+
import * as path from "path";
4+
vi.mock("vscode", () => ({}));
5+
let formatter: import("../src/formatter.js").WitFormatter;
6+
7+
function collectWitFiles(dir: string, acc: string[] = []): string[] {
8+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
9+
const full = path.join(dir, entry.name);
10+
if (entry.isDirectory()) {
11+
collectWitFiles(full, acc);
12+
} else if (entry.isFile() && entry.name.endsWith(".wit")) {
13+
acc.push(full);
14+
}
15+
}
16+
return acc;
17+
}
18+
19+
describe("WIT formatter idempotency", () => {
20+
const testRoot = path.join(__dirname);
21+
const witFiles = collectWitFiles(testRoot).sort();
22+
beforeAll(async () => {
23+
const mod = await import("../src/formatter.js");
24+
formatter = new mod.WitFormatter();
25+
});
26+
27+
it("should find at least one .wit test file", () => {
28+
expect(witFiles.length).toBeGreaterThan(0);
29+
});
30+
31+
for (const file of witFiles) {
32+
it(path.relative(testRoot, file), () => {
33+
const original = fs.readFileSync(file, "utf8");
34+
const formatted = formatter.formatWitContent(original, { tabSize: 2, insertSpaces: true });
35+
const normalize = (s: string) => s.replace(/\r\n/g, "\n");
36+
const origHadFinalNewline = /\n$/.test(original);
37+
const nOriginal = normalize(original).replace(/\n+$/g, "") + (origHadFinalNewline ? "\n" : "");
38+
const nFormatted = normalize(formatted).replace(/\n+$/g, "") + (origHadFinalNewline ? "\n" : "");
39+
expect(nFormatted).toBe(nOriginal);
40+
});
41+
}
42+
});

tests/formatter.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { WitFormatter } from "../src/formatter.js";
3+
4+
vi.mock("vscode", () => ({}));
5+
6+
describe("WitFormatter", () => {
7+
const formatter = new WitFormatter();
8+
const options: { insertSpaces: boolean; tabSize: number } = { insertSpaces: true, tabSize: 2 };
9+
10+
it("formats package declaration", () => {
11+
const input = "package foo:bar ;";
12+
const expected = "package foo:bar;";
13+
expect(formatter.formatWitContent(input, options)).toBe(expected);
14+
});
15+
16+
it("formats interface block", () => {
17+
const input = "package foo:bar;\n\ninterface test{\nf1:func();\nf2:func(a:u32);\nf3:func()->u32;\n}";
18+
const expected =
19+
"package foo:bar;\n\ninterface test {\n f1: func();\n f2: func(a: u32);\n f3: func() -> u32;\n}";
20+
expect(formatter.formatWitContent(input, options)).toBe(expected);
21+
});
22+
23+
it("formats world block", () => {
24+
const input = "package foo:bar;\n\nworld test{\nimport test-interface;\nexport run:func();\n}";
25+
const expected = "package foo:bar;\n\nworld test {\n import test-interface;\n export run: func();\n}";
26+
expect(formatter.formatWitContent(input, options)).toBe(expected);
27+
});
28+
29+
it("formats record definition", () => {
30+
const input =
31+
"package foo:bar;\n\ninterface test{\nrecord person{\nname:string,\nage:u32,\nactive:bool,\n}\n}";
32+
const expected =
33+
"package foo:bar;\n\ninterface test {\n record person {\n name: string,\n age: u32,\n active: bool,\n }\n}";
34+
expect(formatter.formatWitContent(input, options)).toBe(expected);
35+
});
36+
37+
it("formats complex function signature", () => {
38+
const input =
39+
"package foo:bar;\n\ninterface test{\ncomplex-func:func(a:u32,b:string,c:bool)->tuple<u32,string>;\n}";
40+
const expected =
41+
"package foo:bar;\n\ninterface test {\n complex-func: func(a: u32, b: string, c: bool) -> tuple<u32, string>;\n}";
42+
expect(formatter.formatWitContent(input, options)).toBe(expected);
43+
});
44+
45+
it("preserves comments", () => {
46+
const input =
47+
"package foo:bar;\n\n// This is a line comment\ninterface test{\n/// This is a doc comment\nf1:func();\n/* Block comment */\nf2:func();\n}";
48+
const expected =
49+
"package foo:bar;\n\n// This is a line comment\ninterface test {\n /// This is a doc comment\n f1: func();\n /* Block comment */\n f2: func();\n}";
50+
expect(formatter.formatWitContent(input, options)).toBe(expected);
51+
});
52+
53+
it("formats enum", () => {
54+
const input =
55+
"package foo:bar;\n\ninterface test{\nenum status{\npending,\nrunning,\ncompleted,\nfailed,\n}\n}";
56+
const expected =
57+
"package foo:bar;\n\ninterface test {\n enum status {\n pending,\n running,\n completed,\n failed,\n }\n}";
58+
expect(formatter.formatWitContent(input, options)).toBe(expected);
59+
});
60+
61+
it("formats type aliases", () => {
62+
const input = "package foo:bar;\n\ninterface test{\ntype my-string=string;\ntype my-list=list<u32>;\n}";
63+
const expected =
64+
"package foo:bar;\n\ninterface test {\n type my-string = string;\n type my-list = list<u32>;\n}";
65+
expect(formatter.formatWitContent(input, options)).toBe(expected);
66+
});
67+
68+
it("formats use statements", () => {
69+
const input =
70+
"package foo:bar;\n\ninterface test{\nuse other:interface/types.{my-type};\nuse another as alias;\n}";
71+
const expected =
72+
"package foo:bar;\n\ninterface test {\n use other:interface/types.{my-type};\n use another as alias;\n}";
73+
expect(formatter.formatWitContent(input, options)).toBe(expected);
74+
});
75+
76+
it("handles empty document", () => {
77+
expect(formatter.formatWitContent("", options)).toBe("");
78+
});
79+
80+
it("is idempotent on already formatted input", () => {
81+
const input =
82+
"package foo:bar;\n\ninterface test {\n f1: func();\n f2: func(a: u32) -> string;\n}\n\nworld test-world {\n import test;\n export test;\n}";
83+
expect(formatter.formatWitContent(input, options)).toBe(input);
84+
});
85+
86+
it("tuple type alias multiline indentation", () => {
87+
const input = "type load-store-all-sizes = future<tuple<\nstring,\nu8,\n>>;";
88+
const expected = "type load-store-all-sizes = future<tuple<\n string,\n u8,\n>>;";
89+
expect(formatter.formatWitContent(input, options)).toBe(expected);
90+
});
91+
92+
it("function params multiline indentation", () => {
93+
const input = "interface x {\n foo: func(\n a: u8,\n b: u16,\n );\n}";
94+
const expected = "interface x {\n foo: func(\n a: u8,\n b: u16,\n );\n}";
95+
expect(formatter.formatWitContent(input, options)).toBe(expected);
96+
});
97+
});

0 commit comments

Comments
 (0)