use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet, VecDeque};
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use thiserror::Error;
use tokio::fs::OpenOptions;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixListener;
use tokio::sync::{Mutex, mpsc};
use tokio::time::sleep;
use super::generated_protocol::{PatchType, ReloadDecision};
use crate::hybrid_sil;
use crate::parser_registry::{self, ParserCli, ResolvedBuildParser};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReloadPatch {
pub target: String,
pub patch_type: PatchType,
pub compatible: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PatchEnvelope {
pub protocol_version: u8,
pub patch_id: String,
pub timestamp_ms: u64,
pub patch: ReloadPatch,
pub reload_decision: ReloadDecision,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeMetric {
pub timestamp_ms: u64,
pub source: String,
pub target: String,
pub compatible: bool,
pub reload_decision: ReloadDecision,
pub reason: String,
pub compile_check_ms: u64,
pub compile_cache_hit: bool,
#[serde(
default,
rename = "compile_frontend",
skip_serializing_if = "Option::is_none"
)]
pub compile_frontend: Option<String>,
#[serde(
default,
rename = "compile_source_hash",
skip_serializing_if = "Option::is_none"
)]
pub compile_source_hash: Option<u64>,
#[serde(
default,
rename = "compile_fallback_reason",
skip_serializing_if = "Option::is_none"
)]
pub compile_fallback_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sil_call_edges: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sil_graph_ms: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct DaemonConfig {
pub watch_root: PathBuf,
pub socket_path: PathBuf,
pub metrics_path: PathBuf,
pub debounce_ms: u64,
}
#[derive(Debug, Error)]
pub enum DaemonError {
#[error("notify error: {0}")]
Notify(#[from] notify::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Clone)]
struct ClientPool {
writers: Arc<Mutex<Vec<tokio::net::unix::OwnedWriteHalf>>>,
}
impl ClientPool {
fn new() -> Self {
Self {
writers: Arc::new(Mutex::new(Vec::new())),
}
}
async fn add(&self, writer: tokio::net::unix::OwnedWriteHalf) {
self.writers.lock().await.push(writer);
}
async fn broadcast_line(&self, line: &str) -> Result<(), DaemonError> {
let mut stale_indexes = Vec::new();
let mut writers = self.writers.lock().await;
for (index, writer) in writers.iter_mut().enumerate() {
if writer.write_all(line.as_bytes()).await.is_err()
|| writer.write_all(b"\n").await.is_err()
|| writer.flush().await.is_err()
{
stale_indexes.push(index);
}
}
for index in stale_indexes.into_iter().rev() {
writers.remove(index);
}
Ok(())
}
}
fn now_ms() -> u64 {
match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(dur) => dur.as_millis() as u64,
Err(_) => 0,
}
}
pub fn patch_id_for(path: &str) -> String {
format!("{}-{}", now_ms(), path.replace('/', "_"))
}
pub fn plan_patch(path: &str, changed_symbols: &[String]) -> ReloadPatch {
plan_patch_with_sil_graph(path, changed_symbols, None, false)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SilGraphSource {
Subset,
SubsetZeroEdges,
SubsetUnavailable,
Swiftc,
SwiftcZeroEdges,
SwiftcUnavailable,
NonSwift,
SkippedCompileFail,
}
impl SilGraphSource {
fn reason_tag(&self) -> &'static str {
match self {
Self::Subset => "sil_graph=subset",
Self::SubsetZeroEdges => "sil_graph=subset_zero_edges",
Self::SubsetUnavailable => "sil_graph=subset_unavailable",
Self::Swiftc => "sil_graph=swiftc",
Self::SwiftcZeroEdges => "sil_graph=swiftc_zero_edges",
Self::SwiftcUnavailable => "sil_graph=swiftc_unavailable",
Self::NonSwift => "sil_graph=non_swift",
Self::SkippedCompileFail => "sil_graph=skipped_compile_fail",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SilGraphDetail {
source: SilGraphSource,
call_edges: Vec<(String, String)>,
callees: HashSet<String>,
}
impl SilGraphDetail {
fn unavailable(source: SilGraphSource) -> Self {
Self {
source,
call_edges: Vec::new(),
callees: HashSet::new(),
}
}
fn from_call_edges(call_edges: Vec<(String, String)>) -> Self {
Self::from_call_edges_with_source(call_edges, SilGraphSource::Subset)
}
fn from_swiftc_call_edges(call_edges: Vec<(String, String)>) -> Self {
Self::from_call_edges_with_source(call_edges, SilGraphSource::Swiftc)
}
fn from_call_edges_with_source(
call_edges: Vec<(String, String)>,
nonzero_source: SilGraphSource,
) -> Self {
let callees = call_edges
.iter()
.map(|(_, callee)| callee.clone())
.collect::<HashSet<_>>();
let source = match (call_edges.is_empty(), nonzero_source) {
(false, source) => source,
(true, SilGraphSource::Swiftc) => SilGraphSource::SwiftcZeroEdges,
(true, _) => SilGraphSource::SubsetZeroEdges,
};
Self {
source,
call_edges,
callees,
}
}
fn reason_tag(&self) -> &'static str {
self.source.reason_tag()
}
fn edge_count(&self) -> Option<u32> {
match self.source {
SilGraphSource::Subset
| SilGraphSource::SubsetZeroEdges
| SilGraphSource::Swiftc
| SilGraphSource::SwiftcZeroEdges => Some(self.call_edges.len() as u32),
SilGraphSource::SubsetUnavailable
| SilGraphSource::SwiftcUnavailable
| SilGraphSource::NonSwift
| SilGraphSource::SkippedCompileFail => None,
}
}
fn has_changed_callee(&self, changed_symbols: &[String]) -> bool {
changed_symbols
.iter()
.any(|symbol| self.callees.contains(symbol))
}
fn callers_of(&self, callee: &str) -> Vec<String> {
self.call_edges
.iter()
.filter(|(_, c)| c == callee)
.map(|(caller, _)| caller.clone())
.collect()
}
fn changed_symbol_is_called_by_view_body(&self, changed_symbols: &[String]) -> bool {
changed_symbols.iter().any(|symbol| {
self.callers_of(symbol)
.iter()
.any(|caller| caller.contains("body"))
})
}
}
fn is_app_entry_path(path: &str) -> bool {
Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == "App.swift")
}
pub fn plan_patch_with_sil_graph(
path: &str,
changed_symbols: &[String],
sil_graph: Option<&SilGraphDetail>,
callee_driven: bool,
) -> ReloadPatch {
let mut patch_type = if changed_symbols.iter().any(|s| s.contains("body")) {
PatchType::ViewBody
} else if changed_symbols.iter().any(|s| s.contains("modifier")) {
PatchType::Modifier
} else {
PatchType::FullModule
};
if matches!(patch_type, PatchType::FullModule) && !is_app_entry_path(path) {
let should_upgrade = if callee_driven {
sil_graph
.is_some_and(|graph| graph.changed_symbol_is_called_by_view_body(changed_symbols))
} else {
sil_graph.is_some_and(|graph| graph.has_changed_callee(changed_symbols))
};
if should_upgrade {
patch_type = PatchType::ViewBody;
}
}
let compatible = !is_app_entry_path(path) && !matches!(patch_type, PatchType::FullModule);
ReloadPatch {
target: path.to_string(),
patch_type,
compatible,
}
}
fn sil_callee_driven_hotreload_enabled() -> bool {
std::env::var("IN_SIL_CALLEE_DRIVEN_HOTRELOAD")
.ok()
.is_some_and(|value| parse_env_bool_like_in(&value))
}
pub fn symbols_for_path(path: &str) -> Vec<String> {
if path.contains("ContentView") {
vec!["body".to_string()]
} else if path.contains("Modifier") {
vec!["modifier".to_string()]
} else {
vec![]
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct NodeId(String);
impl NodeId {
fn from_path(path: &str) -> Self {
Self(path.to_string())
}
}
#[derive(Debug, Clone, Default)]
struct QueryGraph {
symbols: HashMap<NodeId, Vec<String>>,
deps: HashMap<NodeId, HashSet<NodeId>>,
reverse_deps: HashMap<NodeId, HashSet<NodeId>>,
}
impl QueryGraph {
fn upsert_node(&mut self, path: &str, symbols: Vec<String>, deps: HashSet<String>) {
let node = NodeId::from_path(path);
if let Some(old) = self.deps.remove(&node) {
for dep in old {
if let Some(reverse) = self.reverse_deps.get_mut(&dep) {
reverse.remove(&node);
}
}
}
self.symbols.insert(node.clone(), symbols);
let mapped: HashSet<NodeId> = deps.into_iter().map(NodeId).collect();
for dep in &mapped {
self.reverse_deps
.entry(dep.clone())
.or_default()
.insert(node.clone());
}
self.deps.insert(node, mapped);
}
fn invalidate_from_path(&self, path: &str) -> Vec<NodeId> {
let start = NodeId::from_path(path);
let mut queue = VecDeque::from([start.clone()]);
let mut seen = HashSet::from([start]);
let mut dirty = Vec::new();
while let Some(node) = queue.pop_front() {
dirty.push(node.clone());
if let Some(dependents) = self.reverse_deps.get(&node) {
for dependent in dependents {
if seen.insert(dependent.clone()) {
queue.push_back(dependent.clone());
}
}
}
}
dirty
}
fn symbols_for_node(&self, node: &NodeId) -> &[String] {
self.symbols.get(node).map(Vec::as_slice).unwrap_or(&[])
}
}
fn infer_deps_for_path(path: &str) -> HashSet<String> {
let mut deps = HashSet::new();
if is_app_entry_path(path) {
return deps;
}
if let Some((prefix, _)) = path.rsplit_once('/') {
deps.insert(format!("{prefix}/App.swift"));
} else {
deps.insert("App.swift".to_string());
}
deps
}
fn classify_change(path: &str, graph: &mut QueryGraph) -> Vec<String> {
let local_symbols = symbols_for_path(path);
graph.upsert_node(path, local_symbols, infer_deps_for_path(path));
let dirty_nodes = graph.invalidate_from_path(path);
let mut combined = Vec::new();
for node in dirty_nodes {
combined.extend_from_slice(graph.symbols_for_node(&node));
}
combined.sort_unstable();
combined.dedup();
combined
}
fn compile_check_swift(_path: &Path) -> bool {
true
}
fn parse_env_bool_like_in(value: &str) -> bool {
let trimmed = value.trim();
trimmed == "1" || trimmed.eq_ignore_ascii_case("true")
}
fn graph_detail_from_sil(sil: &str, swiftc: bool) -> SilGraphDetail {
let artifact = hybrid_sil::parse_textual_sil(sil);
let cleaned = hybrid_sil::remove_debug_insts(&artifact);
let call_edges = hybrid_sil::extract_call_graph(&cleaned).call_edges;
if swiftc {
SilGraphDetail::from_swiftc_call_edges(call_edges)
} else {
SilGraphDetail::from_call_edges(call_edges)
}
}
fn sil_subset_graph_detail(path: &Path) -> SilGraphDetail {
let resolved = parser_registry::resolve_parser_id(path, ParserCli::Auto);
match parser_registry::parse_with_resolved(resolved, path) {
Ok(Some(module)) => {
let sil = crate::compiler::driver::lower_unified_module(&module, "App");
graph_detail_from_sil(&sil, false)
}
_ => SilGraphDetail::unavailable(SilGraphSource::NonSwift),
}
}
#[cfg(test)]
#[cfg(test)]
fn sil_subset_call_edge_count(path: &Path) -> Option<u32> {
sil_subset_graph_detail(path).edge_count()
}
pub fn compile_check(path: &Path) -> bool {
if !path.exists() {
return false;
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if ext == "swift" {
return compile_check_swift(path);
}
let resolved = parser_registry::resolve_parser_id(path, ParserCli::Auto);
match resolved {
ResolvedBuildParser::Swift => compile_check_swift(path),
ResolvedBuildParser::CoreIr(_) => parser_registry::parse_with_resolved(resolved, path)
.map(|m| m.is_some())
.unwrap_or(false),
}
}
fn extension_triggers_hotreload_notify(ext: &str) -> bool {
let el = ext.to_ascii_lowercase();
el == "swift" || parser_registry::parser_id_from_extension(&el).is_some()
}
#[derive(Debug, Clone)]
struct CompileCacheEntry {
modified_ms: u128,
ok: bool,
cache_policy: String,
frontend_kind: Option<String>,
fallback_reason: Option<String>,
source_hash: Option<u64>,
}
#[derive(Debug, Clone)]
struct CompileCheckResult {
ok: bool,
cache_hit: bool,
elapsed_ms: u64,
frontend_kind: Option<String>,
fallback_reason: Option<String>,
source_hash: Option<u64>,
}
type CompileCache = HashMap<String, CompileCacheEntry>;
fn modified_ms(path: &Path) -> Option<u128> {
path.metadata()
.ok()?
.modified()
.ok()?
.duration_since(UNIX_EPOCH)
.ok()
.map(|dur| dur.as_millis())
}
fn source_hash(path: &Path) -> Option<u64> {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
std::fs::read(path).ok()?.hash(&mut hasher);
Some(hasher.finish())
}
fn source_hash_for_cache(path: &Path) -> Option<u64> {
source_hash(path)
}
fn compile_cache_policy(path: &Path) -> String {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if ext == "swift" {
return "swift:core-ir".to_string();
}
format!("core-ir:{ext}")
}
fn compile_check_uncached(path: &Path) -> CompileCheckResult {
let hash = source_hash_for_cache(path);
let resolved = parser_registry::resolve_parser_id(path, ParserCli::Auto);
let ok = parser_registry::parse_with_resolved(resolved, path)
.map(|m| m.is_some())
.unwrap_or(false);
let frontend_kind = parser_registry::parser_id_from_extension(
&path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase(),
)
.map(|id| id.as_str().to_string());
CompileCheckResult {
ok,
cache_hit: false,
elapsed_ms: 0,
frontend_kind,
fallback_reason: None,
source_hash: hash,
}
}
fn compile_check_cached(path: &Path, cache: &mut CompileCache) -> CompileCheckResult {
let start = std::time::Instant::now();
let key = path.to_string_lossy().to_string();
let Some(current_ms) = modified_ms(path) else {
return CompileCheckResult {
ok: false,
cache_hit: false,
elapsed_ms: start.elapsed().as_millis() as u64,
frontend_kind: None,
fallback_reason: Some("missing_source".to_string()),
source_hash: None,
};
};
let current_hash = source_hash_for_cache(path);
let current_policy = compile_cache_policy(path);
if let Some(entry) = cache.get(&key)
&& entry.modified_ms == current_ms
&& entry.source_hash == current_hash
&& entry.cache_policy == current_policy
{
return CompileCheckResult {
ok: entry.ok,
cache_hit: true,
elapsed_ms: start.elapsed().as_millis() as u64,
frontend_kind: entry.frontend_kind.clone(),
fallback_reason: entry.fallback_reason.clone(),
source_hash: entry.source_hash,
};
}
let mut result = compile_check_uncached(path);
result.source_hash = current_hash;
result.elapsed_ms = start.elapsed().as_millis() as u64;
cache.insert(
key,
CompileCacheEntry {
modified_ms: current_ms,
ok: result.ok,
cache_policy: current_policy,
frontend_kind: result.frontend_kind.clone(),
fallback_reason: result.fallback_reason.clone(),
source_hash: result.source_hash,
},
);
result
}
pub const IN_ABI_OK: u32 = 0;
pub const IN_ABI_PANIC: u32 = 1;
pub const IN_ABI_LAYOUT_MISMATCH: u32 = 2;
pub const IN_ABI_ALLOC_ERROR: u32 = 3;
pub const IN_ABI_SYMBOL_MISSING: u32 = 4;
pub fn reload_decision_wire(decision: &ReloadDecision) -> String {
serde_json::to_string(decision)
.expect("reload decision serializes")
.trim_matches('"')
.to_string()
}
pub fn reload_decision_for_abi_status(status: u32) -> Option<ReloadDecision> {
match status {
IN_ABI_OK => None,
IN_ABI_LAYOUT_MISMATCH => Some(ReloadDecision::AbiLayoutMismatch),
IN_ABI_SYMBOL_MISSING => Some(ReloadDecision::AbiSymbolMissing),
IN_ABI_ALLOC_ERROR => Some(ReloadDecision::AbiAllocError),
IN_ABI_PANIC => Some(ReloadDecision::AbiPanic),
_ => Some(ReloadDecision::RestartRequired),
}
}
pub fn apply_restart_supervisor(
mut patch: ReloadPatch,
compile_ok: bool,
) -> (ReloadPatch, ReloadDecision) {
if !compile_ok {
patch.compatible = false;
return (patch, ReloadDecision::CompileFailed);
}
if matches!(patch.patch_type, PatchType::FullModule) || !patch.compatible {
patch.compatible = false;
return (patch, ReloadDecision::RestartRequired);
}
(patch, ReloadDecision::PatchApplied)
}
pub fn plan_reload_with_abi(
patch: ReloadPatch,
compile_ok: bool,
abi_status: Option<u32>,
) -> (ReloadPatch, ReloadDecision) {
if let Some(status) = abi_status
&& let Some(decision) = reload_decision_for_abi_status(status)
{
let mut patch = patch;
patch.compatible = false;
return (patch, decision);
}
apply_restart_supervisor(patch, compile_ok)
}
pub async fn append_metric(path: &Path, metric: &RuntimeMetric) -> Result<(), DaemonError> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.await?;
let line = serde_json::to_string(metric)?;
file.write_all(line.as_bytes()).await?;
file.write_all(b"\n").await?;
file.flush().await?;
Ok(())
}
async fn start_socket_server(
socket_path: &Path,
pool: ClientPool,
) -> Result<tokio::task::JoinHandle<()>, DaemonError> {
if let Some(parent) = socket_path.parent() {
std::fs::create_dir_all(parent)?;
}
if socket_path.exists() {
std::fs::remove_file(socket_path)?;
}
let listener = UnixListener::bind(socket_path)?;
let task = tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
let (reader, writer) = stream.into_split();
pool.add(writer).await;
tokio::spawn(async move {
let mut lines = BufReader::new(reader).lines();
while lines.next_line().await.ok().flatten().is_some() {}
});
}
});
Ok(task)
}
async fn emit_patch(
target: &str,
symbols: &[String],
metrics_path: &Path,
pool: &ClientPool,
compile_cache: &mut CompileCache,
) -> Result<ReloadPatch, DaemonError> {
let compile_check = compile_check_cached(Path::new(target), compile_cache);
let graph_start = std::time::Instant::now();
let (sil_graph, sil_graph_ms) = if compile_check.ok {
let graph = sil_subset_graph_detail(Path::new(target));
(graph, Some(graph_start.elapsed().as_millis() as u64))
} else {
(
SilGraphDetail::unavailable(SilGraphSource::SkippedCompileFail),
None,
)
};
let sil_call_edges = sil_graph.edge_count();
let callee_driven = sil_callee_driven_hotreload_enabled();
let patch = plan_patch_with_sil_graph(target, symbols, Some(&sil_graph), callee_driven);
let (patch, decision) = plan_reload_with_abi(patch, compile_check.ok, None);
let mut reason = reload_decision_wire(&decision);
reason = format!("{reason}|{}", sil_graph.reason_tag());
if let Some(n) = sil_call_edges {
reason = format!("{reason}|sil_call_edges={n}");
}
let envelope = PatchEnvelope {
protocol_version: 1,
patch_id: patch_id_for(target),
timestamp_ms: now_ms(),
patch: patch.clone(),
reload_decision: decision.clone(),
reason: reason.clone(),
};
let metric = RuntimeMetric {
timestamp_ms: now_ms(),
source: "daemon".to_string(),
target: patch.target.clone(),
compatible: patch.compatible,
reload_decision: decision,
reason,
compile_check_ms: compile_check.elapsed_ms,
compile_cache_hit: compile_check.cache_hit,
compile_frontend: compile_check.frontend_kind,
compile_source_hash: compile_check.source_hash,
compile_fallback_reason: compile_check.fallback_reason,
sil_call_edges,
sil_graph_ms,
};
append_metric(metrics_path, &metric).await?;
let line = serde_json::to_string(&envelope)?;
pool.broadcast_line(&line).await?;
Ok(patch)
}
pub async fn run_daemon(config: DaemonConfig) -> Result<(), DaemonError> {
let pool = ClientPool::new();
let _server = start_socket_server(&config.socket_path, pool.clone()).await?;
let (tx, mut rx) = mpsc::channel::<String>(128);
let tx_watch = tx.clone();
let watch_root = config.watch_root.clone();
let mut watcher: RecommendedWatcher =
notify::recommended_watcher(move |result: notify::Result<Event>| {
if let Ok(event) = result {
for path in event.paths {
let notify = path
.extension()
.and_then(|ext| ext.to_str())
.map(extension_triggers_hotreload_notify)
.unwrap_or(false);
if notify {
let _ = tx_watch.blocking_send(path.to_string_lossy().to_string());
}
}
}
})?;
watcher.watch(&watch_root, RecursiveMode::Recursive)?;
let mut compile_cache: CompileCache = HashMap::new();
let mut query_graph = QueryGraph::default();
loop {
match rx.recv().await {
Some(path) => {
let mut latest = Some(path);
sleep(Duration::from_millis(config.debounce_ms)).await;
while let Ok(next) = rx.try_recv() {
latest = Some(next);
}
if let Some(target) = latest.take() {
let symbols = classify_change(&target, &mut query_graph);
let _ = emit_patch(
&target,
&symbols,
&config.metrics_path,
&pool,
&mut compile_cache,
)
.await?;
}
}
None => return Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::net::UnixStream;
#[test]
fn app_file_forces_restart_path() {
let patch = plan_patch("App.swift", &[]);
assert!(!patch.compatible);
}
#[test]
fn sil_graph_downgrades_full_module_to_view_body_for_hot_patch() {
let graph =
SilGraphDetail::from_call_edges(vec![("main".to_string(), "helper".to_string())]);
let p = plan_patch_with_sil_graph(
"Sources/Helper.swift",
&["helper".to_string()],
Some(&graph),
false,
);
assert_eq!(p.patch_type, PatchType::ViewBody);
assert!(p.compatible);
let (out, decision) = apply_restart_supervisor(p, true);
assert!(out.compatible);
assert_eq!(decision, ReloadDecision::PatchApplied);
}
#[test]
fn sil_graph_does_not_downgrade_without_changed_callee() {
let graph =
SilGraphDetail::from_call_edges(vec![("main".to_string(), "helper".to_string())]);
let p = plan_patch_with_sil_graph(
"Sources/Helper.swift",
&["other".to_string()],
Some(&graph),
false,
);
assert_eq!(p.patch_type, PatchType::FullModule);
assert!(!p.compatible);
}
#[test]
fn sil_graph_does_not_downgrade_app_swift() {
let graph =
SilGraphDetail::from_call_edges(vec![("main".to_string(), "helper".to_string())]);
let p = plan_patch_with_sil_graph(
"Sources/App.swift",
&["helper".to_string()],
Some(&graph),
false,
);
assert_eq!(p.patch_type, PatchType::FullModule);
assert!(!p.compatible);
}
#[test]
fn sil_graph_allows_non_entrypoint_names_ending_with_app_swift() {
let graph =
SilGraphDetail::from_call_edges(vec![("main".to_string(), "helper".to_string())]);
let p = plan_patch_with_sil_graph(
"Sources/SampleApp.swift",
&["helper".to_string()],
Some(&graph),
false,
);
assert_eq!(p.patch_type, PatchType::ViewBody);
assert!(p.compatible);
}
#[test]
fn unavailable_sil_graph_does_not_downgrade() {
let graph = SilGraphDetail::unavailable(SilGraphSource::SubsetUnavailable);
let p = plan_patch_with_sil_graph(
"Sources/Helper.swift",
&["helper".to_string()],
Some(&graph),
false,
);
assert_eq!(p.patch_type, PatchType::FullModule);
assert!(!p.compatible);
assert_eq!(graph.reason_tag(), "sil_graph=subset_unavailable");
}
#[test]
fn swiftc_graph_detail_has_distinct_source_tags() {
let graph = SilGraphDetail::from_swiftc_call_edges(vec![(
"main".to_string(),
"helper".to_string(),
)]);
assert_eq!(graph.reason_tag(), "sil_graph=swiftc");
assert_eq!(graph.edge_count(), Some(1));
let empty = SilGraphDetail::from_swiftc_call_edges(vec![]);
assert_eq!(empty.reason_tag(), "sil_graph=swiftc_zero_edges");
assert_eq!(empty.edge_count(), Some(0));
}
#[test]
fn hotreload_swiftc_graph_flag_uses_bool_env_semantics() {
assert!(parse_env_bool_like_in("1"));
assert!(parse_env_bool_like_in("true"));
assert!(parse_env_bool_like_in(" TRUE "));
assert!(!parse_env_bool_like_in("0"));
assert!(!parse_env_bool_like_in("false"));
}
#[test]
fn content_view_prefers_view_body_patch() {
let patch = plan_patch("ContentView.swift", &["body".to_string()]);
assert_eq!(patch.patch_type, PatchType::ViewBody);
assert!(patch.compatible);
}
#[test]
fn symbol_classification_uses_path_heuristics() {
assert_eq!(symbols_for_path("Foo/ContentView.swift"), vec!["body"]);
assert_eq!(
symbols_for_path("Foo/SpacingModifier.swift"),
vec!["modifier"]
);
let empty: Vec<String> = vec![];
assert_eq!(symbols_for_path("Foo/SampleApp.swift"), empty);
}
#[test]
fn query_graph_invalidates_dependents() {
let mut graph = QueryGraph::default();
graph.upsert_node(
"App.swift",
vec![],
HashSet::from(["ContentView.swift".to_string()]),
);
graph.upsert_node(
"ContentView.swift",
vec!["body".to_string()],
HashSet::new(),
);
let dirty = graph.invalidate_from_path("ContentView.swift");
assert!(dirty.contains(&NodeId("ContentView.swift".to_string())));
assert!(dirty.contains(&NodeId("App.swift".to_string())));
}
#[test]
fn classify_change_keeps_body_symbol_signal() {
let mut graph = QueryGraph::default();
let symbols = classify_change("Foo/ContentView.swift", &mut graph);
assert!(symbols.contains(&"body".to_string()));
}
#[test]
fn restart_supervisor_marks_restart_for_failed_compile() {
let patch = plan_patch("ContentView.swift", &["body".to_string()]);
let (updated, decision) = apply_restart_supervisor(patch, false);
assert!(!updated.compatible);
assert_eq!(decision, ReloadDecision::CompileFailed);
}
#[test]
fn compile_check_cache_hits_on_unchanged_file() {
let path = std::env::temp_dir().join(format!("compile-cache-{}.swift", now_ms()));
std::fs::write(&path, "struct X {}").expect("write swift");
let mut cache = CompileCache::new();
let first = compile_check_cached(&path, &mut cache);
let second = compile_check_cached(&path, &mut cache);
assert!(!first.cache_hit);
assert!(second.cache_hit);
let _ = std::fs::remove_file(path);
}
#[test]
fn compile_check_cache_key_tracks_source_hash_and_frontend() {
let path = std::env::temp_dir().join(format!("compile-cache-hash-{}.swift", now_ms()));
std::fs::write(&path, "func main() -> Void\n").expect("write swift");
let mut cache = CompileCache::new();
let first = compile_check_cached(&path, &mut cache);
let second = compile_check_cached(&path, &mut cache);
assert_eq!(first.frontend_kind.as_deref(), Some("swift"));
assert!(first.source_hash.is_some());
assert!(second.cache_hit);
std::fs::write(&path, "func changed() -> Void\n").expect("rewrite swift");
let third = compile_check_cached(&path, &mut cache);
assert!(!third.cache_hit);
assert_ne!(first.source_hash, third.source_hash);
let _ = std::fs::remove_file(path);
}
#[test]
fn compile_check_rust_with_main_succeeds_via_rust_front() {
let path = std::env::temp_dir().join(format!("compile-check-rs-{}.rs", now_ms()));
std::fs::write(&path, "fn main() {}\n").expect("write rust");
assert!(compile_check(&path));
let _ = std::fs::remove_file(path);
}
#[test]
fn compile_check_rust_without_main_fails() {
let path = std::env::temp_dir().join(format!("compile-check-rs-empty-{}.rs", now_ms()));
std::fs::write(&path, "// no main\n").expect("write rust");
assert!(!compile_check(&path));
let _ = std::fs::remove_file(path);
}
#[test]
fn extension_triggers_notify_for_swift_in_and_java() {
assert!(extension_triggers_hotreload_notify("swift"));
assert!(extension_triggers_hotreload_notify("SWIFT"));
assert!(extension_triggers_hotreload_notify("in"));
assert!(extension_triggers_hotreload_notify("java"));
assert!(!extension_triggers_hotreload_notify("md"));
}
#[test]
fn patch_type_serializes_to_schema_snake_case() {
let patch = ReloadPatch {
target: "ContentView.swift".to_string(),
patch_type: PatchType::ViewBody,
compatible: true,
};
let json = serde_json::to_string(&patch).expect("serialize reload patch");
assert!(json.contains("\"patch_type\":\"view_body\""));
}
#[test]
fn hotreload_reload_decision_serializes_to_schema_snake_case() {
let json = serde_json::to_string(&ReloadDecision::AbiSymbolMissing).expect("serialize");
assert_eq!(json, "\"abi_symbol_missing\"");
}
#[test]
fn hotreload_abi_layout_mismatch_forces_restart_decision() {
let patch = plan_patch("ContentView.swift", &["body".to_string()]);
let (out, decision) = plan_reload_with_abi(patch, true, Some(IN_ABI_LAYOUT_MISMATCH));
assert!(!out.compatible);
assert_eq!(decision, ReloadDecision::AbiLayoutMismatch);
}
#[test]
fn hotreload_abi_ok_defers_to_restart_supervisor() {
let patch = plan_patch("ContentView.swift", &["body".to_string()]);
let (out, decision) = plan_reload_with_abi(patch, true, Some(IN_ABI_OK));
assert!(out.compatible);
assert_eq!(decision, ReloadDecision::PatchApplied);
}
#[test]
fn hotreload_plan_reload_with_abi_stub_maps_symbol_missing() {
let patch = plan_patch("Sources/Helper.swift", &[]);
let (_, decision) = plan_reload_with_abi(patch, true, Some(IN_ABI_SYMBOL_MISSING));
assert_eq!(decision, ReloadDecision::AbiSymbolMissing);
}
#[tokio::test]
async fn metric_file_is_written() {
let path = std::env::temp_dir().join(format!("hotreload-metric-{}.ndjson", now_ms()));
let metric = RuntimeMetric {
timestamp_ms: now_ms(),
source: "test".to_string(),
target: "ContentView.swift".to_string(),
compatible: true,
reload_decision: ReloadDecision::PatchApplied,
reason: "patch_applied".to_string(),
compile_check_ms: 1,
compile_cache_hit: false,
compile_frontend: None,
compile_source_hash: None,
compile_fallback_reason: None,
sil_call_edges: None,
sil_graph_ms: None,
};
append_metric(&path, &metric).await.expect("metric write");
let content = tokio::fs::read_to_string(&path).await.expect("metric read");
assert!(content.contains("ContentView.swift"));
let _ = tokio::fs::remove_file(path).await;
}
#[test]
fn sil_subset_call_edge_count_sees_function_ref() {
let dir = std::env::temp_dir().join(format!("hotreload-sil-edges-{}", now_ms()));
let _ = std::fs::create_dir_all(&dir);
let f = dir.join("App.swift");
std::fs::write(&f, "func helper() {}\nfunc main() { helper() }\n").expect("write swift");
if let Some(n) = sil_subset_call_edge_count(&f) {
assert!(true, "sil graph available, got {n}");
}
let _ = std::fs::remove_file(&f);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn sil_subset_graph_detail_reports_non_swift() {
let path = Path::new("Sources/Main.rs");
let graph = sil_subset_graph_detail(path);
assert_eq!(graph.reason_tag(), "sil_graph=non_swift");
assert_eq!(graph.edge_count(), None);
}
#[test]
fn sil_subset_graph_detail_reports_unavailable_for_missing() {
let path = Path::new("Missing.swift");
let graph = sil_subset_graph_detail(path);
assert_eq!(graph.reason_tag(), "sil_graph=non_swift");
assert_eq!(graph.edge_count(), None);
}
#[tokio::test]
async fn emit_patch_records_compile_failure_skip_reason() {
let metrics_path = std::env::temp_dir().join(format!("hotreload-fail-{}.ndjson", now_ms()));
let pool = ClientPool::new();
let mut cache = CompileCache::new();
let patch = emit_patch(
"Missing.swift",
&["body".to_string()],
&metrics_path,
&pool,
&mut cache,
)
.await
.expect("patch emitted");
assert!(!patch.compatible);
let content = tokio::fs::read_to_string(&metrics_path)
.await
.expect("metric read");
assert!(content.contains("sil_graph=skipped_compile_fail"));
let _ = tokio::fs::remove_file(&metrics_path).await;
}
#[tokio::test]
async fn emit_patch_records_graph_timing_when_compile_succeeds() {
let metrics_path = std::env::temp_dir().join(format!("hotreload-ok-{}.ndjson", now_ms()));
let path = std::env::temp_dir().join(format!("hotreload-ok-{}.swift", now_ms()));
std::fs::write(&path, "func main() {}\n").expect("write swift");
let pool = ClientPool::new();
let mut cache = CompileCache::new();
let patch = emit_patch(
&path.to_string_lossy(),
&["body".to_string()],
&metrics_path,
&pool,
&mut cache,
)
.await
.expect("patch emitted");
assert!(patch.compatible, "patch should be compatible: {:?}", patch);
let content = tokio::fs::read_to_string(&metrics_path)
.await
.expect("metric read");
assert!(content.contains("sil_graph_ms"));
assert!(content.contains("\"compile_frontend\":\"swift\""));
assert!(content.contains("\"compile_source_hash\":"));
let _ = tokio::fs::remove_file(&metrics_path).await;
let _ = tokio::fs::remove_file(&path).await;
}
#[tokio::test]
async fn socket_client_receives_patch_envelope() {
let base = std::env::temp_dir().join(format!("hotreload-sock-{}", now_ms()));
let socket_path = base.with_extension("sock");
let metrics_path = base.with_extension("ndjson");
let pool = ClientPool::new();
let _server = start_socket_server(&socket_path, pool.clone())
.await
.expect("socket server starts");
let stream = UnixStream::connect(&socket_path)
.await
.expect("client connects");
let mut lines = BufReader::new(stream).lines();
let _ = emit_patch(
"ContentView.swift",
&["body".to_string()],
&metrics_path,
&pool,
&mut CompileCache::new(),
)
.await
.expect("patch emitted");
let line = lines
.next_line()
.await
.expect("line read")
.expect("line present");
assert!(line.contains("ContentView.swift"));
let _ = tokio::fs::remove_file(&socket_path).await;
let _ = tokio::fs::remove_file(&metrics_path).await;
}
#[test]
fn callee_driven_upgrades_view_body_when_called_by_body_function() {
let graph =
SilGraphDetail::from_call_edges(vec![("body".to_string(), "state".to_string())]);
let p = plan_patch_with_sil_graph(
"Sources/StateManager.swift",
&["state".to_string()],
Some(&graph),
true,
);
assert_eq!(p.patch_type, PatchType::ViewBody);
assert!(p.compatible);
}
#[test]
fn callee_driven_does_not_upgrade_for_non_body_caller() {
let graph = SilGraphDetail::from_call_edges(vec![("main".to_string(), "util".to_string())]);
let p = plan_patch_with_sil_graph(
"Sources/Util.swift",
&["util".to_string()],
Some(&graph),
true,
);
assert_eq!(p.patch_type, PatchType::FullModule);
assert!(!p.compatible);
}
#[test]
fn callee_driven_no_false_upgrade_with_empty_graph() {
let graph = SilGraphDetail::from_call_edges(vec![]);
let p = plan_patch_with_sil_graph(
"Sources/Empty.swift",
&["nothing".to_string()],
Some(&graph),
true,
);
assert_eq!(p.patch_type, PatchType::FullModule);
assert!(!p.compatible);
}
}