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