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 /engine | |
| 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
Diffstat (limited to 'engine')
| -rw-r--r-- | engine/engine.go | 130 | ||||
| -rw-r--r-- | engine/engine_test.go | 200 | ||||
| -rw-r--r-- | engine/event.go | 30 |
3 files changed, 360 insertions, 0 deletions
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 == "", + } +} |