|
11 | 11 | //===----------------------------------------------------------------------===// |
12 | 12 |
|
13 | 13 | import SwiftParser |
14 | | -import SwiftSyntax |
| 14 | +@_spi(RawSyntax) import SwiftSyntax |
15 | 15 | import XCTest |
16 | 16 | import _SwiftSyntaxTestSupport |
17 | 17 |
|
@@ -466,4 +466,115 @@ class IncrementalParsingTests: ParserTestCase { |
466 | 466 | ] |
467 | 467 | ) |
468 | 468 | } |
| 469 | + |
| 470 | + // MARK: - Arena compaction |
| 471 | + |
| 472 | + /// A source file of `count` distinct top-level functions. Editing one function |
| 473 | + /// reuses the others from earlier arenas, so editing distinct functions in |
| 474 | + /// turn accumulates retained arenas. |
| 475 | + private func manyFunctions(_ count: Int) -> String { |
| 476 | + return (0..<count).map { "func f\($0)() -> Int { return \($0) }\n" }.joined() |
| 477 | + } |
| 478 | + |
| 479 | + /// Insert `text` at the start of `needle` in `source`, returning the new |
| 480 | + /// source and the corresponding edit. |
| 481 | + private func insert( |
| 482 | + _ text: String, |
| 483 | + before needle: String, |
| 484 | + in source: String |
| 485 | + ) -> (newSource: String, edit: SourceEdit) { |
| 486 | + let range = source.range(of: needle)! |
| 487 | + let offset = source.utf8.distance(from: source.utf8.startIndex, to: range.lowerBound.samePosition(in: source.utf8)!) |
| 488 | + var utf8 = Array(source.utf8) |
| 489 | + utf8.insert(contentsOf: Array(text.utf8), at: offset) |
| 490 | + let edit = SourceEdit( |
| 491 | + range: AbsolutePosition(utf8Offset: offset)..<AbsolutePosition(utf8Offset: offset), |
| 492 | + replacement: text |
| 493 | + ) |
| 494 | + return (String(decoding: utf8, as: UTF8.self), edit) |
| 495 | + } |
| 496 | + |
| 497 | + /// Edits distinct functions in turn and returns the peak retained arena count |
| 498 | + /// observed across the edit sequence. Asserts every intermediate tree |
| 499 | + /// round-trips. |
| 500 | + private func peakRetainedArenas(functionCount: Int, edits: Int, compactArenaThreshold: Int?) throws -> Int { |
| 501 | + var source = manyFunctions(functionCount) |
| 502 | + var result = Parser.parseIncrementally(source: source, parseTransition: nil) |
| 503 | + var peak = result.tree.raw.arena.retainedArenaCount |
| 504 | + for i in 0..<edits { |
| 505 | + let (newSource, edit) = insert(" ", before: "return \(i % functionCount) ", in: source) |
| 506 | + let transition = IncrementalParseTransition( |
| 507 | + previousIncrementalParseResult: result, |
| 508 | + edits: try ConcurrentEdits(concurrent: [edit]), |
| 509 | + compactArenaThreshold: compactArenaThreshold |
| 510 | + ) |
| 511 | + result = Parser.parseIncrementally(source: newSource, parseTransition: transition) |
| 512 | + XCTAssertEqual(result.tree.description, newSource, "tree did not round-trip after edit \(i)") |
| 513 | + peak = max(peak, result.tree.raw.arena.retainedArenaCount) |
| 514 | + source = newSource |
| 515 | + } |
| 516 | + return peak |
| 517 | + } |
| 518 | + |
| 519 | + /// With compaction disabled, editing distinct functions accumulates arenas |
| 520 | + /// well beyond a small bound — this validates that the compaction test below |
| 521 | + /// is actually exercising arena accumulation. |
| 522 | + public func testArenaCountAccumulatesWithoutCompaction() throws { |
| 523 | + let peak = try peakRetainedArenas(functionCount: 16, edits: 16, compactArenaThreshold: nil) |
| 524 | + // Each edit reuses the other functions from earlier arenas, so the retained |
| 525 | + // count grows by roughly one per edit (peak here is near `1 + edits` ≈ 17). |
| 526 | + // This is only a control asserting that accumulation happens at all, so use |
| 527 | + // a loose lower bound: comfortably above the compaction-bounded regime (the |
| 528 | + // test below caps at 4) yet well under the true peak, so it stays robust to |
| 529 | + // small variations in how nodes are pinned across arenas. |
| 530 | + XCTAssertGreaterThan(peak, 8, "expected arenas to accumulate without compaction") |
| 531 | + } |
| 532 | + |
| 533 | + /// With a low threshold, compaction keeps the retained arena count bounded by |
| 534 | + /// the threshold across a long edit sequence, while every tree still |
| 535 | + /// round-trips. |
| 536 | + public func testArenaCompactionBoundsRetainedArenaCount() throws { |
| 537 | + let threshold = 4 |
| 538 | + let peak = try peakRetainedArenas(functionCount: 16, edits: 32, compactArenaThreshold: threshold) |
| 539 | + XCTAssertLessThanOrEqual(peak, threshold, "compaction should keep retained arenas at or below the threshold") |
| 540 | + } |
| 541 | + |
| 542 | + /// A tree produced by a compaction (full reparse) can still be used as the |
| 543 | + /// basis for a subsequent incremental parse that reuses nodes. |
| 544 | + public func testIncrementalParseAfterCompaction() throws { |
| 545 | + let source = manyFunctions(16) |
| 546 | + var result = Parser.parseIncrementally(source: source, parseTransition: nil) |
| 547 | + |
| 548 | + // Force a compaction on the next parse with threshold 1 (any incremental |
| 549 | + // parse's previous tree retains >= 1 arena, so `previous + 1 > 1` always |
| 550 | + // holds and forces a compaction). |
| 551 | + let (afterFirst, edit1) = insert(" ", before: "return 0 ", in: source) |
| 552 | + result = Parser.parseIncrementally( |
| 553 | + source: afterFirst, |
| 554 | + parseTransition: IncrementalParseTransition( |
| 555 | + previousIncrementalParseResult: result, |
| 556 | + edits: try ConcurrentEdits(concurrent: [edit1]), |
| 557 | + compactArenaThreshold: 1 |
| 558 | + ) |
| 559 | + ) |
| 560 | + XCTAssertEqual(result.tree.raw.arena.retainedArenaCount, 1, "compaction should collapse to a single arena") |
| 561 | + XCTAssertEqual(result.tree.description, afterFirst) |
| 562 | + |
| 563 | + // The compacted tree drives a normal incremental parse that reuses nodes. |
| 564 | + var reusedCodeBlockItems = 0 |
| 565 | + let (afterSecond, edit2) = insert(" ", before: "return 5 ", in: afterFirst) |
| 566 | + let result2 = Parser.parseIncrementally( |
| 567 | + source: afterSecond, |
| 568 | + parseTransition: IncrementalParseTransition( |
| 569 | + previousIncrementalParseResult: result, |
| 570 | + edits: try ConcurrentEdits(concurrent: [edit2]), |
| 571 | + reusedNodeCallback: { node in |
| 572 | + if node.kind == .codeBlockItem { reusedCodeBlockItems += 1 } |
| 573 | + }, |
| 574 | + compactArenaThreshold: nil |
| 575 | + ) |
| 576 | + ) |
| 577 | + XCTAssertEqual(result2.tree.description, afterSecond) |
| 578 | + XCTAssertGreaterThan(reusedCodeBlockItems, 0, "incremental parse after compaction should reuse nodes") |
| 579 | + } |
469 | 580 | } |
0 commit comments