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

Configure Feed

Select the types of activity you want to include in your feed.

feat(adapter): Add EcmaScript adapter for JS/TS/JSX/TSX formatting

Fuwn e240a346 acd264ee

+700
+238
adapter_ecmascript.go
··· 1 + package main 2 + 3 + import ( 4 + "github.com/Fuwn/iku/engine" 5 + "strings" 6 + ) 7 + 8 + type EcmaScriptAdapter struct{} 9 + 10 + func (a *EcmaScriptAdapter) Analyze(source []byte) ([]byte, []engine.LineEvent, error) { 11 + sourceLines := strings.Split(string(source), "\n") 12 + events := make([]engine.LineEvent, len(sourceLines)) 13 + insideTemplateString := false 14 + insideBlockComment := false 15 + previousEndedWithContinuation := false 16 + 17 + for lineIndex, currentLine := range sourceLines { 18 + backtickCount := countRawStringDelimiters(currentLine) 19 + wasInsideTemplateString := insideTemplateString 20 + 21 + if backtickCount%2 == 1 { 22 + insideTemplateString = !insideTemplateString 23 + } 24 + 25 + event := engine.NewLineEvent(currentLine) 26 + 27 + if wasInsideTemplateString { 28 + event.InRawString = true 29 + events[lineIndex] = event 30 + 31 + continue 32 + } 33 + 34 + if event.IsBlank { 35 + events[lineIndex] = event 36 + 37 + continue 38 + } 39 + 40 + trimmedContent := event.TrimmedContent 41 + 42 + if insideBlockComment { 43 + event.IsCommentOnly = true 44 + 45 + if strings.Contains(trimmedContent, "*/") { 46 + insideBlockComment = false 47 + } 48 + 49 + events[lineIndex] = event 50 + previousEndedWithContinuation = false 51 + 52 + continue 53 + } 54 + 55 + if strings.HasPrefix(trimmedContent, "/*") { 56 + event.IsCommentOnly = true 57 + 58 + if !strings.Contains(trimmedContent, "*/") { 59 + insideBlockComment = true 60 + } 61 + 62 + events[lineIndex] = event 63 + previousEndedWithContinuation = false 64 + 65 + continue 66 + } 67 + 68 + event.IsClosingBrace = isClosingBrace(currentLine) 69 + event.IsOpeningBrace = isOpeningBrace(currentLine) 70 + event.IsCaseLabel = isCaseLabel(currentLine) 71 + event.IsCommentOnly = isCommentOnly(currentLine) 72 + 73 + if event.IsCommentOnly { 74 + events[lineIndex] = event 75 + 76 + continue 77 + } 78 + 79 + isContinuationLine := previousEndedWithContinuation || 80 + strings.HasPrefix(trimmedContent, ".") || 81 + strings.HasPrefix(trimmedContent, "?.") || 82 + strings.HasPrefix(trimmedContent, "]") 83 + previousEndedWithContinuation = ecmaScriptLineEndsContinuation(trimmedContent) 84 + 85 + if isClosingCurlyBrace(currentLine) { 86 + event.HasASTInfo = true 87 + event.IsScoped = true 88 + event.IsTopLevel = ecmaScriptLineIsTopLevel(currentLine) 89 + events[lineIndex] = event 90 + 91 + continue 92 + } 93 + 94 + if isContinuationLine { 95 + events[lineIndex] = event 96 + 97 + continue 98 + } 99 + 100 + statementType, isScoped := classifyEcmaScriptStatement(trimmedContent) 101 + 102 + if statementType != "" { 103 + event.HasASTInfo = true 104 + event.StatementType = statementType 105 + event.IsScoped = isScoped 106 + event.IsTopLevel = ecmaScriptLineIsTopLevel(currentLine) 107 + event.IsStartLine = true 108 + } else { 109 + event.HasASTInfo = true 110 + event.StatementType = "expression" 111 + event.IsTopLevel = ecmaScriptLineIsTopLevel(currentLine) 112 + } 113 + 114 + events[lineIndex] = event 115 + } 116 + 117 + return source, events, nil 118 + } 119 + 120 + func classifyEcmaScriptStatement(trimmedLine string) (string, bool) { 121 + classified := trimmedLine 122 + 123 + if strings.HasPrefix(classified, "export default ") { 124 + classified = classified[15:] 125 + } else if strings.HasPrefix(classified, "export ") { 126 + classified = classified[7:] 127 + } 128 + 129 + if strings.HasPrefix(classified, "async ") { 130 + classified = classified[6:] 131 + } 132 + 133 + if strings.HasPrefix(classified, "declare ") { 134 + classified = classified[8:] 135 + } 136 + 137 + switch { 138 + case ecmaScriptStatementHasPrefix(classified, "function"): 139 + return "function", true 140 + case ecmaScriptStatementHasPrefix(classified, "class"): 141 + return "class", true 142 + case ecmaScriptStatementHasPrefix(classified, "if"): 143 + return "if", true 144 + case ecmaScriptStatementHasPrefix(classified, "else"): 145 + return "if", true 146 + case ecmaScriptStatementHasPrefix(classified, "for"): 147 + return "for", true 148 + case ecmaScriptStatementHasPrefix(classified, "while"): 149 + return "while", true 150 + case ecmaScriptStatementHasPrefix(classified, "do"): 151 + return "do", true 152 + case ecmaScriptStatementHasPrefix(classified, "switch"): 153 + return "switch", true 154 + case ecmaScriptStatementHasPrefix(classified, "try"): 155 + return "try", true 156 + case ecmaScriptStatementHasPrefix(classified, "interface"): 157 + return "interface", true 158 + case ecmaScriptStatementHasPrefix(classified, "enum"): 159 + return "enum", true 160 + case ecmaScriptStatementHasPrefix(classified, "namespace"): 161 + return "namespace", true 162 + case ecmaScriptStatementHasPrefix(classified, "const"): 163 + return "const", false 164 + case ecmaScriptStatementHasPrefix(classified, "let"): 165 + return "let", false 166 + case ecmaScriptStatementHasPrefix(classified, "var"): 167 + return "var", false 168 + case ecmaScriptStatementHasPrefix(classified, "import"): 169 + return "import", false 170 + case ecmaScriptStatementHasPrefix(classified, "type"): 171 + return "type", false 172 + case ecmaScriptStatementHasPrefix(classified, "return"): 173 + return "return", false 174 + case ecmaScriptStatementHasPrefix(classified, "throw"): 175 + return "throw", false 176 + case ecmaScriptStatementHasPrefix(classified, "await"): 177 + return "await", false 178 + case ecmaScriptStatementHasPrefix(classified, "yield"): 179 + return "yield", false 180 + } 181 + 182 + return "", false 183 + } 184 + 185 + func ecmaScriptStatementHasPrefix(line string, keyword string) bool { 186 + if !strings.HasPrefix(line, keyword) { 187 + return false 188 + } 189 + 190 + if len(line) == len(keyword) { 191 + return true 192 + } 193 + 194 + nextCharacter := line[len(keyword)] 195 + 196 + return nextCharacter == ' ' || nextCharacter == '(' || nextCharacter == '{' || 197 + nextCharacter == ';' || nextCharacter == '<' || nextCharacter == '\t' 198 + } 199 + 200 + func ecmaScriptLineIsTopLevel(sourceLine string) bool { 201 + if len(sourceLine) == 0 { 202 + return false 203 + } 204 + 205 + return !isWhitespace(sourceLine[0]) 206 + } 207 + 208 + func ecmaScriptLineEndsContinuation(trimmedLine string) bool { 209 + if len(trimmedLine) == 0 { 210 + return false 211 + } 212 + 213 + lastCharacter := trimmedLine[len(trimmedLine)-1] 214 + 215 + if lastCharacter == ',' || lastCharacter == '[' || lastCharacter == '(' { 216 + return true 217 + } 218 + 219 + if lastCharacter == '>' && strings.HasPrefix(trimmedLine, "<") { 220 + return true 221 + } 222 + 223 + return false 224 + } 225 + 226 + func isClosingCurlyBrace(sourceLine string) bool { 227 + for characterIndex := 0; characterIndex < len(sourceLine); characterIndex++ { 228 + character := sourceLine[characterIndex] 229 + 230 + if isWhitespace(character) { 231 + continue 232 + } 233 + 234 + return character == '}' 235 + } 236 + 237 + return false 238 + }
+462
adapter_ecmascript_test.go
··· 1 + package main 2 + 3 + import ( 4 + "github.com/Fuwn/iku/engine" 5 + "testing" 6 + ) 7 + 8 + type ecmaScriptTestCase struct { 9 + name string 10 + source string 11 + expected string 12 + } 13 + 14 + var ecmaScriptTestCases = []ecmaScriptTestCase{ 15 + { 16 + name: "blank lines around if block", 17 + source: `const x = 1; 18 + if (x > 0) { 19 + doSomething(); 20 + } 21 + const y = 2; 22 + `, 23 + expected: `const x = 1; 24 + 25 + if (x > 0) { 26 + doSomething(); 27 + } 28 + 29 + const y = 2; 30 + `, 31 + }, 32 + { 33 + name: "blank lines around for loop", 34 + source: `const items = [1, 2, 3]; 35 + for (const item of items) { 36 + process(item); 37 + } 38 + const result = done(); 39 + `, 40 + expected: `const items = [1, 2, 3]; 41 + 42 + for (const item of items) { 43 + process(item); 44 + } 45 + 46 + const result = done(); 47 + `, 48 + }, 49 + { 50 + name: "blank lines between different top-level types", 51 + source: `import { foo } from "bar"; 52 + const x = 1; 53 + function main() { 54 + return x; 55 + } 56 + class Foo { 57 + bar() {} 58 + } 59 + `, 60 + expected: `import { foo } from "bar"; 61 + 62 + const x = 1; 63 + 64 + function main() { 65 + return x; 66 + } 67 + 68 + class Foo { 69 + bar() {} 70 + } 71 + `, 72 + }, 73 + { 74 + name: "no blank between same type", 75 + source: `const x = 1; 76 + const y = 2; 77 + const z = 3; 78 + `, 79 + expected: `const x = 1; 80 + const y = 2; 81 + const z = 3; 82 + `, 83 + }, 84 + { 85 + name: "consecutive imports stay together", 86 + source: `import { a } from "a"; 87 + import { b } from "b"; 88 + import { c } from "c"; 89 + `, 90 + expected: `import { a } from "a"; 91 + import { b } from "b"; 92 + import { c } from "c"; 93 + `, 94 + }, 95 + { 96 + name: "switch with case clauses", 97 + source: `const x = getValue(); 98 + switch (x) { 99 + case "a": 100 + handleA(); 101 + break; 102 + case "b": 103 + handleB(); 104 + break; 105 + } 106 + cleanup(); 107 + `, 108 + expected: `const x = getValue(); 109 + 110 + switch (x) { 111 + case "a": 112 + handleA(); 113 + break; 114 + case "b": 115 + handleB(); 116 + break; 117 + } 118 + 119 + cleanup(); 120 + `, 121 + }, 122 + { 123 + name: "try catch finally", 124 + source: `setup(); 125 + try { 126 + riskyOperation(); 127 + } catch (error) { 128 + handleError(error); 129 + } finally { 130 + cleanup(); 131 + } 132 + done(); 133 + `, 134 + expected: `setup(); 135 + 136 + try { 137 + riskyOperation(); 138 + } catch (error) { 139 + handleError(error); 140 + } finally { 141 + cleanup(); 142 + } 143 + 144 + done(); 145 + `, 146 + }, 147 + { 148 + name: "consecutive scoped blocks", 149 + source: `if (a) { 150 + doA(); 151 + } 152 + if (b) { 153 + doB(); 154 + } 155 + `, 156 + expected: `if (a) { 157 + doA(); 158 + } 159 + 160 + if (b) { 161 + doB(); 162 + } 163 + `, 164 + }, 165 + { 166 + name: "export prefixes", 167 + source: `export const x = 1; 168 + export function foo() { 169 + return x; 170 + } 171 + export class Bar { 172 + baz() {} 173 + } 174 + `, 175 + expected: `export const x = 1; 176 + 177 + export function foo() { 178 + return x; 179 + } 180 + 181 + export class Bar { 182 + baz() {} 183 + } 184 + `, 185 + }, 186 + { 187 + name: "async function", 188 + source: `const data = prepare(); 189 + async function fetchData() { 190 + return await fetch(url); 191 + } 192 + const result = process(); 193 + `, 194 + expected: `const data = prepare(); 195 + 196 + async function fetchData() { 197 + return await fetch(url); 198 + } 199 + 200 + const result = process(); 201 + `, 202 + }, 203 + { 204 + name: "typescript interface and type", 205 + source: `type ID = string; 206 + interface User { 207 + name: string; 208 + id: ID; 209 + } 210 + const defaultUser: User = { name: "", id: "" }; 211 + `, 212 + expected: `type ID = string; 213 + 214 + interface User { 215 + name: string; 216 + id: ID; 217 + } 218 + 219 + const defaultUser: User = { name: "", id: "" }; 220 + `, 221 + }, 222 + { 223 + name: "multi-line function call preserved", 224 + source: `const result = someFunction( 225 + longArgument, 226 + otherArgument, 227 + ); 228 + const next = 1; 229 + `, 230 + expected: `const result = someFunction( 231 + longArgument, 232 + otherArgument, 233 + ); 234 + const next = 1; 235 + `, 236 + }, 237 + { 238 + name: "method chaining preserved", 239 + source: `const result = someArray 240 + .filter(x => x > 0) 241 + .map(x => x * 2); 242 + const next = 1; 243 + `, 244 + expected: `const result = someArray 245 + .filter(x => x > 0) 246 + .map(x => x * 2); 247 + const next = 1; 248 + `, 249 + }, 250 + { 251 + name: "block comment passthrough", 252 + source: `/* 253 + * This is a block comment 254 + */ 255 + const x = 1; 256 + `, 257 + expected: `/* 258 + * This is a block comment 259 + */ 260 + const x = 1; 261 + `, 262 + }, 263 + { 264 + name: "collapses extra blank lines", 265 + source: `const x = 1; 266 + 267 + 268 + const y = 2; 269 + `, 270 + expected: `const x = 1; 271 + const y = 2; 272 + `, 273 + }, 274 + { 275 + name: "while loop", 276 + source: `let count = 0; 277 + while (count < 10) { 278 + count++; 279 + } 280 + const done = true; 281 + `, 282 + expected: `let count = 0; 283 + 284 + while (count < 10) { 285 + count++; 286 + } 287 + 288 + const done = true; 289 + `, 290 + }, 291 + { 292 + name: "nested scopes", 293 + source: `function main() { 294 + const x = 1; 295 + if (x > 0) { 296 + for (let i = 0; i < x; i++) { 297 + process(i); 298 + } 299 + cleanup(); 300 + } 301 + return x; 302 + } 303 + `, 304 + expected: `function main() { 305 + const x = 1; 306 + 307 + if (x > 0) { 308 + for (let i = 0; i < x; i++) { 309 + process(i); 310 + } 311 + 312 + cleanup(); 313 + } 314 + 315 + return x; 316 + } 317 + `, 318 + }, 319 + { 320 + name: "template literal passthrough", 321 + source: "const x = `\nhello\n\nworld\n`;\nconst y = 1;\n", 322 + expected: "const x = `\nhello\n\nworld\n`;\nconst y = 1;\n", 323 + }, 324 + { 325 + name: "jsx expressions", 326 + source: `function Component() { 327 + const data = useMemo(); 328 + if (!data) { 329 + return null; 330 + } 331 + return ( 332 + <div> 333 + <span>{data}</span> 334 + </div> 335 + ); 336 + } 337 + `, 338 + expected: `function Component() { 339 + const data = useMemo(); 340 + 341 + if (!data) { 342 + return null; 343 + } 344 + 345 + return ( 346 + <div> 347 + <span>{data}</span> 348 + </div> 349 + ); 350 + } 351 + `, 352 + }, 353 + { 354 + name: "expression after scoped block", 355 + source: `function main() { 356 + if (x) { 357 + return; 358 + } 359 + doSomething(); 360 + } 361 + `, 362 + expected: `function main() { 363 + if (x) { 364 + return; 365 + } 366 + 367 + doSomething(); 368 + } 369 + `, 370 + }, 371 + { 372 + name: "enum declaration", 373 + source: `type Color = string; 374 + enum Direction { 375 + Up, 376 + Down, 377 + } 378 + const x = Direction.Up; 379 + `, 380 + expected: `type Color = string; 381 + 382 + enum Direction { 383 + Up, 384 + Down, 385 + } 386 + 387 + const x = Direction.Up; 388 + `, 389 + }, 390 + } 391 + 392 + func TestEcmaScriptAdapter(t *testing.T) { 393 + for _, testCase := range ecmaScriptTestCases { 394 + t.Run(testCase.name, func(t *testing.T) { 395 + adapter := &EcmaScriptAdapter{} 396 + _, events, err := adapter.Analyze([]byte(testCase.source)) 397 + 398 + if err != nil { 399 + t.Fatalf("adapter error: %v", err) 400 + } 401 + 402 + formattingEngine := &engine.Engine{CommentMode: engine.CommentsFollow} 403 + result := formattingEngine.FormatToString(events) 404 + 405 + if result != testCase.expected { 406 + t.Errorf("mismatch\ngot:\n%s\nwant:\n%s", result, testCase.expected) 407 + } 408 + }) 409 + } 410 + } 411 + 412 + func TestClassifyEcmaScriptStatement(t *testing.T) { 413 + cases := []struct { 414 + input string 415 + expectedType string 416 + expectedScope bool 417 + }{ 418 + {"function foo() {", "function", true}, 419 + {"async function foo() {", "function", true}, 420 + {"export function foo() {", "function", true}, 421 + {"export default function() {", "function", true}, 422 + {"class Foo {", "class", true}, 423 + {"export class Foo {", "class", true}, 424 + {"if (x) {", "if", true}, 425 + {"else if (y) {", "if", true}, 426 + {"else {", "if", true}, 427 + {"for (const x of items) {", "for", true}, 428 + {"while (true) {", "while", true}, 429 + {"do {", "do", true}, 430 + {"switch (x) {", "switch", true}, 431 + {"try {", "try", true}, 432 + {"interface Foo {", "interface", true}, 433 + {"enum Direction {", "enum", true}, 434 + {"namespace Foo {", "namespace", true}, 435 + {"const x = 1;", "const", false}, 436 + {"let x = 1;", "let", false}, 437 + {"var x = 1;", "var", false}, 438 + {"import { foo } from 'bar';", "import", false}, 439 + {"type Foo = string;", "type", false}, 440 + {"return x;", "return", false}, 441 + {"return;", "return", false}, 442 + {"throw new Error();", "throw", false}, 443 + {"await fetch(url);", "await", false}, 444 + {"yield value;", "yield", false}, 445 + {"export const x = 1;", "const", false}, 446 + {"export default class Foo {", "class", true}, 447 + {"declare const x: number;", "const", false}, 448 + {"declare function foo(): void;", "function", true}, 449 + {"foo();", "", false}, 450 + {"x = 1;", "", false}, 451 + {"", "", false}, 452 + } 453 + 454 + for _, testCase := range cases { 455 + statementType, isScoped := classifyEcmaScriptStatement(testCase.input) 456 + 457 + if statementType != testCase.expectedType || isScoped != testCase.expectedScope { 458 + t.Errorf("classifyEcmaScriptStatement(%q) = (%q, %v), want (%q, %v)", 459 + testCase.input, statementType, isScoped, testCase.expectedType, testCase.expectedScope) 460 + } 461 + } 462 + }