diff --git a/elisp/BUILD b/elisp/BUILD index c1327c144..64810bf3e 100644 --- a/elisp/BUILD +++ b/elisp/BUILD @@ -103,6 +103,7 @@ bzl_library( visibility = ["//visibility:public"], deps = [ "//elisp/common:elisp_info", + "//elisp/private:build_package", "//elisp/private:compile", ], ) diff --git a/elisp/common/elisp_info.bzl b/elisp/common/elisp_info.bzl index 3ac7335e8..26658b816 100644 --- a/elisp/common/elisp_info.bzl +++ b/elisp/common/elisp_info.bzl @@ -47,5 +47,11 @@ additions for this library and all its transitive dependencies. The `depset` uses preorder traversal: entries for libraries closer to the root of the dependency graph come first. The `depset` elements are structures as described in the provider documentation.""", + "package_file": """A `File` object for the -pkg.el file. +None if `enable_package` is False.""", + "metadata_file": """A `File` object for the metadata file. +None if `enable_package` is False.""", + "autoloads_file": """A `File` object for the autoloads file. +None if `enable_package` is False.""", }, ) diff --git a/elisp/elisp_library.bzl b/elisp/elisp_library.bzl index 2229dc8e6..d1a2cd760 100644 --- a/elisp/elisp_library.bzl +++ b/elisp/elisp_library.bzl @@ -15,6 +15,7 @@ """Defines the `elisp_library` rule.""" load("//elisp/common:elisp_info.bzl", "EmacsLispInfo") +load("//elisp/private:build_package.bzl", "build_package") load("//elisp/private:compile.bzl", "COMPILE_ATTRS", "compile") visibility("public") @@ -30,9 +31,19 @@ def _elisp_library_impl(ctx): tags = ctx.attr.tags, fatal_warnings = ctx.attr.fatal_warnings, ) + extra_out = [] + package_file = None + metadata_file = None + autoloads_file = None + if ctx.attr.enable_package and not ctx.attr.testonly: + pkg = build_package(ctx, ctx.files.srcs, ctx.files.data) + extra_out += [pkg.package_file, pkg.metadata_file, pkg.autoloads_file] + package_file = pkg.package_file + metadata_file = pkg.metadata_file + autoloads_file = pkg.autoloads_file return [ DefaultInfo( - files = depset(direct = result.outs), + files = depset(direct = result.outs + extra_out), runfiles = result.runfiles, ), coverage_common.instrumented_files_info( @@ -48,6 +59,9 @@ def _elisp_library_impl(ctx): transitive_source_files = result.transitive_srcs, transitive_compiled_files = result.transitive_outs, transitive_load_path = result.transitive_load_path, + package_file = package_file, + metadata_file = metadata_file, + autoloads_file = autoloads_file, ), ] @@ -84,6 +98,34 @@ To add a load path entry for the current package, specify `.` here.""", doc = "List of `elisp_library` dependencies.", providers = [EmacsLispInfo], ), + "enable_package": attr.bool( + doc = """Enable generation of package.el package for this library. +This value is forced to False if testonly is True.""", + default = True, + ), + "emacs_package_name": attr.string( + doc = """The name used for the package.el package. +This attribute is ignored if enable_package is False. +Otherwise, srcs should contain a package description file `-pkg.el`. +If there is no such package description file, then srcs must contain a file +`.el` containing the appropriate package headers. + +If there is only one file in srcs, then the default value is the file basename +with the .el suffix removed. Otherwise, the default is the target label name, +with underscores replaced with dashes.""", + ), + "_gen_pkg_el": attr.label( + default = "//elisp:gen-pkg-el.elc", + allow_single_file = [".elc"], + ), + "_gen_metadata": attr.label( + default = "//elisp:gen-metadata.elc", + allow_single_file = [".elc"], + ), + "_gen_autoloads": attr.label( + default = "//elisp:gen-autoloads.elc", + allow_single_file = [".elc"], + ), }, doc = """Byte-compiles Emacs Lisp source files and makes the compiled output available to dependencies. All sources are byte-compiled. diff --git a/elisp/private/BUILD b/elisp/private/BUILD index 0d299c292..29312d322 100644 --- a/elisp/private/BUILD +++ b/elisp/private/BUILD @@ -34,6 +34,18 @@ bzl_library( deps = [":run_emacs"], ) +bzl_library( + name = "build_package", + srcs = ["build_package.bzl"], + visibility = [ + "//elisp:__pkg__", + ], + deps = [ + ":run_emacs", + "@bazel_skylib//lib:paths", + ], +) + bzl_library( name = "compile", srcs = ["compile.bzl"], diff --git a/elisp/private/build_package.bzl b/elisp/private/build_package.bzl new file mode 100644 index 000000000..2c5c6a7e1 --- /dev/null +++ b/elisp/private/build_package.bzl @@ -0,0 +1,189 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Private utility functions to build Emacs Lisp packages.""" + +load("@bazel_skylib//lib:paths.bzl", "paths") +load(":run_emacs.bzl", "run_emacs") + +visibility(["//elisp"]) + +def build_package(ctx, srcs, data): + """Build package files. + + Args: + ctx (ctx): rule context + srcs (list of Files): Emacs Lisp sources files + data (list of Files): data files + + Returns: + A structure with the following fields: + package_file: the File object for the -pkg.el file + metadata_file: the File object containing the package metadata + autoloads_file: the File object for the autoloads file + """ + package_name = _get_emacs_package_name(ctx) + + pkg_file = None + + # Try to find an existing -pkg.el file + expected = package_name + "-pkg.el" + for file in srcs + data: + if file.basename == expected: + pkg_file = file + break + + # Generate a -pkg.el file + if pkg_file == None: + expected = package_name + ".el" + for file in srcs + data: + if file.basename == expected: + pkg_file = _generate_pkg_el(ctx, file) + break + if pkg_file == None: + fail("No package metadata found for target", ctx.label) + + # Try to find an existing autoloads file + autoloads_file = None + expected = package_name + "-autoloads.el" + for file in srcs + data: + if file.basename == expected: + autoloads_file = file + break + if autoloads_file == None: + autoloads_file = _generate_autoloads(ctx, package_name, srcs) + metadata_file = _generate_metadata(ctx, pkg_file) + return struct( + package_file = pkg_file, + metadata_file = metadata_file, + autoloads_file = autoloads_file, + ) + +def _get_emacs_package_name(ctx): + """Returns the package name to use for `elisp_library' rules.""" + if ctx.attr.emacs_package_name: + return ctx.attr.emacs_package_name + if len(ctx.files.srcs) != 1: + return ctx.label.name.replace("_", "-") + basename = ctx.files.srcs[0].basename + if not basename.endswith(".el"): + fail("Suspicious single file when guessing package_name for target", ctx.label) + if basename.endswith("-pkg.el"): + fail("Suspicious package_name derived from single source file for target", ctx.label) + return basename[:-len(".el")] + +def _generate_pkg_el(ctx, src): + """Generate -pkg.el file. + + Args: + ctx (ctx): rule context + src (File): Emacs Lisp source file to parse for package metadata + + Returns: + the File object for the -pkg.el file + """ + package_name = src.basename.rsplit(".")[0] + out = ctx.actions.declare_file(paths.join( + _OUTPUT_DIR, + ctx.attr.name, + "{}-pkg.el".format(package_name), + )) + inputs = depset(direct = [src, ctx.file._gen_pkg_el]) + run_emacs( + ctx = ctx, + arguments = [ + "--load=" + ctx.file._gen_pkg_el.path, + "--funcall=elisp/gen-pkg-el-and-exit", + src.path, + out.path, + ], + inputs = inputs, + outputs = [out], + tags = ctx.attr.tags, + mnemonic = "GenPkgEl", + progress_message = "Generating -pkg.el {}".format(out.short_path), + manifest_basename = out.basename, + manifest_sibling = out, + ) + return out + +def _generate_metadata(ctx, package_file): + """Generate metadata file. + + Args: + ctx (ctx): rule context + package_file (File): the File object for the -pkg.el file + + Returns: + The File object for the metadata file + """ + if not package_file.basename.endswith("-pkg.el"): + fail("Unexpected package_file", package_file) + package_name = package_file.basename[:-len("-pkg.el")] + out = ctx.actions.declare_file(paths.join(_OUTPUT_DIR, ctx.attr.name, "{}.json".format(package_name))) + inputs = depset(direct = [package_file, ctx.file._gen_metadata]) + run_emacs( + ctx = ctx, + arguments = [ + "--load=" + ctx.file._gen_metadata.path, + "--funcall=elisp/gen-metadata-and-exit", + package_file.path, + out.path, + ], + inputs = inputs, + outputs = [out], + tags = ctx.attr.tags, + mnemonic = "GenMetadata", + progress_message = "Generating metadata {}".format(out.short_path), + manifest_basename = out.basename, + manifest_sibling = out, + ) + return out + +def _generate_autoloads(ctx, package_name, srcs): + """Generate autoloads file. + + Args: + ctx (ctx): rule context + package_name (string): name of package + srcs (list of Files): Emacs Lisp source files for which to generate autoloads + + Returns: + The generated File. + """ + out = ctx.actions.declare_file(paths.join(_OUTPUT_DIR, ctx.attr.name, "{}-autoloads.el".format(package_name))) + inputs = depset(direct = srcs + [ctx.file._gen_autoloads]) + run_emacs( + ctx = ctx, + arguments = [ + "--load=" + ctx.file._gen_autoloads.path, + "--funcall=elisp/gen-autoloads-and-exit", + out.path, + package_name, + ctx.actions.args().add_all(srcs), + ], + inputs = inputs, + outputs = [out], + tags = ctx.attr.tags, + mnemonic = "GenAutoloads", + progress_message = "Generating autoloads {}".format(out.short_path), + manifest_basename = out.basename, + manifest_sibling = out, + ) + return out + +# Directory relative to the current package where to store compiled files. This +# is equivalent to _objs for C++ rules. See +# https://bazel.build/remote/output-directories#layout-diagram. +_OUTPUT_DIR = "_elisp" diff --git a/elisp/private/tools/BUILD b/elisp/private/tools/BUILD index c01c700f7..446ae8549 100644 --- a/elisp/private/tools/BUILD +++ b/elisp/private/tools/BUILD @@ -300,6 +300,36 @@ bootstrap( ], ) +bootstrap( + name = "gen-pkg-el", + src = "gen-pkg-el.el", + out = "gen-pkg-el.elc", + visibility = [ + "//elisp:__pkg__", + "//tests/tools:__pkg__", + ], +) + +bootstrap( + name = "gen-metadata", + src = "gen-metadata.el", + out = "gen-metadata.elc", + visibility = [ + "//elisp:__pkg__", + "//tests/tools:__pkg__", + ], +) + +bootstrap( + name = "gen-autoloads", + src = "gen-autoloads.el", + out = "gen-autoloads.elc", + visibility = [ + "//elisp:__pkg__", + "//tests/tools:__pkg__", + ], +) + elisp_binary( name = "gen_proto", src = "gen-proto.el", diff --git a/elisp/private/tools/gen-autoloads.el b/elisp/private/tools/gen-autoloads.el new file mode 100644 index 000000000..780718543 --- /dev/null +++ b/elisp/private/tools/gen-autoloads.el @@ -0,0 +1,63 @@ +;;; gen-autoloads.el --- generate autoloads file -*- lexical-binding: t; -*- + +;; Copyright 2021 Google LLC +;; +;; Licensed under the Apache License, Version 2.0 (the "License"); +;; you may not use this file except in compliance with the License. +;; You may obtain a copy of the License at +;; +;; https://www.apache.org/licenses/LICENSE-2.0 +;; +;; Unless required by applicable law or agreed to in writing, software +;; distributed under the License is distributed on an "AS IS" BASIS, +;; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +;; See the License for the specific language governing permissions and +;; limitations under the License. + +;;; Commentary: + +;; Generate an autoloads file. +;; +;; Usage: +;; +;; emacs --quick --batch --load=gen-autoloads.el \ +;; --funcall=elisp/gen-autoloads-and-exit DEST PKGNAME SOURCE... +;; +;; Generates an autoloads file DEST for the SOURCE Emacs Lisp files. +;; +;; Exits with a zero status only if successful. + +;;; Code: + +(require 'cl-lib) +(require 'nadvice) +(require 'package) + +(defun elisp/gen-autoloads-and-exit () + "Generate an autoloads file and exit Emacs. +See the file commentary for details." + (unless noninteractive + (error "This function works only in batch mode")) + + (add-to-list 'ignored-local-variables 'generated-autoload-file) + + (pcase command-line-args-left + (`(,out ,pkgname . ,srcs) + (let* ((workdir (file-name-as-directory (make-temp-file "workdir" :dir))) + ;; Leaving these enabled leads to undefined behavior and doesn’t make + ;; sense in batch mode. + (attempt-stack-overflow-recovery nil) + (attempt-orderly-shutdown-on-fatal-signal nil) + (create-lockfiles nil)) + (dolist (f srcs) + (copy-file f workdir)) + (package-generate-autoloads pkgname workdir) + (copy-file + (expand-file-name (concat workdir (format "%s-autoloads.el" pkgname)) + workdir) + out t) + (kill-emacs 0))) + (_ (error "Usage: emacs elisp/gen-autoloads.el DEST PKGNAME SOURCE...")))) + +(provide 'elisp/gen-autoloads) +;;; gen-autoloads.el ends here diff --git a/elisp/private/tools/gen-metadata.el b/elisp/private/tools/gen-metadata.el new file mode 100644 index 000000000..e4605c9c7 --- /dev/null +++ b/elisp/private/tools/gen-metadata.el @@ -0,0 +1,63 @@ +;;; gen-metadata.el --- generate package info file -*- lexical-binding: t; -*- + +;; Copyright 2021 Google LLC +;; +;; Licensed under the Apache License, Version 2.0 (the "License"); +;; you may not use this file except in compliance with the License. +;; You may obtain a copy of the License at +;; +;; https://www.apache.org/licenses/LICENSE-2.0 +;; +;; Unless required by applicable law or agreed to in writing, software +;; distributed under the License is distributed on an "AS IS" BASIS, +;; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +;; See the License for the specific language governing permissions and +;; limitations under the License. + +;;; Commentary: + +;; Generate a package metadata file. +;; +;; Usage: +;; +;; emacs --quick --batch --load=gen-metadata.el \ +;; --funcall=elisp/gen-metadata-and-exit SOURCE DEST +;; +;; Generates a metadata file DEST using the SOURCE -pkg.el. +;; This is a JSON file that contains package metadata to allow easier extraction +;; for non-Emacs tools. +;; +;; Exits with a zero status only if successful. + +;;; Code: + +(require 'package) +(require 'json) + +(defun elisp/gen-metadata-and-exit () + "Generate package metadata file and exit Emacs. +See the file commentary for details." + (unless noninteractive + (error "This function works only in batch mode")) + (pcase command-line-args-left + (`(,src ,out) + (let* ( + ;; Leaving these enabled leads to undefined behavior and doesn’t + ;; make sense in batch mode. + (attempt-stack-overflow-recovery nil) + (attempt-orderly-shutdown-on-fatal-signal nil) + (metadata (with-temp-buffer + (insert-file-contents src) + (or (package-process-define-package + (read (current-buffer))) + (error "Can't find define-package in %s" src))))) + (write-region + (json-encode + `((name . ,(package-desc-name metadata)) + (version . ,(package-version-join (package-desc-version metadata))))) + nil out) + (kill-emacs 0))) + (_ (error "Usage: emacs elisp/gen-metadata.el SOURCE DEST")))) + +(provide 'elisp/gen-metadata) +;;; gen-metadata.el ends here diff --git a/elisp/private/tools/gen-pkg-el.el b/elisp/private/tools/gen-pkg-el.el new file mode 100644 index 000000000..b79949079 --- /dev/null +++ b/elisp/private/tools/gen-pkg-el.el @@ -0,0 +1,59 @@ +;;; gen-pkg-el.el --- generate -pkg.el file -*- lexical-binding: t; -*- + +;; Copyright 2021 Google LLC +;; +;; Licensed under the Apache License, Version 2.0 (the "License"); +;; you may not use this file except in compliance with the License. +;; You may obtain a copy of the License at +;; +;; https://www.apache.org/licenses/LICENSE-2.0 +;; +;; Unless required by applicable law or agreed to in writing, software +;; distributed under the License is distributed on an "AS IS" BASIS, +;; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +;; See the License for the specific language governing permissions and +;; limitations under the License. + +;;; Commentary: + +;; Generate a -pkg.el file. +;; +;; Usage: +;; +;; emacs --quick --batch --load=gen-pkg-el.el \ +;; --funcall=elisp/gen-pkg-el-and-exit SOURCE DEST +;; +;; Generates a -pkg.el file DEST, using the headers from the SOURCE Emacs Lisp +;; file. +;; +;; SOURCE should contain the headers as +;; described in Info node `(elisp)Simple Packages'. +;; +;; Exits with a zero status only if successful. + +;;; Code: + +(require 'package) +(require 'lisp-mnt) + +(defun elisp/gen-pkg-el-and-exit () + "Generate a -pkg.el file and exit Emacs. +See the file commentary for details." + (unless noninteractive + (error "This function works only in batch mode")) + (pcase command-line-args-left + (`(,src ,out) + (let* ( + ;; Leaving these enabled leads to undefined behavior and doesn’t make + ;; sense in batch mode. + (attempt-stack-overflow-recovery nil) + (attempt-orderly-shutdown-on-fatal-signal nil) + (pkginfo (with-temp-buffer + (insert-file-contents src) + (package-buffer-info)))) + (package-generate-description-file pkginfo out) + (kill-emacs 0))) + (_ (error "Usage: emacs elisp/gen-pkg-el.el SOURCE DEST")))) + +(provide 'elisp/gen-pkg-el) +;;; gen-pkg-el.el ends here