Skip to content

Commit 84c5fe1

Browse files
committed
refactor: add CRLF-aware newline handling and tab support
Add helper functions for cross-platform newline handling: - findNewline: finds first CRLF or LF - skipNewline: advances past CRLF or LF - skipWhitespace: skips spaces, tabs, and newlines - endsWithNewline: checks for trailing CRLF or LF Add NewlineCRLF and NewlineLF constants. Check CRLF first to handle both Windows and Unix line endings without normalization. Signed-off-by: Jose Alekhinne <alekhinejose@gmail.com>
1 parent f7aac5a commit 84c5fe1

2 files changed

Lines changed: 84 additions & 28 deletions

File tree

internal/cli/add/append.go

Lines changed: 71 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,11 @@ func AppendEntry(
4646
idx := strings.Index(existingStr, targetSection)
4747
if idx != -1 {
4848
// Find the end of the section header line
49-
lineEnd := strings.Index(existingStr[idx:], "\n")
49+
lineEnd := findNewline(existingStr[idx:])
5050
if lineEnd != -1 {
51-
insertPoint := idx + lineEnd + 1
52-
return []byte(existingStr[:insertPoint] + "\n" +
51+
insertPoint := idx + lineEnd
52+
insertPoint = skipNewline(existingStr, insertPoint)
53+
return []byte(existingStr[:insertPoint] + config.NewlineLF +
5354
entry + existingStr[insertPoint:])
5455
}
5556
}
@@ -66,10 +67,10 @@ func AppendEntry(
6667
}
6768

6869
// Default (conventions): append at the end
69-
if !strings.HasSuffix(existingStr, "\n") {
70-
existingStr += "\n"
70+
if !endsWithNewline(existingStr) {
71+
existingStr += config.NewlineLF
7172
}
72-
return []byte(existingStr + "\n" + entry)
73+
return []byte(existingStr + config.NewlineLF + entry)
7374
}
7475

7576
// prependAfterHeader inserts an entry after a header line.
@@ -89,7 +90,7 @@ func prependAfterHeader(content, entry, header string) []byte {
8990
entryIdx := strings.Index(content, "## [")
9091
if entryIdx != -1 {
9192
// Insert before the first entry, with separator after
92-
return []byte(content[:entryIdx] + entry + "\n---\n\n" + content[entryIdx:])
93+
return []byte(content[:entryIdx] + entry + config.NewlineLF + "---" + config.NewlineLF + config.NewlineLF + content[entryIdx:])
9394
}
9495

9596
// No existing entries - find header and insert after it
@@ -111,12 +112,12 @@ func prependAfterSeparator(content, entry string) []byte {
111112
entryIdx := strings.Index(content, "- **[")
112113
if entryIdx != -1 {
113114
// Insert before the first entry
114-
return []byte(content[:entryIdx] + entry + "\n" + content[entryIdx:])
115+
return []byte(content[:entryIdx] + entry + config.NewlineLF + content[entryIdx:])
115116
}
116117

117118
// Also check for section-style learnings "## ["
118119
if entryIdx = strings.Index(content, "## ["); entryIdx != -1 {
119-
return []byte(content[:entryIdx] + entry + "\n---\n\n" + content[entryIdx:])
120+
return []byte(content[:entryIdx] + entry + config.NewlineLF + "---" + config.NewlineLF + config.NewlineLF + content[entryIdx:])
120121
}
121122

122123
// No existing entries - find header and insert after it
@@ -125,7 +126,7 @@ func prependAfterSeparator(content, entry string) []byte {
125126

126127
// insertAfterHeader finds a header line and inserts content after it.
127128
//
128-
// Skips blank lines and HTML comments between the header and insertion point.
129+
// Skips blank lines and ctx markers between the header and insertion point.
129130
// Falls back to appending at the end if header is not found.
130131
//
131132
// Parameters:
@@ -138,23 +139,22 @@ func prependAfterSeparator(content, entry string) []byte {
138139
func insertAfterHeader(content, entry, header string) []byte {
139140
idx := strings.Index(content, header)
140141
if idx != -1 {
141-
lineEnd := strings.Index(content[idx:], "\n")
142+
lineEnd := findNewline(content[idx:])
142143
if lineEnd != -1 {
143-
insertPoint := idx + lineEnd + 1
144-
// Skip blank lines and comments
144+
insertPoint := idx + lineEnd
145+
insertPoint = skipNewline(content, insertPoint)
146+
// Skip blank lines and ctx markers
145147
for insertPoint < len(content) {
146-
if content[insertPoint] == '\n' {
147-
insertPoint++
148+
if n := skipNewline(content, insertPoint); n > insertPoint {
149+
insertPoint = n
148150
} else if insertPoint+len(config.CommentOpen) <= len(content) &&
149151
content[insertPoint:insertPoint+len(config.CommentOpen)] == config.CommentOpen {
150-
// Skip HTML comment
152+
// Skip ctx marker
151153
endComment := strings.Index(content[insertPoint:], config.CommentClose)
152154
if endComment != -1 {
153155
insertPoint += endComment + len(config.CommentClose)
154-
// Skip trailing whitespace after comment
155-
for insertPoint < len(content) && (content[insertPoint] == '\n' || content[insertPoint] == ' ') {
156-
insertPoint++
157-
}
156+
// Skip trailing whitespace after marker
157+
insertPoint = skipWhitespace(content, insertPoint)
158158
} else {
159159
break
160160
}
@@ -167,8 +167,56 @@ func insertAfterHeader(content, entry, header string) []byte {
167167
}
168168

169169
// Fallback: append at the end
170-
if !strings.HasSuffix(content, "\n") {
171-
content += "\n"
170+
if !endsWithNewline(content) {
171+
content += config.NewlineLF
172172
}
173-
return []byte(content + "\n" + entry)
173+
return []byte(content + config.NewlineLF + entry)
174+
}
175+
176+
// findNewline returns the index of the first newline (CRLF or LF) in s.
177+
// Returns -1 if no newline is found.
178+
func findNewline(s string) int {
179+
for i := 0; i < len(s); i++ {
180+
if i+1 < len(s) && s[i] == '\r' && s[i+1] == '\n' {
181+
return i
182+
}
183+
if s[i] == '\n' {
184+
return i
185+
}
186+
}
187+
return -1
188+
}
189+
190+
// skipNewline advances pos past a newline (CRLF or LF) if present.
191+
// Returns the new position (unchanged if no newline at pos).
192+
func skipNewline(s string, pos int) int {
193+
if pos >= len(s) {
194+
return pos
195+
}
196+
if pos+1 < len(s) && s[pos] == '\r' && s[pos+1] == '\n' {
197+
return pos + 2
198+
}
199+
if s[pos] == '\n' {
200+
return pos + 1
201+
}
202+
return pos
203+
}
204+
205+
// skipWhitespace advances pos past any whitespace (space, tab, newline).
206+
func skipWhitespace(s string, pos int) int {
207+
for pos < len(s) {
208+
if n := skipNewline(s, pos); n > pos {
209+
pos = n
210+
} else if s[pos] == ' ' || s[pos] == '\t' {
211+
pos++
212+
} else {
213+
break
214+
}
215+
}
216+
return pos
217+
}
218+
219+
// endsWithNewline reports whether s ends with a newline (CRLF or LF).
220+
func endsWithNewline(s string) bool {
221+
return strings.HasSuffix(s, config.NewlineCRLF) || strings.HasSuffix(s, config.NewlineLF)
174222
}

internal/config/config.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
package config
22

33
const (
4-
CommentClose = "-->"
5-
CommentOpen = "<!--"
6-
CtxMarkerEnd = "<!-- ctx:end -->"
7-
CtxMarkerStart = "<!-- ctx:context -->"
8-
DirArchive = "archive"
4+
CommentClose = "-->"
5+
CommentOpen = "<!--"
6+
CtxMarkerEnd = "<!-- ctx:end -->"
7+
CtxMarkerStart = "<!-- ctx:context -->"
8+
DirArchive = "archive"
9+
10+
// NewlineCRLF is Windows new line.
11+
//
12+
// We check NewlineCRLF first, then NewlineLF to handle both formats.
13+
NewlineCRLF = "\r\n"
14+
// NewlineLF is Unix new line.
15+
NewlineLF = "\n"
16+
917
DirClaude = ".claude"
1018
DirClaudeHooks = ".claude/hooks"
1119
DirContext = ".context"

0 commit comments

Comments
 (0)