๐Ÿš€ Grammar-Aware Code Formatter: Structure through separation (supports Go, JavaScript, TypeScript, JSX, and TSX)
go formatter code-formatter javascript typescript jsx tsx

feat(formatter): Stricter token and scoping rules

fuwn.net 2e53255d ab962196

verified
+66 -17
+18 -5
README.md
··· 5 5 6 6 Let your code breathe! 7 7 8 - Iku is a grammar-based Go formatter that enforces consistent blank-line placement by AST node type. 8 + Iku is a grammar-based Go formatter that enforces consistent blank-line placement by statement and declaration type. 9 9 10 10 ## Philosophy 11 11 ··· 13 13 14 14 ## Rules 15 15 16 - 1. **Same AST type means no blank line**: Consecutive statements of the same type stay together 17 - 2. **Different AST type means blank line**: Transitions between statement types get visual separation 18 - 3. **Scoped statements get blank lines**: `if`, `for`, `switch`, `select` always have blank lines before them 19 - 4. **Top-level declarations are separated**: Functions, types, and variables at the package level get blank lines between them 16 + 1. **Same type means no blank line**: Consecutive statements of the same type stay together 17 + 2. **Different type means blank line**: Transitions between statement types get visual separation 18 + 3. **Scoped constructs get blank lines**: `if`, `for`, `switch`, `select`, `func`, `type struct`, `type interface` always have blank lines around them 19 + 4. **Declarations use token types**: `var`, `const`, `type`, `func`, `import` are distinguished by their keyword, not grouped as generic declarations 20 20 21 21 ## How It Works 22 22 ··· 134 134 type Config struct { 135 135 Name string 136 136 } 137 + type ID int 138 + type Name string 137 139 var defaultConfig = Config{} 140 + var x = 1 138 141 func main() { 139 142 run() 140 143 } ··· 149 152 Name string 150 153 } 151 154 155 + type ID int 156 + type Name string 157 + 152 158 var defaultConfig = Config{} 159 + var x = 1 153 160 154 161 func main() { 155 162 run() ··· 159 166 process() 160 167 } 161 168 ``` 169 + 170 + Notice how: 171 + - `type Config struct` is scoped (has braces), so it gets a blank line 172 + - `type ID int` and `type Name string` are unscoped type aliases, so they group together 173 + - `var defaultConfig` and `var x` are unscoped, so they group together 174 + - `func main()` and `func run()` are scoped, so each gets a blank line 162 175 163 176 ### Switch Statements 164 177
+48 -11
formatter.go
··· 13 13 var ( 14 14 closingBracePattern = regexp.MustCompile(`^\s*[\}\)]`) 15 15 openingBracePattern = regexp.MustCompile(`[\{\(]\s*$`) 16 - caseLabelPattern = regexp.MustCompile(`^\s*(case\s+.*|default\s*):\s*$`) 16 + caseLabelPattern = regexp.MustCompile(`^\s*(case\s|default\s*:)|(^\s+.*:\s*$)`) 17 17 ) 18 18 19 19 func isCommentOnly(line string) bool { ··· 128 128 return f.rewrite(formatted, lineInfoMap), nil 129 129 } 130 130 131 + func isGenDeclScoped(genDecl *ast.GenDecl) bool { 132 + for _, spec := range genDecl.Specs { 133 + if typeSpec, ok := spec.(*ast.TypeSpec); ok { 134 + switch typeSpec.Type.(type) { 135 + case *ast.StructType, *ast.InterfaceType: 136 + return true 137 + } 138 + } 139 + } 140 + 141 + return false 142 + } 143 + 131 144 func (f *Formatter) buildLineInfo(fileSet *token.FileSet, file *ast.File) map[int]*lineInfo { 132 145 lineInfoMap := make(map[int]*lineInfo) 133 146 tokenFile := fileSet.File(file.Pos()) ··· 139 152 for _, declaration := range file.Decls { 140 153 startLine := tokenFile.Line(declaration.Pos()) 141 154 endLine := tokenFile.Line(declaration.End()) 142 - typeName := reflect.TypeOf(declaration).String() 143 - lineInfoMap[startLine] = &lineInfo{statementType: typeName, isTopLevel: true, isStartLine: true} 144 - lineInfoMap[endLine] = &lineInfo{statementType: typeName, isTopLevel: true, isStartLine: false} 155 + typeName := "" 156 + isScoped := false 157 + 158 + switch declarationType := declaration.(type) { 159 + case *ast.GenDecl: 160 + typeName = declarationType.Tok.String() 161 + isScoped = isGenDeclScoped(declarationType) 162 + case *ast.FuncDecl: 163 + typeName = "func" 164 + isScoped = true 165 + default: 166 + typeName = reflect.TypeOf(declaration).String() 167 + } 168 + 169 + lineInfoMap[startLine] = &lineInfo{statementType: typeName, isTopLevel: true, isScoped: isScoped, isStartLine: true} 170 + lineInfoMap[endLine] = &lineInfo{statementType: typeName, isTopLevel: true, isScoped: isScoped, isStartLine: false} 145 171 } 146 172 147 173 ast.Inspect(file, func(node ast.Node) bool { ··· 176 202 for _, statement := range statements { 177 203 startLine := tokenFile.Line(statement.Pos()) 178 204 endLine := tokenFile.Line(statement.End()) 179 - typeName := reflect.TypeOf(statement).String() 205 + typeName := "" 180 206 isScoped := false 181 207 182 - switch statement.(type) { 208 + switch statementType := statement.(type) { 209 + case *ast.DeclStmt: 210 + if genericDeclaration, ok := statementType.Decl.(*ast.GenDecl); ok { 211 + typeName = genericDeclaration.Tok.String() 212 + } else { 213 + typeName = reflect.TypeOf(statement).String() 214 + } 183 215 case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt, *ast.SwitchStmt, 184 216 *ast.TypeSwitchStmt, *ast.SelectStmt, *ast.BlockStmt: 185 - 217 + typeName = reflect.TypeOf(statement).String() 186 218 isScoped = true 219 + default: 220 + typeName = reflect.TypeOf(statement).String() 187 221 } 188 222 189 223 existingStart := lineInfoMap[startLine] ··· 258 292 previousType := "" 259 293 previousWasComment := false 260 294 previousWasTopLevel := false 295 + previousWasScoped := false 261 296 insideRawString := false 262 297 263 298 for index, line := range lines { ··· 302 337 currentIsScoped := info != nil && info.isScoped 303 338 304 339 if len(result) > 0 && !previousWasOpenBrace && !isClosingBrace && !isCaseLabel { 305 - if currentIsTopLevel && previousWasTopLevel { 340 + if currentIsTopLevel && previousWasTopLevel && currentType != previousType { 306 341 if f.CommentMode == CommentsFollow && previousWasComment { 307 342 } else { 308 343 needsBlank = true 309 344 } 310 - } else if currentIsScoped { 345 + } else if info != nil && (currentIsScoped || previousWasScoped) { 311 346 if f.CommentMode == CommentsFollow && previousWasComment { 312 347 } else { 313 348 needsBlank = true ··· 329 364 nextIsTopLevel := nextInfo.isTopLevel 330 365 nextIsScoped := nextInfo.isScoped 331 366 332 - if nextIsTopLevel && previousWasTopLevel { 367 + if nextIsTopLevel && previousWasTopLevel && nextInfo.statementType != previousType { 333 368 needsBlank = true 334 - } else if nextIsScoped { 369 + } else if nextIsScoped || previousWasScoped { 335 370 needsBlank = true 336 371 } else if nextInfo.statementType != "" && previousType != "" && nextInfo.statementType != previousType { 337 372 needsBlank = true ··· 352 387 if info != nil { 353 388 previousType = info.statementType 354 389 previousWasTopLevel = info.isTopLevel 390 + previousWasScoped = info.isScoped 355 391 } else if currentType != "" { 356 392 previousType = currentType 357 393 previousWasTopLevel = false 394 + previousWasScoped = false 358 395 } 359 396 } 360 397
-1
main.go
··· 13 13 ) 14 14 15 15 var version = "dev" 16 - 17 16 var ( 18 17 writeFlag = flag.Bool("w", false, "write result to (source) file instead of stdout") 19 18 listFlag = flag.Bool("l", false, "list files whose formatting differs from iku's")