diff --git a/.github/workflows/PreCommitHooks.yml b/.github/workflows/PreCommitHooks.yml index 3c1a1c403..21c399211 100644 --- a/.github/workflows/PreCommitHooks.yml +++ b/.github/workflows/PreCommitHooks.yml @@ -11,7 +11,7 @@ on: - 'master' jobs: - pre-commit: + pc-julia-formatter: name: julia-formatter hook runs-on: ubuntu-latest @@ -44,3 +44,74 @@ jobs: # Check that running the hook again succeeds. pipx run pre-commit try-repo ../../../ julia-formatter --files main.jl --verbose + + + pc-jlfmt: + name: jlfmt hook + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Check for graceful error if jlfmt is not installed + working-directory: .github/workflows/pre-commit + run: | + # At this point, jlfmt won't be on PATH since the GitHub runner + # doesn't automatically add Julia apps to PATH. This is intentional, + # since it lets us check that there is a nice error message if jlfmt + # is not on PATH. + output=$(pipx run pre-commit try-repo ../../../ jlfmt --files main.jl --verbose 2>&1 || true) + echo "$output" | grep -q "make sure that jlfmt is on your PATH" || { echo "Expected graceful error message when jlfmt was not found"; exit 1; } + + - uses: julia-actions/setup-julia@v3 + with: + version: '1' + + - name: Install jlfmt app + # Need to explicitly add registry because of + # https://github.com/JuliaLang/Pkg.jl/issues/4360 which is not + # backported to 1.12 yet + run: + julia -e 'using Pkg; Pkg.Registry.add("General"); Pkg.Apps.add(; path=pwd())' + + - uses: actions/setup-python@v6 + with: + python-version: '3.x' + + - name: Test pre-commit hook + working-directory: .github/workflows/pre-commit + run: | + # Run pre-commit hook, check that it fails. + # pre-commit try-repo will fail if the hook makes changes + pipx run pre-commit try-repo ../../../ jlfmt --files main.jl --verbose && exit 1 + + # Check that the file was changed. + git diff --exit-code main.jl && exit 1 + + # Check that running the hook again succeeds. + pipx run pre-commit try-repo ../../../ jlfmt --files main.jl --verbose + + - name: Test pre-commit hook with --jlfmt-path argument + working-directory: .github/workflows/pre-commit + run: | + # Move the jlfmt binary to somewhere else + mv ~/.julia/bin/jlfmt ~/.julia/bin/jlfmt-moved + + # Reset the test file + git checkout main.jl + + # Add --jlfmt-path arg to the existing jlfmt hook. pre-commit + # try-repo doesn't allow passing hook arguments via the command line, + # so we inject it directly into .pre-commit-hooks.yaml. A tad ugly. + sed -i '/^- id: jlfmt$/a\ args: ["--jlfmt-path=~/.julia/bin/jlfmt-moved"]' ../../../.pre-commit-hooks.yaml + + # Run pre-commit hook, check that it fails. + pipx run pre-commit try-repo ../../../ jlfmt --files main.jl --verbose && exit 1 + + # Check that the file was changed. + git diff --exit-code main.jl && exit 1 + + # Check that running the hook again succeeds. + pipx run pre-commit try-repo ../../../ jlfmt --files main.jl --verbose diff --git a/.github/workflows/pre-commit/main.jl b/.github/workflows/pre-commit/main.jl index eed70a9df..8d372cdfa 100644 --- a/.github/workflows/pre-commit/main.jl +++ b/.github/workflows/pre-commit/main.jl @@ -1,3 +1,5 @@ +# NOTE: This file should remain unformatted (as per JuliaFormatter's default style) as it is +# used to test the pre-commit hooks. function f(x ) return x+1 end diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 0cde3066b..d72a03aec 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -1,9 +1,19 @@ - id: julia-formatter name: "Julia Formatter" - entry: "julia -e 'import JuliaFormatter: format; format(ARGS)'" + entry: "julia --threads=auto -e 'import JuliaFormatter: format; format(ARGS)'" pass_filenames: true always_run: false types: [file] - files: \.(jl|[jq]?md)$ + files: \.(jl|md|jmd|qmd)$ language: "system" description: "An opinionated code formatter for Julia. Plot twist - the opinion is your own." + +- id: jlfmt + name: "JuliaFormatter (Pkg app)" + entry: bin/jlfmt-hook.sh + pass_filenames: true + always_run: false + types: [file] + files: \.(jl|md|jmd|qmd)$ + language: "script" + description: "An opinionated code formatter for Julia, but now via a Pkg app. Plot twist - the opinion is your own." diff --git a/HISTORY_v2.md b/HISTORY_v2.md index e440019d7..dc12f403e 100644 --- a/HISTORY_v2.md +++ b/HISTORY_v2.md @@ -1,3 +1,16 @@ +# v2.4.0 + +Added the `--threads=auto` option to the old `julia-formatter` pre-commit hook, which should speed up invocations of JuliaFormatter. + +Added a new pre-commit hook which uses the `jlfmt` executable. +To use this, you will need to first install `jlfmt` with + +```julia +] app add JuliaFormatter +``` + +Please see [the docs](https://juliaeditorsupport.github.io/JuliaFormatter.jl/stable/integrations/) for more information. + # v2.3.3 Fixed a bug with alignment of multiline strings when the first line contains characters whose display width is not equal to the number of bytes. diff --git a/Project.toml b/Project.toml index 63db36b60..7c5b6c05d 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "JuliaFormatter" uuid = "98e50ef6-434e-11e9-1051-2b60c6c9e899" -version = "2.3.3" +version = "2.4.0" authors = ["Dominique Luna and contributors"] [deps] diff --git a/bin/hook.jl b/bin/hook.jl deleted file mode 100755 index 23c0a5d88..000000000 --- a/bin/hook.jl +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env -S julia --startup-file=no -O1 -using JuliaFormatter - -@debug ARGS -filename = ARGS[1] -exit(format_file(filename) ? 0 : 1) diff --git a/bin/jlfmt-hook.sh b/bin/jlfmt-hook.sh new file mode 100755 index 000000000..9ad90a61d --- /dev/null +++ b/bin/jlfmt-hook.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash + +# Wrapper for the jlfmt executable, which is used in the jlfmt pre-commit hook. +# +# Resolution order for jlfmt: +# +# 1. --jlfmt-path= (explicit override) +# 2. jlfmt on PATH +# 3. $JULIA_DEPOT_PATH/bin (each entry, colon-separated) +# 4. ~/.julia/bin (default depot) +# +# Gracefully errors if jlfmt can't be found. + +jlfmt="" + +# Parse --jlfmt-path argument and pass the rest through. +args=() +for arg in "$@"; do + case "$arg" in + --jlfmt-path=*) jlfmt="${arg#--jlfmt-path=}" ;; + *) args+=("$arg") ;; + esac +done + +if [ -n "$jlfmt" ]; then + # Path was explicitly specified. Expand tildes and check if it exists + jlfmt="${jlfmt/#\~/$HOME}" + if ! command -v "$jlfmt" &>/dev/null; then + echo "Error: The executable '$jlfmt' (passed via --jlfmt-path) was not found." + exit 1 + fi +else + # Not specified, we'll have to search for it ourselves. + # Step 1: Check PATH + if command -v jlfmt &>/dev/null; then + jlfmt="jlfmt" + # Step 2: Check JULIA_DEPOT_PATH + elif [ -n "$JULIA_DEPOT_PATH" ]; then + IFS=: read -ra depots <<< "$JULIA_DEPOT_PATH" + for depot in "${depots[@]}"; do + depot="${depot/#\~/$HOME}" + if [ -x "$depot/bin/jlfmt" ]; then + jlfmt="$depot/bin/jlfmt" + break + fi + done + fi + # Step 3: Check ~/.julia/bin (default fallback: cf. Julia's + # `Base.init_depot_path()` implementation). In principle, we would like to + # run `julia -e 'println(DEPOT_PATH)'`, but that takes too long, so is not + # a viable option for pre-commit. + if [ -z "$jlfmt" ] && [ -x "${HOME}/.julia/bin/jlfmt" ]; then + jlfmt="${HOME}/.julia/bin/jlfmt" + fi + # Error + if [ -z "$jlfmt" ]; then + echo "ERROR: 'jlfmt' not found." + echo "Install it with: julia -e 'import Pkg; Pkg.Apps.add(\"JuliaFormatter\")'" + echo "Then make sure that jlfmt is on your PATH, or tell pre-commit its location with:" + echo "" + echo " args: [\"--jlfmt-path=/path/to/jlfmt\"]" + echo "" + echo "See https://juliaeditorsupport.github.io/JuliaFormatter.jl/stable/integrations/ for details." + exit 1 + fi +fi + +exec "$jlfmt" --threads=auto -- --inplace "${args[@]}" diff --git a/docs/make.jl b/docs/make.jl index 8784bf841..282012b08 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -16,6 +16,7 @@ makedocs(; "SciML Style" => "sciml_style.md", "Configuration File" => "config.md", "Command Line Interface" => "cli.md", + "Integrations" => "integrations.md", "API Reference" => "api.md", ], warnonly = true, diff --git a/docs/src/cli.md b/docs/src/cli.md index 50de3163b..f9a90afc9 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -1,18 +1,24 @@ -# Command Line Interface +# [Command Line Interface](@id cli) JuliaFormatter provides a command-line executable `jlfmt` for formatting Julia source code. +This is a [Pkg app](https://pkgdocs.julialang.org/v1/apps/), and therefore requires Julia v1.12 or later. ## Installation -Install using Julia's app manager: +The app can be installed using Julia's app manager: ```julia -pkg> app add JuliaFormatter +# Install the latest available version +import Pkg; Pkg.Apps.add("JuliaFormatter") + +# Or a specific version. Note that the version must be >= v2.2.0 since that is +# when the `jlfmt` app was introduced. +import Pkg; Pkg.Apps.add(; name = "JuliaFormatter", version = v"2.3.0") ``` -This makes the `jlfmt` command available in your `PATH`. +This should create a new binary, called `jlfmt`, inside the Julia depot's `bin` directory (usually `~/.julia/bin`; but you can check with the `DEPOT_PATH` variable in Julia). -Alternatively, invoke directly without installation: +Alternatively, you can invoke the app directly without installation: ```bash julia -m JuliaFormatter [] ... diff --git a/docs/src/integrations.md b/docs/src/integrations.md index f1c6cb58d..04058599c 100644 --- a/docs/src/integrations.md +++ b/docs/src/integrations.md @@ -2,22 +2,193 @@ ## `pre-commit` -To learn more about `pre-commit`, [check out their docs](https://pre-commit.com). +[`pre-commit`](https://pre-commit.com) is a tool for installing and managing pre-commit Git hooks, which makes Git automatically run specified commands before commits are made. +This is useful for ensuring that code matches a certain quality standard before it is committed: for example, you can use `pre-commit` to run `JuliaFormatter.jl` on your code before it is committed. -With [Pull 674](https://github.com/JuliaEditorSupport/JuliaFormatter.jl/pull/674), support for -`pre-commit` was added. To add `JuliaFormatter.jl` to your own `pre-commit` workflow, -add the following to your `.pre-commit-config.yaml`. +JuliaFormatter provides two pre-commit hooks for you to use. +One uses the old-style `julia -e ...` invocation; the other uses the `jlfmt` app, which must be installed separately. + +### `julia-formatter` hook (uses `julia -e ...`) + +This hook launches JuliaFormatter via your global Julia installation, which means that you must first install JuliaFormatter as a package. + +```julia +import Pkg; Pkg.add("JuliaFormatter") +``` + +To use the hook, you can add the following to your `.pre-commit-config.yaml`: ```yaml repos: -# ... other repos you may have -- repo: "https://github.com/JuliaEditorSupport/JuliaFormatter.jl" - rev: "v1.0.18" # or whatever the desired release is +- repo: https://github.com/JuliaEditorSupport/JuliaFormatter.jl + rev: baf84dcde3e7d39a3339fecb51a5d853f8aa35af hooks: - id: "julia-formatter" -# ... other repos you may have ``` -You can find a list of releases [here](https://github.com/JuliaEditorSupport/JuliaFormatter.jl/releases). -**Be sure to use the entire version string!** (You can double-check this by opening the -release and looking at the part of the URL that follows `.../releases/tag/VERSION`.) +(If you have other pre-commit hooks, just add the `repo: ...` block to your pre-existing list of repos.) + +To pass additional arguments to the Julia invocation (e.g. if JuliaFormatter is installed in a specific project), you can use the `args` field, like so: + +```yaml +repos: +- repo: https://github.com/JuliaEditorSupport/JuliaFormatter.jl + rev: baf84dcde3e7d39a3339fecb51a5d853f8aa35af + hooks: + - id: "julia-formatter" + args: ["--project=/path/to/myproj"] +``` + +**Note that `rev` controls the version of the _hook_ that is checked out; it does not control which version of JuliaFormatter is actually used to format your code.** +The version used to do the actual formatting is determined by the version of JuliaFormatter that is installed in your global Julia environment. +This means that if you want to format your code with JuliaFormatter v1, you must make sure that you install v1 in your global Julia environment. + +The `rev` field above is a commit hash that points to [v2.3.2 of JuliaFormatter.jl](https://github.com/JuliaEditorSupport/JuliaFormatter.jl/releases/tag/v2.3.2). +As of the time of writing, this is the latest release of JuliaFormatter.jl. +However, it is extremely unlikely that this hook will change in future releases, so you do not need to worry about 'updating' it to a newer version. + +!!! note + You could also point to a branch or a release tag, but it is safer to point to a commit hash, since that is immutable and is not vulnerable to e.g. supply chain attacks. + +### `jlfmt` hook (uses the `jlfmt` app) + +To use the `jlfmt` hook you must first make sure that the `jlfmt` app is installed on your system. +Instructions for installing the `jlfmt` app are given in the [CLI documentation](@ref cli). + +The version of `jlfmt` that you install here will be the version that is used to actually format your code. +The `jlfmt` app was only introduced in v2.2.0, so versions before that are inaccessible: if you want to format your code with JuliaFormatter v1, you will not be able to use this hook. + +Once you have the `jlfmt` app installed, you can add the following to your `.pre-commit-config.yaml`: + +```yaml +repos: +- repo: https://github.com/JuliaEditorSupport/JuliaFormatter.jl + rev: TODO TODO + hooks: + - id: "jlfmt" +``` + +The path to the `jlfmt` executable in the `args` field of the hook, like so (although you should not _need_ to do so—see below): + +```yaml +repos: +- repo: https://github.com/JuliaEditorSupport/JuliaFormatter.jl + rev: TODO TODO + hooks: + - id: "jlfmt" + args: ["--jlfmt-path=/path/to/jlfmt"] +``` + +However, even without this argument, the pre-commit hook will attempt to locate `jlfmt` for you. +It looks in the following places, in order: + +1. Using the executable specified via the `--jlfmt-path` argument, if provided. +2. A `jlfmt` executable on your system `PATH`. +3. `{dir}/bin/jlfmt` for each directory `{dir}` in the `JULIA_DEPOT_PATH` environment variable. +4. `~/.julia/bin/jlfmt`, which is the default depot path if `JULIA_DEPOT_PATH` is not set. + +Just like for the `julia-formatter` hook, the `rev` field controls the version of the _hook_ that is checked out, not the version of JuliaFormatter that is used to do the formatting: that is governed by the version of the `jlfmt` app that you installed. +The `rev` field used here points to JuliaFormatter v2.4.0, but this hook is unlikely to change in future releases, so you do not need to worry about updating it to a newer version. + +!!! note + You could also point `rev` to a branch or a release tag, but it is safer to point to a commit hash, since that is immutable and is not vulnerable to e.g. supply chain attacks. + + +## PackageCompiler.jl + +By default, each time JuliaFormatter is launched, it will incur some startup cost due to JIT compilation. +This is the (infamous) TTFX that Julia has in general. + +Although recent releases of JuliaFormatter are substantially faster due to intelligent usage of precompilation, the TTFX can still be a problem in the following circumstances: + +- If you are using JuliaFormatter v1 (which can be quite painfully slow sometimes) +- If you are running JuliaFormatter frequently, e.g. via pre-commit + +which is to say, in many common scenarios! + +To mitigate this problem, you can use [PackageCompiler.jl](https://julialang.github.io/PackageCompiler.jl/) to create a custom sysimage that includes cached precompiled code. +While this one-time setup can be quite a bit of a hassle, the benefits can be quite substantial, with speedups of 10x being quite common in practice for JuliaFormatter v1. + +Here is a step-by-step walkthrough of how to do this: + +1. Make a scratch directory to do stuff in. + + ```bash + mkdir scratch + cd scratch + ``` + +1. Download any Julia codebase that is large enough and contains code that is representative of the code you want to format. + + When generating the compiled sysimage, we will format this codebase. + The choice of codebase can affect the results because the precompilation process will cache code paths that are encountered during this formatting, meaning that you should obtain the largest speedups if you choose a codebase that is similar (or identical!) to the code you will be formatting in the future. + + As an example, we'll use the Julia base repository itself. + + ```bash + git clone --depth 1 --branch v1.11.9 https://github.com/JuliaLang/julia.git + cd julia + ``` + +1. Now launch Julia with the following flags: + + ```bash + julia --startup-file=no --compile=yes -O3 --threads=auto + ``` + +1. And run the following in the Julia REPL. + **Note that the version of JuliaFormatter you install here will be the version that is used to format your code.** + + ```bash + using Pkg + Pkg.activate(; temp=true) + Pkg.add(name="JuliaFormatter", version="2") # Or your preferred version + Pkg.add("PackageCompiler") + + # Write the precompilation workload to a file. + open("precompile_file.jl", "w") do io + write(io, "using JuliaFormatter; format(\".\")") + end + + # Generate a sysimage with that workload. + using PackageCompiler + create_sysimage( + ["JuliaFormatter"]; + sysimage_path="../juliaformatter.so", + precompile_execution_file="precompile_file.jl" + ) + ``` + +1. Now you should have a sysimage file in the `scratch` directory you made just now (but of course you can change that `sysimage_path` if you prefer). + Move it to somewhere that is more permanent. + Once you have done so, you can delete the entire `scratch` directory. + +1. After that, to run JuliaFormatter, you can launch Julia as follows: + + ```bash + julia --startup-file=no --threads=auto -J SYSIMAGE_PATH -O0 --compile=min -e 'using JuliaFormatter; format(".")' + ``` + + where `SYSIMAGE_PATH` is the path to the sysimage you generated in the previous steps. + +This is the basic process: you can tweak any aspect of this to your liking, for example, by wrapping the final `julia` invocation in a script/function that takes a path as an argument and passes it into the `julia` call. +For some ideas, see e.g. [this issue](https://github.com/JuliaEditorSupport/JuliaFormatter.jl/issues/633#issuecomment-1518805248) and [this Gist](https://gist.github.com/penelopeysm/9338c160eeb05437205535c2edcf80ee). + +### Subsequent usage with `pre-commit` + +Once you have generated the sysimage, you can make a custom `pre-commit` hook that uses it, like so: + +```yaml +repos: +- repo: local + hooks: + - id: format + name: format + language: system + entry: julia --startup-file=no ... # The same command as above. +``` + +Unfortunately, there are some downsides to this approach. +Firstly, JuliaFormatter cannot provide such a hook for you because the `entry` field needs to be customised for your system (e.g. the sysimage path). +Furthermore, arguably, such a hook should not be shared across users (unless your sysimage is also shared!). +This means that the pre-commit hook above should not be committed to source control.