diff.go 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978
  1. package diff
  2. import (
  3. "bufio"
  4. "bytes"
  5. "fmt"
  6. "image/color"
  7. "io"
  8. "regexp"
  9. "strconv"
  10. "strings"
  11. "sync"
  12. "github.com/alecthomas/chroma/v2"
  13. "github.com/alecthomas/chroma/v2/formatters"
  14. "github.com/alecthomas/chroma/v2/lexers"
  15. "github.com/alecthomas/chroma/v2/styles"
  16. "github.com/charmbracelet/lipgloss/v2"
  17. "github.com/charmbracelet/lipgloss/v2/compat"
  18. "github.com/charmbracelet/x/ansi"
  19. "github.com/sergi/go-diff/diffmatchpatch"
  20. stylesi "github.com/sst/opencode/internal/styles"
  21. "github.com/sst/opencode/internal/theme"
  22. "github.com/sst/opencode/internal/util"
  23. )
  24. // -------------------------------------------------------------------------
  25. // Core Types
  26. // -------------------------------------------------------------------------
  27. // LineType represents the kind of line in a diff.
  28. type LineType int
  29. const (
  30. LineContext LineType = iota // Line exists in both files
  31. LineAdded // Line added in the new file
  32. LineRemoved // Line removed from the old file
  33. )
  34. // Segment represents a portion of a line for intra-line highlighting
  35. type Segment struct {
  36. Start int
  37. End int
  38. Type LineType
  39. Text string
  40. }
  41. // DiffLine represents a single line in a diff
  42. type DiffLine struct {
  43. OldLineNo int // Line number in old file (0 for added lines)
  44. NewLineNo int // Line number in new file (0 for removed lines)
  45. Kind LineType // Type of line (added, removed, context)
  46. Content string // Content of the line
  47. Segments []Segment // Segments for intraline highlighting
  48. }
  49. // Hunk represents a section of changes in a diff
  50. type Hunk struct {
  51. Header string
  52. Lines []DiffLine
  53. }
  54. // DiffResult contains the parsed result of a diff
  55. type DiffResult struct {
  56. OldFile string
  57. NewFile string
  58. Hunks []Hunk
  59. }
  60. // linePair represents a pair of lines for side-by-side display
  61. type linePair struct {
  62. left *DiffLine
  63. right *DiffLine
  64. }
  65. // -------------------------------------------------------------------------
  66. // Side-by-Side Configuration
  67. // -------------------------------------------------------------------------
  68. // SideBySideConfig configures the rendering of side-by-side diffs
  69. type SideBySideConfig struct {
  70. TotalWidth int
  71. }
  72. // SideBySideOption modifies a SideBySideConfig
  73. type SideBySideOption func(*SideBySideConfig)
  74. // NewSideBySideConfig creates a SideBySideConfig with default values
  75. func NewSideBySideConfig(opts ...SideBySideOption) SideBySideConfig {
  76. config := SideBySideConfig{
  77. TotalWidth: 160, // Default width for side-by-side view
  78. }
  79. for _, opt := range opts {
  80. opt(&config)
  81. }
  82. return config
  83. }
  84. // WithTotalWidth sets the total width for side-by-side view
  85. func WithTotalWidth(width int) SideBySideOption {
  86. return func(s *SideBySideConfig) {
  87. if width > 0 {
  88. s.TotalWidth = width
  89. }
  90. }
  91. }
  92. // -------------------------------------------------------------------------
  93. // Unified Configuration
  94. // -------------------------------------------------------------------------
  95. // UnifiedConfig configures the rendering of unified diffs
  96. type UnifiedConfig struct {
  97. Width int
  98. }
  99. // UnifiedOption modifies a UnifiedConfig
  100. type UnifiedOption func(*UnifiedConfig)
  101. // NewUnifiedConfig creates a UnifiedConfig with default values
  102. func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig {
  103. config := UnifiedConfig{
  104. Width: 80, // Default width for unified view
  105. }
  106. for _, opt := range opts {
  107. opt(&config)
  108. }
  109. return config
  110. }
  111. // WithWidth sets the width for unified view
  112. func WithWidth(width int) UnifiedOption {
  113. return func(u *UnifiedConfig) {
  114. if width > 0 {
  115. u.Width = width
  116. }
  117. }
  118. }
  119. // -------------------------------------------------------------------------
  120. // Diff Parsing
  121. // -------------------------------------------------------------------------
  122. // ParseUnifiedDiff parses a unified diff format string into structured data
  123. func ParseUnifiedDiff(diff string) (DiffResult, error) {
  124. var result DiffResult
  125. var currentHunk *Hunk
  126. result.Hunks = make([]Hunk, 0, 10) // Pre-allocate with a reasonable capacity
  127. scanner := bufio.NewScanner(strings.NewReader(diff))
  128. var oldLine, newLine int
  129. inFileHeader := true
  130. for scanner.Scan() {
  131. line := scanner.Text()
  132. if inFileHeader {
  133. if strings.HasPrefix(line, "--- a/") {
  134. result.OldFile = line[6:]
  135. continue
  136. }
  137. if strings.HasPrefix(line, "+++ b/") {
  138. result.NewFile = line[6:]
  139. inFileHeader = false
  140. continue
  141. }
  142. }
  143. if strings.HasPrefix(line, "@@") {
  144. if currentHunk != nil {
  145. result.Hunks = append(result.Hunks, *currentHunk)
  146. }
  147. currentHunk = &Hunk{
  148. Header: line,
  149. Lines: make([]DiffLine, 0, 10), // Pre-allocate
  150. }
  151. // Manual parsing of hunk header is faster than regex
  152. parts := strings.Split(line, " ")
  153. if len(parts) > 2 {
  154. oldRange := strings.Split(parts[1][1:], ",")
  155. newRange := strings.Split(parts[2][1:], ",")
  156. oldLine, _ = strconv.Atoi(oldRange[0])
  157. newLine, _ = strconv.Atoi(newRange[0])
  158. }
  159. continue
  160. }
  161. if strings.HasPrefix(line, "\\ No newline at end of file") || currentHunk == nil {
  162. continue
  163. }
  164. var dl DiffLine
  165. dl.Content = line
  166. if len(line) > 0 {
  167. switch line[0] {
  168. case '+':
  169. dl.Kind = LineAdded
  170. dl.NewLineNo = newLine
  171. dl.Content = line[1:]
  172. newLine++
  173. case '-':
  174. dl.Kind = LineRemoved
  175. dl.OldLineNo = oldLine
  176. dl.Content = line[1:]
  177. oldLine++
  178. default: // context line
  179. dl.Kind = LineContext
  180. dl.OldLineNo = oldLine
  181. dl.NewLineNo = newLine
  182. oldLine++
  183. newLine++
  184. }
  185. } else { // empty context line
  186. dl.Kind = LineContext
  187. dl.OldLineNo = oldLine
  188. dl.NewLineNo = newLine
  189. oldLine++
  190. newLine++
  191. }
  192. currentHunk.Lines = append(currentHunk.Lines, dl)
  193. }
  194. if currentHunk != nil {
  195. result.Hunks = append(result.Hunks, *currentHunk)
  196. }
  197. return result, scanner.Err()
  198. }
  199. // HighlightIntralineChanges updates lines in a hunk to show character-level differences
  200. func HighlightIntralineChanges(h *Hunk) {
  201. var updated []DiffLine
  202. dmp := diffmatchpatch.New()
  203. for i := 0; i < len(h.Lines); i++ {
  204. // Look for removed line followed by added line
  205. if i+1 < len(h.Lines) &&
  206. h.Lines[i].Kind == LineRemoved &&
  207. h.Lines[i+1].Kind == LineAdded {
  208. oldLine := h.Lines[i]
  209. newLine := h.Lines[i+1]
  210. // Find character-level differences
  211. patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
  212. patches = dmp.DiffCleanupSemantic(patches)
  213. patches = dmp.DiffCleanupMerge(patches)
  214. patches = dmp.DiffCleanupEfficiency(patches)
  215. segments := make([]Segment, 0)
  216. removeStart := 0
  217. addStart := 0
  218. for _, patch := range patches {
  219. switch patch.Type {
  220. case diffmatchpatch.DiffDelete:
  221. segments = append(segments, Segment{
  222. Start: removeStart,
  223. End: removeStart + len(patch.Text),
  224. Type: LineRemoved,
  225. Text: patch.Text,
  226. })
  227. removeStart += len(patch.Text)
  228. case diffmatchpatch.DiffInsert:
  229. segments = append(segments, Segment{
  230. Start: addStart,
  231. End: addStart + len(patch.Text),
  232. Type: LineAdded,
  233. Text: patch.Text,
  234. })
  235. addStart += len(patch.Text)
  236. default:
  237. // Context text, no highlighting needed
  238. removeStart += len(patch.Text)
  239. addStart += len(patch.Text)
  240. }
  241. }
  242. oldLine.Segments = segments
  243. newLine.Segments = segments
  244. updated = append(updated, oldLine, newLine)
  245. i++ // Skip the next line as we've already processed it
  246. } else {
  247. updated = append(updated, h.Lines[i])
  248. }
  249. }
  250. h.Lines = updated
  251. }
  252. // pairLines converts a flat list of diff lines to pairs for side-by-side display
  253. func pairLines(lines []DiffLine) []linePair {
  254. var pairs []linePair
  255. i := 0
  256. for i < len(lines) {
  257. switch lines[i].Kind {
  258. case LineRemoved:
  259. // Check if the next line is an addition, if so pair them
  260. if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
  261. pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
  262. i += 2
  263. } else {
  264. pairs = append(pairs, linePair{left: &lines[i], right: nil})
  265. i++
  266. }
  267. case LineAdded:
  268. pairs = append(pairs, linePair{left: nil, right: &lines[i]})
  269. i++
  270. case LineContext:
  271. pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
  272. i++
  273. }
  274. }
  275. return pairs
  276. }
  277. // -------------------------------------------------------------------------
  278. // Syntax Highlighting
  279. // -------------------------------------------------------------------------
  280. // SyntaxHighlight applies syntax highlighting to text based on file extension
  281. func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.Color) error {
  282. t := theme.CurrentTheme()
  283. // Determine the language lexer to use
  284. l := lexers.Match(fileName)
  285. if l == nil {
  286. l = lexers.Analyse(source)
  287. }
  288. if l == nil {
  289. l = lexers.Fallback
  290. }
  291. l = chroma.Coalesce(l)
  292. // Get the formatter
  293. f := formatters.Get(formatter)
  294. if f == nil {
  295. f = formatters.Fallback
  296. }
  297. // Dynamic theme based on current theme values
  298. syntaxThemeXml := fmt.Sprintf(`
  299. <style name="opencode-theme">
  300. <!-- Base colors -->
  301. <entry type="Background" style="bg:%s"/>
  302. <entry type="Text" style="%s"/>
  303. <entry type="Other" style="%s"/>
  304. <entry type="Error" style="%s"/>
  305. <!-- Keywords -->
  306. <entry type="Keyword" style="%s"/>
  307. <entry type="KeywordConstant" style="%s"/>
  308. <entry type="KeywordDeclaration" style="%s"/>
  309. <entry type="KeywordNamespace" style="%s"/>
  310. <entry type="KeywordPseudo" style="%s"/>
  311. <entry type="KeywordReserved" style="%s"/>
  312. <entry type="KeywordType" style="%s"/>
  313. <!-- Names -->
  314. <entry type="Name" style="%s"/>
  315. <entry type="NameAttribute" style="%s"/>
  316. <entry type="NameBuiltin" style="%s"/>
  317. <entry type="NameBuiltinPseudo" style="%s"/>
  318. <entry type="NameClass" style="%s"/>
  319. <entry type="NameConstant" style="%s"/>
  320. <entry type="NameDecorator" style="%s"/>
  321. <entry type="NameEntity" style="%s"/>
  322. <entry type="NameException" style="%s"/>
  323. <entry type="NameFunction" style="%s"/>
  324. <entry type="NameLabel" style="%s"/>
  325. <entry type="NameNamespace" style="%s"/>
  326. <entry type="NameOther" style="%s"/>
  327. <entry type="NameTag" style="%s"/>
  328. <entry type="NameVariable" style="%s"/>
  329. <entry type="NameVariableClass" style="%s"/>
  330. <entry type="NameVariableGlobal" style="%s"/>
  331. <entry type="NameVariableInstance" style="%s"/>
  332. <!-- Literals -->
  333. <entry type="Literal" style="%s"/>
  334. <entry type="LiteralDate" style="%s"/>
  335. <entry type="LiteralString" style="%s"/>
  336. <entry type="LiteralStringBacktick" style="%s"/>
  337. <entry type="LiteralStringChar" style="%s"/>
  338. <entry type="LiteralStringDoc" style="%s"/>
  339. <entry type="LiteralStringDouble" style="%s"/>
  340. <entry type="LiteralStringEscape" style="%s"/>
  341. <entry type="LiteralStringHeredoc" style="%s"/>
  342. <entry type="LiteralStringInterpol" style="%s"/>
  343. <entry type="LiteralStringOther" style="%s"/>
  344. <entry type="LiteralStringRegex" style="%s"/>
  345. <entry type="LiteralStringSingle" style="%s"/>
  346. <entry type="LiteralStringSymbol" style="%s"/>
  347. <!-- Numbers -->
  348. <entry type="LiteralNumber" style="%s"/>
  349. <entry type="LiteralNumberBin" style="%s"/>
  350. <entry type="LiteralNumberFloat" style="%s"/>
  351. <entry type="LiteralNumberHex" style="%s"/>
  352. <entry type="LiteralNumberInteger" style="%s"/>
  353. <entry type="LiteralNumberIntegerLong" style="%s"/>
  354. <entry type="LiteralNumberOct" style="%s"/>
  355. <!-- Operators -->
  356. <entry type="Operator" style="%s"/>
  357. <entry type="OperatorWord" style="%s"/>
  358. <entry type="Punctuation" style="%s"/>
  359. <!-- Comments -->
  360. <entry type="Comment" style="%s"/>
  361. <entry type="CommentHashbang" style="%s"/>
  362. <entry type="CommentMultiline" style="%s"/>
  363. <entry type="CommentSingle" style="%s"/>
  364. <entry type="CommentSpecial" style="%s"/>
  365. <entry type="CommentPreproc" style="%s"/>
  366. <!-- Generic styles -->
  367. <entry type="Generic" style="%s"/>
  368. <entry type="GenericDeleted" style="%s"/>
  369. <entry type="GenericEmph" style="italic %s"/>
  370. <entry type="GenericError" style="%s"/>
  371. <entry type="GenericHeading" style="bold %s"/>
  372. <entry type="GenericInserted" style="%s"/>
  373. <entry type="GenericOutput" style="%s"/>
  374. <entry type="GenericPrompt" style="%s"/>
  375. <entry type="GenericStrong" style="bold %s"/>
  376. <entry type="GenericSubheading" style="bold %s"/>
  377. <entry type="GenericTraceback" style="%s"/>
  378. <entry type="GenericUnderline" style="underline"/>
  379. <entry type="TextWhitespace" style="%s"/>
  380. </style>
  381. `,
  382. getChromaColor(t.BackgroundPanel()), // Background
  383. getChromaColor(t.Text()), // Text
  384. getChromaColor(t.Text()), // Other
  385. getChromaColor(t.Error()), // Error
  386. getChromaColor(t.SyntaxKeyword()), // Keyword
  387. getChromaColor(t.SyntaxKeyword()), // KeywordConstant
  388. getChromaColor(t.SyntaxKeyword()), // KeywordDeclaration
  389. getChromaColor(t.SyntaxKeyword()), // KeywordNamespace
  390. getChromaColor(t.SyntaxKeyword()), // KeywordPseudo
  391. getChromaColor(t.SyntaxKeyword()), // KeywordReserved
  392. getChromaColor(t.SyntaxType()), // KeywordType
  393. getChromaColor(t.Text()), // Name
  394. getChromaColor(t.SyntaxVariable()), // NameAttribute
  395. getChromaColor(t.SyntaxType()), // NameBuiltin
  396. getChromaColor(t.SyntaxVariable()), // NameBuiltinPseudo
  397. getChromaColor(t.SyntaxType()), // NameClass
  398. getChromaColor(t.SyntaxVariable()), // NameConstant
  399. getChromaColor(t.SyntaxFunction()), // NameDecorator
  400. getChromaColor(t.SyntaxVariable()), // NameEntity
  401. getChromaColor(t.SyntaxType()), // NameException
  402. getChromaColor(t.SyntaxFunction()), // NameFunction
  403. getChromaColor(t.Text()), // NameLabel
  404. getChromaColor(t.SyntaxType()), // NameNamespace
  405. getChromaColor(t.SyntaxVariable()), // NameOther
  406. getChromaColor(t.SyntaxKeyword()), // NameTag
  407. getChromaColor(t.SyntaxVariable()), // NameVariable
  408. getChromaColor(t.SyntaxVariable()), // NameVariableClass
  409. getChromaColor(t.SyntaxVariable()), // NameVariableGlobal
  410. getChromaColor(t.SyntaxVariable()), // NameVariableInstance
  411. getChromaColor(t.SyntaxString()), // Literal
  412. getChromaColor(t.SyntaxString()), // LiteralDate
  413. getChromaColor(t.SyntaxString()), // LiteralString
  414. getChromaColor(t.SyntaxString()), // LiteralStringBacktick
  415. getChromaColor(t.SyntaxString()), // LiteralStringChar
  416. getChromaColor(t.SyntaxString()), // LiteralStringDoc
  417. getChromaColor(t.SyntaxString()), // LiteralStringDouble
  418. getChromaColor(t.SyntaxString()), // LiteralStringEscape
  419. getChromaColor(t.SyntaxString()), // LiteralStringHeredoc
  420. getChromaColor(t.SyntaxString()), // LiteralStringInterpol
  421. getChromaColor(t.SyntaxString()), // LiteralStringOther
  422. getChromaColor(t.SyntaxString()), // LiteralStringRegex
  423. getChromaColor(t.SyntaxString()), // LiteralStringSingle
  424. getChromaColor(t.SyntaxString()), // LiteralStringSymbol
  425. getChromaColor(t.SyntaxNumber()), // LiteralNumber
  426. getChromaColor(t.SyntaxNumber()), // LiteralNumberBin
  427. getChromaColor(t.SyntaxNumber()), // LiteralNumberFloat
  428. getChromaColor(t.SyntaxNumber()), // LiteralNumberHex
  429. getChromaColor(t.SyntaxNumber()), // LiteralNumberInteger
  430. getChromaColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
  431. getChromaColor(t.SyntaxNumber()), // LiteralNumberOct
  432. getChromaColor(t.SyntaxOperator()), // Operator
  433. getChromaColor(t.SyntaxKeyword()), // OperatorWord
  434. getChromaColor(t.SyntaxPunctuation()), // Punctuation
  435. getChromaColor(t.SyntaxComment()), // Comment
  436. getChromaColor(t.SyntaxComment()), // CommentHashbang
  437. getChromaColor(t.SyntaxComment()), // CommentMultiline
  438. getChromaColor(t.SyntaxComment()), // CommentSingle
  439. getChromaColor(t.SyntaxComment()), // CommentSpecial
  440. getChromaColor(t.SyntaxKeyword()), // CommentPreproc
  441. getChromaColor(t.Text()), // Generic
  442. getChromaColor(t.Error()), // GenericDeleted
  443. getChromaColor(t.Text()), // GenericEmph
  444. getChromaColor(t.Error()), // GenericError
  445. getChromaColor(t.Text()), // GenericHeading
  446. getChromaColor(t.Success()), // GenericInserted
  447. getChromaColor(t.TextMuted()), // GenericOutput
  448. getChromaColor(t.Text()), // GenericPrompt
  449. getChromaColor(t.Text()), // GenericStrong
  450. getChromaColor(t.Text()), // GenericSubheading
  451. getChromaColor(t.Error()), // GenericTraceback
  452. getChromaColor(t.Text()), // TextWhitespace
  453. )
  454. r := strings.NewReader(syntaxThemeXml)
  455. style := chroma.MustNewXMLStyle(r)
  456. // Modify the style to use the provided background
  457. s, err := style.Builder().Transform(
  458. func(t chroma.StyleEntry) chroma.StyleEntry {
  459. if _, ok := bg.(lipgloss.NoColor); ok {
  460. return t
  461. }
  462. r, g, b, _ := bg.RGBA()
  463. t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
  464. return t
  465. },
  466. ).Build()
  467. if err != nil {
  468. s = styles.Fallback
  469. }
  470. // Tokenize and format
  471. it, err := l.Tokenise(nil, source)
  472. if err != nil {
  473. return err
  474. }
  475. return f.Format(w, s, it)
  476. }
  477. // getColor returns the appropriate hex color string based on terminal background
  478. func getColor(adaptiveColor compat.AdaptiveColor) *string {
  479. return stylesi.AdaptiveColorToString(adaptiveColor)
  480. }
  481. func getChromaColor(adaptiveColor compat.AdaptiveColor) string {
  482. color := stylesi.AdaptiveColorToString(adaptiveColor)
  483. if color == nil {
  484. return ""
  485. }
  486. return *color
  487. }
  488. // highlightLine applies syntax highlighting to a single line
  489. func highlightLine(fileName string, line string, bg color.Color) string {
  490. var buf bytes.Buffer
  491. err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
  492. if err != nil {
  493. return line
  494. }
  495. return buf.String()
  496. }
  497. // createStyles generates the lipgloss styles needed for rendering diffs
  498. func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle stylesi.Style) {
  499. removedLineStyle = stylesi.NewStyle().Background(t.DiffRemovedBg())
  500. addedLineStyle = stylesi.NewStyle().Background(t.DiffAddedBg())
  501. contextLineStyle = stylesi.NewStyle().Background(t.DiffContextBg())
  502. lineNumberStyle = stylesi.NewStyle().Foreground(t.TextMuted()).Background(t.DiffLineNumber())
  503. return
  504. }
  505. // -------------------------------------------------------------------------
  506. // Rendering Functions
  507. // -------------------------------------------------------------------------
  508. // applyHighlighting applies intra-line highlighting to a piece of text
  509. func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg compat.AdaptiveColor) string {
  510. // Find all ANSI sequences in the content
  511. ansiRegex := regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
  512. ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
  513. // Build a mapping of visible character positions to their actual indices
  514. visibleIdx := 0
  515. ansiSequences := make(map[int]string)
  516. lastAnsiSeq := "\x1b[0m" // Default reset sequence
  517. for i := 0; i < len(content); {
  518. isAnsi := false
  519. for _, match := range ansiMatches {
  520. if match[0] == i {
  521. ansiSequences[visibleIdx] = content[match[0]:match[1]]
  522. lastAnsiSeq = content[match[0]:match[1]]
  523. i = match[1]
  524. isAnsi = true
  525. break
  526. }
  527. }
  528. if isAnsi {
  529. continue
  530. }
  531. // For non-ANSI positions, store the last ANSI sequence
  532. if _, exists := ansiSequences[visibleIdx]; !exists {
  533. ansiSequences[visibleIdx] = lastAnsiSeq
  534. }
  535. visibleIdx++
  536. i++
  537. }
  538. // Apply highlighting
  539. var sb strings.Builder
  540. inSelection := false
  541. currentPos := 0
  542. // Get the appropriate color based on terminal background
  543. bg := getColor(highlightBg)
  544. fg := getColor(theme.CurrentTheme().BackgroundPanel())
  545. var bgColor color.Color
  546. var fgColor color.Color
  547. if bg != nil {
  548. bgColor = lipgloss.Color(*bg)
  549. }
  550. if fg != nil {
  551. fgColor = lipgloss.Color(*fg)
  552. }
  553. for i := 0; i < len(content); {
  554. // Check if we're at an ANSI sequence
  555. isAnsi := false
  556. for _, match := range ansiMatches {
  557. if match[0] == i {
  558. sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence
  559. i = match[1]
  560. isAnsi = true
  561. break
  562. }
  563. }
  564. if isAnsi {
  565. continue
  566. }
  567. // Check for segment boundaries
  568. for _, seg := range segments {
  569. if seg.Type == segmentType {
  570. if currentPos == seg.Start {
  571. inSelection = true
  572. }
  573. if currentPos == seg.End {
  574. inSelection = false
  575. }
  576. }
  577. }
  578. // Get current character
  579. char := string(content[i])
  580. if inSelection {
  581. // Get the current styling
  582. currentStyle := ansiSequences[currentPos]
  583. // Apply foreground and background highlight
  584. if fgColor != nil {
  585. sb.WriteString("\x1b[38;2;")
  586. r, g, b, _ := fgColor.RGBA()
  587. sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
  588. } else {
  589. sb.WriteString("\x1b[49m")
  590. }
  591. if bgColor != nil {
  592. sb.WriteString("\x1b[48;2;")
  593. r, g, b, _ := bgColor.RGBA()
  594. sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
  595. } else {
  596. sb.WriteString("\x1b[39m")
  597. }
  598. sb.WriteString(char)
  599. // Full reset of all attributes to ensure clean state
  600. sb.WriteString("\x1b[0m")
  601. // Reapply the original ANSI sequence
  602. sb.WriteString(currentStyle)
  603. } else {
  604. // Not in selection, just copy the character
  605. sb.WriteString(char)
  606. }
  607. currentPos++
  608. i++
  609. }
  610. return sb.String()
  611. }
  612. // renderLinePrefix renders the line number and marker prefix for a diff line
  613. func renderLinePrefix(dl DiffLine, lineNum string, marker string, lineNumberStyle stylesi.Style, t theme.Theme) string {
  614. // Style the marker based on line type
  615. var styledMarker string
  616. switch dl.Kind {
  617. case LineRemoved:
  618. styledMarker = stylesi.NewStyle().Foreground(t.DiffRemoved()).Background(t.DiffRemovedBg()).Render(marker)
  619. case LineAdded:
  620. styledMarker = stylesi.NewStyle().Foreground(t.DiffAdded()).Background(t.DiffAddedBg()).Render(marker)
  621. case LineContext:
  622. styledMarker = stylesi.NewStyle().Foreground(t.TextMuted()).Background(t.DiffContextBg()).Render(marker)
  623. default:
  624. styledMarker = marker
  625. }
  626. return lineNumberStyle.Render(lineNum + " " + styledMarker)
  627. }
  628. // renderLineContent renders the content of a diff line with syntax and intra-line highlighting
  629. func renderLineContent(fileName string, dl DiffLine, bgStyle stylesi.Style, highlightColor compat.AdaptiveColor, width int) string {
  630. // Apply syntax highlighting
  631. content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
  632. // Apply intra-line highlighting if needed
  633. if len(dl.Segments) > 0 && (dl.Kind == LineRemoved || dl.Kind == LineAdded) {
  634. content = applyHighlighting(content, dl.Segments, dl.Kind, highlightColor)
  635. }
  636. // Add a padding space for added/removed lines
  637. if dl.Kind == LineRemoved || dl.Kind == LineAdded {
  638. content = bgStyle.Render(" ") + content
  639. }
  640. // Create the final line and truncate if needed
  641. return bgStyle.MaxHeight(1).Width(width).Render(
  642. ansi.Truncate(
  643. content,
  644. width,
  645. "...",
  646. ),
  647. )
  648. }
  649. // renderUnifiedLine renders a single line in unified diff format
  650. func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) string {
  651. removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
  652. // Determine line style and marker based on line type
  653. var marker string
  654. var bgStyle stylesi.Style
  655. var lineNum string
  656. var highlightColor compat.AdaptiveColor
  657. switch dl.Kind {
  658. case LineRemoved:
  659. marker = "-"
  660. bgStyle = removedLineStyle
  661. lineNumberStyle = lineNumberStyle.Background(t.DiffRemovedLineNumberBg()).Foreground(t.DiffRemoved())
  662. highlightColor = t.DiffHighlightRemoved() // TODO: handle "none"
  663. if dl.OldLineNo > 0 {
  664. lineNum = fmt.Sprintf("%6d ", dl.OldLineNo)
  665. } else {
  666. lineNum = " "
  667. }
  668. case LineAdded:
  669. marker = "+"
  670. bgStyle = addedLineStyle
  671. lineNumberStyle = lineNumberStyle.Background(t.DiffAddedLineNumberBg()).Foreground(t.DiffAdded())
  672. highlightColor = t.DiffHighlightAdded() // TODO: handle "none"
  673. if dl.NewLineNo > 0 {
  674. lineNum = fmt.Sprintf(" %7d", dl.NewLineNo)
  675. } else {
  676. lineNum = " "
  677. }
  678. case LineContext:
  679. marker = " "
  680. bgStyle = contextLineStyle
  681. if dl.OldLineNo > 0 && dl.NewLineNo > 0 {
  682. lineNum = fmt.Sprintf("%6d %6d", dl.OldLineNo, dl.NewLineNo)
  683. } else {
  684. lineNum = " "
  685. }
  686. }
  687. // Create the line prefix
  688. prefix := renderLinePrefix(dl, lineNum, marker, lineNumberStyle, t)
  689. // Render the content
  690. prefixWidth := ansi.StringWidth(prefix)
  691. contentWidth := width - prefixWidth
  692. content := renderLineContent(fileName, dl, bgStyle, highlightColor, contentWidth)
  693. return prefix + content
  694. }
  695. // renderDiffColumnLine is a helper function that handles the common logic for rendering diff columns
  696. func renderDiffColumnLine(
  697. fileName string,
  698. dl *DiffLine,
  699. colWidth int,
  700. isLeftColumn bool,
  701. t theme.Theme,
  702. ) string {
  703. if dl == nil {
  704. contextLineStyle := stylesi.NewStyle().Background(t.DiffContextBg())
  705. return contextLineStyle.Width(colWidth).Render("")
  706. }
  707. removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
  708. // Determine line style based on line type and column
  709. var marker string
  710. var bgStyle stylesi.Style
  711. var lineNum string
  712. var highlightColor compat.AdaptiveColor
  713. if isLeftColumn {
  714. // Left column logic
  715. switch dl.Kind {
  716. case LineRemoved:
  717. marker = "-"
  718. bgStyle = removedLineStyle
  719. lineNumberStyle = lineNumberStyle.Background(t.DiffRemovedLineNumberBg()).Foreground(t.DiffRemoved())
  720. highlightColor = t.DiffHighlightRemoved() // TODO: handle "none"
  721. case LineAdded:
  722. marker = "?"
  723. bgStyle = contextLineStyle
  724. case LineContext:
  725. marker = " "
  726. bgStyle = contextLineStyle
  727. }
  728. // Format line number for left column
  729. if dl.OldLineNo > 0 {
  730. lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
  731. }
  732. } else {
  733. // Right column logic
  734. switch dl.Kind {
  735. case LineAdded:
  736. marker = "+"
  737. bgStyle = addedLineStyle
  738. lineNumberStyle = lineNumberStyle.Background(t.DiffAddedLineNumberBg()).Foreground(t.DiffAdded())
  739. highlightColor = t.DiffHighlightAdded()
  740. case LineRemoved:
  741. marker = "?"
  742. bgStyle = contextLineStyle
  743. case LineContext:
  744. marker = " "
  745. bgStyle = contextLineStyle
  746. }
  747. // Format line number for right column
  748. if dl.NewLineNo > 0 {
  749. lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
  750. }
  751. }
  752. // Create the line prefix
  753. prefix := renderLinePrefix(*dl, lineNum, marker, lineNumberStyle, t)
  754. // Determine if we should render content
  755. shouldRenderContent := (dl.Kind == LineRemoved && isLeftColumn) ||
  756. (dl.Kind == LineAdded && !isLeftColumn) ||
  757. dl.Kind == LineContext
  758. if !shouldRenderContent {
  759. return bgStyle.Width(colWidth).Render("")
  760. }
  761. // Render the content
  762. prefixWidth := ansi.StringWidth(prefix)
  763. contentWidth := colWidth - prefixWidth
  764. content := renderLineContent(fileName, *dl, bgStyle, highlightColor, contentWidth)
  765. return prefix + content
  766. }
  767. // renderLeftColumn formats the left side of a side-by-side diff
  768. func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
  769. return renderDiffColumnLine(fileName, dl, colWidth, true, theme.CurrentTheme())
  770. }
  771. // renderRightColumn formats the right side of a side-by-side diff
  772. func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
  773. return renderDiffColumnLine(fileName, dl, colWidth, false, theme.CurrentTheme())
  774. }
  775. // -------------------------------------------------------------------------
  776. // Public API
  777. // -------------------------------------------------------------------------
  778. // RenderUnifiedHunk formats a hunk for unified display
  779. func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
  780. // Apply options to create the configuration
  781. config := NewUnifiedConfig(opts...)
  782. // Make a copy of the hunk so we don't modify the original
  783. hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
  784. copy(hunkCopy.Lines, h.Lines)
  785. // Highlight changes within lines
  786. HighlightIntralineChanges(&hunkCopy)
  787. var sb strings.Builder
  788. sb.Grow(len(hunkCopy.Lines) * config.Width)
  789. util.WriteStringsPar(&sb, hunkCopy.Lines, func(line DiffLine) string {
  790. return renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()) + "\n"
  791. })
  792. return sb.String()
  793. }
  794. // RenderSideBySideHunk formats a hunk for side-by-side display
  795. func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) string {
  796. // Apply options to create the configuration
  797. config := NewSideBySideConfig(opts...)
  798. // Make a copy of the hunk so we don't modify the original
  799. hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
  800. copy(hunkCopy.Lines, h.Lines)
  801. // Highlight changes within lines
  802. HighlightIntralineChanges(&hunkCopy)
  803. // Pair lines for side-by-side display
  804. pairs := pairLines(hunkCopy.Lines)
  805. // Calculate column width
  806. colWidth := config.TotalWidth / 2
  807. leftWidth := colWidth
  808. rightWidth := config.TotalWidth - colWidth
  809. var sb strings.Builder
  810. util.WriteStringsPar(&sb, pairs, func(p linePair) string {
  811. wg := &sync.WaitGroup{}
  812. var leftStr, rightStr string
  813. wg.Add(2)
  814. go func() {
  815. defer wg.Done()
  816. leftStr = renderLeftColumn(fileName, p.left, leftWidth)
  817. }()
  818. go func() {
  819. defer wg.Done()
  820. rightStr = renderRightColumn(fileName, p.right, rightWidth)
  821. }()
  822. wg.Wait()
  823. return leftStr + rightStr + "\n"
  824. })
  825. return sb.String()
  826. }
  827. // FormatUnifiedDiff creates a unified formatted view of a diff
  828. func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) {
  829. diffResult, err := ParseUnifiedDiff(diffText)
  830. if err != nil {
  831. return "", err
  832. }
  833. var sb strings.Builder
  834. util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
  835. return RenderUnifiedHunk(filename, h, opts...)
  836. })
  837. return sb.String(), nil
  838. }
  839. // FormatDiff creates a side-by-side formatted view of a diff
  840. func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) {
  841. diffResult, err := ParseUnifiedDiff(diffText)
  842. if err != nil {
  843. return "", err
  844. }
  845. var sb strings.Builder
  846. util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
  847. return RenderSideBySideHunk(filename, h, opts...)
  848. })
  849. return sb.String(), nil
  850. }