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  }