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::{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}