run/engine/
csharp.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4use std::time::{Duration, Instant};
5
6use anyhow::{Context, Result, bail};
7use tempfile::{Builder, TempDir};
8
9use super::{ExecutionOutcome, ExecutionPayload, LanguageEngine, LanguageSession};
10
11pub struct CSharpEngine {
12    runtime: Option<PathBuf>,
13    target_framework: Option<String>,
14}
15
16impl CSharpEngine {
17    pub fn new() -> Self {
18        let runtime = resolve_dotnet_runtime();
19        let target_framework = runtime
20            .as_ref()
21            .and_then(|path| detect_target_framework(path).ok());
22        Self {
23            runtime,
24            target_framework,
25        }
26    }
27
28    fn ensure_runtime(&self) -> Result<&Path> {
29        self.runtime.as_deref().ok_or_else(|| {
30            anyhow::anyhow!(
31                "C# support requires the `dotnet` CLI. Install the .NET SDK from https://dotnet.microsoft.com/download and ensure `dotnet` is on your PATH."
32            )
33        })
34    }
35
36    fn ensure_target_framework(&self) -> Result<&str> {
37        self.target_framework
38            .as_deref()
39            .ok_or_else(|| anyhow::anyhow!("Unable to detect installed .NET SDK target framework"))
40    }
41
42    fn prepare_source(&self, payload: &ExecutionPayload, dir: &Path) -> Result<PathBuf> {
43        let target = dir.join("Program.cs");
44        match payload {
45            ExecutionPayload::Inline { code } | ExecutionPayload::Stdin { code } => {
46                let mut contents = code.to_string();
47                if !contents.ends_with('\n') {
48                    contents.push('\n');
49                }
50                fs::write(&target, contents).with_context(|| {
51                    format!(
52                        "failed to write temporary C# source to {}",
53                        target.display()
54                    )
55                })?;
56            }
57            ExecutionPayload::File { path } => {
58                fs::copy(path, &target).with_context(|| {
59                    format!(
60                        "failed to copy C# source from {} to {}",
61                        path.display(),
62                        target.display()
63                    )
64                })?;
65            }
66        }
67        Ok(target)
68    }
69
70    fn write_project_file(&self, dir: &Path, tfm: &str) -> Result<PathBuf> {
71        let project_path = dir.join("Run.csproj");
72        let contents = format!(
73            r#"<Project Sdk="Microsoft.NET.Sdk">
74  <PropertyGroup>
75    <OutputType>Exe</OutputType>
76    <TargetFramework>{}</TargetFramework>
77    <ImplicitUsings>enable</ImplicitUsings>
78    <Nullable>disable</Nullable>
79        <NoWarn>CS0219;CS8321</NoWarn>
80  </PropertyGroup>
81</Project>
82"#,
83            tfm
84        );
85        fs::write(&project_path, contents).with_context(|| {
86            format!(
87                "failed to write temporary C# project file to {}",
88                project_path.display()
89            )
90        })?;
91        Ok(project_path)
92    }
93
94    fn run_project(
95        &self,
96        runtime: &Path,
97        project: &Path,
98        workdir: &Path,
99    ) -> Result<std::process::Output> {
100        let mut cmd = Command::new(runtime);
101        cmd.arg("run")
102            .arg("--project")
103            .arg(project)
104            .arg("--nologo")
105            .stdout(Stdio::piped())
106            .stderr(Stdio::piped())
107            .current_dir(workdir);
108        cmd.stdin(Stdio::inherit());
109        cmd.env("DOTNET_CLI_TELEMETRY_OPTOUT", "1");
110        cmd.env("DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "1");
111        cmd.output().with_context(|| {
112            format!(
113                "failed to execute dotnet run for project {} using {}",
114                project.display(),
115                runtime.display()
116            )
117        })
118    }
119}
120
121impl LanguageEngine for CSharpEngine {
122    fn id(&self) -> &'static str {
123        "csharp"
124    }
125
126    fn display_name(&self) -> &'static str {
127        "C#"
128    }
129
130    fn aliases(&self) -> &[&'static str] {
131        &["cs", "c#", "dotnet"]
132    }
133
134    fn supports_sessions(&self) -> bool {
135        self.runtime.is_some() && self.target_framework.is_some()
136    }
137
138    fn validate(&self) -> Result<()> {
139        let runtime = self.ensure_runtime()?;
140        let _tfm = self.ensure_target_framework()?;
141
142        let mut cmd = Command::new(runtime);
143        cmd.arg("--version")
144            .stdout(Stdio::null())
145            .stderr(Stdio::null());
146        cmd.status()
147            .with_context(|| format!("failed to invoke {}", runtime.display()))?
148            .success()
149            .then_some(())
150            .ok_or_else(|| anyhow::anyhow!("{} is not executable", runtime.display()))
151    }
152
153    fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
154        let runtime = self.ensure_runtime()?;
155        let tfm = self.ensure_target_framework()?;
156
157        let build_dir = Builder::new()
158            .prefix("run-csharp")
159            .tempdir()
160            .context("failed to create temporary directory for csharp build")?;
161        let dir_path = build_dir.path();
162
163        self.write_project_file(dir_path, tfm)?;
164        self.prepare_source(payload, dir_path)?;
165
166        let project_path = dir_path.join("Run.csproj");
167        let start = Instant::now();
168
169        let output = self.run_project(runtime, &project_path, dir_path)?;
170
171        Ok(ExecutionOutcome {
172            language: self.id().to_string(),
173            exit_code: output.status.code(),
174            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
175            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
176            duration: start.elapsed(),
177        })
178    }
179
180    fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
181        let runtime = self.ensure_runtime()?.to_path_buf();
182        let tfm = self.ensure_target_framework()?.to_string();
183
184        let dir = Builder::new()
185            .prefix("run-csharp-repl")
186            .tempdir()
187            .context("failed to create temporary directory for csharp repl")?;
188        let dir_path = dir.path();
189
190        let project_path = self.write_project_file(dir_path, &tfm)?;
191        let program_path = dir_path.join("Program.cs");
192        fs::write(&program_path, "// C# REPL session\n")
193            .with_context(|| format!("failed to initialize {}", program_path.display()))?;
194
195        Ok(Box::new(CSharpSession {
196            runtime,
197            dir,
198            project_path,
199            program_path,
200            snippets: Vec::new(),
201            previous_stdout: String::new(),
202            previous_stderr: String::new(),
203        }))
204    }
205}
206
207struct CSharpSession {
208    runtime: PathBuf,
209    dir: TempDir,
210    project_path: PathBuf,
211    program_path: PathBuf,
212    snippets: Vec<String>,
213    previous_stdout: String,
214    previous_stderr: String,
215}
216
217impl CSharpSession {
218    fn render_source(&self) -> String {
219        let mut source = String::from(
220            "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Threading.Tasks;\n#nullable disable\n",
221        );
222        for snippet in &self.snippets {
223            source.push_str(snippet);
224            if !snippet.ends_with('\n') {
225                source.push('\n');
226            }
227        }
228        source
229    }
230
231    fn write_source(&self, contents: &str) -> Result<()> {
232        fs::write(&self.program_path, contents).with_context(|| {
233            format!(
234                "failed to write generated C# REPL source to {}",
235                self.program_path.display()
236            )
237        })
238    }
239
240    fn run_current(&mut self, start: Instant) -> Result<(ExecutionOutcome, bool)> {
241        let source = self.render_source();
242        self.write_source(&source)?;
243
244        let output = run_dotnet_project(&self.runtime, &self.project_path, self.dir.path())?;
245        let stdout_full = String::from_utf8_lossy(&output.stdout).into_owned();
246        let stderr_full = String::from_utf8_lossy(&output.stderr).into_owned();
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: "csharp".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 run_snippet(&mut self, snippet: String) -> Result<ExecutionOutcome> {
269        self.snippets.push(snippet);
270        let start = Instant::now();
271        let (outcome, success) = self.run_current(start)?;
272        if !success {
273            let _ = self.snippets.pop();
274        }
275        Ok(outcome)
276    }
277
278    fn reset_state(&mut self) -> Result<()> {
279        self.snippets.clear();
280        self.previous_stdout.clear();
281        self.previous_stderr.clear();
282        let source = self.render_source();
283        self.write_source(&source)
284    }
285}
286
287impl LanguageSession for CSharpSession {
288    fn language_id(&self) -> &str {
289        "csharp"
290    }
291
292    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
293        let trimmed = code.trim();
294        if trimmed.is_empty() {
295            return Ok(ExecutionOutcome {
296                language: self.language_id().to_string(),
297                exit_code: None,
298                stdout: String::new(),
299                stderr: String::new(),
300                duration: Instant::now().elapsed(),
301            });
302        }
303
304        if trimmed.eq_ignore_ascii_case(":reset") {
305            self.reset_state()?;
306            return Ok(ExecutionOutcome {
307                language: self.language_id().to_string(),
308                exit_code: None,
309                stdout: String::new(),
310                stderr: String::new(),
311                duration: Duration::default(),
312            });
313        }
314
315        if trimmed.eq_ignore_ascii_case(":help") {
316            return Ok(ExecutionOutcome {
317                language: self.language_id().to_string(),
318                exit_code: None,
319                stdout:
320                    "C# commands:\n  :reset — clear session state\n  :help  — show this message\n"
321                        .to_string(),
322                stderr: String::new(),
323                duration: Duration::default(),
324            });
325        }
326
327        if should_treat_as_expression(trimmed) {
328            let snippet = wrap_expression(trimmed, self.snippets.len());
329            let outcome = self.run_snippet(snippet)?;
330            if outcome.exit_code.unwrap_or(0) == 0 {
331                return Ok(outcome);
332            }
333        }
334
335        let snippet = prepare_statement(code);
336        let outcome = self.run_snippet(snippet)?;
337        Ok(outcome)
338    }
339
340    fn shutdown(&mut self) -> Result<()> {
341        // TempDir cleanup handled automatically.
342        Ok(())
343    }
344}
345
346fn diff_output(previous: &str, current: &str) -> String {
347    if let Some(stripped) = current.strip_prefix(previous) {
348        stripped.to_string()
349    } else {
350        current.to_string()
351    }
352}
353
354fn should_treat_as_expression(code: &str) -> bool {
355    let trimmed = code.trim();
356    if trimmed.is_empty() {
357        return false;
358    }
359    if trimmed.contains('\n') {
360        return false;
361    }
362    if trimmed.ends_with(';') || trimmed.contains(';') {
363        return false;
364    }
365    let lowered = trimmed.to_ascii_lowercase();
366    const KEYWORDS: [&str; 17] = [
367        "using ",
368        "namespace ",
369        "class ",
370        "struct ",
371        "record ",
372        "enum ",
373        "interface ",
374        "public ",
375        "private ",
376        "protected ",
377        "internal ",
378        "static ",
379        "if ",
380        "for ",
381        "while ",
382        "switch ",
383        "try ",
384    ];
385    if KEYWORDS.iter().any(|kw| lowered.starts_with(kw)) {
386        return false;
387    }
388    if lowered.starts_with("return ") || lowered.starts_with("throw ") {
389        return false;
390    }
391    if trimmed.starts_with("Console.") || trimmed.starts_with("System.Console.") {
392        return false;
393    }
394
395    if trimmed == "true" || trimmed == "false" {
396        return true;
397    }
398    if trimmed.parse::<f64>().is_ok() {
399        return true;
400    }
401    if trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2 {
402        return true;
403    }
404
405    if trimmed.contains("==")
406        || trimmed.contains("!=")
407        || trimmed.contains("<=")
408        || trimmed.contains(">=")
409        || trimmed.contains("&&")
410        || trimmed.contains("||")
411    {
412        return true;
413    }
414    if trimmed.chars().any(|c| "+-*/%<>^|&".contains(c)) {
415        return true;
416    }
417
418    if trimmed
419        .chars()
420        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
421    {
422        return true;
423    }
424
425    false
426}
427
428fn wrap_expression(code: &str, index: usize) -> String {
429    format!("var __repl_val_{index} = ({code});\nConsole.WriteLine(__repl_val_{index});\n")
430}
431
432fn prepare_statement(code: &str) -> String {
433    let mut snippet = code.to_string();
434    if !snippet.ends_with('\n') {
435        snippet.push('\n');
436    }
437    snippet
438}
439
440fn resolve_dotnet_runtime() -> Option<PathBuf> {
441    which::which("dotnet").ok()
442}
443
444fn detect_target_framework(dotnet: &Path) -> Result<String> {
445    let output = Command::new(dotnet)
446        .arg("--list-sdks")
447        .stdout(Stdio::piped())
448        .stderr(Stdio::null())
449        .output()
450        .with_context(|| format!("failed to query SDKs via {}", dotnet.display()))?;
451
452    if !output.status.success() {
453        bail!(
454            "{} --list-sdks exited with status {}",
455            dotnet.display(),
456            output.status
457        );
458    }
459
460    let stdout = String::from_utf8_lossy(&output.stdout);
461    let mut best: Option<(u32, u32, String)> = None;
462
463    for line in stdout.lines() {
464        let version = line.split_whitespace().next().unwrap_or("");
465        if version.is_empty() {
466            continue;
467        }
468        if let Some((major, minor)) = parse_version(version) {
469            let tfm = format!("net{}.{}", major, minor);
470            match &best {
471                Some((b_major, b_minor, _)) if (*b_major, *b_minor) >= (major, minor) => {}
472                _ => best = Some((major, minor, tfm)),
473            }
474        }
475    }
476
477    best.map(|(_, _, tfm)| tfm).ok_or_else(|| {
478        anyhow::anyhow!("unable to infer target framework from dotnet --list-sdks output")
479    })
480}
481
482fn parse_version(version: &str) -> Option<(u32, u32)> {
483    let mut parts = version.split('.');
484    let major = parts.next()?.parse().ok()?;
485    let minor = parts.next().unwrap_or("0").parse().ok()?;
486    Some((major, minor))
487}
488
489fn run_dotnet_project(
490    runtime: &Path,
491    project: &Path,
492    workdir: &Path,
493) -> Result<std::process::Output> {
494    let mut cmd = Command::new(runtime);
495    cmd.arg("run")
496        .arg("--project")
497        .arg(project)
498        .arg("--nologo")
499        .stdout(Stdio::piped())
500        .stderr(Stdio::piped())
501        .current_dir(workdir);
502    cmd.env("DOTNET_CLI_TELEMETRY_OPTOUT", "1");
503    cmd.env("DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "1");
504    cmd.output().with_context(|| {
505        format!(
506            "failed to execute dotnet run for project {} using {}",
507            project.display(),
508            runtime.display()
509        )
510    })
511}