// The CodeChat Editor, a word processor for software developers.
Copyright
// (C) 2024-2026 Bryan A. Jones.
//
// This file is part of the CodeChat Editor. The CodeChat Editor is free
// software: you can redistribute it and/or modify it under the terms of the GNU
// General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// The CodeChat Editor is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
// details.
//
// You should have received a copy of the GNU General Public License along with
// the CodeChat Editor. If not, see
// [http://www.gnu.org/licenses](http://www.gnu.org/licenses).
//! `main.rs` -- Entrypoint for the `CodeChat` Editor Builder
//! =========================================================
//!
//! This code uses [dist](https://opensource.axo.dev/cargo-dist/book/) as a part
//! of the release process. To update the `.github/workflows/release.yml` file
//! this tool creates:
//!
//! 1. Edit `dist-workspace.toml` in the repository root: change `allow-dirty`
//! to `[]`.
//! 2. Run `dist init` and accept the defaults, then run `dist generate`.
//! 3. Review changes to `.github/workflows/release.yml`, reapplying hand edits.
//! 4. Revert the changes to `dist-workspace.toml`.
//! 5. Test
//!
//! Keep the `DIST_VERSION` consistent with the version of dist in
//! `dist-workspace.toml` on release.
// Imports
// -------
//
// ### Standard library
use std::{
env,
ffi::OsStr,
fs, io,
path::{Path, PathBuf},
process::Command,
};
// ### Third-party
use axotag::parse_tag;
use clap::{Parser, Subcommand};
use cmd_lib::run_cmd;
use current_platform::CURRENT_PLATFORM;
use dunce::canonicalize;
use path_slash::PathBufExt;
use regex::Regex;
// ### Local
//
// None
//
// Data structures
// ---------------
//
// The following defines the command-line interface for the CodeChat Editor.
#[derive(Parser)]
#[command(name = "The CodeChat Editor Server", version, about, long_about=None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Install all dependencies.
Install,
/// Update all dependencies.
Update,
/// Run formatters and linters.
Flint {
/// Check only; don't modify files.
#[arg(short, long, default_value_t = false)]
check: bool,
},
/// Run formatters and linters, build all files, build for release, then run
/// tests.
Full,
/// Run all tests.
Test,
/// Repeatedly run the `overall_*` tests until one fails. Useful for shaking
/// out intermittent test failures.
RunUntilFail,
/// Build everything.
Build,
/// Build the Client.
ClientBuild(TypeScriptBuildOptions),
/// Measure test coverage.
Cover,
/// Build the extensions.
ExtBuild(TypeScriptBuildOptions),
/// Change the version for the client, server, and extensions.
ChangeVersion {
/// The new version number, such as "0.1.1".
new_version: String,
},
/// Steps to run before `cargo dist build`.
Prerelease,
/// Steps to run after `cargo dist build`. This builds and publishes a
/// VSCode release.
Postrelease {
/// Receives a target triple, such as `x86_64-pc-windows-msvc`. We can't
/// always infer this, since `dist` cross-compiles the server on OS X,
/// while this program isn't cross-compiled.
#[arg(short, long, default_value_t = CURRENT_PLATFORM.to_string())]
target: String,
/// The CI build passes this. We don't use it, but must receive it to
/// avoid an error.
#[arg(short, long)]
artifacts: Option,
/// The tag which started this build.
#[arg(long)]
tag: String,
},
}
#[derive(Parser)]
struct TypeScriptBuildOptions {
/// True to build for distribution, instead of development.
#[arg(short, long, default_value_t = false)]
dist: bool,
/// True to skip checks for TypeScript errors in the Client.
#[arg(short, long, default_value_t = false)]
skip_check_errors: bool,
}
// Constants
// ---------
//
// These paths are relative to `server/`, which `main` makes the current
// directory.
static VSCODE_PATH: &str = "../extensions/VSCode";
static CLIENT_PATH: &str = "../client";
// The workspace manifest, which records the version the released packages
// inherit.
static WORKSPACE_MANIFEST_PATH: &str = "../Cargo.toml";
// This program's own package. It sits outside the workspace (see its
// `Cargo.toml`), so `--workspace` never reaches it and every tool below needs a
// second, explicit pass over it.
static BUILDER_PATH: &str = "../builder";
static NAPI_TARGET: &str = "NAPI_TARGET";
static DIST_VERSION: &str = "0.33.1";
// Code
// ----
//
// ### Utilities
//
// These functions are called by the build support functions.
/// On Windows, scripts must be run from a shell; on Linux and OS X, scripts are
/// directly executable. This function runs a script regardless of OS.
fn run_script, A: AsRef, P: AsRef + std::fmt::Display>(
// The script to run.
script: T,
// Arguments to pass.
args: &[A],
// The directory to run the script in.
dir: P,
// True to report errors based on the process' exit code; false to ignore
// the code.
check_exit_code: bool,
) -> io::Result<()> {
let script = OsStr::new(script.as_ref());
let mut process;
if cfg!(windows) {
process = Command::new("cmd");
process.arg("/c").arg(script);
} else {
process = Command::new(script);
}
process.args(args).current_dir(&dir);
// A bit crude, but displays the command being run.
println!("{dir}: {process:#?}");
let exit_code = process.status()?.code();
if exit_code == Some(0) || (exit_code.is_some() && !check_exit_code) {
Ok(())
} else {
Err(io::Error::other(format!(
"{}: exit code {exit_code:?} indicates failure.",
script.to_string_lossy()
)))
}
}
/// Quickly synchronize the `src` directory with the `dest` directory, by
/// copying files and removing anything in `dest` not in `src`. It uses OS
/// programs (`robocopy`/`rsync`) to accomplish this. Very important: the `src`
/// **must** end with a `/`, otherwise the Windows and Linux copies aren't
/// identical.
fn quick_copy_dir>(src: P, dest: P, files: Option<&P>) -> io::Result<()> {
assert!(src.as_ref().to_string_lossy().ends_with('/'));
let mut copy_process;
let src = OsStr::new(src.as_ref());
let dest = OsStr::new(dest.as_ref());
#[cfg(windows)]
{
// From `robocopy /?`:
//
// /MIR MIRror a directory tree (equivalent to /E plus /PURGE).
//
// /MT Do multi-threaded copies with n threads (default 8).
//
// /NFL No File List - don't log file names.
//
// /NDL : No Directory List - don't log directory names.
//
// /NJH : No Job Header.
//
// /NJS : No Job Summary.
//
// /NP : No Progress - don't display percentage copied.
//
// /NS : No Size - don't log file sizes.
//
// /NC : No Class - don't log file classes.
copy_process = Command::new("robocopy");
copy_process
.args([
"/MIR", "/MT", "/NFL", "/NDL", "/NJH", "/NJS", "/NP", "/NS", "/NC",
])
.arg(src)
.arg(dest);
// Robocopy expects the files to copy after the dest.
if let Some(files_) = &files {
copy_process.arg(OsStr::new(files_.as_ref()));
}
}
#[cfg(not(windows))]
{
// Create the dest directory, since old CI OSes don't support `rsync
// --mkpath`.
run_script("mkdir", &["-p", dest.to_str().unwrap()], "./", true)?;
let mut tmp;
let src_combined = match files.as_ref() {
Some(files_) => {
tmp = src.to_os_string();
tmp.push(OsStr::new(files_.as_ref()));
tmp.as_os_str()
}
None => src,
};
// Use bash to perform globbing, since rsync doesn't do this.
copy_process = Command::new("bash");
copy_process.args([
"-c",
format!(
"rsync --archive --delete {} {}",
src_combined.to_str().unwrap(),
dest.to_str().unwrap()
)
.as_str(),
]);
}
// Print the command, in case this produces and error or takes a while.
println!("{copy_process:#?}");
// Check for errors.
let exit_code = copy_process
.status()?
.code()
.expect("Copy process terminated by signal");
// Per
// [these docs](https://learn.microsoft.com/en-us/troubleshoot/windows-server/backup-and-storage/return-codes-used-robocopy-utility),
// check the return code.
if (cfg!(windows) && exit_code >= 8) || (!cfg!(windows) && exit_code != 0) {
Err(io::Error::other(format!(
"Copy process return code {exit_code} indicates failure."
)))
} else {
Ok(())
}
}
fn copy_file + std::fmt::Debug>(src: P, dest: P) -> io::Result<()> {
println!("copy {src:?} -> {dest:?}");
fs::copy(src, dest).map(|_| ())
}
fn remove_dir_all_if_exists + std::fmt::Display>(path: P) -> io::Result<()> {
println!("Removing {path}...");
if Path::new(path.as_ref()).try_exists().unwrap() {
fs::remove_dir_all(path.as_ref())?;
}
Ok(())
}
/// Search and replace a file using the regex. It's currently only used to
/// update the version of a file; it does only one replacement.
fn search_and_replace_file<
P: AsRef + std::fmt::Display,
S1: AsRef + std::fmt::Display,
S2: AsRef,
>(
path: P,
search_regex: S1,
replace_string: S2,
) -> io::Result<()> {
let file_contents = fs::read_to_string(&path)?;
let re = Regex::new(search_regex.as_ref())
.map_err(|err| io::Error::other(format!("Error in search regex {search_regex}: {err}")))?;
// Note that this does only one replacement -- there should be exactly one
// version number to replace in a file.
let file_contents_replaced = re.replace(&file_contents, replace_string.as_ref());
assert_ne!(
file_contents, file_contents_replaced,
"No replacements made in {path}."
);
fs::write(&path, file_contents_replaced.as_bytes())
}
// Core routines
// -------------
//
// These functions simplify common build-focused development tasks and support
// CI builds.
/// Apply the provided patch to a file.
fn patch_file(patch: &str, before_patch: &str, file_path: &str) -> io::Result<()> {
let file_path = Path::new(file_path);
let file_contents = fs::read_to_string(file_path)?;
if !file_contents.contains(patch) {
let patch_loc = file_contents
.find(before_patch)
.expect("Patch location not found.")
+ before_patch.len();
let patched_file_contents = format!(
"{}{patch}{}",
&file_contents[..patch_loc],
&file_contents[patch_loc..]
);
fs::write(file_path, &patched_file_contents)?;
}
Ok(())
}
/// After updating files in the client's Node files, perform some fix-ups.
fn patch_client_libs() -> io::Result<()> {
// In
// [older releases](https://www.tiny.cloud/docs/tinymce/5/6.0-upcoming-changes/#options),
// TinyMCE allowed users to change `whitespace_elements`; the whitespace
// inside these isn't removed by TinyMCE. However, this was removed in v6.0.
// Therefore, manually patch TinyMCE instead.
patch_file(
" wc-mermaid graphviz-graph",
"const whitespaceElementsMap = createLookupTable('whitespace_elements', 'pre script noscript style textarea video audio iframe object code",
&format!("{CLIENT_PATH}/node_modules/tinymce/tinymce.js"),
)?;
// Copy across the parts of MathJax that are needed, since bundling it is
// difficult.
remove_dir_all_if_exists(format!("{CLIENT_PATH}/static/mathjax"))?;
for subdir in ["a11y", "adaptors", "input", "output", "sre", "ui"] {
quick_copy_dir(
format!("{CLIENT_PATH}/node_modules/mathjax/{subdir}/"),
format!("{CLIENT_PATH}/static/mathjax/{subdir}"),
None,
)?;
}
quick_copy_dir(
format!("{CLIENT_PATH}/node_modules/mathjax/"),
format!("{CLIENT_PATH}/static/mathjax"),
Some(&"tex-chtml.js".to_string()),
)?;
quick_copy_dir(
format!("{CLIENT_PATH}/node_modules/@mathjax/mathjax-newcm-font/chtml/"),
format!("{CLIENT_PATH}/static/mathjax-newcm-font/chtml"),
None,
)?;
Ok(())
}
fn run_install(cargo_dist: bool) -> io::Result<()> {
run_script("npm", &["install", "-g", "pnpm"], ".", true)?;
// See [the client manifest](../../client/package.json5) for an explanation
// of `--no-frozen-lockfile`.
run_script("pnpm", &["install"], CLIENT_PATH, true)?;
patch_client_libs()?;
run_script("pnpm", &["install"], VSCODE_PATH, true)?;
run_cmd!(
info "cargo fetch";
cargo fetch;
info "Builder: cargo fetch";
cargo fetch --manifest-path=$BUILDER_PATH/Cargo.toml;
)?;
// Install the cargo binstall binary, taken from the
// [docs](https://docs.rs/crate/cargo-binstall/1.15.5).
#[cfg(windows)]
run_cmd! {
pwsh -Command "Set-ExecutionPolicy Unrestricted -Scope Process; iex (iwr 'https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.ps1').Content";
}?;
#[cfg(not(windows))]
// The original command had `'=https'`, but single quotes confused `cmd_lib`
// and aren't needed to quote this. Note that `//` in the URL is a comment
// in Rust, so it must be
// [enclosed in quotes](https://github.com/rust-shell-script/rust_cmd_lib/issues/88).
run_cmd! {
curl -L --proto =https --tlsv1.2 -sSf "https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh" | bash;
}?;
if cargo_dist {
// Installing `cargo-dist` using `cargo binstall` fails intermittently
// on MacOS. Try another approach.
#[cfg(target_os = "macos")]
run_cmd!(
curl --proto =https --tlsv1.2 -LsSf "https://github.com/axodotdev/cargo-dist/releases/download/v${DIST_VERSION}/cargo-dist-installer.sh" | sh;
)?;
#[cfg(not(target_os = "macos"))]
// See
// [https://github.com/bjones1/CodeChat\_Editor/issues/157](https://github.com/bjones1/CodeChat_Editor/issues/157);
// remove this workaround when the upstream issues is fixed.
run_cmd!(
info "cargo binstall cargo-dist";
cargo binstall cargo-dist@${DIST_VERSION} --no-confirm --git https://github.com/axodotdev/cargo-dist;
)?;
}
run_cmd!(
info "cargo binstall cargo-outdated";
cargo binstall cargo-outdated --no-confirm;
info "cargo binstall cargo-sort";
cargo binstall cargo-sort --no-confirm;
info "cargo binstall cargo-audit";
cargo binstall cargo-audit --no-confirm;
info "cargo binstall cargo-machete";
cargo binstall cargo-machete --no-confirm;
info "cargo binstall cargo-llvm-cov";
cargo binstall cargo-llvm-cov --no-confirm;
// Install the required llvm-tools component used by cargo-llvm-cov
// without prompting.
info "rustup component add llvm-tools-preview";
rustup component add llvm-tools-preview;
)
}
fn run_update() -> io::Result<()> {
run_script("pnpm", &["update"], CLIENT_PATH, true)?;
patch_client_libs()?;
run_script("pnpm", &["update"], VSCODE_PATH, true)?;
run_cmd!(
info "cargo update";
cargo update;
info "Builder: cargo update";
cargo update --manifest-path=$BUILDER_PATH/Cargo.toml;
)?;
// Simply display outdated dependencies, but don't consider them an error.
run_script("pnpm", &["outdated"], CLIENT_PATH, false)?;
run_script("pnpm", &["outdated"], VSCODE_PATH, false)?;
run_cmd!(
info "cargo outdated";
cargo outdated --workspace;
)?;
// `cargo outdated` 0.19.0 joins a relative `--manifest-path` onto the
// current directory but never normalizes the result, so the `..` in
// `../builder/Cargo.toml` survives. Cargo then compares that path literally
// against the canonicalized workspace root it discovers and rejects the
// manifest as "a member of the wrong workspace". Running in `builder/`
// avoids the flag, and therefore the bug.
//
// TODO: fold this back into the `run_cmd!` above once cargo-outdated
// normalizes the path it builds.
run_script("cargo", &["outdated"], BUILDER_PATH, true)?;
Ok(())
}
fn run_format_and_lint(check_only: bool) -> io::Result<()> {
// The `-D warnings` flag causes clippy to return a non-zero exit status if
// it issues warnings.
let (clippy_check_only, check, eslint_check) = if check_only {
("-Dwarnings", "--check", "")
} else {
("", "", "--fix")
};
// RUSTSEC-2026-0258 is h2 0.3.x via actix-http; remove this ignore once
// actix-http publishes a compatible release on h2 0.4.16 or newer.
let h2_advisory_ignore = "RUSTSEC-2026-0258";
// `--workspace` reaches every workspace member from any member directory,
// so each tool runs once for the workspace plus once for `builder`. The
// lockfile paths are relative to `server/`, where this runs.
//
// `cargo sort` is the exception: its `--workspace` expands the manifest at
// the path it's given -- the current directory by default -- rather than
// the workspace root cargo would discover, so from `server/` it checks only
// `server/Cargo.toml`. Passing `..` aims it at the workspace root instead.
run_cmd!(
info "cargo clippy and fmt";
cargo clippy --workspace --all-targets --all-features -- $clippy_check_only;
cargo fmt --all $check;
info "Builder: cargo clippy and fmt";
cargo clippy --all-targets --all-features --manifest-path=$BUILDER_PATH/Cargo.toml -- $clippy_check_only;
cargo fmt --all $check --manifest-path=$BUILDER_PATH/Cargo.toml;
info "cargo audit";
cargo audit --file=../Cargo.lock --ignore $h2_advisory_ignore;
info "Builder: cargo audit";
cargo audit --file=$BUILDER_PATH/Cargo.lock --no-fetch --ignore $h2_advisory_ignore;
info "cargo sort";
cargo sort --workspace $check ..;
info "Builder: cargo sort";
cargo sort $check $BUILDER_PATH;
)?;
// `cargo machete` recurses into subdirectories on its own, so a single
// invocation from the repo root covers every crate. It's run outside the
// block above and branched here (rather than interpolating a `--fix`
// variable that may be empty) because passing it an empty string argument
// makes it treat the empty string as an invalid path and error out. The
// `cargo-machete` binary is invoked directly (rather than via `cargo
// machete`) since going through cargo's subcommand dispatch from inside
// this already-running `cargo run` process causes cargo-machete to misparse
// its own arguments.
if check_only {
run_cmd!(
info "cargo machete";
cargo-machete --with-metadata --skip-target-dir ..;
)?;
} else {
run_cmd!(
info "cargo machete";
cargo-machete --with-metadata --skip-target-dir --fix ..;
)?;
}
let mut eslint_args = vec!["eslint", "src"];
if !eslint_check.is_empty() {
eslint_args.push(eslint_check);
}
run_script("npx", &eslint_args, CLIENT_PATH, true)?;
run_script("npx", &eslint_args, VSCODE_PATH, true)?;
run_script("pnpm", &["audit", "--prod"], CLIENT_PATH, false)?;
run_script("pnpm", &["audit", "--prod"], VSCODE_PATH, false)
}
fn run_full() -> io::Result<()> {
run_format_and_lint(true)?;
run_build()?;
// Verify that compiling for release produces no errors.
run_cmd!(
cd ..;
info "dist build";
dist build;
)?;
run_test()
}
fn run_test() -> io::Result<()> {
// `builder` is tested through its own manifest. Its test binaries are
// separate files from the `builder` executable this program is running
// from, and its feature resolution matches the `cargo run` which started
// that executable, so nothing here needs to relink the running binary.
run_cmd!(
info "cargo test";
cargo test --workspace;
info "Builder: cargo test";
cargo test --manifest-path=$BUILDER_PATH/Cargo.toml;
)?;
Ok(())
}
/// Repeatedly run the `overall` integration test modules until one fails, to
/// expose intermittent failures.
fn run_until_fail() -> io::Result<()> {
// Provide a backtrace on a failing test, matching the script's
// `RUST_BACKTRACE=1`.
unsafe {
env::set_var("RUST_BACKTRACE", "1");
}
let mut iteration = 0;
loop {
iteration += 1;
// Clear the screen so only the current iteration's output is visible.
print!("\x1b[2J\x1b[H");
println!("--- Iteration {iteration} ---");
// `run_cmd!` returns an error if `cargo test` exits non-zero, which
// breaks out of the loop -- the same behavior as the script.
run_cmd!(cargo test --test overall).map_err(|err| {
io::Error::other(format!("Test failed on iteration {iteration}: {err}"))
})?;
}
}
fn run_build() -> io::Result<()> {
// Deliberately no `builder` pass: `cargo run` already rebuilt this program
// before it began executing, and rebuilding it here would have to relink
// the running executable.
run_cmd!(
info "cargo build";
cargo build --workspace;
)?;
// Clean out all bundled files before the rebuild.
remove_dir_all_if_exists(format!("{CLIENT_PATH}/static/bundled"))?;
run_client_build(false, false)?;
run_extensions_build(false, false)?;
Ok(())
}
// Build the CodeChat Editor Client.
fn run_client_build(
// True to build for distribution, not development.
dist: bool,
// True to skip checking for TypeScript errors; false to perform these
// checks.
skip_check_errors: bool,
) -> io::Result<()> {
// Ensure the JavaScript data structures generated from Rust are up to date.
run_cmd!(
info "cargo test export_bindings";
cargo test export_bindings;
)?;
let esbuild = PathBuf::from_slash("node_modules/.bin/esbuild");
let distflag = if dist { "--minify" } else { "--sourcemap" };
// The main build for the Client.
//
//
// Overall client build pipeline
//
run_script(
&esbuild,
&[
"src/CodeChatEditorFramework.mts",
"src/CodeChatEditor.mts",
"src/CodeChatEditor-test.mts",
"src/css/CodeChatEditorProject.css",
"--bundle",
"--outdir=./static/bundled",
distflag,
"--format=esm",
"--splitting",
"--metafile=meta.json",
"--entry-names=[dir]/[name]-[hash]",
],
CLIENT_PATH,
true,
)?;
// The PDF viewer for use with VSCode. Build it
// separately, since it's loaded apart from the rest of the Client.
run_script(
&esbuild,
&[
"src/third-party/pdf.js/viewer.mjs",
"node_modules/pdfjs-dist/build/pdf.worker.mjs",
"--bundle",
"--outdir=./static/bundled",
distflag,
"--format=esm",
"--loader:.png=dataurl",
"--loader:.svg=dataurl",
"--loader:.gif=dataurl",
],
CLIENT_PATH,
true,
)?;
// Copy over the cmap (color map?) files, which the bundler doesn't handle.
quick_copy_dir(
format!("{CLIENT_PATH}/node_modules/pdfjs-dist/cmaps/"),
format!("{CLIENT_PATH}/static/bundled/node_modules/pdfjs-dist/cmaps/"),
None,
)?;
// Build the graphviz rendering engine.
run_script(
&esbuild,
&[
"src/third-party/graphviz-webcomponent/renderer.js",
"--bundle",
"--outdir=./static/bundled",
distflag,
"--format=esm",
],
CLIENT_PATH,
true,
)?;
// The HashReader isn't bundled; instead, it's used to translate the JSON
// metafile produced by the main esbuild run to the simpler format used by
// the CodeChat Editor. TODO: rewrite this in Rust.
run_script(
&esbuild,
&[
"src/HashReader.mts",
"--outdir=.",
"--platform=node",
"--format=esm",
],
CLIENT_PATH,
true,
)?;
run_script("node", &["HashReader.js"], CLIENT_PATH, true)?;
// Finally, check the TypeScript with the (slow) TypeScript compiler.
if !skip_check_errors {
run_script(
PathBuf::from_slash("node_modules/.bin/tsc"),
&["-noEmit"],
CLIENT_PATH,
true,
)?;
}
Ok(())
}
// Build the CodeChat Editor extensions.
fn run_extensions_build(
// True to build for distribution, not development.
dist: bool,
// True to skip checking for TypeScript errors; false to perform these
// checks.
skip_check_errors: bool,
) -> io::Result<()> {
let esbuild = PathBuf::from_slash("node_modules/.bin/esbuild");
let distflag = if dist { "--minify" } else { "--sourcemap" };
// The NAPI build.
let mut napi_args = vec!["napi", "build", "--platform", "--output-dir", "src"];
if dist {
napi_args.push("--release");
}
// See if this is a cross-platform build -- if so, add in the specified
// target.
let target;
if let Ok(tmp) = env::var(NAPI_TARGET) {
target = tmp;
napi_args.extend(["--target", &target]);
}
run_script("npx", &napi_args, VSCODE_PATH, true)?;
// Ensure the JavaScript data structures generated from Rust are up to date.
run_cmd!(
info "cargo test export_bindings";
cargo test export_bindings;
)?;
// The main build for the extension.
run_script(
&esbuild,
&[
"src/extension.ts",
"--platform=node",
"--format=cjs",
"--bundle",
// Don't bundle the VSCode library, since it's built in.
"--external:vscode",
"--outdir=./out",
distflag,
// The binaries produced by NAPI-RS should be copied over.
"--loader:.node=copy",
// Avoid the default of adding hash names to the `.node` file
// generated.
"--asset-names=[name]",
],
VSCODE_PATH,
true,
)?;
// Finally, check the TypeScript with the (slow) TypeScript compiler.
if !skip_check_errors {
run_script(
PathBuf::from_slash("node_modules/.bin/tsc"),
&["-noEmit"],
VSCODE_PATH,
true,
)?;
}
Ok(())
}
fn run_change_version(new_version: &String) -> io::Result<()> {
let cargo_regex = r#"(\r?\nversion = ")[\d.]+(?:-[a-z\d]*)?("\r?\n)"#;
let replacement_string = format!("${{1}}{new_version}${{2}}");
// The Server and both extensions inherit `version` from
// `[workspace.package]`, so the workspace manifest is the only Rust
// manifest to rewrite. `builder` and `test_utils` keep their own versions
// and are never released.
search_and_replace_file(WORKSPACE_MANIFEST_PATH, cargo_regex, &replacement_string)?;
search_and_replace_file(
format!("{VSCODE_PATH}/package.json"),
r#"(\r?\n "version": ")[\d.]+(?:-[a-z\d]*)?(",\r?\n)"#,
&replacement_string,
)?;
search_and_replace_file(
format!("{CLIENT_PATH}/package.json5"),
r"(\r?\n version: ')[\d.]+(?:-[a-z\d]*)?(',\r?\n)",
&replacement_string,
)?;
Ok(())
}
fn run_prerelease() -> io::Result<()> {
// Clean out all bundled files before the rebuild.
remove_dir_all_if_exists(format!("{CLIENT_PATH}/static/bundled"))?;
run_install(false)?;
run_client_build(true, false)
}
fn run_postrelease(target: &str, tag: &str) -> io::Result<()> {
// Copy all the Client static files needed by the embedded Server to the
// VSCode extension.
let client_static_dir = format!("{VSCODE_PATH}/static");
remove_dir_all_if_exists(&client_static_dir)?;
quick_copy_dir(&format!("{CLIENT_PATH}/static/"), &client_static_dir, None)?;
copy_file("log4rs.yml", &format!("{VSCODE_PATH}/log4rs.yml"))?;
copy_file(
"hashLocations.json",
&format!("{VSCODE_PATH}/hashLocations.json"),
)?;
// Translate from the target triple to VSCE's target parameter.
let vsce_target = match target {
"x86_64-pc-windows-msvc" => "win32-x64",
"x86_64-unknown-linux-gnu" => "linux-x64",
"x86_64-apple-darwin" => "darwin-x64",
"aarch64-apple-darwin" => "darwin-arm64",
_ => panic!("Unsupported platform {target}."),
};
// Determine if this is a pre-release from the version tag. While `dist`
// does this, it doesn't provide this data in CI until the `host` step,
// which comes after this is called. Determine it based on the tag.
let is_prerelease = if let Ok(parsed_tag) = parse_tag(&[], tag) {
parsed_tag.prerelease
} else {
false
};
let mut args = vec![
"vsce",
"package",
// We use esbuild to package; therefore, tell `vsce` not to package.
"--no-dependencies",
// Since we include the server as a binary, package for the architecture
// the binary was build for.
"--target",
vsce_target,
];
if is_prerelease {
args.push("--pre-release");
}
// `vsce` will invoke this program's `ext_build`; however, it doesn't
// provide a way to pass the target when cross-compiling. Use an environment
// variable instead.
unsafe {
env::set_var(NAPI_TARGET, target);
}
run_script("npx", &args, VSCODE_PATH, true)?;
Ok(())
}
fn run_coverage() -> io::Result<()> {
run_cmd!(
info "cargo llvm-cov --open";
cargo llvm-cov --open;
)
}
// CLI implementation
// ------------------
//
// The following code implements the command-line interface for the CodeChat
// Editor.
impl Cli {
fn run(self) -> io::Result<()> {
match &self.command {
Commands::Install => run_install(true),
Commands::Update => run_update(),
Commands::Flint { check } => run_format_and_lint(*check),
Commands::Full => run_full(),
Commands::Test => run_test(),
Commands::RunUntilFail => run_until_fail(),
Commands::Build => run_build(),
Commands::ClientBuild(build_options) => {
run_client_build(build_options.dist, build_options.skip_check_errors)
}
Commands::ExtBuild(build_options) => {
run_extensions_build(build_options.dist, build_options.skip_check_errors)
}
Commands::ChangeVersion { new_version } => run_change_version(new_version),
Commands::Prerelease => run_prerelease(),
Commands::Postrelease { target, tag, .. } => run_postrelease(target, tag),
Commands::Cover => run_coverage(),
}
}
}
fn main() -> io::Result<()> {
// Change to the `server/` directory, so it can be run from anywhere. Locate
// it relative to this package's manifest. `bt` always runs this through
// `cargo run`, so the source tree is present.
let mut root_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
root_path.push("../server");
// Use `dunce.canonicalize`, since UNC paths booger up some of the build
// tools (cargo can't delete the builder's binary, NPM doesn't accept UNC
// paths.)
let root_path = canonicalize(root_path).unwrap();
env::set_current_dir(root_path).unwrap();
let cli = Cli::parse();
cli.run()?;
Ok(())
}
#[cfg(test)]
mod test {
use super::Cli;
use clap::CommandFactory;
// This is recommended in the
// [docs](https://docs.rs/clap/latest/clap/_derive/_tutorial/chapter_4/index.html).
#[test]
fn verify_cli() {
Cli::command().debug_assert();
}
}