|
| 1 | +// SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +// Copyright (c) 2024-2026 Jonathan D.A. Jewell (hyperpolymath) |
| 3 | + |
| 4 | +//! Compiler resolution (INT-10 / #282 — ADR-019 S4). |
| 5 | +//! |
| 6 | +//! The AffineScript compiler is a native OCaml binary, not a crate the |
| 7 | +//! LSP can link. Per **ADR-019** (`docs/specs/SETTLED-DECISIONS.adoc`) |
| 8 | +//! the GitHub Release is the canonical artifact and the thin Deno/JSR |
| 9 | +//! package `@hyperpolymath/affinescript` (`packages/affinescript-cli`, |
| 10 | +//! #260 S3) is the ergonomic front door that downloads, checksum-verifies, |
| 11 | +//! caches and execs the pinned per-platform binary. |
| 12 | +//! |
| 13 | +//! This module is the LSP side of S4: it resolves *how* to invoke the |
| 14 | +//! compiler, with **no bespoke compiler-bundling in the LSP**. Precedence: |
| 15 | +//! |
| 16 | +//! 1. `AFFINESCRIPT_COMPILER` — an explicit path to a compiler binary. |
| 17 | +//! The escape hatch for source/dev builds (and the resolution seam the |
| 18 | +//! smoke test drives). No download, runs exactly what is named. |
| 19 | +//! 2. `affinescript` on `PATH` — a source/dev install already provisioned |
| 20 | +//! a compiler; use it directly. |
| 21 | +//! 3. The **ADR-019 shim** — `deno run … jsr:@hyperpolymath/affinescript@<pin>`. |
| 22 | +//! This is the default distribution path for an installed LSP: the |
| 23 | +//! shim itself does the download + SHA256 verify + cache + exec. |
| 24 | +//! |
| 25 | +//! The shim version is pinned here in lockstep with the shim package's |
| 26 | +//! `deno.json` `version` (the ADR-019 "one version + checksum per shim |
| 27 | +//! release — no floating fetch" rule). Bump both together. |
| 28 | +
|
| 29 | +/// Pinned ADR-019 shim spec. Must track `packages/affinescript-cli/deno.json` |
| 30 | +/// `version` (and therefore the `pins.js` `VERSION`). Do not float this. |
| 31 | +pub const SHIM_SPEC: &str = "jsr:@hyperpolymath/affinescript@0.1.0"; |
| 32 | + |
| 33 | +/// Environment variable naming an explicit compiler binary (precedence 1). |
| 34 | +pub const COMPILER_ENV: &str = "AFFINESCRIPT_COMPILER"; |
| 35 | + |
| 36 | +/// A resolved way to invoke the compiler: a `program` plus the argv |
| 37 | +/// *prefix* that must precede the usual `check --json <file>` arguments. |
| 38 | +#[derive(Debug, Clone, PartialEq, Eq)] |
| 39 | +pub struct ResolvedCompiler { |
| 40 | + /// Program to spawn. |
| 41 | + pub program: String, |
| 42 | + /// Argv prefix (e.g. the `deno run … <shim>` wrapper); empty for a |
| 43 | + /// direct binary. |
| 44 | + pub prefix_args: Vec<String>, |
| 45 | +} |
| 46 | + |
| 47 | +impl ResolvedCompiler { |
| 48 | + /// Build a `tokio::process::Command` for `program prefix_args… args…`. |
| 49 | + pub fn command<I, S>(&self, args: I) -> tokio::process::Command |
| 50 | + where |
| 51 | + I: IntoIterator<Item = S>, |
| 52 | + S: AsRef<std::ffi::OsStr>, |
| 53 | + { |
| 54 | + let mut cmd = tokio::process::Command::new(&self.program); |
| 55 | + cmd.args(&self.prefix_args); |
| 56 | + cmd.args(args); |
| 57 | + cmd |
| 58 | + } |
| 59 | + |
| 60 | + /// Human-readable form for log messages. |
| 61 | + pub fn display(&self) -> String { |
| 62 | + if self.prefix_args.is_empty() { |
| 63 | + self.program.clone() |
| 64 | + } else { |
| 65 | + format!("{} {}", self.program, self.prefix_args.join(" ")) |
| 66 | + } |
| 67 | + } |
| 68 | +} |
| 69 | + |
| 70 | +/// The ADR-019 shim invocation (precedence 3). Permissions are exactly |
| 71 | +/// what the shim needs: read/write its cache, read env for the cache dir, |
| 72 | +/// net to fetch the pinned Release asset, run to exec the binary. |
| 73 | +fn shim() -> ResolvedCompiler { |
| 74 | + ResolvedCompiler { |
| 75 | + program: "deno".to_string(), |
| 76 | + prefix_args: vec![ |
| 77 | + "run".to_string(), |
| 78 | + "--allow-read".to_string(), |
| 79 | + "--allow-write".to_string(), |
| 80 | + "--allow-env".to_string(), |
| 81 | + "--allow-net".to_string(), |
| 82 | + "--allow-run".to_string(), |
| 83 | + SHIM_SPEC.to_string(), |
| 84 | + ], |
| 85 | + } |
| 86 | +} |
| 87 | + |
| 88 | +/// Pure resolution, with the environment lookups injected so it is |
| 89 | +/// testable without mutating the real process environment or `PATH`. |
| 90 | +fn resolve_with(env_override: Option<String>, affinescript_on_path: bool) -> ResolvedCompiler { |
| 91 | + if let Some(path) = env_override.filter(|p| !p.is_empty()) { |
| 92 | + return ResolvedCompiler { program: path, prefix_args: Vec::new() }; |
| 93 | + } |
| 94 | + if affinescript_on_path { |
| 95 | + return ResolvedCompiler { |
| 96 | + program: "affinescript".to_string(), |
| 97 | + prefix_args: Vec::new(), |
| 98 | + }; |
| 99 | + } |
| 100 | + shim() |
| 101 | +} |
| 102 | + |
| 103 | +/// `true` if `name` resolves to an executable on `PATH`. |
| 104 | +fn binary_on_path(name: &str) -> bool { |
| 105 | + let Some(path) = std::env::var_os("PATH") else { |
| 106 | + return false; |
| 107 | + }; |
| 108 | + std::env::split_paths(&path).any(|dir| { |
| 109 | + let candidate = dir.join(name); |
| 110 | + candidate.is_file() |
| 111 | + || std::fs::metadata(&candidate).map(|m| m.is_file()).unwrap_or(false) |
| 112 | + }) |
| 113 | +} |
| 114 | + |
| 115 | +/// Resolve how to invoke the compiler for this process/host (ADR-019 S4). |
| 116 | +pub fn resolve_compiler() -> ResolvedCompiler { |
| 117 | + resolve_with( |
| 118 | + std::env::var(COMPILER_ENV).ok(), |
| 119 | + binary_on_path("affinescript"), |
| 120 | + ) |
| 121 | +} |
| 122 | + |
| 123 | +#[cfg(test)] |
| 124 | +mod tests { |
| 125 | + use super::*; |
| 126 | + |
| 127 | + #[test] |
| 128 | + fn env_override_wins_over_path_and_shim() { |
| 129 | + let r = resolve_with(Some("/opt/afs/affinescript".to_string()), true); |
| 130 | + assert_eq!(r.program, "/opt/afs/affinescript"); |
| 131 | + assert!(r.prefix_args.is_empty()); |
| 132 | + } |
| 133 | + |
| 134 | + #[test] |
| 135 | + fn empty_env_override_is_ignored() { |
| 136 | + // An exported-but-empty var must not shadow PATH/shim resolution. |
| 137 | + let r = resolve_with(Some(String::new()), false); |
| 138 | + assert_eq!(r, shim()); |
| 139 | + } |
| 140 | + |
| 141 | + #[test] |
| 142 | + fn path_used_when_no_env_override() { |
| 143 | + let r = resolve_with(None, true); |
| 144 | + assert_eq!(r.program, "affinescript"); |
| 145 | + assert!(r.prefix_args.is_empty()); |
| 146 | + } |
| 147 | + |
| 148 | + /// Smoke (INT-10 / #282 S4): the LSP can *locate and exec* a resolved |
| 149 | + /// compiler and read back its `--json` contract. A fake compiler |
| 150 | + /// stands in for the OCaml binary so the test is hermetic (no network, |
| 151 | + /// no Deno, no installed toolchain) while still exercising the real |
| 152 | + /// resolution → `ResolvedCompiler::command` → spawn path. |
| 153 | + #[test] |
| 154 | + fn resolved_compiler_can_be_located_and_executed() { |
| 155 | + use std::io::Write; |
| 156 | + |
| 157 | + let dir = std::env::temp_dir().join(format!("afs-lsp-smoke-{}", std::process::id())); |
| 158 | + std::fs::create_dir_all(&dir).unwrap(); |
| 159 | + let is_windows = cfg!(windows); |
| 160 | + let fake = dir.join(if is_windows { "afsc.bat" } else { "afsc.sh" }); |
| 161 | + let script = if is_windows { |
| 162 | + "@echo {\"version\":1,\"diagnostics\":[],\"success\":true} 1>&2\r\n" |
| 163 | + } else { |
| 164 | + "#!/bin/sh\necho '{\"version\":1,\"diagnostics\":[],\"success\":true}' 1>&2\n" |
| 165 | + }; |
| 166 | + { |
| 167 | + let mut f = std::fs::File::create(&fake).unwrap(); |
| 168 | + f.write_all(script.as_bytes()).unwrap(); |
| 169 | + } |
| 170 | + #[cfg(unix)] |
| 171 | + { |
| 172 | + use std::os::unix::fs::PermissionsExt; |
| 173 | + std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap(); |
| 174 | + } |
| 175 | + |
| 176 | + // Resolve exactly as production would for an explicit binary… |
| 177 | + let resolved = resolve_with(Some(fake.to_string_lossy().into_owned()), false); |
| 178 | + assert!(resolved.prefix_args.is_empty()); |
| 179 | + |
| 180 | + // …then locate + exec it through the same command builder the LSP |
| 181 | + // uses. A blocking std command mirrors the tokio one (same argv). |
| 182 | + let mut cmd = std::process::Command::new(&resolved.program); |
| 183 | + cmd.args(&resolved.prefix_args) |
| 184 | + .args(["check", "--json", "/nonexistent.affine"]); |
| 185 | + let out = cmd.output().expect("LSP must be able to exec the resolved compiler"); |
| 186 | + |
| 187 | + let stderr = String::from_utf8_lossy(&out.stderr); |
| 188 | + assert!( |
| 189 | + stderr.contains("\"version\":1") && stderr.contains("\"success\":true"), |
| 190 | + "compiler --json contract not observed; stderr was: {stderr}" |
| 191 | + ); |
| 192 | + |
| 193 | + let _ = std::fs::remove_dir_all(&dir); |
| 194 | + } |
| 195 | + |
| 196 | + #[test] |
| 197 | + fn shim_is_the_default_when_nothing_local() { |
| 198 | + let r = resolve_with(None, false); |
| 199 | + assert_eq!(r.program, "deno"); |
| 200 | + assert_eq!(r.prefix_args.last().map(String::as_str), Some(SHIM_SPEC)); |
| 201 | + // ADR-019: pin must be exact (no floating @latest / bare name). |
| 202 | + assert!(SHIM_SPEC.contains("@hyperpolymath/affinescript@")); |
| 203 | + assert!(!SHIM_SPEC.ends_with("affinescript")); |
| 204 | + } |
| 205 | +} |
0 commit comments