Skip to content

Commit 5810e07

Browse files
committed
Use extension methods.
1 parent 5fe38a2 commit 5810e07

File tree

10 files changed

+135
-169
lines changed

10 files changed

+135
-169
lines changed

.cursorrules

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,19 @@ When invoking `swiftc`, pass `-module-cache-path` with a temporary directory to
4444

4545
Avoid putting type information in non-type names. Variable names should reflect the role of the value, if at all possible. If you can't find a better name for a variable than one that reflects its type, use a single letter name.
4646

47+
Extension property and method names must be clear about what they return. Use gerunds (verbs ending in -ing) for transformations (e.g., `normalizingLineEndings()` not `normalizedLineEndings`).
48+
49+
Avoid computed properties that aren't O(1). Any operation that scans or transforms the entire collection should be a method, not a property.
50+
51+
Only use intermediate variables where they reduce nesting or significantly reduce code size. Variables used only once should be avoided.
52+
4753
## Documentation Guidelines
4854

4955
- Every declaration outside a function body needs a doc comment except for tests and declarations that satisfy protocol requirements.
5056
- Capture the complete contract in concise summaries following better-code contracts principles
5157
- Avoid verbose parameter blocks when good naming and a clear summary suffice
5258
- Name parameters in summary (e.g., "`nodes`'s text") to show their roles precisely
59+
- Use `self` rather than "this T" where T is the type of `self` (e.g., "`self` decoded as UTF-8" not "This data decoded as UTF-8")
5360
- Raise the level of abstraction - focus on semantic meaning rather than implementation details
5461
- Doc comments describe the contract (what, not how). Implementation details and rationale belong in regular comments inside the function body.
5562
- When multiple sentences are needed, separate them with a blank line

Sources/gyb-swift/AST.swift

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,16 @@ struct SubstitutionNode: ASTNode {
5050
/// Note: Nesting is handled by Swift's compiler, not by the parser.
5151
typealias AST = [ASTNode]
5252

53-
// MARK: - Helper Functions
54-
55-
/// Extracts code content from a gybBlockOpen token (%{...}%).
56-
/// Removes the %{ prefix, }% suffix, and optional trailing newline.
57-
private func extractCodeFromBlockToken(_ token: Substring) -> Substring {
58-
let suffixLength = token.last?.isNewline == true ? 3 : 2 // }%\n or }%
59-
return token.dropFirst(2).dropLast(suffixLength)
53+
// MARK: - Helper Extensions
54+
55+
extension StringProtocol {
56+
/// The code content from a gyb block (%{...}%).
57+
///
58+
/// Removes the %{ prefix, }% suffix, and optional trailing newline.
59+
var codeBlockContent: SubSequence {
60+
let suffixLength = last?.isNewline == true ? 3 : 2 // }%\n or }%
61+
return dropFirst(2).dropLast(suffixLength)
62+
}
6063
}
6164

6265
// MARK: - Parse Context
@@ -93,7 +96,7 @@ struct ParseContext {
9396
case .gybBlock:
9497
// Extract code between %{ and }%
9598
return CodeNode(
96-
code: extractCodeFromBlockToken(token.text),
99+
code: token.text.codeBlockContent,
97100
sourcePosition: token.text.startIndex)
98101

99102
case .symbol:

Sources/gyb-swift/ExecutionContext.swift

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ struct CodeGenerator {
3838
/// Whether to emit line directives in the template output.
3939
let emitLineDirectives: Bool
4040

41-
/// Precomputed line start positions for efficient line number calculation.
42-
private let lineStarts: [String.Index]
41+
/// Precomputed line boundaries for efficient line number calculation.
42+
private let lineBounds: [String.Index]
4343

4444
init(
4545
templateText: String,
@@ -51,7 +51,7 @@ struct CodeGenerator {
5151
self.filename = filename
5252
self.lineDirective = lineDirective
5353
self.emitLineDirectives = emitLineDirectives
54-
self.lineStarts = getLineStarts(templateText)
54+
self.lineBounds = templateText.lineBounds()
5555
}
5656

5757
/// Returns Swift code executing `nodes`, batching consecutive nodes of the same type.
@@ -64,9 +64,8 @@ struct CodeGenerator {
6464
let lines = chunks.map { chunk -> String in
6565
// Code nodes: emit with source location directive at the start
6666
if let firstCode = chunk.first as? CodeNode {
67-
let lineNumber =
68-
getLineNumber(
69-
for: firstCode.sourcePosition, in: templateText, lineStarts: lineStarts)
67+
let lineNumber = templateText.lineNumber(
68+
at: firstCode.sourcePosition, lineBounds: lineBounds)
7069
let sourceLocationDirective = formatSourceLocation(
7170
#"#sourceLocation(file: "\(file)", line: \(line))"#,
7271
filename: filename,
@@ -147,7 +146,7 @@ struct CodeGenerator {
147146
throw GYBError.executionFailed(filename: filename, errorOutput: result.stderr)
148147
}
149148

150-
return normalizeLineEndings(result.stdout)
149+
return result.stdout.normalizingLineEndings()
151150
}
152151

153152
/// Executes `swiftCode` by compiling and running the executable.
@@ -203,7 +202,8 @@ struct CodeGenerator {
203202
/// Compiles Swift source file to executable.
204203
private func compileSwiftCode(source: URL, output: URL, moduleCache: URL) throws {
205204
let result = try resultsOfRunning(
206-
["swiftc",
205+
[
206+
"swiftc",
207207
source.platformString,
208208
"-o", output.platformString,
209209
"-module-cache-path", moduleCache.platformString,
@@ -222,7 +222,7 @@ struct CodeGenerator {
222222
throw GYBError.executionFailed(filename: filename, errorOutput: result.stderr)
223223
}
224224

225-
return normalizeLineEndings(result.stdout)
225+
return result.stdout.normalizingLineEndings()
226226
}
227227

228228
/// Returns the start position of `nodes`'s first element.
@@ -259,7 +259,7 @@ struct CodeGenerator {
259259

260260
// Always emit #sourceLocation in the intermediate Swift code for error reporting
261261
let index = sourceLocationIndex(for: nodes)
262-
let lineNumber = getLineNumber(for: index, in: templateText, lineStarts: lineStarts)
262+
let lineNumber = templateText.lineNumber(at: index, lineBounds: lineBounds)
263263
let sourceLocationDirective = formatSourceLocation(
264264
#"#sourceLocation(file: "\(file)", line: \(line))"#,
265265
filename: filename,
@@ -275,7 +275,7 @@ struct CodeGenerator {
275275
output = outputDirective + "\n" + output
276276
}
277277

278-
let escaped = escapeForSwiftMultilineString(output)
278+
let escaped = output.escapedForSwiftMultilineString()
279279
swiftCode.append(
280280
#"print(""""#
281281
+ "\n\(escaped)\n"
@@ -290,14 +290,21 @@ struct CodeGenerator {
290290
}
291291
}
292292

293-
/// Returns `text` escaped for Swift multiline string literals, preserving `\(...)` interpolations.
294-
private func escapeForSwiftMultilineString(_ text: String) -> String {
295-
return
296-
text
297-
.replacingOccurrences(of: #"\"#, with: #"\\"#)
298-
.replacingOccurrences(of: #"""""#, with: #"\"\"\""#)
299-
// Undo escaping for interpolations so \(expr) is undisturbed.
300-
.replacingOccurrences(of: #"\\("#, with: #"\("#)
293+
extension String {
294+
/// Returns `self` escaped for Swift multiline string literals, preserving `\(...)` interpolations.
295+
func escapedForSwiftMultilineString() -> String {
296+
replacingOccurrences(of: #"\"#, with: #"\\"#)
297+
.replacingOccurrences(of: #"""""#, with: #"\"\"\""#)
298+
// Undo escaping for interpolations so \(expr) is undisturbed.
299+
.replacingOccurrences(of: #"\\("#, with: #"\("#)
300+
}
301+
302+
/// Returns `self` with line endings normalized to Unix style (`\n`) for cross-platform consistency.
303+
///
304+
/// On Windows, Swift's print() outputs `\r\n` line endings, but our tests expect `\n`.
305+
func normalizingLineEndings() -> String {
306+
replacingOccurrences(of: "\r\n", with: "\n")
307+
}
301308
}
302309

303310
/// Returns `template`'s `\(file)` and `\(line)` placeholders replaced by `filename` and `line`.
@@ -307,10 +314,3 @@ private func formatSourceLocation(_ template: String, filename: String, line: In
307314
.replacingOccurrences(of: #"\(file)"#, with: filename)
308315
.replacingOccurrences(of: #"\(line)"#, with: "\(line)")
309316
}
310-
311-
/// Normalizes line endings to Unix style (`\n`) for cross-platform consistency.
312-
///
313-
/// On Windows, Swift's print() outputs `\r\n` line endings, but our tests expect `\n`.
314-
private func normalizeLineEndings(_ text: String) -> String {
315-
return text.replacingOccurrences(of: "\r\n", with: "\n")
316-
}

Sources/gyb-swift/ProcessUtilities.swift

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,8 @@ func resultsOfRunning(_ commandLine: [String], setSDKRoot: Bool = true) throws -
178178
p.waitUntilExit()
179179

180180
// Retrieve the data (blocks until background reads complete)
181-
let stdout = try decodeUTF8(stdoutData(), as: "\(command) stdout")
182-
let stderr = try decodeUTF8(stderrData(), as: "\(command) stderr")
181+
let stdout = try stdoutData().asUTF8(source: "\(command) stdout")
182+
let stderr = try stderrData().asUTF8(source: "\(command) stderr")
183183

184184
return ProcessResults(
185185
stdout: stdout,
@@ -218,10 +218,12 @@ private func readPipeInBackground(_ pipe: Pipe) -> () -> Data {
218218
}
219219
}
220220

221-
/// Decodes `data` as UTF-8, throwing `Failure` with `source` name if it fails.
222-
private func decodeUTF8(_ data: Data, as source: String) throws -> String {
223-
guard let result = String(data: data, encoding: .utf8) else {
224-
throw Failure("\(source) not UTF-8 encoded")
221+
extension Data {
222+
/// `self` decoded as UTF-8.
223+
func asUTF8(source: String) throws -> String {
224+
guard let result = String(data: self, encoding: .utf8) else {
225+
throw Failure("\(source) not UTF-8 encoded")
226+
}
227+
return result
225228
}
226-
return result
227229
}

Sources/gyb-swift/StringUtilities.swift

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@ import Foundation
33

44
// MARK: - String Utilities
55

6-
/// Returns the start of each line in `text` followed by `text.endIndex`.
7-
func getLineStarts(_ text: String) -> [String.Index] {
8-
text.split(omittingEmptySubsequences: false) { $0.isNewline }
9-
.map(\.startIndex) + [text.endIndex]
10-
}
6+
extension String {
7+
/// Returns the start of each line followed by `endIndex`.
8+
func lineBounds() -> [String.Index] {
9+
split(omittingEmptySubsequences: false) { $0.isNewline }
10+
.map(\.startIndex) + [endIndex]
11+
}
1112

12-
/// Returns the 1-based line number for a given index in the text.
13-
func getLineNumber(for index: String.Index, in text: String, lineStarts: [String.Index]) -> Int {
14-
// Use binary search to find the first line start that is after the given index
15-
// lineStarts is sorted, so partitioningIndex performs O(log N) binary search
16-
return lineStarts.partitioningIndex { $0 > index }
13+
/// Returns the 1-based line number for `index`.
14+
func lineNumber(at index: String.Index, lineBounds: [String.Index]) -> Int {
15+
// Use binary search to find the first line boundary that is after the given index
16+
// lineBounds is sorted, so partitioningIndex performs O(log N) binary search
17+
return lineBounds.partitioningIndex { $0 > index }
18+
}
1719
}

Sources/gyb-swift/Tokenization.swift

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ struct TemplateTokens: Sequence, IteratorProtocol {
116116
let codePart = remainingText.dropFirst(2)
117117

118118
// Use Swift tokenizer to find the real closing }
119-
let closeIndex = tokenizeSwiftToUnmatchedCloseCurly(codePart)
119+
let closeIndex = codePart.indexOfFirstSwiftUnmatchedCloseCurly()
120120

121121
if closeIndex < codePart.endIndex {
122122
// Include ${ + code + }
@@ -138,7 +138,7 @@ struct TemplateTokens: Sequence, IteratorProtocol {
138138
let codePart = remainingText.dropFirst(2)
139139

140140
// Use Swift tokenizer to find the real closing }
141-
let closeIndex = tokenizeSwiftToUnmatchedCloseCurly(codePart)
141+
let closeIndex = codePart.indexOfFirstSwiftUnmatchedCloseCurly()
142142

143143
if closeIndex < codePart.endIndex {
144144
let afterClose = codePart.index(after: closeIndex)
@@ -190,30 +190,32 @@ struct TemplateTokens: Sequence, IteratorProtocol {
190190

191191
// MARK: - Swift Tokenization
192192

193-
/// Returns the index of the first unmatched `}` in Swift code,
194-
/// or `code.endIndex` if none exists.
195-
func tokenizeSwiftToUnmatchedCloseCurly(_ code: Substring) -> String.Index {
196-
// Parse to get tokens, which automatically handles braces within strings and comments.
197-
let parsed = Parser.parse(source: String(code))
198-
199-
var nesting = 0
200-
for token in parsed.tokens(viewMode: .sourceAccurate) {
201-
if token.tokenKind == .leftBrace {
202-
nesting += 1
203-
} else if token.tokenKind == .rightBrace {
204-
nesting -= 1
205-
if nesting < 0 {
206-
return convertUTF8OffsetToIndex(
207-
token.positionAfterSkippingLeadingTrivia.utf8Offset, in: code)
193+
extension StringProtocol {
194+
/// The index of the first unmatched `}` when parsed as Swift code, or `endIndex` if none exists.
195+
///
196+
/// Uses SwiftSyntax to properly handle braces within strings and comments.
197+
func indexOfFirstSwiftUnmatchedCloseCurly() -> String.Index {
198+
// Parse to get tokens, which automatically handles braces within strings and comments.
199+
let parsed = Parser.parse(source: String(self))
200+
201+
var nesting = 0
202+
for token in parsed.tokens(viewMode: .sourceAccurate) {
203+
if token.tokenKind == .leftBrace {
204+
nesting += 1
205+
} else if token.tokenKind == .rightBrace {
206+
nesting -= 1
207+
if nesting < 0 {
208+
return indexFromUTF8Offset(token.positionAfterSkippingLeadingTrivia.utf8Offset)
209+
}
208210
}
209211
}
210-
}
211212

212-
return code.endIndex
213-
}
213+
return endIndex
214+
}
214215

215-
/// Converts a UTF-8 byte offset to a `String.Index` within the given substring.
216-
private func convertUTF8OffsetToIndex(_ utf8Offset: Int, in code: Substring) -> String.Index {
217-
let utf8Index = code.utf8.index(code.utf8.startIndex, offsetBy: utf8Offset)
218-
return String.Index(utf8Index, within: code) ?? code.endIndex
216+
/// Converts a UTF-8 byte offset to a `String.Index`.
217+
func indexFromUTF8Offset(_ utf8Offset: Int) -> String.Index {
218+
let utf8Index = utf8.index(utf8.startIndex, offsetBy: utf8Offset)
219+
return String.Index(utf8Index, within: self) ?? endIndex
220+
}
219221
}

Tests/gyb-swiftTests/ExecutionTests.swift

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,49 +4,33 @@ import Testing
44

55
@Test("execute simple literal template")
66
func execute_literalTemplate() throws {
7-
let text = "Hello, World!"
8-
let result = try execute(text)
9-
10-
#expect(result == "Hello, World!")
7+
#expect(try execute("Hello, World!") == "Hello, World!")
118
}
129

1310
@Test("execute template with escaped symbols")
1411
func execute_templateWithEscapedSymbols() throws {
15-
let text = "Price: $$50"
16-
let result = try execute(text)
17-
18-
#expect(result == "Price: $50")
12+
#expect(try execute("Price: $$50") == "Price: $50")
1913
}
2014

2115
@Test("substitution with bound variable")
2216
func substitution_withSimpleBinding() throws {
23-
let text = "x = ${x}"
24-
let result = try execute(text, bindings: ["x": "42"])
25-
#expect(result == "x = 42")
17+
#expect(try execute("x = ${x}", bindings: ["x": "42"]) == "x = 42")
2618
}
2719

2820
@Test("empty template")
2921
func execute_emptyTemplate() throws {
30-
let text = ""
31-
let result = try execute(text)
32-
33-
#expect(result == "")
22+
#expect(try execute("") == "")
3423
}
3524

3625
@Test("template with only whitespace")
3726
func execute_whitespaceOnly() throws {
3827
let text = " \n\t\n "
39-
let result = try execute(text)
40-
41-
#expect(result == text)
28+
#expect(try execute(text) == text)
4229
}
4330

4431
@Test("mixed literal and symbols")
4532
func execute_mixedLiteralsAndSymbols() throws {
46-
let text = "Regular $$text with %%symbols"
47-
let result = try execute(text)
48-
49-
#expect(result == "Regular $text with %symbols")
33+
#expect(try execute("Regular $$text with %%symbols") == "Regular $text with %symbols")
5034
}
5135

5236
@Test("malformed substitutions handled gracefully")
@@ -64,9 +48,7 @@ func parse_malformedSubstitution() {
6448
@Test("multiple literals in sequence")
6549
func execute_multipleLiterals() throws {
6650
let text = "First line\nSecond line\nThird line"
67-
let result = try execute(text)
68-
69-
#expect(result == text)
51+
#expect(try execute(text) == text)
7052
}
7153

7254
@Test("line directive generation")

0 commit comments

Comments
 (0)