github.com/replit/upm@v0.0.0-20240423230255-9ce4fc3ea24c/internal/backends/elisp/elisp.go (about) 1 // Package elisp provides a backend for Emacs Lisp using Cask. 2 package elisp 3 4 import ( 5 "context" 6 "encoding/json" 7 "fmt" 8 "os" 9 "os/exec" 10 "path/filepath" 11 "regexp" 12 "strings" 13 14 "github.com/replit/upm/internal/api" 15 "github.com/replit/upm/internal/nix" 16 "github.com/replit/upm/internal/util" 17 "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" 18 ) 19 20 // elispPatterns is the FilenamePatterns value for ElispBackend. 21 var elispPatterns = []string{"*.el"} 22 23 func elispCaskIsAvailable() bool { 24 _, err := exec.LookPath("emacs") 25 if err == nil { 26 _, err = exec.LookPath("cask") 27 } 28 return err == nil 29 } 30 31 // ElispBackend is the UPM language backend for Emacs Lisp using Cask. 32 var ElispBackend = api.LanguageBackend{ 33 Name: "elisp-cask", 34 Specfile: "Cask", 35 Lockfile: "packages.txt", 36 IsAvailable: elispCaskIsAvailable, 37 FilenamePatterns: elispPatterns, 38 Quirks: api.QuirksNotReproducible, 39 GetPackageDir: func() string { 40 return ".cask" 41 }, 42 Search: func(query string) []api.PkgInfo { 43 tmpdir, err := os.MkdirTemp("", "elpa") 44 if err != nil { 45 util.DieIO("%s", err) 46 } 47 defer os.RemoveAll(tmpdir) 48 49 // Run script with lexical binding (any header comment 50 // in the script would not be respected, so we have to 51 // do it this way). 52 code := fmt.Sprintf( 53 "(eval '(progn %s) t)", util.GetResource("/elisp/elpa-search.el"), 54 ) 55 code = strings.Replace(code, "~", "`", -1) 56 outputB := util.GetCmdOutput([]string{ 57 "emacs", "-Q", "--batch", "--eval", code, 58 tmpdir, "search", query, 59 }) 60 var results []api.PkgInfo 61 if err := json.Unmarshal(outputB, &results); err != nil { 62 util.DieProtocol("%s", err) 63 } 64 return results 65 }, 66 Info: func(name api.PkgName) api.PkgInfo { 67 tmpdir, err := os.MkdirTemp("", "elpa") 68 if err != nil { 69 util.DieIO("%s", err) 70 } 71 defer os.RemoveAll(tmpdir) 72 73 // Run script with lexical binding (any header comment 74 // in the script would not be respected, so we have to 75 // do it this way). 76 code := fmt.Sprintf( 77 "(eval '(progn %s) t)", util.GetResource("/elisp/elpa-search.el"), 78 ) 79 code = strings.Replace(code, "~", "`", -1) 80 outputB := util.GetCmdOutput([]string{ 81 "emacs", "-Q", "--batch", "--eval", code, 82 tmpdir, "info", string(name), 83 }) 84 var info api.PkgInfo 85 if err := json.Unmarshal(outputB, &info); err != nil { 86 util.DieProtocol("%s", err) 87 } 88 return info 89 }, 90 Add: func(ctx context.Context, pkgs map[api.PkgName]api.PkgSpec, projectName string) { 91 //nolint:ineffassign,wastedassign,staticcheck 92 span, ctx := tracer.StartSpanFromContext(ctx, "elisp add") 93 defer span.Finish() 94 contentsB, err := os.ReadFile("Cask") 95 var contents string 96 if os.IsNotExist(err) { 97 contents = `(source melpa) 98 (source gnu) 99 (source org) 100 ` 101 } else if err != nil { 102 util.DieIO("Cask: %s", err) 103 } else { 104 contents = string(contentsB) 105 } 106 107 // Ensure newline before the stuff we add, for 108 // readability. 109 if len(contents) > 0 && contents[len(contents)-1] != '\n' { 110 contents += "\n" 111 } 112 113 for name, spec := range pkgs { 114 contents += fmt.Sprintf(`(depends-on "%s"`, name) 115 if spec != "" { 116 contents += fmt.Sprintf(" %s", spec) 117 } 118 contents += ")\n" 119 } 120 121 contentsB = []byte(contents) 122 util.ProgressMsg("write Cask") 123 util.TryWriteAtomic("Cask", contentsB) 124 }, 125 Remove: func(ctx context.Context, pkgs map[api.PkgName]bool) { 126 //nolint:ineffassign,wastedassign,staticcheck 127 span, ctx := tracer.StartSpanFromContext(ctx, "elisp remove") 128 defer span.Finish() 129 contentsB, err := os.ReadFile("Cask") 130 if err != nil { 131 util.DieIO("Cask: %s", err) 132 } 133 contents := string(contentsB) 134 135 for name := range pkgs { 136 contents = regexp.MustCompile( 137 fmt.Sprintf( 138 `(?m)^ *\(depends-on +"%s".*\)\n?$`, 139 regexp.QuoteMeta(string(name)), 140 ), 141 ).ReplaceAllLiteralString(contents, "") 142 } 143 144 contentsB = []byte(contents) 145 util.ProgressMsg("write Cask") 146 util.TryWriteAtomic("Cask", contentsB) 147 }, 148 Install: func(ctx context.Context) { 149 //nolint:ineffassign,wastedassign,staticcheck 150 span, ctx := tracer.StartSpanFromContext(ctx, "cask install") 151 defer span.Finish() 152 util.RunCmd([]string{"cask", "install"}) 153 outputB := util.GetCmdOutput( 154 []string{"cask", "eval", util.GetResource( 155 "/elisp/cask-list-installed.el", 156 )}, 157 ) 158 util.ProgressMsg("write packages.txt") 159 util.TryWriteAtomic("packages.txt", outputB) 160 }, 161 ListSpecfile: func(mergeAllGroups bool) map[api.PkgName]api.PkgSpec { 162 outputB := util.GetCmdOutput( 163 []string{"cask", "eval", util.GetResource( 164 "/elisp/cask-list-specfile.el", 165 )}, 166 ) 167 pkgs := map[api.PkgName]api.PkgSpec{} 168 for _, line := range strings.Split(string(outputB), "\n") { 169 if line == "" { 170 continue 171 } 172 fields := strings.SplitN(line, "=", 2) 173 if len(fields) != 2 { 174 util.DieProtocol("unexpected output, expected name=spec: %s", line) 175 } 176 name := api.PkgName(fields[0]) 177 spec := api.PkgSpec(fields[1]) 178 pkgs[name] = spec 179 } 180 return pkgs 181 }, 182 ListLockfile: func() map[api.PkgName]api.PkgVersion { 183 contentsB, err := os.ReadFile("packages.txt") 184 if err != nil { 185 util.DieIO("packages.txt: %s", err) 186 } 187 contents := string(contentsB) 188 r := regexp.MustCompile(`(.+)=(.+)`) 189 pkgs := map[api.PkgName]api.PkgVersion{} 190 for _, match := range r.FindAllStringSubmatch(contents, -1) { 191 name := api.PkgName(match[1]) 192 version := api.PkgVersion(match[2]) 193 pkgs[name] = version 194 } 195 return pkgs 196 }, 197 GuessRegexps: util.Regexps([]string{ 198 `\(\s*require\s*'\s*([^)[:space:]]+)[^)]*\)`, 199 }), 200 Guess: func(ctx context.Context) (map[string][]api.PkgName, bool) { 201 //nolint:ineffassign,wastedassign,staticcheck 202 span, ctx := tracer.StartSpanFromContext(ctx, "elisp guess") 203 defer span.Finish() 204 r := regexp.MustCompile( 205 `\(\s*require\s*'\s*([^)[:space:]]+)[^)]*\)`, 206 ) 207 required := map[string]bool{} 208 for _, match := range util.SearchRecursive(r, elispPatterns) { 209 required[match[1]] = true 210 } 211 212 if len(required) == 0 { 213 return map[string][]api.PkgName{}, true 214 } 215 216 r = regexp.MustCompile( 217 `\(\s*provide\s*'\s*([^)[:space:]]+)[^)]*\)`, 218 ) 219 provided := map[string]bool{} 220 for _, match := range util.SearchRecursive(r, elispPatterns) { 221 provided[match[1]] = true 222 } 223 224 tempdir, err := os.MkdirTemp("", "epkgs") 225 if err != nil { 226 util.DieIO("%s", err) 227 } 228 defer os.RemoveAll(tempdir) 229 230 url := "https://github.com/emacsmirror/epkgs/raw/master/epkg.sqlite" 231 epkgs := filepath.Join(tempdir, "epkgs.sqlite") 232 util.DownloadFile(epkgs, url) 233 234 clauses := []string{} 235 for feature := range required { 236 if strings.ContainsAny(feature, `\'`) { 237 continue 238 } 239 if provided[feature] { 240 continue 241 } 242 clauses = append(clauses, fmt.Sprintf("feature = '%s'", feature)) 243 } 244 if len(clauses) == 0 { 245 return map[string][]api.PkgName{}, true 246 } 247 where := strings.Join(clauses, " OR ") 248 query := fmt.Sprintf("SELECT package FROM provided PR WHERE (%s) "+ 249 "AND NOT EXISTS (SELECT 1 FROM builtin_libraries B "+ 250 "WHERE PR.feature = B.feature) "+ 251 "AND NOT EXISTS (SELECT 1 FROM packages PK "+ 252 "WHERE PR.package = PK.name AND PK.class = 'builtin');", 253 where, 254 ) 255 output := string(util.GetCmdOutput([]string{"sqlite3", epkgs, query})) 256 257 r = regexp.MustCompile(`"(.+?)"`) 258 names := map[string][]api.PkgName{} 259 for _, match := range r.FindAllStringSubmatch(output, -1) { 260 name := match[1] 261 names[name] = []api.PkgName{api.PkgName(name)} 262 } 263 return names, true 264 }, 265 InstallReplitNixSystemDependencies: nix.DefaultInstallReplitNixSystemDependencies, 266 }