diff.go 30 KB

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