History

Improve code diff highlight, fix incorrect rendered diff result (#19958)

Use Unicode placeholders to replace HTML tags and HTML entities first, then do diff, then recover the HTML tags and HTML entities. Now the code diff with highlight has stable behavior, and won't emit broken tags.

wxiaoguang committed 4 years ago

Commit 3310dd11 parent 14178c55 files · +379 −378Browse files

Showing the changes to services/gitdiff/gitdiff.go (1 of 5 files). Show all

Unified · Split

services/gitdiff/gitdiff.go+21−291
@@ -15,7 +15,6 @@ import ( · 14 unmodified lines
15 "io"15 "io"
16 "net/url"16 "net/url"
17 "os"17 "os"
18 "regexp"
19 "sort"18 "sort"
20 "strings"19 "strings"
21 "time"20 "time"
@@ -40,7 +39,7 @@ import ( · 18 unmodified lines
40 "golang.org/x/text/transform"39 "golang.org/x/text/transform"
41)40)
42 41
43// DiffLineType represents the type of a DiffLine.42// DiffLineType represents the type of DiffLine.
44type DiffLineType uint843type DiffLineType uint8
45 44
46// DiffLineType possible values.45// DiffLineType possible values.
@@ -51,7 +50,7 @@ const ( · 4 unmodified lines
51 DiffLineSection50 DiffLineSection
52)51)
53 52
54// DiffFileType represents the type of a DiffFile.53// DiffFileType represents the type of DiffFile.
55type DiffFileType uint854type DiffFileType uint8
56 55
57// DiffFileType possible values.56// DiffFileType possible values.
@@ -100,12 +99,12 @@ type DiffLineSectionInfo struct { · 42 unmodified lines
100// BlobExcerptChunkSize represent max lines of excerpt99// BlobExcerptChunkSize represent max lines of excerpt
101const BlobExcerptChunkSize = 20100const BlobExcerptChunkSize = 20
102 101
103// GetType returns the type of a DiffLine.102// GetType returns the type of DiffLine.
104func (d *DiffLine) GetType() int {103func (d *DiffLine) GetType() int {
105 return int(d.Type)104 return int(d.Type)
106}105}
107 106
108// CanComment returns whether or not a line can get commented107// CanComment returns whether a line can get commented
109func (d *DiffLine) CanComment() bool {108func (d *DiffLine) CanComment() bool {
110 return len(d.Comments) == 0 && d.Type != DiffLineSection109 return len(d.Comments) == 0 && d.Type != DiffLineSection
111}110}
@@ -191,287 +190,13 @@ var ( · 79 unmodified lines
191 codeTagSuffix = []byte(`</span>`)190 codeTagSuffix = []byte(`</span>`)
192)191)
193 192
194var (193func diffToHTML(lineWrapperTags []string, diffs []diffmatchpatch.Diff, lineType DiffLineType) string {
195 unfinishedtagRegex = regexp.MustCompile(`<[^>]*$`)
196 trailingSpanRegex = regexp.MustCompile(`<span\s*[[:alpha:]="]*?[>]?$`)
197 entityRegex = regexp.MustCompile(`&[#]*?[0-9[:alpha:]]*$`)
198)
199
200// shouldWriteInline represents combinations where we manually write inline changes
201func shouldWriteInline(diff diffmatchpatch.Diff, lineType DiffLineType) bool {
202 if true &&
203 diff.Type == diffmatchpatch.DiffEqual ||
204 diff.Type == diffmatchpatch.DiffInsert && lineType == DiffLineAdd ||
205 diff.Type == diffmatchpatch.DiffDelete && lineType == DiffLineDel {
206 return true
207 }
208 return false
209}
210
211func fixupBrokenSpans(diffs []diffmatchpatch.Diff) []diffmatchpatch.Diff {
212 // Create a new array to store our fixed up blocks
213 fixedup := make([]diffmatchpatch.Diff, 0, len(diffs))
214
215 // semantically label some numbers
216 const insert, delete, equal = 0, 1, 2
217
218 // record the positions of the last type of each block in the fixedup blocks
219 last := []int{-1, -1, -1}
220 operation := []diffmatchpatch.Operation{diffmatchpatch.DiffInsert, diffmatchpatch.DiffDelete, diffmatchpatch.DiffEqual}
221
222 // create a writer for insert and deletes
223 toWrite := []strings.Builder{
224 {},
225 {},
226 }
227
228 // make some flags for insert and delete
229 unfinishedTag := []bool{false, false}
230 unfinishedEnt := []bool{false, false}
231
232 // store stores the provided text in the writer for the typ
233 store := func(text string, typ int) {
234 (&(toWrite[typ])).WriteString(text)
235 }
236
237 // hasStored returns true if there is stored content
238 hasStored := func(typ int) bool {
239 return (&toWrite[typ]).Len() > 0
240 }
241
242 // stored will return that content
243 stored := func(typ int) string {
244 return (&toWrite[typ]).String()
245 }
246
247 // empty will empty the stored content
248 empty := func(typ int) {
249 (&toWrite[typ]).Reset()
250 }
251
252 // pop will remove the stored content appending to a diff block for that typ
253 pop := func(typ int, fixedup []diffmatchpatch.Diff) []diffmatchpatch.Diff {
254 if hasStored(typ) {
255 if last[typ] > last[equal] {
256 fixedup[last[typ]].Text += stored(typ)
257 } else {
258 fixedup = append(fixedup, diffmatchpatch.Diff{
259 Type: operation[typ],
260 Text: stored(typ),
261 })
262 }
263 empty(typ)
264 }
265 return fixedup
266 }
267
268 // Now we walk the provided diffs and check the type of each block in turn
269 for _, diff := range diffs {
270
271 typ := delete // flag for handling insert or delete typs
272 switch diff.Type {
273 case diffmatchpatch.DiffEqual:
274 // First check if there is anything stored
275 if hasStored(insert) || hasStored(delete) {
276 // There are two reasons for storing content:
277 // 1. Unfinished Entity <- Could be more efficient here by not doing this if we're looking for a tag
278 if unfinishedEnt[insert] || unfinishedEnt[delete] {
279 // we look for a ';' to finish an entity
280 idx := strings.IndexRune(diff.Text, ';')
281 if idx >= 0 {
282 // if we find a ';' store the preceding content to both insert and delete
283 store(diff.Text[:idx+1], insert)
284 store(diff.Text[:idx+1], delete)
285
286 // and remove it from this block
287 diff.Text = diff.Text[idx+1:]
288
289 // reset the ent flags
290 unfinishedEnt[insert] = false
291 unfinishedEnt[delete] = false
292 } else {
293 // otherwise store it all on insert and delete
294 store(diff.Text, insert)
295 store(diff.Text, delete)
296 // and empty this block
297 diff.Text = ""
298 }
299 }
300 // 2. Unfinished Tag
301 if unfinishedTag[insert] || unfinishedTag[delete] {
302 // we look for a '>' to finish a tag
303 idx := strings.IndexRune(diff.Text, '>')
304 if idx >= 0 {
305 store(diff.Text[:idx+1], insert)
306 store(diff.Text[:idx+1], delete)
307 diff.Text = diff.Text[idx+1:]
308 unfinishedTag[insert] = false
309 unfinishedTag[delete] = false
310 } else {
311 store(diff.Text, insert)
312 store(diff.Text, delete)
313 diff.Text = ""
314 }
315 }
316
317 // If we've completed the required tag/entities
318 if !(unfinishedTag[insert] || unfinishedTag[delete] || unfinishedEnt[insert] || unfinishedEnt[delete]) {
319 // pop off the stack
320 fixedup = pop(insert, fixedup)
321 fixedup = pop(delete, fixedup)
322 }
323
324 // If that has left this diff block empty then shortcut
325 if len(diff.Text) == 0 {
326 continue
327 }
328 }
329
330 // check if this block ends in an unfinished tag?
331 idx := unfinishedtagRegex.FindStringIndex(diff.Text)
332 if idx != nil {
333 unfinishedTag[insert] = true
334 unfinishedTag[delete] = true
335 } else {
336 // otherwise does it end in an unfinished entity?
337 idx = entityRegex.FindStringIndex(diff.Text)
338 if idx != nil {
339 unfinishedEnt[insert] = true
340 unfinishedEnt[delete] = true
341 }
342 }
343
344 // If there is an unfinished component
345 if idx != nil {
346 // Store the fragment
347 store(diff.Text[idx[0]:], insert)
348 store(diff.Text[idx[0]:], delete)
349 // and remove it from this block
350 diff.Text = diff.Text[:idx[0]]
351 }
352
353 // If that hasn't left the block empty
354 if len(diff.Text) > 0 {
355 // store the position of the last equal block and store it in our diffs
356 last[equal] = len(fixedup)
357 fixedup = append(fixedup, diff)
358 }
359 continue
360 case diffmatchpatch.DiffInsert:
361 typ = insert
362 fallthrough
363 case diffmatchpatch.DiffDelete:
364 // First check if there is anything stored for this type
365 if hasStored(typ) {
366 // if there is prepend it to this block, empty the storage and reset our flags
367 diff.Text = stored(typ) + diff.Text
368 empty(typ)
369 unfinishedEnt[typ] = false
370 unfinishedTag[typ] = false
371 }
372
373 // check if this block ends in an unfinished tag
374 idx := unfinishedtagRegex.FindStringIndex(diff.Text)
375 if idx != nil {
376 unfinishedTag[typ] = true
377 } else {
378 // otherwise does it end in an unfinished entity
379 idx = entityRegex.FindStringIndex(diff.Text)
380 if idx != nil {
381 unfinishedEnt[typ] = true
382 }
383 }
384
385 // If there is an unfinished component
386 if idx != nil {
387 // Store the fragment
388 store(diff.Text[idx[0]:], typ)
389 // and remove it from this block
390 diff.Text = diff.Text[:idx[0]]
391 }
392
393 // If that hasn't left the block empty
394 if len(diff.Text) > 0 {
395 // if the last block of this type was after the last equal block
396 if last[typ] > last[equal] {
397 // store this blocks content on that block
398 fixedup[last[typ]].Text += diff.Text
399 } else {
400 // otherwise store the position of the last block of this type and store the block
401 last[typ] = len(fixedup)
402 fixedup = append(fixedup, diff)
403 }
404 }
405 continue
406 }
407 }
408
409 // pop off any remaining stored content
410 fixedup = pop(insert, fixedup)
411 fixedup = pop(delete, fixedup)
412
413 return fixedup
414}
415
416func diffToHTML(fileName string, diffs []diffmatchpatch.Diff, lineType DiffLineType) DiffInline {
417 buf := bytes.NewBuffer(nil)194 buf := bytes.NewBuffer(nil)
418 match := ""195 // restore the line wrapper tags <span class="line"> and <span class="cl">, if necessary
419 196 for _, tag := range lineWrapperTags {
420 diffs = fixupBrokenSpans(diffs)197 buf.WriteString(tag)
421 198 }
422 for _, diff := range diffs {199 for _, diff := range diffs {
423 if shouldWriteInline(diff, lineType) {
424 if len(match) > 0 {
425 diff.Text = match + diff.Text
426 match = ""
427 }
428 // Chroma HTML syntax highlighting is done before diffing individual lines in order to maintain consistency.
429 // Since inline changes might split in the middle of a chroma span tag or HTML entity, make we manually put it back together
430 // before writing so we don't try insert added/removed code spans in the middle of one of those
431 // and create broken HTML. This is done by moving incomplete HTML forward until it no longer matches our pattern of
432 // a line ending with an incomplete HTML entity or partial/opening <span>.
433
434 // EX:
435 // diffs[{Type: dmp.DiffDelete, Text: "language</span><span "},
436 // {Type: dmp.DiffEqual, Text: "c"},
437 // {Type: dmp.DiffDelete, Text: "lass="p">}]
438
439 // After first iteration
440 // diffs[{Type: dmp.DiffDelete, Text: "language</span>"}, //write out
441 // {Type: dmp.DiffEqual, Text: "<span c"},
442 // {Type: dmp.DiffDelete, Text: "lass="p">,</span>}]
443
444 // After second iteration
445 // {Type: dmp.DiffEqual, Text: ""}, // write out
446 // {Type: dmp.DiffDelete, Text: "<span class="p">,</span>}]
447
448 // Final
449 // {Type: dmp.DiffDelete, Text: "<span class="p">,</span>}]
450 // end up writing <span class="removed-code"><span class="p">,</span></span>
451 // Instead of <span class="removed-code">lass="p",</span></span>
452
453 m := trailingSpanRegex.FindStringSubmatchIndex(diff.Text)
454 if m != nil {
455 match = diff.Text[m[0]:m[1]]
456 diff.Text = strings.TrimSuffix(diff.Text, match)
457 }
458 m = entityRegex.FindStringSubmatchIndex(diff.Text)
459 if m != nil {
460 match = diff.Text[m[0]:m[1]]
461 diff.Text = strings.TrimSuffix(diff.Text, match)
462 }
463 // Print an existing closing span first before opening added/remove-code span so it doesn't unintentionally close it
464 if strings.HasPrefix(diff.Text, "</span>") {
465 buf.WriteString("</span>")
466 diff.Text = strings.TrimPrefix(diff.Text, "</span>")
467 }
468 // If we weren't able to fix it then this should avoid broken HTML by not inserting more spans below
469 // The previous/next diff section will contain the rest of the tag that is missing here
470 if strings.Count(diff.Text, "<") != strings.Count(diff.Text, ">") {
471 buf.WriteString(diff.Text)
472 continue
473 }
474 }
475 switch {200 switch {
476 case diff.Type == diffmatchpatch.DiffEqual:201 case diff.Type == diffmatchpatch.DiffEqual:
477 buf.WriteString(diff.Text)202 buf.WriteString(diff.Text)
@@ -485,7 +210,10 @@ func diffToHTML(fileName string, diffs []diffmatchpatch.Diff, lineType DiffLineT · 7 unmodified lines
485 buf.Write(codeTagSuffix)210 buf.Write(codeTagSuffix)
486 }211 }
487 }212 }
488 return DiffInlineWithUnicodeEscape(template.HTML(buf.String()))213 for range lineWrapperTags {
214 buf.WriteString("</span>")
215 }
216 return buf.String()
489}217}
490 218
491// GetLine gets a specific line by type (add or del) and file line number219// GetLine gets a specific line by type (add or del) and file line number
@@ -597,10 +325,12 @@ func (diffSection *DiffSection) GetComputedInlineDiffFor(diffLine *DiffLine) Dif · 105 unmodified lines
597 return DiffInlineWithHighlightCode(diffSection.FileName, language, diffLine.Content)325 return DiffInlineWithHighlightCode(diffSection.FileName, language, diffLine.Content)
598 }326 }
599 327
600 diffRecord := diffMatchPatch.DiffMain(highlight.Code(diffSection.FileName, language, diff1[1:]), highlight.Code(diffSection.FileName, language, diff2[1:]), true)328 hcd := newHighlightCodeDiff()
601 diffRecord = diffMatchPatch.DiffCleanupEfficiency(diffRecord)329 diffRecord := hcd.diffWithHighlight(diffSection.FileName, language, diff1[1:], diff2[1:])
602 330 // it seems that Gitea doesn't need the line wrapper of Chroma, so do not add them back
603 return diffToHTML(diffSection.FileName, diffRecord, diffLine.Type)331 // if the line wrappers are still needed in the future, it can be added back by "diffToHTML(hcd.lineWrapperTags. ...)"
332 diffHTML := diffToHTML(nil, diffRecord, diffLine.Type)
333 return DiffInlineWithUnicodeEscape(template.HTML(diffHTML))
604}334}
605 335
606// DiffFile represents a file diff.336// DiffFile represents a file diff.
@@ -1289,7 +1019,7 @@ func readFileName(rd *strings.Reader) (string, bool) { · 682 unmodified lines
1289 if char == '"' {1019 if char == '"' {
1290 fmt.Fscanf(rd, "%q ", &name)1020 fmt.Fscanf(rd, "%q ", &name)
1291 if len(name) == 0 {1021 if len(name) == 0 {
1292 log.Error("Reader has no file name: %v", rd)1022 log.Error("Reader has no file name: reader=%+v", rd)
1293 return "", true1023 return "", true
1294 }1024 }
1295 1025
@@ -1311,7 +1041,7 @@ func readFileName(rd *strings.Reader) (string, bool) { · 15 unmodified lines
1311 }1041 }
1312 }1042 }
1313 if len(name) < 2 {1043 if len(name) < 2 {
1314 log.Error("Unable to determine name from reader: %v", rd)1044 log.Error("Unable to determine name from reader: reader=%+v", rd)
1315 return "", true1045 return "", true
1316 }1046 }
1317 return name[2:], ambiguity1047 return name[2:], ambiguity