From e240a3468d0fa2afe427430679b3651e8a79b870 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Thu, 5 Feb 2026 08:09:00 +0000 Subject: feat(adapter): Add EcmaScript adapter for JS/TS/JSX/TSX formatting --- adapter_ecmascript.go | 238 +++++++++++++++++++++++ adapter_ecmascript_test.go | 462 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 700 insertions(+) create mode 100644 adapter_ecmascript.go create mode 100644 adapter_ecmascript_test.go 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 ( +
+ {data} +
+ ); +} +`, + expected: `function Component() { + const data = useMemo(); + + if (!data) { + return null; + } + + return ( +
+ {data} +
+ ); +} +`, + }, + { + 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) + } + } +} -- cgit v1.2.3