Repository: tact-lang/tact

Issue:
Support directory formatting
```
tact-fmt ./src/stdlib
```


Discussion:

Solution patch:
diff --git a/src/cli/tact-fmt/__snapshots__/e2e.spec.ts.snap b/src/cli/tact-fmt/__snapshots__/e2e.spec.ts.snap
index 00a75bf6..d90d19f0 100644
--- a/src/cli/tact-fmt/__snapshots__/e2e.spec.ts.snap
+++ b/src/cli/tact-fmt/__snapshots__/e2e.spec.ts.snap
@@ -16,15 +16,6 @@ exports[`tact-fmt foo.tact Default run 1`] = `
 `;
 
 exports[`tact-fmt foo.tact Default run with write to file 1`] = `
-{
-  "code": 0,
-  "kind": "exited",
-  "stderr": "",
-  "stdout": "",
-}
-`;
-
-exports[`tact-fmt foo.tact Default run with write to file 2`] = `
 "contract Test {
     get fun greeting(): String {
         return "hello world";
@@ -33,11 +24,26 @@ exports[`tact-fmt foo.tact Default run with write to file 2`] = `
 "
 `;
 
+exports[`tact-fmt foo.tact Run on directory 1`] = `
+"fun foo1() {}
+"
+`;
+
+exports[`tact-fmt foo.tact Run on directory 2`] = `
+"fun foo2() {}
+"
+`;
+
+exports[`tact-fmt foo.tact Run on directory 3`] = `
+"fun foo3() {}
+"
+`;
+
 exports[`tact-fmt foo.tact With syntax error 1`] = `
 {
   "code": 1,
   "kind": "exited",
-  "stderr": "src/cli/tact-fmt/output/contact.tact:4:33: Expected "!!", "!=", "%", "&", "&&", "(", ")", "*", "+", ",", "-", ".", "/", "<", "<<", "<=", "==", ">", ">=", ">>", "?", "^", "|", or "||"
+  "stderr": "Cannot format file src/cli/tact-fmt/output/contact.tact: src/cli/tact-fmt/output/contact.tact:4:33: Expected "!!", "!=", "%", "&", "&&", "(", ")", "*", "+", ",", "-", ".", "/", "<", "<<", "<=", "==", ">", ">=", ">>", "?", "^", "|", or "||"
   3 |     get fun greeting(): String {
 > 4 |         return foo("hello world";
                                       ^
diff --git a/src/cli/tact-fmt/e2e.spec.ts b/src/cli/tact-fmt/e2e.spec.ts
index ab75fa2a..3a20c6b9 100644
--- a/src/cli/tact-fmt/e2e.spec.ts
+++ b/src/cli/tact-fmt/e2e.spec.ts
@@ -1,6 +1,6 @@
 import { join, normalize } from "path";
 import { runCommand } from "@/cli/test-util.build";
-import { readFileSync, writeFileSync } from "fs";
+import { readFileSync, rmSync, writeFileSync } from "fs";
 import { mkdir } from "fs/promises";
 
 // disable tests on windows
@@ -25,6 +25,14 @@ contract Test {
 }
 `;
 
+const badContract = `
+contract /*comment*/ Test {
+    get fun greeting(): String {
+        return "hello world";
+    }
+}
+`;
+
 const contractWithSyntaxError = `
 contract Test {
     get fun greeting(): String {
@@ -36,7 +44,7 @@ contract Test {
 describe("tact-fmt foo.tact", () => {
     testExceptWindows("Exits with correct code", async () => {
         await mkdir(outputDir, { recursive: true });
-        const file = join(outputDir, "contact.tact");
+        const file = join(outputDir, "contract.tact");
         writeFileSync(file, goodContract);
         const result = await tactFmt(file);
         expect(result).toMatchObject({ kind: "exited", code: 0 });
@@ -44,7 +52,7 @@ describe("tact-fmt foo.tact", () => {
 
     testExceptWindows("Default run", async () => {
         await mkdir(outputDir, { recursive: true });
-        const file = join(outputDir, "contact.tact");
+        const file = join(outputDir, "contract.tact");
         writeFileSync(file, goodContract);
         const result = await tactFmt(file);
         expect(result).toMatchSnapshot();
@@ -52,13 +60,53 @@ describe("tact-fmt foo.tact", () => {
 
     testExceptWindows("Default run with write to file", async () => {
         await mkdir(outputDir, { recursive: true });
-        const file = join(outputDir, "contact.tact");
+        const file = join(outputDir, "contract.tact");
         writeFileSync(file, goodContract);
-        const result = await tactFmt(file, "-w");
-        expect(result).toMatchSnapshot();
+        await tactFmt(file, "-w");
 
         const newContent = readFileSync(file, "utf8");
         expect(newContent).toMatchSnapshot();
+
+        rmSync(file);
+    });
+
+    testExceptWindows("Run on directory", async () => {
+        const dir = outputDir;
+        const innerDir = join(dir, "inner");
+        const innerInnerDir = join(innerDir, "inner-2");
+
+        await mkdir(dir, { recursive: true });
+        await mkdir(innerDir, { recursive: true });
+        await mkdir(innerInnerDir, { recursive: true });
+
+        // inner
+        //   file1.tact
+        //   inner-2
+        //      file2.tact
+        //      file3.tact
+        const file1 = join(innerDir, "file1.tact");
+        const file2 = join(innerInnerDir, "file2.tact");
+        const file3 = join(innerInnerDir, "file3.tact");
+
+        writeFileSync(file1, "fun foo1() {   }");
+        writeFileSync(file2, "fun foo2() {  }");
+        writeFileSync(file3, "fun foo3() {     }");
+
+        await tactFmt(innerDir, "-w");
+
+        expect(readFileSync(file1, "utf8")).toMatchSnapshot();
+        expect(readFileSync(file2, "utf8")).toMatchSnapshot();
+        expect(readFileSync(file3, "utf8")).toMatchSnapshot();
+
+        rmSync(innerDir, { recursive: true });
+    });
+
+    testExceptWindows("With error", async () => {
+        await mkdir(outputDir, { recursive: true });
+        const file = join(outputDir, "contract.tact");
+        writeFileSync(file, badContract);
+        const result = await tactFmt(file);
+        expect(result).toMatchObject({ kind: "exited", code: 1 });
     });
 
     testExceptWindows("With syntax error", async () => {
diff --git a/src/cli/tact-fmt/index.ts b/src/cli/tact-fmt/index.ts
index 655c011f..f72c21e4 100644
--- a/src/cli/tact-fmt/index.ts
+++ b/src/cli/tact-fmt/index.ts
@@ -6,7 +6,10 @@ import { showCommit } from "@/cli/version";
 import { FormatterErrors } from "@/cli/tact-fmt/error-schema";
 import * as fs from "fs";
 import { formatCode } from "@/fmt/fmt";
-import { writeFileSync } from "fs";
+import path, { join } from "path";
+import { getAnsiMarkup, isColorSupported } from "@/cli/colors";
+import { glob } from "glob";
+import { cwd } from "process";
 
 const fmtVersion = "0.0.1";
 
@@ -52,7 +55,7 @@ const ArgSchema = (Parser: ArgParser) => {
 const showHelp = () => {
     console.log(`
     Usage
-      $ tact-fmt [...flags] TACT-FILE
+      $ tact-fmt [...flags] <file> or <directory>
 
     Flags
       -w, --write                 Write result to same file
@@ -61,11 +64,67 @@ const showHelp = () => {
 
     Examples
       $ tact-fmt --version
-      ${fmtVersion}`);
+      ${fmtVersion}
+
+      $ tact-fmt file.tact
+      Format and output the result to stdout
+
+      $ tact-fmt -w file.tact
+      Format and rewrite file.tact
+
+      $ tact-fmt -w ./sources
+      Format and rewrite all Tact files in ./sources`);
 };
 
 type Args = ArgConsumer<GetParserResult<ReturnType<typeof ArgSchema>>>;
 
+const markup = getAnsiMarkup(Boolean(isColorSupported()));
+
+function formatFile(filepath: string, write: boolean): boolean | undefined {
+    const content = readFileOrFail(filepath);
+    if (typeof content === "undefined") return undefined;
+
+    const [res, time] = measureTime(() => formatCode(filepath, content));
+    if (res.$ === "FormatCodeError") {
+        console.error(
+            `Cannot format file ${path.relative(cwd(), filepath)}:`,
+            res.message,
+        );
+        return undefined;
+    }
+
+    const alreadyFormatted = content === res.code;
+
+    if (write) {
+        console.log(
+            markup.gray(path.basename(filepath)),
+            `${time.toFixed(0)}ms`,
+            status(content, res.code),
+        );
+        fs.writeFileSync(filepath, res.code);
+
+        return alreadyFormatted;
+    } else {
+        console.log(res.code);
+    }
+    return alreadyFormatted;
+}
+
+function status(before: string, after: string) {
+    if (before !== after) {
+        return "(reformatted)";
+    }
+    return "(unchanged)";
+}
+
+function measureTime<T>(fn: () => T): [T, number] {
+    const startTime = performance.now();
+    const result = fn();
+    const endTime = performance.now();
+    const time = endTime - startTime;
+    return [result, time];
+}
+
 const parseArgs = (Errors: FormatterErrors, Args: Args) => {
     if (Args.single("help")) {
         if (noUnknownParams(Errors, Args)) {
@@ -84,17 +143,29 @@ const parseArgs = (Errors: FormatterErrors, Args: Args) => {
 
     const filePath = Args.single("immediate");
     if (filePath) {
-        const fileContent = fs.readFileSync(filePath, "utf8");
-        const res = formatCode(filePath, fileContent);
-        if (res.$ === "FormatCodeError") {
-            console.error(res.message);
-            process.exit(1);
+        const write = Args.single("write") ?? false;
+
+        if (!fs.statSync(filePath).isFile()) {
+            const files = globSync(["**/*.tact"], {
+                cwd: filePath,
+            });
+
+            let someFileCannotBeFormatted = false;
+            for (const file of files) {
+                const res = formatFile(join(filePath, file), write);
+                if (typeof res === "undefined") {
+                    someFileCannotBeFormatted = true;
+                }
+            }
+            if (someFileCannotBeFormatted) {
+                process.exit(1);
+            }
+            return;
         }
 
-        if (Args.single("write")) {
-            writeFileSync(filePath, res.code);
-        } else {
-            console.log(res.code);
+        const res = formatFile(filePath, write);
+        if (typeof res === "undefined") {
+            process.exit(1);
         }
         return;
     }
@@ -117,3 +188,17 @@ const noUnknownParams = (Errors: FormatterErrors, Args: Args): boolean => {
     showHelp();
     return false;
 };
+
+function readFileOrFail(filePath: string): string | undefined {
+    try {
+        return fs.readFileSync(filePath, "utf8");
+    } catch (e) {
+        const error = e as Error;
+        console.error(`Cannot read file: ${error.message}`);
+        return undefined;
+    }
+}
+
+const globSync = (globs: string[], options: { cwd: string }) => {
+    return globs.flatMap((g) => glob.sync(g, options));
+};
diff --git a/src/fmt/formatter/format-expressions.ts b/src/fmt/formatter/format-expressions.ts
index 76635377..404e5eae 100644
--- a/src/fmt/formatter/format-expressions.ts
+++ b/src/fmt/formatter/format-expressions.ts
@@ -442,6 +442,7 @@ const formatSuffix: FormatRule = (code, node) => {
     // foo.bar()
     //        ^^
     const firstSuffix = suffixesList.at(0);
+    const secondSuffix = suffixesList.at(1);
 
     // first call suffix attached to first expression
     const firstSuffixIsCallOrNotNull =
@@ -501,6 +502,14 @@ const formatSuffix: FormatRule = (code, node) => {
 
     if (firstSuffixIsCallOrNotNull) {
         formatExpression(code, firstSuffix);
+
+        if (
+            secondSuffix &&
+            secondSuffix.$ === "node" &&
+            secondSuffix.type === "SuffixUnboxNotNull"
+        ) {
+            formatExpression(code, secondSuffix);
+        }
     }
 
     const shouldBeMultiline =



Test patch:
diff --git a/src/fmt/test/formatter-test.spec.ts b/src/fmt/test/formatter-test.spec.ts
index 3c45677a..67af7cdb 100644
--- a/src/fmt/test/formatter-test.spec.ts
+++ b/src/fmt/test/formatter-test.spec.ts
@@ -1356,6 +1356,15 @@ describe("should format", () => {
             `),
             );
 
+            it(
+                "unbox not null 2",
+                intact(`
+                fun foo() {
+                    let cell: Cell = getConfigParam(0)!!;
+                }
+            `),
+            );
+
             it(
                 "simple call chain",
                 intact(`
@@ -2496,6 +2505,16 @@ describe("should format", () => {
         `),
         );
 
+        // TODO:
+        // it(
+        //     "contract with comments between parameters and body",
+        //     intact(`
+        //     contract Foo(
+        //         value: Int,
+        //     ) /*comment*/ {}
+        // `),
+        // );
+
         it(
             "contract with single field",
             intact(`