main
semdiff.go
Eric Bower
·
2026-02-25
1package patchbin
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/hex"
8 "fmt"
9 "path/filepath"
10 "regexp"
11 "strings"
12
13 "github.com/bluekeyes/go-gitdiff/gitdiff"
14 sitter "github.com/smacker/go-tree-sitter"
15 "github.com/smacker/go-tree-sitter/golang"
16 "github.com/smacker/go-tree-sitter/javascript"
17 "github.com/smacker/go-tree-sitter/python"
18 "github.com/smacker/go-tree-sitter/rust"
19 "github.com/smacker/go-tree-sitter/typescript/tsx"
20 "github.com/smacker/go-tree-sitter/typescript/typescript"
21)
22
23// SemanticChangeKind describes how an entity changed between the old and
24// new side of a hunk.
25type SemanticChangeKind string
26
27const (
28 SemanticAdded SemanticChangeKind = "added"
29 SemanticRemoved SemanticChangeKind = "removed"
30 SemanticModified SemanticChangeKind = "modified"
31 SemanticSignatureChanged SemanticChangeKind = "signature_changed"
32 SemanticRenamed SemanticChangeKind = "renamed"
33)
34
35// semanticEntity is a named, queryable unit of code (function, type, etc)
36// extracted from one side (old or new) of a single hunk.
37type semanticEntity struct {
38 Kind string
39 Name string
40 Signature string
41 BodyHash string
42}
43
44// SemanticChange is a single reviewer-facing summary line describing what
45// changed about one entity in one hunk.
46type SemanticChange struct {
47 Kind SemanticChangeKind
48 EntityKind string
49 Name string
50 OldSig string
51 NewSig string
52 HunkIndex int
53 HunkAnchor string
54}
55
56// languageSpec binds a tree-sitter grammar and entity-extraction query to a
57// set of file extensions. Adding a new language means adding one of these
58// and nothing else in this file.
59//
60// enclosingNameFromComment extracts an entity name from a unified diff hunk
61// header comment (e.g. "func (s *Foo) Bar(...)" -> "Bar"). It's a fallback
62// for hunks whose fragment text doesn't include a full declaration node --
63// common for hunks that only touch the middle of a large function body,
64// since we only have the patch, not the full file, to parse.
65type languageSpec struct {
66 language *sitter.Language
67 query string
68 enclosingNameFromComment func(string) (kind, name string, ok bool)
69}
70
71var languageRegistry = map[string]languageSpec{
72 ".go": {
73 language: golang.GetLanguage(),
74 query: `
75(function_declaration
76 name: (identifier) @name) @entity
77
78(method_declaration
79 name: (field_identifier) @name) @entity
80
81(type_declaration
82 (type_spec name: (type_identifier) @name)) @entity
83`,
84 enclosingNameFromComment: goEnclosingNameFromComment,
85 },
86 ".js": {
87 language: javascript.GetLanguage(),
88 query: jsFamilyQuery,
89 enclosingNameFromComment: jsEnclosingNameFromComment,
90 },
91 ".jsx": {
92 language: javascript.GetLanguage(),
93 query: jsFamilyQuery,
94 enclosingNameFromComment: jsEnclosingNameFromComment,
95 },
96 ".mjs": {
97 language: javascript.GetLanguage(),
98 query: jsFamilyQuery,
99 enclosingNameFromComment: jsEnclosingNameFromComment,
100 },
101 ".cjs": {
102 language: javascript.GetLanguage(),
103 query: jsFamilyQuery,
104 enclosingNameFromComment: jsEnclosingNameFromComment,
105 },
106 ".ts": {
107 language: typescript.GetLanguage(),
108 query: tsQuery,
109 enclosingNameFromComment: jsEnclosingNameFromComment,
110 },
111 ".tsx": {
112 language: tsx.GetLanguage(),
113 query: tsQuery,
114 enclosingNameFromComment: jsEnclosingNameFromComment,
115 },
116 ".py": {
117 language: python.GetLanguage(),
118 query: `
119(function_definition
120 name: (identifier) @name) @entity
121
122(class_definition
123 name: (identifier) @name) @entity
124`,
125 enclosingNameFromComment: pyEnclosingNameFromComment,
126 },
127 ".rs": {
128 language: rust.GetLanguage(),
129 query: `
130(function_item
131 name: (identifier) @name) @entity
132
133(struct_item
134 name: (type_identifier) @name) @entity
135
136(enum_item
137 name: (type_identifier) @name) @entity
138
139(trait_item
140 name: (type_identifier) @name) @entity
141`,
142 enclosingNameFromComment: rustEnclosingNameFromComment,
143 },
144}
145
146// jsFamilyQuery covers the declaration shapes shared by JavaScript and
147// TypeScript.
148const jsFamilyQuery = `
149(function_declaration
150 name: (identifier) @name) @entity
151
152(method_definition
153 name: (property_identifier) @name) @entity
154
155(class_declaration
156 name: (identifier) @name) @entity
157`
158
159// tsQuery covers TypeScript's declaration shapes. It can't share
160// jsFamilyQuery's class_declaration pattern because TypeScript's grammar
161// requires a (type_identifier) name node there instead of JavaScript's
162// (identifier), and a query naming a field type invalid for the grammar
163// fails to compile at all, not just to match.
164const tsQuery = `
165(function_declaration
166 name: (identifier) @name) @entity
167
168(method_definition
169 name: (property_identifier) @name) @entity
170
171(class_declaration
172 name: (type_identifier) @name) @entity
173
174(interface_declaration
175 name: (type_identifier) @name) @entity
176
177(type_alias_declaration
178 name: (type_identifier) @name) @entity
179`
180
181var goFuncCommentPattern = regexp.MustCompile(`^func\s*(?:\([^)]*\)\s*)?([A-Za-z_][A-Za-z0-9_]*)\s*\(`)
182
183func goEnclosingNameFromComment(comment string) (kind, name string, ok bool) {
184 m := goFuncCommentPattern.FindStringSubmatch(comment)
185 if m == nil {
186 return "", "", false
187 }
188 return "function_declaration", m[1], true
189}
190
191var (
192 jsFunctionCommentPattern = regexp.MustCompile(`^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\(`)
193 jsClassCommentPattern = regexp.MustCompile(`^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)`)
194 jsMethodCommentPattern = regexp.MustCompile(`^\s*(?:public\s+|private\s+|protected\s+|static\s+|async\s+|readonly\s+)*(?:get\s+|set\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:<[^>]*>)?\s*\(`)
195 jsControlKeywords = map[string]bool{
196 "if": true, "for": true, "while": true, "switch": true, "catch": true,
197 "function": true, "return": true, "constructor": true,
198 }
199)
200
201func jsEnclosingNameFromComment(comment string) (kind, name string, ok bool) {
202 if m := jsFunctionCommentPattern.FindStringSubmatch(comment); m != nil {
203 return "function_declaration", m[1], true
204 }
205 if m := jsClassCommentPattern.FindStringSubmatch(comment); m != nil {
206 return "class_declaration", m[1], true
207 }
208 if m := jsMethodCommentPattern.FindStringSubmatch(comment); m != nil && !jsControlKeywords[m[1]] {
209 return "method_definition", m[1], true
210 }
211 return "", "", false
212}
213
214var (
215 pyFunctionCommentPattern = regexp.MustCompile(`^\s*(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(`)
216 pyClassCommentPattern = regexp.MustCompile(`^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)`)
217)
218
219func pyEnclosingNameFromComment(comment string) (kind, name string, ok bool) {
220 if m := pyFunctionCommentPattern.FindStringSubmatch(comment); m != nil {
221 return "function_definition", m[1], true
222 }
223 if m := pyClassCommentPattern.FindStringSubmatch(comment); m != nil {
224 return "class_definition", m[1], true
225 }
226 return "", "", false
227}
228
229var (
230 rustFunctionCommentPattern = regexp.MustCompile(`^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?(?:extern\s+"[^"]*"\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)`)
231 rustStructCommentPattern = regexp.MustCompile(`^\s*(?:pub(?:\([^)]*\))?\s+)?struct\s+([A-Za-z_][A-Za-z0-9_]*)`)
232 rustEnumCommentPattern = regexp.MustCompile(`^\s*(?:pub(?:\([^)]*\))?\s+)?enum\s+([A-Za-z_][A-Za-z0-9_]*)`)
233 rustTraitCommentPattern = regexp.MustCompile(`^\s*(?:pub(?:\([^)]*\))?\s+)?trait\s+([A-Za-z_][A-Za-z0-9_]*)`)
234)
235
236func rustEnclosingNameFromComment(comment string) (kind, name string, ok bool) {
237 if m := rustFunctionCommentPattern.FindStringSubmatch(comment); m != nil {
238 return "function_item", m[1], true
239 }
240 if m := rustStructCommentPattern.FindStringSubmatch(comment); m != nil {
241 return "struct_item", m[1], true
242 }
243 if m := rustEnumCommentPattern.FindStringSubmatch(comment); m != nil {
244 return "enum_item", m[1], true
245 }
246 if m := rustTraitCommentPattern.FindStringSubmatch(comment); m != nil {
247 return "trait_item", m[1], true
248 }
249 return "", "", false
250}
251
252func languageForFile(name string) (languageSpec, bool) {
253 spec, ok := languageRegistry[strings.ToLower(filepath.Ext(name))]
254 return spec, ok
255}
256
257// SupportsSemanticDiff reports whether a file name's extension has a
258// registered language spec, i.e. whether AnalyzeSemanticChanges can produce
259// anything better than an empty result for it.
260func SupportsSemanticDiff(fileName string) bool {
261 _, ok := languageForFile(fileName)
262 return ok
263}
264
265// SemanticSummary aggregates semantic changes across every file in a patch,
266// for a reviewer-facing rollup shown above the per-file breakdown.
267type SemanticSummary struct {
268 Added int
269 Modified int
270 SignatureChanged int
271 Removed int
272 AnalyzedFileCount int
273 SkippedFiles []string
274}
275
276func (s SemanticSummary) HasContent() bool {
277 return s.AnalyzedFileCount > 0 || len(s.SkippedFiles) > 0
278}
279
280func (s SemanticSummary) Total() int {
281 return s.Added + s.Modified + s.SignatureChanged + s.Removed
282}
283
284// SummarizeSemanticChanges folds one file's changes into a running summary.
285// Call once per file in a patch with its changes (possibly nil) and whether
286// the file's language was supported, then use the returned summary as-is.
287func SummarizeSemanticChanges(summary SemanticSummary, fileName string, supported bool, changes []SemanticChange) SemanticSummary {
288 if !supported {
289 summary.SkippedFiles = append(summary.SkippedFiles, fileName)
290 return summary
291 }
292
293 summary.AnalyzedFileCount++
294 for _, c := range changes {
295 switch c.Kind {
296 case SemanticAdded:
297 summary.Added++
298 case SemanticRemoved:
299 summary.Removed++
300 case SemanticSignatureChanged:
301 summary.SignatureChanged++
302 default:
303 summary.Modified++
304 }
305 }
306
307 return summary
308}
309
310// AnalyzeSemanticChanges produces a reviewer-facing list of semantic changes
311// for a single diffed file. It only has access to the hunks present in the
312// patch, not the full pre/post-image files, so entity extraction runs
313// per-hunk on the old and new fragment text. Unsupported languages or parse
314// failures degrade to an empty, non-error result so callers can always fall
315// back to the line diff.
316func AnalyzeSemanticChanges(file *gitdiff.File) []SemanticChange {
317 name := file.NewName
318 if name == "" {
319 name = file.OldName
320 }
321
322 spec, ok := languageForFile(name)
323 if !ok || file.IsBinary {
324 return nil
325 }
326
327 query, err := sitter.NewQuery([]byte(spec.query), spec.language)
328 if err != nil {
329 return nil
330 }
331 defer query.Close()
332
333 var changes []SemanticChange
334 for hunkIdx, frag := range file.TextFragments {
335 oldText, newText := fragmentSides(frag)
336
337 oldEntities := extractEntities(spec.language, query, oldText)
338 newEntities := extractEntities(spec.language, query, newText)
339
340 hunkChanges := diffEntities(oldEntities, newEntities, hunkIdx)
341 if len(hunkChanges) == 0 {
342 hunkChanges = enclosingChangeFromComment(spec, frag, hunkIdx)
343 }
344 if len(hunkChanges) == 0 {
345 hunkChanges = genericChunkChange(frag, hunkIdx)
346 }
347 changes = append(changes, hunkChanges...)
348 }
349
350 return mergeChangesByEntity(changes)
351}
352
353// semanticChangeKindRank orders SemanticChangeKind by specificity, most
354// specific first, so mergeChangesByEntity can keep the most informative
355// classification when the same entity is flagged by more than one hunk.
356var semanticChangeKindRank = map[SemanticChangeKind]int{
357 SemanticSignatureChanged: 0,
358 SemanticRenamed: 1,
359 SemanticAdded: 2,
360 SemanticRemoved: 2,
361 SemanticModified: 3,
362}
363
364// mergeChangesByEntity collapses multiple hunks flagging the same entity
365// (e.g. a function whose body spans several hunks) into a single change.
366// A large function edited across many hunks would otherwise produce one
367// "modified" entry per hunk that touches it, repeating the same information
368// with no added value. The first hunk's anchor is kept for the link, but the
369// most specific kind across all matching hunks wins.
370func mergeChangesByEntity(changes []SemanticChange) []SemanticChange {
371 order := make([]string, 0, len(changes))
372 merged := make(map[string]SemanticChange, len(changes))
373
374 for _, c := range changes {
375 key := c.EntityKind + "\x00" + c.Name
376 existing, ok := merged[key]
377 if !ok {
378 merged[key] = c
379 order = append(order, key)
380 continue
381 }
382 if semanticChangeKindRank[c.Kind] < semanticChangeKindRank[existing.Kind] {
383 existing.Kind = c.Kind
384 existing.OldSig = c.OldSig
385 existing.NewSig = c.NewSig
386 merged[key] = existing
387 }
388 }
389
390 result := make([]SemanticChange, 0, len(order))
391 for _, key := range order {
392 result = append(result, merged[key])
393 }
394 return result
395}
396
397// enclosingChangeFromComment falls back to git's own "nearest enclosing
398// function" hunk header (gitdiff.TextFragment.Comment) when a hunk's
399// fragment text doesn't contain a full declaration for tree-sitter to
400// match -- typically because the hunk only touches lines deep inside a
401// function body, and we don't have the full file to parse for context.
402func enclosingChangeFromComment(spec languageSpec, frag *gitdiff.TextFragment, hunkIdx int) []SemanticChange {
403 if spec.enclosingNameFromComment == nil || frag.Comment == "" {
404 return nil
405 }
406 if frag.LinesAdded == 0 && frag.LinesDeleted == 0 {
407 return nil
408 }
409
410 kind, name, ok := spec.enclosingNameFromComment(frag.Comment)
411 if !ok {
412 return nil
413 }
414
415 return []SemanticChange{{
416 Kind: SemanticModified,
417 EntityKind: kind,
418 Name: name,
419 HunkIndex: hunkIdx,
420 }}
421}
422
423// genericChunkChange is the last-resort fallback for a hunk with real edits
424// where neither a full declaration nor an enclosing-function comment could
425// be identified -- e.g. a change inside an anonymous closure passed as a
426// struct field, or a hunk in a language/file with no named top-level
427// entities (go.mod, go.sum). It reports the hunk by its line range instead
428// of by name, so reviewers still see *something* changed there.
429func genericChunkChange(frag *gitdiff.TextFragment, hunkIdx int) []SemanticChange {
430 if frag.LinesAdded == 0 && frag.LinesDeleted == 0 {
431 return nil
432 }
433
434 return []SemanticChange{{
435 Kind: SemanticModified,
436 EntityKind: "chunk",
437 Name: fmt.Sprintf("lines %d-%d", frag.NewPosition, frag.NewPosition+frag.NewLines-1),
438 HunkIndex: hunkIdx,
439 }}
440}
441
442// fragmentSides reconstructs the pre-image and post-image text of a hunk
443// from its line list, since gitdiff only exposes the unified representation.
444func fragmentSides(frag *gitdiff.TextFragment) (oldText, newText string) {
445 var oldBuf, newBuf bytes.Buffer
446 for _, line := range frag.Lines {
447 switch line.Op {
448 case gitdiff.OpContext:
449 oldBuf.WriteString(line.Line)
450 newBuf.WriteString(line.Line)
451 case gitdiff.OpDelete:
452 oldBuf.WriteString(line.Line)
453 case gitdiff.OpAdd:
454 newBuf.WriteString(line.Line)
455 }
456 }
457 return oldBuf.String(), newBuf.String()
458}
459
460// extractEntities runs the entity query against a best-effort parse of a
461// hunk fragment. Tree-sitter is error-tolerant, so a syntactically
462// incomplete fragment (a hunk that doesn't span whole declarations) still
463// yields partial results rather than failing outright.
464func extractEntities(lang *sitter.Language, query *sitter.Query, src string) []semanticEntity {
465 if strings.TrimSpace(src) == "" {
466 return nil
467 }
468
469 root, err := sitter.ParseCtx(context.Background(), []byte(src), lang)
470 if err != nil || root == nil {
471 return nil
472 }
473
474 cursor := sitter.NewQueryCursor()
475 defer cursor.Close()
476 cursor.Exec(query, root)
477
478 srcBytes := []byte(src)
479 var entities []semanticEntity
480 for {
481 match, ok := cursor.NextMatch()
482 if !ok {
483 break
484 }
485
486 var entityNode *sitter.Node
487 var name string
488 for _, capture := range match.Captures {
489 captureName := query.CaptureNameForId(capture.Index)
490 switch captureName {
491 case "entity":
492 entityNode = capture.Node
493 case "name":
494 name = capture.Node.Content(srcBytes)
495 }
496 }
497 if entityNode == nil || name == "" {
498 continue
499 }
500
501 body := entityNode.Content(srcBytes)
502 entities = append(entities, semanticEntity{
503 Kind: entityNode.Type(),
504 Name: name,
505 Signature: signatureOf(body),
506 BodyHash: hashBody(body),
507 })
508 }
509
510 return entities
511}
512
513// signatureOf reduces an entity's source text to a single-line
514// approximation of its declaration for display purposes.
515func signatureOf(body string) string {
516 if idx := strings.Index(body, "{"); idx >= 0 {
517 body = body[:idx]
518 }
519 return strings.Join(strings.Fields(body), " ")
520}
521
522func hashBody(body string) string {
523 sum := sha256.Sum256([]byte(strings.Join(strings.Fields(body), " ")))
524 return hex.EncodeToString(sum[:])
525}
526
527// diffEntities classifies entities found in one hunk's old side vs new side
528// by name. It only compares entities within the same hunk since that's the
529// unit of context we have available from a patchset alone.
530func diffEntities(oldEntities, newEntities []semanticEntity, hunkIdx int) []SemanticChange {
531 oldByName := map[string]semanticEntity{}
532 for _, e := range oldEntities {
533 oldByName[e.Name] = e
534 }
535 newByName := map[string]semanticEntity{}
536 for _, e := range newEntities {
537 newByName[e.Name] = e
538 }
539
540 var changes []SemanticChange
541 for name, newEntity := range newByName {
542 oldEntity, existed := oldByName[name]
543 if !existed {
544 changes = append(changes, SemanticChange{
545 Kind: SemanticAdded,
546 EntityKind: newEntity.Kind,
547 Name: name,
548 NewSig: newEntity.Signature,
549 HunkIndex: hunkIdx,
550 })
551 continue
552 }
553 if oldEntity.BodyHash == newEntity.BodyHash {
554 continue
555 }
556 kind := SemanticModified
557 if oldEntity.Signature != newEntity.Signature {
558 kind = SemanticSignatureChanged
559 }
560 changes = append(changes, SemanticChange{
561 Kind: kind,
562 EntityKind: newEntity.Kind,
563 Name: name,
564 OldSig: oldEntity.Signature,
565 NewSig: newEntity.Signature,
566 HunkIndex: hunkIdx,
567 })
568 }
569 for name, oldEntity := range oldByName {
570 if _, stillExists := newByName[name]; stillExists {
571 continue
572 }
573 changes = append(changes, SemanticChange{
574 Kind: SemanticRemoved,
575 EntityKind: oldEntity.Kind,
576 Name: name,
577 OldSig: oldEntity.Signature,
578 HunkIndex: hunkIdx,
579 })
580 }
581
582 return changes
583}