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