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