Files
wehub-resource-sync c4536f7e05
CI / test (push) Failing after 1s
CI / macOS amd64 (push) Has been cancelled
CI / macOS arm64 (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:31 +08:00

478 lines
11 KiB
Go

package comments
import (
"cmp"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"loov.dev/lensm/internal/atomicfile"
)
const commentsFileVersion = 1
type View string
const (
ViewSource View = "source"
ViewGoAsm View = "go_asm"
ViewNativeAsm View = "native_asm"
)
type Coord struct {
Binary string `json:"binary,omitempty"`
Function string `json:"function"`
View View `json:"view"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
PC uint64 `json:"pc,omitempty"`
PCHex string `json:"pc_hex,omitempty"`
}
type Record struct {
Coord
Text string `json:"text"`
UpdatedAt time.Time `json:"updated_at"`
}
type commentsDiskFile struct {
Version int `json:"version"`
Binary string `json:"binary,omitempty"`
Comments []json.RawMessage `json:"comments"`
}
type Store struct {
mu sync.RWMutex
path string
binary string
records map[string]Record
// touched marks keys mutated since the last sync with the file;
// saves merge the on-disk state for all other keys, so another lensm
// process writing the same sidecar loses only conflicting edits to
// the same record, not its whole set of comments.
touched map[string]bool
// dirty marks in-memory changes not yet written by Flush.
dirty bool
// preserved holds records this version doesn't understand (e.g. views
// added by a newer lensm), kept verbatim so saving doesn't destroy them.
preserved []json.RawMessage
}
func DefaultPath(binaryPath string) string {
if binaryPath == "" {
return ""
}
return binaryPath + ".lensm-comments.json"
}
func Open(path, binaryPath string) (*Store, error) {
binaryPath = CleanPath(binaryPath)
store := &Store{
path: path,
binary: binaryPath,
records: map[string]Record{},
touched: map[string]bool{},
}
if path == "" {
return store, nil
}
if err := store.load(); err != nil {
return nil, err
}
return store, nil
}
func (store *Store) Path() string {
if store == nil {
return ""
}
return store.path
}
func (store *Store) Get(coord Coord) string {
if store == nil {
return ""
}
store.mu.RLock()
defer store.mu.RUnlock()
rec, ok := store.records[store.key(coord)]
if !ok {
return ""
}
return rec.Text
}
func (store *Store) Set(coord Coord, text string) error {
return store.set(coord, text, true)
}
// SetBuffered updates the comment in memory and defers the disk write
// until Flush, so typing in an editor doesn't rewrite the sidecar on
// every keystroke.
func (store *Store) SetBuffered(coord Coord, text string) error {
return store.set(coord, text, false)
}
func (store *Store) set(coord Coord, text string, persist bool) error {
if store == nil {
return errors.New("comment store is not initialized")
}
text = strings.TrimSpace(text)
if err := coord.validate(); err != nil {
return err
}
store.mu.Lock()
defer store.mu.Unlock()
key := store.key(coord)
existing, exists := store.records[key]
if text == "" {
// Even when the record isn't in memory it may exist on disk,
// written by another process sharing the sidecar; fall through so
// the deletion is recorded and the next save drops it instead of
// re-adopting it.
delete(store.records, key)
} else {
coord = store.Normalize(coord)
if exists && existing.Text == text {
return nil
}
store.records[key] = Record{
Coord: coord,
Text: text,
UpdatedAt: time.Now().UTC(),
}
}
wasTouched := store.touched[key]
store.touched[key] = true
wasDirty := store.dirty
store.dirty = true
if !persist {
return nil
}
if err := store.saveLocked(); err != nil {
// Roll back so the in-memory store keeps matching the file.
if exists {
store.records[key] = existing
} else {
delete(store.records, key)
}
if !wasTouched {
delete(store.touched, key)
}
store.dirty = wasDirty
return err
}
store.dirty = false
return nil
}
// Flush writes buffered changes to disk.
func (store *Store) Flush() error {
if store == nil {
return nil
}
store.mu.Lock()
defer store.mu.Unlock()
if !store.dirty {
return nil
}
if err := store.saveLocked(); err != nil {
return err
}
store.dirty = false
return nil
}
func (store *Store) All() []Record {
if store == nil {
return nil
}
store.mu.RLock()
defer store.mu.RUnlock()
out := make([]Record, 0, len(store.records))
for _, rec := range store.records {
out = append(out, rec)
}
sortCommentRecords(out)
return out
}
func (store *Store) Filter(function string, view View) []Record {
if store == nil {
return nil
}
store.mu.RLock()
defer store.mu.RUnlock()
var out []Record
for _, rec := range store.records {
if function != "" && rec.Function != function {
continue
}
if view != "" && rec.View != view {
continue
}
out = append(out, rec)
}
sortCommentRecords(out)
return out
}
func (store *Store) ForSource(function, file string, line int) string {
return store.Get(Coord{
Function: function,
View: ViewSource,
File: file,
Line: line,
})
}
func (store *Store) ForAsm(function string, view View, pc uint64) string {
return store.Get(Coord{
Function: function,
View: view,
PC: pc,
})
}
func (store *Store) SetSource(function, file string, line int, text string) error {
return store.Set(Coord{
Function: function,
View: ViewSource,
File: file,
Line: line,
}, text)
}
func (store *Store) SetAsm(function string, view View, pc uint64, text string) error {
return store.Set(Coord{
Function: function,
View: view,
PC: pc,
}, text)
}
func (store *Store) load() error {
data, err := os.ReadFile(store.path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
var disk commentsDiskFile
if err := json.Unmarshal(data, &disk); err != nil {
return fmt.Errorf("load comments: %w", err)
}
if disk.Version != 0 && disk.Version != commentsFileVersion {
return fmt.Errorf("unsupported comments file version %d", disk.Version)
}
for _, raw := range disk.Comments {
var rec Record
if err := json.Unmarshal(raw, &rec); err != nil {
store.preserved = append(store.preserved, raw)
continue
}
if rec.Binary == "" {
rec.Binary = cmp.Or(disk.Binary, store.binary)
}
rec.PCHex = commentPCHex(rec.Coord)
if err := rec.Coord.validate(); err != nil {
store.preserved = append(store.preserved, raw)
continue
}
if rec.Text == "" {
continue
}
store.records[store.key(rec.Coord)] = rec
}
return nil
}
func (store *Store) saveLocked() error {
if store.path == "" {
return nil
}
store.mergeExternalLocked()
records := make([]Record, 0, len(store.records))
for _, rec := range store.records {
records = append(records, rec)
}
sortCommentRecords(records)
// Don't create a file just to record that nothing exists, e.g. after
// deleting a comment that was never saved.
if len(records) == 0 && len(store.preserved) == 0 {
if _, err := os.Stat(store.path); errors.Is(err, os.ErrNotExist) {
clear(store.touched)
return nil
}
}
comments := make([]json.RawMessage, 0, len(records)+len(store.preserved))
for _, rec := range records {
raw, err := json.Marshal(rec)
if err != nil {
return err
}
comments = append(comments, raw)
}
comments = append(comments, store.preserved...)
data, err := json.MarshalIndent(commentsDiskFile{
Version: commentsFileVersion,
Binary: store.binary,
Comments: comments,
}, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
if err := os.MkdirAll(filepath.Dir(store.path), 0o755); err != nil {
return err
}
if err := atomicfile.Write(store.path, data, 0o644); err != nil {
return err
}
// The in-memory state now matches the file again.
clear(store.touched)
return nil
}
// mergeExternalLocked folds records another lensm process wrote to the
// sidecar into the in-memory store before a full-file rewrite. Keys
// mutated locally since the last sync win; everything else — additions,
// edits, and deletions from the other process — is adopted, so two
// processes sharing a sidecar lose at most conflicting edits to the
// same record instead of each other's whole comment sets.
func (store *Store) mergeExternalLocked() {
data, err := os.ReadFile(store.path)
if err != nil {
return // nothing on disk to merge
}
var disk commentsDiskFile
if err := json.Unmarshal(data, &disk); err != nil {
return
}
if disk.Version != 0 && disk.Version != commentsFileVersion {
return
}
merged := make(map[string]Record, len(store.records))
for key, rec := range store.records {
if store.touched[key] {
merged[key] = rec
}
}
store.preserved = nil
for _, raw := range disk.Comments {
var rec Record
if err := json.Unmarshal(raw, &rec); err != nil {
store.preserved = append(store.preserved, raw)
continue
}
if rec.Binary == "" {
rec.Binary = cmp.Or(disk.Binary, store.binary)
}
rec.PCHex = commentPCHex(rec.Coord)
if err := rec.Coord.validate(); err != nil {
store.preserved = append(store.preserved, raw)
continue
}
if rec.Text == "" {
continue
}
key := store.key(rec.Coord)
if store.touched[key] {
continue
}
merged[key] = rec
}
store.records = merged
}
func (store *Store) Normalize(coord Coord) Coord {
coord.Binary = cmp.Or(coord.Binary, store.binary)
coord.Binary = CleanPath(coord.Binary)
coord.PCHex = commentPCHex(coord)
return coord
}
func (store *Store) key(coord Coord) string {
coord = store.Normalize(coord)
switch coord.View {
case ViewSource:
return fmt.Sprintf("%s\x00%s\x00%s\x00%d", coord.Function, coord.View, CleanPath(coord.File), coord.Line)
case ViewGoAsm, ViewNativeAsm:
return fmt.Sprintf("%s\x00%s\x00%x", coord.Function, coord.View, coord.PC)
default:
return fmt.Sprintf("%s\x00%s\x00%s\x00%d\x00%x", coord.Function, coord.View, CleanPath(coord.File), coord.Line, coord.PC)
}
}
func (coord Coord) validate() error {
if coord.Function == "" {
return errors.New("function is required")
}
switch coord.View {
case ViewSource:
if coord.File == "" {
return errors.New("file is required for source comments")
}
if coord.Line <= 0 {
return errors.New("line must be positive for source comments")
}
case ViewGoAsm, ViewNativeAsm:
default:
return fmt.Errorf("unsupported comment view %q", coord.View)
}
return nil
}
func sortCommentRecords(records []Record) {
slices.SortFunc(records, func(a, b Record) int {
if c := cmp.Compare(a.Function, b.Function); c != 0 {
return c
}
if c := cmp.Compare(a.View, b.View); c != 0 {
return c
}
if c := cmp.Compare(a.File, b.File); c != 0 {
return c
}
if c := cmp.Compare(a.Line, b.Line); c != 0 {
return c
}
return cmp.Compare(a.PC, b.PC)
})
}
func CleanPath(path string) string {
if path == "" {
return ""
}
if abs, err := filepath.Abs(path); err == nil {
return filepath.Clean(abs)
}
return filepath.Clean(path)
}
func FormatPC(pc uint64) string {
return fmt.Sprintf("0x%x", pc)
}
func commentPCHex(coord Coord) string {
if coord.View == ViewGoAsm || coord.View == ViewNativeAsm || coord.PC != 0 {
return FormatPC(coord.PC)
}
return ""
}