Skip to content

Commit 160efa0

Browse files
feat(lsp): INT-10 — affinescript-lsp resolves compiler via ADR-019 shim (#260 S4) (#287)
Wires affinescript-lsp onto the ADR-019 compiler distribution path (#260 S1/S2/S3 already merged: ADR-019 + release matrix + the @hyperpolymath/affinescript shim). - New src/compiler.rs: resolve_compiler() with precedence AFFINESCRIPT_COMPILER → `affinescript` on PATH → the pinned `jsr:@hyperpolymath/affinescript` shim (shim does download + SHA256-verify + cache + exec). No bespoke compiler-bundling in the LSP. Shim spec pinned in lockstep with the shim deno.json. - check_document() now invokes the resolved compiler instead of a hardcoded `affinescript` on PATH. - Smoke test: the LSP can locate + exec a resolved compiler and read back its --json contract (hermetic fake compiler, no net / Deno); plus resolution-precedence unit tests. 26/26 green. - TECH-DEBT ledger: INT-10 closed, #260 S4 done. Closes #282. Refs #260, #181, ADR-019. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bbe6ebc commit 160efa0

3 files changed

Lines changed: 238 additions & 9 deletions

File tree

docs/TECH-DEBT.adoc

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,12 +194,17 @@ valid WASI-0.2 component, ownership section survives,
194194
|INT-04 |Publish to JSR/npm |S2 |#181 packaging READY (dry-run green,
195195
manual workflow); JSR publish authorised + dispatched (owner go
196196
2026-05-19); compiler-binary distribution decided = **ADR-019**
197-
(#260, Releases + thin Deno/JSR shim, staged S1..S4) — unblocks INT-10
197+
(#260, Releases + thin Deno/JSR shim, staged S1..S4) — S1/S2/S3
198+
merged (#283/#284/#285); **S4 done — INT-10 closed (#282)**
198199
|INT-07 |`affinescript-tea` runtime |S2 |#182 runtime + run loop shipped
199200
(TeaApp/parseTeaLayout, Linear-msg enforced); INT-01 cleared (#253)
200201
|INT-08 |DOM reconciler |S2 |#183 implemented + compiles; `.as`→`.affine`
201202
fixed; runtime blocked by #255 (wasm loop-codegen defect)
202-
|INT-05/06/09/10/11/12 |ledger-only; filed when blocker closes |— |planned
203+
|INT-10 |`affinescript-lsp` distribution |S2 |**DONE** (#282, ADR-019
204+
S4): LSP resolves the compiler via `AFFINESCRIPT_COMPILER` → `affinescript`
205+
on `PATH` → the `@hyperpolymath/affinescript` shim (`src/compiler.rs`);
206+
no bespoke bundling; locate/exec smoke + resolution unit tests green
207+
|INT-05/06/09/11/12 |ledger-only; filed when blocker closes |— |planned
203208
|===
204209

205210
== Section E — SAT (satellite repos)
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
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+
}

tools/affinescript-lsp/src/main.rs

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,21 @@
3131
//!
3232
//! This replaces the fragile regex parsing from the pre-Phase-A implementation
3333
//! and is the foundation for Phases B-D.
34+
//!
35+
//! ## Compiler resolution (INT-10 / #282, ADR-019 S4)
36+
//!
37+
//! The compiler binary is *not* bundled. How it is located is owned by
38+
//! [`compiler::resolve_compiler`]: `AFFINESCRIPT_COMPILER`, else
39+
//! `affinescript` on `PATH`, else the `@hyperpolymath/affinescript`
40+
//! shim (which downloads + SHA256-verifies + caches the pinned Release
41+
//! binary).
3442
3543
use tower_lsp::jsonrpc::Result;
3644
use tower_lsp::lsp_types::*;
3745
use tower_lsp::{Client, LanguageServer, LspService, Server};
3846

3947
mod capabilities;
48+
mod compiler;
4049
mod diagnostics;
4150
mod document;
4251
mod handlers;
@@ -69,7 +78,6 @@ impl Backend {
6978
/// text. Falls back to an internal error diagnostic if the compiler
7079
/// is not found or returns unparseable output.
7180
async fn check_document(&self, uri: &Url, text: &str) -> Vec<Diagnostic> {
72-
use tokio::process::Command;
7381
use std::process::Stdio;
7482

7583
// Write source to a temp file so the compiler can read it
@@ -81,11 +89,18 @@ impl Backend {
8189
return vec![];
8290
}
8391

84-
// Run `affinescript check --json <path>`
85-
let output = match Command::new("affinescript")
86-
.arg("check")
87-
.arg("--json")
88-
.arg(&temp_path)
92+
// Resolve the compiler per ADR-019 (INT-10 / #282 S4): explicit
93+
// `AFFINESCRIPT_COMPILER`, else `affinescript` on PATH, else the
94+
// `@hyperpolymath/affinescript` shim. No bespoke bundling here.
95+
let resolved = compiler::resolve_compiler();
96+
97+
// Run `<compiler> check --json <path>`
98+
let output = match resolved
99+
.command([
100+
std::ffi::OsStr::new("check"),
101+
std::ffi::OsStr::new("--json"),
102+
temp_path.as_os_str(),
103+
])
89104
.stdout(Stdio::piped())
90105
.stderr(Stdio::piped())
91106
.output()
@@ -96,7 +111,11 @@ impl Backend {
96111
self.client
97112
.log_message(
98113
MessageType::ERROR,
99-
format!("Failed to run affinescript: {}", e),
114+
format!(
115+
"Failed to run the AffineScript compiler via `{}`: {}",
116+
resolved.display(),
117+
e
118+
),
100119
)
101120
.await;
102121
let _ = tokio::fs::remove_file(&temp_path).await;

0 commit comments

Comments
 (0)