aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-05 08:09:00 +0000
committerFuwn <[email protected]>2026-02-05 08:09:00 +0000
commite240a3468d0fa2afe427430679b3651e8a79b870 (patch)
tree958ee121d80fa414d4513906975f875d53a1c2fe
parentrefactor(formatter): Replace rewrite logic with engine (diff)
downloadiku-e240a3468d0fa2afe427430679b3651e8a79b870.tar.xz
iku-e240a3468d0fa2afe427430679b3651e8a79b870.zip
feat(adapter): Add EcmaScript adapter for JS/TS/JSX/TSX formatting
-rw-r--r--adapter_ecmascript.go238
-rw-r--r--adapter_ecmascript_test.go462
2 files changed, 700 insertions, 0 deletions
diff --git a/adapter_ecmascript.go b/adapter_ecmascript.go
new file mode 100644
index 0000000..9e0b66d
--- /dev/null
+++ b/adapter_ecmascript.go
@@ -0,0 +1,238 @@
+package main
+
+import (
+ "github.com/Fuwn/iku/engine"
+ "strings"
+)
+
+type EcmaScriptAdapter struct{}
+
+func (a *EcmaScriptAdapter) Analyze(source []byte) ([]byte, []engine.LineEvent, error) {
+ sourceLines := strings.Split(string(source), "\n")
+ events := make([]engine.LineEvent, len(sourceLines))
+ insideTemplateString := false
+ insideBlockComment := false
+ previousEndedWithContinuation := false
+
+ for lineIndex, currentLine := range sourceLines {
+ backtickCount := countRawStringDelimiters(currentLine)
+ wasInsideTemplateString := insideTemplateString
+
+ if backtickCount%2 == 1 {
+ insideTemplateString = !insideTemplateString
+ }
+
+ event := engine.NewLineEvent(currentLine)
+
+ if wasInsideTemplateString {
+ event.InRawString = true
+ events[lineIndex] = event
+
+ continue
+ }
+
+ if event.IsBlank {
+ events[lineIndex] = event
+
+ continue
+ }
+
+ trimmedContent := event.TrimmedContent
+
+ if insideBlockComment {
+ event.IsCommentOnly = true
+
+ if strings.Contains(trimmedContent, "*/") {
+ insideBlockComment = false
+ }
+
+ events[lineIndex] = event
+ previousEndedWithContinuation = false
+
+ continue
+ }
+
+ if strings.HasPrefix(trimmedContent, "/*") {
+ event.IsCommentOnly = true
+
+ if !strings.Contains(trimmedContent, "*/") {
+ insideBlockComment = true
+ }
+
+ events[lineIndex] = event
+ previousEndedWithContinuation = false
+
+ continue
+ }
+
+ event.IsClosingBrace = isClosingBrace(currentLine)
+ event.IsOpeningBrace = isOpeningBrace(currentLine)
+ event.IsCaseLabel = isCaseLabel(currentLine)
+ event.IsCommentOnly = isCommentOnly(currentLine)
+
+ if event.IsCommentOnly {
+ events[lineIndex] = event
+
+ continue
+ }
+
+ isContinuationLine := previousEndedWithContinuation ||
+ strings.HasPrefix(trimmedContent, ".") ||
+ strings.HasPrefix(trimmedContent, "?.") ||
+ strings.HasPrefix(trimmedContent, "]")
+ previousEndedWithContinuation = ecmaScriptLineEndsContinuation(trimmedContent)
+
+ if isClosingCurlyBrace(currentLine) {
+ event.HasASTInfo = true
+ event.IsScoped = true
+ event.IsTopLevel = ecmaScriptLineIsTopLevel(currentLine)
+ events[lineIndex] = event
+
+ continue
+ }
+
+ if isContinuationLine {
+ events[lineIndex] = event
+
+ continue
+ }
+
+ statementType, isScoped := classifyEcmaScriptStatement(trimmedContent)
+
+ if statementType != "" {
+ event.HasASTInfo = true
+ event.StatementType = statementType
+ event.IsScoped = isScoped
+ event.IsTopLevel = ecmaScriptLineIsTopLevel(currentLine)
+ event.IsStartLine = true
+ } else {
+ event.HasASTInfo = true
+ event.StatementType = "expression"
+ event.IsTopLevel = ecmaScriptLineIsTopLevel(currentLine)
+ }
+
+ events[lineIndex] = event
+ }
+
+ return source, events, nil
+}
+
+func classifyEcmaScriptStatement(trimmedLine string) (string, bool) {
+ classified := trimmedLine
+
+ if strings.HasPrefix(classified, "export default ") {
+ classified = classified[15:]
+ } else if strings.HasPrefix(classified, "export ") {
+ classified = classified[7:]
+ }
+
+ if strings.HasPrefix(classified, "async ") {
+ classified = classified[6:]
+ }
+
+ if strings.HasPrefix(classified, "declare ") {
+ classified = classified[8:]
+ }
+
+ switch {
+ case ecmaScriptStatementHasPrefix(classified, "function"):
+ return "function", true
+ case ecmaScriptStatementHasPrefix(classified, "class"):
+ return "class", true
+ case ecmaScriptStatementHasPrefix(classified, "if"):
+ return "if", true
+ case ecmaScriptStatementHasPrefix(classified, "else"):
+ return "if", true
+ case ecmaScriptStatementHasPrefix(classified, "for"):
+ return "for", true
+ case ecmaScriptStatementHasPrefix(classified, "while"):
+ return "while", true
+ case ecmaScriptStatementHasPrefix(classified, "do"):
+ return "do", true
+ case ecmaScriptStatementHasPrefix(classified, "switch"):
+ return "switch", true
+ case ecmaScriptStatementHasPrefix(classified, "try"):
+ return "try", true
+ case ecmaScriptStatementHasPrefix(classified, "interface"):
+ return "interface", true
+ case ecmaScriptStatementHasPrefix(classified, "enum"):
+ return "enum", true
+ case ecmaScriptStatementHasPrefix(classified, "namespace"):
+ return "namespace", true
+ case ecmaScriptStatementHasPrefix(classified, "const"):
+ return "const", false
+ case ecmaScriptStatementHasPrefix(classified, "let"):
+ return "let", false
+ case ecmaScriptStatementHasPrefix(classified, "var"):
+ return "var", false
+ case ecmaScriptStatementHasPrefix(classified, "import"):
+ return "import", false
+ case ecmaScriptStatementHasPrefix(classified, "type"):
+ return "type", false
+ case ecmaScriptStatementHasPrefix(classified, "return"):
+ return "return", false
+ case ecmaScriptStatementHasPrefix(classified, "throw"):
+ return "throw", false
+ case ecmaScriptStatementHasPrefix(classified, "await"):
+ return "await", false
+ case ecmaScriptStatementHasPrefix(classified, "yield"):
+ return "yield", false
+ }
+
+ return "", false
+}
+
+func ecmaScriptStatementHasPrefix(line string, keyword string) bool {
+ if !strings.HasPrefix(line, keyword) {
+ return false
+ }
+
+ if len(line) == len(keyword) {
+ return true
+ }
+
+ nextCharacter := line[len(keyword)]
+
+ return nextCharacter == ' ' || nextCharacter == '(' || nextCharacter == '{' ||
+ nextCharacter == ';' || nextCharacter == '<' || nextCharacter == '\t'
+}
+
+func ecmaScriptLineIsTopLevel(sourceLine string) bool {
+ if len(sourceLine) == 0 {
+ return false
+ }
+
+ return !isWhitespace(sourceLine[0])
+}
+
+func ecmaScriptLineEndsContinuation(trimmedLine string) bool {
+ if len(trimmedLine) == 0 {
+ return false
+ }
+
+ lastCharacter := trimmedLine[len(trimmedLine)-1]
+
+ if lastCharacter == ',' || lastCharacter == '[' || lastCharacter == '(' {
+ return true
+ }
+
+ if lastCharacter == '>' && strings.HasPrefix(trimmedLine, "<") {
+ return true
+ }
+
+ return false
+}
+
+func isClosingCurlyBrace(sourceLine string) bool {
+ for characterIndex := 0; characterIndex < len(sourceLine); characterIndex++ {
+ character := sourceLine[characterIndex]
+
+ if isWhitespace(character) {
+ continue
+ }
+
+ return character == '}'
+ }
+
+ return false
+}
diff --git a/adapter_ecmascript_test.go b/adapter_ecmascript_test.go
new file mode 100644
index 0000000..ee739ac
--- /dev/null
+++ b/adapter_ecmascript_test.go
@@ -0,0 +1,462 @@
+package main
+
+import (
+ "github.com/Fuwn/iku/engine"
+ "testing"
+)
+
+type ecmaScriptTestCase struct {
+ name string
+ source string
+ expected string
+}
+
+var ecmaScriptTestCases = []ecmaScriptTestCase{
+ {
+ name: "blank lines around if block",
+ source: `const x = 1;
+if (x > 0) {
+ doSomething();
+}
+const y = 2;
+`,
+ expected: `const x = 1;
+
+if (x > 0) {
+ doSomething();
+}
+
+const y = 2;
+`,
+ },
+ {
+ name: "blank lines around for loop",
+ source: `const items = [1, 2, 3];
+for (const item of items) {
+ process(item);
+}
+const result = done();
+`,
+ expected: `const items = [1, 2, 3];
+
+for (const item of items) {
+ process(item);
+}
+
+const result = done();
+`,
+ },
+ {
+ name: "blank lines between different top-level types",
+ source: `import { foo } from "bar";
+const x = 1;
+function main() {
+ return x;
+}
+class Foo {
+ bar() {}
+}
+`,
+ expected: `import { foo } from "bar";
+
+const x = 1;
+
+function main() {
+ return x;
+}
+
+class Foo {
+ bar() {}
+}
+`,
+ },
+ {
+ name: "no blank between same type",
+ source: `const x = 1;
+const y = 2;
+const z = 3;
+`,
+ expected: `const x = 1;
+const y = 2;
+const z = 3;
+`,
+ },
+ {
+ name: "consecutive imports stay together",
+ source: `import { a } from "a";
+import { b } from "b";
+import { c } from "c";
+`,
+ expected: `import { a } from "a";
+import { b } from "b";
+import { c } from "c";
+`,
+ },
+ {
+ name: "switch with case clauses",
+ source: `const x = getValue();
+switch (x) {
+case "a":
+ handleA();
+ break;
+case "b":
+ handleB();
+ break;
+}
+cleanup();
+`,
+ expected: `const x = getValue();
+
+switch (x) {
+case "a":
+ handleA();
+ break;
+case "b":
+ handleB();
+ break;
+}
+
+cleanup();
+`,
+ },
+ {
+ name: "try catch finally",
+ source: `setup();
+try {
+ riskyOperation();
+} catch (error) {
+ handleError(error);
+} finally {
+ cleanup();
+}
+done();
+`,
+ expected: `setup();
+
+try {
+ riskyOperation();
+} catch (error) {
+ handleError(error);
+} finally {
+ cleanup();
+}
+
+done();
+`,
+ },
+ {
+ name: "consecutive scoped blocks",
+ source: `if (a) {
+ doA();
+}
+if (b) {
+ doB();
+}
+`,
+ expected: `if (a) {
+ doA();
+}
+
+if (b) {
+ doB();
+}
+`,
+ },
+ {
+ name: "export prefixes",
+ source: `export const x = 1;
+export function foo() {
+ return x;
+}
+export class Bar {
+ baz() {}
+}
+`,
+ expected: `export const x = 1;
+
+export function foo() {
+ return x;
+}
+
+export class Bar {
+ baz() {}
+}
+`,
+ },
+ {
+ name: "async function",
+ source: `const data = prepare();
+async function fetchData() {
+ return await fetch(url);
+}
+const result = process();
+`,
+ expected: `const data = prepare();
+
+async function fetchData() {
+ return await fetch(url);
+}
+
+const result = process();
+`,
+ },
+ {
+ name: "typescript interface and type",
+ source: `type ID = string;
+interface User {
+ name: string;
+ id: ID;
+}
+const defaultUser: User = { name: "", id: "" };
+`,
+ expected: `type ID = string;
+
+interface User {
+ name: string;
+ id: ID;
+}
+
+const defaultUser: User = { name: "", id: "" };
+`,
+ },
+ {
+ name: "multi-line function call preserved",
+ source: `const result = someFunction(
+ longArgument,
+ otherArgument,
+);
+const next = 1;
+`,
+ expected: `const result = someFunction(
+ longArgument,
+ otherArgument,
+);
+const next = 1;
+`,
+ },
+ {
+ name: "method chaining preserved",
+ source: `const result = someArray
+ .filter(x => x > 0)
+ .map(x => x * 2);
+const next = 1;
+`,
+ expected: `const result = someArray
+ .filter(x => x > 0)
+ .map(x => x * 2);
+const next = 1;
+`,
+ },
+ {
+ name: "block comment passthrough",
+ source: `/*
+ * This is a block comment
+ */
+const x = 1;
+`,
+ expected: `/*
+ * This is a block comment
+ */
+const x = 1;
+`,
+ },
+ {
+ name: "collapses extra blank lines",
+ source: `const x = 1;
+
+
+const y = 2;
+`,
+ expected: `const x = 1;
+const y = 2;
+`,
+ },
+ {
+ name: "while loop",
+ source: `let count = 0;
+while (count < 10) {
+ count++;
+}
+const done = true;
+`,
+ expected: `let count = 0;
+
+while (count < 10) {
+ count++;
+}
+
+const done = true;
+`,
+ },
+ {
+ name: "nested scopes",
+ source: `function main() {
+ const x = 1;
+ if (x > 0) {
+ for (let i = 0; i < x; i++) {
+ process(i);
+ }
+ cleanup();
+ }
+ return x;
+}
+`,
+ expected: `function main() {
+ const x = 1;
+
+ if (x > 0) {
+ for (let i = 0; i < x; i++) {
+ process(i);
+ }
+
+ cleanup();
+ }
+
+ return x;
+}
+`,
+ },
+ {
+ name: "template literal passthrough",
+ source: "const x = `\nhello\n\nworld\n`;\nconst y = 1;\n",
+ expected: "const x = `\nhello\n\nworld\n`;\nconst y = 1;\n",
+ },
+ {
+ name: "jsx expressions",
+ source: `function Component() {
+ const data = useMemo();
+ if (!data) {
+ return null;
+ }
+ return (
+ <div>
+ <span>{data}</span>
+ </div>
+ );
+}
+`,
+ expected: `function Component() {
+ const data = useMemo();
+
+ if (!data) {
+ return null;
+ }
+
+ return (
+ <div>
+ <span>{data}</span>
+ </div>
+ );
+}
+`,
+ },
+ {
+ name: "expression after scoped block",
+ source: `function main() {
+ if (x) {
+ return;
+ }
+ doSomething();
+}
+`,
+ expected: `function main() {
+ if (x) {
+ return;
+ }
+
+ doSomething();
+}
+`,
+ },
+ {
+ name: "enum declaration",
+ source: `type Color = string;
+enum Direction {
+ Up,
+ Down,
+}
+const x = Direction.Up;
+`,
+ expected: `type Color = string;
+
+enum Direction {
+ Up,
+ Down,
+}
+
+const x = Direction.Up;
+`,
+ },
+}
+
+func TestEcmaScriptAdapter(t *testing.T) {
+ for _, testCase := range ecmaScriptTestCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ adapter := &EcmaScriptAdapter{}
+ _, events, err := adapter.Analyze([]byte(testCase.source))
+
+ if err != nil {
+ t.Fatalf("adapter error: %v", err)
+ }
+
+ formattingEngine := &engine.Engine{CommentMode: engine.CommentsFollow}
+ result := formattingEngine.FormatToString(events)
+
+ if result != testCase.expected {
+ t.Errorf("mismatch\ngot:\n%s\nwant:\n%s", result, testCase.expected)
+ }
+ })
+ }
+}
+
+func TestClassifyEcmaScriptStatement(t *testing.T) {
+ cases := []struct {
+ input string
+ expectedType string
+ expectedScope bool
+ }{
+ {"function foo() {", "function", true},
+ {"async function foo() {", "function", true},
+ {"export function foo() {", "function", true},
+ {"export default function() {", "function", true},
+ {"class Foo {", "class", true},
+ {"export class Foo {", "class", true},
+ {"if (x) {", "if", true},
+ {"else if (y) {", "if", true},
+ {"else {", "if", true},
+ {"for (const x of items) {", "for", true},
+ {"while (true) {", "while", true},
+ {"do {", "do", true},
+ {"switch (x) {", "switch", true},
+ {"try {", "try", true},
+ {"interface Foo {", "interface", true},
+ {"enum Direction {", "enum", true},
+ {"namespace Foo {", "namespace", true},
+ {"const x = 1;", "const", false},
+ {"let x = 1;", "let", false},
+ {"var x = 1;", "var", false},
+ {"import { foo } from 'bar';", "import", false},
+ {"type Foo = string;", "type", false},
+ {"return x;", "return", false},
+ {"return;", "return", false},
+ {"throw new Error();", "throw", false},
+ {"await fetch(url);", "await", false},
+ {"yield value;", "yield", false},
+ {"export const x = 1;", "const", false},
+ {"export default class Foo {", "class", true},
+ {"declare const x: number;", "const", false},
+ {"declare function foo(): void;", "function", true},
+ {"foo();", "", false},
+ {"x = 1;", "", false},
+ {"", "", false},
+ }
+
+ for _, testCase := range cases {
+ statementType, isScoped := classifyEcmaScriptStatement(testCase.input)
+
+ if statementType != testCase.expectedType || isScoped != testCase.expectedScope {
+ t.Errorf("classifyEcmaScriptStatement(%q) = (%q, %v), want (%q, %v)",
+ testCase.input, statementType, isScoped, testCase.expectedType, testCase.expectedScope)
+ }
+ }
+}