From f37a1ee1e2577a0e8fd76d4c965abb93cd99a6cf Mon Sep 17 00:00:00 2001 From: Fuwn Date: Wed, 11 Feb 2026 11:12:10 +0000 Subject: feat: Support JSON configuration file --- README.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++----- configuration.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ engine/engine.go | 11 +++++++++-- formatter.go | 8 ++++++-- inspect.go | 5 ++++- main.go | 31 +++++++++---------------------- 6 files changed, 118 insertions(+), 32 deletions(-) create mode 100644 configuration.go diff --git a/README.md b/README.md index fbdfafd..881add4 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # 🚀 Iku -> Grammar-Aware Go Formatter: Structure through separation +> Grammar-Aware Code Formatter: Structure through separation Let your code breathe! -Iku is a grammar-based Go formatter that enforces consistent blank-line placement by statement and declaration type. +Iku is a grammar-based formatter that enforces consistent blank-line placement by statement and declaration type. It supports Go, JavaScript, TypeScript, JSX, and TSX. ## Philosophy @@ -20,7 +20,9 @@ Code structure should be visually apparent from its formatting. Iku groups state ## How It Works -Iku applies standard Go formatting (via [go/format](https://pkg.go.dev/go/format)) first ([formatter.go#29](https://github.com/Fuwn/iku/blob/main/formatter.go#L29)), then adds its grammar-based blank-line rules on top. Your code gets `go fmt` output plus structural separation. +For Go files, Iku applies standard Go formatting (via [go/format](https://pkg.go.dev/go/format)) first, then adds its grammar-based blank-line rules on top. Your code gets `go fmt` output plus structural separation. + +For JavaScript and TypeScript files (`.js`, `.ts`, `.jsx`, `.tsx`), Iku uses a heuristic line-based analyser that classifies statements by keyword (`function`, `class`, `if`, `for`, `try`, etc.) and applies the same blank-line rules. ## Installation @@ -42,11 +44,13 @@ echo 'package main ...' | iku # Format and print to stdout iku file.go +iku component.tsx # Format in-place iku -w file.go +iku -w src/ -# Format entire directory +# Format entire directory (Go, JS, TS, JSX, TSX) iku -w . # List files that need formatting @@ -63,9 +67,46 @@ iku -d file.go | `-w` | Write result to file instead of stdout | | `-l` | List files whose formatting differs | | `-d` | Display diffs instead of rewriting | -| `--comments` | Comment attachment mode: `follow`, `precede`, `standalone` | | `--version` | Print version | +## Configuration + +Iku looks for `.iku.json` or `iku.json` in the current working directory. + +```json +{ + "comment_mode": "follow", + "group_single_line_functions": false +} +``` + +All fields are optional. Omitted fields use their defaults. + +### `comment_mode` + +Controls how comments interact with blank-line insertion. Default: `"follow"`. + +| Mode | Behaviour | +|------|-----------| +| `follow` | Comments attach to the **next** statement. The blank line goes **before** the comment. | +| `precede` | Comments attach to the **previous** statement. The blank line goes **after** the comment. | +| `standalone` | Comments are independent. Blank lines are placed strictly by statement rules. | + +### `group_single_line_functions` + +When `true`, consecutive single-line function declarations of the same type are kept together without blank lines. Default: `false`. + +```go +// group_single_line_functions = true +func Base() string { return baseDirectory } +func Config() string { return configFile } + +// group_single_line_functions = false (default) +func Base() string { return baseDirectory } + +func Config() string { return configFile } +``` + ## Examples ### Before diff --git a/configuration.go b/configuration.go new file mode 100644 index 0000000..b00d778 --- /dev/null +++ b/configuration.go @@ -0,0 +1,44 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +type Configuration struct { + GroupSingleLineFunctions bool `json:"group_single_line_functions"` + CommentMode string `json:"comment_mode"` +} + +func (configuration Configuration) commentMode() (CommentMode, error) { + switch strings.ToLower(configuration.CommentMode) { + case "", "follow": + return CommentsFollow, nil + case "precede": + return CommentsPrecede, nil + case "standalone": + return CommentsStandalone, nil + default: + return 0, fmt.Errorf("invalid comment_mode: %q (use follow, precede, or standalone)", configuration.CommentMode) + } +} + +func loadConfiguration() Configuration { + var configuration Configuration + + for _, fileName := range []string{".iku.json", "iku.json"} { + fileData, readError := os.ReadFile(fileName) + + if readError != nil { + continue + } + + _ = json.Unmarshal(fileData, &configuration) + + break + } + + return configuration +} diff --git a/engine/engine.go b/engine/engine.go index 0b37dd4..6f7b7a0 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -11,7 +11,8 @@ const ( ) type Engine struct { - CommentMode CommentMode + CommentMode CommentMode + GroupSingleLineScopes bool } func (e *Engine) format(events []LineEvent, resultBuilder *strings.Builder) { @@ -21,6 +22,7 @@ func (e *Engine) format(events []LineEvent, resultBuilder *strings.Builder) { previousWasComment := false previousWasTopLevel := false previousWasScoped := false + previousWasSingleLineScope := false for eventIndex, event := range events { if event.InRawString { @@ -48,6 +50,7 @@ func (e *Engine) format(events []LineEvent, resultBuilder *strings.Builder) { needsBlankLine := false currentIsTopLevel := event.HasASTInfo && event.IsTopLevel currentIsScoped := event.HasASTInfo && event.IsScoped + currentIsSingleLineScope := currentIsScoped && !event.IsOpeningBrace && !event.IsClosingBrace if hasWrittenContent && !previousWasOpenBrace && !event.IsClosingBrace && !event.IsCaseLabel && !event.IsContinuation { if currentIsTopLevel && previousWasTopLevel && currentStatementType != previousStatementType { @@ -55,7 +58,9 @@ func (e *Engine) format(events []LineEvent, resultBuilder *strings.Builder) { needsBlankLine = true } } else if event.HasASTInfo && (currentIsScoped || previousWasScoped) { - if !(e.CommentMode == CommentsFollow && previousWasComment) { + if e.GroupSingleLineScopes && currentIsSingleLineScope && previousWasSingleLineScope && currentStatementType == previousStatementType { + needsBlankLine = false + } else if !(e.CommentMode == CommentsFollow && previousWasComment) { needsBlankLine = true } } else if currentStatementType != "" && previousStatementType != "" && currentStatementType != previousStatementType { @@ -104,10 +109,12 @@ func (e *Engine) format(events []LineEvent, resultBuilder *strings.Builder) { previousStatementType = event.StatementType previousWasTopLevel = event.IsTopLevel previousWasScoped = event.IsScoped + previousWasSingleLineScope = currentIsSingleLineScope } else if currentStatementType != "" { previousStatementType = currentStatementType previousWasTopLevel = false previousWasScoped = false + previousWasSingleLineScope = false } } diff --git a/formatter.go b/formatter.go index 0256694..a82d619 100644 --- a/formatter.go +++ b/formatter.go @@ -14,7 +14,8 @@ const ( ) type Formatter struct { - CommentMode CommentMode + CommentMode CommentMode + Configuration Configuration } type lineInformation struct { @@ -31,7 +32,10 @@ func (f *Formatter) Format(source []byte, filename string) ([]byte, error) { return nil, err } - formattingEngine := &engine.Engine{CommentMode: MapCommentMode(f.CommentMode)} + formattingEngine := &engine.Engine{ + CommentMode: MapCommentMode(f.CommentMode), + GroupSingleLineScopes: f.Configuration.GroupSingleLineFunctions, + } return formattingEngine.FormatToBytes(events), nil } diff --git a/inspect.go b/inspect.go index 0c05657..8452de5 100644 --- a/inspect.go +++ b/inspect.go @@ -45,7 +45,10 @@ func (f *Formatter) buildLineInfo(tokenFileSet *token.FileSet, parsedFile *ast.F } lineInformationMap[startLine] = &lineInformation{statementType: statementType, isTopLevel: true, isScoped: isScoped, isStartLine: true} - lineInformationMap[endLine] = &lineInformation{statementType: statementType, isTopLevel: true, isScoped: isScoped, isStartLine: false} + + if endLine != startLine { + lineInformationMap[endLine] = &lineInformation{statementType: statementType, isTopLevel: true, isScoped: isScoped, isStartLine: false} + } } ast.Inspect(parsedFile, func(astNode ast.Node) bool { diff --git a/main.go b/main.go index c41bfe2..781d873 100644 --- a/main.go +++ b/main.go @@ -15,11 +15,10 @@ import ( var version = "dev" var ( - writeFlag = flag.Bool("w", false, "write result to (source) file instead of stdout") - listFlag = flag.Bool("l", false, "list files whose formatting differs from iku's") - diffFlag = flag.Bool("d", false, "display diffs instead of rewriting files") - commentsFlag = flag.String("comments", "follow", "comment attachment mode: follow, precede, standalone") - versionFlag = flag.Bool("version", false, "print version") + writeFlag = flag.Bool("w", false, "write result to (source) file instead of stdout") + listFlag = flag.Bool("l", false, "list files whose formatting differs from iku's") + diffFlag = flag.Bool("d", false, "display diffs instead of rewriting files") + versionFlag = flag.Bool("version", false, "print version") ) func main() { @@ -35,14 +34,15 @@ func main() { os.Exit(0) } - commentMode, err := parseCommentMode(*commentsFlag) + configuration := loadConfiguration() + commentMode, validationError := configuration.commentMode() - if err != nil { - fmt.Fprintf(os.Stderr, "iku: %v\n", err) + if validationError != nil { + fmt.Fprintf(os.Stderr, "iku: %v\n", validationError) os.Exit(2) } - formatter := &Formatter{CommentMode: commentMode} + formatter := &Formatter{CommentMode: commentMode, Configuration: configuration} if flag.NArg() == 0 { if *writeFlag { @@ -84,19 +84,6 @@ func main() { os.Exit(exitCode) } -func parseCommentMode(commentModeString string) (CommentMode, error) { - switch strings.ToLower(commentModeString) { - case "follow": - return CommentsFollow, nil - case "precede": - return CommentsPrecede, nil - case "standalone": - return CommentsStandalone, nil - default: - return 0, fmt.Errorf("invalid comment mode: %q (use follow, precede, or standalone)", commentModeString) - } -} - var supportedFileExtensions = map[string]bool{ ".go": true, ".js": true, -- cgit v1.2.3