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}