1use std::collections::BTreeSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5use std::time::{Duration, Instant};
6
7use anyhow::{Context, Result};
8use tempfile::{Builder, TempDir};
9
10use super::{ExecutionOutcome, ExecutionPayload, LanguageEngine, LanguageSession};
11
12pub struct SwiftEngine {
13 executable: Option<PathBuf>,
14}
15
16impl SwiftEngine {
17 pub fn new() -> Self {
18 Self {
19 executable: resolve_swift_binary(),
20 }
21 }
22
23 fn ensure_executable(&self) -> Result<&Path> {
24 self.executable.as_deref().ok_or_else(|| {
25 anyhow::anyhow!(
26 "Swift support requires the `swift` executable. Install Xcode command-line tools or the Swift toolchain from https://www.swift.org/download/ and ensure `swift` is on your PATH."
27 )
28 })
29 }
30
31 fn write_temp_source(&self, code: &str) -> Result<(TempDir, PathBuf)> {
32 let dir = Builder::new()
33 .prefix("run-swift")
34 .tempdir()
35 .context("failed to create temporary directory for Swift source")?;
36 let path = dir.path().join("snippet.swift");
37 let mut contents = code.to_string();
38 if !contents.ends_with('\n') {
39 contents.push('\n');
40 }
41 fs::write(&path, contents).with_context(|| {
42 format!(
43 "failed to write temporary Swift source to {}",
44 path.display()
45 )
46 })?;
47 Ok((dir, path))
48 }
49
50 fn execute_path(&self, path: &Path) -> Result<std::process::Output> {
51 let executable = self.ensure_executable()?;
52 let mut cmd = Command::new(executable);
53 cmd.arg(path).stdout(Stdio::piped()).stderr(Stdio::piped());
54 cmd.stdin(Stdio::inherit());
55 if let Some(parent) = path.parent() {
56 cmd.current_dir(parent);
57 }
58 cmd.output().with_context(|| {
59 format!(
60 "failed to execute {} with script {}",
61 executable.display(),
62 path.display()
63 )
64 })
65 }
66}
67
68impl LanguageEngine for SwiftEngine {
69 fn id(&self) -> &'static str {
70 "swift"
71 }
72
73 fn display_name(&self) -> &'static str {
74 "Swift"
75 }
76
77 fn aliases(&self) -> &[&'static str] {
78 &["swiftlang"]
79 }
80
81 fn supports_sessions(&self) -> bool {
82 self.executable.is_some()
83 }
84
85 fn validate(&self) -> Result<()> {
86 let executable = self.ensure_executable()?;
87 let mut cmd = Command::new(executable);
88 cmd.arg("--version")
89 .stdout(Stdio::null())
90 .stderr(Stdio::null());
91 cmd.status()
92 .with_context(|| format!("failed to invoke {}", executable.display()))?
93 .success()
94 .then_some(())
95 .ok_or_else(|| anyhow::anyhow!("{} is not executable", executable.display()))
96 }
97
98 fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
99 let start = Instant::now();
100 let (temp_dir, path) = match payload {
101 ExecutionPayload::Inline { code } | ExecutionPayload::Stdin { code } => {
102 let (dir, path) = self.write_temp_source(code)?;
103 (Some(dir), path)
104 }
105 ExecutionPayload::File { path } => (None, path.clone()),
106 };
107
108 let output = self.execute_path(&path)?;
109 drop(temp_dir);
110
111 Ok(ExecutionOutcome {
112 language: self.id().to_string(),
113 exit_code: output.status.code(),
114 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
115 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
116 duration: start.elapsed(),
117 })
118 }
119
120 fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
121 let executable = self.ensure_executable()?.to_path_buf();
122 Ok(Box::new(SwiftSession::new(executable)?))
123 }
124}
125
126fn resolve_swift_binary() -> Option<PathBuf> {
127 which::which("swift").ok()
128}
129
130#[derive(Default)]
131struct SwiftSessionState {
132 imports: BTreeSet<String>,
133 declarations: Vec<String>,
134 statements: Vec<String>,
135}
136
137struct SwiftSession {
138 executable: PathBuf,
139 workspace: TempDir,
140 state: SwiftSessionState,
141 previous_stdout: String,
142 previous_stderr: String,
143}
144
145impl SwiftSession {
146 fn new(executable: PathBuf) -> Result<Self> {
147 let workspace = Builder::new()
148 .prefix("run-swift-repl")
149 .tempdir()
150 .context("failed to create temporary directory for Swift repl")?;
151 let session = Self {
152 executable,
153 workspace,
154 state: SwiftSessionState::default(),
155 previous_stdout: String::new(),
156 previous_stderr: String::new(),
157 };
158 session.persist_source()?;
159 Ok(session)
160 }
161
162 fn source_path(&self) -> PathBuf {
163 self.workspace.path().join("session.swift")
164 }
165
166 fn persist_source(&self) -> Result<()> {
167 let source = self.render_source();
168 fs::write(self.source_path(), source)
169 .with_context(|| "failed to write Swift session source".to_string())
170 }
171
172 fn render_source(&self) -> String {
173 let mut source = String::from("import Foundation\n");
174
175 for import in &self.state.imports {
176 let trimmed = import.trim();
177 if trimmed.eq("import Foundation") {
178 continue;
179 }
180 source.push_str(trimmed);
181 if !trimmed.ends_with('\n') {
182 source.push('\n');
183 }
184 }
185 source.push('\n');
186
187 for decl in &self.state.declarations {
188 source.push_str(decl);
189 if !decl.ends_with('\n') {
190 source.push('\n');
191 }
192 source.push('\n');
193 }
194
195 if self.state.statements.is_empty() {
196 source.push_str("// session body\n");
197 } else {
198 for stmt in &self.state.statements {
199 source.push_str(stmt);
200 if !stmt.ends_with('\n') {
201 source.push('\n');
202 }
203 }
204 }
205
206 source
207 }
208
209 fn run_program(&self) -> Result<std::process::Output> {
210 let mut cmd = Command::new(&self.executable);
211 cmd.arg("session.swift")
212 .stdout(Stdio::piped())
213 .stderr(Stdio::piped())
214 .current_dir(self.workspace.path());
215 cmd.output().with_context(|| {
216 format!(
217 "failed to execute {} for Swift session",
218 self.executable.display()
219 )
220 })
221 }
222
223 fn run_current(&mut self, start: Instant) -> Result<(ExecutionOutcome, bool)> {
224 self.persist_source()?;
225 let output = self.run_program()?;
226 let stdout_full = normalize_output(&output.stdout);
227 let stderr_full = normalize_output(&output.stderr);
228
229 let stdout_delta = diff_output(&self.previous_stdout, &stdout_full);
230 let stderr_delta = diff_output(&self.previous_stderr, &stderr_full);
231
232 let success = output.status.success();
233 if success {
234 self.previous_stdout = stdout_full;
235 self.previous_stderr = stderr_full;
236 }
237
238 let outcome = ExecutionOutcome {
239 language: "swift".to_string(),
240 exit_code: output.status.code(),
241 stdout: stdout_delta,
242 stderr: stderr_delta,
243 duration: start.elapsed(),
244 };
245
246 Ok((outcome, success))
247 }
248
249 fn apply_import(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
250 let mut inserted = Vec::new();
251 for line in code.lines() {
252 let trimmed = line.trim();
253 if trimmed.is_empty() {
254 continue;
255 }
256 let stmt = if trimmed.ends_with(';') {
257 trimmed.trim_end_matches(';').to_string()
258 } else {
259 trimmed.to_string()
260 };
261 if self.state.imports.insert(stmt.clone()) {
262 inserted.push(stmt);
263 }
264 }
265
266 if inserted.is_empty() {
267 return Ok((
268 ExecutionOutcome {
269 language: "swift".to_string(),
270 exit_code: None,
271 stdout: String::new(),
272 stderr: String::new(),
273 duration: Duration::default(),
274 },
275 true,
276 ));
277 }
278
279 let start = Instant::now();
280 let (outcome, success) = self.run_current(start)?;
281 if !success {
282 for stmt in inserted {
283 self.state.imports.remove(&stmt);
284 }
285 self.persist_source()?;
286 }
287 Ok((outcome, success))
288 }
289
290 fn apply_declaration(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
291 let snippet = ensure_trailing_newline(code);
292 self.state.declarations.push(snippet);
293 let start = Instant::now();
294 let (outcome, success) = self.run_current(start)?;
295 if !success {
296 let _ = self.state.declarations.pop();
297 self.persist_source()?;
298 }
299 Ok((outcome, success))
300 }
301
302 fn apply_statement(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
303 self.state.statements.push(ensure_trailing_newline(code));
304 let start = Instant::now();
305 let (outcome, success) = self.run_current(start)?;
306 if !success {
307 let _ = self.state.statements.pop();
308 self.persist_source()?;
309 }
310 Ok((outcome, success))
311 }
312
313 fn apply_expression(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
314 self.state.statements.push(wrap_expression(code));
315 let start = Instant::now();
316 let (outcome, success) = self.run_current(start)?;
317 if !success {
318 let _ = self.state.statements.pop();
319 self.persist_source()?;
320 }
321 Ok((outcome, success))
322 }
323
324 fn reset(&mut self) -> Result<()> {
325 self.state.imports.clear();
326 self.state.declarations.clear();
327 self.state.statements.clear();
328 self.previous_stdout.clear();
329 self.previous_stderr.clear();
330 self.persist_source()
331 }
332}
333
334impl LanguageSession for SwiftSession {
335 fn language_id(&self) -> &str {
336 "swift"
337 }
338
339 fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
340 let trimmed = code.trim();
341 if trimmed.is_empty() {
342 return Ok(ExecutionOutcome {
343 language: "swift".to_string(),
344 exit_code: None,
345 stdout: String::new(),
346 stderr: String::new(),
347 duration: Duration::default(),
348 });
349 }
350
351 if trimmed.eq_ignore_ascii_case(":reset") {
352 self.reset()?;
353 return Ok(ExecutionOutcome {
354 language: "swift".to_string(),
355 exit_code: None,
356 stdout: String::new(),
357 stderr: String::new(),
358 duration: Duration::default(),
359 });
360 }
361
362 if trimmed.eq_ignore_ascii_case(":help") {
363 return Ok(ExecutionOutcome {
364 language: "swift".to_string(),
365 exit_code: None,
366 stdout:
367 "Swift commands:\n :reset — clear session state\n :help — show this message\n"
368 .to_string(),
369 stderr: String::new(),
370 duration: Duration::default(),
371 });
372 }
373
374 match classify_snippet(trimmed) {
375 SwiftSnippet::Import => {
376 let (outcome, _) = self.apply_import(code)?;
377 Ok(outcome)
378 }
379 SwiftSnippet::Declaration => {
380 let (outcome, _) = self.apply_declaration(code)?;
381 Ok(outcome)
382 }
383 SwiftSnippet::Expression => {
384 let (outcome, _) = self.apply_expression(trimmed)?;
385 Ok(outcome)
386 }
387 SwiftSnippet::Statement => {
388 let (outcome, _) = self.apply_statement(code)?;
389 Ok(outcome)
390 }
391 }
392 }
393
394 fn shutdown(&mut self) -> Result<()> {
395 Ok(())
396 }
397}
398
399enum SwiftSnippet {
400 Import,
401 Declaration,
402 Statement,
403 Expression,
404}
405
406fn classify_snippet(code: &str) -> SwiftSnippet {
407 if is_import(code) {
408 return SwiftSnippet::Import;
409 }
410
411 if is_declaration(code) {
412 return SwiftSnippet::Declaration;
413 }
414
415 if should_wrap_expression(code) {
416 return SwiftSnippet::Expression;
417 }
418
419 SwiftSnippet::Statement
420}
421
422fn is_import(code: &str) -> bool {
423 code.lines()
424 .all(|line| line.trim_start().starts_with("import "))
425}
426
427fn is_declaration(code: &str) -> bool {
428 let lowered = code.trim_start().to_ascii_lowercase();
429 const PREFIXES: [&str; 8] = [
430 "func ",
431 "class ",
432 "struct ",
433 "enum ",
434 "protocol ",
435 "extension ",
436 "actor ",
437 "typealias ",
438 ];
439 PREFIXES.iter().any(|prefix| lowered.starts_with(prefix))
440}
441
442fn should_wrap_expression(code: &str) -> bool {
443 if code.contains('\n') {
444 return false;
445 }
446
447 let trimmed = code.trim();
448 if trimmed.is_empty() {
449 return false;
450 }
451
452 if trimmed.ends_with(';') {
453 return false;
454 }
455
456 let lowered = trimmed.to_ascii_lowercase();
457 const STATEMENT_PREFIXES: [&str; 10] = [
458 "let ", "var ", "if ", "for ", "while ", "repeat ", "guard ", "switch ", "return ",
459 "throw ",
460 ];
461
462 if STATEMENT_PREFIXES
463 .iter()
464 .any(|prefix| lowered.starts_with(prefix))
465 {
466 return false;
467 }
468
469 if trimmed.contains('=') {
470 return false;
471 }
472
473 true
474}
475
476fn ensure_trailing_newline(code: &str) -> String {
477 let mut owned = code.to_string();
478 if !owned.ends_with('\n') {
479 owned.push('\n');
480 }
481 owned
482}
483
484fn wrap_expression(code: &str) -> String {
485 format!("print(({}))\n", code.trim())
486}
487
488fn diff_output(previous: &str, current: &str) -> String {
489 if let Some(stripped) = current.strip_prefix(previous) {
490 stripped.to_string()
491 } else {
492 current.to_string()
493 }
494}
495
496fn normalize_output(bytes: &[u8]) -> String {
497 String::from_utf8_lossy(bytes)
498 .replace("\r\n", "\n")
499 .replace('\r', "")
500}