| | | 1 | | package neovim |
| | | 2 | | |
| | | 3 | | import ( |
| | | 4 | | "archive/tar" |
| | | 5 | | "archive/zip" |
| | | 6 | | "compress/gzip" |
| | | 7 | | "fmt" |
| | | 8 | | "io" |
| | | 9 | | "os" |
| | | 10 | | "path/filepath" |
| | | 11 | | |
| | | 12 | | "github.com/jedi-knights/neospec/internal/domain" |
| | | 13 | | ) |
| | | 14 | | |
| | | 15 | | // Cache manages the on-disk store of extracted Neovim binaries. |
| | | 16 | | // Layout: <cacheDir>/<version>/<platform>/bin/nvim[.exe] |
| | | 17 | | type Cache struct { |
| | | 18 | | root string |
| | | 19 | | } |
| | | 20 | | |
| | | 21 | | // NewCache creates a Cache rooted at the given directory. |
| | 22 | 22 | | func NewCache(root string) *Cache { |
| | 22 | 23 | | return &Cache{root: root} |
| | 22 | 24 | | } |
| | | 25 | | |
| | | 26 | | // VersionDir returns the cache subdirectory for a version+platform pair. |
| | | 27 | | func (c *Cache) VersionDir(v domain.Version, p domain.Platform) string { |
| | | 28 | | return filepath.Join(c.root, v.Tag, string(p.OS), string(p.Arch)) |
| | | 29 | | } |
| | | 30 | | |
| | | 31 | | // Lookup checks whether a cached binary exists and returns its path. |
| | | 32 | | func (c *Cache) Lookup(v domain.Version, p domain.Platform) (string, bool) { |
| | | 33 | | binPath := filepath.Join(c.VersionDir(v, p), "bin", domain.BinaryName(p)) |
| | | 34 | | if _, err := os.Stat(binPath); err == nil { |
| | | 35 | | return binPath, true |
| | | 36 | | } |
| | | 37 | | return "", false |
| | | 38 | | } |
| | | 39 | | |
| | | 40 | | // Extract unpacks the archive at archivePath into the cache directory for the |
| | | 41 | | // given version and platform, and returns the path to the nvim binary. |
| | | 42 | | func (c *Cache) Extract(v domain.Version, p domain.Platform, archivePath string) (string, error) { |
| | | 43 | | destDir := c.VersionDir(v, p) |
| | | 44 | | if err := os.MkdirAll(destDir, 0o755); err != nil { |
| | | 45 | | return "", fmt.Errorf("creating cache dir: %w", err) |
| | | 46 | | } |
| | | 47 | | |
| | | 48 | | switch p.OS { |
| | | 49 | | case domain.OSWindows: |
| | | 50 | | if err := extractZip(archivePath, destDir); err != nil { |
| | | 51 | | return "", err |
| | | 52 | | } |
| | | 53 | | default: |
| | | 54 | | if err := extractTarGz(archivePath, destDir); err != nil { |
| | | 55 | | return "", err |
| | | 56 | | } |
| | | 57 | | } |
| | | 58 | | |
| | | 59 | | binPath := filepath.Join(destDir, "bin", domain.BinaryName(p)) |
| | | 60 | | if _, err := os.Stat(binPath); err != nil { |
| | | 61 | | return "", fmt.Errorf("binary not found after extraction at %s: %w", binPath, err) |
| | | 62 | | } |
| | | 63 | | return binPath, nil |
| | | 64 | | } |
| | | 65 | | |
| | | 66 | | // extractTarGz extracts a .tar.gz archive, stripping the first path component |
| | | 67 | | // so that nvim-linux-x86_64/bin/nvim becomes <destDir>/bin/nvim. |
| | 13 | 68 | | func extractTarGz(archivePath, destDir string) error { |
| | 13 | 69 | | f, err := os.Open(archivePath) |
| | 1 | 70 | | if err != nil { |
| | 1 | 71 | | return err |
| | 1 | 72 | | } |
| | 12 | 73 | | defer f.Close() |
| | 12 | 74 | | |
| | 12 | 75 | | gz, err := gzip.NewReader(f) |
| | 2 | 76 | | if err != nil { |
| | 2 | 77 | | return fmt.Errorf("opening gzip stream: %w", err) |
| | 2 | 78 | | } |
| | 10 | 79 | | defer gz.Close() |
| | 10 | 80 | | |
| | 10 | 81 | | tr := tar.NewReader(gz) |
| | 10 | 82 | | for { |
| | 22 | 83 | | hdr, err := tr.Next() |
| | 6 | 84 | | if err == io.EOF { |
| | 6 | 85 | | break |
| | | 86 | | } |
| | 1 | 87 | | if err != nil { |
| | 1 | 88 | | return fmt.Errorf("reading tar entry: %w", err) |
| | 1 | 89 | | } |
| | | 90 | | |
| | 15 | 91 | | relPath := stripFirstComponent(hdr.Name) |
| | 2 | 92 | | if relPath == "" { |
| | 2 | 93 | | continue |
| | | 94 | | } |
| | | 95 | | |
| | 13 | 96 | | target := filepath.Join(destDir, relPath) |
| | 13 | 97 | | |
| | 13 | 98 | | switch hdr.Typeflag { |
| | 2 | 99 | | case tar.TypeDir: |
| | 1 | 100 | | if err := os.MkdirAll(target, 0o755); err != nil { |
| | 1 | 101 | | return err |
| | 1 | 102 | | } |
| | 10 | 103 | | case tar.TypeReg: |
| | 1 | 104 | | if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { |
| | 1 | 105 | | return err |
| | 1 | 106 | | } |
| | 9 | 107 | | mode := os.FileMode(hdr.Mode) |
| | 1 | 108 | | if err := writeFile(target, tr, mode); err != nil { |
| | 1 | 109 | | return err |
| | 1 | 110 | | } |
| | 1 | 111 | | case tar.TypeSymlink: |
| | 1 | 112 | | // Neovim archives include symlinks; recreate them. |
| | 1 | 113 | | _ = os.Remove(target) // ignore error — may not exist |
| | 0 | 114 | | if err := os.Symlink(hdr.Linkname, target); err != nil { |
| | 0 | 115 | | return err |
| | 0 | 116 | | } |
| | | 117 | | } |
| | | 118 | | } |
| | 6 | 119 | | return nil |
| | | 120 | | } |
| | | 121 | | |
| | | 122 | | // extractZip extracts a .zip archive (Windows), stripping the first component. |
| | 6 | 123 | | func extractZip(archivePath, destDir string) error { |
| | 6 | 124 | | r, err := zip.OpenReader(archivePath) |
| | 1 | 125 | | if err != nil { |
| | 1 | 126 | | return err |
| | 1 | 127 | | } |
| | 5 | 128 | | defer r.Close() |
| | 5 | 129 | | |
| | 5 | 130 | | for _, f := range r.File { |
| | 9 | 131 | | relPath := stripFirstComponent(f.Name) |
| | 1 | 132 | | if relPath == "" { |
| | 1 | 133 | | continue |
| | | 134 | | } |
| | 8 | 135 | | target := filepath.Join(destDir, relPath) |
| | 8 | 136 | | |
| | 2 | 137 | | if f.FileInfo().IsDir() { |
| | 1 | 138 | | if err := os.MkdirAll(target, 0o755); err != nil { |
| | 1 | 139 | | return err |
| | 1 | 140 | | } |
| | 1 | 141 | | continue |
| | | 142 | | } |
| | | 143 | | |
| | 1 | 144 | | if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { |
| | 1 | 145 | | return err |
| | 1 | 146 | | } |
| | 5 | 147 | | rc, err := f.Open() |
| | 0 | 148 | | if err != nil { |
| | 0 | 149 | | return err |
| | 0 | 150 | | } |
| | 5 | 151 | | writeErr := writeFile(target, rc, f.Mode()) |
| | 0 | 152 | | if cerr := rc.Close(); cerr != nil && writeErr == nil { |
| | 0 | 153 | | writeErr = cerr |
| | 0 | 154 | | } |
| | 1 | 155 | | if writeErr != nil { |
| | 1 | 156 | | return writeErr |
| | 1 | 157 | | } |
| | | 158 | | } |
| | 2 | 159 | | return nil |
| | | 160 | | } |
| | | 161 | | |
| | 15 | 162 | | func writeFile(path string, r io.Reader, mode os.FileMode) (retErr error) { |
| | 15 | 163 | | dst, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) |
| | 3 | 164 | | if err != nil { |
| | 3 | 165 | | return err |
| | 3 | 166 | | } |
| | 12 | 167 | | defer func() { |
| | 0 | 168 | | if cerr := dst.Close(); cerr != nil && retErr == nil { |
| | 0 | 169 | | retErr = cerr |
| | 0 | 170 | | } |
| | | 171 | | }() |
| | 12 | 172 | | _, retErr = io.Copy(dst, r) |
| | 12 | 173 | | return |
| | | 174 | | } |
| | | 175 | | |
| | | 176 | | // stripFirstComponent removes the first path segment from a slash-separated |
| | | 177 | | // path, e.g. "nvim-linux-x86_64/bin/nvim" → "bin/nvim". |
| | 24 | 178 | | func stripFirstComponent(p string) string { |
| | 24 | 179 | | for i, c := range p { |
| | 23 | 180 | | if (c == '/' || c == '\\') && i > 0 { |
| | 23 | 181 | | return filepath.FromSlash(p[i+1:]) |
| | 23 | 182 | | } |
| | | 183 | | } |
| | 1 | 184 | | return "" |
| | | 185 | | } |