package gitea import ( "fmt" "strconv" "strings" ) // PositionMap holds a per-file mapping of GitHub diff-position to new-file line number. // Position is a 1-indexed offset from the @@ hunk header line in the unified diff. type PositionMap struct { // files maps filename → (position → new-file line number). // Deletion lines are mapped to -1 (no new-file line). files map[string]map[int]int } // Translate converts a GitHub diff-position to a new-file line number for a given file. // Returns an error if the file is not in the diff or the position is out of range. // If the position targets a deletion line, it maps to the nearest non-deletion line below; // if no such line exists, returns an error. func (pm *PositionMap) Translate(file string, position int) (int, error) { if pm == nil || pm.files == nil { return 0, fmt.Errorf("empty position map") } fileMap, ok := pm.files[file] if !ok { return 0, fmt.Errorf("file %q not found in diff", file) } if position < 1 { return 0, fmt.Errorf("position %d out of range (must be >= 1)", position) } lineNum, ok := fileMap[position] if !ok { return 0, fmt.Errorf("position %d out of range for file %q", position, file) } // lineNum == -1 means this position is a deletion line. // Map to the nearest non-deletion line below. if lineNum == -1 { maxPos := pm.maxPosition(file) for p := position + 1; p <= maxPos; p++ { if ln, exists := fileMap[p]; exists && ln > 0 { return ln, nil } } return 0, fmt.Errorf("position %d targets a deletion line with no subsequent new-file line in %q", position, file) } return lineNum, nil } // maxPosition returns the highest position number for a file. func (pm *PositionMap) maxPosition(file string) int { max := 0 for pos := range pm.files[file] { if pos > max { max = pos } } return max } // BuildPositionToLineMap parses a unified diff and builds a PositionMap // mapping diff-position → new-file line number per file. // // Diff-position counting rules (GitHub spec): // - The @@ hunk header line is position 1 for the file's first hunk // - Every subsequent line increments position by 1 — context, additions, AND deletions // - A new @@ hunk within the same file continues incrementing (does not reset) // - Position maps to the new file line number for additions and context lines // - Deletion lines have a position but no new-file line number (stored as -1) func BuildPositionToLineMap(diff string) (*PositionMap, error) { pm := &PositionMap{files: make(map[string]map[int]int)} lines := strings.Split(diff, "\n") var currentFile string var position int var newLine int for _, line := range lines { // Detect new file in diff if strings.HasPrefix(line, "+++ b/") { currentFile = strings.TrimPrefix(line, "+++ b/") position = 0 newLine = 0 if pm.files[currentFile] == nil { pm.files[currentFile] = make(map[int]int) } continue } // Deleted file: +++ /dev/null means the file is being deleted if strings.HasPrefix(line, "+++ /dev/null") { currentFile = "" continue } // Skip --- lines (old file header) if strings.HasPrefix(line, "--- ") { continue } // Skip diff --git lines if strings.HasPrefix(line, "diff --git") { continue } // Skip index lines if strings.HasPrefix(line, "index ") { continue } // Binary file detection if strings.HasPrefix(line, "Binary files") { currentFile = "" continue } // Parse hunk headers if strings.HasPrefix(line, "@@") && currentFile != "" { position++ newLine = parseHunkStart(line) continue } if currentFile == "" { continue } // Skip "\ No newline at end of file" markers if strings.HasPrefix(line, `\`) { continue } // Process diff content lines if strings.HasPrefix(line, "+") { // Addition: has a new-file line number position++ pm.files[currentFile][position] = newLine newLine++ } else if strings.HasPrefix(line, "-") { // Deletion: has a position but no new-file line number position++ pm.files[currentFile][position] = -1 } else if strings.HasPrefix(line, " ") { // Context line position++ pm.files[currentFile][position] = newLine newLine++ } } return pm, nil } // parseHunkStart extracts the new-file starting line number from a hunk header. // Format: @@ -old_start[,old_count] +new_start[,new_count] @@ func parseHunkStart(hunkLine string) int { plusIdx := strings.Index(hunkLine, "+") if plusIdx < 0 { return 1 } rest := hunkLine[plusIdx+1:] endIdx := 0 for endIdx < len(rest) && rest[endIdx] >= '0' && rest[endIdx] <= '9' { endIdx++ } if endIdx == 0 { return 1 } n, err := strconv.Atoi(rest[:endIdx]) if err != nil { return 1 } return n }