Skip to main content

run/engine/
swift.rs

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}