diff options
| author | Fuwn <[email protected]> | 2026-02-05 07:14:33 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-05 07:14:33 +0000 |
| commit | 30fb266c13d32f838b1c5ee5be6036fc8eed4430 (patch) | |
| tree | c4228b7f2a0902e5d0ab88e31946b12f85b68255 | |
| parent | docs(README): Update "How It Works" source (diff) | |
| download | iku-30fb266c13d32f838b1c5ee5be6036fc8eed4430.tar.xz iku-30fb266c13d32f838b1c5ee5be6036fc8eed4430.zip | |
feat(engine): Add language-agnostic formatting engine with Go adapter
| -rw-r--r-- | adapter_go.go | 89 | ||||
| -rw-r--r-- | engine/engine.go | 130 | ||||
| -rw-r--r-- | engine/engine_test.go | 200 | ||||
| -rw-r--r-- | engine/event.go | 30 | ||||
| -rw-r--r-- | parity_test.go | 308 |
5 files changed, 757 insertions, 0 deletions
diff --git a/adapter_go.go b/adapter_go.go new file mode 100644 index 0000000..59096bb --- /dev/null +++ b/adapter_go.go @@ -0,0 +1,89 @@ +package main + +import ( + "github.com/Fuwn/iku/engine" + "go/format" + "go/parser" + "go/token" + "strings" +) + +type GoAdapter struct{} + +func (a *GoAdapter) Analyze(source []byte) ([]byte, []engine.LineEvent, error) { + formattedSource, err := format.Source(source) + + if err != nil { + return nil, nil, err + } + + tokenFileSet := token.NewFileSet() + parsedFile, err := parser.ParseFile(tokenFileSet, "", formattedSource, parser.ParseComments) + + if err != nil { + return nil, nil, err + } + + formatter := &Formatter{} + lineInformationMap := formatter.buildLineInfo(tokenFileSet, parsedFile) + sourceLines := strings.Split(string(formattedSource), "\n") + events := make([]engine.LineEvent, len(sourceLines)) + insideRawString := false + + for lineIndex, currentLine := range sourceLines { + backtickCount := countRawStringDelimiters(currentLine) + wasInsideRawString := insideRawString + + if backtickCount%2 == 1 { + insideRawString = !insideRawString + } + + event := engine.NewLineEvent(currentLine) + + if wasInsideRawString { + event.InRawString = true + events[lineIndex] = event + + continue + } + + if event.IsBlank { + events[lineIndex] = event + + continue + } + + lineNumber := lineIndex + 1 + currentInformation := lineInformationMap[lineNumber] + + if currentInformation != nil { + event.HasASTInfo = true + event.StatementType = currentInformation.statementType + event.IsTopLevel = currentInformation.isTopLevel + event.IsScoped = currentInformation.isScoped + event.IsStartLine = currentInformation.isStartLine + } + + event.IsClosingBrace = isClosingBrace(currentLine) + event.IsOpeningBrace = isOpeningBrace(currentLine) + event.IsCaseLabel = isCaseLabel(currentLine) + event.IsCommentOnly = isCommentOnly(currentLine) + event.IsPackageDecl = isPackageLine(event.TrimmedContent) + events[lineIndex] = event + } + + return formattedSource, events, nil +} + +func MapCommentMode(mode CommentMode) engine.CommentMode { + switch mode { + case CommentsFollow: + return engine.CommentsFollow + case CommentsPrecede: + return engine.CommentsPrecede + case CommentsStandalone: + return engine.CommentsStandalone + default: + return engine.CommentsFollow + } +} diff --git a/engine/engine.go b/engine/engine.go new file mode 100644 index 0000000..7a5399c --- /dev/null +++ b/engine/engine.go @@ -0,0 +1,130 @@ +package engine + +import "strings" + +type CommentMode int + +const ( + CommentsFollow CommentMode = iota + CommentsPrecede + CommentsStandalone +) + +type Engine struct { + CommentMode CommentMode +} + +func (e *Engine) Format(events []LineEvent) []string { + resultLines := make([]string, 0, len(events)) + previousWasOpenBrace := false + previousStatementType := "" + previousWasComment := false + previousWasTopLevel := false + previousWasScoped := false + + for eventIndex, event := range events { + if event.InRawString { + resultLines = append(resultLines, event.Content) + + continue + } + + if event.IsBlank { + continue + } + + currentStatementType := event.StatementType + + if event.IsPackageDecl { + currentStatementType = "package" + } + + needsBlankLine := false + currentIsTopLevel := event.HasASTInfo && event.IsTopLevel + currentIsScoped := event.HasASTInfo && event.IsScoped + + if len(resultLines) > 0 && !previousWasOpenBrace && !event.IsClosingBrace && !event.IsCaseLabel { + if currentIsTopLevel && previousWasTopLevel && currentStatementType != previousStatementType { + if !(e.CommentMode == CommentsFollow && previousWasComment) { + needsBlankLine = true + } + } else if event.HasASTInfo && (currentIsScoped || previousWasScoped) { + if !(e.CommentMode == CommentsFollow && previousWasComment) { + needsBlankLine = true + } + } else if currentStatementType != "" && previousStatementType != "" && currentStatementType != previousStatementType { + if !(e.CommentMode == CommentsFollow && previousWasComment) { + needsBlankLine = true + } + } + + if e.CommentMode == CommentsFollow && event.IsCommentOnly && !previousWasComment { + nextIndex := e.findNextNonComment(events, eventIndex+1) + + if nextIndex >= 0 { + next := events[nextIndex] + + if next.HasASTInfo { + nextIsTopLevel := next.IsTopLevel + nextIsScoped := next.IsScoped + + if nextIsTopLevel && previousWasTopLevel && next.StatementType != previousStatementType { + needsBlankLine = true + } else if nextIsScoped || previousWasScoped { + needsBlankLine = true + } else if next.StatementType != "" && previousStatementType != "" && next.StatementType != previousStatementType { + needsBlankLine = true + } + } + } + } + } + + if needsBlankLine { + resultLines = append(resultLines, "") + } + + resultLines = append(resultLines, event.Content) + previousWasOpenBrace = event.IsOpeningBrace || event.IsCaseLabel + previousWasComment = event.IsCommentOnly + + if event.HasASTInfo { + previousStatementType = event.StatementType + previousWasTopLevel = event.IsTopLevel + previousWasScoped = event.IsScoped + } else if currentStatementType != "" { + previousStatementType = currentStatementType + previousWasTopLevel = false + previousWasScoped = false + } + } + + return resultLines +} + +func (e *Engine) FormatToString(events []LineEvent) string { + lines := e.Format(events) + output := strings.Join(lines, "\n") + + if !strings.HasSuffix(output, "\n") { + output += "\n" + } + + return output +} + +func (e *Engine) findNextNonComment(events []LineEvent, startIndex int) int { + for eventIndex := startIndex; eventIndex < len(events); eventIndex++ { + if events[eventIndex].IsBlank { + continue + } + + if events[eventIndex].IsCommentOnly { + continue + } + + return eventIndex + } + + return -1 +} diff --git a/engine/engine_test.go b/engine/engine_test.go new file mode 100644 index 0000000..f961277 --- /dev/null +++ b/engine/engine_test.go @@ -0,0 +1,200 @@ +package engine + +import ( + "strings" + "testing" +) + +func formatResult(formattingEngine *Engine, events []LineEvent) string { + return strings.Join(formattingEngine.Format(events), "\n") +} + +func TestEngineCollapsesBlanks(t *testing.T) { + events := []LineEvent{ + {Content: "\tx := 1", TrimmedContent: "x := 1", HasASTInfo: true, StatementType: "*ast.AssignStmt", IsStartLine: true}, + {Content: "", TrimmedContent: "", IsBlank: true}, + {Content: "", TrimmedContent: "", IsBlank: true}, + {Content: "\ty := 2", TrimmedContent: "y := 2", HasASTInfo: true, StatementType: "*ast.AssignStmt", IsStartLine: true}, + } + formattingEngine := &Engine{CommentMode: CommentsFollow} + result := formatResult(formattingEngine, events) + + if result != "\tx := 1\n\ty := 2" { + t.Errorf("expected blanks collapsed, got:\n%s", result) + } +} + +func TestEngineScopeBoundary(t *testing.T) { + events := []LineEvent{ + {Content: "\tx := 1", TrimmedContent: "x := 1", HasASTInfo: true, StatementType: "*ast.AssignStmt"}, + {Content: "\tif x > 0 {", TrimmedContent: "if x > 0 {", HasASTInfo: true, StatementType: "*ast.IfStmt", IsScoped: true, IsStartLine: true, IsOpeningBrace: true}, + {Content: "\t\ty := 2", TrimmedContent: "y := 2", HasASTInfo: true, StatementType: "*ast.AssignStmt"}, + {Content: "\t}", TrimmedContent: "}", IsClosingBrace: true, HasASTInfo: true, StatementType: "*ast.IfStmt", IsScoped: true}, + {Content: "\tz := 3", TrimmedContent: "z := 3", HasASTInfo: true, StatementType: "*ast.AssignStmt"}, + } + formattingEngine := &Engine{CommentMode: CommentsFollow} + result := formatResult(formattingEngine, events) + expected := "\tx := 1\n\n\tif x > 0 {\n\t\ty := 2\n\t}\n\n\tz := 3" + + if result != expected { + t.Errorf("expected scope boundaries, got:\n%s\nwant:\n%s", result, expected) + } +} + +func TestEngineStatementTypeTransition(t *testing.T) { + events := []LineEvent{ + {Content: "\tx := 1", TrimmedContent: "x := 1", HasASTInfo: true, StatementType: "*ast.AssignStmt"}, + {Content: "\tvar a = 3", TrimmedContent: "var a = 3", HasASTInfo: true, StatementType: "var"}, + } + formattingEngine := &Engine{CommentMode: CommentsFollow} + result := formatResult(formattingEngine, events) + expected := "\tx := 1\n\n\tvar a = 3" + + if result != expected { + t.Errorf("expected blank between different types, got:\n%s\nwant:\n%s", result, expected) + } +} + +func TestEngineSuppressAfterOpenBrace(t *testing.T) { + events := []LineEvent{ + {Content: "func main() {", TrimmedContent: "func main() {", HasASTInfo: true, StatementType: "func", IsScoped: true, IsTopLevel: true, IsStartLine: true, IsOpeningBrace: true}, + {Content: "\tif true {", TrimmedContent: "if true {", HasASTInfo: true, StatementType: "*ast.IfStmt", IsScoped: true, IsStartLine: true, IsOpeningBrace: true}, + {Content: "\t\tx := 1", TrimmedContent: "x := 1", HasASTInfo: true, StatementType: "*ast.AssignStmt"}, + {Content: "\t}", TrimmedContent: "}", IsClosingBrace: true}, + } + formattingEngine := &Engine{CommentMode: CommentsFollow} + result := formatResult(formattingEngine, events) + expected := "func main() {\n\tif true {\n\t\tx := 1\n\t}" + + if result != expected { + t.Errorf("should not insert blank after open brace, got:\n%s\nwant:\n%s", result, expected) + } +} + +func TestEngineSuppressBeforeCloseBrace(t *testing.T) { + events := []LineEvent{ + {Content: "\tx := 1", TrimmedContent: "x := 1", HasASTInfo: true, StatementType: "*ast.AssignStmt", IsScoped: false}, + {Content: "}", TrimmedContent: "}", IsClosingBrace: true}, + } + formattingEngine := &Engine{CommentMode: CommentsFollow} + result := formatResult(formattingEngine, events) + expected := "\tx := 1\n}" + + if result != expected { + t.Errorf("should not insert blank before close brace, got:\n%s\nwant:\n%s", result, expected) + } +} + +func TestEngineSuppressCaseLabel(t *testing.T) { + events := []LineEvent{ + {Content: "\tcase 1:", TrimmedContent: "case 1:", HasASTInfo: true, StatementType: "*ast.AssignStmt", IsCaseLabel: true, IsOpeningBrace: false}, + {Content: "\t\tfoo()", TrimmedContent: "foo()", HasASTInfo: true, StatementType: "*ast.ExprStmt"}, + {Content: "\tcase 2:", TrimmedContent: "case 2:", HasASTInfo: true, StatementType: "*ast.AssignStmt", IsCaseLabel: true}, + } + formattingEngine := &Engine{CommentMode: CommentsFollow} + result := formatResult(formattingEngine, events) + expected := "\tcase 1:\n\t\tfoo()\n\tcase 2:" + + if result != expected { + t.Errorf("should not insert blank before case label, got:\n%s\nwant:\n%s", result, expected) + } +} + +func TestEngineRawStringPassthrough(t *testing.T) { + events := []LineEvent{ + {Content: "\tx := `", TrimmedContent: "x := `", HasASTInfo: true, StatementType: "*ast.AssignStmt"}, + {Content: "raw line 1", TrimmedContent: "raw line 1", InRawString: true}, + {Content: "", TrimmedContent: "", InRawString: true}, + {Content: "raw line 2`", TrimmedContent: "raw line 2`", InRawString: true}, + {Content: "\ty := 1", TrimmedContent: "y := 1", HasASTInfo: true, StatementType: "*ast.AssignStmt"}, + } + formattingEngine := &Engine{CommentMode: CommentsFollow} + result := formatResult(formattingEngine, events) + expected := "\tx := `\nraw line 1\n\nraw line 2`\n\ty := 1" + + if result != expected { + t.Errorf("raw strings should pass through unchanged, got:\n%s\nwant:\n%s", result, expected) + } +} + +func TestEngineTopLevelDifferentTypes(t *testing.T) { + events := []LineEvent{ + {Content: "type Foo struct {", TrimmedContent: "type Foo struct {", HasASTInfo: true, StatementType: "type", IsTopLevel: true, IsScoped: true, IsStartLine: true, IsOpeningBrace: true}, + {Content: "\tX int", TrimmedContent: "X int"}, + {Content: "}", TrimmedContent: "}", IsClosingBrace: true, HasASTInfo: true, StatementType: "type", IsTopLevel: true, IsScoped: true}, + {Content: "var x = 1", TrimmedContent: "var x = 1", HasASTInfo: true, StatementType: "var", IsTopLevel: true, IsStartLine: true}, + } + formattingEngine := &Engine{CommentMode: CommentsFollow} + result := formatResult(formattingEngine, events) + expected := "type Foo struct {\n\tX int\n}\n\nvar x = 1" + + if result != expected { + t.Errorf("expected blank between different top-level types, got:\n%s\nwant:\n%s", result, expected) + } +} + +func TestEngineCommentLookAhead(t *testing.T) { + events := []LineEvent{ + {Content: "\tx := 1", TrimmedContent: "x := 1", HasASTInfo: true, StatementType: "*ast.AssignStmt"}, + {Content: "\t// comment about if", TrimmedContent: "// comment about if", IsCommentOnly: true}, + {Content: "\tif true {", TrimmedContent: "if true {", HasASTInfo: true, StatementType: "*ast.IfStmt", IsScoped: true, IsStartLine: true, IsOpeningBrace: true}, + {Content: "\t\ty := 2", TrimmedContent: "y := 2", HasASTInfo: true, StatementType: "*ast.AssignStmt"}, + {Content: "\t}", TrimmedContent: "}", IsClosingBrace: true}, + } + formattingEngine := &Engine{CommentMode: CommentsFollow} + result := formatResult(formattingEngine, events) + expected := "\tx := 1\n\n\t// comment about if\n\tif true {\n\t\ty := 2\n\t}" + + if result != expected { + t.Errorf("comment should trigger look-ahead blank, got:\n%s\nwant:\n%s", result, expected) + } +} + +func TestEnginePackageDeclaration(t *testing.T) { + events := []LineEvent{ + {Content: "package main", TrimmedContent: "package main", IsPackageDecl: true}, + {Content: "", TrimmedContent: "", IsBlank: true}, + {Content: "func main() {", TrimmedContent: "func main() {", HasASTInfo: true, StatementType: "func", IsTopLevel: true, IsScoped: true, IsStartLine: true, IsOpeningBrace: true}, + {Content: "}", TrimmedContent: "}", IsClosingBrace: true, HasASTInfo: true, StatementType: "func", IsTopLevel: true, IsScoped: true}, + } + formattingEngine := &Engine{CommentMode: CommentsFollow} + result := formatResult(formattingEngine, events) + expected := "package main\n\nfunc main() {\n}" + + if result != expected { + t.Errorf("package should separate from func, got:\n%s\nwant:\n%s", result, expected) + } +} + +func TestEngineFormatToString(t *testing.T) { + events := []LineEvent{ + {Content: "package main", TrimmedContent: "package main", IsPackageDecl: true}, + } + formattingEngine := &Engine{CommentMode: CommentsFollow} + result := formattingEngine.FormatToString(events) + + if result != "package main\n" { + t.Errorf("FormatToString should end with newline, got: %q", result) + } +} + +func TestEngineFindNextNonComment(t *testing.T) { + events := []LineEvent{ + {Content: "x", TrimmedContent: "x"}, + {Content: "", TrimmedContent: "", IsBlank: true}, + {Content: "// comment", TrimmedContent: "// comment", IsCommentOnly: true}, + {Content: "y", TrimmedContent: "y"}, + } + formattingEngine := &Engine{} + index := formattingEngine.findNextNonComment(events, 1) + + if index != 3 { + t.Errorf("expected index 3, got %d", index) + } + + index = formattingEngine.findNextNonComment(events, 4) + + if index != -1 { + t.Errorf("expected -1 when past end, got %d", index) + } +} diff --git a/engine/event.go b/engine/event.go new file mode 100644 index 0000000..e9253e8 --- /dev/null +++ b/engine/event.go @@ -0,0 +1,30 @@ +package engine + +import "strings" + +type LineEvent struct { + Content string + TrimmedContent string + StatementType string + IsTopLevel bool + IsScoped bool + IsStartLine bool + HasASTInfo bool + IsClosingBrace bool + IsOpeningBrace bool + IsCaseLabel bool + IsCommentOnly bool + IsBlank bool + InRawString bool + IsPackageDecl bool +} + +func NewLineEvent(content string) LineEvent { + trimmed := strings.TrimSpace(content) + + return LineEvent{ + Content: content, + TrimmedContent: trimmed, + IsBlank: trimmed == "", + } +} diff --git a/parity_test.go b/parity_test.go new file mode 100644 index 0000000..af747f8 --- /dev/null +++ b/parity_test.go @@ -0,0 +1,308 @@ +package main + +import ( + "github.com/Fuwn/iku/engine" + "testing" +) + +type parityInput struct { + name string + source string +} + +var parityInputs = []parityInput{ + { + name: "extra blank lines collapsed", + source: `package main + +func main() { + x := 1 + + + y := 2 +} +`, + }, + { + name: "scoped statements", + source: `package main + +func main() { + x := 1 + if x > 0 { + y := 2 + } + z := 3 +} +`, + }, + { + name: "nested scopes", + source: `package main + +func main() { + if true { + x := 1 + if false { + y := 2 + } + z := 3 + } +} +`, + }, + { + name: "for loop", + source: `package main + +func main() { + x := 1 + for i := 0; i < 10; i++ { + y := i + } + z := 2 +} +`, + }, + { + name: "switch statement", + source: `package main + +func main() { + x := 1 + switch x { + case 1: + y := 2 + } + z := 3 +} +`, + }, + { + name: "multiple functions", + source: `package main + +func foo() { + x := 1 +} + + +func bar() { + y := 2 +} +`, + }, + { + name: "type struct before var", + source: `package main + +type Foo struct { + X int +} +var x = 1 +`, + }, + { + name: "different statement types", + source: `package main + +func main() { + x := 1 + y := 2 + var a = 3 + defer cleanup() + defer cleanup2() + go worker() + return +} +`, + }, + { + name: "consecutive ifs", + source: `package main + +func main() { + if err != nil { + return + } + if x > 0 { + y = 1 + } +} +`, + }, + { + name: "case clause with scoped statement", + source: `package main + +func main() { + switch x { + case 1: + foo() + if err != nil { + return + } + } +} +`, + }, + { + name: "defer inline func", + source: `package main + +func main() { + defer func() { _ = file.Close() }() + fileInfo, err := file.Stat() +} +`, + }, + { + name: "case clause assignments only", + source: `package main + +func main() { + switch x { + case "user": + roleStyle = UserStyle + contentStyle = ContentStyle + prefix = "You" + case "assistant": + roleStyle = AssistantStyle + } +} +`, + }, + { + name: "raw string literal", + source: "package main\n\nvar x = `\nline 1\n\nline 2\n`\nvar y = 1\n", + }, + { + name: "mixed top-level declarations", + source: `package main + +import "fmt" + +const x = 1 + +var y = 2 + +type Z struct{} + +func main() { + fmt.Println(x, y) +} +`, + }, + { + name: "empty function body", + source: `package main + +func main() { +} +`, + }, + { + name: "comment before scoped statement", + source: `package main + +func main() { + x := 1 + // this is a comment + if x > 0 { + y := 2 + } +} +`, + }, + { + name: "multiple blank lines between functions", + source: `package main + +func a() {} + + + +func b() {} + + + + +func c() {} +`, + }, + { + name: "select statement", + source: `package main + +func main() { + x := 1 + select { + case <-ch: + y := 2 + } + z := 3 +} +`, + }, + { + name: "range loop", + source: `package main + +func main() { + items := []int{1, 2, 3} + for _, item := range items { + _ = item + } + done := true +} +`, + }, + { + name: "interface declaration", + source: `package main + +type Reader interface { + Read(p []byte) (n int, err error) +} +var x = 1 +`, + }, +} + +func TestEngineParityWithFormatter(t *testing.T) { + for _, commentMode := range []CommentMode{CommentsFollow, CommentsPrecede, CommentsStandalone} { + for _, input := range parityInputs { + name := input.name + + switch commentMode { + case CommentsPrecede: + name += "/precede" + case CommentsStandalone: + name += "/standalone" + } + + t.Run(name, func(t *testing.T) { + formatter := &Formatter{CommentMode: commentMode} + oldResult, err := formatter.Format([]byte(input.source)) + + if err != nil { + t.Fatalf("old formatter error: %v", err) + } + + adapter := &GoAdapter{} + _, events, err := adapter.Analyze([]byte(input.source)) + + if err != nil { + t.Fatalf("adapter error: %v", err) + } + + formattingEngine := &engine.Engine{CommentMode: MapCommentMode(commentMode)} + newResult := formattingEngine.FormatToString(events) + + if string(oldResult) != newResult { + t.Errorf("parity mismatch\nold:\n%s\nnew:\n%s", oldResult, newResult) + } + }) + } + } +} |